django-ninja-aio-crud 0.10.2__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ninja_aio/views/api.py ADDED
@@ -0,0 +1,582 @@
1
+ from typing import List
2
+
3
+ from ninja import NinjaAPI, Router, Schema, Path, Query
4
+ from ninja.constants import NOT_SET
5
+ from ninja.pagination import paginate, AsyncPaginationBase, PageNumberPagination
6
+ from django.http import HttpRequest
7
+ from django.db.models import Model, QuerySet
8
+ from django.conf import settings
9
+ from pydantic import create_model
10
+
11
+ from ninja_aio.schemas.helpers import ModelQuerySetSchema, QuerySchema, DecoratorsSchema
12
+
13
+ from ninja_aio.models import ModelSerializer, ModelUtil
14
+ from ninja_aio.schemas import (
15
+ GenericMessageSchema,
16
+ M2MRelationSchema,
17
+ )
18
+ from ninja_aio.helpers.api import ManyToManyAPI
19
+ from ninja_aio.types import ModelSerializerMeta, VIEW_TYPES
20
+ from ninja_aio.decorators import unique_view, decorate_view
21
+ from ninja_aio.models import serializers
22
+
23
+ ERROR_CODES = frozenset({400, 401, 404})
24
+
25
+
26
+ class API:
27
+ api: NinjaAPI = None
28
+ router_tag: str = ""
29
+ router_tags: list[str] = []
30
+ api_route_path: str = ""
31
+ auth: list | None = NOT_SET
32
+ router: Router = None
33
+
34
+ def views(self):
35
+ """
36
+ Override this method to add your custom views. For example:
37
+ @self.router.get(some_path, response=some_schema)
38
+ async def some_method(request, *args, **kwargs):
39
+ pass
40
+
41
+ You can add views just doing:
42
+
43
+ @self.router.get(some_path, response=some_schema)
44
+ async def some_method(request, *args, **kwargs):
45
+ pass
46
+
47
+ @self.router.post(some_path, response=some_schema)
48
+ async def some_method(request, *args, **kwargs):
49
+ pass
50
+
51
+ If you provided a list of auths you can chose which of your views
52
+ should be authenticated:
53
+
54
+ AUTHENTICATED VIEW:
55
+
56
+ @self.router.get(some_path, response=some_schema, auth=self.auth)
57
+ async def some_method(request, *args, **kwargs):
58
+ pass
59
+
60
+ NOT AUTHENTICATED VIEW:
61
+
62
+ @self.router.post(some_path, response=some_schema)
63
+ async def some_method(request, *args, **kwargs):
64
+ pass
65
+ """
66
+ pass
67
+
68
+ def _add_views(self):
69
+ for name in dir(self.__class__):
70
+ method = getattr(self.__class__, name)
71
+ if hasattr(method, "_api_register"):
72
+ method._api_register(self)
73
+
74
+ def add_views_to_route(self):
75
+ return self.api.add_router(f"{self.api_route_path}", self._add_views())
76
+
77
+
78
+ class APIView(API):
79
+ """
80
+ Base class to register custom, non-CRUD endpoints on a Ninja Router.
81
+
82
+ Usage:
83
+ from ninja_aio.decorations import api_get
84
+
85
+ @api.view(prefix="/custom", tags=["Custom"])
86
+ class CustomAPIView(APIView):
87
+ @api_get("/hello", response=SomeSchema)
88
+ async def hello(request):
89
+ return SomeSchema(...)
90
+
91
+ or
92
+
93
+ class CustomAPIView(APIView):
94
+ api = api
95
+ api_route_path = "/custom"
96
+ router_tags = ["Custom"]
97
+
98
+ def views(self):
99
+ @self.router.get("/hello", response=SomeSchema)
100
+ async def hello(request):
101
+ return SomeSchema(...)
102
+
103
+
104
+ CustomAPIView().add_views_to_route()
105
+
106
+ Attributes:
107
+ api: NinjaAPI instance used to mount the router.
108
+ router_tag: Single tag used if router_tags is not provided.
109
+ router_tags: List of tags assigned to the router.
110
+ api_route_path: Base path where the router is mounted.
111
+ auth: Default auth list or NOT_SET for unauthenticated endpoints.
112
+ router: Router instance where views are registered.
113
+ error_codes: Common error codes returned by endpoints.
114
+
115
+ Overridable methods:
116
+ views(): Register your endpoints using self.router.get/post/patch/delete.
117
+ """
118
+
119
+ def __init__(
120
+ self, api: NinjaAPI = None, prefix: str = None, tags: list[str] = None
121
+ ) -> None:
122
+ self.api = api or self.api
123
+ self.api_route_path = prefix or self.api_route_path
124
+ self.router_tags = tags or self.router_tags or [self.router_tag]
125
+ self.router = Router(tags=self.router_tags)
126
+ self.error_codes = ERROR_CODES
127
+
128
+ def _add_views(self):
129
+ super()._add_views()
130
+ self.views()
131
+ return self.router
132
+
133
+
134
+ class APIViewSet(API):
135
+ """
136
+ Base viewset generating async CRUD + optional M2M endpoints for a Django model.
137
+
138
+ Usage:
139
+ @api.viewset(model=MyModel)
140
+ class MyModelViewSet(APIViewSet):
141
+ pass
142
+
143
+ or
144
+
145
+ class MyModelViewSet(APIViewSet):
146
+ model = MyModel
147
+ api = api
148
+ MyModelViewSet().add_views_to_route()
149
+
150
+ Automatic schema generation:
151
+ If model is a ModelSerializer (subclass of ModelSerializerMeta),
152
+ read/create/update schemas are auto-generated from its serializers.
153
+ Otherwise provide schema_in / schema_out / schema_update manually.
154
+
155
+ Generated endpoints (unless disabled via `disable`):
156
+ POST / -> create_view (201, schema_out)
157
+ GET / -> list_view (200, List[schema_out] paginated)
158
+ GET /{pk} -> retrieve_view (200, schema_out)
159
+ PATCH /{pk}/ -> update_view (200, schema_out)
160
+ DELETE /{pk}/ -> delete_view (204)
161
+
162
+ M2M endpoints (per entry in m2m_relations) if enabled:
163
+ GET /{pk}/{related_path} -> list related objects (paginated)
164
+ POST /{pk}/{related_path}/ -> add/remove related objects (depending on m2m_add / m2m_remove)
165
+
166
+ M2M filters:
167
+ Each M2MRelationSchema may define a filters dict:
168
+ filters = { "field_name": (type, default) }
169
+ A dynamic Pydantic Filters schema is generated and exposed as query params
170
+ on the related GET endpoint: /{pk}/{related_path}?field_name=value.
171
+ To apply custom filter logic implement an hook named:
172
+ <related_name>_query_params_handler(self, queryset, filters_dict)
173
+ It receives the initial related queryset and the validated/dumped filters
174
+ dict, and must return the (optionally) filtered queryset.
175
+
176
+ Example:
177
+ @api.viewset(model=models.User)
178
+ class UserViewSet(APIViewSet):
179
+ m2m_relations = [
180
+ M2MRelationSchema(
181
+ model=models.Tag,
182
+ related_name="tags",
183
+ filters={
184
+ "name": (str, "")
185
+ }
186
+ )
187
+ ]
188
+
189
+ def tags_query_params_handler(self, queryset, filters):
190
+ name_filter = filters.get("name")
191
+ if name_filter:
192
+ queryset = queryset.filter(name__icontains=name_filter)
193
+ return queryset
194
+
195
+ If filters is empty or omitted no query params are added for that relation.
196
+
197
+ Attribute summary:
198
+ model: Django model or ModelSerializer.
199
+ api: NinjaAPI instance.
200
+ schema_in / schema_out / schema_update: Pydantic schemas (auto when ModelSerializer).
201
+ auth: Default auth list or NOT_SET (no auth). Verb specific auth: get_auth, post_auth, patch_auth, delete_auth.
202
+ pagination_class: AsyncPaginationBase subclass (default PageNumberPagination).
203
+ query_params: Dict[str, (type, default)] to build a dynamic filters schema for list_view.
204
+ disable: List of view type strings: 'create','list','retrieve','update','delete','all'.
205
+ api_route_path: Base path; auto-resolved from verbose name if empty.
206
+ list_docs / create_docs / retrieve_docs / update_docs / delete_docs: Endpoint descriptions.
207
+ m2m_relations: List of M2MRelationSchema describing related model, related_name, custom path, auth, filters.
208
+ m2m_add / m2m_remove / m2m_get: Enable add/remove/get M2M operations.
209
+ m2m_auth: Auth list for all M2M endpoints unless overridden per relation.
210
+
211
+ Overridable hooks:
212
+ views(): Register extra custom endpoints on self.router.
213
+ query_params_handler(queryset, filters): Sync/Async hook to apply list filters.
214
+ <related_name>_query_params_handler(queryset, filters): Async hook for per-M2M filtering.
215
+
216
+ Error responses:
217
+ All endpoints may return GenericMessageSchema for codes in ERROR_CODES (400,401,404).
218
+
219
+ Internal:
220
+ Dynamic path/filter schemas built with pydantic.create_model.
221
+ unique_view decorator prevents duplicate registration.
222
+ """
223
+
224
+ model: ModelSerializer | Model
225
+ serializer_class: serializers.Serializer | None = None
226
+ schema_in: Schema | None = None
227
+ schema_out: Schema | None = None
228
+ schema_update: Schema | None = None
229
+ get_auth: list | None = NOT_SET
230
+ post_auth: list | None = NOT_SET
231
+ patch_auth: list | None = NOT_SET
232
+ delete_auth: list | None = NOT_SET
233
+ pagination_class: type[AsyncPaginationBase] = PageNumberPagination
234
+ query_params: dict[str, tuple[type, ...]] = {}
235
+ disable: list[type[VIEW_TYPES]] = []
236
+ list_docs = "List all objects."
237
+ create_docs = "Create a new object."
238
+ retrieve_docs = "Retrieve a specific object by its primary key."
239
+ update_docs = "Update an object by its primary key."
240
+ delete_docs = "Delete an object by its primary key."
241
+ m2m_relations: list[M2MRelationSchema] = []
242
+ m2m_auth: list | None = NOT_SET
243
+ extra_decorators: DecoratorsSchema = DecoratorsSchema()
244
+
245
+ def __init__(
246
+ self,
247
+ api: NinjaAPI = None,
248
+ model: Model | ModelSerializer = None,
249
+ prefix: str = None,
250
+ tags: list[str] = None,
251
+ ) -> None:
252
+ self.api = api or self.api
253
+ self.error_codes = ERROR_CODES
254
+ self.model = model or self.model
255
+ self.model_util = (
256
+ ModelUtil(self.model, serializer_class=self.serializer_class)
257
+ if not isinstance(self.model, ModelSerializerMeta)
258
+ else self.model.util
259
+ )
260
+ self.schema_out, self.schema_in, self.schema_update = self.get_schemas()
261
+ self.path_schema = self._generate_path_schema()
262
+ self.filters_schema = self._generate_filters_schema()
263
+ self.model_verbose_name = self.model._meta.verbose_name.capitalize()
264
+ self.router_tag = self.router_tag or self.model_verbose_name
265
+ self.router_tags = self.router_tags or tags or [self.router_tag]
266
+ self.router = Router(tags=self.router_tags)
267
+ self.append_slash = getattr(settings, "NINJA_AIO_APPEND_SLASH", True)
268
+ self.path = "/" if self.append_slash else ""
269
+ self.get_path = ""
270
+ self.get_path_retrieve = f"{{{self.model_util.model_pk_name}}}"
271
+ self.path_retrieve = (
272
+ f"{self.get_path_retrieve}/"
273
+ if self.append_slash
274
+ else self.get_path_retrieve
275
+ )
276
+ self.api_route_path = (
277
+ self.api_route_path
278
+ or prefix
279
+ or self.model_util.verbose_name_path_resolver()
280
+ )
281
+ self.m2m_api = (
282
+ None
283
+ if not self.m2m_relations
284
+ else ManyToManyAPI(relations=self.m2m_relations, view_set=self)
285
+ )
286
+
287
+ @property
288
+ def _crud_views(self):
289
+ """
290
+ Mapping of CRUD operation name to (response schema, view factory).
291
+ """
292
+ return {
293
+ "create": (self.schema_in, self.create_view),
294
+ "list": (self.schema_out, self.list_view),
295
+ "retrieve": (self.schema_out, self.retrieve_view),
296
+ "update": (self.schema_update, self.update_view),
297
+ "delete": (None, self.delete_view),
298
+ }
299
+
300
+ def _auth_view(self, view_type: str):
301
+ """
302
+ Resolve auth for a specific HTTP verb; falls back to self.auth if NOT_SET.
303
+ """
304
+ auth = getattr(self, f"{view_type}_auth", None)
305
+ return auth if auth is not NOT_SET else self.auth
306
+
307
+ def get_view_auth(self):
308
+ return self._auth_view("get")
309
+
310
+ def post_view_auth(self):
311
+ return self._auth_view("post")
312
+
313
+ def patch_view_auth(self):
314
+ return self._auth_view("patch")
315
+
316
+ def delete_view_auth(self):
317
+ return self._auth_view("delete")
318
+
319
+ def _generate_schema(self, fields: dict, name: str) -> Schema:
320
+ """
321
+ Dynamically build a Pydantic model for path / filter schemas.
322
+ """
323
+ return create_model(f"{self.model_util.model_name}{name}", **fields)
324
+
325
+ def _generate_path_schema(self):
326
+ """
327
+ Schema containing only the primary key field for path resolution.
328
+ """
329
+ return self._generate_schema(
330
+ {self.model_util.model_pk_name: self.model_util.pk_field_type}, "PathSchema"
331
+ )
332
+
333
+ def _generate_filters_schema(self):
334
+ """
335
+ Build filters schema from query_params definition.
336
+ """
337
+ return self._generate_schema(self.query_params, "FiltersSchema")
338
+
339
+ def _get_pk(self, data: Schema):
340
+ """
341
+ Extract pk from a path schema instance.
342
+ """
343
+ return data.model_dump()[self.model_util.model_pk_name]
344
+
345
+ def _get_query_data(self) -> ModelQuerySetSchema:
346
+ """
347
+ Return default query data for list/retrieve views.
348
+ """
349
+ return (
350
+ ModelQuerySetSchema()
351
+ if not isinstance(self.model, ModelSerializerMeta)
352
+ else self.model.query_util.read_config
353
+ )
354
+
355
+ def get_schemas(self):
356
+ """
357
+ Compute and return (schema_out, schema_in, schema_update).
358
+
359
+ - If model is a ModelSerializer (ModelSerializerMeta), auto-generate read/create/update schemas.
360
+ - Otherwise, use existing schemas or generate from serializer_class if provided.
361
+ """
362
+ # ModelSerializer case: prefer explicitly set schemas, otherwise generate from the model
363
+ if isinstance(self.model, ModelSerializerMeta):
364
+ return (
365
+ self.schema_out or self.model.generate_read_s(),
366
+ self.schema_in or self.model.generate_create_s(),
367
+ self.schema_update or self.model.generate_update_s(),
368
+ )
369
+
370
+ # Non-ModelSerializer: start from provided schemas
371
+ schema_out, schema_in, schema_update = (
372
+ self.schema_out,
373
+ self.schema_in,
374
+ self.schema_update,
375
+ )
376
+
377
+ # If a serializer_class is available, generate from it
378
+ if self.serializer_class:
379
+ schema_in = schema_in or self.serializer_class.generate_create_s()
380
+ schema_out = schema_out or self.serializer_class.generate_read_s()
381
+ schema_update = schema_update or self.serializer_class.generate_update_s()
382
+
383
+ return (schema_out, schema_in, schema_update)
384
+
385
+ async def query_params_handler(
386
+ self, queryset: QuerySet[ModelSerializer], filters: dict
387
+ ):
388
+ """
389
+ Override to apply custom filtering logic for list_view.
390
+ filters is already validated and dumped.
391
+ Return the (possibly modified) queryset.
392
+ """
393
+ return queryset
394
+
395
+ def create_view(self):
396
+ """
397
+ Register create endpoint.
398
+ """
399
+
400
+ @self.router.post(
401
+ self.path,
402
+ auth=self.post_view_auth(),
403
+ summary=f"Create {self.model._meta.verbose_name.capitalize()}",
404
+ description=self.create_docs,
405
+ response={201: self.schema_out, self.error_codes: GenericMessageSchema},
406
+ )
407
+ @decorate_view(unique_view(self), *self.extra_decorators.create)
408
+ async def create(request: HttpRequest, data: self.schema_in): # type: ignore
409
+ return 201, await self.model_util.create_s(request, data, self.schema_out)
410
+
411
+ return create
412
+
413
+ def list_view(self):
414
+ """
415
+ Register list endpoint with pagination and optional filters.
416
+ """
417
+
418
+ @self.router.get(
419
+ self.get_path,
420
+ auth=self.get_view_auth(),
421
+ summary=f"List {self.model._meta.verbose_name_plural.capitalize()}",
422
+ description=self.list_docs,
423
+ response={
424
+ 200: List[self.schema_out],
425
+ self.error_codes: GenericMessageSchema,
426
+ },
427
+ )
428
+ @decorate_view(
429
+ paginate(self.pagination_class),
430
+ unique_view(self, plural=True),
431
+ *self.extra_decorators.list,
432
+ )
433
+ async def list(
434
+ request: HttpRequest,
435
+ filters: Query[self.filters_schema] = None, # type: ignore
436
+ ):
437
+ qs = await self.model_util.get_objects(
438
+ request,
439
+ query_data=self._get_query_data(),
440
+ is_for_read=True,
441
+ )
442
+ if filters is not None:
443
+ qs = await self.query_params_handler(qs, filters.model_dump())
444
+ return await self.model_util.list_read_s(self.schema_out, request, qs)
445
+
446
+ return list
447
+
448
+ def retrieve_view(self):
449
+ """
450
+ Register retrieve endpoint.
451
+ """
452
+
453
+ @self.router.get(
454
+ self.get_path_retrieve,
455
+ auth=self.get_view_auth(),
456
+ summary=f"Retrieve {self.model._meta.verbose_name.capitalize()}",
457
+ description=self.retrieve_docs,
458
+ response={200: self.schema_out, self.error_codes: GenericMessageSchema},
459
+ )
460
+ @decorate_view(unique_view(self), *self.extra_decorators.retrieve)
461
+ async def retrieve(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
462
+ query_data = self._get_query_data()
463
+ return await self.model_util.read_s(
464
+ self.schema_out,
465
+ request,
466
+ query_data=QuerySchema(
467
+ getters={"pk": self._get_pk(pk)}, **query_data.model_dump()
468
+ ),
469
+ )
470
+
471
+ return retrieve
472
+
473
+ def update_view(self):
474
+ """
475
+ Register update endpoint.
476
+ """
477
+
478
+ @self.router.patch(
479
+ self.path_retrieve,
480
+ auth=self.patch_view_auth(),
481
+ summary=f"Update {self.model._meta.verbose_name.capitalize()}",
482
+ description=self.update_docs,
483
+ response={200: self.schema_out, self.error_codes: GenericMessageSchema},
484
+ )
485
+ @decorate_view(unique_view(self), *self.extra_decorators.update)
486
+ async def update(
487
+ request: HttpRequest,
488
+ data: self.schema_update, # type: ignore
489
+ pk: Path[self.path_schema], # type: ignore
490
+ ):
491
+ return await self.model_util.update_s(
492
+ request, data, self._get_pk(pk), self.schema_out
493
+ )
494
+
495
+ return update
496
+
497
+ def delete_view(self):
498
+ """
499
+ Register delete endpoint.
500
+ """
501
+
502
+ @self.router.delete(
503
+ self.path_retrieve,
504
+ auth=self.delete_view_auth(),
505
+ summary=f"Delete {self.model._meta.verbose_name.capitalize()}",
506
+ description=self.delete_docs,
507
+ response={204: None, self.error_codes: GenericMessageSchema},
508
+ )
509
+ @decorate_view(unique_view(self), *self.extra_decorators.delete)
510
+ async def delete(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
511
+ return 204, await self.model_util.delete_s(request, self._get_pk(pk))
512
+
513
+ return delete
514
+
515
+ def views(self):
516
+ """
517
+ Override to register custom non-CRUD endpoints on self.router.
518
+ Use auth=self.auth or verb specific auth attributes if needed.
519
+ """
520
+ pass
521
+
522
+ def _set_additional_views(self):
523
+ self.views()
524
+ if self.m2m_api is not None:
525
+ self.m2m_api._add_views()
526
+ return self.router
527
+
528
+ def _add_views(self):
529
+ """
530
+ Register CRUD (unless disabled), custom views, and M2M endpoints.
531
+ If 'all' in disable only CRUD is skipped; M2M + custom still added.
532
+ """
533
+ super()._add_views()
534
+ if "all" in self.disable:
535
+ return self._set_additional_views()
536
+
537
+ for views_type, (schema, view) in self._crud_views.items():
538
+ if views_type not in self.disable and (
539
+ schema is not None or views_type == "delete"
540
+ ):
541
+ view()
542
+
543
+ return self._set_additional_views()
544
+
545
+
546
+ class ReadOnlyViewSet(APIViewSet):
547
+ """
548
+ ReadOnly viewset generating async List + Retrieve endpoints for a Django model.
549
+
550
+ Usage:
551
+ @api.viewset(model=MyModel)
552
+ class MyModelReadOnlyViewSet(ReadOnlyViewSet):
553
+ pass
554
+
555
+ or
556
+
557
+ class MyModelReadOnlyViewSet(ReadOnlyViewSet):
558
+ model = MyModel
559
+ api = api
560
+ MyModelReadOnlyViewSet().add_views_to_route()
561
+ """
562
+
563
+ disable = ["create", "update", "delete"]
564
+
565
+
566
+ class WriteOnlyViewSet(APIViewSet):
567
+ """
568
+ WriteOnly viewset generating async Create + Update + Delete endpoints for a Django model.
569
+
570
+ Usage:
571
+ @api.viewset(model=MyModel)
572
+ class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
573
+ pass
574
+
575
+ or
576
+
577
+ class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
578
+ model = MyModel
579
+ api = api
580
+ """
581
+
582
+ disable = ["list", "retrieve"]