django-ninja-aio-crud 1.0.1__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.
- {django_ninja_aio_crud-1.0.1.dist-info → django_ninja_aio_crud-1.0.2.dist-info}/METADATA +3 -1
- django_ninja_aio_crud-1.0.2.dist-info/RECORD +17 -0
- ninja_aio/__init__.py +1 -1
- ninja_aio/exceptions.py +1 -1
- ninja_aio/helpers/__init__.py +3 -0
- ninja_aio/helpers/api.py +432 -0
- ninja_aio/models.py +677 -361
- ninja_aio/renders.py +17 -24
- ninja_aio/schemas.py +16 -1
- ninja_aio/views.py +14 -189
- django_ninja_aio_crud-1.0.1.dist-info/RECORD +0 -15
- {django_ninja_aio_crud-1.0.1.dist-info → django_ninja_aio_crud-1.0.2.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-1.0.1.dist-info → django_ninja_aio_crud-1.0.2.dist-info}/licenses/LICENSE +0 -0
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
416
|
-
self
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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.
|
|
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,,
|
|
File without changes
|
{django_ninja_aio_crud-1.0.1.dist-info → django_ninja_aio_crud-1.0.2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|