django-ninja-aio-crud 0.10.3__tar.gz → 0.11.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.
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/PKG-INFO +2 -1
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/ninja_aio/__init__.py +1 -1
- django_ninja_aio_crud-0.11.0/ninja_aio/auth.py +108 -0
- django_ninja_aio_crud-0.11.0/ninja_aio/decoratos.py +142 -0
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/ninja_aio/exceptions.py +13 -0
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/ninja_aio/models.py +332 -2
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/ninja_aio/views.py +7 -10
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/pyproject.toml +1 -0
- django_ninja_aio_crud-0.10.3/ninja_aio/auth.py +0 -43
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/LICENSE +0 -0
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/README.md +0 -0
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/ninja_aio/api.py +0 -0
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/ninja_aio/parsers.py +0 -0
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/ninja_aio/renders.py +0 -0
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/ninja_aio/schemas.py +0 -0
- {django_ninja_aio_crud-0.10.3 → django_ninja_aio_crud-0.11.0}/ninja_aio/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-ninja-aio-crud
|
|
3
|
-
Version: 0.
|
|
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,108 @@
|
|
|
1
|
+
from joserfc import jwt, jwk, errors
|
|
2
|
+
from django.http.request import HttpRequest
|
|
3
|
+
from ninja.security.http import HttpBearer
|
|
4
|
+
|
|
5
|
+
from .exceptions import AuthError
|
|
6
|
+
|
|
7
|
+
|
|
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
|
+
"""
|
|
74
|
+
jwt_public: jwk.RSAKey
|
|
75
|
+
claims: dict[str, dict]
|
|
76
|
+
algorithms: list[str] = ["RS256"]
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def get_claims(cls):
|
|
80
|
+
return jwt.JWTClaimsRegistry(**cls.claims)
|
|
81
|
+
|
|
82
|
+
def validate_claims(self, claims: jwt.Claims):
|
|
83
|
+
jwt_claims = self.get_claims()
|
|
84
|
+
jwt_claims.validate(claims)
|
|
85
|
+
|
|
86
|
+
async def auth_handler(self, request: HttpRequest):
|
|
87
|
+
"""
|
|
88
|
+
Override this method to make your own authentication
|
|
89
|
+
"""
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
async def authenticate(self, request: HttpRequest, token: str):
|
|
93
|
+
"""
|
|
94
|
+
Authenticate the request and return the user if authentication is successful.
|
|
95
|
+
If authentication fails, returns false.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
self.dcd = jwt.decode(token, self.jwt_public, algorithms=self.algorithms)
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
# raise AuthError(", ".join(exc.args), 401)
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
self.validate_claims(self.dcd.claims)
|
|
105
|
+
except errors.JoseError as exc:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
return await self.auth_handler(request)
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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
|
|
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]] = []
|
|
@@ -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,43 +0,0 @@
|
|
|
1
|
-
from joserfc import jwt, jwk, errors
|
|
2
|
-
from django.http.request import HttpRequest
|
|
3
|
-
from ninja.security.http import HttpBearer
|
|
4
|
-
|
|
5
|
-
from .exceptions import AuthError
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class AsyncJwtBearer(HttpBearer):
|
|
9
|
-
jwt_public: jwk.RSAKey
|
|
10
|
-
claims: dict[str, dict]
|
|
11
|
-
algorithms: list[str] = ["RS256"]
|
|
12
|
-
|
|
13
|
-
@classmethod
|
|
14
|
-
def get_claims(cls):
|
|
15
|
-
return jwt.JWTClaimsRegistry(**cls.claims)
|
|
16
|
-
|
|
17
|
-
def validate_claims(self, claims: jwt.Claims):
|
|
18
|
-
jwt_claims = self.get_claims()
|
|
19
|
-
jwt_claims.validate(claims)
|
|
20
|
-
|
|
21
|
-
async def auth_handler(self, request: HttpRequest):
|
|
22
|
-
"""
|
|
23
|
-
Override this method to make your own authentication
|
|
24
|
-
"""
|
|
25
|
-
pass
|
|
26
|
-
|
|
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
|
-
"""
|
|
32
|
-
try:
|
|
33
|
-
self.dcd = jwt.decode(token, self.jwt_public, algorithms=self.algorithms)
|
|
34
|
-
except ValueError as exc:
|
|
35
|
-
# raise AuthError(", ".join(exc.args), 401)
|
|
36
|
-
return False
|
|
37
|
-
|
|
38
|
-
try:
|
|
39
|
-
self.validate_claims(self.dcd.claims)
|
|
40
|
-
except errors.JoseError as exc:
|
|
41
|
-
return False
|
|
42
|
-
|
|
43
|
-
return await self.auth_handler(request)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|