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.
- django_ninja_aio_crud-1.0.2.dist-info/METADATA +338 -0
- 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 +19 -193
- django_ninja_aio_crud-1.0.0.dist-info/METADATA +0 -527
- django_ninja_aio_crud-1.0.0.dist-info/RECORD +0 -15
- {django_ninja_aio_crud-1.0.0.dist-info → django_ninja_aio_crud-1.0.2.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-1.0.0.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.
|
|
@@ -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
|
-
|
|
411
|
-
self
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
"""
|