django-ninja-aio-crud 1.0.0__py3-none-any.whl → 1.0.2__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/renders.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import base64
2
2
  from ipaddress import IPv4Address, IPv6Address
3
+ from typing import Any
3
4
 
4
5
  import orjson
5
6
  from django.http import HttpRequest
@@ -28,27 +29,19 @@ class ORJSONRenderer(BaseRenderer):
28
29
  return cls.parse_data(data)
29
30
 
30
31
  @classmethod
31
- def parse_data(cls, data: dict):
32
- for k, v in data.items():
33
- if isinstance(v, bytes):
34
- data |= {k: base64.b64encode(v).decode()}
35
- continue
36
- if isinstance(v, (IPv4Address, IPv6Address)):
37
- data |= {k: str(v)}
38
- continue
39
- if isinstance(v, dict):
40
- for k_rel, v_rel in v.items():
41
- if isinstance(v_rel, bytes):
42
- v |= {k_rel: base64.b64encode(v_rel).decode()}
43
- if isinstance(v_rel, (IPv4Address, IPv6Address)):
44
- v |= {k_rel: str(v_rel)}
45
- data |= {k: v}
46
- if isinstance(v, list):
47
- for index_rel, f_rel in enumerate(v):
48
- for k_rel, v_rel in f_rel.items():
49
- if isinstance(v_rel, bytes):
50
- v[index_rel] |= {k_rel: base64.b64encode(v_rel).decode()}
51
- if isinstance(v_rel, (IPv4Address, IPv6Address)):
52
- v[index_rel] |= {k_rel: str(v_rel)}
53
- data |= {k: v}
54
- return data
32
+ def transform(cls, value):
33
+ if isinstance(value, bytes):
34
+ return base64.b64encode(value).decode()
35
+ if isinstance(value, (IPv4Address, IPv6Address)):
36
+ return str(value)
37
+ if isinstance(value, dict):
38
+ return {k: cls.transform(v) for k, v in value.items()}
39
+ if isinstance(value, list):
40
+ return [cls.transform(item) for item in value]
41
+ return value
42
+
43
+ @classmethod
44
+ def parse_data(cls, data: dict | Any):
45
+ if not isinstance(data, dict):
46
+ return cls.transform(data)
47
+ return {k: cls.transform(v) for k, v in data.items()}
ninja_aio/schemas.py CHANGED
@@ -3,7 +3,7 @@ from typing import Optional, Type
3
3
  from ninja import Schema
4
4
  from .models import ModelSerializer
5
5
  from django.db.models import Model
6
- from pydantic import BaseModel, RootModel, ConfigDict
6
+ from pydantic import BaseModel, RootModel, ConfigDict, model_validator
7
7
 
8
8
 
9
9
  class GenericMessageSchema(RootModel[dict[str, str]]):
@@ -63,5 +63,20 @@ class M2MRelationSchema(BaseModel):
63
63
  path: Optional[str] = ""
64
64
  auth: Optional[list] = None
65
65
  filters: Optional[dict[str, tuple]] = None
66
+ related_schema: Optional[Type[Schema]] = None
66
67
 
67
68
  model_config = ConfigDict(arbitrary_types_allowed=True)
69
+
70
+ @model_validator(mode="before")
71
+ @classmethod
72
+ def validate_related_schema(cls, data):
73
+ related_schema = data.get("related_schema")
74
+ if related_schema is not None:
75
+ return data
76
+ model = data.get("model")
77
+ if not issubclass(model, ModelSerializer):
78
+ raise ValueError(
79
+ "related_schema must be provided if model is not a ModelSerializer",
80
+ )
81
+ data["related_schema"] = model.generate_related_s()
82
+ return data
ninja_aio/views.py CHANGED
@@ -1,4 +1,3 @@
1
- import asyncio
2
1
  from typing import List
3
2
 
4
3
  from ninja import NinjaAPI, Router, Schema, Path, Query
@@ -11,12 +10,9 @@ from pydantic import create_model
11
10
  from .models import ModelSerializer, ModelUtil
12
11
  from .schemas import (
13
12
  GenericMessageSchema,
14
- M2MSchemaOut,
15
- M2MSchemaIn,
16
- M2MAddSchemaIn,
17
- M2MRemoveSchemaIn,
18
13
  M2MRelationSchema,
19
14
  )
15
+ from .helpers import ManyToManyAPI
20
16
  from .types import ModelSerializerMeta, VIEW_TYPES
21
17
  from .decorators import unique_view
22
18
 
@@ -186,7 +182,6 @@ class APIViewSet:
186
182
  self.schema_out, self.schema_in, self.schema_update = self.get_schemas()
187
183
  self.path_schema = self._generate_path_schema()
188
184
  self.filters_schema = self._generate_filters_schema()
189
- self.m2m_filters_schemas = self._generate_m2m_filters_schemas()
190
185
  self.model_verbose_name = self.model._meta.verbose_name.capitalize()
191
186
  self.router_tag = self.model_verbose_name
192
187
  self.router = Router(tags=[self.router_tag])
@@ -197,6 +192,11 @@ class APIViewSet:
197
192
  self.api_route_path = (
198
193
  self.api_route_path or self.model_util.verbose_name_path_resolver()
199
194
  )
195
+ self.m2m_api = (
196
+ None
197
+ if not self.m2m_relations
198
+ else ManyToManyAPI(relations=self.m2m_relations, view_set=self)
199
+ )
200
200
 
201
201
  @property
202
202
  def _crud_views(self):
@@ -250,18 +250,6 @@ class APIViewSet:
250
250
  """
251
251
  return self._generate_schema(self.query_params, "FiltersSchema")
252
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
-
265
253
  def _get_pk(self, data: Schema):
266
254
  """
267
255
  Extract pk from a path schema instance.
@@ -294,6 +282,7 @@ class APIViewSet:
294
282
  """
295
283
  Register create endpoint.
296
284
  """
285
+
297
286
  @self.router.post(
298
287
  self.path,
299
288
  auth=self.post_view_auth(),
@@ -311,6 +300,7 @@ class APIViewSet:
311
300
  """
312
301
  Register list endpoint with pagination and optional filters.
313
302
  """
303
+
314
304
  @self.router.get(
315
305
  self.get_path,
316
306
  auth=self.get_view_auth(),
@@ -347,6 +337,7 @@ class APIViewSet:
347
337
  """
348
338
  Register retrieve endpoint.
349
339
  """
340
+
350
341
  @self.router.get(
351
342
  self.get_path_retrieve,
352
343
  auth=self.get_view_auth(),
@@ -365,6 +356,7 @@ class APIViewSet:
365
356
  """
366
357
  Register update endpoint.
367
358
  """
359
+
368
360
  @self.router.patch(
369
361
  self.path_retrieve,
370
362
  auth=self.patch_view_auth(),
@@ -388,6 +380,7 @@ class APIViewSet:
388
380
  """
389
381
  Register delete endpoint.
390
382
  """
383
+
391
384
  @self.router.delete(
392
385
  self.path_retrieve,
393
386
  auth=self.delete_view_auth(),
@@ -406,174 +399,13 @@ class APIViewSet:
406
399
  Override to register custom non-CRUD endpoints on self.router.
407
400
  Use auth=self.auth or verb specific auth attributes if needed.
408
401
  """
402
+ pass
409
403
 
410
- async def _check_m2m_objs(
411
- self,
412
- request: HttpRequest,
413
- objs_pks: list,
414
- model: ModelSerializer | Model,
415
- related_manager: QuerySet,
416
- remove: bool = False,
417
- ):
418
- """
419
- Validate requested add/remove pk list for M2M operations.
420
- Returns (errors, details, objects_to_process).
421
- """
422
- errors, objs_detail, objs = [], [], []
423
- rel_objs = [rel_obj async for rel_obj in related_manager.select_related().all()]
424
- rel_model_name = model._meta.verbose_name.capitalize()
425
- for obj_pk in objs_pks:
426
- rel_obj = await (
427
- await ModelUtil(model).get_object(request, filters={"pk": obj_pk})
428
- ).afirst()
429
- if rel_obj is None:
430
- errors.append(f"{rel_model_name} with pk {obj_pk} not found.")
431
- continue
432
- if remove ^ (rel_obj in rel_objs):
433
- errors.append(
434
- f"{rel_model_name} with id {obj_pk} is {'not ' if remove else ''}in {self.model_util.model_name}"
435
- )
436
- continue
437
- objs.append(rel_obj)
438
- objs_detail.append(
439
- f"{rel_model_name} with id {obj_pk} successfully {'removed' if remove else 'added'}"
440
- )
441
- return errors, objs_detail, objs
442
-
443
- def _m2m_views(self):
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
453
- rel_util = ModelUtil(model)
454
- rel_path = (
455
- rel_util.verbose_name_path_resolver()
456
- if not m2m_data.path
457
- else m2m_data.path
458
- )
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:
464
-
465
- @self.router.get(
466
- f"{self.path_retrieve}{rel_path}",
467
- response={
468
- 200: List[model.generate_related_s(),],
469
- self.error_codes: GenericMessageSchema,
470
- },
471
- auth=m2m_auth,
472
- summary=f"Get {rel_util.model._meta.verbose_name_plural.capitalize()}",
473
- description=f"Get all related {rel_util.model._meta.verbose_name_plural.capitalize()}",
474
- )
475
- @unique_view(f"get_{self.model_util.model_name}_{rel_path}")
476
- @paginate(self.pagination_class)
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
- ):
482
- obj = await self.model_util.get_object(request, self._get_pk(pk))
483
- related_manager = getattr(obj, related_name)
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
- )
499
- related_objs = [
500
- await rel_util.read_s(
501
- request, rel_obj, model.generate_related_s()
502
- )
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
- )
517
-
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,
558
- )
559
-
560
- await asyncio.gather(
561
- related_manager.aadd(*add_objs),
562
- related_manager.aremove(*remove_objs),
563
- )
564
- results = add_details + remove_details
565
- errors = add_errors + remove_errors
566
-
567
- return {
568
- "results": {
569
- "count": len(results),
570
- "details": results,
571
- },
572
- "errors": {
573
- "count": len(errors),
574
- "details": errors,
575
- },
576
- }
404
+ def _set_additional_views(self):
405
+ self.views()
406
+ if self.m2m_api is not None:
407
+ self.m2m_api._add_views()
408
+ return self.router
577
409
 
578
410
  def _add_views(self):
579
411
  """
@@ -581,10 +413,7 @@ class APIViewSet:
581
413
  If 'all' in disable only CRUD is skipped; M2M + custom still added.
582
414
  """
583
415
  if "all" in self.disable:
584
- if self.m2m_relations:
585
- self._m2m_views()
586
- self.views()
587
- return self.router
416
+ return self._set_additional_views()
588
417
 
589
418
  for views_type, (schema, view) in self._crud_views.items():
590
419
  if views_type not in self.disable and (
@@ -592,10 +421,7 @@ class APIViewSet:
592
421
  ):
593
422
  view()
594
423
 
595
- self.views()
596
- if self.m2m_relations:
597
- self._m2m_views()
598
- return self.router
424
+ return self._set_additional_views()
599
425
 
600
426
  def add_views_to_route(self):
601
427
  """