django-ninja-aio-crud 0.9.1__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.1
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.1"
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
@@ -219,13 +236,12 @@ class APIViewSet:
219
236
  return queryset
220
237
 
221
238
  def create_view(self):
222
- @self.api.post(
223
- f"{self.api_route_path}/",
239
+ @self.router.post(
240
+ self.path,
224
241
  auth=self.post_view_auth(),
225
242
  summary=f"Create {self.model._meta.verbose_name.capitalize()}",
226
243
  description=self.create_docs,
227
244
  response={201: self.schema_out, self.error_codes: GenericMessageSchema},
228
- tags=[self.router_tag],
229
245
  )
230
246
  async def create(request: HttpRequest, data: self.schema_in): # type: ignore
231
247
  return 201, await self.model_util.create_s(request, data, self.schema_out)
@@ -234,8 +250,8 @@ class APIViewSet:
234
250
  return create
235
251
 
236
252
  def list_view(self):
237
- @self.api.get(
238
- self.api_route_path,
253
+ @self.router.get(
254
+ self.get_path,
239
255
  auth=self.get_view_auth(),
240
256
  summary=f"List {self.model._meta.verbose_name_plural.capitalize()}",
241
257
  description=self.list_docs,
@@ -243,7 +259,6 @@ class APIViewSet:
243
259
  200: List[self.schema_out],
244
260
  self.error_codes: GenericMessageSchema,
245
261
  },
246
- tags=[self.router_tag],
247
262
  )
248
263
  @paginate(self.pagination_class)
249
264
  async def list(
@@ -268,13 +283,12 @@ class APIViewSet:
268
283
  return list
269
284
 
270
285
  def retrieve_view(self):
271
- @self.api.get(
272
- f"{self.api_route_path}/{self.get_path_retrieve}",
286
+ @self.router.get(
287
+ self.get_path_retrieve,
273
288
  auth=self.get_view_auth(),
274
289
  summary=f"Retrieve {self.model._meta.verbose_name.capitalize()}",
275
290
  description=self.retrieve_docs,
276
291
  response={200: self.schema_out, self.error_codes: GenericMessageSchema},
277
- tags=[self.router_tag],
278
292
  )
279
293
  async def retrieve(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
280
294
  obj = await self.model_util.get_object(request, self._get_pk(pk))
@@ -284,13 +298,12 @@ class APIViewSet:
284
298
  return retrieve
285
299
 
286
300
  def update_view(self):
287
- @self.api.patch(
288
- f"{self.api_route_path}/{self.path_retrieve}",
301
+ @self.router.patch(
302
+ self.path_retrieve,
289
303
  auth=self.patch_view_auth(),
290
304
  summary=f"Update {self.model._meta.verbose_name.capitalize()}",
291
305
  description=self.update_docs,
292
306
  response={200: self.schema_out, self.error_codes: GenericMessageSchema},
293
- tags=[self.router_tag],
294
307
  )
295
308
  async def update(
296
309
  request: HttpRequest,
@@ -305,13 +318,12 @@ class APIViewSet:
305
318
  return update
306
319
 
307
320
  def delete_view(self):
308
- @self.api.delete(
309
- f"{self.api_route_path}/{self.path_retrieve}",
321
+ @self.router.delete(
322
+ self.path_retrieve,
310
323
  auth=self.delete_view_auth(),
311
324
  summary=f"Delete {self.model._meta.verbose_name.capitalize()}",
312
325
  description=self.delete_docs,
313
326
  response={204: None, self.error_codes: GenericMessageSchema},
314
- tags=[self.router_tag],
315
327
  )
316
328
  async def delete(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
317
329
  return 204, await self.model_util.delete_s(request, self._get_pk(pk))
@@ -352,8 +364,145 @@ class APIViewSet:
352
364
  pass
353
365
  """
354
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
+
355
502
  def _add_views(self):
356
503
  if "all" in self.disable:
504
+ if self.m2m_relations:
505
+ self._m2m_views()
357
506
  self.views()
358
507
  return self.router
359
508
 
@@ -364,8 +513,9 @@ class APIViewSet:
364
513
  view()
365
514
 
366
515
  self.views()
516
+ if self.m2m_relations:
517
+ self._m2m_views()
367
518
  return self.router
368
519
 
369
520
  def add_views_to_route(self):
370
- self._add_views()
371
- return self.api.add_router(f"{self.api_route_path}", self.router, tags=[self.router_tag])
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]