diff --git a/README.md b/README.md index b61afa3..9ea9271 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ # Lazy Ninja -**Lazy Ninja** is a Django library that simplifies the creation of CRUD API endpoints using Django Ninja. It dynamically scans your Django models and generates Pydantic schemas for listing, retrieving, creating, and updating records. The library also allows you to customize behavior through hook functions (controllers) and schema configurations. +**Lazy Ninja** is a Django library that simplifies the generation of API endpoints using Django Ninja. It dynamically scans your Django models and generates Pydantic schemas for listing, retrieving, creating, and updating records. The library also allows you to customize behavior through hook functions (controllers) and schema configurations. By leveraging Django Ninja, Lazy Ninja provides automatic, interactive API documentation via OpenAPI, making it easy to visualize and interact with your endpoints. -> **Note:** This pre-release (alpha) version supports only JSON data. `multipart/form-data` (file uploads) is not yet available. For `ImageField` or `FileField`, use the full URL as a string. - --- ## Installation @@ -47,7 +45,7 @@ Add `api.urls` to your `urls.py` to expose the endpoints. ## Features -- **Automatic CRUD Endpoints**: Instantly generate API routes for your Django models. +- **Automatic Endpoints**: Instantly generate API routes for your Django models. - **Dynamic Schema Generation**: Automatically create Pydantic schemas for your models. - **Custom Controllers**: Customize route behavior with hooks like `before_create` and `after_update`. - **Built-in Filtering, Sorting, and Pagination**: Simplify data handling with query parameters. @@ -60,7 +58,7 @@ Add `api.urls` to your `urls.py` to expose the endpoints. - [x] Basic CRUD operations - [x] Asynchronous support - [x] Filtering, sorting, and pagination -- [ ] File upload support +- [X] File upload support - [ ] Authentication and RBAC - [ ] Advanced model relationships diff --git a/docs/docs/en/index.md b/docs/docs/en/index.md index be5509c..d0fe252 100644 --- a/docs/docs/en/index.md +++ b/docs/docs/en/index.md @@ -1,13 +1,10 @@ -# Lazy Ninja: Generate CRUD API Endpoints for Django +# Lazy Ninja: Generate API Endpoints for Django [![PyPI version](https://badge.fury.io/py/lazy-ninja.svg)](https://badge.fury.io/py/lazy-ninja) [![Downloads](https://static.pepy.tech/personalized-badge/lazy-ninja?period=total&units=international_system&left_color=grey&right_color=blue&left_text=Downloads)](https://pepy.tech/project/lazy-ninja) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ->**Note:** This version supports only JSON data. `multipart/form-data` (file uploads) is not yet available. For `ImageField` or `FileField`, use the full URL as a string - - -**Lazy Ninja** is a Django library that automates the generation of CRUD API endpoints with Django Ninja. It dynamically scans your Django models and creates Pydantic schemas for listing, detailing, creating, and updating records—all while allowing you to customize behavior via hook functions (controllers) and schema configurations. +**Lazy Ninja** is a Django library that automates the generation of API endpoints with Django Ninja. It dynamically scans your Django models and creates Pydantic schemas for listing, detailing, creating, and updating records—all while allowing you to customize behavior via hook functions (controllers) and schema configurations. By leveraging Django Ninja, Lazy Ninja benefits from automatic, interactive API documentation generated through OpenAPI (Swagger UI and ReDoc), giving developers an intuitive interface to quickly visualize and interact with API endpoints. @@ -15,7 +12,7 @@ By leveraging Django Ninja, Lazy Ninja benefits from automatic, interactive API **Key Features:** -- **Instant CRUD Endpoints:** Automatically generate API endpoints from Django models. +- **Instant API Endpoints:** Automatically generate API endpoints from Django models. - **Dynamic Schema Generation:** Automatically create Pydantic models. - **Customizable Hooks:** Add pre-processing, post-processing, or custom logic to routes by creating controllers for specific models. - **Smart Filtering/Sorting:** Built-in support for filters like `field=value` or `field>value`. @@ -96,7 +93,7 @@ python manage.py runserver ## Instant API Endpoints -Your CRUD API is now live at `http://localhost:8000/api` with these endpoints: +Your API is now live at `http://localhost:8000/api` with these endpoints: | Method | URL | Action | | ------ | -------------------| ---------------| @@ -281,5 +278,77 @@ api = DynamicAPI(api, pagination_type="page-number") Alternatively, set NINJA_PAGINATION_CLASS in settings.py to override the default globally. -### License +--- +## File Upload Support + +Lazy Ninja now supports handling file uploads for models with `FileField` and `ImageField` using `multipart/form-data`. This feature allows you to define which fields should use `multipart/form-data` and provides flexibility to handle mixed models where some routes use JSON while others use `multipart/form-data`. + +### How to Use File Upload Parameters + +When initializing `DynamicAPI`, you can configure the following parameters: + +- **`file_fields`**: Specify which fields in a model should use `multipart/form-data`. +- **`use_multipart`**: Explicitly define whether `create` and `update` operations for specific models should use `multipart/form-data`. +- **`auto_multipart`**: Automatically detect file fields in models and enable `multipart/form-data` for them (default: `True`). +- **`auto_detect_files`**: Automatically detect `FileField` and `ImageField` in models (default: `True`). +- **`auto_multipart`**: Automatically enable `multipart/form-data` for models with detected file fields (default: `True`). + +### Example Usage +```python +from ninja import NinjaAPI +from lazy_ninja.builder import DynamicAPI + +api = NinjaAPI() + +auto_api = DynamicAPI( + api, + is_async=True, + file_fields={"Gallery": ["images"], "Product": ["pimages"]}, # Specify file fields + use_multipart={ + "Product": { + "create": True, # Use multipart/form-data for creation + "update": True # Use multipart/form-data for updates + } + }, + auto_detect_files=True, # Automatically detect file fields in models + auto_multipart=True # Automatically enable multipart/form-data for detected file fields +) + +auto_api.register_all_models() +``` + +In this example: +- The `Gallery` model will use `multipart/form-data` for the `images` field. +- The `Product` model will use `multipart/form-data` for the `pimages` field during `create` and `update` operations. +- Models without file fields will continue to use JSON by default. + +--- + +## Custom Middleware for PUT/PATCH with Multipart + +To handle `PUT` and `PATCH` requests with `multipart/form-data`, Lazy Ninja includes a custom middleware: `ProcessPutPatchMiddleware`. This middleware ensures that `PUT` and `PATCH` requests are processed correctly by temporarily converting them to `POST` for form data handling. + +### Why This Middleware is Needed +Django has a known limitation where `PUT` and `PATCH` requests with `multipart/form-data` are not processed correctly. While Django Ninja recently introduced updates to address this, certain scenarios still require a custom solution. This middleware resolves those issues and ensures compatibility with both synchronous and asynchronous request handlers. + +### How to Use the Middleware +Add the middleware to your Django project's `MIDDLEWARE` setting: +```python +MIDDLEWARE = [ + ... + 'lazy_ninja.middleware.ProcessPutPatchMiddleware', + ... +] +``` + +### How It Works +- Converts `PUT` and `PATCH` requests with `multipart/form-data` to `POST` temporarily for proper processing. +- Restores the original HTTP method after processing the request. + +--- + +## Contributing & Feedback +Lazy Ninja is still evolving — contributions, suggestions, and feedback are more than welcome! Feel free to open issues, discuss ideas, or submit PRs. + +## License This project is licensed under the terms of the MIT license. \ No newline at end of file diff --git a/src/lazy_ninja/__init__.py b/src/lazy_ninja/__init__.py index 368323a..cab9785 100644 --- a/src/lazy_ninja/__init__.py +++ b/src/lazy_ninja/__init__.py @@ -9,16 +9,7 @@ from .helpers import get_hook from .registry import ModelRegistry, controller_for from .routes import register_model_routes_internal -from .middleware import ErrorHandlingMiddleware - -__all__ = [ - 'ErrorHandlingMiddleware', - 'LazyNinjaError', - 'NotFoundError', - 'ValidationError', - 'DatabaseOperationError', - 'SynchronousOperationError', -] +from .file_upload import FileUploadConfig def register_model_routes( api: NinjaAPI, @@ -29,31 +20,37 @@ def register_model_routes( create_schema: Optional[Type[Schema]] = None, update_schema: Optional[Type[Schema]] = None, pagination_strategy: Optional[str] = None, + file_upload_config: Optional[FileUploadConfig] = None, + use_multipart_create: bool = False, + use_multipart_update: bool = False, is_async: bool = True ) -> None: """ Main function to register CRUD routes for a Django model using Django Ninja. - Parameters: - - api: Instance of NinjaAPI. - - model: The Django model class. - - base_url: Base URL for the resource endpoints. - - list_schema: Pydantic schema for listing objects. - - detail_schema: Pydantic schema for retrieving object details. - - create_schema: (Optional) Pydantic schema for creating an object. - - update_schema: (Optional) Pydantic schema for updating an object. + Args: + api: NinjaAPI instance. + model: Django model class. + base_url: Base URL for the routes. + list_schema: Schema for list responses. + detail_schema: Schema for detail responses. + create_schema: (Optional) Schema for create requests. + update_schema: (Optional) Schema for update requests. + pagination_strategy: (Optional) Strategy for pagination. + file_upload_config: (Optional) Configuration for file uploads. + use_multipart_create: Whether to use multipart/form-data for create endpoint. + use_multipart_update: Whether to use multipart/form-data for update endpoint. + is_async: Whether to use async routes (default: True). This function retrieves the registered controller for the model (if any) and passes its hooks to the internal route registration function. """ - # Retrieve the custom controller for the model; use BaseModelController if none is registered. ModelRegistry.discover_controllers() controller = ModelRegistry.get_controller(model.__name__) if not controller: controller = BaseModelController - # Call the internal function that sets up the router and registers all endpoints. register_model_routes_internal( api=api, model=model, @@ -71,5 +68,8 @@ def register_model_routes( after_delete=get_hook(controller, 'after_delete'), custom_response=get_hook(controller, 'custom_response'), pagination_strategy=pagination_strategy, + file_upload_config=file_upload_config, + use_multipart_create=use_multipart_create, + use_multipart_update=use_multipart_update, is_async=is_async ) \ No newline at end of file diff --git a/src/lazy_ninja/builder.py b/src/lazy_ninja/builder.py index 808fe49..1a3131e 100644 --- a/src/lazy_ninja/builder.py +++ b/src/lazy_ninja/builder.py @@ -12,6 +12,7 @@ from .utils import generate_schema from .helpers import to_kebab_case from .pagination import get_pagination_strategy +from .file_upload import FileUploadConfig, detect_file_fields p = inflect.engine() @@ -89,6 +90,10 @@ def __init__( schema_config: Optional[Dict[str, Dict[str, List[str]]]] = None, custom_schemas: Optional[Dict[str, Dict[str, Type[Schema]]]] = None, pagination_type: Optional[str] = None, + file_fields: Optional[Dict[str, List[str]]] = None, + auto_detect_files: bool = True, + auto_multipart: bool = True, + use_multipart: Optional[Dict[str, Dict[str, bool]]] = None, is_async: bool = True ): """ @@ -96,7 +101,6 @@ def __init__( Args: api: The NinjaAPI instance. - is_async: Whether to use async routes (default: True). exclude: Configuration for model/app exclusions. schema_config: Dictionary mapping model names to schema configurations (e.g., exclude fields and optional fields). @@ -106,7 +110,14 @@ def __init__( If a schema is not provided for a specific operation, the default generated schema will be used. pagination_type: Type of pagination to use ('limit-offset' or 'page-number'). If None, uses NINJA_PAGINATION_CLASS from settings. - + file_fields: Dictionary mapping model names to lists of file field names + (e.g., {"Product": ["image", "document"]}). + auto_detect_files: Whether to automatically detect file fields in models. + auto_multipart: Whether to automatically use multipart for models with file fields. + use_multipart: Dictionary specifying which models should use multipart/form-data + (e.g., {"Product": {"create": True, "update": True}}). + is_async: Whether to use async routes (default: True). + Pagination Configuration: The pagination can be configured in three ways (in order of precedence): 1. pagination_type parameter in DynamicAPI @@ -121,6 +132,15 @@ def __init__( self.custom_schemas = custom_schemas or {} self.is_async = is_async self.pagination_strategy = get_pagination_strategy(pagination_type=pagination_type) + + self.file_fields = file_fields or {} + self.use_multipart = use_multipart or {} + self.auto_detect_files = auto_detect_files + self.auto_multipart = auto_multipart + self.file_upload_config = FileUploadConfig( + file_fields=self.file_fields + ) + self._already_registered = False @staticmethod @@ -165,6 +185,29 @@ def _register_all_models_sync(self) -> None: create_schema = generate_schema(model, exclude=exclude_fields, optional_fields=optional_fields) update_schema = generate_schema(model, exclude=exclude_fields, optional_fields=optional_fields, update=True) + detected_single_file_fields = [] + detected_multiple_file_fields = [] + + if self.auto_detect_files: + detected_single_file_fields, detected_multiple_file_fields = detect_file_fields(model) + + model_file_fields = list(set(self.file_fields.get(model_name, []) + detected_single_file_fields)) + + if model_file_fields: + self.file_upload_config.file_fields[model_name] = model_file_fields + + if detected_multiple_file_fields: + existing = self.file_upload_config.multiple_file_fields.get(model_name, []) + self.file_upload_config.multiple_file_fields[model_name] = list(set(existing + detected_multiple_file_fields)) + + use_multipart_create = self.use_multipart.get(model_name, {}).get("create", False) + use_multipart_update = self.use_multipart.get(model_name, {}).get("update", False) + + has_any_file_fields = bool(model_file_fields) or bool(detected_multiple_file_fields) + if self.auto_multipart and has_any_file_fields: + use_multipart_create = True + use_multipart_update = True + register_model_routes( api=self.api, model=model, @@ -174,6 +217,9 @@ def _register_all_models_sync(self) -> None: create_schema=create_schema, update_schema=update_schema, pagination_strategy=self.pagination_strategy, + file_upload_config=self.file_upload_config if model_file_fields else None, + use_multipart_create=use_multipart_create, + use_multipart_update=use_multipart_update, is_async=getattr(self, 'is_async', True) ) diff --git a/src/lazy_ninja/file_upload.py b/src/lazy_ninja/file_upload.py new file mode 100644 index 0000000..1bbff57 --- /dev/null +++ b/src/lazy_ninja/file_upload.py @@ -0,0 +1,100 @@ +from typing import Dict, List, Tuple + +from django.db import models + +class FileUploadConfig: + """ + Configuration for file upload fields in a model. + + This class helps configure which fields are file fields and + whether to use multipart/form-data for specific models. + """ + def __init__( + self, + file_fields: Dict[str, List[str]] = None, + multiple_file_fields: Dict[str, List[str]] = None, + ): + """ + Initialize file upload configuration. + + Args: + file_fields: Dictionary mapping model names to lists of file field names + e.g. {"MyModel": ["image", "attachment"]} + multiple_file_fields: Dictionary mapping model names to lists of field names + that can accept multiple files + e.g. {"MyModel": ["gallery_images"]} + """ + self.file_fields = file_fields or {} + self.multiple_file_fields = multiple_file_fields or {} + + def get_model_file_fields(self, model_name: str) -> List[str]: + """Get list of file fields for a model.""" + return self.file_fields.get(model_name, []) + + def get_model_multiple_file_fields(self, model_name: str) -> List[str]: + """Get list of multiple file fields for a model.""" + return self.multiple_file_fields.get(model_name, []) + + def is_multiple_file_field(self, model_name: str, field_name: str) -> bool: + """Check if a field is configured for multiple file uploads.""" + return field_name in self.get_model_multiple_file_fields(model_name) + + +def detect_file_fields(model) -> Tuple[List[str], List[str]]: + """ + Automatically detect file fields in a Django model. + + Returns a tuple of (single_file_fields, multiple_file_fields) + """ + single_file_fields = [] + multiple_file_fields = [] + + for field in model._meta.get_fields(): + if isinstance(field, (models.FileField, models.ImageField)): + single_file_fields.append(field.name) + + elif isinstance(field, models.ManyToManyField): + related_model = field.related_model + if related_model: + + for related_field in related_model._meta.get_fields(): + if isinstance(related_field, (models.FileField, models.ImageField)): + multiple_file_fields.append(field.name) + break + + elif isinstance(field, models.ManyToOneRel): + related_model = field.related_model + if related_model: + has_file_field = False + + for related_field in related_model._meta.get_fields(): + if isinstance(related_field, (models.FileField, models.ImageField)): + has_file_field = True + break + + if has_file_field: + multiple_file_fields.append(field.get_accessor_name()) + + elif isinstance(field, models.OneToOneField): + related_model = field.related_model + if related_model: + + for related_model in related_model._meta.get_fields(): + if isinstance(related_field, (models.FileField, models.ImageField)): + single_file_fields.append(field.name) + break + + elif isinstance(field, models.OneToOneRel): + related_model = field.related_model + if related_model: + has_file_field = False + + for related_field in related_model._meta.get_fields(): + if isinstance(related_field, (models.FileField, models.ImageField)): + has_file_field = True + break + + if has_file_field: + single_file_fields.append(field.get_accessor_name()) + + return single_file_fields, multiple_file_fields diff --git a/src/lazy_ninja/middleware/__init__.py b/src/lazy_ninja/middleware/__init__.py index 4d2ba99..3346e83 100644 --- a/src/lazy_ninja/middleware/__init__.py +++ b/src/lazy_ninja/middleware/__init__.py @@ -1 +1,2 @@ -from .error_handling import ErrorHandlingMiddleware \ No newline at end of file +from .error_handling import ErrorHandlingMiddleware +from .process_put_patch import ProcessPutPatchMiddleware \ No newline at end of file diff --git a/src/lazy_ninja/middleware/process_put_patch.py b/src/lazy_ninja/middleware/process_put_patch.py new file mode 100644 index 0000000..fe8e232 --- /dev/null +++ b/src/lazy_ninja/middleware/process_put_patch.py @@ -0,0 +1,33 @@ +from asgiref.sync import sync_to_async, iscoroutinefunction + +from django.http import HttpRequest +from django.utils.deprecation import MiddlewareMixin + +class ProcessPutPatchMiddleware(MiddlewareMixin): + """ + Middleware to handle PUT/PATCH requests as POST for form data processing + Works for both synchronous and asynchronous request handlers + """ + + def _core_processing(self, request: HttpRequest) -> None: + if request.method in ("PUT", "PATCH") and request.content_type != "application/json": + original_method = request.method + request.method = "POST" + request.META["REQUEST_METHOD"] = "POST" + + request._load_post_and_files() + + request.method = original_method + request.META["REQUEST_METHOD"] = original_method + + def __call__(self, request: HttpRequest): + if iscoroutinefunction(self.get_response): + async def async_handler(request): + await sync_to_async(self._core_processing)(request) + response = await self.get_response(request) + return response + return async_handler(request) + else: + self._core_processing(request) + response = self.get_response(request) + return response \ No newline at end of file diff --git a/src/lazy_ninja/routes.py b/src/lazy_ninja/routes.py index 47b78c7..6758614 100644 --- a/src/lazy_ninja/routes.py +++ b/src/lazy_ninja/routes.py @@ -2,9 +2,10 @@ from django.shortcuts import get_object_or_404 from django.db.models import Model, QuerySet +from django.db import models from asgiref.sync import sync_to_async -from ninja import Router, Schema, NinjaAPI +from ninja import Router, Schema, NinjaAPI, Form from ninja.pagination import paginate from .utils import ( @@ -19,6 +20,7 @@ from .pagination import BasePagination from .helpers import execute_hook, handle_response, apply_filters, apply_filters_async from .errors import handle_exception, handle_exception_async +from .file_upload import FileUploadConfig, detect_file_fields def register_model_routes_internal( @@ -38,6 +40,9 @@ def register_model_routes_internal( after_delete: Optional[Callable[[Any], None]] = None, custom_response: Optional[Callable[[Any, Any], Any]] = None, pagination_strategy: Optional[BasePagination] = None, + file_upload_config: Optional[FileUploadConfig] = None, + use_multipart_create: bool = False, + use_multipart_update: bool = False, is_async: bool = True ) -> None: """ @@ -111,50 +116,253 @@ async def get_item(request, item_id: int) -> Any: return await handle_exception_async(e) if create_schema: - @router.post("/", response=detail_schema, tags=[model.__name__], operation_id=f"create_{model_name}") - async def create_item(request, payload: create_schema) -> Any: #type: ignore - """Create a new object.""" - try: - if before_create: - payload = await execute_hook_async(before_create, request, payload, create_schema) or payload - - data = await convert_foreign_keys_async(model, payload.model_dump()) - - create_instance = sync_to_async(lambda m, **kwargs: m.objects.create(**kwargs)) - instance = await create_instance(model, **data) - - if after_create: - instance = await execute_hook_async(after_create, request, instance) or instance + if use_multipart_create: + @router.post("/", response=detail_schema, tags=[model.__name__], operation_id=f"create_{model_name}") + async def create_item(request, payload: create_schema = Form(...)) -> Any: #type: ignore + """Create a new object.""" + try: + if before_create: + payload = await execute_hook_async(before_create, request, payload, create_schema) or payload + + data = payload.model_dump() + file_fields_map: Dict[str, list] = {} + + if file_upload_config: + single_file_fields = file_upload_config.get_model_file_fields(model.__name__) + for field_name in single_file_fields: + if field_name in request.FILES: + data[field_name] = request.FILES[field_name] + + multiple_file_fields = file_upload_config.get_model_multiple_file_fields(model.__name__) + for field_name in multiple_file_fields: + files = request.FILES.getlist(field_name) + if files: + file_fields_map[field_name] = files + data.pop(field_name, None) + + data = await convert_foreign_keys_async(model, data) - return await handle_response_async(instance, detail_schema, custom_response, request) + create_instance = sync_to_async(lambda m, **kwargs: m.objects.create(**kwargs)) + instance = await create_instance(model, **data) + + if file_fields_map: + get_fields = sync_to_async(lambda m: m._meta.get_fields()) + model_fields = await get_fields(model) + + for field_name, files in file_fields_map.items(): + relation = next((f for f in model_fields if f.name == field_name), None) + if not relation: + continue + + if isinstance(relation, models.ManyToManyField): + target_model = relation.remote_field.model + elif isinstance(relation, models.ManyToOneRel): + target_model = relation.related_model + elif isinstance(relation, models.OneToOneField): + target_model = relation.related_model + elif isinstance(relation, models.OneToOneRel): + target_model = relation.related_model + else: + continue + + detect_files = sync_to_async(detect_file_fields) + single_file_fields, _ = await detect_files(target_model) + if not single_file_fields: + continue + + file_field = single_file_fields[0] + + if isinstance(relation, models.ManyToManyField): + manager = getattr(instance, field_name) + clear_manager = sync_to_async(manager.clear) + await clear_manager() + + created_objs = [] + for f in files: + create_related = sync_to_async(lambda model, **kwargs: model.objects.create(**kwargs)) + obj = await create_related(target_model, **{file_field: f}) + created_objs.append(obj) + + add_to_manager = sync_to_async(lambda mgr, objs: mgr.add(*objs)) + await add_to_manager(manager, created_objs) + + elif isinstance(relation, models.ManyToOneRel): + fk_name = relation.field.name + for f in files: + create_related = sync_to_async(lambda model, **kwargs: model.objects.create(**kwargs)) + await create_related(target_model, **{file_field: f, fk_name: instance}) + + elif isinstance(relation, models.OneToOneField): + if files: + create_related = sync_to_async(lambda model, **kwargs: model.objects.create(**kwargs)) + related_obj = await create_related(target_model, **{file_field: files[[0]]}) + + setattr(instance, field_name, related_obj) + save_instance = sync_to_async(lambda obj: obj.save()) + await save_instance(instance) + + elif isinstance(relation, models.OneToOneRel): + fk_name = relation.field.name + if files: + create_related = sync_to_async(lambda model, **kwargs: model.objects.create(**kwargs)) + await create_related(target_model, **{file_field: files[0], fk_name: instance}) + + if after_create: + instance = await execute_hook_async(after_create, request, instance) or instance + + return await handle_response_async(instance, detail_schema, custom_response, request) - except Exception as e: - return await handle_exception_async(e) + except Exception as e: + return await handle_exception_async(e) + else: + @router.post("/", response=detail_schema, tags=[model.__name__], operation_id=f"create_{model_name}") + async def create_item(request, payload: create_schema) -> Any: #type: ignore + """Create a new object.""" + try: + if before_create: + payload = await execute_hook_async(before_create, request, payload, create_schema) or payload + + data = await convert_foreign_keys_async(model, payload.model_dump()) + + create_instance = sync_to_async(lambda m, **kwargs: m.objects.create(**kwargs)) + instance = await create_instance(model, **data) + + if after_create: + instance = await execute_hook_async(after_create, request, instance) or instance + + return await handle_response_async(instance, detail_schema, custom_response, request) + + except Exception as e: + return await handle_exception_async(e) if update_schema: - @router.patch("/{item_id}", response=detail_schema, tags=[model.__name__], operation_id=f"update_{model_name}") - async def update_item(request, item_id: int, payload: update_schema) -> Any: #type: ignore - """Update an existing object.""" - try: - instance = await get_object_or_404_async(model, id=item_id) - - if before_update: - payload = await execute_hook_async(before_update, request, instance, payload, update_schema) or payload + if use_multipart_update: + @router.patch("/{item_id}", response=detail_schema, tags=[model.__name__], operation_id=f"update_{model_name}") + async def update_item(request, item_id: int, payload: update_schema = Form(...)) -> Any: #type: ignore + """Update an existing object.""" + try: + instance = await get_object_or_404_async(model, id=item_id) - data = await convert_foreign_keys_async(model, payload.model_dump(exclude_unset=True)) - - for key, value in data.items(): - setattr(instance, key, value) + if before_update: + payload = await execute_hook_async(before_update, request, instance, payload, update_schema) or payload + + data = payload.model_dump(exclude_unset=True) + file_fields_map: Dict[str, list] = {} + + if file_upload_config: + single_file_fields = file_upload_config.get_model_file_fields(model.__name__) + + for field_name in single_file_fields: + if field_name in request.FILES: + data[field_name] = request.FILES[field_name] + + multiple_file_fields = file_upload_config.get_model_multiple_file_fields(model.__name__) + for field_name in multiple_file_fields: + files = request.FILES.getlist(field_name) + if files: + file_fields_map[field_name] = files + data.pop(field_name, None) + + data = await convert_foreign_keys_async(model, data) + + for key, value in data.items(): + setattr(instance, key, value) + + save_instance = sync_to_async(lambda obj: obj.save()) + await save_instance(instance) + + if file_fields_map: + get_fields = sync_to_async(lambda m: m._meta.get_fields()) + model_fields = await get_fields(model) + + for field_name, files in file_fields_map.items(): + relation = next((f for f in model_fields if f.name == field_name), None) + if not relation: + continue + + if isinstance(relation, models.ManyToManyField): + target_model = relation.remote_field.model + elif isinstance(relation, models.ManyToOneRel): + target_model = relation.related_model + elif isinstance(relation, models.OneToOneField): + target_model = relation.related_model + elif isinstance(relation, models.OneToOneRel): + target_model = relation.related_model + else: + continue + + detect_files = sync_to_async(detect_file_fields) + single_file_fields, _ = await detect_files(target_model) + if not single_file_fields: + continue + + file_field = single_file_fields[0] + + if isinstance(relation, models.ManyToManyField): + manager = getattr(instance, field_name) + clear_manager = sync_to_async(manager.clear) + await clear_manager() + + created_objs = [] + for f in files: + create_related = sync_to_async(lambda model, **kwargs: model.objects.create(**kwargs)) + obj = await create_related(target_model, **{file_field: f}) + created_objs.append(obj) + + add_to_manager = sync_to_async(lambda mgr, objs: mgr.add(*objs)) + await add_to_manager(manager, created_objs) + + elif isinstance(relation, models.ManyToOneRel): + fk_name = relation.field.name + for f in files: + create_related = sync_to_async(lambda model, **kwargs: model.objects.create(**kwargs)) + await create_related(target_model, **{file_field: f, fk_name: instance}) + + elif isinstance(relation, models.OneToOneField): + if files: + create_related = sync_to_async(lambda model, **kwargs: model.objects.create(**kwargs)) + related_obj = await create_related(target_model, **{file_field: files[[0]]}) + + setattr(instance, field_name, related_obj) + save_instance = sync_to_async(lambda obj: obj.save()) + await save_instance(instance) + + elif isinstance(relation, models.OneToOneRel): + fk_name = relation.field.name + if files: + create_related = sync_to_async(lambda model, **kwargs: model.objects.create(**kwargs)) + await create_related(target_model, **{file_field: files[0], fk_name: instance}) + + if after_update: + instance = await execute_hook_async(after_update, request, instance) or instance + + return await handle_response_async(instance, detail_schema, custom_response, request) + except Exception as e: + return await handle_exception_async(e) + else: + @router.patch("/{item_id}", response=detail_schema, tags=[model.__name__], operation_id=f"update_{model_name}") + async def update_item(request, item_id: int, payload: update_schema) -> Any: #type: ignore + """Update an existing object.""" + try: + instance = await get_object_or_404_async(model, id=item_id) - save_instance = sync_to_async(lambda obj: obj.save()) - await save_instance(instance) - - if after_update: - instance = await execute_hook_async(after_update, request, instance) or instance + if before_update: + payload = await execute_hook_async(before_update, request, instance, payload, update_schema) or payload + + data = await convert_foreign_keys_async(model, payload.model_dump(exclude_unset=True)) + + for key, value in data.items(): + setattr(instance, key, value) + + save_instance = sync_to_async(lambda obj: obj.save()) + await save_instance(instance) - return await handle_response_async(instance, detail_schema, custom_response, request) - except Exception as e: - return await handle_exception_async(e) + if after_update: + instance = await execute_hook_async(after_update, request, instance) or instance + + return await handle_response_async(instance, detail_schema, custom_response, request) + except Exception as e: + return await handle_exception_async(e) @router.delete("/{item_id}", response={200: Dict[str, str]}, tags=[model.__name__], operation_id=f"delete_{model_name}") async def delete_item(request, item_id: int) -> Dict[str, str]: @@ -174,7 +382,6 @@ async def delete_item(request, item_id: int) -> Dict[str, str]: return {"message": f"{model.__name__} with ID {item_id} has been deleted"} except Exception as e: return await handle_exception_async(e) - else: @router.get("/", response=List[list_schema], tags=[model.__name__], operation_id=f"list_{model_name}") @paginate(paginator_class) @@ -201,39 +408,208 @@ def get_item(request, item_id: int) -> Any: return handle_exception(e) if create_schema: - @router.post("/", response=detail_schema, tags=[model.__name__], operation_id=f"create_{model_name}") - def create_item(request, payload: create_schema) -> Any: #type: ignore - """Create a new object.""" - try: - if before_create: - payload = execute_hook(before_create, request, payload, create_schema) or payload - data = convert_foreign_keys(model, payload.model_dump()) - instance = model.objects.create(**data) - if after_create: - instance = execute_hook(after_create, request, instance) or instance - return handle_response(instance, detail_schema, custom_response, request) - except Exception as e: - return handle_exception(e) + if use_multipart_create: + @router.post("/", response=detail_schema, tags=[model.__name__], operation_id=f"create_{model_name}") + def create_item(request, payload: create_schema = Form(...)) -> Any: #type: ignore + """Create a new object.""" + try: + if before_create: + payload = execute_hook(before_create, request, payload, create_schema) or payload + + data = payload.model_dump() + file_fields_map: Dict[str, list] = {} + + if file_upload_config: + single_file_fields = file_upload_config.get_model_file_fields(model.__name__) + for field_name in single_file_fields: + if field_name in request.FILES: + data[field_name] = request.FILES[field_name] + + multiple_file_fields = file_upload_config.get_model_multiple_file_fields(model.__name__) + for field_name in multiple_file_fields: + files = request.FILES.getlist(field_name) + if files: + file_fields_map[field_name] = files + data.pop(field_name, None) - if update_schema: - @router.patch("/{item_id}", response=detail_schema, tags=[model.__name__], operation_id=f"update_{model_name}") - def update_item(request, item_id: int, payload: update_schema) -> Any: #type: ignore - """Update an existing object.""" - try: - instance = get_object_or_404(model, id=item_id) - if before_update: - payload = execute_hook(before_update, request, instance, payload, update_schema) or payload - data = convert_foreign_keys(model, payload.model_dump(exclude_unset=True)) - - for key, value in data.items(): - setattr(instance, key, value) - instance.save() + data = convert_foreign_keys(model, data) + + instance = model.objects.create(**data) + + for field_name, files in file_fields_map.items(): + relation = next((f for f in model._meta.get_fields() if f.name == field_name), None) + if not relation: + continue + + if isinstance(relation, models.ManyToManyField): + target_model = relation.remote_field.model + elif isinstance(relation, models.ManyToOneRel): + target_model = relation.related_model + elif isinstance(relation, models.OneToOneField): + target_model = relation.related_model + elif isinstance(relation, models.OneToOneRel): + target_model = relation.related_model + else: + continue + + single_file_fields, _ = detect_file_fields(target_model) + if not single_file_fields: + continue + + file_field = single_file_fields[0] + + if isinstance(relation, models.ManyToManyField): + manager = getattr(instance, field_name) + manager.clear() + created_objs = [] + for f in files: + obj = target_model.objects.create(**{file_field: f}) + created_objs.append(obj) + manager.add(*created_objs) + + elif isinstance(relation, models.ManyToOneRel): + fk_name = relation.field.name + for f in files: + target_model.objects.create(**{file_field: f, fk_name: instance}) + + elif isinstance(relation, models.OneToOneField): + if files: + related_obj = target_model.objects.create(**{file_field: files[0]}) + setattr(instance, field_name, related_obj) + instance.save() + + elif isinstance(relation, models.OneToOneRel): + fk_name = relation.field.name + if files: + target_model.objects.create(**{file_field: files[0], fk_name: instance}) + + if after_create: + instance = execute_hook(after_create, request, instance) or instance + + return handle_response(instance, detail_schema, custom_response, request) - if after_update: - instance = execute_hook(after_update, request, instance) or instance - return handle_response(instance, detail_schema, custom_response, request) - except Exception as e: - return handle_exception(e) + except Exception as e: + return handle_exception(e) + else: + @router.post("/", response=detail_schema, tags=[model.__name__], operation_id=f"create_{model_name}") + def create_item(request, payload: create_schema) -> Any: #type: ignore + """Create a new object.""" + try: + if before_create: + payload = execute_hook(before_create, request, payload, create_schema) or payload + data = convert_foreign_keys(model, payload.model_dump()) + instance = model.objects.create(**data) + if after_create: + instance = execute_hook(after_create, request, instance) or instance + return handle_response(instance, detail_schema, custom_response, request) + except Exception as e: + return handle_exception(e) + + if update_schema: + if use_multipart_update: + @router.patch("/{item_id}", response=detail_schema, tags=[model.__name__], operation_id=f"update_{model_name}") + def update_item(request, item_id: int, payload: update_schema = Form(...)) -> Any: #type: ignore + """Update an existing object.""" + try: + instance = get_object_or_404(model, id=item_id) + + if before_update: + payload = execute_hook(before_update, request, instance, payload, update_schema) or payload + + data = payload.model_dump(exclude_unset=True) + file_fields_map: Dict[str, list] = {} + + if file_upload_config: + single_file_fields = file_upload_config.get_model_file_fields(model.__name__) + for field_name in single_file_fields: + if field_name in request.FILES: + data[field_name] = request.FILES[field_name] + + multiple_file_fields = file_upload_config.get_model_multiple_file_fields(model.__name__) + for field_name in multiple_file_fields: + files = request.FILES.getlist(field_name) + if files: + file_fields_map[field_name] = files + data.pop(field_name, None) + + data = convert_foreign_keys(model, data) + + for key, value in data.items(): + setattr(instance, key, value) + instance.save() + + for field_name, files in file_fields_map.items(): + relation = next((f for f in model._meta.get_fields() if f.name == field_name), None) + if not relation: + continue + + if isinstance(relation, models.ManyToManyField): + target_model = relation.remote_field.model + elif isinstance(relation, models.ManyToOneRel): + target_model = relation.related_model + elif isinstance(relation, models.OneToOneField): + target_model = relation.related_model + elif isinstance(relation, models.OneToOneRel): + target_model = relation.related_model + else: + continue + + single_file_fields, _ = detect_file_fields(target_model) + if not single_file_fields: + continue + + file_field = single_file_fields[0] + + if isinstance(relation, models.ManyToManyField): + manager = getattr(instance, field_name) + manager.clear() + created_objs = [] + for f in files: + obj = target_model.objects.create(**{file_field: f}) + created_objs.append(obj) + manager.add(*created_objs) + + elif isinstance(relation, models.ManyToOneRel): + fk_name = relation.field.name + for f in files: + target_model.objects.create(**{file_field: f, fk_name: instance}) + + elif isinstance(relation, models.OneToOneField): + if files: + related_obj = target_model.objects.create(**{file_field: files[0]}) + setattr(instance, field_name, related_obj) + instance.save() + + elif isinstance(relation, models.OneToOneRel): + fk_name = relation.field.name + if files: + target_model.objects.create(**{file_field: files[0], fk_name: instance}) + + if after_update: + instance = execute_hook(after_update, request, instance) or instance + return handle_response(instance, detail_schema, custom_response, request) + except Exception as e: + return handle_exception(e) + + else: + @router.patch("/{item_id}", response=detail_schema, tags=[model.__name__], operation_id=f"update_{model_name}") + def update_item(request, item_id: int, payload: update_schema) -> Any: #type: ignore + """Update an existing object.""" + try: + instance = get_object_or_404(model, id=item_id) + if before_update: + payload = execute_hook(before_update, request, instance, payload, update_schema) or payload + data = convert_foreign_keys(model, payload.model_dump(exclude_unset=True)) + + for key, value in data.items(): + setattr(instance, key, value) + instance.save() + + if after_update: + instance = execute_hook(after_update, request, instance) or instance + return handle_response(instance, detail_schema, custom_response, request) + except Exception as e: + return handle_exception(e) @router.delete("/{item_id}", response={200: Dict[str, str]}, tags=[model.__name__], operation_id=f"delete_{model_name}") def delete_item(request, item_id: int) -> Dict[str, str]: