django-ninja-aio-crud 0.9.2__tar.gz → 0.10.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.9.2
3
+ Version: 0.10.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.9.2"
3
+ __version__ = "0.10.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -1,4 +1,4 @@
1
- from joserfc import jwt, jwk
1
+ from joserfc import jwt, jwk, errors
2
2
  from django.http.request import HttpRequest
3
3
  from ninja.security.http import HttpBearer
4
4
 
@@ -25,11 +25,19 @@ class AsyncJwtBearer(HttpBearer):
25
25
  pass
26
26
 
27
27
  async def authenticate(self, request: HttpRequest, token: str):
28
+ """
29
+ Authenticate the request and return the user if authentication is successful.
30
+ If authentication fails, returns false.
31
+ """
28
32
  try:
29
33
  self.dcd = jwt.decode(token, self.jwt_public, algorithms=self.algorithms)
30
34
  except ValueError as exc:
31
- raise AuthError(", ".join(exc.args), 401)
35
+ # raise AuthError(", ".join(exc.args), 401)
36
+ return False
32
37
 
33
- self.validate_claims(self.dcd.claims)
38
+ try:
39
+ self.validate_claims(self.dcd.claims)
40
+ except errors.JoseError as exc:
41
+ return False
34
42
 
35
43
  return await self.auth_handler(request)
@@ -60,6 +60,10 @@ class ModelUtil:
60
60
  filters: dict = None,
61
61
  getters: dict = None,
62
62
  with_qs_request=True,
63
+ ) -> (
64
+ type["ModelSerializer"]
65
+ | models.Model
66
+ | models.QuerySet[type["ModelSerializer"] | models.Model]
63
67
  ):
64
68
  get_q = {self.model_pk_name: pk} if pk is not None else {}
65
69
  if getters:
@@ -73,6 +77,9 @@ class ModelUtil:
73
77
  if filters:
74
78
  obj_qs = obj_qs.filter(**filters)
75
79
 
80
+ if not get_q:
81
+ return obj_qs
82
+
76
83
  try:
77
84
  obj = await obj_qs.aget(**get_q)
78
85
  except ObjectDoesNotExist:
@@ -0,0 +1,29 @@
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 = []
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  from typing import List
2
3
 
3
4
  from ninja import NinjaAPI, Router, Schema, Path, Query
@@ -8,7 +9,13 @@ from django.db.models import Model, QuerySet
8
9
  from pydantic import create_model
9
10
 
10
11
  from .models import ModelSerializer, ModelUtil
11
- from .schemas import GenericMessageSchema
12
+ from .schemas import (
13
+ GenericMessageSchema,
14
+ M2MSchemaOut,
15
+ M2MSchemaIn,
16
+ M2MAddSchemaIn,
17
+ M2MRemoveSchemaIn,
18
+ )
12
19
  from .types import ModelSerializerMeta, VIEW_TYPES
13
20
 
14
21
  ERROR_CODES = frozenset({400, 401, 404, 428})
@@ -93,6 +100,11 @@ class APIViewSet:
93
100
  - **retrieve_docs** (`str`): Documentation for the retrieve view.
94
101
  - **update_docs** (`str`): Documentation for the update view.
95
102
  - **delete_docs** (`str`): Documentation for the delete view.
103
+ - **m2m_relations** (`tuple[ModelSerializer | Model, str]`): Many-to-many relations to manage.
104
+ - **m2m_add** (`bool`): Enable add operation for M2M relations.
105
+ - **m2m_remove** (`bool`): Enable remove operation for M2M relations.
106
+ - **m2m_get** (`bool`): Enable get operation for M2M relations.
107
+ - **m2m_auth** (`list | None`): Authentication for M2M views.
96
108
 
97
109
  ## Notes:
98
110
  If the model is a ModelSerializer instance, schemas are generated
@@ -137,6 +149,11 @@ class APIViewSet:
137
149
  retrieve_docs = "Retrieve a specific object by its primary key."
138
150
  update_docs = "Update an object by its primary key."
139
151
  delete_docs = "Delete an object by its primary key."
152
+ m2m_relations: tuple[ModelSerializer | Model, str] = []
153
+ m2m_add = True
154
+ m2m_remove = True
155
+ m2m_get = True
156
+ m2m_auth: list | None = NOT_SET
140
157
 
141
158
  def __init__(self) -> None:
142
159
  self.error_codes = ERROR_CODES
@@ -347,8 +364,145 @@ class APIViewSet:
347
364
  pass
348
365
  """
349
366
 
367
+ async def _check_m2m_objs(
368
+ self,
369
+ request: HttpRequest,
370
+ objs_pks: list,
371
+ model: ModelSerializer | Model,
372
+ related_manager: QuerySet,
373
+ remove: bool = False,
374
+ ):
375
+ errors, objs_detail, objs = [], [], []
376
+ rel_objs = [rel_obj async for rel_obj in related_manager.select_related().all()]
377
+ rel_model_name = model._meta.verbose_name.capitalize()
378
+ for obj_pk in objs_pks:
379
+ rel_obj = await (
380
+ await ModelUtil(model).get_object(request, filters={"pk": obj_pk})
381
+ ).afirst()
382
+ if rel_obj is None:
383
+ errors.append(f"{rel_model_name} with pk {obj_pk} not found.")
384
+ continue
385
+ if remove ^ (rel_obj in rel_objs):
386
+ errors.append(
387
+ f"{rel_model_name} with id {obj_pk} is {'not ' if remove else ''} in {self.model_util.model_name}"
388
+ )
389
+ continue
390
+ objs.append(rel_obj)
391
+ objs_detail.append(
392
+ f"{rel_model_name} with id {obj_pk} successfully {'removed' if remove else 'added'}"
393
+ )
394
+ return errors, objs_detail, objs
395
+
396
+ def _m2m_views(self):
397
+ for model, related_name in self.m2m_relations:
398
+ rel_util = ModelUtil(model)
399
+ rel_path = rel_util.verbose_name_path_resolver()
400
+ if self.m2m_get:
401
+
402
+ @self.router.get(
403
+ f"{self.path_retrieve}{rel_path}",
404
+ response={
405
+ 200: List[
406
+ model.generate_related_s(),
407
+ ],
408
+ self.error_codes : GenericMessageSchema,
409
+ },
410
+ auth=self.m2m_auth,
411
+ summary=f"Get {rel_util.model._meta.verbose_name_plural.capitalize()}",
412
+ description=f"Get all related {rel_util.model._meta.verbose_name_plural.capitalize()}",
413
+ )
414
+ async def get_related(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
415
+ obj = await self.model_util.get_object(request, self._get_pk(pk))
416
+ related_manager = getattr(obj, related_name)
417
+ related_qs = related_manager.all()
418
+ related_objs = [
419
+ await rel_util.read_s(
420
+ request, rel_obj, model.generate_related_s()
421
+ )
422
+ async for rel_obj in related_qs
423
+ ]
424
+ return related_objs
425
+ get_related.__name__ = f"get_{self.model_util.model_name}_{rel_path}"
426
+
427
+ if self.m2m_add or self.m2m_remove:
428
+ if self.m2m_add and self.m2m_remove:
429
+ schema_in = M2MSchemaIn
430
+ elif self.m2m_add:
431
+ schema_in = M2MAddSchemaIn
432
+ else: # self.m2m_remove must be True
433
+ schema_in = M2MRemoveSchemaIn
434
+
435
+ @self.router.post(
436
+ f"{self.path_retrieve}{rel_path}/",
437
+ response={
438
+ 200: M2MSchemaOut,
439
+ self.error_codes: GenericMessageSchema,
440
+ },
441
+ auth=self.m2m_auth,
442
+ summary=f"Add or Remove {rel_util.model._meta.verbose_name_plural.capitalize()}",
443
+ description=f"Add or remove {rel_util.model._meta.verbose_name_plural.capitalize()}"
444
+ )
445
+ async def add_and_remove_related(
446
+ request: HttpRequest,
447
+ pk: Path[self.path_schema], # type: ignore
448
+ data: schema_in, # type: ignore
449
+ ):
450
+ obj = await self.model_util.get_object(
451
+ request, self._get_pk(pk)
452
+ )
453
+ related_manager: QuerySet = getattr(obj, related_name)
454
+ (
455
+ add_errors,
456
+ add_details,
457
+ add_objs,
458
+ remove_errors,
459
+ remove_details,
460
+ remove_objs,
461
+ ) = [], [], [], [], [], []
462
+ if self.m2m_add and hasattr(data, "add"):
463
+ (
464
+ add_errors,
465
+ add_details,
466
+ add_objs,
467
+ ) = await self._check_m2m_objs(
468
+ request, data.add, model, related_manager
469
+ )
470
+ if self.m2m_remove and hasattr(data, "remove"):
471
+ (
472
+ remove_errors,
473
+ remove_details,
474
+ remove_objs,
475
+ ) = await self._check_m2m_objs(
476
+ request,
477
+ data.remove,
478
+ model,
479
+ related_manager,
480
+ remove=True,
481
+ )
482
+ await asyncio.gather(
483
+ related_manager.aadd(*add_objs),
484
+ related_manager.aremove(*remove_objs),
485
+ )
486
+ results = add_details + remove_details
487
+ errors = add_errors + remove_errors
488
+
489
+ return {
490
+ "results": {
491
+ "count": len(results),
492
+ "details": results,
493
+ },
494
+ "errors": {
495
+ "count": len(errors),
496
+ "details": errors,
497
+ },
498
+ }
499
+ add_and_remove_related.__name__ = f"add_and_remove_{self.model_util.model_name}_{rel_path}"
500
+
501
+
350
502
  def _add_views(self):
351
503
  if "all" in self.disable:
504
+ if self.m2m_relations:
505
+ self._m2m_views()
352
506
  self.views()
353
507
  return self.router
354
508
 
@@ -359,9 +513,9 @@ class APIViewSet:
359
513
  view()
360
514
 
361
515
  self.views()
516
+ if self.m2m_relations:
517
+ self._m2m_views()
362
518
  return self.router
363
519
 
364
520
  def add_views_to_route(self):
365
- return self.api.add_router(
366
- f"{self.api_route_path}", self._add_views()
367
- )
521
+ return self.api.add_router(f"{self.api_route_path}", self._add_views())
@@ -1,5 +0,0 @@
1
- from pydantic import RootModel
2
-
3
-
4
- class GenericMessageSchema(RootModel[dict[str, str]]):
5
- root: dict[str, str]