diff --git a/docs/api_controller/model_controller/01_getting_started.md b/docs/api_controller/model_controller/01_getting_started.md new file mode 100644 index 00000000..1ab56ef1 --- /dev/null +++ b/docs/api_controller/model_controller/01_getting_started.md @@ -0,0 +1,129 @@ +# Getting Started with Model Controllers + +Model Controllers in Ninja Extra provide a powerful way to automatically generate CRUD (Create, Read, Update, Delete) operations for Django ORM models. They simplify API development by handling common database operations while remaining highly customizable. + +## **Installation** + +First, ensure you have Ninja Extra and ninja-schema installed: + +```bash +pip install django-ninja-extra ninja-schema +``` + +`ninja-schema` package is optional, but it's recommended for generating schemas. + +## **Basic Usage** + +Let's start with a simple example. Consider this Django model: + +```python +from django.db import models + +class Category(models.Model): + title = models.CharField(max_length=100) + +class Event(models.Model): + title = models.CharField(max_length=100) + category = models.OneToOneField( + Category, null=True, blank=True, + on_delete=models.SET_NULL, + related_name='events' + ) + start_date = models.DateField() + end_date = models.DateField() + + def __str__(self): + return self.title +``` + +To create a basic Model Controller for the Event model: + +```python +from ninja_extra import ( + ModelConfig, + ModelControllerBase, + api_controller, + NinjaExtraAPI +) +from .models import Event + +@api_controller("/events") +class EventModelController(ModelControllerBase): + model_config = ModelConfig( + model=Event, + ) + +# Register the controller with your API +api = NinjaExtraAPI() +api.register_controllers(EventModelController) +``` + +This simple setup automatically creates the following endpoints: + +- `POST /events/` - Create a new event +- `GET /events/{id}` - Retrieve a specific event +- `PUT /events/{id}` - Update an event +- `PATCH /events/{id}` - Partially update an event +- `DELETE /events/{id}` - Delete an event +- `GET /events/` - List all events (with pagination) + +It is important to that if `model_config.model` is not set, the controller becomes a regular NinjaExtra controller. + +## **Generated Schemas** + +The Model Controller automatically generates Pydantic schemas for your model using `ninja-schema`. These schemas handle: + +- Input validation +- Output serialization +- Automatic documentation in the OpenAPI schema + +For example, the generated schemas for our `Event` model would look like this: + +```python +# Auto-generated create/update schema +class EventCreateSchema(Schema): + title: str + start_date: date + end_date: date + category: Optional[int] = None + +# Auto-generated retrieve schema +class EventSchema(Schema): + id: int + title: str + start_date: date + end_date: date + category: Optional[int] = None +``` + +## **Customizing Routes** + +You can control which routes are generated using the `allowed_routes` parameter: + +```python +@api_controller("/events") +class EventModelController(ModelControllerBase): + model_config = ModelConfig( + model=Event, + allowed_routes=["list", "find_one"] # Only generate GET and GET/{id} endpoints + ) +``` + +## **Async Support** + +Model Controllers support `async` operations out of the box. Just set `async_routes=True`: + +```python +@api_controller("/events") +class EventModelController(ModelControllerBase): + model_config = ModelConfig( + model=Event, + async_routes=True # Enable async routes + ) +``` + +## **Next Steps** + +- Learn about [Model Configuration](02_model_configuration.md) for detailed schema and route customization +- Explore [Model Services](03_model_service.md) for customizing CRUD operations +- See how to use [Query and Path Parameters](04_parameters.md) effectively with `ModelEndpointFactory` diff --git a/docs/api_controller/model_controller/02_model_configuration.md b/docs/api_controller/model_controller/02_model_configuration.md new file mode 100644 index 00000000..6be5e3d1 --- /dev/null +++ b/docs/api_controller/model_controller/02_model_configuration.md @@ -0,0 +1,224 @@ +# Model Configuration + +The `ModelConfig` class in Ninja Extra provides extensive configuration options for Model Controllers. It allows you to customize schema generation, route behavior, and pagination settings. + +## **Basic Configuration** + +Here's a comprehensive example of `ModelConfig` usage: + +```python +from ninja_extra import ( + ModelConfig, + ModelControllerBase, + ModelSchemaConfig, + api_controller, +) +from .models import Event + +@api_controller("/events") +class EventModelController(ModelControllerBase): + model_config = ModelConfig( + model=Event, + schema_config=ModelSchemaConfig( + read_only_fields=["id", "created_at"], + write_only_fields=["password"], + include=["title", "start_date", "end_date", "category"], + exclude=set(), # Fields to exclude + depth=1, # Nesting depth for related fields + ), + async_routes=False, # Enable/disable async routes + allowed_routes=["create", "find_one", "update", "patch", "delete", "list"], + ) +``` + +## **Schema Configuration** + +The `ModelSchemaConfig` class controls how Pydantic schemas are generated from your Django models: + +```python +from ninja_extra import ModelConfig, ModelSchemaConfig + +# Detailed schema configuration +schema_config = ModelSchemaConfig( + # Include specific fields (use "__all__" for all fields) + include=["title", "description", "start_date"], + + # Exclude specific fields + exclude={"internal_notes", "secret_key"}, + + # Fields that should be read-only (excluded from create/update schemas) + read_only_fields=["id", "created_at", "updated_at"], + + # Fields that should be write-only (excluded from retrieve schemas) + write_only_fields=["password"], + + # Depth of relationship traversal + depth=1, + + # Additional Pydantic config options + extra_config_dict={ + "title": "EventSchema", + "description": "Schema for Event model", + "populate_by_name": True + } +) + +model_config = ModelConfig( + model=Event, + schema_config=schema_config +) +``` + +## **Custom Schemas** + +You can provide your own Pydantic schemas instead of using auto-generated ones: + +```python +from datetime import date +from pydantic import BaseModel, Field + +class EventCreateSchema(BaseModel): + title: str = Field(..., max_length=100) + start_date: date + end_date: date + category_id: int | None = None + +class EventRetrieveSchema(BaseModel): + id: int + title: str + start_date: date + end_date: date + category_id: int | None + +@api_controller("/events") +class EventModelController(ModelControllerBase): + model_config = ModelConfig( + model=Event, + create_schema=EventCreateSchema, + retrieve_schema=EventRetrieveSchema, + update_schema=EventCreateSchema, # Reuse create schema for updates + ) +``` + +## **Pagination Configuration** + +Model Controllers support customizable pagination for list endpoints: + +```python +from ninja.pagination import LimitOffsetPagination +from ninja_extra import ( + ModelConfig, + ModelPagination +) +from ninja_extra.pagination import NinjaPaginationResponseSchema + +@api_controller("/events") +class EventModelController(ModelControllerBase): + model_config = ModelConfig( + model=Event, + # Configure pagination + pagination=ModelPagination( + klass=LimitOffsetPagination, + pagination_schema=NinjaPaginationResponseSchema, + paginator_kwargs={ + "limit": 20, + "offset": 100 + } + ) + ) +``` + +## **Route Configuration** + +You can customize individual route behavior using route info dictionaries: + +```python +@api_controller("/events") +class EventModelController(ModelControllerBase): + model_config = ModelConfig( + model=Event, + # Customize specific route configurations + create_route_info={ + "summary": "Create a new event", + "description": "Creates a new event with the provided data", + "tags": ["events"], + "deprecated": False, + }, + list_route_info={ + "summary": "List all events", + "description": "Retrieves a paginated list of all events", + "tags": ["events"], + }, + find_one_route_info={ + "summary": "Get event details", + "description": "Retrieves details of a specific event", + "tags": ["events"], + } + ) +``` + +## **Async Routes Configuration** + +Enable async routes and configure async behavior: + +```python +@api_controller("/events") +class AsyncEventModelController(ModelControllerBase): + model_config = ModelConfig( + model=Event, + # Async-specific configurations + async_routes=True, + schema_config=ModelSchemaConfig( + read_only_fields=["id"], + depth=1 + ) + ) + + # Custom async service implementation + service = AsyncEventModelService(model=Event) +``` + +## **Configuration Inheritance** + +ModelConfig also support configuration inheritance: + +```python +from ninja_extra.controllers import ModelConfig + +class BaseModelConfig(ModelConfig): + async_routes = True + schema_config = ModelSchemaConfig( + read_only_fields=["id", "created_at", "updated_at"], + depth=1 + ) + +@api_controller("/events") +class EventModelController(ModelControllerBase): + model_config = BaseModelConfig( + model=Event, + # Override or extend base configuration + allowed_routes=["list", "find_one"] + ) +``` + +## **Best Practices** + +1. **Schema Configuration**: + - Always specify `read_only_fields` for auto-generated fields + - Use `depth` carefully as it can impact performance + - Consider using `exclude` for sensitive fields + +2. **Route Configuration**: + - Limit `allowed_routes` to only necessary endpoints + - Provide meaningful summaries and descriptions + - Use tags for API organization + +3. **Pagination**: + - Always set reasonable limits + - Consider your data size when choosing pagination class + - Use appropriate page sizes for your use case + +4. **Async Support**: + - Enable `async_routes` when using async database operations + - Implement custom async services for complex operations + - Consider performance implications of async operations diff --git a/docs/api_controller/model_controller/03_model_service.md b/docs/api_controller/model_controller/03_model_service.md new file mode 100644 index 00000000..0a22aa55 --- /dev/null +++ b/docs/api_controller/model_controller/03_model_service.md @@ -0,0 +1,325 @@ +# Model Service + +The Model Service layer in Ninja Extra handles all CRUD operations for your models. While the default implementation works well for simple cases, you can customize it for more complex scenarios. + +## **Default Model Service** + +The default `ModelService` implements both synchronous and asynchronous operations: + +```python +from ninja_extra.controllers.model.interfaces import ModelServiceBase, AsyncModelServiceBase + +class ModelService(ModelServiceBase, AsyncModelServiceBase): + def __init__(self, model): + super().__init__(model=model) + ... +``` + +This provides the following methods: + +### **Synchronous Methods:** +- `get_one(pk, **kwargs)` - Retrieve a single object +- `get_all(**kwargs)` - Retrieve all objects +- `create(schema, **kwargs)` - Create a new object +- `update(instance, schema, **kwargs)` - Update an object +- `patch(instance, schema, **kwargs)` - Partially update an object +- `delete(instance, **kwargs)` - Delete an object + +### **Asynchronous Methods:** +- `get_one_async(pk, **kwargs)` - Async retrieve +- `get_all_async(**kwargs)` - Async retrieve all +- `create_async(schema, **kwargs)` - Async create +- `update_async(instance, schema, **kwargs)` - Async update +- `patch_async(instance, schema, **kwargs)` - Async patch +- `delete_async(instance, **kwargs)` - Async delete + +## **Custom Model Service** + +Here's how to create a custom service with additional business logic: + +```python +from typing import Any, List, Union +from django.db.models import QuerySet +from ninja_extra import ModelService +from pydantic import BaseModel + +class EventModelService(ModelService): + def get_one(self, pk: Any, **kwargs: Any) -> Event: + # Add custom logic for retrieving an event + event = super().get_one(pk, **kwargs) + if not event.is_published and not kwargs.get('is_admin'): + raise PermissionError("Event not published") + return event + + def get_all(self, **kwargs: Any) -> Union[QuerySet, List[Any]]: + # Filter events based on criteria + queryset = self.model.objects.all() + if not kwargs.get('is_admin'): + queryset = queryset.filter(is_published=True) + return queryset + + def create(self, schema: BaseModel, **kwargs: Any) -> Any: + # Add custom creation logic + data = schema.model_dump(by_alias=True) + data['created_by'] = kwargs.get('user_id') + + instance = self.model._default_manager.create(**data) + return instance + + def update(self, instance: Event, schema: BaseModel, **kwargs: Any) -> Any: + # Add validation before update + if instance.is_locked: + raise ValueError("Cannot update locked event") + return super().update(instance, schema, **kwargs) +``` + +## **Async Model Service** + +For async operations, you can customize the async methods: + +```python +from ninja_extra import ModelService +from asgiref.sync import sync_to_async + + +class AsyncEventModelService(ModelService): + async def get_all_async(self, **kwargs: Any) -> QuerySet: + # Custom async implementation + @sync_to_async + def get_filtered_events(): + queryset = self.model.objects.all() + if kwargs.get('category'): + queryset = queryset.filter(category_id=kwargs['category']) + return queryset + + return await get_filtered_events() + + async def create_async(self, schema: BaseModel, **kwargs: Any) -> Any: + # Custom async creation + @sync_to_async + def create_event(): + data = schema.model_dump(by_alias=True) + data['created_by'] = kwargs.get('user_id') + return self.model._default_manager.create(**data) + + return await create_event() +``` + +## **Using Custom Services** + +Attach your custom service to your Model Controller: + +```python +@api_controller("/events") +class EventModelController(ModelControllerBase): + service_type = EventModelService + model_config = ModelConfig(model=Event) +``` + +For async controllers: + +```python +@api_controller("/events") +class AsyncEventModelController(ModelControllerBase): + service_type = AsyncEventModelService + model_config = ModelConfig( + model=Event, + async_routes=True + ) +``` + +## **Advanced Service Patterns** + +### **Service with Dependency Injection** + +Model Services support dependency injection, allowing you to inject other services and dependencies when the controller is instantiated. Here's a practical example using email notifications and user tracking: + +```python +from datetime import datetime +from typing import Any, Optional +from django.core.mail import send_mail +from ninja_extra import ModelService, api_controller, ModelConfig +from pydantic import BaseModel +from injector import inject + + +class EmailService: + """Service for handling email notifications""" + def send_event_notification(self, event_data: dict, recipient_email: str): + subject = f"Event Update: {event_data['title']}" + message = ( + f"Event Details:\n" + f"Title: {event_data['title']}\n" + f"Date: {event_data['start_date']} to {event_data['end_date']}\n" + ) + send_mail( + subject=subject, + message=message, + from_email="events@example.com", + recipient_list=[recipient_email], + fail_silently=False, + ) + + +class UserActivityService: + """Service for tracking user activities""" + def track_activity(self, user_id: int, action: str, details: dict): + UserActivity.objects.create( + user_id=user_id, + action=action, + details=details, + timestamp=datetime.now() + ) +``` +Creating `EventModelService` with `EmailService` and `UserActivityService` as dependencies. +```python +class EventModelService(ModelService): + """ + Event service with email notifications and activity tracking. + Dependencies are automatically injected by the framework. + """ + @inject + def __init__( + self, + model: Event, + email_service: EmailService, + activity_service: UserActivityService + ): + super().__init__(model=model) + self.email_service = email_service + self.activity_service = activity_service + + def create(self, schema: BaseModel, **kwargs: Any) -> Any: + # Create the event + event = super().create(schema, **kwargs) + + # Track the creation activity + if user_id := kwargs.get('user_id'): + self.activity_service.track_activity( + user_id=user_id, + action="event_created", + details={ + "event_id": event.id, + "title": event.title + } + ) + + # Send notification to organizer + if organizer_email := kwargs.get('organizer_email'): + self.email_service.send_event_notification( + event_data=schema.model_dump(), + recipient_email=organizer_email + ) + + return event + + def update(self, instance: Event, schema: BaseModel, **kwargs: Any) -> Any: + # Update the event + updated_event = super().update(instance, schema, **kwargs) + + # Track the update activity + if user_id := kwargs.get('user_id'): + self.activity_service.track_activity( + user_id=user_id, + action="event_updated", + details={ + "event_id": updated_event.id, + "title": updated_event.title, + "changes": schema.model_dump() + } + ) + + # Notify relevant parties about the update + if notify_participants := kwargs.get('notify_participants'): + for participant in updated_event.participants.all(): + self.email_service.send_event_notification( + event_data=schema.model_dump(), + recipient_email=participant.email + ) + + return updated_event + + def delete(self, instance: Event, **kwargs: Any) -> Any: + event_data = { + "id": instance.id, + "title": instance.title + } + + # Delete the event + super().delete(instance, **kwargs) + + # Track the deletion + if user_id := kwargs.get('user_id'): + self.activity_service.track_activity( + user_id=user_id, + action="event_deleted", + details=event_data + ) + + # Notify participants about cancellation + if notify_participants := kwargs.get('notify_participants'): + for participant in instance.participants.all(): + self.email_service.send_event_notification( + event_data={ + **event_data, + "message": "Event has been cancelled" + }, + recipient_email=participant.email + ) +``` + +Creating `EventModelController` with `EventModelService` as the service. +```python +from ninja_extra.controllers import ModelEndpointFactory, ModelControllerBase, ModelConfig +from ninja_extra import api_controller + +@api_controller("/events") +class EventModelController(ModelControllerBase): + service_type = EventModelService + model_config = ModelConfig(model=Event, allowed_routes=['find_one', 'list']) + + create_new_event = ModelEndpointFactory.create( + path="/?organizer_email=str", + schema_in=model_config.create_schema, + schema_out=model_config.retrieve_schema, + custom_handler=lambda self, data, **kw: self.service.create(data, **kw) + ) + + update_event = ModelEndpointFactory.update( + path="/{int:event_id}/?notify_participants=str", + lookup_param="event_id", + schema_in=model_config.update_schema, + schema_out=model_config.retrieve_schema, + object_getter=lambda self, pk, **kw: self.get_object_or_exception(self.model_config.model, pk=pk), + custom_handler=lambda self, **kw: self.service.update(**kw), + ) + +``` +Register the services in the injector module +```python +from injector import Module, singleton + + +class EventModule(Module): + def configure(self, binder): + binder.bind(EmailService, to=EmailService, scope=singleton) + binder.bind(UserActivityService, to=UserActivityService, scope=singleton) + +## settings.py +```python +NINJA_EXTRA = { + 'INJECTOR_MODULES': [ + 'your_app.injector_module.EventModule' + ] +} +``` + +The injected services provide several benefits: + +- Automatic email notifications when events are created/updated/deleted +- User activity tracking for audit trails +- Clean separation of business logic +- Easy to extend with additional services +- Testable components with clear dependencies + +For more information on dependency injection, please refer to the [Dependency Injection](service_module_injector.md) page. diff --git a/docs/api_controller/model_controller/04_parameters.md b/docs/api_controller/model_controller/04_parameters.md new file mode 100644 index 00000000..95f16652 --- /dev/null +++ b/docs/api_controller/model_controller/04_parameters.md @@ -0,0 +1,226 @@ +# Path and Query Parameters + +Model Controllers in Ninja Extra provide flexible ways to handle path and query parameters in your API endpoints when using the `ModelEndpointFactory`. This guide covers how to work with these parameters effectively. + +> **Note:** This guide is only useful if you are using a Custom `ModelService` and you are not interested in adding additional logic to the route handler. + +## **Basic Path Parameters** + +Path parameters are part of the URL path and are typically used to identify specific resources: + +```python +from ninja_extra import ModelEndpointFactory, ModelControllerBase + +@api_controller("/events") +class EventModelController(ModelControllerBase): + # Basic path parameter for event ID + get_event = ModelEndpointFactory.find_one( + path="/{int:id}", # int converter for ID + lookup_param="id", + schema_out=EventSchema + ) +``` + +`lookup_param` is the name of the parameter in the model that will be used to lookup the object. + +## **Path Parameter Types** + +The following parameter types are supported: + +```python +@api_controller("/events") +class EventModelController(ModelControllerBase): + # Integer parameter + get_by_id = ModelEndpointFactory.find_one( + path="/{int:id}", + lookup_param="id", + schema_out=EventSchema + ) + + # String parameter + get_by_slug = ModelEndpointFactory.find_one( + path="/{str:slug}", + lookup_param="slug", + schema_out=EventSchema + ) + + # UUID parameter + get_by_uuid = ModelEndpointFactory.find_one( + path="/{uuid:uuid}", + lookup_param="uuid", + schema_out=EventSchema + ) + + # Date parameter + get_by_date = ModelEndpointFactory.find_one( + path="/{date:event_date}", + lookup_param="event_date", + schema_out=EventSchema + ) +``` + +## **Query Parameters** + +Query parameters are added to the URL after the `?` character and are useful for filtering, sorting, and pagination: + +```python +from typing import Optional +from ninja_extra import ModelEndpointFactory + +@api_controller("/events") +class EventModelController(ModelControllerBase): + # Endpoint with query parameters + list_events = ModelEndpointFactory.list( + path="/?category=int&status=str", # Define query parameters + schema_out=EventSchema, + queryset_getter=lambda self, **kwargs: self.get_filtered_events(**kwargs) + ) + + def get_filtered_events(self, category: Optional[int] = None, + status: Optional[str] = None, **kwargs): + queryset = self.model.objects.all() + + if category: + queryset = queryset.filter(category_id=category) + if status: + queryset = queryset.filter(status=status) + + return queryset +``` + +## **Combining Path and Query Parameters** + +You can combine both types of parameters in a single endpoint: + +```python +class EventQueryParamsModelService(ModelService): + def get_category_events( + self, + category_id: int, + status: Optional[str] = None, + date: Optional[date] = None, + **kwargs + ): + queryset = self.model.objects.filter(category_id=category_id) + if status: + queryset = queryset.filter(status=status) + if date: + queryset = queryset.filter(start_date=date) + return queryset + +@api_controller("/events") +class EventModelController(ModelControllerBase): + service = EventQueryParamsModelService(model=Event) + # Path and query parameters together + get_category_events = ModelEndpointFactory.list( + path="/{int:category_id}/events?status=str&date=date", + schema_out=EventSchema, + queryset_getter=lambda self, **kwargs: self.service.get_category_events(**kwargs) + ) +``` + +## **Custom Parameter Handling** + +You can implement custom parameter handling using object getters: + +```python +class CustomParamsModelService(ModelService): + def get_by_slug(self, slug: str) -> Event: + return self.model.objects.get(slug=slug) + + +@api_controller("/events") +class EventModelController(ModelControllerBase): + service = CustomParamsModelService(model=Event) + get_event = ModelEndpointFactory.find_one( + path="/{str:slug}", + lookup_param="slug", + schema_out=EventSchema, + object_getter=lambda self, slug, **kwargs: self.service.get_by_slug(slug) + ) +``` + +## **Async Parameter Handling** + +For async controllers, parameter handling works similarly: + +```python + +class AsyncCustomParamsModelService(ModelService): + async def get_filtered_events(self, **kwargs): + @sync_to_async + def get_events(): + queryset = self.model.objects.all() + + if kwargs.get('category'): + queryset = queryset.filter(category_id=kwargs['category']) + if kwargs.get('status'): + queryset = queryset.filter(status=kwargs['status']) + + return queryset + + return await get_events() + +@api_controller("/events") +class AsyncEventModelController(ModelControllerBase): + service_type = AsyncCustomParamsModelService + model_config = ModelConfig( + model=Event, + async_routes=True + ) + + list_events = ModelEndpointFactory.list( + path="/?category=int&status=str", + schema_out=EventSchema, + queryset_getter=lambda self, **kwargs: self.service.get_filtered_events(**kwargs) + ) + +``` + +## **Parameter Validation** + +You can add validation to your parameters using Pydantic models: + +```python +from datetime import date +from typing import Optional +from pydantic import BaseModel, Field + +class EventQueryParams(BaseModel): + category_id: Optional[int] = None + status: Optional[str] = Field(None, pattern="^(active|inactive|draft)$") + date_from: Optional[date] = None + date_to: Optional[date] = None + + +class EventQueryParamsModelService(ModelService): + def get_filtered_events(self, params: EventQueryParams): + queryset = self.model.objects.all() + + if params.category_id: + queryset = queryset.filter(category_id=params.category_id) + if params.status: + queryset = queryset.filter(status=params.status) + if params.date_from: + queryset = queryset.filter(start_date__gte=params.date_from) + if params.date_to: + queryset = queryset.filter(end_date__lte=params.date_to) + + return queryset + + +@api_controller("/events") +class EventModelController(ModelControllerBase): + service_type = EventQueryParamsModelService + model_config = ModelConfig( + model=Event, + async_routes=True + ) + + list_events = ModelEndpointFactory.list( + path="/", + schema_in=EventQueryParams, + schema_out=EventSchema, + queryset_getter=lambda self, query: self.service.get_filtered_events(query) + ) +``` diff --git a/mkdocs.yml b/mkdocs.yml index ae0f0f59..17370343 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,7 +37,11 @@ nav: - Index: api_controller/index.md - Controller Routes: api_controller/api_controller_route.md - Controller Permissions: api_controller/api_controller_permission.md - - Model Controller: api_controller/model_controller.md + - Model Controller: + - Getting Started: api_controller/model_controller/01_getting_started.md + - 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 - Usage: - Quick Tutorial: tutorial/index.md - Authentication: tutorial/authentication.md diff --git a/ninja_extra/controllers/base.py b/ninja_extra/controllers/base.py index 80c29b7d..4b0a5744 100644 --- a/ninja_extra/controllers/base.py +++ b/ninja_extra/controllers/base.py @@ -1,6 +1,7 @@ import inspect import re import uuid +import warnings from abc import ABC from typing import ( TYPE_CHECKING, @@ -279,7 +280,11 @@ class SomeController(ControllerBase): ``` """ - service: ModelService + service_type: Type[ModelService] = ModelService + + def __init__(self, service: ModelService): + self.service = service + model_config: Optional[ModelConfig] = None @@ -419,13 +424,23 @@ def __call__(self, cls: ControllerClassType) -> ControllerClassType: if issubclass(cls, ModelControllerBase): if cls.model_config: + assert ( + cls.service_type is not None + ), "service_type is required for ModelControllerBase" # if model_config is not provided, treat controller class as normal builder = ModelControllerBuilder(cls, self) builder.register_model_routes() # We create a global service for handle CRUD Operations at class level # giving room for it to be changed at instance level through Dependency injection - if not hasattr(cls, "service"): - cls.service = ModelService(cls.model_config.model) + if hasattr(cls, "service"): + warnings.warn( + "ModelControllerBase.service is deprecated. " + "Use ModelControllerBase.service_type instead.", + DeprecationWarning, + stacklevel=2, + ) + # if not hasattr(cls, "service"): + # cls.service = ModelService(cls.model_config.model) compute_api_route_function(cls, self) diff --git a/ninja_extra/controllers/model/schemas.py b/ninja_extra/controllers/model/schemas.py index 06805da5..cc0e40d2 100644 --- a/ninja_extra/controllers/model/schemas.py +++ b/ninja_extra/controllers/model/schemas.py @@ -104,7 +104,7 @@ class ModelConfig(PydanticModel): list_route_info: t.Dict = {} # extra @get('/') information delete_route_info: t.Dict = {} # extra @delete() information - @field_validator("allowed_routes") + @field_validator("allowed_routes", mode="before") def validate_allow_routes(cls, value: t.List[t.Any]) -> t.Any: defaults = ["create", "find_one", "update", "patch", "delete", "list"] for item in value: diff --git a/ninja_extra/controllers/route/route_functions.py b/ninja_extra/controllers/route/route_functions.py index 639524b4..6c732609 100644 --- a/ninja_extra/controllers/route/route_functions.py +++ b/ninja_extra/controllers/route/route_functions.py @@ -120,12 +120,28 @@ def _process_view_function_result(self, result: Any) -> Any: return result def _get_controller_instance(self) -> "ControllerBase": + from ninja_extra.controllers.base import ModelControllerBase + injector = get_injector() _api_controller = self.get_api_controller() + additional_kwargs = {} - controller_instance: "ControllerBase" = injector.create_object( - _api_controller.controller_class + if issubclass(_api_controller.controller_class, ModelControllerBase): + controller_klass = cast( + ModelControllerBase, _api_controller.controller_class + ) + # make sure model_config is not None + if controller_klass.model_config is not None: + service = injector.create_object( + controller_klass.service_type, + additional_kwargs={"model": controller_klass.model_config.model}, + ) + additional_kwargs.update({"service": service}) + + controller_instance = injector.create_object( + _api_controller.controller_class, additional_kwargs=additional_kwargs ) + return controller_instance def get_route_execution_context( diff --git a/ninja_extra/pagination/__init__.py b/ninja_extra/pagination/__init__.py index 82da7ac2..e611ad14 100644 --- a/ninja_extra/pagination/__init__.py +++ b/ninja_extra/pagination/__init__.py @@ -1,6 +1,6 @@ from ninja.pagination import LimitOffsetPagination, PageNumberPagination, PaginationBase -from ninja_extra.schemas import PaginatedResponseSchema +from ninja_extra.schemas import NinjaPaginationResponseSchema, PaginatedResponseSchema from .decorator import paginate from .models import PageNumberPaginationExtra @@ -15,4 +15,5 @@ "PaginatedResponseSchema", "PaginatorOperation", "AsyncPaginatorOperation", + "NinjaPaginationResponseSchema", ] diff --git a/tests/test_api_instance.py b/tests/test_api_instance.py index c5d94090..092900d4 100644 --- a/tests/test_api_instance.py +++ b/tests/test_api_instance.py @@ -45,7 +45,11 @@ def test_api_auto_discover_controller(): ) as mock_register_controllers: ninja_extra_api.auto_discover_controllers() assert mock_register_controllers.call_count == 2 - assert "" in ControllerRegistry.get_controllers() + + assert ( + "" + in ControllerRegistry.get_controllers() + ) @api_controller class SomeAPI2Controller: diff --git a/tests/test_model_controller/async_samples.py b/tests/test_model_controller/async_samples.py index 425987f5..c6d447c0 100644 --- a/tests/test_model_controller/async_samples.py +++ b/tests/test_model_controller/async_samples.py @@ -7,7 +7,6 @@ ModelControllerBase, ModelPagination, ModelSchemaConfig, - ModelService, api_controller, ) from ninja_extra.schemas import NinjaPaginationResponseSchema @@ -67,14 +66,18 @@ class AsyncEventModelControllerRetrieveAndList(ModelControllerBase): @api_controller("/event-custom") -class AsyncEventController(ModelService): - def __init__(self): - ModelService.__init__(self, model=Event) +class AsyncEventController(ModelControllerBase): + model_config = ModelConfig( + model=Event, + allowed_routes=[], + ) create_event = ModelAsyncEndpointFactory.create( schema_in=CreateEventSchema, schema_out=EventSchema, - custom_handler=lambda self, schema, **kw: self.create_async(schema, **kw), + custom_handler=lambda self, schema, **kw: self.service.create_async( + schema, **kw + ), ) update_event = ModelAsyncEndpointFactory.update( @@ -82,8 +85,8 @@ def __init__(self): lookup_param="event_id", schema_in=CreateEventSchema, schema_out=EventSchema, - object_getter=lambda self, pk, **kw: self.get_one_async(pk), - custom_handler=lambda self, **kw: self.update_async(**kw), + object_getter=lambda self, pk, **kw: self.service.get_one_async(pk), + custom_handler=lambda self, **kw: self.service.update_async(**kw), ) patch_event = ModelAsyncEndpointFactory.patch( @@ -91,15 +94,15 @@ def __init__(self): lookup_param="event_id", schema_in=CreateEventSchema, schema_out=EventSchema, - object_getter=lambda self, pk, **kw: self.get_one_async(pk), - custom_handler=lambda self, **kw: self.patch_async(**kw), + object_getter=lambda self, pk, **kw: self.service.get_one_async(pk), + custom_handler=lambda self, **kw: self.service.patch_async(**kw), ) retrieve_event = ModelAsyncEndpointFactory.find_one( path="/{int:event_id}", lookup_param="event_id", schema_out=EventSchema, - object_getter=lambda self, pk, **kw: self.get_one_async(pk), + object_getter=lambda self, pk, **kw: self.service.get_one_async(pk), ) list_events = ModelAsyncEndpointFactory.list( @@ -119,6 +122,6 @@ def __init__(self): delete_event = ModelAsyncEndpointFactory.delete( path="/{int:event_id}", lookup_param="event_id", - object_getter=lambda self, pk, **kw: self.get_one_async(pk), - custom_handler=lambda self, **kw: self.delete_async(**kw), + object_getter=lambda self, pk, **kw: self.service.get_one_async(pk), + custom_handler=lambda self, **kw: self.service.delete_async(**kw), ) diff --git a/tests/test_model_controller/model_service_with_sample.py b/tests/test_model_controller/model_service_with_sample.py new file mode 100644 index 00000000..fbc7619b --- /dev/null +++ b/tests/test_model_controller/model_service_with_sample.py @@ -0,0 +1,39 @@ +from typing import Any + +from injector import inject +from pydantic import BaseModel + +from ninja_extra import ModelService +from ninja_extra.controllers.base import ModelControllerBase, api_controller +from ninja_extra.controllers.model.schemas import ModelConfig + +from ..models import Event + + +class LoggingService: + def __init__(self): + pass + + def log(self, message: str): + print(message) + + +class EventModelService(ModelService): + """ + EventModelService is a custom model service that allows for logging of events. + """ + + @inject + def __init__(self, model: Event, logging_service: LoggingService): + super().__init__(model=model) + self.logging_service = logging_service + + def create(self, schema: BaseModel, **kwargs: Any) -> Any: + self.logging_service.log("Creating event") + return super().create(schema, **kwargs) + + +@api_controller("/events") +class EventModelController(ModelControllerBase): + service_type = EventModelService + model_config = ModelConfig(model=Event) diff --git a/tests/test_model_controller/samples.py b/tests/test_model_controller/samples.py index 21371ce5..255e18a7 100644 --- a/tests/test_model_controller/samples.py +++ b/tests/test_model_controller/samples.py @@ -7,7 +7,6 @@ ModelEndpointFactory, ModelPagination, ModelSchemaConfig, - ModelService, api_controller, ) from ninja_extra.schemas import NinjaPaginationResponseSchema @@ -74,14 +73,16 @@ class EventModelControllerRetrieveAndList(ModelControllerBase): @api_controller("/event-custom") -class EventController(ModelService): - def __init__(self): - ModelService.__init__(self, model=Event) +class EventController(ModelControllerBase): + model_config = ModelConfig( + model=Event, + allowed_routes=[], + ) create_event = ModelEndpointFactory.create( schema_in=CreateEventSchema, schema_out=EventSchema, - custom_handler=lambda self, schema, **kw: self.create(schema, **kw), + custom_handler=lambda self, schema, **kw: self.service.create(schema, **kw), ) update_event = ModelEndpointFactory.update( @@ -90,7 +91,7 @@ def __init__(self): schema_in=CreateEventSchema, schema_out=EventSchema, object_getter=lambda self, pk, **kw: Event.objects.filter(id=pk).first(), - custom_handler=lambda self, **kw: self.update(**kw), + custom_handler=lambda self, **kw: self.service.update(**kw), ) patch_event = ModelEndpointFactory.patch( @@ -99,7 +100,7 @@ def __init__(self): schema_in=CreateEventSchema, schema_out=EventSchema, object_getter=lambda self, pk, **kw: Event.objects.filter(id=pk).first(), - custom_handler=lambda self, **kw: self.patch(**kw), + custom_handler=lambda self, **kw: self.service.patch(**kw), ) retrieve_event = ModelEndpointFactory.find_one( @@ -121,5 +122,5 @@ def __init__(self): path="/{int:event_id}", lookup_param="event_id", object_getter=lambda self, pk, **kw: Event.objects.filter(id=pk).first(), - custom_handler=lambda self, **kw: self.delete(**kw), + custom_handler=lambda self, **kw: self.service.delete(**kw), ) diff --git a/tests/test_model_controller/test_model_service_operation.py b/tests/test_model_controller/test_model_service_operation.py new file mode 100644 index 00000000..f4e14b3f --- /dev/null +++ b/tests/test_model_controller/test_model_service_operation.py @@ -0,0 +1,33 @@ +import pytest + +from ninja_extra.testing import TestClient + +from .model_service_with_sample import EventModelController + + +@pytest.mark.django_db +def test_model_service_injection(): + client = TestClient(EventModelController) + # POST + res = client.post( + "/", + json={ + "start_date": "2020-01-01", + "end_date": "2020-01-02", + "title": "Testing ModelService Injection", + }, + ) + assert res.status_code == 201 + data = res.json() + + res = client.get(f"/{data['id']}") + data = res.json() + + data.pop("id") + assert data == { + "end_date": "2020-01-02", + "start_date": "2020-01-01", + "title": "Testing ModelService Injection", + "category": None, + } + assert res.status_code == 200