django-ninja-aio-crud 1.0.1__py3-none-any.whl → 1.0.3__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.
@@ -411,168 +399,13 @@ class APIViewSet:
411
399
  Override to register custom non-CRUD endpoints on self.router.
412
400
  Use auth=self.auth or verb specific auth attributes if needed.
413
401
  """
402
+ pass
414
403
 
415
- async def _check_m2m_objs(
416
- self,
417
- request: HttpRequest,
418
- objs_pks: list,
419
- model: ModelSerializer | Model,
420
- related_manager: QuerySet,
421
- remove: bool = False,
422
- ):
423
- """
424
- Validate requested add/remove pk list for M2M operations.
425
- Returns (errors, details, objects_to_process).
426
- """
427
- errors, objs_detail, objs = [], [], []
428
- rel_objs = [rel_obj async for rel_obj in related_manager.select_related().all()]
429
- rel_model_name = model._meta.verbose_name.capitalize()
430
- for obj_pk in objs_pks:
431
- rel_obj = await (
432
- await ModelUtil(model).get_object(request, filters={"pk": obj_pk})
433
- ).afirst()
434
- if rel_obj is None:
435
- errors.append(f"{rel_model_name} with pk {obj_pk} not found.")
436
- continue
437
- if remove ^ (rel_obj in rel_objs):
438
- errors.append(
439
- f"{rel_model_name} with id {obj_pk} is {'not ' if remove else ''}in {self.model_util.model_name}"
440
- )
441
- continue
442
- objs.append(rel_obj)
443
- objs_detail.append(
444
- f"{rel_model_name} with id {obj_pk} successfully {'removed' if remove else 'added'}"
445
- )
446
- return errors, objs_detail, objs
447
-
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()}",
478
- )
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,
496
- )
497
- )
498
- is not None
499
- ):
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
- )
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
- 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
- }
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
576
409
 
577
410
  def _add_views(self):
578
411
  """
@@ -580,11 +413,7 @@ class APIViewSet:
580
413
  If 'all' in disable only CRUD is skipped; M2M + custom still added.
581
414
  """
582
415
  if "all" in self.disable:
583
- if self.m2m_relations:
584
- for m2m_relation in self.m2m_relations:
585
- self._m2m_views(m2m_relation)
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,11 +421,7 @@ class APIViewSet:
592
421
  ):
593
422
  view()
594
423
 
595
- self.views()
596
- if self.m2m_relations:
597
- for m2m_relation in self.m2m_relations:
598
- self._m2m_views(m2m_relation)
599
- return self.router
424
+ return self._set_additional_views()
600
425
 
601
426
  def add_views_to_route(self):
602
427
  """
@@ -1,15 +0,0 @@
1
- ninja_aio/__init__.py,sha256=hC9T2fsBJwd5QxeaoghQNrIN1i5mJfBAnK_Ky4lD7zs,119
2
- ninja_aio/api.py,sha256=Fe6l3YCy7MW5TY4-Lbl80CFuK2NT2Y7tHfmqPk6Mqak,1735
3
- ninja_aio/auth.py,sha256=zUwruKcz7MXuOnWp5k1CCSwEc8s2Lyqqk7Qm9kPbJ3o,5149
4
- ninja_aio/decorators.py,sha256=LsvHbMxmw_So8NV0ey5NRRvSbfYkOZLeLQ4Fix7rQAY,5519
5
- ninja_aio/exceptions.py,sha256=iEX4PNqtRXXr75M8veOynmFZcIE5lGURHU_ISSgzX0Y,2578
6
- ninja_aio/models.py,sha256=-p1wgOg-r5bYWQ9DzUSNKxsUvWiDg6kruMQ_LxZFvQE,32948
7
- ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
8
- ninja_aio/renders.py,sha256=0eYklRKd59aV4cZDom5vLZyA99Ob17OwkpMybsRXvyg,1970
9
- ninja_aio/schemas.py,sha256=iv3VHCMlzez6Qs70zITYIwEz0EFOOaMPDVGRcTZCygA,1875
10
- ninja_aio/types.py,sha256=TJSGlA7bt4g9fvPhJ7gzH5tKbLagPmZUzfgttEOp4xs,468
11
- ninja_aio/views.py,sha256=EGOqybYR0Y0k7DVlMUwaruyEwF1LTJcM1EeFBU69EL0,23113
12
- django_ninja_aio_crud-1.0.1.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
13
- django_ninja_aio_crud-1.0.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
14
- django_ninja_aio_crud-1.0.1.dist-info/METADATA,sha256=jIF-L1BHlBtCp8n7mTH1IK388J2FP_73567_ppirNUQ,8331
15
- django_ninja_aio_crud-1.0.1.dist-info/RECORD,,