django-ninja-aio-crud 0.11.4__py3-none-any.whl → 1.0.1__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.py CHANGED
@@ -15,6 +15,7 @@ from .schemas import (
15
15
  M2MSchemaIn,
16
16
  M2MAddSchemaIn,
17
17
  M2MRemoveSchemaIn,
18
+ M2MRelationSchema,
18
19
  )
19
20
  from .types import ModelSerializerMeta, VIEW_TYPES
20
21
  from .decorators import unique_view
@@ -75,64 +76,86 @@ class APIView:
75
76
 
76
77
  class APIViewSet:
77
78
  """
78
- A base class for creating API views with CRUD operations.
79
-
80
- This class provides methods for creating, listing, retrieving, updating,
81
- and deleting objects of a specified model. It supports pagination,
82
- authentication, and custom query parameters.
83
-
84
- ## Attributes:
85
- - **model** (`ModelSerializer | Model`): The model for CRUD operations.
86
- - **api** (`NinjaAPI`): The API instance to which the views are added.
87
- - **schema_in** (`Schema | None`): Schema for input data in create/update operations.
88
- - **schema_out** (`Schema | None`): Schema for output data in list/retrieve operations.
89
- - **schema_update** (`Schema | None`): Schema for update operations.
90
- - **auth** (`list | None`): Authentication classes for the views.
91
- - **get_auth** (`list | None`): Authentication for GET requests.
92
- - **post_auth** (`list | None`): Authentication for POST requests.
93
- - **patch_auth** (`list | None`): Authentication for PATCH requests.
94
- - **delete_auth** (`list | None`): Authentication for DELETE requests.
95
- - **pagination_class** (`type[AsyncPaginationBase]`): Pagination class to use.
96
- - **query_params** (`dict[str, tuple[type, ...]]`): Query parameters for filtering.
97
- - **disable** (`list[type[VIEW_TYPES]]`): List of view types to disable.
98
- - **api_route_path** (`str`): Base path for the API route.
99
- - **list_docs** (`str`): Documentation for the list view.
100
- - **create_docs** (`str`): Documentation for the create view.
101
- - **retrieve_docs** (`str`): Documentation for the retrieve view.
102
- - **update_docs** (`str`): Documentation for the update view.
103
- - **delete_docs** (`str`): Documentation for the delete view.
104
- - **m2m_relations** (`list[tuple[ModelSerializer | Model, str, str, list]]`): Many-to-many relations to manage. Each tuple contains:
105
- - The related model.
106
- - The related name on the main model.
107
- - The name of the path, if not provided a default will be used.
108
- - The authentication for the m2m views.
109
- - **m2m_add** (`bool`): Enable add operation for M2M relations.
110
- - **m2m_remove** (`bool`): Enable remove operation for M2M relations.
111
- - **m2m_get** (`bool`): Enable get operation for M2M relations.
112
- - **m2m_auth** (`list | None`): Authentication for M2M views.
113
-
114
- ## Notes:
115
- If the model is a ModelSerializer instance, schemas are generated
116
- automatically based on Create, Read, and Update serializers.
117
- Override the `views` method to add custom views.
118
- Override the `query_params_handler` method to handle query params
119
- and return a filtered queryset.
120
-
121
- ## Methods:
122
- - **create_view**: Creates a new object.
123
- - **list_view**: Lists all objects.
124
- - **retrieve_view**: Retrieves an object by its primary key.
125
- - **update_view**: Updates an object by its primary key.
126
- - **delete_view**: Deletes an object by its primary key.
127
- - **views**: Override to add custom views.
128
- - **add_views_to_route**: Adds the views to the API route.
129
-
130
- ## Example:
131
- class MyModelViewSet(APIViewSet):
132
- model = MyModel # Your Django model
133
- api = my_api_instance # Your NinjaAPI instance
79
+ Base viewset generating async CRUD + optional M2M endpoints for a Django model.
134
80
 
81
+ Usage:
82
+ class MyModelViewSet(APIViewSet):
83
+ model = MyModel
84
+ api = api
135
85
  MyModelViewSet().add_views_to_route()
86
+
87
+ Automatic schema generation:
88
+ If model is a ModelSerializer (subclass of ModelSerializerMeta),
89
+ read/create/update schemas are auto-generated from its serializers.
90
+ Otherwise provide schema_in / schema_out / schema_update manually.
91
+
92
+ Generated endpoints (unless disabled via `disable`):
93
+ POST / -> create_view (201, schema_out)
94
+ GET / -> list_view (200, List[schema_out] paginated)
95
+ GET /{pk} -> retrieve_view (200, schema_out)
96
+ PATCH /{pk}/ -> update_view (200, schema_out)
97
+ DELETE /{pk}/ -> delete_view (204)
98
+
99
+ M2M endpoints (per entry in m2m_relations) if enabled:
100
+ GET /{pk}/{related_path} -> list related objects (paginated)
101
+ POST /{pk}/{related_path}/ -> add/remove related objects (depending on m2m_add / m2m_remove)
102
+
103
+ M2M filters:
104
+ Each M2MRelationSchema may define a filters dict:
105
+ filters = { "field_name": (type, default) }
106
+ A dynamic Pydantic Filters schema is generated and exposed as query params
107
+ on the related GET endpoint: /{pk}/{related_path}?field_name=value.
108
+ To apply custom filter logic implement an async hook named:
109
+ <related_name>_query_params_handler(self, queryset, filters_dict)
110
+ It receives the initial related queryset and the validated/dumped filters
111
+ dict, and must return the (optionally) filtered queryset.
112
+
113
+ Example:
114
+ class UserViewSet(APIViewSet):
115
+ model = models.User
116
+ m2m_relations = [
117
+ M2MRelationSchema(
118
+ model=models.Tag,
119
+ related_name="tags",
120
+ filters={
121
+ "name": (str, "")
122
+ }
123
+ )
124
+ ]
125
+
126
+ async def tags_query_params_handler(self, queryset, filters):
127
+ name_filter = filters.get("name")
128
+ if name_filter:
129
+ queryset = queryset.filter(name__icontains=name_filter)
130
+ return queryset
131
+
132
+ If filters is empty or omitted no query params are added for that relation.
133
+
134
+ Attribute summary:
135
+ model: Django model or ModelSerializer.
136
+ api: NinjaAPI instance.
137
+ schema_in / schema_out / schema_update: Pydantic schemas (auto when ModelSerializer).
138
+ auth: Default auth list or NOT_SET (no auth). Verb specific auth: get_auth, post_auth, patch_auth, delete_auth.
139
+ pagination_class: AsyncPaginationBase subclass (default PageNumberPagination).
140
+ query_params: Dict[str, (type, default)] to build a dynamic filters schema for list_view.
141
+ disable: List of view type strings: 'create','list','retrieve','update','delete','all'.
142
+ api_route_path: Base path; auto-resolved from verbose name if empty.
143
+ list_docs / create_docs / retrieve_docs / update_docs / delete_docs: Endpoint descriptions.
144
+ m2m_relations: List of M2MRelationSchema describing related model, related_name, custom path, auth, filters.
145
+ m2m_add / m2m_remove / m2m_get: Enable add/remove/get M2M operations.
146
+ m2m_auth: Auth list for all M2M endpoints unless overridden per relation.
147
+
148
+ Overridable hooks:
149
+ views(): Register extra custom endpoints on self.router.
150
+ query_params_handler(queryset, filters): Async hook to apply list filters.
151
+ <related_name>_query_params_handler(queryset, filters): Async hook for per-M2M filtering.
152
+
153
+ Error responses:
154
+ All endpoints may return GenericMessageSchema for codes in ERROR_CODES (400,401,404,428).
155
+
156
+ Internal:
157
+ Dynamic path/filter schemas built with pydantic.create_model.
158
+ unique_view decorator prevents duplicate registration.
136
159
  """
137
160
 
138
161
  model: ModelSerializer | Model
@@ -154,10 +177,7 @@ class APIViewSet:
154
177
  retrieve_docs = "Retrieve a specific object by its primary key."
155
178
  update_docs = "Update an object by its primary key."
156
179
  delete_docs = "Delete an object by its primary key."
157
- m2m_relations: list[tuple[ModelSerializer | Model, str, str, list]] = []
158
- m2m_add = True
159
- m2m_remove = True
160
- m2m_get = True
180
+ m2m_relations: list[M2MRelationSchema] = []
161
181
  m2m_auth: list | None = NOT_SET
162
182
 
163
183
  def __init__(self) -> None:
@@ -166,6 +186,7 @@ class APIViewSet:
166
186
  self.schema_out, self.schema_in, self.schema_update = self.get_schemas()
167
187
  self.path_schema = self._generate_path_schema()
168
188
  self.filters_schema = self._generate_filters_schema()
189
+ self.m2m_filters_schemas = self._generate_m2m_filters_schemas()
169
190
  self.model_verbose_name = self.model._meta.verbose_name.capitalize()
170
191
  self.router_tag = self.model_verbose_name
171
192
  self.router = Router(tags=[self.router_tag])
@@ -180,8 +201,7 @@ class APIViewSet:
180
201
  @property
181
202
  def _crud_views(self):
182
203
  """
183
- key: view type (create, list, retrieve, update, delete or all)
184
- value: tuple with schema and view method
204
+ Mapping of CRUD operation name to (response schema, view factory).
185
205
  """
186
206
  return {
187
207
  "create": (self.schema_in, self.create_view),
@@ -192,6 +212,9 @@ class APIViewSet:
192
212
  }
193
213
 
194
214
  def _auth_view(self, view_type: str):
215
+ """
216
+ Resolve auth for a specific HTTP verb; falls back to self.auth if NOT_SET.
217
+ """
195
218
  auth = getattr(self, f"{view_type}_auth", None)
196
219
  return auth if auth is not NOT_SET else self.auth
197
220
 
@@ -208,20 +231,47 @@ class APIViewSet:
208
231
  return self._auth_view("delete")
209
232
 
210
233
  def _generate_schema(self, fields: dict, name: str) -> Schema:
234
+ """
235
+ Dynamically build a Pydantic model for path / filter schemas.
236
+ """
211
237
  return create_model(f"{self.model_util.model_name}{name}", **fields)
212
238
 
213
239
  def _generate_path_schema(self):
240
+ """
241
+ Schema containing only the primary key field for path resolution.
242
+ """
214
243
  return self._generate_schema(
215
244
  {self.model_util.model_pk_name: (int | str, ...)}, "PathSchema"
216
245
  )
217
246
 
218
247
  def _generate_filters_schema(self):
248
+ """
249
+ Build filters schema from query_params definition.
250
+ """
219
251
  return self._generate_schema(self.query_params, "FiltersSchema")
220
252
 
253
+ def _generate_m2m_filters_schemas(self):
254
+ """
255
+ Build per-relation filters schemas for M2M endpoints.
256
+ """
257
+ return {
258
+ m2m_data.related_name: self._generate_schema(
259
+ {} if not m2m_data.filters else m2m_data.filters,
260
+ f"{self.model_util.model_name}{m2m_data.related_name.capitalize()}FiltersSchema",
261
+ )
262
+ for m2m_data in self.m2m_relations
263
+ }
264
+
221
265
  def _get_pk(self, data: Schema):
266
+ """
267
+ Extract pk from a path schema instance.
268
+ """
222
269
  return data.model_dump()[self.model_util.model_pk_name]
223
270
 
224
271
  def get_schemas(self):
272
+ """
273
+ Return (schema_out, schema_in, schema_update), generating them if model is a ModelSerializer.
274
+ """
225
275
  if isinstance(self.model, ModelSerializerMeta):
226
276
  return (
227
277
  self.model.generate_read_s(),
@@ -234,13 +284,17 @@ class APIViewSet:
234
284
  self, queryset: QuerySet[ModelSerializer], filters: dict
235
285
  ):
236
286
  """
237
- Override this method to handle request query params making queries to the database
238
- based on filters or any other logic. This method should return a queryset. filters
239
- are given already dumped by the schema.
287
+ Override to apply custom filtering logic for list_view.
288
+ filters is already validated and dumped.
289
+ Return the (possibly modified) queryset.
240
290
  """
241
291
  return queryset
242
292
 
243
293
  def create_view(self):
294
+ """
295
+ Register create endpoint.
296
+ """
297
+
244
298
  @self.router.post(
245
299
  self.path,
246
300
  auth=self.post_view_auth(),
@@ -255,6 +309,10 @@ class APIViewSet:
255
309
  return create
256
310
 
257
311
  def list_view(self):
312
+ """
313
+ Register list endpoint with pagination and optional filters.
314
+ """
315
+
258
316
  @self.router.get(
259
317
  self.get_path,
260
318
  auth=self.get_view_auth(),
@@ -288,6 +346,10 @@ class APIViewSet:
288
346
  return list
289
347
 
290
348
  def retrieve_view(self):
349
+ """
350
+ Register retrieve endpoint.
351
+ """
352
+
291
353
  @self.router.get(
292
354
  self.get_path_retrieve,
293
355
  auth=self.get_view_auth(),
@@ -303,6 +365,10 @@ class APIViewSet:
303
365
  return retrieve
304
366
 
305
367
  def update_view(self):
368
+ """
369
+ Register update endpoint.
370
+ """
371
+
306
372
  @self.router.patch(
307
373
  self.path_retrieve,
308
374
  auth=self.patch_view_auth(),
@@ -323,6 +389,10 @@ class APIViewSet:
323
389
  return update
324
390
 
325
391
  def delete_view(self):
392
+ """
393
+ Register delete endpoint.
394
+ """
395
+
326
396
  @self.router.delete(
327
397
  self.path_retrieve,
328
398
  auth=self.delete_view_auth(),
@@ -338,35 +408,8 @@ class APIViewSet:
338
408
 
339
409
  def views(self):
340
410
  """
341
- Override this method to add your custom views. For example:
342
- @self.router.get(some_path, response=some_schema)
343
- async def some_method(request, *args, **kwargs):
344
- pass
345
-
346
- You can add multilple views just doing:
347
-
348
- @self.router.get(some_path, response=some_schema)
349
- async def some_method(request, *args, **kwargs):
350
- pass
351
-
352
- @self.router.post(some_path, response=some_schema)
353
- async def some_method(request, *args, **kwargs):
354
- pass
355
-
356
- If you provided a list of auths you can chose which of your views
357
- should be authenticated:
358
-
359
- AUTHENTICATED VIEW:
360
-
361
- @self.router.get(some_path, response=some_schema, auth=self.auth)
362
- async def some_method(request, *args, **kwargs):
363
- pass
364
-
365
- NOT AUTHENTICATED VIEW:
366
-
367
- @self.router.post(some_path, response=some_schema)
368
- async def some_method(request, *args, **kwargs):
369
- pass
411
+ Override to register custom non-CRUD endpoints on self.router.
412
+ Use auth=self.auth or verb specific auth attributes if needed.
370
413
  """
371
414
 
372
415
  async def _check_m2m_objs(
@@ -377,6 +420,10 @@ class APIViewSet:
377
420
  related_manager: QuerySet,
378
421
  remove: bool = False,
379
422
  ):
423
+ """
424
+ Validate requested add/remove pk list for M2M operations.
425
+ Returns (errors, details, objects_to_process).
426
+ """
380
427
  errors, objs_detail, objs = [], [], []
381
428
  rel_objs = [rel_obj async for rel_obj in related_manager.select_related().all()]
382
429
  rel_model_name = model._meta.verbose_name.capitalize()
@@ -389,7 +436,7 @@ class APIViewSet:
389
436
  continue
390
437
  if remove ^ (rel_obj in rel_objs):
391
438
  errors.append(
392
- f"{rel_model_name} with id {obj_pk} is {'not ' if remove else ''} in {self.model_util.model_name}"
439
+ f"{rel_model_name} with id {obj_pk} is {'not ' if remove else ''}in {self.model_util.model_name}"
393
440
  )
394
441
  continue
395
442
  objs.append(rel_obj)
@@ -398,123 +445,144 @@ class APIViewSet:
398
445
  )
399
446
  return errors, objs_detail, objs
400
447
 
401
- def _m2m_views(self):
402
- for m2m_data in self.m2m_relations:
403
- m2m_auth = self.m2m_auth
404
- if len(m2m_data) == 3:
405
- model, related_name, m2m_path = m2m_data
406
- elif len(m2m_data) == 4:
407
- model, related_name, m2m_path, m2m_auth = m2m_data
408
- else:
409
- model, related_name = m2m_data
410
- m2m_path = ""
411
- rel_util = ModelUtil(model)
412
- rel_path = (
413
- rel_util.verbose_name_path_resolver()
414
- if not m2m_path
415
- else m2m_path
448
+ def _m2m_views(self, m2m_relation: M2MRelationSchema):
449
+ """
450
+ Register M2M get/manage endpoints for each relation in m2m_relations.
451
+ Supports optional per-relation filters and custom query handler:
452
+ <related_name>_query_params_handler.
453
+ """
454
+ model = m2m_relation.model
455
+ related_name = m2m_relation.related_name
456
+ m2m_auth = m2m_relation.auth or self.m2m_auth
457
+ rel_util = ModelUtil(model)
458
+ rel_path = (
459
+ rel_util.verbose_name_path_resolver()
460
+ if not m2m_relation.path
461
+ else m2m_relation.path
462
+ )
463
+ m2m_add = m2m_relation.add
464
+ m2m_remove = m2m_relation.remove
465
+ m2m_get = m2m_relation.get
466
+ filters_schema = self.m2m_filters_schemas.get(related_name)
467
+ if m2m_get:
468
+
469
+ @self.router.get(
470
+ f"{self.path_retrieve}{rel_path}",
471
+ response={
472
+ 200: List[model.generate_related_s(),],
473
+ self.error_codes: GenericMessageSchema,
474
+ },
475
+ auth=m2m_auth,
476
+ summary=f"Get {rel_util.model._meta.verbose_name_plural.capitalize()}",
477
+ description=f"Get all related {rel_util.model._meta.verbose_name_plural.capitalize()}",
416
478
  )
417
- if self.m2m_get:
418
-
419
- @self.router.get(
420
- f"{self.path_retrieve}{rel_path}",
421
- response={
422
- 200: List[model.generate_related_s(),],
423
- self.error_codes: GenericMessageSchema,
424
- },
425
- auth=m2m_auth,
426
- summary=f"Get {rel_util.model._meta.verbose_name_plural.capitalize()}",
427
- description=f"Get all related {rel_util.model._meta.verbose_name_plural.capitalize()}",
428
- )
429
- @unique_view(f"get_{self.model_util.model_name}_{rel_path}")
430
- @paginate(self.pagination_class)
431
- async def get_related(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
432
- obj = await self.model_util.get_object(request, self._get_pk(pk))
433
- related_manager = getattr(obj, related_name)
434
- related_qs = related_manager.all()
435
- related_objs = [
436
- await rel_util.read_s(
437
- request, rel_obj, model.generate_related_s()
479
+ @unique_view(f"get_{self.model_util.model_name}_{rel_path}")
480
+ @paginate(self.pagination_class)
481
+ async def get_related(
482
+ request: HttpRequest,
483
+ pk: Path[self.path_schema], # type: ignore
484
+ filters: Query[filters_schema] = None, # type: ignore
485
+ ):
486
+ obj = await self.model_util.get_object(request, self._get_pk(pk))
487
+ related_manager = getattr(obj, related_name)
488
+ related_qs = related_manager.all()
489
+ if (
490
+ filters is not None
491
+ and (
492
+ query_handler := getattr(
493
+ self,
494
+ f"{m2m_relation.related_name}_query_params_handler",
495
+ None,
438
496
  )
439
- async for rel_obj in related_qs
440
- ]
441
- return related_objs
442
-
443
- if self.m2m_add or self.m2m_remove:
444
- summary = f"{'Add or Remove' if self.m2m_add and self.m2m_remove else 'Add' if self.m2m_add else 'Remove'} {rel_util.model._meta.verbose_name_plural.capitalize()}"
445
- description = f"{'Add or remove' if self.m2m_add and self.m2m_remove else 'Add' if self.m2m_add else 'Remove'} {rel_util.model._meta.verbose_name_plural.capitalize()}"
446
- schema_in = (
447
- M2MSchemaIn
448
- if self.m2m_add and self.m2m_remove
449
- else M2MAddSchemaIn
450
- if self.m2m_add
451
- else M2MRemoveSchemaIn
452
- )
453
-
454
- @self.router.post(
455
- f"{self.path_retrieve}{rel_path}/",
456
- response={
457
- 200: M2MSchemaOut,
458
- self.error_codes: GenericMessageSchema,
459
- },
460
- auth=m2m_auth,
461
- summary=summary,
462
- description=description,
463
- )
464
- @unique_view(f"manage_{self.model_util.model_name}_{rel_path}")
465
- async def manage_related(
466
- request: HttpRequest,
467
- pk: Path[self.path_schema], # type: ignore
468
- data: schema_in, # type: ignore
497
+ )
498
+ is not None
469
499
  ):
470
- obj = await self.model_util.get_object(request, self._get_pk(pk))
471
- related_manager: QuerySet = getattr(obj, related_name)
472
- add_errors, add_details, add_objs = [], [], []
473
- remove_errors, remove_details, remove_objs = [], [], []
474
-
475
- if self.m2m_add and hasattr(data, "add"):
476
- (
477
- add_errors,
478
- add_details,
479
- add_objs,
480
- ) = await self._check_m2m_objs(
481
- request, data.add, model, related_manager
482
- )
483
- if self.m2m_remove and hasattr(data, "remove"):
484
- (
485
- remove_errors,
486
- remove_details,
487
- remove_objs,
488
- ) = await self._check_m2m_objs(
489
- request,
490
- data.remove,
491
- model,
492
- related_manager,
493
- remove=True,
494
- )
500
+ related_qs = await query_handler(related_qs, filters.model_dump())
501
+ related_objs = [
502
+ await rel_util.read_s(request, rel_obj, model.generate_related_s())
503
+ async for rel_obj in related_qs
504
+ ]
505
+ return related_objs
506
+
507
+ if m2m_add or m2m_remove:
508
+ summary = f"{'Add or Remove' if m2m_add and m2m_remove else 'Add' if m2m_add else 'Remove'} {rel_util.model._meta.verbose_name_plural.capitalize()}"
509
+ description = f"{'Add or remove' if m2m_add and m2m_remove else 'Add' if m2m_add else 'Remove'} {rel_util.model._meta.verbose_name_plural.capitalize()}"
510
+ schema_in = (
511
+ M2MSchemaIn
512
+ if m2m_add and m2m_remove
513
+ else M2MAddSchemaIn
514
+ if m2m_add
515
+ else M2MRemoveSchemaIn
516
+ )
495
517
 
496
- await asyncio.gather(
497
- related_manager.aadd(*add_objs),
498
- related_manager.aremove(*remove_objs),
518
+ @self.router.post(
519
+ f"{self.path_retrieve}{rel_path}/",
520
+ response={
521
+ 200: M2MSchemaOut,
522
+ self.error_codes: GenericMessageSchema,
523
+ },
524
+ auth=m2m_auth,
525
+ summary=summary,
526
+ description=description,
527
+ )
528
+ @unique_view(f"manage_{self.model_util.model_name}_{rel_path}")
529
+ async def manage_related(
530
+ request: HttpRequest,
531
+ pk: Path[self.path_schema], # type: ignore
532
+ data: schema_in, # type: ignore
533
+ ):
534
+ obj = await self.model_util.get_object(request, self._get_pk(pk))
535
+ related_manager: QuerySet = getattr(obj, related_name)
536
+ add_errors, add_details, add_objs = [], [], []
537
+ remove_errors, remove_details, remove_objs = [], [], []
538
+
539
+ if m2m_add and hasattr(data, "add"):
540
+ (
541
+ add_errors,
542
+ add_details,
543
+ add_objs,
544
+ ) = await self._check_m2m_objs(
545
+ request, data.add, model, related_manager
546
+ )
547
+ if m2m_remove and hasattr(data, "remove"):
548
+ (
549
+ remove_errors,
550
+ remove_details,
551
+ remove_objs,
552
+ ) = await self._check_m2m_objs(
553
+ request,
554
+ data.remove,
555
+ model,
556
+ related_manager,
557
+ remove=True,
499
558
  )
500
- results = add_details + remove_details
501
- errors = add_errors + remove_errors
502
-
503
- return {
504
- "results": {
505
- "count": len(results),
506
- "details": results,
507
- },
508
- "errors": {
509
- "count": len(errors),
510
- "details": errors,
511
- },
512
- }
559
+ await asyncio.gather(
560
+ related_manager.aadd(*add_objs),
561
+ related_manager.aremove(*remove_objs),
562
+ )
563
+ results = add_details + remove_details
564
+ errors = add_errors + remove_errors
565
+
566
+ return {
567
+ "results": {
568
+ "count": len(results),
569
+ "details": results,
570
+ },
571
+ "errors": {
572
+ "count": len(errors),
573
+ "details": errors,
574
+ },
575
+ }
513
576
 
514
577
  def _add_views(self):
578
+ """
579
+ Register CRUD (unless disabled), custom views, and M2M endpoints.
580
+ If 'all' in disable only CRUD is skipped; M2M + custom still added.
581
+ """
515
582
  if "all" in self.disable:
516
583
  if self.m2m_relations:
517
- self._m2m_views()
584
+ for m2m_relation in self.m2m_relations:
585
+ self._m2m_views(m2m_relation)
518
586
  self.views()
519
587
  return self.router
520
588
 
@@ -526,8 +594,12 @@ class APIViewSet:
526
594
 
527
595
  self.views()
528
596
  if self.m2m_relations:
529
- self._m2m_views()
597
+ for m2m_relation in self.m2m_relations:
598
+ self._m2m_views(m2m_relation)
530
599
  return self.router
531
600
 
532
601
  def add_views_to_route(self):
602
+ """
603
+ Attach router with registered endpoints to the NinjaAPI instance.
604
+ """
533
605
  return self.api.add_router(f"{self.api_route_path}", self._add_views())