From d50236596d970825a2137254192b4f4a43d73240 Mon Sep 17 00:00:00 2001 From: Martin Boos Date: Tue, 24 Jun 2025 11:50:23 +0200 Subject: [PATCH 01/10] feat(mixins): add mixins --- ninja_extra/controllers/model/service.py | 4 +- ninja_extra/mixins/__init__.py | 455 +++++++++++++++++++++++ 2 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 ninja_extra/mixins/__init__.py diff --git a/ninja_extra/controllers/model/service.py b/ninja_extra/controllers/model/service.py index 56aeb57b..b319d4ba 100644 --- a/ninja_extra/controllers/model/service.py +++ b/ninja_extra/controllers/model/service.py @@ -30,10 +30,10 @@ def get_one(self, pk: t.Any, **kwargs: t.Any) -> t.Any: async def get_one_async(self, pk: t.Any, **kwargs: t.Any) -> t.Any: return await sync_to_async(self.get_one, thread_sensitive=True)(pk, **kwargs) - def get_all(self, **kwargs: t.Any) -> t.Union[QuerySet, t.List[t.Any]]: + def get_all(self, **kwargs: t.Any) -> QuerySet: return self.model.objects.all() - async def get_all_async(self, **kwargs: t.Any) -> t.Union[QuerySet, t.List[t.Any]]: + async def get_all_async(self, **kwargs: t.Any) -> QuerySet: return await sync_to_async(self.get_all, thread_sensitive=True)(**kwargs) def create(self, schema: PydanticModel, **kwargs: t.Any) -> t.Any: diff --git a/ninja_extra/mixins/__init__.py b/ninja_extra/mixins/__init__.py new file mode 100644 index 00000000..aa24ad4a --- /dev/null +++ b/ninja_extra/mixins/__init__.py @@ -0,0 +1,455 @@ +# core/ninja_mixins.py +""" +Enhanced Django Ninja Extra mixins for programmatic API controller configuration. + +This module provides a mixin-based approach to defining API controllers where +routes are automatically created as actual methods with decorators. +""" + +from __future__ import annotations + +import abc +import re +from abc import abstractmethod +from typing import TYPE_CHECKING, Any, ClassVar, Optional, TypeVar, cast + +from django.core.exceptions import ImproperlyConfigured +from django.db import models +from django.db.models import Choices, IntegerChoices +from ninja import Schema +from ninja.orm import create_schema, fields +from pydantic import BaseModel + +from ninja_extra import ( + ModelConfig, + ModelControllerBase, + ModelEndpointFactory, +) +from ninja_extra.controllers.model.endpoints import ModelEndpointFunction +from ninja_extra.ordering import Ordering, ordering + +if TYPE_CHECKING: + from django.db.models import Model as DjangoModel + from django.db.models import QuerySet + + +class ModelMixinBase(abc.ABC): + """ + Abstract base class for route mixins. + + Each mixin defines route generation methods that create actual API endpoints. + """ + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Process mixins and create routes when subclass is created.""" + + super().__init_subclass__(**kwargs) + + # Only process if this is a final controller class (not intermediate mixin) + if issubclass(cls, MixinModelControllerBase) and issubclass( + cls, ModelMixinBase + ): + cls._process_mixins() + + def __provides__( + self, + ) -> None: + """ + Don't understand exactly why, but this is needed, otherwise `ninja_extra\\controllers\\model\builder.py", line 201, in register_model_routes` crashes. + """ + return + + @classmethod + @abstractmethod + def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: + """Create routes for the given controller class.""" + ... + + @classmethod + def _process_mixins(cls) -> None: + """Process all mixins and create their routes.""" + mixin_classes = [ + base + for base in cls.__mro__ + if ( + issubclass(base, ModelMixinBase) + and base not in (cls, ModelControllerBase, ModelMixinBase) + ) + ] + + assert issubclass(cls, MixinModelControllerBase), ( + f"{cls} must inherit from MixinModelControllerBase" + ) + + for mixin in mixin_classes: + mixin.create_routes(cls) + + +class IntegerChoicesSchema(Schema): + """ + Schema for IntegerChoices. + """ + + id: int + label: str + + +class TextChoicesSchema(Schema): + """ + Schema for TextChoices. + """ + + id: str + label: str + + +class MixinTextChoiceModel(models.Model): + """ + Dummy model class for TextChoices. + """ + + id = models.CharField( + max_length=255, primary_key=True, null=False, blank=False, unique=True + ) + label = models.TextField(max_length=255, null=False, blank=True) + + class Meta: + managed = False + + +class MixinIntegerChoiceModel(models.Model): + """ + Dummy model class for IntegerChoices. + """ + + id = models.IntegerField(primary_key=True, null=False, blank=False, unique=True) + label = models.TextField(max_length=255, null=False, blank=True) + + class Meta: + managed = False + + +class MixinModelControllerBase(ModelControllerBase): + """ + Enhanced ModelControllerBase that works with programmatic route creation. + """ + + input_schema: ClassVar[type[BaseModel] | None] = None + output_schema: ClassVar[type[BaseModel] | None] = None + model_class: ClassVar[type[DjangoModel]] + auto_operation_ids: ClassVar[bool] = True + operation_id_prefix: ClassVar[str | None] = None + filter_schema: ClassVar[type[Schema] | None] = None + ordering_fields: ClassVar[list[str]] = [] + lookup_field: ClassVar[str | None] = None + + retrieve: ClassVar[Optional[ModelEndpointFunction]] + list: ClassVar[Optional[ModelEndpointFunction]] + create: ClassVar[Optional[ModelEndpointFunction]] + update: ClassVar[Optional[ModelEndpointFunction]] + patch: ClassVar[Optional[ModelEndpointFunction]] + delete: ClassVar[Optional[ModelEndpointFunction]] + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Configure the controller.""" + + if not cls.model_class and not getattr(cls, "model_config", None): + msg = f"Controller {cls.__name__} must define a model_class attribute" + raise ImproperlyConfigured(msg) + + if hasattr(cls, "model_config") and cls.model_config and cls.model_config.model: + cls.model_class = cls.model_config.model + + cls._setup() + + super().__init_subclass__(**kwargs) + + @classmethod + def _setup(cls) -> None: + cls._configure_schemas() + cls._ensure_model_config() + + if cls.lookup_field is None: + meta = getattr(cls.model_class, "_meta", None) + pk = getattr(meta, "pk", None) + + pk_name = pk.attname if pk else "id" + _type = fields.TYPES.get(pk.get_internal_type()) if pk else None + pk_type = _type.__name__ if _type and hasattr(_type, "__name__") else "int" + cls.lookup_field = f"{{{pk_type}:{pk_name}}}" + + @classmethod + def _configure_schemas(cls) -> None: + """ + Configure `input_schema` and `output_schema` where applicable. + """ + + is_choices = issubclass(cls.model_class, Choices) + is_integer_choices = is_choices and issubclass(cls.model_class, IntegerChoices) + if cls.output_schema is None: + if is_choices: + cls.output_schema = ( + IntegerChoicesSchema if is_integer_choices else TextChoicesSchema + ) + else: + cls.output_schema = create_schema(cls.model_class) + if not is_choices and cls.input_schema is None: + cls.input_schema = create_schema(cls.model_class, exclude=["id"]) + + if is_choices: + model_class = ( + MixinIntegerChoiceModel if is_integer_choices else MixinTextChoiceModel + ) + cls.model_config = ModelConfig(model=model_class) + + @classmethod + def _ensure_model_config(cls) -> None: + """Ensure ModelConfig is properly set up for dependency injection.""" + if not hasattr(cls, "model_config") or not cls.model_config: + cls.model_config = ModelConfig(model=cls.model_class) + elif not cls.model_config.model: + cls.model_config.model = cls.model_class + cls.model_config.allowed_routes = [] + + @classmethod + def generate_operation_id(cls, operation: str) -> str: + """Generate operation_id for the given operation.""" + prefix = cls.operation_id_prefix or cls._generate_operation_id_prefix() + return f"{operation}{_to_pascal_case(prefix)}" + + @classmethod + def _generate_operation_id_prefix(cls) -> str: + """Generate operation_id prefix from controller class name.""" + class_name = cls.__name__ + if class_name.endswith("Controller"): + base_name = class_name[:-10] + elif class_name.endswith("API"): + base_name = class_name[:-3] + else: + base_name = class_name + return _to_snake_case(base_name) + + +T = TypeVar("T", bound=Schema) + + +class RetrieveModelMixin(ModelMixinBase): + """Mixin for retrieving a single model instance by ID.""" + + @classmethod + def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: + """Create the retrieve route.""" + operation_id = ( + controller_cls.generate_operation_id("get") + if controller_cls.auto_operation_ids + else None + ) + + assert controller_cls.output_schema + + controller_cls.retrieve = ModelEndpointFactory.find_one( + path=f"/{controller_cls.lookup_field}", + operation_id=operation_id, + schema_out=controller_cls.output_schema, + lookup_param="id", + ) + + +class ListModelMixin(ModelMixinBase): + """ + Enhanced list mixin that allows for custom parameters and queryset manipulation. + """ + + @classmethod + def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: + """Create the enhanced list route.""" + # Prevent this basic mixin from overwriting a more specific one. + if hasattr(controller_cls, "list"): + return + + if not controller_cls.output_schema: + msg = f"{controller_cls.__name__} must define an 'output_schema' for the ListModelMixin to work." + raise ImproperlyConfigured(msg) + + operation_id = ( + controller_cls.generate_operation_id("list") + if controller_cls.auto_operation_ids + else None + ) + + # TODO(mbo20): refactor into separate ChoideModelMixin class + if issubclass(controller_cls.model_class, models.Choices): + assert hasattr(controller_cls.model_class, "choices") + choices = controller_cls.model_class.choices + is_integer_choices = issubclass(controller_cls.model_class, IntegerChoices) + choice_class = ( + MixinIntegerChoiceModel if is_integer_choices else MixinTextChoiceModel + ) + + # noinspection PyUnusedLocal + def get_choices(self: MixinModelControllerBase) -> list: # noqa: ARG001 + mapped = [(choice[0], str(choice[1])) for choice in choices] + choices_sorted = sorted(mapped, key=lambda t: t[1]) + return [choice_class(id=k, label=v) for (k, v) in choices_sorted] + + controller_cls.list = ModelEndpointFactory.list( + path="/", + operation_id=operation_id, + tags=[controller_cls.model_class.__name__], + schema_out=controller_cls.output_schema, + queryset_getter=get_choices, # type: ignore[arg-type] + ) + + else: + + @ordering(Ordering) + def list_all( + self: MixinModelControllerBase, + ) -> QuerySet: + return self.service.get_all() + + def list_ordered( + self: MixinModelControllerBase, + ) -> QuerySet: + ctx = self.context + ordering_args = ( + ctx.request.GET.getlist("ordering", []) + if ctx and ctx.request and ctx.request.method == "GET" + else [] + ) + ordering_input = Ordering().Input(ordering=",".join(ordering_args)) + return cast( + QuerySet, list_all(self, ordering=ordering_input) + ) # todo: why is this cast needed? Without it, mypy is unhappy. + + assert controller_cls.output_schema + controller_cls.list = ModelEndpointFactory.list( + path="/", + operation_id=operation_id, + tags=[controller_cls.model_class.__name__], + schema_out=controller_cls.output_schema, + queryset_getter=list_ordered, + ) + + +class CreateModelMixin(ModelMixinBase): + """Mixin for creating new model instances.""" + + @classmethod + def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: + """Create the create route.""" + operation_id = ( + controller_cls.generate_operation_id("create") + if controller_cls.auto_operation_ids + else None + ) + + assert controller_cls.input_schema + assert controller_cls.output_schema + controller_cls.create = ModelEndpointFactory.create( + path="/", + operation_id=operation_id, + tags=[controller_cls.model_class.__name__], + schema_in=controller_cls.input_schema, + schema_out=controller_cls.output_schema, + ) + + +class PutModelMixin(ModelMixinBase): + """Mixin for full updates of model instances (PUT).""" + + @classmethod + def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: + """Create the update route.""" + operation_id = ( + controller_cls.generate_operation_id("update") + if controller_cls.auto_operation_ids + else None + ) + + assert controller_cls.input_schema + assert controller_cls.output_schema + controller_cls.update = ModelEndpointFactory.update( + path=f"/{controller_cls.lookup_field}", + operation_id=operation_id, + tags=[controller_cls.model_class.__name__], + schema_in=controller_cls.input_schema, + schema_out=controller_cls.output_schema, + lookup_param="id", + ) + + +class PatchModelMixin(ModelMixinBase): + """Mixin for partial updates of model instances (PATCH).""" + + @classmethod + def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: + """Create the patch route.""" + operation_id = ( + controller_cls.generate_operation_id("patch") + if controller_cls.auto_operation_ids + else None + ) + + patch_schema = ( + create_schema( # see: https://github.com/vitalik/django-ninja/issues/1183 + controller_cls.model_class, + exclude=["id"], + optional_fields="__all__", # type: ignore + ) + ) + + assert controller_cls.output_schema + controller_cls.patch = ModelEndpointFactory.patch( + path=f"/{controller_cls.lookup_field}", + operation_id=operation_id, + tags=[controller_cls.model_class.__name__], + schema_in=patch_schema, + schema_out=controller_cls.output_schema, + lookup_param="id", + ) + + +class DeleteModelMixin(ModelMixinBase): + """Mixin for deleting model instances.""" + + @classmethod + def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: + """Create the delete route.""" + operation_id = ( + controller_cls.generate_operation_id("delete") + if controller_cls.auto_operation_ids + else None + ) + + controller_cls.delete = ModelEndpointFactory.delete( + path=f"/{controller_cls.lookup_field}", + operation_id=operation_id, + tags=[controller_cls.model_class.__name__], + lookup_param="id", + ) + + +class ReadModelMixin(RetrieveModelMixin, ListModelMixin): + """Convenience mixin for read-only operations (retrieve + list).""" + + +class UpdateModelMixin(PatchModelMixin, PutModelMixin): + """Convenience mixin for update operations.""" + + +class CRUDModelMixin( + CreateModelMixin, ReadModelMixin, UpdateModelMixin, DeleteModelMixin +): + """Convenience mixin for CRUD operations (retrieve + update + delete).""" + + +def _to_snake_case(name: str) -> str: + """Convert CamelCase to snake_case.""" + s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower() + + +def _to_pascal_case(name: str) -> str: + """Convert snake_case to PascalCase.""" + return "".join(word.capitalize() for word in name.split("_")) From 5c6b9b25276f84ce59ba5ba4eab835a621663dd5 Mon Sep 17 00:00:00 2001 From: Martin Boos Date: Mon, 30 Jun 2025 13:54:59 +0200 Subject: [PATCH 02/10] feat(mixins): add tests --- ninja_extra/mixins/__init__.py | 7 +- tests/test_model_controller/test_mixins.py | 136 +++++++++++++++++++++ 2 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 tests/test_model_controller/test_mixins.py diff --git a/ninja_extra/mixins/__init__.py b/ninja_extra/mixins/__init__.py index aa24ad4a..84aef63d 100644 --- a/ninja_extra/mixins/__init__.py +++ b/ninja_extra/mixins/__init__.py @@ -15,7 +15,8 @@ from django.core.exceptions import ImproperlyConfigured from django.db import models -from django.db.models import Choices, IntegerChoices +from django.db.models import Choices, IntegerChoices, QuerySet +from django.db.models import Model as DjangoModel from ninja import Schema from ninja.orm import create_schema, fields from pydantic import BaseModel @@ -28,10 +29,6 @@ from ninja_extra.controllers.model.endpoints import ModelEndpointFunction from ninja_extra.ordering import Ordering, ordering -if TYPE_CHECKING: - from django.db.models import Model as DjangoModel - from django.db.models import QuerySet - class ModelMixinBase(abc.ABC): """ diff --git a/tests/test_model_controller/test_mixins.py b/tests/test_model_controller/test_mixins.py new file mode 100644 index 00000000..a124ee68 --- /dev/null +++ b/tests/test_model_controller/test_mixins.py @@ -0,0 +1,136 @@ +import pytest + +from ninja_extra import api_controller +from ninja_extra.mixins import ( + DeleteModelMixin, + ListModelMixin, + MixinModelControllerBase, + PatchModelMixin, + PutModelMixin, + RetrieveModelMixin, +) +from ninja_extra.testing import TestClient + +from ..models import Event + + +def test_controller_without_model_class(): + with pytest.raises(Exception): + + @api_controller("/mixin-case-1") + class EventMixinModelControllerBase(MixinModelControllerBase): + pass + + +def test_empty_controller(): + @api_controller("/mixin-case-1") + class EventMixinModelControllerBase(MixinModelControllerBase): + model_class = Event + + TestClient(EventMixinModelControllerBase) + + # todo: how to test??? + + +@pytest.mark.django_db +def test_list_controller(): + @api_controller("/mixin-list", tags=["Events"]) + class EventMixinModelControllerBase(MixinModelControllerBase, ListModelMixin): + model_class = Event + + client = TestClient(EventMixinModelControllerBase) + + events = [ + Event.objects.create( + title="Testing", end_date="2020-01-02", start_date="2020-01-01" + ), + Event.objects.create( + title="Testing", end_date="2020-01-02", start_date="2020-01-01" + ), + ] + + resp = client.get( + "/", + ) + assert resp.json()["count"] == len(events) + + for event in events: + event.delete() + + +@pytest.mark.django_db +def test_retrieve_controller(): + @api_controller("/mixin-retrieve", tags=["Events"]) + class EventMixinModelControllerBase(MixinModelControllerBase, RetrieveModelMixin): + model_class = Event + + client = TestClient(EventMixinModelControllerBase) + event1 = Event.objects.create( + title="Testing", end_date="2020-01-02", start_date="2020-01-01" + ) + + resp = client.get( + f"/{event1.pk}", + ) + assert resp.json()["title"] == event1.title + + event1.delete() + + +@pytest.mark.django_db +def test_patch_controller(): + @api_controller("/mixin-patch", tags=["Events"]) + class EventMixinModelControllerBase(MixinModelControllerBase, PatchModelMixin): + model_class = Event + + client = TestClient(EventMixinModelControllerBase) + event1 = Event.objects.create( + title="Testing", end_date="2020-01-02", start_date="2020-01-01" + ) + + new_title = "Updated!" + resp = client.patch(f"/{event1.pk}", json={"title": new_title}) + assert resp.json()["title"] == new_title + + event1.delete() + + +@pytest.mark.django_db +def test_put_controller(): + @api_controller("/mixin-put") + class EventMixinModelControllerBase(MixinModelControllerBase, PutModelMixin): + model_class = Event + + client = TestClient(EventMixinModelControllerBase) + event1 = Event.objects.create( + title="Testing", end_date="2020-01-02", start_date="2020-01-01" + ) + + new_title = "Updated!" + new_start = "2025-01-01" + new_end = "2025-12-31" + resp = client.put( + f"/{event1.pk}", + json={"title": new_title, "start_date": new_start, "end_date": new_end}, + ) + assert resp.json()["title"] == new_title + assert resp.json()["start_date"] == new_start + assert resp.json()["end_date"] == new_end + + event1.delete() + + +@pytest.mark.django_db +def test_delete_controller(): + @api_controller() + class EventMixinModelControllerBase(MixinModelControllerBase, DeleteModelMixin): + model_class = Event + + client = TestClient(EventMixinModelControllerBase) + event1 = Event.objects.create( + title="Testing", end_date="2020-01-02", start_date="2020-01-01" + ) + + resp = client.delete(f"/{event1.pk}") + assert resp.status_code == 204 + assert not Event.objects.filter(id=event1.pk).exists() From a3d38d79bf62da2f3c793ab05f15cd1e217faebd Mon Sep 17 00:00:00 2001 From: Martin Boos Date: Mon, 30 Jun 2025 14:01:01 +0200 Subject: [PATCH 03/10] fix(mixins): fix check for `model_class` --- ninja_extra/mixins/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ninja_extra/mixins/__init__.py b/ninja_extra/mixins/__init__.py index 84aef63d..eb04f902 100644 --- a/ninja_extra/mixins/__init__.py +++ b/ninja_extra/mixins/__init__.py @@ -150,7 +150,7 @@ class MixinModelControllerBase(ModelControllerBase): def __init_subclass__(cls, **kwargs: Any) -> None: """Configure the controller.""" - if not cls.model_class and not getattr(cls, "model_config", None): + if not hasattr(cls, "model_class") and not getattr(cls, "model_config", None): msg = f"Controller {cls.__name__} must define a model_class attribute" raise ImproperlyConfigured(msg) From a786bec4f2950ea70af74990221d4d7b13127251 Mon Sep 17 00:00:00 2001 From: Martin Boos Date: Mon, 30 Jun 2025 14:02:08 +0200 Subject: [PATCH 04/10] refactor(mixins): refactor tests a little --- tests/test_model_controller/test_mixins.py | 205 ++++++++++++--------- 1 file changed, 113 insertions(+), 92 deletions(-) diff --git a/tests/test_model_controller/test_mixins.py b/tests/test_model_controller/test_mixins.py index a124ee68..b028a509 100644 --- a/tests/test_model_controller/test_mixins.py +++ b/tests/test_model_controller/test_mixins.py @@ -1,4 +1,5 @@ import pytest +from django.core.exceptions import ImproperlyConfigured from ninja_extra import api_controller from ninja_extra.mixins import ( @@ -9,128 +10,148 @@ PutModelMixin, RetrieveModelMixin, ) + + from ninja_extra.testing import TestClient from ..models import Event +@pytest.fixture +def controller_client_factory(): -def test_controller_without_model_class(): - with pytest.raises(Exception): + def _factory(*mixins): + # Dynamically create a controller class with the given mixins + @api_controller + class DynamicController(MixinModelControllerBase, *mixins): + model_class = Event - @api_controller("/mixin-case-1") - class EventMixinModelControllerBase(MixinModelControllerBase): - pass + return TestClient(DynamicController) + return _factory -def test_empty_controller(): - @api_controller("/mixin-case-1") - class EventMixinModelControllerBase(MixinModelControllerBase): - model_class = Event - TestClient(EventMixinModelControllerBase) +@pytest.fixture +def event_obj(db): + """Provides a single, clean Event object for tests that need one.""" + return Event.objects.create( + title="Initial Title", end_date="2025-01-02", start_date="2025-01-01" + ) - # todo: how to test??? +def test_controller_without_model_class_raises_specific_error(): + """ + Test that a mixincontroller without `model_class` attribute raises specific error. + """ + with pytest.raises(ImproperlyConfigured, match="must define a model_class attribute"): + @api_controller + class FaultyController(MixinModelControllerBase): + pass -@pytest.mark.django_db -def test_list_controller(): - @api_controller("/mixin-list", tags=["Events"]) - class EventMixinModelControllerBase(MixinModelControllerBase, ListModelMixin): - model_class = Event - - client = TestClient(EventMixinModelControllerBase) - - events = [ - Event.objects.create( - title="Testing", end_date="2020-01-02", start_date="2020-01-01" - ), - Event.objects.create( - title="Testing", end_date="2020-01-02", start_date="2020-01-01" - ), - ] - - resp = client.get( - "/", - ) - assert resp.json()["count"] == len(events) - for event in events: - event.delete() +def test_empty_controller_returns_404(controller_client_factory): + """ + Test an empty controller without any mixins. + """ + client = controller_client_factory() + with pytest.raises(Exception, match="Cannot resolve \"/\""): + client.get("/") @pytest.mark.django_db -def test_retrieve_controller(): - @api_controller("/mixin-retrieve", tags=["Events"]) - class EventMixinModelControllerBase(MixinModelControllerBase, RetrieveModelMixin): - model_class = Event - - client = TestClient(EventMixinModelControllerBase) - event1 = Event.objects.create( - title="Testing", end_date="2020-01-02", start_date="2020-01-01" - ) +def test_list_controller(controller_client_factory): + """ + Test retrieving a paginated list of objects. + """ + client = controller_client_factory(ListModelMixin) + Event.objects.create(title="Event 1", end_date="2025-01-02", start_date="2025-01-01") + Event.objects.create(title="Event 2", end_date="2025-01-03", start_date="2025-01-04") - resp = client.get( - f"/{event1.pk}", - ) - assert resp.json()["title"] == event1.title + response = client.get("/") - event1.delete() + assert response.status_code == 200 + # The default response is a list of objects. + assert response.json().get("count",0) == 2 @pytest.mark.django_db -def test_patch_controller(): - @api_controller("/mixin-patch", tags=["Events"]) - class EventMixinModelControllerBase(MixinModelControllerBase, PatchModelMixin): - model_class = Event - - client = TestClient(EventMixinModelControllerBase) - event1 = Event.objects.create( - title="Testing", end_date="2020-01-02", start_date="2020-01-01" - ) +def test_retrieve_controller(controller_client_factory, event_obj): + """Test retrieving a single, existing item.""" + client = controller_client_factory(RetrieveModelMixin) + response = client.get(f"/{event_obj.pk}") + assert response.status_code == 200 + assert response.json()["title"] == event_obj.title + + +@pytest.mark.django_db +def test_retrieve_not_found(controller_client_factory): + """ + Test that requesting a non-existent object returns a 404. + """ + client = controller_client_factory(RetrieveModelMixin) + response = client.get("/9999") + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_patch_controller(controller_client_factory, event_obj): + """ + Verify database persistence for PATCH. + """ + client = controller_client_factory(PatchModelMixin) new_title = "Updated!" - resp = client.patch(f"/{event1.pk}", json={"title": new_title}) - assert resp.json()["title"] == new_title - event1.delete() + response = client.patch(f"/{event_obj.pk}", json={"title": new_title}) + + assert response.status_code == 200 + assert response.json()["title"] == new_title + + # Verify the change was actually saved + event_obj.refresh_from_db() + assert event_obj.title == new_title @pytest.mark.django_db -def test_put_controller(): - @api_controller("/mixin-put") - class EventMixinModelControllerBase(MixinModelControllerBase, PutModelMixin): - model_class = Event - - client = TestClient(EventMixinModelControllerBase) - event1 = Event.objects.create( - title="Testing", end_date="2020-01-02", start_date="2020-01-01" - ) +def test_put_controller(controller_client_factory, event_obj): + """ + Verify database persistence for PUT. + """ + client = controller_client_factory(PutModelMixin) + payload = { + "title": "Full Update Title", + "start_date": "2026-01-01", + "end_date": "2026-12-31", + } + response = client.put(f"/{event_obj.pk}", json=payload) + + assert response.status_code == 200 + + # Verify the change was saved + event_obj.refresh_from_db() + assert event_obj.title == payload["title"] + assert str(event_obj.start_date) == payload["start_date"] - new_title = "Updated!" - new_start = "2025-01-01" - new_end = "2025-12-31" - resp = client.put( - f"/{event1.pk}", - json={"title": new_title, "start_date": new_start, "end_date": new_end}, - ) - assert resp.json()["title"] == new_title - assert resp.json()["start_date"] == new_start - assert resp.json()["end_date"] == new_end - event1.delete() +@pytest.mark.django_db +def test_put_fails_with_partial_data(controller_client_factory, event_obj): + """ + Ensure that a request with missing data fails with a 422 Unprocessable Entity error. + """ + client = controller_client_factory(PutModelMixin) + payload = {"title": "Partial Update"} # Missing start_date and end_date + + response = client.put(f"/{event_obj.pk}", json=payload) + assert response.status_code == 422 @pytest.mark.django_db -def test_delete_controller(): - @api_controller() - class EventMixinModelControllerBase(MixinModelControllerBase, DeleteModelMixin): - model_class = Event - - client = TestClient(EventMixinModelControllerBase) - event1 = Event.objects.create( - title="Testing", end_date="2020-01-02", start_date="2020-01-01" - ) +def test_delete_controller(controller_client_factory, event_obj): + """ + Ensure that the specified object is deleted from the database. + """ + + client = controller_client_factory(DeleteModelMixin) + response = client.delete(f"/{event_obj.pk}") - resp = client.delete(f"/{event1.pk}") - assert resp.status_code == 204 - assert not Event.objects.filter(id=event1.pk).exists() + assert response.status_code == 204 + assert not Event.objects.filter(id=event_obj.pk).exists() \ No newline at end of file From 98894e8582976331d33c130e53b9c073585a5d4a Mon Sep 17 00:00:00 2001 From: Martin Boos Date: Mon, 30 Jun 2025 15:55:37 +0200 Subject: [PATCH 05/10] feat(mixins): add docs --- .../model_controller/05_mixins.md | 147 ++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 148 insertions(+) create mode 100644 docs/api_controller/model_controller/05_mixins.md diff --git a/docs/api_controller/model_controller/05_mixins.md b/docs/api_controller/model_controller/05_mixins.md new file mode 100644 index 00000000..50175be9 --- /dev/null +++ b/docs/api_controller/model_controller/05_mixins.md @@ -0,0 +1,147 @@ +# Programmatic Controller Mixins + +## Core Components + +- **`MixinModelControllerBase`**: The required base class for controllers using this system. It manages route creation and model configuration. +- **Endpoint Mixins**: Declarative classes that add specific endpoints (e.g., list, create) to the controller. + +## Quickstart: Full CRUD API + +To create all CRUD endpoints for a Django model, inherit from `MixinModelControllerBase` and `CRUDModelMixin`. + +**Model:** +```python +from django.db import models + +class Project(models.Model): + name = models.CharField(max_length=100) + description = models.TextField(blank=True) +``` + +**Controller:** +```python +from ninja_extra import api_controller +from ninja_extra.mixins import MixinModelControllerBase, CRUDModelMixin +from .models import Project + +@api_controller("/projects") +class ProjectController(MixinModelControllerBase, CRUDModelMixin): + model_class = Project +``` +This automatically creates the following endpoints: +- `POST /projects/` +- `GET /projects/` +- `GET /projects/{id}/` +- `PUT /projects/{id}/` +- `PATCH /projects/{id}/` +- `DELETE /projects/{id}/` + +## Available Mixins + +### CRUD Mixins + +| Mixin | HTTP Method | Path | Description | +|:---------------------|:------------|:---------|:-----------------------------------| +| `ListModelMixin` | `GET` | `/` | List all model instances. | +| `CreateModelMixin` | `POST` | `/` | Create a new model instance. | +| `RetrieveModelMixin` | `GET` | `/{id}/` | Retrieve a single model instance. | +| `PutModelMixin` | `PUT` | `/{id}/` | Fully update a model instance. | +| `PatchModelMixin` | `PATCH` | `/{id}/` | Partially update a model instance. | +| `DeleteModelMixin` | `DELETE` | `/{id}/` | Delete a model instance. | + +### Convenience Mixins + +| Mixin | Bundles | Purpose | +|:-------------------|:---------------------------------------|:-----------------------------------| +| `ReadModelMixin` | `ListModelMixin`, `RetrieveModelMixin` | Read-only endpoints. | +| `UpdateModelMixin` | `PutModelMixin`, `PatchModelMixin` | Write-only update endpoints. | +| `CRUDModelMixin` | All mixins | Full Create, Read, Update, Delete. | + +## Controller Configuration + +Override class variables on your controller to customize behavior. + +### `model_class` (Required) +The Django model to be exposed via the API. +```python +class MyController(MixinModelControllerBase, ReadModelMixin): + model_class = MyModel +``` + +### `input_schema` & `output_schema` +Override the auto-generated Pydantic schemas. `input_schema` is used for `POST`/`PUT`/`PATCH` bodies. `output_schema` is used for all responses. +```python +class MyController(MixinModelControllerBase, CRUDModelMixin): + model_class = MyModel + input_schema = MyCustomInputSchema + output_schema = MyCustomOutputSchema +``` + +### `lookup_field` +Customize the URL parameter for single-object lookups. Defaults to the model's primary key (e.g., `{int:id}`). +```python +class MyController(MixinModelControllerBase, ReadModelMixin): + model_class = MyModel + # Generates path: /my-model/{str:name}/ + lookup_field = "{str:name}" +``` + +### `ordering_fields` +Enable and constrain query parameter ordering on list endpoints. +```python +class MyController(MixinModelControllerBase, ReadModelMixin): + model_class = MyModel + ordering_fields = ["name", "created_at"] +``` +Enables requests like `GET /?ordering=name` and `GET /?ordering=-created_at`. + +### `auto_operation_ids` & `operation_id_prefix` +Control OpenAPI `operationId` generation. +```python +class MyController(MixinModelControllerBase, ReadModelMixin): + model_class = MyModel + + # Option 1: Disable completely + auto_operation_ids = False + + # Option 2: Add a custom prefix + # Generates: "listCustomPrefix", "getCustomPrefix" + operation_id_prefix = "CustomPrefix" +``` + +## API for Django Choices + +If `model_class` is set to a Django `Choices` enum, `ListModelMixin` creates a read-only endpoint that lists the available choices. + +**Model** + +```python +from django.db import models + +class TaskStatus(models.TextChoices): + PENDING = "PENDING", "Pending" + COMPLETED = "COMPLETED", "Completed" +``` + +**Controller** +```python +from ninja_extra.mixins import MixinModelControllerBase, ReadModelMixin +from .models import TaskStatus + +@api_controller("/task-statuses") +class TaskStatusController(MixinModelControllerBase, ReadModelMixin): + model_class = TaskStatus +``` +A `GET /task-statuses/` request returns a (paginated) list of choices: +```json +[ + { + "id": "COMPLETED", + "label": "Completed" + }, + { + "id": "PENDING", + "label": "Pending" + } +] +``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 3db15f8b..dd862c47 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,7 @@ nav: - Model Configuration: api_controller/model_controller/02_model_configuration.md - Model Service: api_controller/model_controller/03_model_service.md - Parameters: api_controller/model_controller/04_parameters.md + - Mixins: api_controller/model_controller/05_mixins.md - Usage: - Quick Tutorial: tutorial/index.md - Authentication: tutorial/authentication.md From 10d50c0e28179221161165eb7f4777622ebf549f Mon Sep 17 00:00:00 2001 From: Martin Boos Date: Mon, 30 Jun 2025 15:58:45 +0200 Subject: [PATCH 06/10] chore(code): format --- ninja_extra/mixins/__init__.py | 2 +- tests/test_model_controller/test_mixins.py | 25 +++++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/ninja_extra/mixins/__init__.py b/ninja_extra/mixins/__init__.py index eb04f902..e9344281 100644 --- a/ninja_extra/mixins/__init__.py +++ b/ninja_extra/mixins/__init__.py @@ -150,7 +150,7 @@ class MixinModelControllerBase(ModelControllerBase): def __init_subclass__(cls, **kwargs: Any) -> None: """Configure the controller.""" - if not hasattr(cls, "model_class") and not getattr(cls, "model_config", None): + if not hasattr(cls, "model_class") and not getattr(cls, "model_config", None): msg = f"Controller {cls.__name__} must define a model_class attribute" raise ImproperlyConfigured(msg) diff --git a/tests/test_model_controller/test_mixins.py b/tests/test_model_controller/test_mixins.py index b028a509..0e7e7275 100644 --- a/tests/test_model_controller/test_mixins.py +++ b/tests/test_model_controller/test_mixins.py @@ -10,15 +10,13 @@ PutModelMixin, RetrieveModelMixin, ) - - from ninja_extra.testing import TestClient from ..models import Event + @pytest.fixture def controller_client_factory(): - def _factory(*mixins): # Dynamically create a controller class with the given mixins @api_controller @@ -42,7 +40,10 @@ def test_controller_without_model_class_raises_specific_error(): """ Test that a mixincontroller without `model_class` attribute raises specific error. """ - with pytest.raises(ImproperlyConfigured, match="must define a model_class attribute"): + with pytest.raises( + ImproperlyConfigured, match="must define a model_class attribute" + ): + @api_controller class FaultyController(MixinModelControllerBase): pass @@ -53,7 +54,7 @@ def test_empty_controller_returns_404(controller_client_factory): Test an empty controller without any mixins. """ client = controller_client_factory() - with pytest.raises(Exception, match="Cannot resolve \"/\""): + with pytest.raises(Exception, match='Cannot resolve "/"'): client.get("/") @@ -63,14 +64,18 @@ def test_list_controller(controller_client_factory): Test retrieving a paginated list of objects. """ client = controller_client_factory(ListModelMixin) - Event.objects.create(title="Event 1", end_date="2025-01-02", start_date="2025-01-01") - Event.objects.create(title="Event 2", end_date="2025-01-03", start_date="2025-01-04") + Event.objects.create( + title="Event 1", end_date="2025-01-02", start_date="2025-01-01" + ) + Event.objects.create( + title="Event 2", end_date="2025-01-03", start_date="2025-01-04" + ) response = client.get("/") assert response.status_code == 200 # The default response is a list of objects. - assert response.json().get("count",0) == 2 + assert response.json().get("count", 0) == 2 @pytest.mark.django_db @@ -138,7 +143,7 @@ def test_put_fails_with_partial_data(controller_client_factory, event_obj): Ensure that a request with missing data fails with a 422 Unprocessable Entity error. """ client = controller_client_factory(PutModelMixin) - payload = {"title": "Partial Update"} # Missing start_date and end_date + payload = {"title": "Partial Update"} # Missing start_date and end_date response = client.put(f"/{event_obj.pk}", json=payload) assert response.status_code == 422 @@ -154,4 +159,4 @@ def test_delete_controller(controller_client_factory, event_obj): response = client.delete(f"/{event_obj.pk}") assert response.status_code == 204 - assert not Event.objects.filter(id=event_obj.pk).exists() \ No newline at end of file + assert not Event.objects.filter(id=event_obj.pk).exists() From b013c09d59204f14051be43f1957bae3accef621 Mon Sep 17 00:00:00 2001 From: Martin Boos Date: Mon, 30 Jun 2025 16:28:25 +0200 Subject: [PATCH 07/10] chore(code): format --- ninja_extra/mixins/__init__.py | 2 +- tests/test_model_controller/test_mixins.py | 27 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/ninja_extra/mixins/__init__.py b/ninja_extra/mixins/__init__.py index e9344281..30256f45 100644 --- a/ninja_extra/mixins/__init__.py +++ b/ninja_extra/mixins/__init__.py @@ -54,7 +54,7 @@ def __provides__( """ Don't understand exactly why, but this is needed, otherwise `ninja_extra\\controllers\\model\builder.py", line 201, in register_model_routes` crashes. """ - return + return # pragma: no cover @classmethod @abstractmethod diff --git a/tests/test_model_controller/test_mixins.py b/tests/test_model_controller/test_mixins.py index 0e7e7275..c12135da 100644 --- a/tests/test_model_controller/test_mixins.py +++ b/tests/test_model_controller/test_mixins.py @@ -1,7 +1,7 @@ import pytest from django.core.exceptions import ImproperlyConfigured -from ninja_extra import api_controller +from ninja_extra import ModelConfig, api_controller from ninja_extra.mixins import ( DeleteModelMixin, ListModelMixin, @@ -74,7 +74,30 @@ def test_list_controller(controller_client_factory): response = client.get("/") assert response.status_code == 200 - # The default response is a list of objects. + assert response.json().get("count", 0) == 2 + + +@pytest.mark.django_db +def test_modelconfig_controller(controller_client_factory): + """ + Test retrieving a paginated list of objects fro. + """ + + @api_controller + class EventController(MixinModelControllerBase, ListModelMixin): + model_config = ModelConfig(model=Event) + + client = TestClient(EventController) + Event.objects.create( + title="Event 11", end_date="2025-01-02", start_date="2025-01-01" + ) + Event.objects.create( + title="Event 22", end_date="2025-01-03", start_date="2025-01-04" + ) + + response = client.get("/") + + assert response.status_code == 200 assert response.json().get("count", 0) == 2 From 8399376417cd461483f29bdd16a80dc45e9a0b78 Mon Sep 17 00:00:00 2001 From: Martin Boos Date: Mon, 30 Jun 2025 17:02:34 +0200 Subject: [PATCH 08/10] chore(code): format --- ninja_extra/mixins/__init__.py | 2 +- tests/test_model_controller/test_mixins.py | 89 +++++++++++++++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/ninja_extra/mixins/__init__.py b/ninja_extra/mixins/__init__.py index 30256f45..2dc62bcf 100644 --- a/ninja_extra/mixins/__init__.py +++ b/ninja_extra/mixins/__init__.py @@ -11,7 +11,7 @@ import abc import re from abc import abstractmethod -from typing import TYPE_CHECKING, Any, ClassVar, Optional, TypeVar, cast +from typing import Any, ClassVar, Optional, TypeVar, cast from django.core.exceptions import ImproperlyConfigured from django.db import models diff --git a/tests/test_model_controller/test_mixins.py b/tests/test_model_controller/test_mixins.py index c12135da..cf25f197 100644 --- a/tests/test_model_controller/test_mixins.py +++ b/tests/test_model_controller/test_mixins.py @@ -1,5 +1,6 @@ import pytest from django.core.exceptions import ImproperlyConfigured +from django.db import models from ninja_extra import ModelConfig, api_controller from ninja_extra.mixins import ( @@ -20,10 +21,10 @@ def controller_client_factory(): def _factory(*mixins): # Dynamically create a controller class with the given mixins @api_controller - class DynamicController(MixinModelControllerBase, *mixins): + class DynamicControllerClass(MixinModelControllerBase, *mixins): model_class = Event - return TestClient(DynamicController) + return TestClient(DynamicControllerClass) return _factory @@ -183,3 +184,87 @@ def test_delete_controller(controller_client_factory, event_obj): assert response.status_code == 204 assert not Event.objects.filter(id=event_obj.pk).exists() + + +@pytest.mark.django_db +def test_text_choices_controller(controller_client_factory, event_obj): + """ + Ensure that the specified object is deleted from the database. + """ + + class CoffeeCycle(models.TextChoices): + # The moment of creation. + INIT = "__init__", "Instantiated: The Cup is Full" + + # The representation of the coffee. + REPR = "__repr__", "Official String Representation: 'Hot, Black Coffee'" + + # What happens when you add milk. + ADD = "__add__", "Overloaded: Now with Milk" + + # The end of the coffee's life. + DEL = "__del__", "Garbage Collected: The Cup is Empty" + + @api_controller + class CoffeeControllerAPI(MixinModelControllerBase, ListModelMixin): + model_class = CoffeeCycle + + client = TestClient(CoffeeControllerAPI) + response = client.get("/") + + assert response.status_code == 200 + assert response.json() == { + "count": 4, + "next": None, + "previous": None, + "results": [ + {"id": "__del__", "label": "Garbage Collected: The Cup is Empty"}, + {"id": "__init__", "label": "Instantiated: The Cup is Full"}, + { + "id": "__repr__", + "label": "Official String Representation: 'Hot, Black Coffee'", + }, + {"id": "__add__", "label": "Overloaded: Now with Milk"}, + ], + } + + +@pytest.mark.django_db +def test_integer_choices_controller(): + """ + Tests that the ListModelMixin correctly serves a Django IntegerChoices enum, + with integer IDs and labels sorted alphabetically. + """ + + # 1. Define an IntegerChoices class for the test + class PythonRelease(models.IntegerChoices): + LEGACY = 2, "Legacy Python" + MODERN = 3, "Modern Python" + THE_FUTURE = 4, "The Future (Maybe)" + + # 2. Create a controller that uses the IntegerChoices class + @api_controller + class PythonReleaseController(MixinModelControllerBase, ListModelMixin): + model_class = PythonRelease + + # 3. Instantiate the client and make the request + client = TestClient(PythonReleaseController) + response = client.get("/") + + # 4. Assert the response is correct + assert response.status_code == 200 + + # The expected JSON response. IDs are integers, and the list is + # sorted by the label text. + expected_data = { + "count": 3, + "next": None, + "previous": None, + "results": [ + {"id": 2, "label": "Legacy Python"}, + {"id": 3, "label": "Modern Python"}, + {"id": 4, "label": "The Future (Maybe)"}, + ], + } + + assert response.json() == expected_data From 73b134916d47c8766fceb1bed293b962346e6bf2 Mon Sep 17 00:00:00 2001 From: Martin Boos Date: Tue, 1 Jul 2025 08:47:23 +0200 Subject: [PATCH 09/10] test(mixins): improve mixin tests --- ninja_extra/mixins/__init__.py | 56 +++++----------------- tests/test_model_controller/test_mixins.py | 18 ++++++- 2 files changed, 29 insertions(+), 45 deletions(-) diff --git a/ninja_extra/mixins/__init__.py b/ninja_extra/mixins/__init__.py index 2dc62bcf..0a2de4e2 100644 --- a/ninja_extra/mixins/__init__.py +++ b/ninja_extra/mixins/__init__.py @@ -134,7 +134,6 @@ class MixinModelControllerBase(ModelControllerBase): input_schema: ClassVar[type[BaseModel] | None] = None output_schema: ClassVar[type[BaseModel] | None] = None model_class: ClassVar[type[DjangoModel]] - auto_operation_ids: ClassVar[bool] = True operation_id_prefix: ClassVar[str | None] = None filter_schema: ClassVar[type[Schema] | None] = None ordering_fields: ClassVar[list[str]] = [] @@ -204,8 +203,7 @@ def _ensure_model_config(cls) -> None: """Ensure ModelConfig is properly set up for dependency injection.""" if not hasattr(cls, "model_config") or not cls.model_config: cls.model_config = ModelConfig(model=cls.model_class) - elif not cls.model_config.model: - cls.model_config.model = cls.model_class + assert cls.model_config.model, f"The model_class is not set for {cls.__name__}" cls.model_config.allowed_routes = [] @classmethod @@ -236,14 +234,9 @@ class RetrieveModelMixin(ModelMixinBase): @classmethod def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: """Create the retrieve route.""" - operation_id = ( - controller_cls.generate_operation_id("get") - if controller_cls.auto_operation_ids - else None - ) - assert controller_cls.output_schema + operation_id = controller_cls.generate_operation_id("get") controller_cls.retrieve = ModelEndpointFactory.find_one( path=f"/{controller_cls.lookup_field}", operation_id=operation_id, @@ -260,19 +253,9 @@ class ListModelMixin(ModelMixinBase): @classmethod def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: """Create the enhanced list route.""" - # Prevent this basic mixin from overwriting a more specific one. - if hasattr(controller_cls, "list"): - return + assert controller_cls.output_schema - if not controller_cls.output_schema: - msg = f"{controller_cls.__name__} must define an 'output_schema' for the ListModelMixin to work." - raise ImproperlyConfigured(msg) - - operation_id = ( - controller_cls.generate_operation_id("list") - if controller_cls.auto_operation_ids - else None - ) + operation_id = controller_cls.generate_operation_id("list") # TODO(mbo20): refactor into separate ChoideModelMixin class if issubclass(controller_cls.model_class, models.Choices): @@ -289,6 +272,7 @@ def get_choices(self: MixinModelControllerBase) -> list: # noqa: ARG001 choices_sorted = sorted(mapped, key=lambda t: t[1]) return [choice_class(id=k, label=v) for (k, v) in choices_sorted] + controller_cls.list = ModelEndpointFactory.list( path="/", operation_id=operation_id, @@ -335,14 +319,10 @@ class CreateModelMixin(ModelMixinBase): @classmethod def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: """Create the create route.""" - operation_id = ( - controller_cls.generate_operation_id("create") - if controller_cls.auto_operation_ids - else None - ) - assert controller_cls.input_schema assert controller_cls.output_schema + + operation_id = controller_cls.generate_operation_id("create") controller_cls.create = ModelEndpointFactory.create( path="/", operation_id=operation_id, @@ -358,14 +338,10 @@ class PutModelMixin(ModelMixinBase): @classmethod def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: """Create the update route.""" - operation_id = ( - controller_cls.generate_operation_id("update") - if controller_cls.auto_operation_ids - else None - ) - assert controller_cls.input_schema assert controller_cls.output_schema + + operation_id = controller_cls.generate_operation_id("update") controller_cls.update = ModelEndpointFactory.update( path=f"/{controller_cls.lookup_field}", operation_id=operation_id, @@ -382,11 +358,7 @@ class PatchModelMixin(ModelMixinBase): @classmethod def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: """Create the patch route.""" - operation_id = ( - controller_cls.generate_operation_id("patch") - if controller_cls.auto_operation_ids - else None - ) + assert controller_cls.output_schema patch_schema = ( create_schema( # see: https://github.com/vitalik/django-ninja/issues/1183 @@ -396,7 +368,7 @@ def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: ) ) - assert controller_cls.output_schema + operation_id = controller_cls.generate_operation_id("patch") controller_cls.patch = ModelEndpointFactory.patch( path=f"/{controller_cls.lookup_field}", operation_id=operation_id, @@ -413,11 +385,7 @@ class DeleteModelMixin(ModelMixinBase): @classmethod def create_routes(cls, controller_cls: type[MixinModelControllerBase]) -> None: """Create the delete route.""" - operation_id = ( - controller_cls.generate_operation_id("delete") - if controller_cls.auto_operation_ids - else None - ) + operation_id = controller_cls.generate_operation_id("delete") controller_cls.delete = ModelEndpointFactory.delete( path=f"/{controller_cls.lookup_field}", diff --git a/tests/test_model_controller/test_mixins.py b/tests/test_model_controller/test_mixins.py index cf25f197..87d4759c 100644 --- a/tests/test_model_controller/test_mixins.py +++ b/tests/test_model_controller/test_mixins.py @@ -4,7 +4,7 @@ from ninja_extra import ModelConfig, api_controller from ninja_extra.mixins import ( - DeleteModelMixin, + CreateModelMixin, DeleteModelMixin, ListModelMixin, MixinModelControllerBase, PatchModelMixin, @@ -102,6 +102,7 @@ class EventController(MixinModelControllerBase, ListModelMixin): assert response.json().get("count", 0) == 2 + @pytest.mark.django_db def test_retrieve_controller(controller_client_factory, event_obj): """Test retrieving a single, existing item.""" @@ -173,6 +174,21 @@ def test_put_fails_with_partial_data(controller_client_factory, event_obj): assert response.status_code == 422 +@pytest.mark.django_db +def test_create_controller(controller_client_factory): + """ + Test retrieving a paginated list of objects. + """ + client = controller_client_factory(CreateModelMixin) + assert Event.objects.exists() is False + + response = client.post("/", json={"title": "__init__", "start_date": "1956-01-31", "end_date": "2018-07-31"}) + + assert response.status_code == 201 + + assert Event.objects.filter(title="__init__", start_date="1956-01-31").exists() is True + + @pytest.mark.django_db def test_delete_controller(controller_client_factory, event_obj): """ From 64cd298c899f9b5747b606eaae55432d02929149 Mon Sep 17 00:00:00 2001 From: Martin Boos Date: Tue, 1 Jul 2025 08:51:48 +0200 Subject: [PATCH 10/10] chore(code): format --- ninja_extra/mixins/__init__.py | 1 - tests/test_model_controller/test_mixins.py | 17 +++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/ninja_extra/mixins/__init__.py b/ninja_extra/mixins/__init__.py index 0a2de4e2..c7e97845 100644 --- a/ninja_extra/mixins/__init__.py +++ b/ninja_extra/mixins/__init__.py @@ -272,7 +272,6 @@ def get_choices(self: MixinModelControllerBase) -> list: # noqa: ARG001 choices_sorted = sorted(mapped, key=lambda t: t[1]) return [choice_class(id=k, label=v) for (k, v) in choices_sorted] - controller_cls.list = ModelEndpointFactory.list( path="/", operation_id=operation_id, diff --git a/tests/test_model_controller/test_mixins.py b/tests/test_model_controller/test_mixins.py index 87d4759c..ae12223d 100644 --- a/tests/test_model_controller/test_mixins.py +++ b/tests/test_model_controller/test_mixins.py @@ -4,7 +4,8 @@ from ninja_extra import ModelConfig, api_controller from ninja_extra.mixins import ( - CreateModelMixin, DeleteModelMixin, + CreateModelMixin, + DeleteModelMixin, ListModelMixin, MixinModelControllerBase, PatchModelMixin, @@ -102,7 +103,6 @@ class EventController(MixinModelControllerBase, ListModelMixin): assert response.json().get("count", 0) == 2 - @pytest.mark.django_db def test_retrieve_controller(controller_client_factory, event_obj): """Test retrieving a single, existing item.""" @@ -182,11 +182,20 @@ def test_create_controller(controller_client_factory): client = controller_client_factory(CreateModelMixin) assert Event.objects.exists() is False - response = client.post("/", json={"title": "__init__", "start_date": "1956-01-31", "end_date": "2018-07-31"}) + response = client.post( + "/", + json={ + "title": "__init__", + "start_date": "1956-01-31", + "end_date": "2018-07-31", + }, + ) assert response.status_code == 201 - assert Event.objects.filter(title="__init__", start_date="1956-01-31").exists() is True + assert ( + Event.objects.filter(title="__init__", start_date="1956-01-31").exists() is True + ) @pytest.mark.django_db