diff --git a/docs/api_controller/model_controller.md b/docs/api_controller/model_controller.md index 2308f2e..08edc7b 100644 --- a/docs/api_controller/model_controller.md +++ b/docs/api_controller/model_controller.md @@ -75,6 +75,7 @@ The `ModelConfig` is a Pydantic schema designed for validating and configuring t - **model**: A mandatory field representing the Django model type associated with the Model Controller. - **async_routes**: Indicates if controller **routes** should be created as **`asynchronous`** route functions - **allowed_routes**: A list specifying the API actions permissible for generation in the Model Controller. The default value is `["create", "find_one", "update", "patch", "delete", "list"]`. +- **lookup_field**: The model field that should be used for performing object lookup of individual model instances. Defaults to `'pk'`. This is similar to Django REST Framework's `lookup_field` option. - **create_schema**: An optional Pydantic schema outlining the data input types for a `create` or `POST` operation in the Model Controller. The default is `None`. If not provided, the `ModelController` will generate a new schema based on the `schema_config` option. - **update_schema**: An optional Pydantic schema detailing the data input types for an `update` or `PUT` operation in the Model Controller. The default is `None`. If not provided, the `create_schema` will be used if available, or a new schema will be generated based on the `schema_config` option. - **retrieve_schema**: An optional Pydantic schema output defining the data output types for various operations. The default is `None`. If not provided, the `ModelController` will generate a schema based on the `schema_config` option. @@ -115,6 +116,138 @@ The `ModelConfig` is a Pydantic schema designed for validating and configuring t ) ``` +## **Custom Lookup Field** + +By default, Model Controllers use the primary key (`pk`) for object lookups in operations like +`find_one`, `update`, `patch`, and `delete`. You can change this behavior using the `lookup_field` option, +which is similar to Django REST Framework's `lookup_field`. + +This is useful when you want to expose a different field in your URLs, such as a `slug`, `uuid`, or any other unique field. + +### **Basic Example** + +Consider a `Client` model with a unique `key` field: + +```python +from django.db import models + +class Client(models.Model): + key = models.CharField(max_length=20, unique=True) +``` + +You can create a Model Controller that uses `key` for lookups instead of `id`: + +```python +from ninja_extra import ( + ModelConfig, + ModelControllerBase, + api_controller, +) +from .models import Client + +@api_controller("/clients") +class ClientModelController(ModelControllerBase): + model_config = ModelConfig( + model=Client, + lookup_field="key", # Use 'key' field for lookups instead of 'pk' + ) +``` + +This will generate the following endpoints: + +| Method | URL | Description | +|--------|-----|-------------| +| POST | `/clients/` | Create a new client | +| GET | `/clients/` | List all clients | +| GET | `/clients/{key}` | Get a client by key | +| PUT | `/clients/{key}` | Update a client by key | +| PATCH | `/clients/{key}` | Partial update a client by key | +| DELETE | `/clients/{key}` | Delete a client by key | + +Notice that the URL parameter is `{key}` (string) instead of `{id}` (integer). + +### **Using UUID as Lookup Field** + +A common use case is using UUID for lookups: + +```python +import uuid +from django.db import models + +class Article(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + title = models.CharField(max_length=200) + content = models.TextField() +``` + +```python +from ninja_extra import ( + ModelConfig, + ModelControllerBase, + api_controller, +) +from .models import Article + +@api_controller("/articles") +class ArticleModelController(ModelControllerBase): + model_config = ModelConfig( + model=Article, + lookup_field="uuid", + ) +``` + +Now you can access articles using their UUID: +``` +GET /articles/550e8400-e29b-41d4-a716-446655440000 +``` + +### **Using Slug as Lookup Field** + +Another common pattern is using slugs for SEO-friendly URLs: + +```python +from django.db import models + +class Post(models.Model): + slug = models.SlugField(max_length=100, unique=True) + title = models.CharField(max_length=200) + body = models.TextField() +``` + +```python +from ninja_extra import ( + ModelConfig, + ModelControllerBase, + api_controller, +) +from .models import Post + +@api_controller("/posts") +class PostModelController(ModelControllerBase): + model_config = ModelConfig( + model=Post, + lookup_field="slug", + ) +``` + +Now you can access posts using their slug: +``` +GET /posts/my-awesome-post +PUT /posts/my-awesome-post +DELETE /posts/my-awesome-post +``` + +### **Important Notes** + +1. **Unique Fields**: The `lookup_field` should be a unique field to ensure reliable lookups. Using a non-unique field may return unexpected results. + +2. **URL Parameter Type**: The URL parameter type is automatically inferred from the field type: + - `CharField`, `SlugField`, `UUIDField` → `str` + - `IntegerField`, `AutoField` → `int` + - etc. + +3. **Custom Model Service**: If you're using a custom `ModelService`, make sure it handles the `lookup_field` parameter correctly. The default `ModelService` automatically supports custom lookup fields. + ## **More on Model Controller Operations** In NinjaExtra Model Controller, the controller's behavior can be controlled by what is provided in the `allowed_routes` list within the `model_config` option. diff --git a/ninja_extra/controllers/model/builder.py b/ninja_extra/controllers/model/builder.py index b63efd8..d1eb73d 100644 --- a/ninja_extra/controllers/model/builder.py +++ b/ninja_extra/controllers/model/builder.py @@ -35,14 +35,31 @@ def __init__( self._config: ModelConfig = base_cls.model_config self._base_cls = base_cls self._api_controller_instance = api_controller_instance - model_pk = getattr( - self._config.model._meta.pk, - "name", - self._config.model._meta.pk.attname, - ) - internal_type = self._config.model._meta.pk.get_internal_type() + + # Get lookup field configuration (defaults to 'pk') + lookup_field = self._config.lookup_field + + if lookup_field == "pk": + # Use primary key field (default behavior) + lookup_field_name = getattr( + self._config.model._meta.pk, + "name", + self._config.model._meta.pk.attname, + ) + internal_type = self._config.model._meta.pk.get_internal_type() + else: + # Use specified lookup field + lookup_field_name = lookup_field + try: + field = self._config.model._meta.get_field(lookup_field) + internal_type = field.get_internal_type() # type: ignore[union-attr] + except Exception: + # Fallback to string type if field not found + internal_type = "CharField" + self._pk_type: t.Type = TYPES.get(internal_type, str) # type: ignore[assignment] - self._model_pk_name = model_pk + self._model_pk_name = lookup_field_name + self._lookup_field = lookup_field self._model_name = self._config.model.__name__.replace("Model", "") self._retrieve_schema = self._config.retrieve_schema @@ -101,6 +118,7 @@ def _register_update_endpoint(self) -> None: update_item = self._route_factory.update( path=_path, lookup_param=self._model_pk_name, + lookup_field=self._lookup_field, schema_in=self._update_schema, # type:ignore[arg-type] schema_out=kw.pop("schema_out", self._retrieve_schema), # type:ignore[arg-type] **kw, # type:ignore[arg-type] @@ -125,6 +143,7 @@ def _register_patch_endpoint(self) -> None: patch_item = self._route_factory.patch( path=_path, lookup_param=self._model_pk_name, + lookup_field=self._lookup_field, schema_out=kw.pop("schema_out", self._retrieve_schema), # type:ignore[arg-type] schema_in=self._patch_schema, # type:ignore[arg-type] **kw, # type:ignore[arg-type] @@ -147,6 +166,7 @@ def _register_find_one_endpoint(self) -> None: get_item = self._route_factory.find_one( path=_path, lookup_param=self._model_pk_name, + lookup_field=self._lookup_field, schema_out=self._retrieve_schema, # type:ignore[arg-type] **kw, # type:ignore[arg-type] ) @@ -199,6 +219,7 @@ def _register_delete_endpoint(self) -> None: delete_item = self._route_factory.delete( path=_path, lookup_param=self._model_pk_name, + lookup_field=self._lookup_field, **kw, # type:ignore[arg-type] ) diff --git a/ninja_extra/controllers/model/endpoints.py b/ninja_extra/controllers/model/endpoints.py index ce81c12..5acc447 100644 --- a/ninja_extra/controllers/model/endpoints.py +++ b/ninja_extra/controllers/model/endpoints.py @@ -166,6 +166,7 @@ def update( lookup_param: str, schema_in: t.Type[PydanticModel], schema_out: t.Type[PydanticModel], + lookup_field: str = "pk", status_code: int = status.HTTP_200_OK, auth: t.Any = NOT_SET, throttle: t.Union[BaseThrottle, t.List[BaseThrottle], NOT_SET_TYPE] = NOT_SET, @@ -202,6 +203,7 @@ def _setup(model_controller_type: t.Type["ModelControllerBase"]) -> t.Callable: schema_in=schema_in, object_getter=object_getter, lookup_param=lookup_param, + lookup_field=lookup_field, custom_handler=custom_handler, ), prefix_route_params=api_controller.prefix_route_params, @@ -237,6 +239,7 @@ def patch( lookup_param: str, schema_in: t.Type[PydanticModel], schema_out: t.Type[PydanticModel], + lookup_field: str = "pk", status_code: int = status.HTTP_200_OK, auth: t.Any = NOT_SET, throttle: t.Union[BaseThrottle, t.List[BaseThrottle], NOT_SET_TYPE] = NOT_SET, @@ -273,6 +276,7 @@ def _setup(model_controller_type: t.Type["ModelControllerBase"]) -> t.Callable: object_getter=object_getter, custom_handler=custom_handler, lookup_param=lookup_param, + lookup_field=lookup_field, schema_in=schema_in, ), prefix_route_params=api_controller.prefix_route_params, @@ -307,6 +311,7 @@ def find_one( path: str, lookup_param: str, schema_out: t.Type[PydanticModel], + lookup_field: str = "pk", status_code: int = status.HTTP_200_OK, auth: t.Any = NOT_SET, throttle: t.Union[BaseThrottle, t.List[BaseThrottle], NOT_SET_TYPE] = NOT_SET, @@ -339,7 +344,9 @@ def _setup(model_controller_type: t.Type["ModelControllerBase"]) -> t.Callable: get_item = _path_resolver( path, cls._find_one_handler( - object_getter=object_getter, lookup_param=lookup_param + object_getter=object_getter, + lookup_param=lookup_param, + lookup_field=lookup_field, ), prefix_route_params=api_controller.prefix_route_params, ) @@ -472,6 +479,7 @@ def delete( cls, path: str, lookup_param: str, + lookup_field: str = "pk", status_code: int = status.HTTP_204_NO_CONTENT, auth: t.Any = NOT_SET, throttle: t.Union[BaseThrottle, t.List[BaseThrottle], NOT_SET_TYPE] = NOT_SET, @@ -507,6 +515,7 @@ def _setup(model_controller_type: t.Type["ModelControllerBase"]) -> t.Callable: cls._delete_handler( object_getter=object_getter, lookup_param=lookup_param, + lookup_field=lookup_field, custom_handler=custom_handler, status_code=status_code, ), @@ -553,6 +562,7 @@ def _delete_handler( *, object_getter: t.Optional[t.Callable[..., DjangoModel]], lookup_param: str, + lookup_field: str = "pk", custom_handler: t.Optional[t.Callable[..., t.Any]], status_code: int, ) -> t.Callable: @@ -561,7 +571,7 @@ def delete_item(self: "ModelControllerBase", **kwargs: t.Any) -> t.Any: obj = ( object_getter(self, pk=pk, **kwargs) if object_getter - else self.service.get_one(pk=pk, **kwargs) + else self.service.get_one(pk=pk, lookup_field=lookup_field, **kwargs) ) if not obj: # pragma: no cover raise NotFound() @@ -580,13 +590,14 @@ def _find_one_handler( *, object_getter: t.Optional[t.Callable[..., DjangoModel]], lookup_param: str, + lookup_field: str = "pk", ) -> t.Callable: def get_item(self: "ModelControllerBase", **kwargs: t.Any) -> t.Any: pk = kwargs.pop(lookup_param) obj = ( object_getter(self, pk=pk, **kwargs) if object_getter - else self.service.get_one(pk=pk, **kwargs) + else self.service.get_one(pk=pk, lookup_field=lookup_field, **kwargs) ) if not obj: # pragma: no cover raise NotFound() @@ -603,6 +614,7 @@ def _patch_handler( schema_in: t.Type[PydanticModel], object_getter: t.Optional[t.Callable[..., DjangoModel]], lookup_param: str, + lookup_field: str = "pk", custom_handler: t.Optional[t.Callable[..., t.Any]], ) -> t.Callable: def patch_item( @@ -614,7 +626,7 @@ def patch_item( obj = ( object_getter(self, pk=pk, **kwargs) if object_getter - else self.service.get_one(pk=pk, **kwargs) + else self.service.get_one(pk=pk, lookup_field=lookup_field, **kwargs) ) if not obj: # pragma: no cover raise NotFound() @@ -637,6 +649,7 @@ def _update_handler( schema_in: t.Type[PydanticModel], object_getter: t.Optional[t.Callable[..., DjangoModel]], lookup_param: str, + lookup_field: str = "pk", custom_handler: t.Optional[t.Callable[..., t.Any]], ) -> t.Callable: def update_item( @@ -648,7 +661,7 @@ def update_item( obj = ( object_getter(self, pk=pk, **kwargs) if object_getter - else self.service.get_one(pk=pk, **kwargs) + else self.service.get_one(pk=pk, lookup_field=lookup_field, **kwargs) ) if not obj: # pragma: no cover @@ -734,6 +747,7 @@ def _delete_handler( *, object_getter: t.Optional[t.Callable[..., DjangoModel]], lookup_param: str, + lookup_field: str = "pk", custom_handler: t.Optional[t.Callable[..., t.Any]], status_code: int, ) -> t.Callable: @@ -742,7 +756,9 @@ async def delete_item(self: "ModelControllerBase", **kwargs: t.Any) -> t.Any: obj = ( object_getter(self, pk=pk, **kwargs) if object_getter - else self.service.get_one_async(pk=pk, **kwargs) + else self.service.get_one_async( + pk=pk, lookup_field=lookup_field, **kwargs + ) ) obj = await _check_if_coroutine(obj) if not obj: # pragma: no cover @@ -767,13 +783,16 @@ def _find_one_handler( *, object_getter: t.Optional[t.Callable[..., DjangoModel]], lookup_param: str, + lookup_field: str = "pk", ) -> t.Callable: async def get_item(self: "ModelControllerBase", **kwargs: t.Any) -> t.Any: pk = kwargs.pop(lookup_param) obj = ( object_getter(self, pk=pk, **kwargs) if object_getter - else self.service.get_one_async(pk=pk, **kwargs) + else self.service.get_one_async( + pk=pk, lookup_field=lookup_field, **kwargs + ) ) obj = await _check_if_coroutine(obj) @@ -793,6 +812,7 @@ def _patch_handler( schema_in: t.Type[PydanticModel], object_getter: t.Optional[t.Callable[..., DjangoModel]], lookup_param: str, + lookup_field: str = "pk", custom_handler: t.Optional[t.Callable[..., t.Any]], ) -> t.Callable: async def patch_item( @@ -804,7 +824,9 @@ async def patch_item( obj = ( object_getter(self, pk=pk, **kwargs) if object_getter - else self.service.get_one_async(pk=pk, **kwargs) + else self.service.get_one_async( + pk=pk, lookup_field=lookup_field, **kwargs + ) ) obj = await _check_if_coroutine(obj) @@ -834,6 +856,7 @@ def _update_handler( schema_in: t.Type[PydanticModel], object_getter: t.Optional[t.Callable[..., DjangoModel]], lookup_param: str, + lookup_field: str = "pk", custom_handler: t.Optional[t.Callable[..., t.Any]], ) -> t.Callable: async def update_item( @@ -845,7 +868,9 @@ async def update_item( obj = ( object_getter(self, pk=pk, **kwargs) if object_getter - else self.service.get_one_async(pk=pk, **kwargs) + else self.service.get_one_async( + pk=pk, lookup_field=lookup_field, **kwargs + ) ) obj = await _check_if_coroutine(obj) diff --git a/ninja_extra/controllers/model/schemas.py b/ninja_extra/controllers/model/schemas.py index 07bf9b2..94c35ff 100644 --- a/ninja_extra/controllers/model/schemas.py +++ b/ninja_extra/controllers/model/schemas.py @@ -90,6 +90,10 @@ class ModelConfig(PydanticModel): ] ) async_routes: bool = False + lookup_field: str = Field( + default="pk", + description="The model field that should be used for performing object lookup of individual model instances. Defaults to 'pk'.", + ) create_schema: t.Optional[t.Type[PydanticModel]] = None retrieve_schema: t.Optional[t.Type[PydanticModel]] = None update_schema: t.Optional[t.Type[PydanticModel]] = None diff --git a/ninja_extra/controllers/model/service.py b/ninja_extra/controllers/model/service.py index 9e3216a..111c28b 100644 --- a/ninja_extra/controllers/model/service.py +++ b/ninja_extra/controllers/model/service.py @@ -22,8 +22,12 @@ def __init__(self, model: t.Type[Model]) -> None: self.model = model def get_one(self, pk: t.Any, **kwargs: t.Any) -> t.Any: + lookup_field = kwargs.pop("lookup_field", "pk") obj = get_object_or_exception( - klass=self.model, error_message=None, exception=NotFound, pk=pk + klass=self.model, + error_message=None, + exception=NotFound, + **{lookup_field: pk}, ) return obj diff --git a/tests/test_model_controller/samples.py b/tests/test_model_controller/samples.py index 891fbca..26336ba 100644 --- a/tests/test_model_controller/samples.py +++ b/tests/test_model_controller/samples.py @@ -11,7 +11,7 @@ ) from ninja_extra.schemas import NinjaPaginationResponseSchema -from ..models import Event +from ..models import Client, Event class CreateEventSchema(ModelSchema): @@ -129,3 +129,34 @@ class EventController(ModelControllerBase): object_getter=lambda self, pk, **kw: Event.objects.filter(id=pk).first(), custom_handler=lambda self, **kw: self.service.delete(**kw), ) + + +# Schema for Client model +class ClientSchema(ModelSchema): + class Config: + model = Client + include = ["id", "key"] + + +class CreateClientSchema(ModelSchema): + class Config: + model = Client + include = ["key"] + + +@api_controller("/client") +class ClientModelControllerWithLookupField(ModelControllerBase): + """ + Model Controller that uses 'key' as lookup_field instead of 'pk'. + This allows retrieving, updating, patching, and deleting clients by their key. + """ + + model_config = ModelConfig( + model=Client, + lookup_field="key", # Use 'key' field for lookups instead of 'pk' + create_schema=CreateClientSchema, + retrieve_schema=ClientSchema, + update_schema=CreateClientSchema, + patch_schema=CreateClientSchema, + pagination=None, + ) diff --git a/tests/test_model_controller/test_lookup_field.py b/tests/test_model_controller/test_lookup_field.py new file mode 100644 index 0000000..8d9e1ba --- /dev/null +++ b/tests/test_model_controller/test_lookup_field.py @@ -0,0 +1,117 @@ +""" +Tests for the lookup_field configuration in ModelController. + +The lookup_field option allows using a different field for object lookups +instead of the default primary key ('pk'). This is similar to Django REST +Framework's lookup_field option. +""" + +import pytest + +from ninja_extra.testing import TestClient + +from ..models import Client +from .samples import ClientModelControllerWithLookupField + + +@pytest.mark.django_db +def test_model_controller_with_lookup_field(): + """ + Test that ModelController correctly uses lookup_field='key' for all CRUD operations. + + The Client model has a unique 'key' field, and this test verifies that: + - URLs use the key field (/{str:key}) instead of pk (/{int:id}) + - GET, PUT, PATCH, DELETE operations lookup by key + """ + client = TestClient(ClientModelControllerWithLookupField) + + # CREATE - Create a new client with a unique key + test_key = "test-client-key-123" + res = client.post("/", json={"key": test_key}) + assert res.status_code == 201 + data = res.json() + assert data["key"] == test_key + client_id = data["id"] + + # GET by key (not by id) - This is the key feature of lookup_field + res = client.get(f"/{test_key}") + assert res.status_code == 200 + data = res.json() + assert data["key"] == test_key + assert data["id"] == client_id + + # LIST - should still work normally + res = client.get("/") + assert res.status_code == 200 + data = res.json() + assert any(item["key"] == test_key for item in data) + + # PUT by key - Update the client using key as lookup + new_key = "updated-key-456" + res = client.put(f"/{test_key}", json={"key": new_key}) + assert res.status_code == 200 + data = res.json() + assert data["key"] == new_key + assert data["id"] == client_id # Same id, different key + + # PATCH by key - Patch the client using the new key + patched_key = "patched-key-789" + res = client.patch(f"/{new_key}", json={"key": patched_key}) + assert res.status_code == 200 + data = res.json() + assert data["key"] == patched_key + assert data["id"] == client_id + + # DELETE by key + res = client.delete(f"/{patched_key}") + assert res.status_code == 204 + + # Verify the client is deleted + assert not Client.objects.filter(key=patched_key).exists() + + +@pytest.mark.django_db +def test_model_controller_lookup_field_not_found(): + """ + Test that using a non-existent key returns 404. + """ + client = TestClient(ClientModelControllerWithLookupField) + + # GET with non-existent key should return 404 + res = client.get("/non-existent-key") + assert res.status_code == 404 + + # PUT with non-existent key should return 404 + res = client.put("/non-existent-key", json={"key": "new-key"}) + assert res.status_code == 404 + + # PATCH with non-existent key should return 404 + res = client.patch("/non-existent-key", json={"key": "new-key"}) + assert res.status_code == 404 + + # DELETE with non-existent key should return 404 + res = client.delete("/non-existent-key") + assert res.status_code == 404 + + +@pytest.mark.django_db +def test_lookup_field_uses_correct_url_parameter_type(): + """ + Test that the URL parameter type matches the lookup field type. + + For 'key' (CharField), the URL should use string type: /{str:key} + """ + client = TestClient(ClientModelControllerWithLookupField) + + # Create a client with a key containing special characters that are valid in URLs + special_key = "client-with-dashes" + res = client.post("/", json={"key": special_key}) + assert res.status_code == 201 + + # Access using the string key + res = client.get(f"/{special_key}") + assert res.status_code == 200 + assert res.json()["key"] == special_key + + # Cleanup + Client.objects.filter(key=special_key).delete()