django-ninja-aio-crud 0.11.3__tar.gz → 1.0.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 0.11.3
3
+ Version: 1.0.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "0.11.3"
3
+ __version__ = "1.0.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -0,0 +1,67 @@
1
+ from typing import Optional, Type
2
+
3
+ from ninja import Schema
4
+ from .models import ModelSerializer
5
+ from django.db.models import Model
6
+ from pydantic import BaseModel, RootModel, ConfigDict
7
+
8
+
9
+ class GenericMessageSchema(RootModel[dict[str, str]]):
10
+ root: dict[str, str]
11
+
12
+
13
+ class M2MDetailSchema(Schema):
14
+ count: int
15
+ details: list[str]
16
+
17
+
18
+ class M2MSchemaOut(Schema):
19
+ errors: M2MDetailSchema
20
+ results: M2MDetailSchema
21
+
22
+
23
+ class M2MAddSchemaIn(Schema):
24
+ add: list = []
25
+
26
+
27
+ class M2MRemoveSchemaIn(Schema):
28
+ remove: list = []
29
+
30
+
31
+ class M2MSchemaIn(Schema):
32
+ add: list = []
33
+ remove: list = []
34
+
35
+
36
+ class M2MRelationSchema(BaseModel):
37
+ """
38
+ Configuration schema for declaring a Many-to-Many relation in the API.
39
+
40
+ Attributes:
41
+ model (Type[ModelSerializer] | Type[Model]): Target model class or its serializer.
42
+ related_name (str): Name of the relationship field on the Django model.
43
+ add (bool): Enable adding related objects (default True).
44
+ remove (bool): Enable removing related objects (default True).
45
+ get (bool): Enable retrieving related objects (default True).
46
+ path (str | None): Optional custom URL path segment (None/"" => auto-generated).
47
+ auth (list | None): Optional list of authentication backends for the endpoints.
48
+ filters (dict[str, tuple] | None): Field name -> (type, default) pairs for query filtering.
49
+
50
+ Example:
51
+ M2MRelationSchema(
52
+ model=BookSerializer,
53
+ related_name="authors",
54
+ filters={"country": ("str", '')}
55
+ )
56
+ """
57
+
58
+ model: Type[ModelSerializer] | Type[Model]
59
+ related_name: str
60
+ add: bool = True
61
+ remove: bool = True
62
+ get: bool = True
63
+ path: Optional[str] = ""
64
+ auth: Optional[list] = None
65
+ filters: Optional[dict[str, tuple]] = None
66
+
67
+ model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -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,60 +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** (`tuple[ModelSerializer | Model, str]`): Many-to-many relations to manage.
105
- - **m2m_add** (`bool`): Enable add operation for M2M relations.
106
- - **m2m_remove** (`bool`): Enable remove operation for M2M relations.
107
- - **m2m_get** (`bool`): Enable get operation for M2M relations.
108
- - **m2m_auth** (`list | None`): Authentication for M2M views.
109
-
110
- ## Notes:
111
- If the model is a ModelSerializer instance, schemas are generated
112
- automatically based on Create, Read, and Update serializers.
113
- Override the `views` method to add custom views.
114
- Override the `query_params_handler` method to handle query params
115
- and return a filtered queryset.
116
-
117
- ## Methods:
118
- - **create_view**: Creates a new object.
119
- - **list_view**: Lists all objects.
120
- - **retrieve_view**: Retrieves an object by its primary key.
121
- - **update_view**: Updates an object by its primary key.
122
- - **delete_view**: Deletes an object by its primary key.
123
- - **views**: Override to add custom views.
124
- - **add_views_to_route**: Adds the views to the API route.
125
-
126
- ## Example:
127
- class MyModelViewSet(APIViewSet):
128
- model = MyModel # Your Django model
129
- api = my_api_instance # Your NinjaAPI instance
79
+ Base viewset generating async CRUD + optional M2M endpoints for a Django model.
130
80
 
81
+ Usage:
82
+ class MyModelViewSet(APIViewSet):
83
+ model = MyModel
84
+ api = api
131
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.
132
159
  """
133
160
 
134
161
  model: ModelSerializer | Model
@@ -150,12 +177,8 @@ class APIViewSet:
150
177
  retrieve_docs = "Retrieve a specific object by its primary key."
151
178
  update_docs = "Update an object by its primary key."
152
179
  delete_docs = "Delete an object by its primary key."
153
- m2m_relations: list[tuple[ModelSerializer | Model, str]] = []
154
- m2m_add = True
155
- m2m_remove = True
156
- m2m_get = True
180
+ m2m_relations: list[M2MRelationSchema] = []
157
181
  m2m_auth: list | None = NOT_SET
158
- m2m_path: str = ""
159
182
 
160
183
  def __init__(self) -> None:
161
184
  self.error_codes = ERROR_CODES
@@ -163,6 +186,7 @@ class APIViewSet:
163
186
  self.schema_out, self.schema_in, self.schema_update = self.get_schemas()
164
187
  self.path_schema = self._generate_path_schema()
165
188
  self.filters_schema = self._generate_filters_schema()
189
+ self.m2m_filters_schemas = self._generate_m2m_filters_schemas()
166
190
  self.model_verbose_name = self.model._meta.verbose_name.capitalize()
167
191
  self.router_tag = self.model_verbose_name
168
192
  self.router = Router(tags=[self.router_tag])
@@ -177,8 +201,7 @@ class APIViewSet:
177
201
  @property
178
202
  def _crud_views(self):
179
203
  """
180
- key: view type (create, list, retrieve, update, delete or all)
181
- value: tuple with schema and view method
204
+ Mapping of CRUD operation name to (response schema, view factory).
182
205
  """
183
206
  return {
184
207
  "create": (self.schema_in, self.create_view),
@@ -189,6 +212,9 @@ class APIViewSet:
189
212
  }
190
213
 
191
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
+ """
192
218
  auth = getattr(self, f"{view_type}_auth", None)
193
219
  return auth if auth is not NOT_SET else self.auth
194
220
 
@@ -205,20 +231,47 @@ class APIViewSet:
205
231
  return self._auth_view("delete")
206
232
 
207
233
  def _generate_schema(self, fields: dict, name: str) -> Schema:
234
+ """
235
+ Dynamically build a Pydantic model for path / filter schemas.
236
+ """
208
237
  return create_model(f"{self.model_util.model_name}{name}", **fields)
209
238
 
210
239
  def _generate_path_schema(self):
240
+ """
241
+ Schema containing only the primary key field for path resolution.
242
+ """
211
243
  return self._generate_schema(
212
244
  {self.model_util.model_pk_name: (int | str, ...)}, "PathSchema"
213
245
  )
214
246
 
215
247
  def _generate_filters_schema(self):
248
+ """
249
+ Build filters schema from query_params definition.
250
+ """
216
251
  return self._generate_schema(self.query_params, "FiltersSchema")
217
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
+
218
265
  def _get_pk(self, data: Schema):
266
+ """
267
+ Extract pk from a path schema instance.
268
+ """
219
269
  return data.model_dump()[self.model_util.model_pk_name]
220
270
 
221
271
  def get_schemas(self):
272
+ """
273
+ Return (schema_out, schema_in, schema_update), generating them if model is a ModelSerializer.
274
+ """
222
275
  if isinstance(self.model, ModelSerializerMeta):
223
276
  return (
224
277
  self.model.generate_read_s(),
@@ -231,13 +284,16 @@ class APIViewSet:
231
284
  self, queryset: QuerySet[ModelSerializer], filters: dict
232
285
  ):
233
286
  """
234
- Override this method to handle request query params making queries to the database
235
- based on filters or any other logic. This method should return a queryset. filters
236
- 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.
237
290
  """
238
291
  return queryset
239
292
 
240
293
  def create_view(self):
294
+ """
295
+ Register create endpoint.
296
+ """
241
297
  @self.router.post(
242
298
  self.path,
243
299
  auth=self.post_view_auth(),
@@ -252,6 +308,9 @@ class APIViewSet:
252
308
  return create
253
309
 
254
310
  def list_view(self):
311
+ """
312
+ Register list endpoint with pagination and optional filters.
313
+ """
255
314
  @self.router.get(
256
315
  self.get_path,
257
316
  auth=self.get_view_auth(),
@@ -285,6 +344,9 @@ class APIViewSet:
285
344
  return list
286
345
 
287
346
  def retrieve_view(self):
347
+ """
348
+ Register retrieve endpoint.
349
+ """
288
350
  @self.router.get(
289
351
  self.get_path_retrieve,
290
352
  auth=self.get_view_auth(),
@@ -300,6 +362,9 @@ class APIViewSet:
300
362
  return retrieve
301
363
 
302
364
  def update_view(self):
365
+ """
366
+ Register update endpoint.
367
+ """
303
368
  @self.router.patch(
304
369
  self.path_retrieve,
305
370
  auth=self.patch_view_auth(),
@@ -320,6 +385,9 @@ class APIViewSet:
320
385
  return update
321
386
 
322
387
  def delete_view(self):
388
+ """
389
+ Register delete endpoint.
390
+ """
323
391
  @self.router.delete(
324
392
  self.path_retrieve,
325
393
  auth=self.delete_view_auth(),
@@ -335,35 +403,8 @@ class APIViewSet:
335
403
 
336
404
  def views(self):
337
405
  """
338
- Override this method to add your custom views. For example:
339
- @self.router.get(some_path, response=some_schema)
340
- async def some_method(request, *args, **kwargs):
341
- pass
342
-
343
- You can add multilple views just doing:
344
-
345
- @self.router.get(some_path, response=some_schema)
346
- async def some_method(request, *args, **kwargs):
347
- pass
348
-
349
- @self.router.post(some_path, response=some_schema)
350
- async def some_method(request, *args, **kwargs):
351
- pass
352
-
353
- If you provided a list of auths you can chose which of your views
354
- should be authenticated:
355
-
356
- AUTHENTICATED VIEW:
357
-
358
- @self.router.get(some_path, response=some_schema, auth=self.auth)
359
- async def some_method(request, *args, **kwargs):
360
- pass
361
-
362
- NOT AUTHENTICATED VIEW:
363
-
364
- @self.router.post(some_path, response=some_schema)
365
- async def some_method(request, *args, **kwargs):
366
- pass
406
+ Override to register custom non-CRUD endpoints on self.router.
407
+ Use auth=self.auth or verb specific auth attributes if needed.
367
408
  """
368
409
 
369
410
  async def _check_m2m_objs(
@@ -374,6 +415,10 @@ class APIViewSet:
374
415
  related_manager: QuerySet,
375
416
  remove: bool = False,
376
417
  ):
418
+ """
419
+ Validate requested add/remove pk list for M2M operations.
420
+ Returns (errors, details, objects_to_process).
421
+ """
377
422
  errors, objs_detail, objs = [], [], []
378
423
  rel_objs = [rel_obj async for rel_obj in related_manager.select_related().all()]
379
424
  rel_model_name = model._meta.verbose_name.capitalize()
@@ -386,7 +431,7 @@ class APIViewSet:
386
431
  continue
387
432
  if remove ^ (rel_obj in rel_objs):
388
433
  errors.append(
389
- f"{rel_model_name} with id {obj_pk} is {'not ' if remove else ''} in {self.model_util.model_name}"
434
+ f"{rel_model_name} with id {obj_pk} is {'not ' if remove else ''}in {self.model_util.model_name}"
390
435
  )
391
436
  continue
392
437
  objs.append(rel_obj)
@@ -396,14 +441,26 @@ class APIViewSet:
396
441
  return errors, objs_detail, objs
397
442
 
398
443
  def _m2m_views(self):
399
- for model, related_name in self.m2m_relations:
444
+ """
445
+ Register M2M get/manage endpoints for each relation in m2m_relations.
446
+ Supports optional per-relation filters and custom query handler:
447
+ <related_name>_query_params_handler.
448
+ """
449
+ for m2m_data in self.m2m_relations:
450
+ model = m2m_data.model
451
+ related_name = m2m_data.related_name
452
+ m2m_auth = m2m_data.auth or self.m2m_auth
400
453
  rel_util = ModelUtil(model)
401
454
  rel_path = (
402
455
  rel_util.verbose_name_path_resolver()
403
- if not self.m2m_path
404
- else self.m2m_path
456
+ if not m2m_data.path
457
+ else m2m_data.path
405
458
  )
406
- if self.m2m_get:
459
+ m2m_add = m2m_data.add
460
+ m2m_remove = m2m_data.remove
461
+ m2m_get = m2m_data.get
462
+ filters_schema = self.m2m_filters_schemas.get(related_name)
463
+ if m2m_get:
407
464
 
408
465
  @self.router.get(
409
466
  f"{self.path_retrieve}{rel_path}",
@@ -411,16 +468,34 @@ class APIViewSet:
411
468
  200: List[model.generate_related_s(),],
412
469
  self.error_codes: GenericMessageSchema,
413
470
  },
414
- auth=self.m2m_auth,
471
+ auth=m2m_auth,
415
472
  summary=f"Get {rel_util.model._meta.verbose_name_plural.capitalize()}",
416
473
  description=f"Get all related {rel_util.model._meta.verbose_name_plural.capitalize()}",
417
474
  )
418
475
  @unique_view(f"get_{self.model_util.model_name}_{rel_path}")
419
476
  @paginate(self.pagination_class)
420
- async def get_related(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
477
+ async def get_related(
478
+ request: HttpRequest,
479
+ pk: Path[self.path_schema], # type: ignore
480
+ filters: Query[filters_schema] = None # type: ignore
481
+ ):
421
482
  obj = await self.model_util.get_object(request, self._get_pk(pk))
422
483
  related_manager = getattr(obj, related_name)
423
484
  related_qs = related_manager.all()
485
+ if (
486
+ filters is not None
487
+ and (
488
+ query_handler := getattr(
489
+ self,
490
+ f"{m2m_data.related_name}_query_params_handler",
491
+ None,
492
+ )
493
+ )
494
+ is not None
495
+ ):
496
+ related_qs = await query_handler(
497
+ related_qs, filters.model_dump()
498
+ )
424
499
  related_objs = [
425
500
  await rel_util.read_s(
426
501
  request, rel_obj, model.generate_related_s()
@@ -429,14 +504,14 @@ class APIViewSet:
429
504
  ]
430
505
  return related_objs
431
506
 
432
- if self.m2m_add or self.m2m_remove:
433
- 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()}"
434
- 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()}"
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()}"
435
510
  schema_in = (
436
511
  M2MSchemaIn
437
- if self.m2m_add and self.m2m_remove
512
+ if m2m_add and m2m_remove
438
513
  else M2MAddSchemaIn
439
- if self.m2m_add
514
+ if m2m_add
440
515
  else M2MRemoveSchemaIn
441
516
  )
442
517
 
@@ -446,7 +521,7 @@ class APIViewSet:
446
521
  200: M2MSchemaOut,
447
522
  self.error_codes: GenericMessageSchema,
448
523
  },
449
- auth=self.m2m_auth,
524
+ auth=m2m_auth,
450
525
  summary=summary,
451
526
  description=description,
452
527
  )
@@ -461,7 +536,7 @@ class APIViewSet:
461
536
  add_errors, add_details, add_objs = [], [], []
462
537
  remove_errors, remove_details, remove_objs = [], [], []
463
538
 
464
- if self.m2m_add and hasattr(data, "add"):
539
+ if m2m_add and hasattr(data, "add"):
465
540
  (
466
541
  add_errors,
467
542
  add_details,
@@ -469,7 +544,7 @@ class APIViewSet:
469
544
  ) = await self._check_m2m_objs(
470
545
  request, data.add, model, related_manager
471
546
  )
472
- if self.m2m_remove and hasattr(data, "remove"):
547
+ if m2m_remove and hasattr(data, "remove"):
473
548
  (
474
549
  remove_errors,
475
550
  remove_details,
@@ -501,6 +576,10 @@ class APIViewSet:
501
576
  }
502
577
 
503
578
  def _add_views(self):
579
+ """
580
+ Register CRUD (unless disabled), custom views, and M2M endpoints.
581
+ If 'all' in disable only CRUD is skipped; M2M + custom still added.
582
+ """
504
583
  if "all" in self.disable:
505
584
  if self.m2m_relations:
506
585
  self._m2m_views()
@@ -519,4 +598,7 @@ class APIViewSet:
519
598
  return self.router
520
599
 
521
600
  def add_views_to_route(self):
601
+ """
602
+ Attach router with registered endpoints to the NinjaAPI instance.
603
+ """
522
604
  return self.api.add_router(f"{self.api_route_path}", self._add_views())
@@ -1,29 +0,0 @@
1
- from ninja import Schema
2
- from pydantic import RootModel
3
-
4
-
5
- class GenericMessageSchema(RootModel[dict[str, str]]):
6
- root: dict[str, str]
7
-
8
-
9
- class M2MDetailSchema(Schema):
10
- count: int
11
- details: list[str]
12
-
13
-
14
- class M2MSchemaOut(Schema):
15
- errors: M2MDetailSchema
16
- results: M2MDetailSchema
17
-
18
-
19
- class M2MAddSchemaIn(Schema):
20
- add: list = []
21
-
22
-
23
- class M2MRemoveSchemaIn(Schema):
24
- remove: list = []
25
-
26
-
27
- class M2MSchemaIn(Schema):
28
- add: list = []
29
- remove: list = []