django-ninja-aio-crud 0.10.3__py3-none-any.whl → 0.11.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 0.10.3
3
+ Version: 0.11.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10
@@ -27,6 +27,7 @@ Requires-Dist: django-ninja >=1.3.0
27
27
  Requires-Dist: joserfc >=1.0.0
28
28
  Requires-Dist: orjson >= 3.10.7
29
29
  Requires-Dist: coverage ; extra == "test"
30
+ Project-URL: Documentation, https://caspel26.github.io/django-ninja-aio-crud/
30
31
  Project-URL: Repository, https://github.com/caspel26/django-ninja-aio-crud
31
32
  Provides-Extra: test
32
33
 
@@ -0,0 +1,15 @@
1
+ ninja_aio/__init__.py,sha256=ImDLFWPLZMYkYRZ7pVVcxj0pL4w9uOQRFNmGg6QZVHg,120
2
+ ninja_aio/api.py,sha256=Fe6l3YCy7MW5TY4-Lbl80CFuK2NT2Y7tHfmqPk6Mqak,1735
3
+ ninja_aio/auth.py,sha256=zUwruKcz7MXuOnWp5k1CCSwEc8s2Lyqqk7Qm9kPbJ3o,5149
4
+ ninja_aio/decoratos.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=Fzu2ko3kUxkOnrjG5QYdmOXZd2gcpYGjVuocCW44NfQ,473
10
+ ninja_aio/types.py,sha256=TJSGlA7bt4g9fvPhJ7gzH5tKbLagPmZUzfgttEOp4xs,468
11
+ ninja_aio/views.py,sha256=OipMCYxT9JHWdmffCkV2OLXYFA4ODXBb94aLAFnOhFo,20780
12
+ django_ninja_aio_crud-0.11.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
13
+ django_ninja_aio_crud-0.11.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
14
+ django_ninja_aio_crud-0.11.0.dist-info/METADATA,sha256=XDeQA1BFDGiwCEckt18BGyrJncXtqqe7DNj6oH5fagQ,14217
15
+ django_ninja_aio_crud-0.11.0.dist-info/RECORD,,
ninja_aio/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "0.10.3"
3
+ __version__ = "0.11.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
ninja_aio/auth.py CHANGED
@@ -6,6 +6,71 @@ from .exceptions import AuthError
6
6
 
7
7
 
8
8
  class AsyncJwtBearer(HttpBearer):
9
+ """
10
+ AsyncJwtBearer provides asynchronous JWT-based authentication for Django Ninja endpoints
11
+ using HTTP Bearer tokens. It decodes and validates JWTs against a configured public key
12
+ and claim registry, then delegates user retrieval to an overridable async handler.
13
+ Attributes:
14
+ jwt_public (jwk.RSAKey):
15
+ The RSA public key (JWK format) used to verify the JWT signature.
16
+ Must be set externally before authentication occurs.
17
+ claims (dict[str, dict]):
18
+ A mapping defining expected JWT claims passed to jwt.JWTClaimsRegistry.
19
+ Each key corresponds to a claim name; values configure validation rules
20
+ (e.g., {'iss': {'value': 'https://issuer.example'}}).
21
+ algorithms (list[str]):
22
+ List of permitted JWT algorithms for signature verification. Defaults to ["RS256"].
23
+ dcd (jwt.Token | None):
24
+ Set after successful decode; holds the decoded token object (assigned dynamically).
25
+ Class Methods:
26
+ get_claims() -> jwt.JWTClaimsRegistry:
27
+ Constructs and returns a claims registry from the class-level claims definition.
28
+ Instance Methods:
29
+ validate_claims(claims: jwt.Claims) -> None:
30
+ Validates the provided claims object against the registry. Raises jose.errors.JoseError
31
+ or ValueError-derived exceptions if validation fails.
32
+ auth_handler(request: HttpRequest) -> Any:
33
+ Asynchronous hook to be overridden by subclasses to implement application-specific
34
+ user resolution (e.g., fetching a user model instance). Must return a user-like object
35
+ on success or raise / return False on failure.
36
+ authenticate(request: HttpRequest, token: str) -> Any | bool:
37
+ Orchestrates authentication:
38
+ 1. Attempts to decode the JWT using the configured public key and algorithms.
39
+ 2. Validates claims via validate_claims.
40
+ 3. Delegates to auth_handler for domain-specific user retrieval.
41
+ Returns the user object on success; returns False if decoding or claim validation fails.
42
+ Usage Notes:
43
+ - You must assign jwt_public (jwk.RSAKey) and populate claims before calling authenticate.
44
+ - Override auth_handler to integrate with your user persistence layer.
45
+ - Token decoding failures (e.g., signature mismatch, malformed token) result in False.
46
+ - Claim validation errors (e.g., expired token, issuer mismatch) result in False.
47
+ - This class does not itself raise HTTP errors; caller may translate False into an HTTP response.
48
+ Example Extension:
49
+ class MyBearer(AsyncJwtBearer):
50
+ jwt_public = jwk.RSAKey.import_key(open("pub.pem").read())
51
+ claims = {
52
+ "iss": {"value": "https://auth.example"},
53
+ "aud": {"value": "my-api"},
54
+ }
55
+ async def auth_handler(self, request):
56
+ sub = self.dcd.claims.get("sub")
57
+ return await get_user_by_id(sub)
58
+ Thread Safety:
59
+ - Instances are not inherently thread-safe if mutable shared state is attached.
60
+ - Prefer per-request instantiation or ensure read-only shared configuration.
61
+ Security Considerations:
62
+ - Ensure jwt_public key rotation strategy is in place.
63
+ - Validate critical claims (exp, nbf, iss, aud) via the claims registry configuration.
64
+ - Avoid logging raw tokens or sensitive claim contents.
65
+ Raises:
66
+ jose.errors.JoseError:
67
+ Propagated from validate_claims if claim checks fail.
68
+ ValueError:
69
+ May occur during token decoding (e.g., invalid structure) but is internally caught
70
+ and converted to a False return value.
71
+ Return Semantics:
72
+ - authenticate -> user object (success) | False (failure)
73
+ """
9
74
  jwt_public: jwk.RSAKey
10
75
  claims: dict[str, dict]
11
76
  algorithms: list[str] = ["RS256"]
ninja_aio/decoratos.py ADDED
@@ -0,0 +1,142 @@
1
+ from django.db.transaction import Atomic
2
+ from functools import wraps
3
+ from asgiref.sync import sync_to_async
4
+
5
+
6
+ class AsyncAtomicContextManager(Atomic):
7
+ def __init__(self, using=None, savepoint=True, durable=False):
8
+ super().__init__(using, savepoint, durable)
9
+
10
+ async def __aenter__(self):
11
+ await sync_to_async(super().__enter__)()
12
+ return self
13
+
14
+ async def __aexit__(self, exc_type, exc_value, traceback):
15
+ await sync_to_async(super().__exit__)(exc_type, exc_value, traceback)
16
+
17
+
18
+ def aatomic(func):
19
+ """
20
+ Decorator that executes the wrapped async function inside an asynchronous atomic
21
+ database transaction context.
22
+
23
+ This is useful when you want all ORM write operations performed by the coroutine
24
+ to either fully succeed or fully roll back on error, preserving data integrity.
25
+
26
+ Parameters:
27
+ func (Callable): The asynchronous function to wrap.
28
+
29
+ Returns:
30
+ Callable: A new async function that, when awaited, runs inside an
31
+ AsyncAtomicContextManager transaction.
32
+
33
+ Behavior:
34
+ - Opens an async atomic transaction before invoking the wrapped coroutine.
35
+ - Commits if the coroutine completes successfully.
36
+ - Rolls back if an exception is raised and propagates the original exception.
37
+
38
+ Example:
39
+ @aatomic
40
+ async def create_order(user_id: int, items: list[Item]):
41
+ # Perform multiple related DB writes atomically
42
+ ...
43
+
44
+ Notes:
45
+ - Ensure AsyncAtomicContextManager is properly implemented to integrate with
46
+ your async ORM / database backend.
47
+ - Only use on async functions.
48
+ """
49
+ @wraps(func)
50
+ async def wrapper(*args, **kwargs):
51
+ async with AsyncAtomicContextManager():
52
+ return await func(*args, **kwargs)
53
+ return wrapper
54
+
55
+
56
+ def unique_view(self: object | str, plural: bool = False):
57
+ """
58
+ Return a decorator that appends a model-specific suffix to a function's __name__ for uniqueness.
59
+
60
+ This is helpful when multiple view functions share a common base name but must be
61
+ distinct (e.g., for route registration, debugging, or introspection) per model context.
62
+
63
+ self : object | str
64
+ - If a string, it is used directly as the suffix.
65
+ - If an object, its `model_util` attribute is inspected for:
66
+ * model_util.model_name (when plural is False)
67
+ * model_util.verbose_name_view_resolver() (when plural is True)
68
+ Missing attributes or call failures result in no suffix being applied.
69
+ plural : bool, default False
70
+ If True and `self` is an object with `model_util.verbose_name_view_resolver`,
71
+ the resolved pluralized verbose name is used; otherwise the singular model name
72
+ (model_util.model_name) is used.
73
+
74
+ Callable[[Callable], Callable]
75
+ A decorator. When applied, it mutates the target function's __name__ in place to:
76
+ "<original_name>_<suffix>" if a suffix is resolved. If no suffix is found, the
77
+ function is returned unchanged.
78
+
79
+ - Does NOT wrap or alter the call signature or async/sync nature of the function.
80
+ - Performs a simple in-place mutation of func.__name__ before returning the original function.
81
+ - No metadata (e.g., __doc__, __qualname__) is altered besides __name__.
82
+
83
+ Suffix Resolution Logic
84
+ -----------------------
85
+ 1. If `self` is a str: suffix = self
86
+ 2. Else if `self` has `model_util`:
87
+ - plural == True: suffix = model_util.verbose_name_view_resolver()
88
+ - plural == False: suffix = model_util.model_name
89
+ 3. If resolution fails or yields a falsy value, no mutation occurs.
90
+
91
+ Side Effects
92
+ ------------
93
+ - Modifies function.__name__, which can affect:
94
+ - Debugging output
95
+ - Route registration relying on function names
96
+ - Tools expecting the original name
97
+ - Because mutation is in place, reusing the original function object elsewhere
98
+ may produce unexpected naming.
99
+
100
+ Examples
101
+ # Using a string suffix directly
102
+ @unique_view("book")
103
+ def list_items():
104
+
105
+ # Using an object with model_util.model_name
106
+ @unique_view(viewset_instance) # where viewset_instance.model_util.model_name == "author"
107
+ def retrieve():
108
+ # Resulting function name: "retrieve_author"
109
+
110
+ # Using plural form via verbose_name_view_resolver()
111
+ @unique_view(viewset_instance, plural=True) # e.g., returns "authors"
112
+ def list():
113
+ # Resulting function name: "list_authors"
114
+
115
+ Caveats
116
+ - If the underlying attributes or resolver callable raise exceptions, they are not caught.
117
+ - Ensure that the modified name does not conflict with other functions after decoration.
118
+ - Use cautiously when decorators relying on original __name__ appear earlier in the chain.
119
+ """
120
+ def decorator(func):
121
+ # Allow usage as unique_view(self_instance) or unique_view("model_name")
122
+ if isinstance(self, str):
123
+ suffix = self
124
+ else:
125
+ suffix = (
126
+ getattr(
127
+ getattr(self, "model_util", None),
128
+ "verbose_name_view_resolver",
129
+ None,
130
+ )()
131
+ if plural
132
+ else getattr(
133
+ getattr(self, "model_util", None),
134
+ "model_name",
135
+ None,
136
+ )
137
+ )
138
+ if suffix:
139
+ func.__name__ = f"{func.__name__}_{suffix}"
140
+ return func # Return original function (no wrapper)
141
+
142
+ return decorator
ninja_aio/exceptions.py CHANGED
@@ -4,6 +4,7 @@ from joserfc.errors import JoseError
4
4
  from ninja import NinjaAPI
5
5
  from django.http import HttpRequest, HttpResponse
6
6
  from pydantic import ValidationError
7
+ from django.db.models import Model
7
8
 
8
9
 
9
10
  class BaseException(Exception):
@@ -35,6 +36,18 @@ class AuthError(BaseException):
35
36
  pass
36
37
 
37
38
 
39
+ class NotFoundError(BaseException):
40
+ status_code = 404
41
+ error = "not found"
42
+
43
+ def __init__(self, model: Model, details=None):
44
+ super().__init__(
45
+ error={model._meta.verbose_name: self.error},
46
+ status_code=self.status_code,
47
+ details=details,
48
+ )
49
+
50
+
38
51
  class PydanticValidationError(BaseException):
39
52
  def __init__(self, details=None):
40
53
  super().__init__("Validation Error", 400, details)
ninja_aio/models.py CHANGED
@@ -17,7 +17,7 @@ from django.db.models.fields.related_descriptors import (
17
17
  ForwardOneToOneDescriptor,
18
18
  )
19
19
 
20
- from .exceptions import SerializeError
20
+ from .exceptions import SerializeError, NotFoundError
21
21
  from .types import S_TYPES, F_TYPES, SCHEMA_TYPES, ModelSerializerMeta
22
22
 
23
23
 
@@ -26,6 +26,79 @@ async def agetattr(obj, name: str, default=None):
26
26
 
27
27
 
28
28
  class ModelUtil:
29
+ """
30
+ ModelUtil
31
+ =========
32
+ Async utility bound to a Django model class (or a ModelSerializer subclass)
33
+ providing high‑level CRUD helpers plus (de)serialization glue for Django Ninja.
34
+
35
+ Overview
36
+ --------
37
+ Central responsibilities:
38
+ - Introspect model metadata (field list, pk name, verbose names).
39
+ - Normalize inbound payloads (custom / optional fields, FK resolution, base64 decoding).
40
+ - Normalize outbound payloads (resolve nested relation dicts into model instances).
41
+ - Prefetch reverse relations to mitigate N+1 issues.
42
+ - Invoke optional serializer hooks: custom_actions(), post_create(), queryset_request().
43
+
44
+ Compatible With
45
+ ---------------
46
+ - Plain Django models.
47
+ - Models using ModelSerializerMeta exposing:
48
+ get_fields(mode), is_custom(name), is_optional(name),
49
+ queryset_request(request), custom_actions(payload), post_create().
50
+
51
+ Key Methods
52
+ -----------
53
+ - get_object(request, pk=None, filters=None, getters=None, with_qs_request=True)
54
+ Returns a single object (when pk/getters) or a queryset (otherwise), with
55
+ select_related + prefetch_related applied.
56
+ - get_reverse_relations()
57
+ Discovers reverse relation names for safe prefetch_related usage.
58
+ - parse_input_data(request, data)
59
+ Converts a Schema into a model-ready dict:
60
+ * Strips custom + ignored optionals.
61
+ * Decodes BinaryField (base64 -> bytes).
62
+ * Replaces FK ids with related instances.
63
+ Returns (payload, customs_dict).
64
+ - parse_output_data(request, data)
65
+ Post-processes serialized output; replaces nested FK/OneToOne dicts
66
+ with authoritative related instances and rewrites nested FK keys to <name>_id.
67
+ - create_s / read_s / update_s / delete_s
68
+ High-level async CRUD operations wrapping the above transformations and hooks.
69
+
70
+ Error Handling
71
+ --------------
72
+ - Missing objects -> SerializeError({...}, 404).
73
+ - Bad base64 -> SerializeError({...}, 400).
74
+
75
+ Performance Notes
76
+ -----------------
77
+ - Each FK in parse_input_data triggers its own async fetch.
78
+ - Reverse relation prefetching is opportunistic; trim serializable fields
79
+ if over-fetching becomes an issue.
80
+
81
+ Return Shapes
82
+ -------------
83
+ - create_s / read_s / update_s -> dict (post-processed schema dump).
84
+ - delete_s -> None.
85
+ - get_object -> model instance or queryset.
86
+
87
+ Design Choices
88
+ --------------
89
+ - Stateless aside from holding the target model (safe to instantiate per request).
90
+ - Avoids caching; callers may add caching where profiling justifies it.
91
+ - Treats absent optional fields as "leave unchanged" (update) or "omit" (create).
92
+
93
+ Assumptions
94
+ -----------
95
+ - Schema provides model_dump(mode="json").
96
+ - Django async ORM (Django 4.1+).
97
+ - BinaryField inputs are base64 strings.
98
+ - Related primary keys are simple scalars on input.
99
+
100
+ """
101
+
29
102
  def __init__(self, model: type["ModelSerializer"] | models.Model):
30
103
  self.model = model
31
104
 
@@ -87,7 +160,7 @@ class ModelUtil:
87
160
  try:
88
161
  obj = await obj_qs.aget(**get_q)
89
162
  except ObjectDoesNotExist:
90
- raise SerializeError({self.model_name: "not found"}, 404)
163
+ raise NotFoundError(self.model)
91
164
 
92
165
  return obj
93
166
 
@@ -214,21 +287,278 @@ class ModelUtil:
214
287
 
215
288
 
216
289
  class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
290
+ """
291
+ ModelSerializer
292
+ =================
293
+ Abstract mixin for Django models centralizing (on the model class itself) the
294
+ declarative configuration required to auto-generate create / update / read /
295
+ related schemas.
296
+
297
+ Goals
298
+ - Remove duplication between Model and separate serializer classes.
299
+ - Provide clear extension points (sync + async hooks, custom synthetic fields).
300
+
301
+ Inner configuration classes
302
+ - CreateSerializer
303
+ fields : required model fields on create
304
+ optionals : optional model fields (accepted if present; ignored if None)
305
+ customs : [(name, type, default)] synthetic (non‑model) inputs
306
+ excludes : model fields disallowed on create
307
+ - UpdateSerializer (same structure; usually use optionals for PATCH-like behavior)
308
+ - ReadSerializer
309
+ fields : model fields to expose
310
+ excludes : fields always excluded (e.g. password)
311
+ customs : [(name, type, default)] computed outputs (property > callable > default)
312
+
313
+ Generated schema helpers
314
+ - generate_create_s() -> input schema ("In")
315
+ - generate_update_s() -> input schema for partial/full update ("Patch")
316
+ - generate_read_s() -> detailed output schema ("Out")
317
+ - generate_related_s() -> compact nested schema ("Related")
318
+
319
+ Relation handling (only if related model is also a ModelSerializer)
320
+ - Forward FK / OneToOne serialized as single nested objects
321
+ - Reverse OneToOne / Reverse FK / M2M serialized as single or list
322
+ - Relations skipped if related model exposes no read/custom fields
323
+
324
+ Classification helpers
325
+ - is_custom(field) -> True if declared in create/update customs
326
+ - is_optional(field) -> True if declared in create/update optionals
327
+
328
+ Sync lifecycle hooks (override as needed)
329
+ save():
330
+ on_create_before_save() (only first insert)
331
+ before_save()
332
+ super().save()
333
+ on_create_after_save() (only first insert)
334
+ after_save()
335
+ delete():
336
+ super().delete()
337
+ on_delete()
338
+
339
+ Async extension points
340
+ - queryset_request(request): request-scoped queryset filtering
341
+ - post_create(): async logic after creation
342
+ - custom_actions(payload_customs): react to synthetic fields
343
+
344
+ Utilities
345
+ - has_changed(field): compares in-memory value vs DB persisted value
346
+ - verbose_name_path_resolver(): slugified plural verbose name
347
+
348
+ Implementation notes
349
+ - If both fields and excludes are empty (create/update), optionals are used as the base.
350
+ - customs + optionals are passed as custom_fields to the schema factory.
351
+ - Nested relation schemas generated only if the related model explicitly declares
352
+ readable or custom fields.
353
+
354
+ Minimal example
355
+ ```python
356
+ from django.db import models
357
+ from ninja_aio.models import ModelSerializer
358
+
359
+
360
+ class User(ModelSerializer):
361
+ username = models.CharField(max_length=150, unique=True)
362
+ email = models.EmailField(unique=True)
363
+
364
+ class CreateSerializer:
365
+ fields = ["username", "email"]
366
+
367
+ class ReadSerializer:
368
+ fields = ["id", "username", "email"]
369
+
370
+ def __str__(self):
371
+ return self.username
372
+ ```
373
+ -------------------------------------
374
+ Conceptual Equivalent (Ninja example)
375
+ Using django-ninja you might otherwise write:
376
+ ```python
377
+ from ninja import ModelSchema
378
+ from api.models import User
379
+
380
+
381
+ class UserIn(ModelSchema):
382
+ class Meta:
383
+ model = User
384
+ fields = ["username", "email"]
385
+
386
+
387
+ class UserOut(ModelSchema):
388
+ class Meta:
389
+ model = User
390
+ model_fields = ["id", "username", "email"]
391
+ ```
392
+
393
+ Summary
394
+ Centralizes serialization intent on the model, reducing boilerplate and keeping
395
+ API and model definitions consistent.
396
+ """
217
397
  class Meta:
218
398
  abstract = True
219
399
 
220
400
  class CreateSerializer:
401
+ """Configuration container describing how to build a create (input) schema for a model.
402
+
403
+ Purpose
404
+ -------
405
+ Describes which fields are accepted (and in what form) when creating a new
406
+ instance. A factory/metaclass can read this configuration to generate a
407
+ Pydantic / Ninja input schema.
408
+
409
+ Attributes
410
+ ----------
411
+ fields : list[str]
412
+ Explicit REQUIRED model field names for creation. If empty, the
413
+ implementation may infer required fields from the model (excluding
414
+ auto / read-only fields). Prefer being explicit.
415
+ optionals : list[tuple[str, type]]
416
+ Model fields allowed on create but not required. Each tuple:
417
+ (field_name, python_type)
418
+ If omitted in the payload they are ignored; if present with null/None
419
+ the caller signals an intentional null (subject to model constraints).
420
+ customs : list[tuple[str, type, Any]]
421
+ Non-model / synthetic input fields driving creation logic (e.g.,
422
+ password confirmation, initial related IDs, flags). Each tuple:
423
+ (name, python_type, default_value)
424
+ Resolution order (implementation-dependent):
425
+ 1. Value provided by the incoming payload.
426
+ 2. If default_value is callable -> invoked (passing model class or
427
+ context if supported).
428
+ 3. Literal default_value.
429
+ These values are typically consumed inside custom_actions or post_create
430
+ hooks and are NOT persisted directly unless you do so manually.
431
+ excludes : list[str]
432
+ Model field names that must be rejected on create (e.g., "id",
433
+ audit fields, computed columns).
434
+
435
+ Recommended Conventions
436
+ -----------------------
437
+ - Always exclude primary keys and auto-managed timestamps.
438
+ - Keep customs minimal and clearly documented.
439
+ - Use optionals instead of putting nullable fields in fields if they are
440
+ not logically required for initial creation.
441
+
442
+ Extensibility
443
+ -------------
444
+ A higher-level builder can:
445
+ 1. Collect fields + optionals + customs.
446
+ 2. Build a schema where fields are required, optionals are Optional[…],
447
+ and customs become additional inputs not mapped directly to the model.
448
+ """
221
449
  fields: list[str] = []
222
450
  customs: list[tuple[str, type, Any]] = []
223
451
  optionals: list[tuple[str, type]] = []
224
452
  excludes: list[str] = []
225
453
 
226
454
  class ReadSerializer:
455
+ """Configuration container describing how to build a read (output) schema for a model.
456
+
457
+ Attributes
458
+ ---------
459
+ fields : list[str]
460
+ Explicit model field names to include in the read schema. If empty, an
461
+ implementation may choose to include all model fields (or none, depending
462
+ on the consuming logic).
463
+ excludes : list[str]
464
+ Model field names to force-exclude even if they would otherwise be included
465
+ (e.g., sensitive columns like password, secrets, internal flags).
466
+ customs : list[tuple[str, type, Any]]
467
+ Additional computed / synthetic attributes to append to the serialized
468
+ output. Each tuple is:
469
+ (attribute_name, python_type, default_value)
470
+ The attribute is resolved in the following preferred order (implementation
471
+ dependent):
472
+ 1. Attribute / property on the model instance with that name.
473
+ 2. Callable (if the default_value is a callable) invoked to produce a value.
474
+ 3. Fallback to the literal default_value.
475
+ Example:
476
+ customs = [
477
+ ("full_name", str, lambda obj: f"{obj.first_name} {obj.last_name}".strip())
478
+ ]
479
+
480
+ Conceptual Equivalent (Ninja example)
481
+ -------------------------------------
482
+ Using django-ninja you might otherwise write:
483
+
484
+ ```python
485
+ from ninja import ModelSchema
486
+ from api.models import User
487
+
488
+
489
+ class UserOut(ModelSchema):
490
+ class Meta:
491
+ model = User
492
+ model_fields = ["id", "username", "email"]
493
+ ```
494
+
495
+ This ReadSerializer object centralizes the same intent in a lightweight,
496
+ framework-agnostic configuration primitive that can be inspected to build
497
+ schemas dynamically.
498
+
499
+ Recommended Conventions
500
+ -----------------------
501
+ - Keep fields minimal; prefer explicit inclusion over implicit broad exposure.
502
+ - Use excludes as a safety net (e.g., always exclude "password").
503
+ - For customs, always specify a concrete python_type for better downstream
504
+ validation / OpenAPI generation.
505
+ - Prefer callables as default_value when computing derived data; use simple
506
+ literals only for static fallbacks.
507
+
508
+ Extensibility Notes
509
+ -------------------
510
+ A higher-level factory or metaclass can:
511
+ 1. Read these lists.
512
+ 2. Reflect on the model.
513
+ 3. Generate a Pydantic / Ninja schema class at runtime.
514
+ This separation enables cleaner unit testing (the config is pure data) and
515
+ reduces coupling to a specific serialization framework."""
227
516
  fields: list[str] = []
228
517
  excludes: list[str] = []
229
518
  customs: list[tuple[str, type, Any]] = []
230
519
 
231
520
  class UpdateSerializer:
521
+ """Configuration container describing how to build an update (partial/full) input schema.
522
+
523
+ Purpose
524
+ -------
525
+ Defines which fields can be changed and how they are treated when updating
526
+ an existing instance (PATCH / PUT–style operations).
527
+
528
+ Attributes
529
+ ----------
530
+ fields : list[str]
531
+ Explicit REQUIRED fields for an update operation (rare; most updates are
532
+ partial so this is often left empty). If non-empty, these must be present
533
+ in the payload.
534
+ optionals : list[tuple[str, type]]
535
+ Updatable fields that are optional (most typical case). Omitted fields
536
+ are left untouched. Provided null/None values indicate an explicit attempt
537
+ to nullify (subject to model constraints).
538
+ customs : list[tuple[str, type, Any]]
539
+ Non-model / instruction fields guiding update behavior (e.g., "rotate_key",
540
+ "regenerate_token"). Each tuple:
541
+ (name, python_type, default_value)
542
+ Resolution order mirrors CreateSerializer (payload > callable > literal).
543
+ Typically consumed in custom_actions before or after saving.
544
+ excludes : list[str]
545
+ Fields that must never be updated (immutable or managed fields).
546
+
547
+ Recommended Conventions
548
+ -----------------------
549
+ - Prefer listing editable columns in optionals rather than fields to facilitate
550
+ partial updates.
551
+ - Use customs for operational flags (e.g., "reset_password": bool).
552
+ - Keep excludes synchronized with CreateSerializer excludes where appropriate.
553
+
554
+ Extensibility
555
+ -------------
556
+ A schema builder can:
557
+ 1. Treat fields as required.
558
+ 2. Treat optionals as Optional[…].
559
+ 3. Inject customs as additional validated inputs.
560
+ 4. Enforce excludes by rejecting them if present in incoming data.
561
+ """
232
562
  fields: list[str] = []
233
563
  customs: list[tuple[str, type, Any]] = []
234
564
  optionals: list[tuple[str, type]] = []
ninja_aio/views.py CHANGED
@@ -17,6 +17,7 @@ from .schemas import (
17
17
  M2MRemoveSchemaIn,
18
18
  )
19
19
  from .types import ModelSerializerMeta, VIEW_TYPES
20
+ from .decoratos import unique_view
20
21
 
21
22
  ERROR_CODES = frozenset({400, 401, 404, 428})
22
23
 
@@ -243,10 +244,9 @@ class APIViewSet:
243
244
  description=self.create_docs,
244
245
  response={201: self.schema_out, self.error_codes: GenericMessageSchema},
245
246
  )
247
+ @unique_view(self)
246
248
  async def create(request: HttpRequest, data: self.schema_in): # type: ignore
247
249
  return 201, await self.model_util.create_s(request, data, self.schema_out)
248
-
249
- create.__name__ = f"create_{self.model_util.model_name}"
250
250
  return create
251
251
 
252
252
  def list_view(self):
@@ -260,6 +260,7 @@ class APIViewSet:
260
260
  self.error_codes: GenericMessageSchema,
261
261
  },
262
262
  )
263
+ @unique_view(self, plural=True)
263
264
  @paginate(self.pagination_class)
264
265
  async def list(
265
266
  request: HttpRequest,
@@ -278,8 +279,6 @@ class APIViewSet:
278
279
  async for obj in qs.all()
279
280
  ]
280
281
  return objs
281
-
282
- list.__name__ = f"list_{self.model_util.verbose_name_view_resolver()}"
283
282
  return list
284
283
 
285
284
  def retrieve_view(self):
@@ -290,11 +289,10 @@ class APIViewSet:
290
289
  description=self.retrieve_docs,
291
290
  response={200: self.schema_out, self.error_codes: GenericMessageSchema},
292
291
  )
292
+ @unique_view(self)
293
293
  async def retrieve(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
294
294
  obj = await self.model_util.get_object(request, self._get_pk(pk))
295
295
  return await self.model_util.read_s(request, obj, self.schema_out)
296
-
297
- retrieve.__name__ = f"retrieve_{self.model_util.model_name}"
298
296
  return retrieve
299
297
 
300
298
  def update_view(self):
@@ -305,6 +303,7 @@ class APIViewSet:
305
303
  description=self.update_docs,
306
304
  response={200: self.schema_out, self.error_codes: GenericMessageSchema},
307
305
  )
306
+ @unique_view(self)
308
307
  async def update(
309
308
  request: HttpRequest,
310
309
  data: self.schema_update, # type: ignore
@@ -313,8 +312,6 @@ class APIViewSet:
313
312
  return await self.model_util.update_s(
314
313
  request, data, self._get_pk(pk), self.schema_out
315
314
  )
316
-
317
- update.__name__ = f"update_{self.model_util.model_name}"
318
315
  return update
319
316
 
320
317
  def delete_view(self):
@@ -325,10 +322,10 @@ class APIViewSet:
325
322
  description=self.delete_docs,
326
323
  response={204: None, self.error_codes: GenericMessageSchema},
327
324
  )
325
+
326
+ @unique_view(self)
328
327
  async def delete(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
329
328
  return 204, await self.model_util.delete_s(request, self._get_pk(pk))
330
-
331
- delete.__name__ = f"delete_{self.model_util.model_name}"
332
329
  return delete
333
330
 
334
331
  def views(self):
@@ -1,14 +0,0 @@
1
- ninja_aio/__init__.py,sha256=fZFssPDWLCd-cBFhbchuSNtmVBoW_DIr8XNcWxN2Y_4,120
2
- ninja_aio/api.py,sha256=Fe6l3YCy7MW5TY4-Lbl80CFuK2NT2Y7tHfmqPk6Mqak,1735
3
- ninja_aio/auth.py,sha256=c_ILAySswjbSIqnE9Y0G5n1qreXzAtSAaWfrhyer-i8,1283
4
- ninja_aio/exceptions.py,sha256=gPnZX1Do2GXudbU8wDYkwhO70Qj0ZNrIJJ2UXRs9vYk,2241
5
- ninja_aio/models.py,sha256=r-Ku5EOSTq1eAeXZHVz0ILTyXBmbS4o46JYA_Cp_B8w,18969
6
- ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
7
- ninja_aio/renders.py,sha256=0eYklRKd59aV4cZDom5vLZyA99Ob17OwkpMybsRXvyg,1970
8
- ninja_aio/schemas.py,sha256=Fzu2ko3kUxkOnrjG5QYdmOXZd2gcpYGjVuocCW44NfQ,473
9
- ninja_aio/types.py,sha256=TJSGlA7bt4g9fvPhJ7gzH5tKbLagPmZUzfgttEOp4xs,468
10
- ninja_aio/views.py,sha256=Mb38z0QS9Iwe-qB9wuuxcfCOJymRI9SppbHOfns6yHg,20944
11
- django_ninja_aio_crud-0.10.3.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
12
- django_ninja_aio_crud-0.10.3.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
13
- django_ninja_aio_crud-0.10.3.dist-info/METADATA,sha256=FoNR5DJfYjhC9C9wJyLRsUZnww65ScjS7RpT-av5OxQ,14139
14
- django_ninja_aio_crud-0.10.3.dist-info/RECORD,,