fastadmin 0.3.3__tar.gz → 0.3.5__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.
- {fastadmin-0.3.3 → fastadmin-0.3.5}/PKG-INFO +2 -2
- {fastadmin-0.3.3 → fastadmin-0.3.5}/README.md +1 -1
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/django/app/api.py +15 -1
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/fastapi/api.py +26 -3
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/flask/api.py +15 -1
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/service.py +81 -40
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/base.py +50 -3
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/helpers.py +6 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/orms/django.py +16 -4
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/orms/ponyorm.py +65 -3
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/orms/sqlalchemy.py +98 -3
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/orms/tortoise.py +40 -4
- {fastadmin-0.3.3 → fastadmin-0.3.5}/pyproject.toml +2 -1
- {fastadmin-0.3.3 → fastadmin-0.3.5}/LICENSE +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/__init__.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/__init__.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/exceptions.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/__init__.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/django/__init__.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/django/app/__init__.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/django/app/urls.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/django/app/views.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/fastapi/__init__.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/fastapi/app.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/fastapi/views.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/flask/__init__.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/flask/app.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/frameworks/flask/views.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/helpers.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/api/schemas.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/__init__.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/decorators.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/orms/__init__.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/models/schemas.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/settings.py +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-6Z7niv9l.js +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-C151k0-L.js +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-C9KPOOKD.js +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-D1fCgYB7.js +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-D3TWcJOI.js +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-DRNcaZ-V.js +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/assets/worker-MF2p-l5_.js +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/images/favicon.png +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/images/header-logo.svg +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/images/sign-in-logo.svg +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/index.html +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/index.min.css +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/static/index.min.js +0 -0
- {fastadmin-0.3.3 → fastadmin-0.3.5}/fastadmin/templates/index.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastadmin
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Flask/Django inspired by Django Admin.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -42,7 +42,7 @@ Description-Content-Type: text/markdown
|
|
|
42
42
|
|
|
43
43
|
# Admin Dashboard for FastAPI / Flask / Django
|
|
44
44
|
|
|
45
|
-
[](https://codecov.io/gh/vsdudakov/fastadmin)
|
|
45
|
+
[](https://app.codecov.io/gh/vsdudakov/fastadmin)
|
|
46
46
|
[](https://github.com/vsdudakov/fastadmin/blob/master/LICENSE)
|
|
47
47
|
[](https://pypi.org/project/fastadmin/)
|
|
48
48
|
[](https://www.python.org/downloads/release/python-3120/)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Admin Dashboard for FastAPI / Flask / Django
|
|
2
2
|
|
|
3
|
-
[](https://codecov.io/gh/vsdudakov/fastadmin)
|
|
3
|
+
[](https://app.codecov.io/gh/vsdudakov/fastadmin)
|
|
4
4
|
[](https://github.com/vsdudakov/fastadmin/blob/master/LICENSE)
|
|
5
5
|
[](https://pypi.org/project/fastadmin/)
|
|
6
6
|
[](https://www.python.org/downloads/release/python-3120/)
|
|
@@ -64,6 +64,7 @@ async def sign_in(request: HttpRequest) -> JsonResponse:
|
|
|
64
64
|
session_id = await api_service.sign_in(
|
|
65
65
|
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
66
66
|
payload,
|
|
67
|
+
request=request,
|
|
67
68
|
)
|
|
68
69
|
|
|
69
70
|
response = JsonResponse({})
|
|
@@ -111,7 +112,10 @@ async def me(request: HttpRequest) -> JsonResponse:
|
|
|
111
112
|
if not user_id:
|
|
112
113
|
raise AdminApiException(401, "User is not authenticated.")
|
|
113
114
|
obj = await api_service.get(
|
|
114
|
-
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
115
|
+
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
116
|
+
settings.ADMIN_USER_MODEL,
|
|
117
|
+
user_id,
|
|
118
|
+
request=request,
|
|
115
119
|
)
|
|
116
120
|
return JsonResponse(obj)
|
|
117
121
|
|
|
@@ -143,6 +147,7 @@ async def dashboard_widget(request: HttpRequest, model: str) -> JsonResponse:
|
|
|
143
147
|
min_x_field=min_x_field,
|
|
144
148
|
max_x_field=max_x_field,
|
|
145
149
|
period_x_field=period_x_field,
|
|
150
|
+
request=request,
|
|
146
151
|
)
|
|
147
152
|
return JsonResponse(data)
|
|
148
153
|
except AdminApiException as e:
|
|
@@ -182,6 +187,7 @@ async def list_objs(request: HttpRequest, model: str) -> JsonResponse:
|
|
|
182
187
|
filters=list_filters,
|
|
183
188
|
offset=offset,
|
|
184
189
|
limit=limit,
|
|
190
|
+
request=request,
|
|
185
191
|
)
|
|
186
192
|
return JsonResponse(
|
|
187
193
|
{
|
|
@@ -212,6 +218,7 @@ async def get(request: HttpRequest, model: str, id: UUID | int | str) -> JsonRes
|
|
|
212
218
|
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
213
219
|
model,
|
|
214
220
|
id,
|
|
221
|
+
request=request,
|
|
215
222
|
)
|
|
216
223
|
return JsonResponse(obj)
|
|
217
224
|
|
|
@@ -234,6 +241,7 @@ async def add(request: HttpRequest, model: str) -> JsonResponse:
|
|
|
234
241
|
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
235
242
|
model,
|
|
236
243
|
json.loads(request.body),
|
|
244
|
+
request=request,
|
|
237
245
|
)
|
|
238
246
|
return JsonResponse(obj)
|
|
239
247
|
except AdminApiException as e:
|
|
@@ -257,6 +265,7 @@ async def change_password(request: HttpRequest, id: UUID | int | str) -> JsonRes
|
|
|
257
265
|
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
258
266
|
id,
|
|
259
267
|
json.loads(request.body),
|
|
268
|
+
request=request,
|
|
260
269
|
)
|
|
261
270
|
return JsonResponse(id, safe=False)
|
|
262
271
|
|
|
@@ -283,6 +292,7 @@ async def change(request: HttpRequest, model: str, id: UUID | int | str) -> Json
|
|
|
283
292
|
model,
|
|
284
293
|
id,
|
|
285
294
|
json.loads(request.body),
|
|
295
|
+
request=request,
|
|
286
296
|
)
|
|
287
297
|
return JsonResponse(obj)
|
|
288
298
|
|
|
@@ -319,6 +329,7 @@ async def export(request: HttpRequest, model: str) -> JsonResponse:
|
|
|
319
329
|
search=search,
|
|
320
330
|
sort_by=sort_by,
|
|
321
331
|
filters=list_filters,
|
|
332
|
+
request=request,
|
|
322
333
|
)
|
|
323
334
|
response = StreamingHttpResponse(stream, content_type=content_type)
|
|
324
335
|
response.headers["Content-Disposition"] = f'attachment; filename="{file_name}"'
|
|
@@ -349,6 +360,7 @@ async def delete(
|
|
|
349
360
|
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
350
361
|
model,
|
|
351
362
|
id,
|
|
363
|
+
request=request,
|
|
352
364
|
)
|
|
353
365
|
return JsonResponse(deleted_id, safe=False)
|
|
354
366
|
|
|
@@ -378,6 +390,7 @@ async def action(
|
|
|
378
390
|
model,
|
|
379
391
|
action,
|
|
380
392
|
payload,
|
|
393
|
+
request=request,
|
|
381
394
|
)
|
|
382
395
|
return JsonResponse({})
|
|
383
396
|
|
|
@@ -397,5 +410,6 @@ async def configuration(request: HttpRequest) -> JsonResponse:
|
|
|
397
410
|
|
|
398
411
|
obj = await api_service.get_configuration(
|
|
399
412
|
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
413
|
+
request=request,
|
|
400
414
|
)
|
|
401
415
|
return JsonResponse(asdict(obj))
|
|
@@ -35,6 +35,7 @@ async def sign_in(
|
|
|
35
35
|
session_id = await api_service.sign_in(
|
|
36
36
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
37
37
|
payload,
|
|
38
|
+
request=request,
|
|
38
39
|
)
|
|
39
40
|
|
|
40
41
|
response.set_cookie(settings.ADMIN_SESSION_ID_KEY, value=session_id, httponly=True)
|
|
@@ -78,7 +79,10 @@ async def me(
|
|
|
78
79
|
if not user_id:
|
|
79
80
|
raise AdminApiException(401, "User is not authenticated.")
|
|
80
81
|
return await api_service.get(
|
|
81
|
-
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
82
|
+
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
83
|
+
settings.ADMIN_USER_MODEL,
|
|
84
|
+
user_id,
|
|
85
|
+
request=request,
|
|
82
86
|
)
|
|
83
87
|
except AdminApiException as e:
|
|
84
88
|
raise HTTPException(e.status_code, detail=e.detail) from None
|
|
@@ -107,6 +111,7 @@ async def dashboard_widget(
|
|
|
107
111
|
min_x_field=min_x_field,
|
|
108
112
|
max_x_field=max_x_field,
|
|
109
113
|
period_x_field=period_x_field,
|
|
114
|
+
request=request,
|
|
110
115
|
)
|
|
111
116
|
return data
|
|
112
117
|
except AdminApiException as e:
|
|
@@ -146,6 +151,7 @@ async def list_objs(
|
|
|
146
151
|
filters=list_filters,
|
|
147
152
|
offset=offset,
|
|
148
153
|
limit=limit,
|
|
154
|
+
request=request,
|
|
149
155
|
)
|
|
150
156
|
return {
|
|
151
157
|
"total": total,
|
|
@@ -172,6 +178,7 @@ async def get(
|
|
|
172
178
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
173
179
|
model,
|
|
174
180
|
id,
|
|
181
|
+
request=request,
|
|
175
182
|
)
|
|
176
183
|
except AdminApiException as e:
|
|
177
184
|
raise HTTPException(e.status_code, detail=e.detail) from None
|
|
@@ -194,6 +201,7 @@ async def add(
|
|
|
194
201
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
195
202
|
model,
|
|
196
203
|
payload,
|
|
204
|
+
request=request,
|
|
197
205
|
)
|
|
198
206
|
except AdminApiException as e:
|
|
199
207
|
raise HTTPException(e.status_code, detail=e.detail) from None
|
|
@@ -212,7 +220,12 @@ async def change_password(
|
|
|
212
220
|
:return: An object.
|
|
213
221
|
"""
|
|
214
222
|
try:
|
|
215
|
-
await api_service.change_password(
|
|
223
|
+
await api_service.change_password(
|
|
224
|
+
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
225
|
+
id,
|
|
226
|
+
payload,
|
|
227
|
+
request=request,
|
|
228
|
+
)
|
|
216
229
|
return id
|
|
217
230
|
except AdminApiException as e:
|
|
218
231
|
raise HTTPException(e.status_code, detail=e.detail) from None
|
|
@@ -233,7 +246,13 @@ async def change(
|
|
|
233
246
|
:return: An object.
|
|
234
247
|
"""
|
|
235
248
|
try:
|
|
236
|
-
return await api_service.change(
|
|
249
|
+
return await api_service.change(
|
|
250
|
+
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
251
|
+
model,
|
|
252
|
+
id,
|
|
253
|
+
payload,
|
|
254
|
+
request=request,
|
|
255
|
+
)
|
|
237
256
|
except AdminApiException as e:
|
|
238
257
|
raise HTTPException(e.status_code, detail=e.detail) from None
|
|
239
258
|
|
|
@@ -268,6 +287,7 @@ async def export(
|
|
|
268
287
|
search=search,
|
|
269
288
|
sort_by=sort_by,
|
|
270
289
|
filters=list_filters,
|
|
290
|
+
request=request,
|
|
271
291
|
)
|
|
272
292
|
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
|
|
273
293
|
return StreamingResponse(
|
|
@@ -296,6 +316,7 @@ async def delete(
|
|
|
296
316
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
297
317
|
model,
|
|
298
318
|
id,
|
|
319
|
+
request=request,
|
|
299
320
|
)
|
|
300
321
|
except AdminApiException as e:
|
|
301
322
|
raise HTTPException(e.status_code, detail=e.detail) from None
|
|
@@ -321,6 +342,7 @@ async def action(
|
|
|
321
342
|
model,
|
|
322
343
|
action,
|
|
323
344
|
payload,
|
|
345
|
+
request=request,
|
|
324
346
|
)
|
|
325
347
|
except AdminApiException as e:
|
|
326
348
|
raise HTTPException(e.status_code, detail=e.detail) from None
|
|
@@ -337,4 +359,5 @@ async def configuration(
|
|
|
337
359
|
"""
|
|
338
360
|
return await api_service.get_configuration(
|
|
339
361
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
362
|
+
request=request,
|
|
340
363
|
)
|
|
@@ -30,6 +30,7 @@ async def sign_in() -> Response:
|
|
|
30
30
|
session_id = await api_service.sign_in(
|
|
31
31
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
32
32
|
payload,
|
|
33
|
+
request=request,
|
|
33
34
|
)
|
|
34
35
|
response = make_response({})
|
|
35
36
|
response.set_cookie(settings.ADMIN_SESSION_ID_KEY, value=session_id, httponly=True)
|
|
@@ -74,7 +75,10 @@ async def me() -> dict:
|
|
|
74
75
|
if not user_id:
|
|
75
76
|
raise AdminApiException(401, "User is not authenticated.")
|
|
76
77
|
return await api_service.get(
|
|
77
|
-
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
78
|
+
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
79
|
+
settings.ADMIN_USER_MODEL,
|
|
80
|
+
user_id,
|
|
81
|
+
request=request,
|
|
78
82
|
)
|
|
79
83
|
except AdminApiException as e:
|
|
80
84
|
http_exception = HTTPException(e.detail)
|
|
@@ -104,6 +108,7 @@ async def dashboard_widget(model: str) -> dict:
|
|
|
104
108
|
min_x_field=min_x_field,
|
|
105
109
|
max_x_field=max_x_field,
|
|
106
110
|
period_x_field=period_x_field,
|
|
111
|
+
request=request,
|
|
107
112
|
)
|
|
108
113
|
return data
|
|
109
114
|
except AdminApiException as e:
|
|
@@ -142,6 +147,7 @@ async def list_objs(model: str) -> dict:
|
|
|
142
147
|
filters=list_filters,
|
|
143
148
|
offset=offset,
|
|
144
149
|
limit=limit,
|
|
150
|
+
request=request,
|
|
145
151
|
)
|
|
146
152
|
return {
|
|
147
153
|
"total": total,
|
|
@@ -174,6 +180,7 @@ async def get(model: str, id: UUID | int | str) -> dict:
|
|
|
174
180
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
175
181
|
model,
|
|
176
182
|
id,
|
|
183
|
+
request=request,
|
|
177
184
|
)
|
|
178
185
|
except AdminApiException as e:
|
|
179
186
|
http_exception = HTTPException(e.detail)
|
|
@@ -195,6 +202,7 @@ async def add(model: str) -> dict:
|
|
|
195
202
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
196
203
|
model,
|
|
197
204
|
payload,
|
|
205
|
+
request=request,
|
|
198
206
|
)
|
|
199
207
|
except AdminApiException as e:
|
|
200
208
|
http_exception = HTTPException(e.detail)
|
|
@@ -220,6 +228,7 @@ async def change_password(id: UUID | int | str) -> UUID | int | str:
|
|
|
220
228
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
221
229
|
id,
|
|
222
230
|
payload,
|
|
231
|
+
request=request,
|
|
223
232
|
)
|
|
224
233
|
return id
|
|
225
234
|
except AdminApiException as e:
|
|
@@ -248,6 +257,7 @@ async def change(model: str, id: UUID | int | str) -> dict:
|
|
|
248
257
|
model,
|
|
249
258
|
id,
|
|
250
259
|
payload,
|
|
260
|
+
request=request,
|
|
251
261
|
)
|
|
252
262
|
except AdminApiException as e:
|
|
253
263
|
http_exception = HTTPException(e.detail)
|
|
@@ -283,6 +293,7 @@ async def export(model: str) -> Response:
|
|
|
283
293
|
search=search,
|
|
284
294
|
sort_by=sort_by,
|
|
285
295
|
filters=list_filters,
|
|
296
|
+
request=request,
|
|
286
297
|
)
|
|
287
298
|
response = Response(stream, mimetype=content_type)
|
|
288
299
|
response.headers["Content-Disposition"] = f'attachment; filename="{file_name}"'
|
|
@@ -313,6 +324,7 @@ async def delete(
|
|
|
313
324
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
314
325
|
model,
|
|
315
326
|
id,
|
|
327
|
+
request=request,
|
|
316
328
|
)
|
|
317
329
|
except AdminApiException as e:
|
|
318
330
|
http_exception = HTTPException(e.detail)
|
|
@@ -340,6 +352,7 @@ async def action(
|
|
|
340
352
|
model,
|
|
341
353
|
action,
|
|
342
354
|
payload,
|
|
355
|
+
request=request,
|
|
343
356
|
)
|
|
344
357
|
return {}
|
|
345
358
|
except AdminApiException as e:
|
|
@@ -357,5 +370,6 @@ async def configuration() -> dict:
|
|
|
357
370
|
"""
|
|
358
371
|
obj = await api_service.get_configuration(
|
|
359
372
|
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
|
|
373
|
+
request=request,
|
|
360
374
|
)
|
|
361
375
|
return asdict(obj)
|
|
@@ -36,6 +36,14 @@ from fastadmin.settings import settings
|
|
|
36
36
|
logger = logging.getLogger(__name__)
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def is_allowed_field_or_path(field: str, allowed_fields: set[str]) -> bool:
|
|
40
|
+
"""Allow direct fields and nested paths from allowed base fields."""
|
|
41
|
+
if field in allowed_fields:
|
|
42
|
+
return True
|
|
43
|
+
base_field = field.split("__", 1)[0]
|
|
44
|
+
return base_field in allowed_fields
|
|
45
|
+
|
|
46
|
+
|
|
39
47
|
def convert_id(id: str | int | UUID) -> int | UUID | None:
|
|
40
48
|
"""Convert the given id to int or UUID.
|
|
41
49
|
|
|
@@ -94,10 +102,31 @@ async def get_user_id_from_session_id(session_id: str | None) -> UUID | int | No
|
|
|
94
102
|
|
|
95
103
|
|
|
96
104
|
class ApiService:
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _bind_admin_context(
|
|
107
|
+
admin_model: ModelAdmin | InlineModelAdmin | Any, request: Any | None, user: Any | None
|
|
108
|
+
) -> None:
|
|
109
|
+
if hasattr(admin_model, "set_context"):
|
|
110
|
+
admin_model.set_context(request=request, user=user)
|
|
111
|
+
|
|
112
|
+
async def _get_authenticated_user(self, session_id: str | None) -> tuple[UUID | int, Any | None]:
|
|
113
|
+
current_user_id = await get_user_id_from_session_id(session_id)
|
|
114
|
+
if not current_user_id:
|
|
115
|
+
raise AdminApiException(401, detail="User is not authenticated.")
|
|
116
|
+
|
|
117
|
+
admin_user_model = get_admin_model(settings.ADMIN_USER_MODEL)
|
|
118
|
+
current_user = (
|
|
119
|
+
await admin_user_model.get_obj(current_user_id)
|
|
120
|
+
if admin_user_model and hasattr(admin_user_model, "get_obj")
|
|
121
|
+
else None
|
|
122
|
+
)
|
|
123
|
+
return current_user_id, current_user
|
|
124
|
+
|
|
97
125
|
async def sign_in(
|
|
98
126
|
self,
|
|
99
127
|
session_id: str | None,
|
|
100
128
|
payload: SignInInputSchema,
|
|
129
|
+
request: Any | None = None,
|
|
101
130
|
) -> str:
|
|
102
131
|
model = settings.ADMIN_USER_MODEL
|
|
103
132
|
admin_model = get_admin_model(model)
|
|
@@ -110,6 +139,7 @@ class ApiService:
|
|
|
110
139
|
else:
|
|
111
140
|
authenticate_fn = sync_to_async(admin_model.authenticate) # type: ignore [arg-type]
|
|
112
141
|
|
|
142
|
+
self._bind_admin_context(admin_model, request=request, user=None)
|
|
113
143
|
user_id = await authenticate_fn(payload.username, payload.password)
|
|
114
144
|
|
|
115
145
|
if not user_id or not isinstance(user_id, int | UUID):
|
|
@@ -131,10 +161,9 @@ class ApiService:
|
|
|
131
161
|
async def sign_out(
|
|
132
162
|
self,
|
|
133
163
|
session_id: str | None,
|
|
164
|
+
request: Any | None = None,
|
|
134
165
|
) -> bool:
|
|
135
|
-
|
|
136
|
-
if not current_user_id:
|
|
137
|
-
raise AdminApiException(401, detail="User is not authenticated.")
|
|
166
|
+
_current_user_id, _current_user = await self._get_authenticated_user(session_id)
|
|
138
167
|
|
|
139
168
|
return True
|
|
140
169
|
|
|
@@ -145,10 +174,9 @@ class ApiService:
|
|
|
145
174
|
min_x_field: str | None = None,
|
|
146
175
|
max_x_field: str | None = None,
|
|
147
176
|
period_x_field: str | None = None,
|
|
177
|
+
request: Any | None = None,
|
|
148
178
|
) -> dict[str, str | int | float]:
|
|
149
|
-
|
|
150
|
-
if not current_user_id:
|
|
151
|
-
raise AdminApiException(401, detail="User is not authenticated.")
|
|
179
|
+
_current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
152
180
|
|
|
153
181
|
query_params = DashboardWidgetQuerySchema(
|
|
154
182
|
min_x_field=min_x_field,
|
|
@@ -160,6 +188,8 @@ class ApiService:
|
|
|
160
188
|
if not dashboard_widget_model:
|
|
161
189
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
162
190
|
|
|
191
|
+
self._bind_admin_context(dashboard_widget_model, request=request, user=current_user)
|
|
192
|
+
|
|
163
193
|
if inspect.iscoroutinefunction(dashboard_widget_model.get_data):
|
|
164
194
|
get_data = dashboard_widget_model.get_data
|
|
165
195
|
else:
|
|
@@ -182,10 +212,9 @@ class ApiService:
|
|
|
182
212
|
filters: dict | None = None,
|
|
183
213
|
offset: int | None = 0,
|
|
184
214
|
limit: int | None = 10,
|
|
215
|
+
request: Any | None = None,
|
|
185
216
|
) -> tuple[list[dict], int]:
|
|
186
|
-
|
|
187
|
-
if not current_user_id:
|
|
188
|
-
raise AdminApiException(401, detail="User is not authenticated.")
|
|
217
|
+
_current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
189
218
|
|
|
190
219
|
query_params = ListQuerySchema(
|
|
191
220
|
search=search,
|
|
@@ -198,13 +227,14 @@ class ApiService:
|
|
|
198
227
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
199
228
|
if not admin_model:
|
|
200
229
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
230
|
+
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
201
231
|
|
|
202
232
|
# validations
|
|
203
|
-
fields = admin_model.get_fields_for_serialize()
|
|
233
|
+
fields = set(admin_model.get_fields_for_serialize())
|
|
204
234
|
|
|
205
235
|
if query_params.search and admin_model.search_fields:
|
|
206
236
|
for field in admin_model.search_fields:
|
|
207
|
-
if
|
|
237
|
+
if not is_allowed_field_or_path(field, fields):
|
|
208
238
|
raise AdminApiException(422, detail=f"Search by {field} is not allowed")
|
|
209
239
|
|
|
210
240
|
exclude_filter_fields = ("search", "sort_by", "offset", "limit")
|
|
@@ -227,7 +257,7 @@ class ApiService:
|
|
|
227
257
|
raise AdminApiException(422, detail=f"Sort by {query_params.sort_by} is not allowed")
|
|
228
258
|
elif admin_model.ordering:
|
|
229
259
|
for ordering_field in admin_model.ordering:
|
|
230
|
-
if ordering_field.strip("-")
|
|
260
|
+
if not is_allowed_field_or_path(ordering_field.strip("-"), fields):
|
|
231
261
|
raise AdminApiException(422, detail=f"Sort by {ordering_field} is not allowed")
|
|
232
262
|
|
|
233
263
|
if admin_model.list_select_related:
|
|
@@ -248,14 +278,14 @@ class ApiService:
|
|
|
248
278
|
session_id: str | None,
|
|
249
279
|
model: str,
|
|
250
280
|
id: UUID | int | str,
|
|
281
|
+
request: Any | None = None,
|
|
251
282
|
) -> dict:
|
|
252
|
-
|
|
253
|
-
if not current_user_id:
|
|
254
|
-
raise AdminApiException(401, detail="User is not authenticated.")
|
|
283
|
+
_current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
255
284
|
|
|
256
285
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
257
286
|
if not admin_model:
|
|
258
287
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
288
|
+
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
259
289
|
|
|
260
290
|
try:
|
|
261
291
|
obj = await admin_model.get_obj(id)
|
|
@@ -270,14 +300,14 @@ class ApiService:
|
|
|
270
300
|
session_id: str | None,
|
|
271
301
|
model: str,
|
|
272
302
|
payload: dict,
|
|
303
|
+
request: Any | None = None,
|
|
273
304
|
) -> dict:
|
|
274
|
-
|
|
275
|
-
if not current_user_id:
|
|
276
|
-
raise AdminApiException(401, detail="User is not authenticated.")
|
|
305
|
+
_current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
277
306
|
|
|
278
307
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
279
308
|
if not admin_model:
|
|
280
309
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
310
|
+
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
281
311
|
return await admin_model.save_model(None, payload) # type: ignore [return-value]
|
|
282
312
|
|
|
283
313
|
async def change_password(
|
|
@@ -285,14 +315,14 @@ class ApiService:
|
|
|
285
315
|
session_id: str | None,
|
|
286
316
|
id: UUID | int | str,
|
|
287
317
|
payload: dict,
|
|
318
|
+
request: Any | None = None,
|
|
288
319
|
) -> None:
|
|
289
|
-
|
|
290
|
-
if not current_user_id:
|
|
291
|
-
raise AdminApiException(401, detail="User is not authenticated.")
|
|
320
|
+
_current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
292
321
|
|
|
293
322
|
admin_model = get_admin_model(settings.ADMIN_USER_MODEL)
|
|
294
323
|
if not admin_model:
|
|
295
324
|
raise AdminApiException(404, detail=f"{settings.ADMIN_USER_MODEL} model is not registered.")
|
|
325
|
+
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
296
326
|
|
|
297
327
|
payload = ChangePasswordInputSchema(**payload)
|
|
298
328
|
if payload.password != payload.confirm_password:
|
|
@@ -325,14 +355,14 @@ class ApiService:
|
|
|
325
355
|
model: str,
|
|
326
356
|
id: UUID | int | str,
|
|
327
357
|
payload: dict,
|
|
358
|
+
request: Any | None = None,
|
|
328
359
|
) -> dict:
|
|
329
|
-
|
|
330
|
-
if not current_user_id:
|
|
331
|
-
raise AdminApiException(401, detail="User is not authenticated.")
|
|
360
|
+
_current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
332
361
|
|
|
333
362
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
334
363
|
if not admin_model:
|
|
335
364
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
365
|
+
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
336
366
|
|
|
337
367
|
try:
|
|
338
368
|
obj = await admin_model.save_model(id, payload)
|
|
@@ -350,10 +380,9 @@ class ApiService:
|
|
|
350
380
|
search: str | None = None,
|
|
351
381
|
sort_by: str | None = None,
|
|
352
382
|
filters: dict | None = None,
|
|
383
|
+
request: Any | None = None,
|
|
353
384
|
) -> tuple[str, str, StringIO | BytesIO | None]:
|
|
354
|
-
|
|
355
|
-
if not current_user_id:
|
|
356
|
-
raise AdminApiException(401, detail="User is not authenticated.")
|
|
385
|
+
_current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
357
386
|
|
|
358
387
|
query_params = ListQuerySchema(
|
|
359
388
|
search=search,
|
|
@@ -366,13 +395,14 @@ class ApiService:
|
|
|
366
395
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
367
396
|
if not admin_model:
|
|
368
397
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
398
|
+
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
369
399
|
|
|
370
400
|
# validations
|
|
371
|
-
fields = admin_model.get_fields_for_serialize()
|
|
401
|
+
fields = set(admin_model.get_fields_for_serialize())
|
|
372
402
|
|
|
373
403
|
if query_params.search and admin_model.search_fields:
|
|
374
404
|
for field in admin_model.search_fields:
|
|
375
|
-
if
|
|
405
|
+
if not is_allowed_field_or_path(field, fields):
|
|
376
406
|
raise AdminApiException(422, detail=f"Search by {field} is not allowed")
|
|
377
407
|
|
|
378
408
|
exclude_filter_fields = ("search", "sort_by", "offset", "limit")
|
|
@@ -395,7 +425,7 @@ class ApiService:
|
|
|
395
425
|
raise AdminApiException(422, detail=f"Sort by {query_params.sort_by} is not allowed")
|
|
396
426
|
elif admin_model.ordering:
|
|
397
427
|
for ordering_field in admin_model.ordering:
|
|
398
|
-
if ordering_field.strip("-")
|
|
428
|
+
if not is_allowed_field_or_path(ordering_field.strip("-"), fields):
|
|
399
429
|
raise AdminApiException(422, detail=f"Sort by {ordering_field} is not allowed")
|
|
400
430
|
|
|
401
431
|
content_type = "text/plain"
|
|
@@ -424,14 +454,14 @@ class ApiService:
|
|
|
424
454
|
session_id: str | None,
|
|
425
455
|
model: str,
|
|
426
456
|
id: UUID | int | str,
|
|
457
|
+
request: Any | None = None,
|
|
427
458
|
) -> UUID | int | str:
|
|
428
|
-
current_user_id = await
|
|
429
|
-
if not current_user_id:
|
|
430
|
-
raise AdminApiException(401, detail="User is not authenticated.")
|
|
459
|
+
current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
431
460
|
|
|
432
461
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
433
462
|
if not admin_model:
|
|
434
463
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
464
|
+
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
435
465
|
|
|
436
466
|
if str(current_user_id) == str(id) and model == settings.ADMIN_USER_MODEL:
|
|
437
467
|
raise AdminApiException(403, detail="You cannot delete yourself.")
|
|
@@ -441,14 +471,20 @@ class ApiService:
|
|
|
441
471
|
raise AdminApiException(404, detail=f"{model} not found.") from None
|
|
442
472
|
return id
|
|
443
473
|
|
|
444
|
-
async def action(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
474
|
+
async def action(
|
|
475
|
+
self,
|
|
476
|
+
session_id: str | None,
|
|
477
|
+
model: str,
|
|
478
|
+
action: str,
|
|
479
|
+
payload: ActionInputSchema,
|
|
480
|
+
request: Any | None = None,
|
|
481
|
+
) -> None:
|
|
482
|
+
_current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
448
483
|
|
|
449
484
|
admin_model = get_admin_or_admin_inline_model(model)
|
|
450
485
|
if not admin_model:
|
|
451
486
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
487
|
+
self._bind_admin_context(admin_model, request=request, user=current_user)
|
|
452
488
|
|
|
453
489
|
if action not in admin_model.actions:
|
|
454
490
|
raise AdminApiException(422, detail=f"{action} action is not in actions setting.")
|
|
@@ -467,9 +503,11 @@ class ApiService:
|
|
|
467
503
|
async def get_configuration(
|
|
468
504
|
self,
|
|
469
505
|
session_id: str | None,
|
|
506
|
+
request: Any | None = None,
|
|
470
507
|
) -> ConfigurationSchema:
|
|
471
|
-
|
|
472
|
-
|
|
508
|
+
try:
|
|
509
|
+
current_user_id, current_user = await self._get_authenticated_user(session_id)
|
|
510
|
+
except AdminApiException:
|
|
473
511
|
return ConfigurationSchema(
|
|
474
512
|
site_name=settings.ADMIN_SITE_NAME,
|
|
475
513
|
site_sign_in_logo=settings.ADMIN_SITE_SIGN_IN_LOGO,
|
|
@@ -485,7 +523,10 @@ class ApiService:
|
|
|
485
523
|
)
|
|
486
524
|
|
|
487
525
|
admin_models = cast(dict[Any, ModelAdmin | InlineModelAdmin], get_admin_models())
|
|
488
|
-
models = cast(
|
|
526
|
+
models = cast(
|
|
527
|
+
Sequence[ModelSchema],
|
|
528
|
+
await generate_models_schema(admin_models, user_id=current_user_id, user=current_user, request=request),
|
|
529
|
+
)
|
|
489
530
|
dashboard_widgets = generate_dashboard_widgets_schema()
|
|
490
531
|
return ConfigurationSchema(
|
|
491
532
|
site_name=settings.ADMIN_SITE_NAME,
|
|
@@ -3,6 +3,7 @@ import datetime
|
|
|
3
3
|
import inspect
|
|
4
4
|
import json
|
|
5
5
|
from collections.abc import Sequence
|
|
6
|
+
from contextvars import ContextVar
|
|
6
7
|
from io import BytesIO, StringIO
|
|
7
8
|
from typing import Any
|
|
8
9
|
from uuid import UUID
|
|
@@ -16,6 +17,9 @@ from fastadmin.models.schemas import DashboardWidgetType, ModelFieldWidgetSchema
|
|
|
16
17
|
class BaseModelAdmin:
|
|
17
18
|
"""Base class for model admin"""
|
|
18
19
|
|
|
20
|
+
_request_context: ContextVar[Any | None]
|
|
21
|
+
_user_context: ContextVar[Any | None]
|
|
22
|
+
|
|
19
23
|
# Use it only if you use several orms in your project.
|
|
20
24
|
model_name_prefix: str | None = None
|
|
21
25
|
|
|
@@ -198,6 +202,23 @@ class BaseModelAdmin:
|
|
|
198
202
|
:params model_cls: an orm/db model class.
|
|
199
203
|
"""
|
|
200
204
|
self.model_cls = model_cls
|
|
205
|
+
self._request_context = ContextVar(f"fastadmin_admin_request_context_{id(self)}", default=None)
|
|
206
|
+
self._user_context = ContextVar(f"fastadmin_admin_user_context_{id(self)}", default=None)
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def request(self) -> Any | None:
|
|
210
|
+
"""Current request object for this async context."""
|
|
211
|
+
return self._request_context.get()
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def user(self) -> Any | None:
|
|
215
|
+
"""Current authenticated user object for this async context."""
|
|
216
|
+
return self._user_context.get()
|
|
217
|
+
|
|
218
|
+
def set_context(self, request: Any | None = None, user: Any | None = None) -> None:
|
|
219
|
+
"""Set request/user context for the current async task."""
|
|
220
|
+
self._request_context.set(request)
|
|
221
|
+
self._user_context.set(user)
|
|
201
222
|
|
|
202
223
|
@staticmethod
|
|
203
224
|
def get_model_pk_name(orm_model_cls: Any) -> str:
|
|
@@ -227,6 +248,8 @@ class BaseModelAdmin:
|
|
|
227
248
|
search: str | None = None,
|
|
228
249
|
sort_by: str | None = None,
|
|
229
250
|
filters: dict | None = None,
|
|
251
|
+
prefetch_related_fields: list[str] | None = None,
|
|
252
|
+
additional_search_fields: list[str] | None = None,
|
|
230
253
|
) -> tuple[list[Any], int]:
|
|
231
254
|
"""This method is used to get list of orm/db model objects.
|
|
232
255
|
|
|
@@ -235,6 +258,8 @@ class BaseModelAdmin:
|
|
|
235
258
|
:params search: a search query.
|
|
236
259
|
:params sort_by: a sort by field name.
|
|
237
260
|
:params filters: a dict of filters.
|
|
261
|
+
:params prefetch_related_fields: a list of related fields to prefetch.
|
|
262
|
+
:params additional_search_fields: a list of additional search fields.
|
|
238
263
|
:return: A tuple of list of objects and total count.
|
|
239
264
|
"""
|
|
240
265
|
raise NotImplementedError
|
|
@@ -372,12 +397,12 @@ class BaseModelAdmin:
|
|
|
372
397
|
return serialized_dict
|
|
373
398
|
|
|
374
399
|
async def _serialize_obj_after_save(self, obj: Any) -> dict:
|
|
375
|
-
"""Serialize object after save; re-fetch if detached
|
|
400
|
+
"""Serialize object after save; re-fetch if detached/session is over."""
|
|
376
401
|
try:
|
|
377
402
|
return await self.serialize_obj(obj)
|
|
378
403
|
except Exception as exc:
|
|
379
|
-
#
|
|
380
|
-
if exc.__class__.__name__
|
|
404
|
+
# ORM-specific detached/session-over cases after commit.
|
|
405
|
+
if exc.__class__.__name__ not in {"DetachedInstanceError", "DatabaseSessionIsOver"}:
|
|
381
406
|
raise
|
|
382
407
|
pk_name = self.get_model_pk_name(self.model_cls)
|
|
383
408
|
pk = getattr(obj, pk_name, None)
|
|
@@ -697,6 +722,9 @@ class ModelAdmin(BaseModelAdmin):
|
|
|
697
722
|
|
|
698
723
|
|
|
699
724
|
class DashboardWidgetAdmin:
|
|
725
|
+
_request_context: ContextVar[Any | None]
|
|
726
|
+
_user_context: ContextVar[Any | None]
|
|
727
|
+
|
|
700
728
|
title: str
|
|
701
729
|
dashboard_widget_type: DashboardWidgetType
|
|
702
730
|
x_field: str
|
|
@@ -706,6 +734,25 @@ class DashboardWidgetAdmin:
|
|
|
706
734
|
x_field_filter_widget_props: dict[str, Any] | None = None
|
|
707
735
|
x_field_periods: list[str] | None = None
|
|
708
736
|
|
|
737
|
+
def __init__(self) -> None:
|
|
738
|
+
self._request_context = ContextVar(f"fastadmin_dashboard_request_context_{id(self)}", default=None)
|
|
739
|
+
self._user_context = ContextVar(f"fastadmin_dashboard_user_context_{id(self)}", default=None)
|
|
740
|
+
|
|
741
|
+
@property
|
|
742
|
+
def request(self) -> Any | None:
|
|
743
|
+
"""Current request object for this async context."""
|
|
744
|
+
return self._request_context.get()
|
|
745
|
+
|
|
746
|
+
@property
|
|
747
|
+
def user(self) -> Any | None:
|
|
748
|
+
"""Current authenticated user object for this async context."""
|
|
749
|
+
return self._user_context.get()
|
|
750
|
+
|
|
751
|
+
def set_context(self, request: Any | None = None, user: Any | None = None) -> None:
|
|
752
|
+
"""Set request/user context for the current async task."""
|
|
753
|
+
self._request_context.set(request)
|
|
754
|
+
self._user_context.set(user)
|
|
755
|
+
|
|
709
756
|
async def get_data(
|
|
710
757
|
self,
|
|
711
758
|
min_x_field: str | None = None,
|
|
@@ -101,6 +101,8 @@ def get_admin_or_admin_inline_model(orm_model_cls: str) -> ModelAdmin | InlineMo
|
|
|
101
101
|
async def generate_models_schema(
|
|
102
102
|
admin_models: dict[Any, ModelAdmin | InlineModelAdmin],
|
|
103
103
|
user_id: UUID | int | None = None,
|
|
104
|
+
user: Any | None = None,
|
|
105
|
+
request: Any | None = None,
|
|
104
106
|
inline_parent_admin_modal: ModelAdmin | None = None,
|
|
105
107
|
) -> list[ModelSchema | InlineModelSchema]:
|
|
106
108
|
"""Generate models schema
|
|
@@ -111,6 +113,8 @@ async def generate_models_schema(
|
|
|
111
113
|
"""
|
|
112
114
|
models_schemas: list[ModelSchema | InlineModelSchema] = []
|
|
113
115
|
for orm_model_cls, admin_model_obj in admin_models.items():
|
|
116
|
+
if hasattr(admin_model_obj, "set_context"):
|
|
117
|
+
admin_model_obj.set_context(request=request, user=user)
|
|
114
118
|
orm_model_fields = admin_model_obj.get_model_fields_with_widget_types()
|
|
115
119
|
orm_model_fields_for_serialize = admin_model_obj.get_fields_for_serialize()
|
|
116
120
|
|
|
@@ -258,6 +262,8 @@ async def generate_models_schema(
|
|
|
258
262
|
view_on_site=admin_model_obj.view_on_site,
|
|
259
263
|
inlines=await generate_models_schema(
|
|
260
264
|
{inline.model: inline(inline.model) for inline in admin_model_obj.inlines},
|
|
265
|
+
user=user,
|
|
266
|
+
request=request,
|
|
261
267
|
inline_parent_admin_modal=admin_model_obj,
|
|
262
268
|
user_id=user_id,
|
|
263
269
|
), # type: ignore [arg-type]
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import operator
|
|
2
1
|
from base64 import b64decode
|
|
3
2
|
from typing import Any
|
|
4
3
|
from uuid import UUID
|
|
@@ -245,6 +244,8 @@ class DjangoORMMixin:
|
|
|
245
244
|
search: str | None = None,
|
|
246
245
|
sort_by: str | None = None,
|
|
247
246
|
filters: dict | None = None,
|
|
247
|
+
prefetch_related_fields: list[str] | None = None,
|
|
248
|
+
additional_search_fields: list[str] | None = None,
|
|
248
249
|
) -> tuple[list[Any], int]:
|
|
249
250
|
"""This method is used to get list of orm/db model objects.
|
|
250
251
|
|
|
@@ -253,19 +254,30 @@ class DjangoORMMixin:
|
|
|
253
254
|
:params search: a search query.
|
|
254
255
|
:params sort_by: a sort by field name.
|
|
255
256
|
:params filters: a dict of filters.
|
|
257
|
+
:params prefetch_related_fields: a list of related fields to prefetch.
|
|
258
|
+
:params additional_search_fields: a list of additional search fields.
|
|
256
259
|
:return: A tuple of list of objects and total count.
|
|
257
260
|
"""
|
|
258
261
|
qs = self.model_cls.objects.all()
|
|
259
262
|
|
|
263
|
+
if prefetch_related_fields:
|
|
264
|
+
qs = qs.prefetch_related(*prefetch_related_fields)
|
|
265
|
+
|
|
260
266
|
if filters:
|
|
261
267
|
for field_with_condition, value in filters.items():
|
|
262
268
|
field = field_with_condition[0]
|
|
263
269
|
condition = field_with_condition[1]
|
|
264
270
|
qs = qs.filter(**{f"{field}__{condition}" if condition != "exact" else field: value})
|
|
265
271
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
272
|
+
search_fields = list(self.search_fields)
|
|
273
|
+
if additional_search_fields:
|
|
274
|
+
search_fields.extend(additional_search_fields)
|
|
275
|
+
|
|
276
|
+
if search and search_fields:
|
|
277
|
+
search_conditions = [Q(**{f + "__icontains": search}) for f in search_fields]
|
|
278
|
+
search_q = search_conditions[0]
|
|
279
|
+
for condition in search_conditions[1:]:
|
|
280
|
+
search_q |= condition
|
|
269
281
|
qs = qs.filter(search_q)
|
|
270
282
|
|
|
271
283
|
if sort_by:
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import inspect
|
|
1
2
|
from enum import EnumMeta
|
|
3
|
+
from types import SimpleNamespace
|
|
2
4
|
from typing import Any
|
|
3
5
|
from uuid import UUID
|
|
4
6
|
|
|
@@ -222,6 +224,8 @@ class PonyORMMixin:
|
|
|
222
224
|
search: str | None = None,
|
|
223
225
|
sort_by: str | None = None,
|
|
224
226
|
filters: dict | None = None,
|
|
227
|
+
prefetch_related_fields: list[str] | None = None,
|
|
228
|
+
additional_search_fields: list[str] | None = None,
|
|
225
229
|
) -> tuple[list[Any], int]:
|
|
226
230
|
"""This method is used to get list of orm/db model objects.
|
|
227
231
|
|
|
@@ -230,6 +234,8 @@ class PonyORMMixin:
|
|
|
230
234
|
:params search: a search query.
|
|
231
235
|
:params sort_by: a sort by field name.
|
|
232
236
|
:params filters: a dict of filters.
|
|
237
|
+
:params prefetch_related_fields: a list of related fields to prefetch.
|
|
238
|
+
:params additional_search_fields: a list of additional search fields.
|
|
233
239
|
:return: A tuple of list of objects and total count.
|
|
234
240
|
"""
|
|
235
241
|
|
|
@@ -269,11 +275,16 @@ class PonyORMMixin:
|
|
|
269
275
|
filter_expr = f""""{value}" {pony_condition} m.{field}"""
|
|
270
276
|
qs = qs.filter(filter_expr)
|
|
271
277
|
|
|
272
|
-
|
|
278
|
+
search_fields = list(self.search_fields)
|
|
279
|
+
if additional_search_fields:
|
|
280
|
+
search_fields.extend(additional_search_fields)
|
|
281
|
+
|
|
282
|
+
if search and search_fields:
|
|
273
283
|
ids = []
|
|
274
|
-
for search_field in
|
|
284
|
+
for search_field in search_fields:
|
|
285
|
+
pony_search_field = search_field.replace("__", ".")
|
|
275
286
|
# Pony string filter for case-insensitive search
|
|
276
|
-
filter_expr = f'"{search.lower()}" in m.{
|
|
287
|
+
filter_expr = f'"{search.lower()}" in m.{pony_search_field}.lower()'
|
|
277
288
|
qs_ids = qs.filter(filter_expr)
|
|
278
289
|
objs = list(qs_ids)
|
|
279
290
|
ids += [o.id for o in objs]
|
|
@@ -293,6 +304,9 @@ class PonyORMMixin:
|
|
|
293
304
|
if self.list_select_related:
|
|
294
305
|
qs = qs.prefetch(*[getattr(self.model_cls, field) for field in self.list_select_related])
|
|
295
306
|
|
|
307
|
+
if prefetch_related_fields:
|
|
308
|
+
qs = qs.prefetch(*[getattr(self.model_cls, field) for field in prefetch_related_fields])
|
|
309
|
+
|
|
296
310
|
if offset is not None and limit is not None:
|
|
297
311
|
qs = qs.limit(limit, offset=offset)
|
|
298
312
|
|
|
@@ -309,6 +323,54 @@ class PonyORMMixin:
|
|
|
309
323
|
"""
|
|
310
324
|
return self.model_cls.select(**{self.get_model_pk_name(self.model_cls): id}).first()
|
|
311
325
|
|
|
326
|
+
@sync_to_async
|
|
327
|
+
@db_session
|
|
328
|
+
def orm_serialize_obj_by_id(self, id: UUID | int | str) -> dict | None:
|
|
329
|
+
"""Serialize object by id in one db_session (avoids detached access)."""
|
|
330
|
+
obj = self.model_cls.select(**{self.get_model_pk_name(self.model_cls): id}).first()
|
|
331
|
+
if not obj:
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
fields = self.get_model_fields_with_widget_types()
|
|
335
|
+
fields_for_serialize = self.get_fields_for_serialize()
|
|
336
|
+
obj_dict: dict[str, Any] = {}
|
|
337
|
+
|
|
338
|
+
for field in fields:
|
|
339
|
+
if field.name not in fields_for_serialize:
|
|
340
|
+
continue
|
|
341
|
+
if field.is_m2m:
|
|
342
|
+
rel_model_cls = getattr(self.model_cls, field.column_name).py_type
|
|
343
|
+
rel_key_id = self.get_model_pk_name(rel_model_cls)
|
|
344
|
+
obj_dict[field.name] = [getattr(o, rel_key_id) for o in getattr(obj, field.column_name)]
|
|
345
|
+
else:
|
|
346
|
+
relation_attr = getattr(self.model_cls, field.column_name, None)
|
|
347
|
+
if relation_attr and getattr(relation_attr, "is_relation", False):
|
|
348
|
+
rel_obj = getattr(obj, field.column_name)
|
|
349
|
+
if rel_obj is None:
|
|
350
|
+
obj_dict[field.name] = None
|
|
351
|
+
else:
|
|
352
|
+
rel_model_cls = relation_attr.py_type
|
|
353
|
+
rel_key_id = self.get_model_pk_name(rel_model_cls)
|
|
354
|
+
obj_dict[field.name] = getattr(rel_obj, rel_key_id)
|
|
355
|
+
else:
|
|
356
|
+
obj_dict[field.name] = getattr(obj, field.column_name)
|
|
357
|
+
|
|
358
|
+
obj_dict["__str__"] = str(obj)
|
|
359
|
+
proxy = SimpleNamespace(**obj_dict)
|
|
360
|
+
for field_name in fields_for_serialize:
|
|
361
|
+
display_field_function = getattr(self, field_name, None)
|
|
362
|
+
if not display_field_function or not hasattr(display_field_function, "is_display"):
|
|
363
|
+
continue
|
|
364
|
+
if inspect.iscoroutinefunction(display_field_function):
|
|
365
|
+
# Async display functions are not expected in Pony sync session context.
|
|
366
|
+
continue
|
|
367
|
+
try:
|
|
368
|
+
obj_dict[field_name] = display_field_function(proxy)
|
|
369
|
+
except (AttributeError, TypeError, KeyError):
|
|
370
|
+
obj_dict[field_name] = display_field_function(obj)
|
|
371
|
+
|
|
372
|
+
return obj_dict
|
|
373
|
+
|
|
312
374
|
@sync_to_async
|
|
313
375
|
@db_session
|
|
314
376
|
def orm_save_obj(self, id: UUID | Any | None, payload: dict) -> Any:
|
|
@@ -12,6 +12,66 @@ from fastadmin.settings import settings
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class SqlAlchemyMixin:
|
|
15
|
+
def _build_search_condition(self, field_path: str, search: str) -> Any | None:
|
|
16
|
+
"""Build SQLAlchemy search condition for simple and nested fields.
|
|
17
|
+
|
|
18
|
+
Supports direct fields (e.g. ``name``) and relation paths using
|
|
19
|
+
Django-style separator (e.g. ``tournament__name``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def recurse(model_cls: Any, parts: list[str]) -> Any | None:
|
|
23
|
+
attr = getattr(model_cls, parts[0], None)
|
|
24
|
+
if attr is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
if len(parts) == 1:
|
|
28
|
+
return attr.ilike(f"%{search}%")
|
|
29
|
+
|
|
30
|
+
rel_property = getattr(attr, "property", None)
|
|
31
|
+
if rel_property is None:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
related_model = getattrs(rel_property, "mapper.class_")
|
|
35
|
+
if related_model is None:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
nested_condition = recurse(related_model, parts[1:])
|
|
39
|
+
if nested_condition is None:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
if getattr(rel_property, "uselist", False):
|
|
43
|
+
return attr.any(nested_condition)
|
|
44
|
+
return attr.has(nested_condition)
|
|
45
|
+
|
|
46
|
+
return recurse(self.model_cls, field_path.split("__"))
|
|
47
|
+
|
|
48
|
+
def _resolve_ordering_field(self, ordering_field: str) -> str:
|
|
49
|
+
"""Resolve ordering field for SQLAlchemy.
|
|
50
|
+
|
|
51
|
+
Relation keys (e.g. `tournament`) are not direct SQL columns.
|
|
52
|
+
For MANYTOONE/ONETOONE relations, sort by local FK column
|
|
53
|
+
(e.g. `tournament_id`) instead.
|
|
54
|
+
"""
|
|
55
|
+
if not ordering_field:
|
|
56
|
+
return ordering_field
|
|
57
|
+
|
|
58
|
+
prefix = "-" if ordering_field.startswith("-") else ""
|
|
59
|
+
field_name = ordering_field.lstrip("-")
|
|
60
|
+
|
|
61
|
+
# Keep nested paths unchanged (join/loader strategy specific)
|
|
62
|
+
if "__" in field_name:
|
|
63
|
+
return ordering_field
|
|
64
|
+
|
|
65
|
+
mapper = inspect(self.model_cls)
|
|
66
|
+
relation = next((rel for rel in mapper.relationships if rel.key == field_name), None)
|
|
67
|
+
if relation and getattrs(relation, "direction.name") in ("MANYTOONE", "ONETOONE"):
|
|
68
|
+
local_columns = list(getattr(relation, "local_columns", []))
|
|
69
|
+
if local_columns:
|
|
70
|
+
return f"{prefix}{local_columns[0].key}"
|
|
71
|
+
return f"{prefix}{field_name}_id"
|
|
72
|
+
|
|
73
|
+
return ordering_field
|
|
74
|
+
|
|
15
75
|
@staticmethod
|
|
16
76
|
def get_model_pk_name(orm_model_cls: Any) -> str:
|
|
17
77
|
"""This method is used to get model pk name.
|
|
@@ -240,6 +300,8 @@ class SqlAlchemyMixin:
|
|
|
240
300
|
search: str | None = None,
|
|
241
301
|
sort_by: str | None = None,
|
|
242
302
|
filters: dict | None = None,
|
|
303
|
+
prefetch_related_fields: list[str] | None = None,
|
|
304
|
+
additional_search_fields: list[str] | None = None,
|
|
243
305
|
) -> tuple[list[Any], int]:
|
|
244
306
|
"""This method is used to get list of orm/db model objects.
|
|
245
307
|
|
|
@@ -248,10 +310,13 @@ class SqlAlchemyMixin:
|
|
|
248
310
|
:params search: a search query.
|
|
249
311
|
:params sort_by: a sort by field name.
|
|
250
312
|
:params filters: a dict of filters.
|
|
313
|
+
:params prefetch_related_fields: a list of related fields to prefetch.
|
|
314
|
+
:params additional_search_fields: a list of additional search fields.
|
|
251
315
|
:return: A tuple of list of objects and total count.
|
|
252
316
|
"""
|
|
253
317
|
|
|
254
318
|
def convert_sort_by(sort_by: str) -> str:
|
|
319
|
+
sort_by = self._resolve_ordering_field(sort_by)
|
|
255
320
|
if sort_by.startswith("-"):
|
|
256
321
|
return sort_by[1:] + " desc"
|
|
257
322
|
return sort_by
|
|
@@ -293,10 +358,18 @@ class SqlAlchemyMixin:
|
|
|
293
358
|
q.append(model_field.ilike(f"%{value}%"))
|
|
294
359
|
qs = qs.where(and_(*q))
|
|
295
360
|
|
|
296
|
-
|
|
361
|
+
search_fields = list(self.search_fields)
|
|
362
|
+
if additional_search_fields:
|
|
363
|
+
search_fields.extend(additional_search_fields)
|
|
364
|
+
|
|
365
|
+
if search and search_fields:
|
|
297
366
|
q = []
|
|
298
|
-
for field in
|
|
299
|
-
|
|
367
|
+
for field in search_fields:
|
|
368
|
+
condition = self._build_search_condition(field, search)
|
|
369
|
+
if condition is not None:
|
|
370
|
+
q.append(condition)
|
|
371
|
+
if not q:
|
|
372
|
+
return [], 0
|
|
300
373
|
qs = qs.where(or_(*q))
|
|
301
374
|
|
|
302
375
|
if sort_by:
|
|
@@ -312,6 +385,28 @@ class SqlAlchemyMixin:
|
|
|
312
385
|
for field in self.list_select_related:
|
|
313
386
|
qs = qs.options(selectinload(getattr(self.model_cls, field)))
|
|
314
387
|
|
|
388
|
+
if prefetch_related_fields:
|
|
389
|
+
for field_path in prefetch_related_fields:
|
|
390
|
+
parts = field_path.split("__")
|
|
391
|
+
current_model = self.model_cls
|
|
392
|
+
attr = getattr(current_model, parts[0], None)
|
|
393
|
+
if attr is None:
|
|
394
|
+
continue
|
|
395
|
+
option = selectinload(attr)
|
|
396
|
+
current_model = getattrs(attr, "property.mapper.class_")
|
|
397
|
+
for part in parts[1:]:
|
|
398
|
+
if current_model is None:
|
|
399
|
+
break
|
|
400
|
+
nested_attr = getattr(current_model, part, None)
|
|
401
|
+
if nested_attr is None:
|
|
402
|
+
break
|
|
403
|
+
next_model = getattrs(nested_attr, "property.mapper.class_")
|
|
404
|
+
if next_model is None:
|
|
405
|
+
break
|
|
406
|
+
option = option.selectinload(nested_attr)
|
|
407
|
+
current_model = next_model
|
|
408
|
+
qs = qs.options(option)
|
|
409
|
+
|
|
315
410
|
if offset is not None and limit is not None:
|
|
316
411
|
qs = qs.offset(offset)
|
|
317
412
|
qs = qs.limit(limit)
|
|
@@ -11,6 +11,31 @@ from fastadmin.settings import settings
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class TortoiseMixin:
|
|
14
|
+
def _resolve_ordering_field(self, ordering_field: str) -> str:
|
|
15
|
+
"""Resolve ordering field for Tortoise.
|
|
16
|
+
|
|
17
|
+
Tortoise 1.x does not allow ordering by relation names directly
|
|
18
|
+
(e.g. `tournament`). For FK/O2O relation fields, order by their
|
|
19
|
+
backing id field instead (e.g. `tournament_id`).
|
|
20
|
+
"""
|
|
21
|
+
if not ordering_field:
|
|
22
|
+
return ordering_field
|
|
23
|
+
|
|
24
|
+
prefix = "-" if ordering_field.startswith("-") else ""
|
|
25
|
+
field_name = ordering_field.lstrip("-")
|
|
26
|
+
|
|
27
|
+
# Respect explicit nested ordering (e.g. relation__name)
|
|
28
|
+
if "__" in field_name:
|
|
29
|
+
return ordering_field
|
|
30
|
+
|
|
31
|
+
orm_field = self.model_cls._meta.fields_map.get(field_name)
|
|
32
|
+
if not orm_field:
|
|
33
|
+
return ordering_field
|
|
34
|
+
|
|
35
|
+
if orm_field.__class__.__name__ in ("ForeignKeyFieldInstance", "OneToOneFieldInstance"):
|
|
36
|
+
return f"{prefix}{field_name}_id"
|
|
37
|
+
return ordering_field
|
|
38
|
+
|
|
14
39
|
@staticmethod
|
|
15
40
|
def get_model_pk_name(orm_model_cls: Any) -> str:
|
|
16
41
|
"""This method is used to get model pk name.
|
|
@@ -229,6 +254,8 @@ class TortoiseMixin:
|
|
|
229
254
|
search: str | None = None,
|
|
230
255
|
sort_by: str | None = None,
|
|
231
256
|
filters: dict | None = None,
|
|
257
|
+
prefetch_related_fields: list[str] | None = None,
|
|
258
|
+
additional_search_fields: list[str] | None = None,
|
|
232
259
|
) -> tuple[list[Any], int]:
|
|
233
260
|
"""This method is used to get list of orm/db model objects.
|
|
234
261
|
|
|
@@ -237,29 +264,38 @@ class TortoiseMixin:
|
|
|
237
264
|
:params search: a search query.
|
|
238
265
|
:params sort_by: a sort by field name.
|
|
239
266
|
:params filters: a dict of filters.
|
|
267
|
+
:params prefetch_related_fields: a list of related fields to prefetch.
|
|
268
|
+
:params additional_search_fields: a list of additional search fields.
|
|
240
269
|
:return: A tuple of list of objects and total count.
|
|
241
270
|
"""
|
|
242
271
|
qs = self.model_cls.all()
|
|
243
272
|
|
|
273
|
+
if prefetch_related_fields:
|
|
274
|
+
qs = qs.prefetch_related(*prefetch_related_fields).distinct()
|
|
275
|
+
|
|
244
276
|
if filters:
|
|
245
277
|
for field_with_condition, value in filters.items():
|
|
246
278
|
field = field_with_condition[0]
|
|
247
279
|
condition = field_with_condition[1]
|
|
248
280
|
qs = qs.filter(**{f"{field}__{condition}" if condition != "exact" else field: value})
|
|
249
281
|
|
|
250
|
-
|
|
282
|
+
search_fields = list(self.search_fields)
|
|
283
|
+
if additional_search_fields:
|
|
284
|
+
search_fields.extend(additional_search_fields)
|
|
285
|
+
|
|
286
|
+
if search and search_fields:
|
|
251
287
|
qs = qs.filter(
|
|
252
288
|
functools.reduce(
|
|
253
289
|
operator.or_,
|
|
254
|
-
(Q(**{f + "__icontains": search}) for f in
|
|
290
|
+
(Q(**{f + "__icontains": search}) for f in search_fields),
|
|
255
291
|
Q(),
|
|
256
292
|
)
|
|
257
293
|
)
|
|
258
294
|
|
|
259
295
|
if sort_by:
|
|
260
|
-
qs = qs.order_by(sort_by)
|
|
296
|
+
qs = qs.order_by(self._resolve_ordering_field(sort_by))
|
|
261
297
|
elif self.ordering:
|
|
262
|
-
qs = qs.order_by(*self.ordering)
|
|
298
|
+
qs = qs.order_by(*(self._resolve_ordering_field(field) for field in self.ordering))
|
|
263
299
|
|
|
264
300
|
total = await qs.count()
|
|
265
301
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "fastadmin"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.5"
|
|
4
4
|
description = "FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Flask/Django inspired by Django Admin."
|
|
5
5
|
authors = ["Seva D <vsdudakov@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -61,6 +61,7 @@ isort = "^6.0.1"
|
|
|
61
61
|
python-dotenv = "^1.0.0"
|
|
62
62
|
jinja2 = "^3.1.2"
|
|
63
63
|
pytest-xdist = "^3.6.1"
|
|
64
|
+
typing-extensions = "^4.15.0"
|
|
64
65
|
|
|
65
66
|
[build-system]
|
|
66
67
|
requires = ["poetry-core"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|