django-ninja-aio-crud 0.1.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.
- django_ninja_aio_crud-0.1.0.dist-info/METADATA +273 -0
- django_ninja_aio_crud-0.1.0.dist-info/RECORD +11 -0
- django_ninja_aio_crud-0.1.0.dist-info/WHEEL +4 -0
- ninja_aio/__init__.py +3 -0
- ninja_aio/auth.py +50 -0
- ninja_aio/exceptions.py +24 -0
- ninja_aio/models.py +305 -0
- ninja_aio/parsers.py +7 -0
- ninja_aio/renders.py +43 -0
- ninja_aio/schemas.py +5 -0
- ninja_aio/views.py +194 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-ninja-aio-crud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django Ninja AIO CRUD - Rest Framework
|
|
5
|
+
Author: Giuseppe Casillo
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Topic :: Internet
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
13
|
+
Classifier: Topic :: Software Development
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Framework :: Django
|
|
22
|
+
Classifier: Framework :: AsyncIO
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Requires-Dist: django-ninja >=1.3.0
|
|
26
|
+
Requires-Dist: joserfc >=1.0.0
|
|
27
|
+
Requires-Dist: orjson >= 3.10.7
|
|
28
|
+
Project-URL: Repository, https://github.com/caspel26/django-ninja-aio-crud
|
|
29
|
+
|
|
30
|
+
# 🥷 django-ninja-aio-crud
|
|
31
|
+
> [!NOTE]
|
|
32
|
+
> Django ninja aio crud framework is based on **<a href="https://django-ninja.dev/">Django Ninja framework</a>**. It comes out with built-in views and models which are able to make automatic async CRUD operations and codes views class based making the developers' life easier and the code cleaner.
|
|
33
|
+
|
|
34
|
+
## 📝 Instructions
|
|
35
|
+
|
|
36
|
+
### 📚 Prerequisites
|
|
37
|
+
- Install Python from the [official website](https://www.python.org/) (latest version) and ensure it is added to the system Path and environment variables.
|
|
38
|
+
|
|
39
|
+
### 💻 Setup your environment
|
|
40
|
+
- Create a virtual environment
|
|
41
|
+
```bash
|
|
42
|
+
python -m venv .venv
|
|
43
|
+
```
|
|
44
|
+
### ✅ Activate it
|
|
45
|
+
- If you are from linux activate it with
|
|
46
|
+
```bash
|
|
47
|
+
. .venv/bin/activate
|
|
48
|
+
```
|
|
49
|
+
- If you are from windows activate it with
|
|
50
|
+
```bash
|
|
51
|
+
. .venv/Scripts/activate
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 📥 Install requirements
|
|
55
|
+
```bash
|
|
56
|
+
pip install django-ninja-aio
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 🚀 Usage
|
|
60
|
+
|
|
61
|
+
> [!TIP]
|
|
62
|
+
> If you find **django ninja aio crud** useful, consider :star: this project
|
|
63
|
+
> and why not ... [Buy me a coffee](https://buymeacoffee.com/caspel26)
|
|
64
|
+
|
|
65
|
+
### ModelSerializer
|
|
66
|
+
- You can serialize your models using ModelSerializer and made them inherit from it. In your models.py import ModelSerializer
|
|
67
|
+
```Python
|
|
68
|
+
from ninja_aio.models import ModelSerializer
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Foo(ModelSerializer):
|
|
72
|
+
name = mdoels.CharField()
|
|
73
|
+
bar = models.CharField()
|
|
74
|
+
|
|
75
|
+
class ReadSerializer:
|
|
76
|
+
fields = ["id", "name", "bar"]
|
|
77
|
+
|
|
78
|
+
class CreateSerializer:
|
|
79
|
+
fields = ["name", "bar"]
|
|
80
|
+
|
|
81
|
+
class UpdateSerializer:
|
|
82
|
+
fields = ["name", "bar"]
|
|
83
|
+
```
|
|
84
|
+
- ReadSerializer, CreateSerializer, UpdateSerializer are used to define which fields would be included in runtime schemas creation. You can also specify custom fields and handle their function by overriding custom_actions ModelSerializer's method(custom fields are only available for Create and Update serializers).
|
|
85
|
+
```Python
|
|
86
|
+
from ninja_aio.models import ModelSerializer
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Foo(ModelSerializer):
|
|
90
|
+
name = mdoels.CharField()
|
|
91
|
+
bar = models.CharField()
|
|
92
|
+
active = models.BooleanField(default=False)
|
|
93
|
+
|
|
94
|
+
class ReadSerializer:
|
|
95
|
+
fields = ["id", "name", "bar"]
|
|
96
|
+
|
|
97
|
+
class CreateSerializer:
|
|
98
|
+
customs = [("force_activation", bool, False)]
|
|
99
|
+
fields = ["name", "bar"]
|
|
100
|
+
|
|
101
|
+
class UpdateSerializer:
|
|
102
|
+
fields = ["name", "bar"]
|
|
103
|
+
|
|
104
|
+
async def custom_actions(self, payload: dict[str, Any]):
|
|
105
|
+
if not payload.get("force_activation"):
|
|
106
|
+
return
|
|
107
|
+
setattr(self, "force_activation", True)
|
|
108
|
+
|
|
109
|
+
async def post_create(self) -> None:
|
|
110
|
+
if not hasattr(self, "force_activation") or not getattr(self, "force_activation"):
|
|
111
|
+
return
|
|
112
|
+
self.active = True
|
|
113
|
+
await self.asave()
|
|
114
|
+
```
|
|
115
|
+
- post create method is a custom method that comes out to handle actions which will be excuted after that the object is created. It can be used, indeed, for example to handle custom fields' actions.
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
### APIViewSet
|
|
119
|
+
- View class used to automatically generate CRUD views. in your views.py import APIViewSet and define your api using NinjaAPI class. As Parser and Render of the API you must use ninja_aio built-in classes which will serialize data using orjson.
|
|
120
|
+
```Python
|
|
121
|
+
from ninja import NinjAPI
|
|
122
|
+
from ninja_aio.views import APIViewSet
|
|
123
|
+
from ninja_aio.parsers import ORJSONParser
|
|
124
|
+
from ninja_aio.renders import ORJSONRender
|
|
125
|
+
|
|
126
|
+
from .models import Foo
|
|
127
|
+
|
|
128
|
+
api = NinjaAPI(renderer=ORJSONRenderer(), parser=ORJSONParser())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class FooAPI(APIViewSet):
|
|
132
|
+
model = Foo
|
|
133
|
+
api = api
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
FooAPI().add_views_to_route()
|
|
137
|
+
```
|
|
138
|
+
- and that's it, your model CRUD will be automatically created. You can also add custom views to CRUD overriding the built-in method "views".
|
|
139
|
+
```Python
|
|
140
|
+
from ninja import NinjAPI, Schema
|
|
141
|
+
from ninja_aio.views import APIViewSet
|
|
142
|
+
from ninja_aio.parsers import ORJSONParser
|
|
143
|
+
from ninja_aio.renders import ORJSONRender
|
|
144
|
+
|
|
145
|
+
from .models import Foo
|
|
146
|
+
|
|
147
|
+
api = NinjaAPI(renderer=ORJSONRenderer(), parser=ORJSONParser())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ExampleSchemaOut(Schema):
|
|
151
|
+
sum: float
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ExampleSchemaIn(Schema):
|
|
155
|
+
n1: float
|
|
156
|
+
n2: float
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class FooAPI(APIViewSet):
|
|
160
|
+
model = Foo
|
|
161
|
+
api = api
|
|
162
|
+
|
|
163
|
+
def views(self):
|
|
164
|
+
@self.router.post("numbers-sum/", response={200: ExampleSchemaOut)
|
|
165
|
+
async def sum(request: HttpRequest, data: ExampleSchemaIn):
|
|
166
|
+
return 200, {sum: data.n1 + data.n2}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
FooAPI().add_views_to_route()
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### APIView
|
|
173
|
+
- View class to code generic views class based. In your views.py import APIView class.
|
|
174
|
+
```Python
|
|
175
|
+
from ninja import NinjAPI, Schema
|
|
176
|
+
from ninja_aio.views import APIView
|
|
177
|
+
from ninja_aio.parsers import ORJSONParser
|
|
178
|
+
from ninja_aio.renders import ORJSONRender
|
|
179
|
+
|
|
180
|
+
api = NinjaAPI(renderer=ORJSONRenderer(), parser=ORJSONParser())
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ExampleSchemaOut(Schema):
|
|
184
|
+
sum: float
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ExampleSchemaIn(Schema):
|
|
188
|
+
n1: float
|
|
189
|
+
n2: float
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class SumView(APIView):
|
|
193
|
+
api = api
|
|
194
|
+
api_router_path = "numbers-sum/"
|
|
195
|
+
router_tag = "Sum"
|
|
196
|
+
|
|
197
|
+
def views(self):
|
|
198
|
+
@self.router.post(self.api_router_path, response={200: ExampleSchemaOut)
|
|
199
|
+
async def sum(request: HttpRequest, data: ExampleSchemaIn):
|
|
200
|
+
return 200, {sum: data.n1 + data.n2}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
SumView().add_views_to_route()
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## 🔒 Authentication
|
|
207
|
+
### Jwt
|
|
208
|
+
- AsyncJWTBearer built-in class is an authenticator class which use joserfc module. It cames out with authenticate method which validate given claims. Override auth handler method to write your own authentication method. Default algorithms used is RS256. a jwt Token istance is set as class atribute so you can use it by self.dcd.
|
|
209
|
+
```Python
|
|
210
|
+
from ninja_aio.auth import AsyncJWTBearer
|
|
211
|
+
from django.conf import settings
|
|
212
|
+
from django.http import HttpRequest
|
|
213
|
+
|
|
214
|
+
from .models import Foo
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class CustomJWTBearer(AsyncJWTBearer):
|
|
218
|
+
jwt_public = settings.JWT_PUBLIC
|
|
219
|
+
claims = {"foo_id": {"essential": True}}
|
|
220
|
+
|
|
221
|
+
async def auth_handler(self, request: HttpRequest):
|
|
222
|
+
try:
|
|
223
|
+
request.user = await Foo.objects.aget(id=self.dcd.claims["foo_id"])
|
|
224
|
+
except Foo.DoesNotExist:
|
|
225
|
+
return None
|
|
226
|
+
return request.user
|
|
227
|
+
```
|
|
228
|
+
- Then add it to views.
|
|
229
|
+
```Python
|
|
230
|
+
from ninja import NinjAPI, Schema
|
|
231
|
+
from ninja_aio.views import APIViewSet, APIView
|
|
232
|
+
from ninja_aio.parsers import ORJSONParser
|
|
233
|
+
from ninja_aio.renders import ORJSONRender
|
|
234
|
+
|
|
235
|
+
from .models import Foo
|
|
236
|
+
|
|
237
|
+
api = NinjaAPI(renderer=ORJSONRenderer(), parser=ORJSONParser())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class FooAPI(APIViewSet):
|
|
241
|
+
model = Foo
|
|
242
|
+
api = api
|
|
243
|
+
auths = CustomJWTBearer()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class ExampleSchemaOut(Schema):
|
|
247
|
+
sum: float
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class ExampleSchemaIn(Schema):
|
|
251
|
+
n1: float
|
|
252
|
+
n2: float
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class SumView(APIView):
|
|
256
|
+
api = api
|
|
257
|
+
api_router_path = "numbers-sum/"
|
|
258
|
+
router_tag = "Sum"
|
|
259
|
+
auths = CustomJWTBearer()
|
|
260
|
+
|
|
261
|
+
def views(self):
|
|
262
|
+
@self.router.post(self.api_router_path, response={200: ExampleSchemaOut}, auth=self.auths)
|
|
263
|
+
async def sum(request: HttpRequest, data: ExampleSchemaIn):
|
|
264
|
+
return 200, {sum: data.n1 + data.n2}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
FooAPI().add_views_to_route()
|
|
268
|
+
SumView().add_views_to_route()
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## 📌 Notes
|
|
272
|
+
- Feel free to contribute and improve the program. 🛠️
|
|
273
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ninja_aio/__init__.py,sha256=b8kPWi0QdIvtWqqVRGTPL-1Nnwvlnf-FNDPVUvZOq8s,69
|
|
2
|
+
ninja_aio/auth.py,sha256=hGgiblvffpHmmakjaxdNT3G0tq39-Bvc5oLNqjn4qd8,1300
|
|
3
|
+
ninja_aio/exceptions.py,sha256=PPNr1CdC7M7Kx1MtiBGuR4hATYQqEuvxCRU6uSG7rgM,543
|
|
4
|
+
ninja_aio/models.py,sha256=G6AOtGNtaiQNZ1nG6rxv-xNRzZmSgbrNYwQjXmNeFmo,10710
|
|
5
|
+
ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
|
|
6
|
+
ninja_aio/renders.py,sha256=uADkEyHQr4yet2h9j89Zjr9AbIElWCiLVk5HoqLSm10,1453
|
|
7
|
+
ninja_aio/schemas.py,sha256=EgRkfhnzZqwGvdBmqlZixMtMcoD1ZxV_qzJ3fmaAy20,113
|
|
8
|
+
ninja_aio/views.py,sha256=pEtc0ovXvymfKJRtthd8cAps2RetTr9gzn-Xmxs763o,6126
|
|
9
|
+
django_ninja_aio_crud-0.1.0.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
|
|
10
|
+
django_ninja_aio_crud-0.1.0.dist-info/METADATA,sha256=atQGOc7SRwQLalwhnT65yJCqdAIiqTQXyyIpz05WH0s,8031
|
|
11
|
+
django_ninja_aio_crud-0.1.0.dist-info/RECORD,,
|
ninja_aio/__init__.py
ADDED
ninja_aio/auth.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
|
|
9
|
+
class AsyncJwtBearer(HttpBearer):
|
|
10
|
+
jwt_public: jwk.RSAKey
|
|
11
|
+
claims: dict[str, dict]
|
|
12
|
+
algorithms: list[str] = ["RS256"]
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def get_claims(cls):
|
|
16
|
+
return jwt.JWTClaimsRegistry(**cls.claims)
|
|
17
|
+
|
|
18
|
+
def validate_claims(self, claims: jwt.Claims):
|
|
19
|
+
jwt_claims = self.get_claims()
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
jwt_claims.validate(claims)
|
|
23
|
+
except (
|
|
24
|
+
errors.InvalidClaimError,
|
|
25
|
+
errors.MissingClaimError,
|
|
26
|
+
errors.ExpiredTokenError,
|
|
27
|
+
):
|
|
28
|
+
raise AuthError()
|
|
29
|
+
|
|
30
|
+
async def auth_handler(self, request: HttpRequest):
|
|
31
|
+
"""
|
|
32
|
+
Override this method to make your own authentication
|
|
33
|
+
"""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
async def authenticate(self, request: HttpRequest, token: str):
|
|
37
|
+
try:
|
|
38
|
+
self.dcd = jwt.decode(token, self.jwt_public, algorithms=self.algorithms)
|
|
39
|
+
except (
|
|
40
|
+
errors.BadSignatureError,
|
|
41
|
+
ValueError,
|
|
42
|
+
):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
self.validate_claims(self.dcd.claims)
|
|
47
|
+
except AuthError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
return await self.auth_handler(request)
|
ninja_aio/exceptions.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class BaseException(Exception):
|
|
2
|
+
error: str | dict = ""
|
|
3
|
+
status_code: int = 400
|
|
4
|
+
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
error: str | dict = None,
|
|
8
|
+
status_code: int | None = None,
|
|
9
|
+
is_critical: bool = False,
|
|
10
|
+
) -> None:
|
|
11
|
+
self.error = error or self.error
|
|
12
|
+
self.status_code = status_code or self.status_code
|
|
13
|
+
self.is_critical = is_critical
|
|
14
|
+
|
|
15
|
+
def get_error(self):
|
|
16
|
+
return self.error, self.status_code
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SerializeError(BaseException):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthError(BaseException):
|
|
24
|
+
pass
|
ninja_aio/models.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from typing import Any, Literal
|
|
3
|
+
|
|
4
|
+
from ninja.schema import Schema
|
|
5
|
+
from ninja.orm import create_schema
|
|
6
|
+
|
|
7
|
+
from django.db import models
|
|
8
|
+
from django.http import HttpResponse, HttpRequest
|
|
9
|
+
from django.core.exceptions import ObjectDoesNotExist
|
|
10
|
+
from django.db.models.fields.related import OneToOneRel
|
|
11
|
+
from django.db.models.fields.related_descriptors import (
|
|
12
|
+
ReverseManyToOneDescriptor,
|
|
13
|
+
ReverseOneToOneDescriptor,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from .exceptions import SerializeError
|
|
17
|
+
|
|
18
|
+
S_TYPES = Literal["create", "update"]
|
|
19
|
+
REL_TYPES = Literal["many", "one"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModelSerializer(models.Model):
|
|
23
|
+
class Meta:
|
|
24
|
+
abstract = True
|
|
25
|
+
|
|
26
|
+
class CreateSerializer:
|
|
27
|
+
fields: list[str] = []
|
|
28
|
+
customs: list[tuple[str, type, Any]] = []
|
|
29
|
+
|
|
30
|
+
class ReadSerializer:
|
|
31
|
+
fields: list[str] = []
|
|
32
|
+
|
|
33
|
+
class UpdateSerializer:
|
|
34
|
+
fields: list[str] = []
|
|
35
|
+
customs: list[tuple[str, type, Any]] = []
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def has_custom_fields_create(self):
|
|
39
|
+
return hasattr(self.CreateSerializer, "customs")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def has_custom_fields_update(self):
|
|
43
|
+
return hasattr(self.UpdateSerializer, "customs")
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def has_custom_fields(self):
|
|
47
|
+
return self.has_custom_fields_create or self.has_custom_fields_update
|
|
48
|
+
|
|
49
|
+
def has_changed(self, field: str) -> bool:
|
|
50
|
+
"""
|
|
51
|
+
Check if a model field has changed
|
|
52
|
+
"""
|
|
53
|
+
if not self.pk:
|
|
54
|
+
return False
|
|
55
|
+
old_value = (
|
|
56
|
+
self.__class__._default_manager.filter(pk=self.pk)
|
|
57
|
+
.values(field)
|
|
58
|
+
.get()[field]
|
|
59
|
+
)
|
|
60
|
+
return getattr(self, field) != old_value
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
async def queryset_request(cls, request: HttpRequest):
|
|
64
|
+
"""
|
|
65
|
+
Override this method to return a filtered queryset based
|
|
66
|
+
on the request received
|
|
67
|
+
"""
|
|
68
|
+
return cls.objects.select_related().all()
|
|
69
|
+
|
|
70
|
+
async def post_create(self) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Override this method to execute code after the object
|
|
73
|
+
has been created
|
|
74
|
+
"""
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
async def custom_actions(self, payload: dict[str, Any]):
|
|
78
|
+
"""
|
|
79
|
+
Override this method to execute custom actions based on
|
|
80
|
+
custom given fields. It could be useful for post create method.
|
|
81
|
+
"""
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def get_reverse_relations(cls):
|
|
86
|
+
reverse_rels = []
|
|
87
|
+
for f in cls.ReadSerializer.fields:
|
|
88
|
+
field_obj = getattr(cls, f)
|
|
89
|
+
if isinstance(field_obj, ReverseManyToOneDescriptor):
|
|
90
|
+
reverse_rels.append(field_obj.field.__dict__.get("_related_name"))
|
|
91
|
+
if isinstance(field_obj, ReverseOneToOneDescriptor):
|
|
92
|
+
reverse_rels.append(
|
|
93
|
+
list(field_obj.__dict__.values())[0].__dict__.get("related_name")
|
|
94
|
+
)
|
|
95
|
+
return reverse_rels
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def get_reverse_relation_schema(
|
|
99
|
+
cls, obj: type["ModelSerializer"], rel_type: type[REL_TYPES], field: str
|
|
100
|
+
):
|
|
101
|
+
for index, rel_f in enumerate(obj.ReadSerializer.fields):
|
|
102
|
+
if rel_f == cls._meta.model_name:
|
|
103
|
+
obj.ReadSerializer.fields.pop(index)
|
|
104
|
+
break
|
|
105
|
+
rel_schema = obj.generate_read_s(depth=0)
|
|
106
|
+
if rel_type == "many":
|
|
107
|
+
rel_schema = list[rel_schema]
|
|
108
|
+
rel_data = (
|
|
109
|
+
field,
|
|
110
|
+
rel_schema | None,
|
|
111
|
+
None,
|
|
112
|
+
)
|
|
113
|
+
obj.ReadSerializer.fields.append(cls._meta.model_name)
|
|
114
|
+
return rel_data
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def get_schema_out_data(cls):
|
|
118
|
+
fields = []
|
|
119
|
+
reverse_rels = []
|
|
120
|
+
for f in cls.ReadSerializer.fields:
|
|
121
|
+
field_obj = getattr(cls, f)
|
|
122
|
+
if isinstance(field_obj, ReverseManyToOneDescriptor):
|
|
123
|
+
rel_obj: ModelSerializer = field_obj.field.__dict__.get("model")
|
|
124
|
+
rel_data = cls.get_reverse_relation_schema(rel_obj, "many", f)
|
|
125
|
+
reverse_rels.append(rel_data)
|
|
126
|
+
continue
|
|
127
|
+
if isinstance(field_obj, ReverseOneToOneDescriptor):
|
|
128
|
+
rel_obj: ModelSerializer = list(field_obj.__dict__.values())[
|
|
129
|
+
0
|
|
130
|
+
].__dict__.get("related_model")
|
|
131
|
+
rel_data = cls.get_reverse_relation_schema(rel_obj, "one", f)
|
|
132
|
+
reverse_rels.append(rel_data)
|
|
133
|
+
continue
|
|
134
|
+
fields.append(f)
|
|
135
|
+
return fields, reverse_rels
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def is_custom(cls, field: str):
|
|
139
|
+
customs = cls.get_custom_fields("create") or []
|
|
140
|
+
customs.extend(cls.get_custom_fields("update") or [])
|
|
141
|
+
return any(field in custom_f for custom_f in customs)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
async def parse_input_data(cls, request: HttpRequest, data: Schema):
|
|
145
|
+
payload = data.model_dump()
|
|
146
|
+
customs = {k: v for k, v in payload.items() if cls.is_custom(k)}
|
|
147
|
+
for k, v in payload.items():
|
|
148
|
+
if cls.is_custom(k):
|
|
149
|
+
continue
|
|
150
|
+
field_obj = getattr(cls, k).field
|
|
151
|
+
if isinstance(field_obj, models.BinaryField):
|
|
152
|
+
if not v.endswith(b"=="):
|
|
153
|
+
v = v + b"=="
|
|
154
|
+
payload |= {k: base64.b64decode(v)}
|
|
155
|
+
if isinstance(field_obj, models.ForeignKey):
|
|
156
|
+
try:
|
|
157
|
+
rel: ModelSerializer = await field_obj.related_model.get_object(
|
|
158
|
+
request, v
|
|
159
|
+
)
|
|
160
|
+
except ObjectDoesNotExist:
|
|
161
|
+
raise SerializeError({k: "not found"}, 404)
|
|
162
|
+
payload |= {k: rel}
|
|
163
|
+
new_payload = {k: v for k, v in payload.items() if k not in customs}
|
|
164
|
+
return new_payload, customs
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
async def parse_output_data(cls, request: HttpRequest, data: Schema):
|
|
168
|
+
olds_k: list[dict] = []
|
|
169
|
+
payload = data.model_dump()
|
|
170
|
+
for k, v in payload.items():
|
|
171
|
+
try:
|
|
172
|
+
field_obj = getattr(cls, k).field
|
|
173
|
+
except AttributeError:
|
|
174
|
+
field_obj = getattr(cls, k).related
|
|
175
|
+
if isinstance(v, dict) and (
|
|
176
|
+
isinstance(field_obj, models.ForeignKey)
|
|
177
|
+
or isinstance(field_obj, OneToOneRel)
|
|
178
|
+
):
|
|
179
|
+
rel: ModelSerializer = await field_obj.related_model.get_object(
|
|
180
|
+
request, list(v.values())[0]
|
|
181
|
+
)
|
|
182
|
+
if isinstance(field_obj, models.ForeignKey):
|
|
183
|
+
for rel_k, rel_v in v.items():
|
|
184
|
+
field_rel_obj = getattr(rel, rel_k)
|
|
185
|
+
if isinstance(field_rel_obj, models.ForeignKey):
|
|
186
|
+
olds_k.append({rel_k: rel_v})
|
|
187
|
+
for obj in olds_k:
|
|
188
|
+
for old_k, old_v in obj.items():
|
|
189
|
+
v.pop(old_k)
|
|
190
|
+
v |= {f"{old_k}_id": old_v}
|
|
191
|
+
olds_k = []
|
|
192
|
+
payload |= {k: rel}
|
|
193
|
+
return payload
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def get_custom_fields(cls, s_type: type[S_TYPES]):
|
|
197
|
+
try:
|
|
198
|
+
match s_type:
|
|
199
|
+
case "create":
|
|
200
|
+
customs = cls.CreateSerializer.customs
|
|
201
|
+
case "update":
|
|
202
|
+
customs = cls.UpdateSerializer.customs
|
|
203
|
+
except AttributeError:
|
|
204
|
+
return None
|
|
205
|
+
return customs
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def get_optional_fields(cls, s_type: type[S_TYPES]) -> list[str] | None:
|
|
209
|
+
try:
|
|
210
|
+
match s_type:
|
|
211
|
+
case "create":
|
|
212
|
+
optionals = cls.CreateSerializer.optionals
|
|
213
|
+
case "update":
|
|
214
|
+
optionals = cls.UpdateSerializer.optionals
|
|
215
|
+
except AttributeError:
|
|
216
|
+
return None
|
|
217
|
+
return optionals
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def generate_read_s(cls, depth: int = 1) -> Schema:
|
|
221
|
+
fields, reverse_rels = cls.get_schema_out_data()
|
|
222
|
+
customs = [custom for custom in reverse_rels]
|
|
223
|
+
return create_schema(
|
|
224
|
+
model=cls,
|
|
225
|
+
name=f"{cls._meta.model_name}SchemaOut",
|
|
226
|
+
depth=depth,
|
|
227
|
+
fields=fields,
|
|
228
|
+
custom_fields=customs,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def generate_create_s(cls) -> Schema:
|
|
233
|
+
return create_schema(
|
|
234
|
+
model=cls,
|
|
235
|
+
name=f"{cls._meta.model_name}SchemaIn",
|
|
236
|
+
fields=cls.CreateSerializer.fields,
|
|
237
|
+
optional_fields=cls.get_optional_fields("create"),
|
|
238
|
+
custom_fields=cls.get_custom_fields("create"),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def generate_update_s(cls) -> Schema:
|
|
243
|
+
return create_schema(
|
|
244
|
+
model=cls,
|
|
245
|
+
name=f"{cls._meta.model_name}SchemaPatch",
|
|
246
|
+
fields=cls.UpdateSerializer.fields,
|
|
247
|
+
optional_fields=cls.get_optional_fields("update"),
|
|
248
|
+
custom_fields=cls.get_custom_fields("update"),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
async def get_object(cls, request: HttpRequest, pk: int | str):
|
|
253
|
+
q = {cls._meta.pk.attname: pk}
|
|
254
|
+
try:
|
|
255
|
+
obj = (
|
|
256
|
+
await (await cls.queryset_request(request))
|
|
257
|
+
.prefetch_related(*cls.get_reverse_relations())
|
|
258
|
+
.aget(**q)
|
|
259
|
+
)
|
|
260
|
+
except ObjectDoesNotExist:
|
|
261
|
+
raise SerializeError({cls._meta.model_name: "not found"}, 404)
|
|
262
|
+
return obj
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
async def create_s(cls, request: HttpRequest, data: Schema):
|
|
266
|
+
try:
|
|
267
|
+
payload, customs = await cls.parse_input_data(request, data)
|
|
268
|
+
pk = (await cls.objects.acreate(**payload)).pk
|
|
269
|
+
obj = await cls.get_object(request, pk)
|
|
270
|
+
except SerializeError as e:
|
|
271
|
+
return e.status_code, e.error
|
|
272
|
+
payload |= customs
|
|
273
|
+
await obj.custom_actions(payload)
|
|
274
|
+
await obj.post_create()
|
|
275
|
+
return await cls.read_s(request, obj)
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
async def read_s(cls, request: HttpRequest, obj: type["ModelSerializer"]):
|
|
279
|
+
schema = cls.generate_read_s().from_orm(obj)
|
|
280
|
+
return await cls.parse_output_data(request, schema)
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
async def update_s(cls, request: HttpRequest, data: Schema, pk: int | str):
|
|
284
|
+
try:
|
|
285
|
+
obj = await cls.get_object(request, pk)
|
|
286
|
+
except SerializeError as e:
|
|
287
|
+
return e.status_code, e.error
|
|
288
|
+
|
|
289
|
+
payload, customs = await cls.parse_input_data(request, data)
|
|
290
|
+
for k, v in payload.items():
|
|
291
|
+
if v is not None:
|
|
292
|
+
setattr(obj, k, v)
|
|
293
|
+
payload |= customs
|
|
294
|
+
await obj.custom_actions(payload)
|
|
295
|
+
await obj.asave()
|
|
296
|
+
return await cls.read_s(request, obj)
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
async def delete_s(cls, request: HttpRequest, pk: int | str):
|
|
300
|
+
try:
|
|
301
|
+
obj = await cls.get_object(request, pk)
|
|
302
|
+
except SerializeError as e:
|
|
303
|
+
return e.status_code, e.error
|
|
304
|
+
await obj.adelete()
|
|
305
|
+
return HttpResponse(status=204)
|
ninja_aio/parsers.py
ADDED
ninja_aio/renders.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
import orjson
|
|
4
|
+
from django.http import HttpRequest
|
|
5
|
+
from ninja.renderers import BaseRenderer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ORJSONRenderer(BaseRenderer):
|
|
9
|
+
media_type = "application/json"
|
|
10
|
+
|
|
11
|
+
def render(self, request: HttpRequest, data: dict | list, *, response_status):
|
|
12
|
+
if isinstance(data, list):
|
|
13
|
+
return orjson.dumps(self.render_list(data))
|
|
14
|
+
return orjson.dumps(self.render_dict(data))
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def render_list(cls, data: list[dict]) -> list[dict]:
|
|
18
|
+
return [cls.parse_data(d) for d in data]
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def render_dict(cls, data: dict):
|
|
22
|
+
return cls.parse_data(data)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def parse_data(cls, data: dict):
|
|
26
|
+
for k, v in data.items():
|
|
27
|
+
if isinstance(v, bytes):
|
|
28
|
+
data |= {k: base64.b64encode(v).decode()}
|
|
29
|
+
if isinstance(v, dict):
|
|
30
|
+
for k_rel, v_rel in v.items():
|
|
31
|
+
if not isinstance(v_rel, bytes):
|
|
32
|
+
continue
|
|
33
|
+
v |= {k_rel: base64.b64encode(v_rel).decode()}
|
|
34
|
+
data |= {k: v}
|
|
35
|
+
if isinstance(v, list):
|
|
36
|
+
index_rel = 0
|
|
37
|
+
for f_rel in v:
|
|
38
|
+
for k_rel, v_rel in f_rel.items():
|
|
39
|
+
if isinstance(v_rel, bytes):
|
|
40
|
+
v[index_rel] |= {k_rel: base64.b64encode(v_rel).decode()}
|
|
41
|
+
index_rel += 1
|
|
42
|
+
data |= {k: v}
|
|
43
|
+
return data
|
ninja_aio/schemas.py
ADDED
ninja_aio/views.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from ninja import NinjaAPI, Router
|
|
4
|
+
from ninja.constants import NOT_SET
|
|
5
|
+
from django.http import HttpRequest
|
|
6
|
+
|
|
7
|
+
from .models import ModelSerializer
|
|
8
|
+
from .schemas import GenericMessageSchema
|
|
9
|
+
from .exceptions import SerializeError
|
|
10
|
+
|
|
11
|
+
ERROR_CODES = frozenset({400, 401, 404, 428})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIView:
|
|
15
|
+
api: NinjaAPI
|
|
16
|
+
router_tag: str
|
|
17
|
+
api_route_path: str
|
|
18
|
+
auths: list | None = NOT_SET
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.router = Router(tags=[self.router_tag])
|
|
22
|
+
self.error_codes = ERROR_CODES
|
|
23
|
+
|
|
24
|
+
def views(self):
|
|
25
|
+
"""
|
|
26
|
+
Override this method to add your custom views. For example:
|
|
27
|
+
@self.router.get(some_path, response=some_schema)
|
|
28
|
+
async def some_method(request, *args, **kwargs):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
You can add multilple views just doing:
|
|
32
|
+
|
|
33
|
+
@self.router.get(some_path, response=some_schema)
|
|
34
|
+
async def some_method(request, *args, **kwargs):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@self.router.post(some_path, response=some_schema)
|
|
38
|
+
async def some_method(request, *args, **kwargs):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
If you provided a list of auths you can chose which of your views
|
|
42
|
+
should be authenticated:
|
|
43
|
+
|
|
44
|
+
AUTHENTICATED VIEW:
|
|
45
|
+
|
|
46
|
+
@self.router.get(some_path, response=some_schema, auth=self.auths)
|
|
47
|
+
async def some_method(request, *args, **kwargs):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
NOT AUTHENTICATED VIEW:
|
|
51
|
+
|
|
52
|
+
@self.router.post(some_path, response=some_schema)
|
|
53
|
+
async def some_method(request, *args, **kwargs):
|
|
54
|
+
pass
|
|
55
|
+
"""
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def add_views(self):
|
|
59
|
+
self.views()
|
|
60
|
+
return self.router
|
|
61
|
+
|
|
62
|
+
def add_views_to_route(self):
|
|
63
|
+
return self.api.add_router(f"{self.api_route_path}/", self.add_views())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class APIViewSet:
|
|
67
|
+
model: ModelSerializer
|
|
68
|
+
api: NinjaAPI
|
|
69
|
+
auths: list | None = NOT_SET
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
self.router = Router(tags=[self.model._meta.model_name.capitalize()])
|
|
73
|
+
self.schema_in = self.model.generate_create_s()
|
|
74
|
+
self.schema_out = self.model.generate_read_s()
|
|
75
|
+
self.schema_update = self.model.generate_update_s()
|
|
76
|
+
self.path = "/"
|
|
77
|
+
self.path_retrieve = f"{self.model._meta.pk.attname}/"
|
|
78
|
+
self.error_codes = ERROR_CODES
|
|
79
|
+
|
|
80
|
+
def create_view(self):
|
|
81
|
+
@self.router.post(
|
|
82
|
+
self.path,
|
|
83
|
+
auth=self.auths,
|
|
84
|
+
response={200: self.schema_out, self.error_codes: GenericMessageSchema},
|
|
85
|
+
)
|
|
86
|
+
async def create(request: HttpRequest, data: self.schema_in):
|
|
87
|
+
return await self.model.create_s(request, data)
|
|
88
|
+
|
|
89
|
+
create.__name__ = f"create_{self.model._meta.model_name}"
|
|
90
|
+
|
|
91
|
+
def list_view(self):
|
|
92
|
+
@self.router.get(
|
|
93
|
+
self.path,
|
|
94
|
+
auth=self.auths,
|
|
95
|
+
response={
|
|
96
|
+
200: List[self.schema_out],
|
|
97
|
+
self.error_codes: GenericMessageSchema,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
async def list(request: HttpRequest):
|
|
101
|
+
qs = await self.model.queryset_request(request)
|
|
102
|
+
rels = self.model.get_reverse_relations()
|
|
103
|
+
if len(rels) > 0:
|
|
104
|
+
qs = qs.prefetch_related(*rels)
|
|
105
|
+
objs = [await self.model.read_s(request, obj) async for obj in qs.all()]
|
|
106
|
+
return objs
|
|
107
|
+
|
|
108
|
+
list.__name__ = f"list_{self.model._meta.verbose_name_plural}"
|
|
109
|
+
|
|
110
|
+
def retrieve_view(self):
|
|
111
|
+
@self.router.get(
|
|
112
|
+
self.path_retrieve,
|
|
113
|
+
auth=self.auths,
|
|
114
|
+
response={200: self.schema_out, self.error_codes: GenericMessageSchema},
|
|
115
|
+
)
|
|
116
|
+
async def retrieve(request: HttpRequest, pk: int | str):
|
|
117
|
+
try:
|
|
118
|
+
obj = await self.model.get_object(request, pk)
|
|
119
|
+
except SerializeError as e:
|
|
120
|
+
return e.status_code, e.error
|
|
121
|
+
return await self.model.read_s(request, obj)
|
|
122
|
+
|
|
123
|
+
retrieve.__name__ = f"retrieve_{self.model._meta.model_name}"
|
|
124
|
+
|
|
125
|
+
def update_view(self):
|
|
126
|
+
@self.router.patch(
|
|
127
|
+
self.path_retrieve,
|
|
128
|
+
auth=self.auths,
|
|
129
|
+
response={200: self.schema_out, self.error_codes: GenericMessageSchema},
|
|
130
|
+
)
|
|
131
|
+
async def update(request: HttpRequest, data: self.schema_update, pk: int | str):
|
|
132
|
+
return await self.model.update_s(request, data, pk)
|
|
133
|
+
|
|
134
|
+
update.__name__ = f"update_{self.model._meta.model_name}"
|
|
135
|
+
|
|
136
|
+
def delete_view(self):
|
|
137
|
+
@self.router.delete(
|
|
138
|
+
self.path_retrieve,
|
|
139
|
+
auth=self.auths,
|
|
140
|
+
response={204: None, self.error_codes: GenericMessageSchema},
|
|
141
|
+
)
|
|
142
|
+
async def delete(request: HttpRequest, pk: int | str):
|
|
143
|
+
return await self.model.delete_s(request, pk)
|
|
144
|
+
|
|
145
|
+
delete.__name__ = f"delete_{self.model._meta.model_name}"
|
|
146
|
+
|
|
147
|
+
def views(self):
|
|
148
|
+
"""
|
|
149
|
+
Override this method to add your custom views. For example:
|
|
150
|
+
@self.router.get(some_path, response=some_schema)
|
|
151
|
+
async def some_method(request, *args, **kwargs):
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
You can add multilple views just doing:
|
|
155
|
+
|
|
156
|
+
@self.router.get(some_path, response=some_schema)
|
|
157
|
+
async def some_method(request, *args, **kwargs):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
@self.router.post(some_path, response=some_schema)
|
|
161
|
+
async def some_method(request, *args, **kwargs):
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
If you provided a list of auths you can chose which of your views
|
|
165
|
+
should be authenticated:
|
|
166
|
+
|
|
167
|
+
AUTHENTICATED VIEW:
|
|
168
|
+
|
|
169
|
+
@self.router.get(some_path, response=some_schema, auth=self.auths)
|
|
170
|
+
async def some_method(request, *args, **kwargs):
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
NOT AUTHENTICATED VIEW:
|
|
174
|
+
|
|
175
|
+
@self.router.post(some_path, response=some_schema)
|
|
176
|
+
async def some_method(request, *args, **kwargs):
|
|
177
|
+
pass
|
|
178
|
+
"""
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def add_views(self):
|
|
182
|
+
self.create_view()
|
|
183
|
+
self.list_view()
|
|
184
|
+
self.retrieve_view()
|
|
185
|
+
self.update_view()
|
|
186
|
+
self.delete_view()
|
|
187
|
+
self.views()
|
|
188
|
+
return self.router
|
|
189
|
+
|
|
190
|
+
def add_views_to_route(self):
|
|
191
|
+
return self.api.add_router(
|
|
192
|
+
f"{self.model._meta.verbose_name_plural}/",
|
|
193
|
+
self.add_views(),
|
|
194
|
+
)
|