fastadmin 0.3.2__tar.gz → 0.3.4__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.2 → fastadmin-0.3.4}/PKG-INFO +2 -2
- {fastadmin-0.3.2 → fastadmin-0.3.4}/README.md +1 -1
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/views.py +8 -1
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/views.py +7 -1
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/views.py +7 -1
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/helpers.py +0 -15
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/service.py +14 -6
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/base.py +35 -3
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/helpers.py +1 -1
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/orms/ponyorm.py +52 -1
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/orms/sqlalchemy.py +75 -1
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/orms/tortoise.py +27 -2
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/index.min.js +70 -70
- {fastadmin-0.3.2 → fastadmin-0.3.4}/pyproject.toml +2 -1
- {fastadmin-0.3.2 → fastadmin-0.3.4}/LICENSE +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/__init__.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/__init__.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/exceptions.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/__init__.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/__init__.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/__init__.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/api.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/urls.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/__init__.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/api.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/app.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/__init__.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/api.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/app.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/schemas.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/__init__.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/decorators.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/orms/__init__.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/orms/django.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/schemas.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/settings.py +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-6Z7niv9l.js +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-C151k0-L.js +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-C9KPOOKD.js +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-D1fCgYB7.js +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-D3TWcJOI.js +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-DRNcaZ-V.js +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-MF2p-l5_.js +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/images/favicon.png +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/images/header-logo.svg +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/images/sign-in-logo.svg +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/index.html +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/index.min.css +0 -0
- {fastadmin-0.3.2 → fastadmin-0.3.4}/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.4
|
|
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/)
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
1
3
|
from django.http import HttpResponse
|
|
2
4
|
|
|
3
5
|
from fastadmin.api.helpers import get_template
|
|
4
6
|
from fastadmin.settings import ROOT_DIR, settings
|
|
5
7
|
|
|
6
8
|
|
|
9
|
+
def _get_admin_prefix() -> str:
|
|
10
|
+
"""Return admin URL prefix from env at request time so it respects os.environ set after import."""
|
|
11
|
+
return os.getenv("ADMIN_PREFIX", settings.ADMIN_PREFIX)
|
|
12
|
+
|
|
13
|
+
|
|
7
14
|
def index(request):
|
|
8
15
|
"""This method is used to render index page.
|
|
9
16
|
|
|
@@ -13,7 +20,7 @@ def index(request):
|
|
|
13
20
|
template = get_template(
|
|
14
21
|
ROOT_DIR / "templates" / "index.html",
|
|
15
22
|
{
|
|
16
|
-
"ADMIN_PREFIX":
|
|
23
|
+
"ADMIN_PREFIX": _get_admin_prefix(),
|
|
17
24
|
},
|
|
18
25
|
)
|
|
19
26
|
return HttpResponse(template)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import os
|
|
2
3
|
|
|
3
4
|
from fastapi import APIRouter
|
|
4
5
|
from fastapi.responses import HTMLResponse
|
|
@@ -10,6 +11,11 @@ logger = logging.getLogger(__name__)
|
|
|
10
11
|
router = APIRouter()
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
def _get_admin_prefix() -> str:
|
|
15
|
+
"""Return admin URL prefix from env at request time so it respects os.environ set after import."""
|
|
16
|
+
return os.getenv("ADMIN_PREFIX", settings.ADMIN_PREFIX)
|
|
17
|
+
|
|
18
|
+
|
|
13
19
|
@router.get("/", response_class=HTMLResponse)
|
|
14
20
|
def index():
|
|
15
21
|
"""This method is used to render index page.
|
|
@@ -20,6 +26,6 @@ def index():
|
|
|
20
26
|
return get_template(
|
|
21
27
|
ROOT_DIR / "templates" / "index.html",
|
|
22
28
|
{
|
|
23
|
-
"ADMIN_PREFIX":
|
|
29
|
+
"ADMIN_PREFIX": _get_admin_prefix(),
|
|
24
30
|
},
|
|
25
31
|
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import os
|
|
2
3
|
|
|
3
4
|
from flask import Blueprint
|
|
4
5
|
|
|
@@ -12,6 +13,11 @@ views_router = Blueprint(
|
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
def _get_admin_prefix() -> str:
|
|
17
|
+
"""Return admin URL prefix from env at request time so it respects os.environ set after import."""
|
|
18
|
+
return os.getenv("ADMIN_PREFIX", settings.ADMIN_PREFIX)
|
|
19
|
+
|
|
20
|
+
|
|
15
21
|
@views_router.route("/")
|
|
16
22
|
def index():
|
|
17
23
|
"""This method is used to render index page.
|
|
@@ -21,6 +27,6 @@ def index():
|
|
|
21
27
|
return get_template(
|
|
22
28
|
ROOT_DIR / "templates" / "index.html",
|
|
23
29
|
{
|
|
24
|
-
"ADMIN_PREFIX":
|
|
30
|
+
"ADMIN_PREFIX": _get_admin_prefix(),
|
|
25
31
|
},
|
|
26
32
|
)
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import base64
|
|
2
|
-
import binascii
|
|
3
1
|
from pathlib import Path
|
|
4
2
|
from uuid import UUID
|
|
5
3
|
|
|
@@ -105,19 +103,6 @@ def is_valid_id(id: UUID | int | str) -> bool:
|
|
|
105
103
|
return False
|
|
106
104
|
|
|
107
105
|
|
|
108
|
-
def is_valid_base64(value: str) -> bool:
|
|
109
|
-
"""Check if a string is a valid base64.
|
|
110
|
-
|
|
111
|
-
:param value: A string to test.
|
|
112
|
-
:return: True if s is a valid base64, False otherwise.
|
|
113
|
-
"""
|
|
114
|
-
try:
|
|
115
|
-
base64.decodebytes(value.encode("ascii"))
|
|
116
|
-
return True
|
|
117
|
-
except binascii.Error:
|
|
118
|
-
return False
|
|
119
|
-
|
|
120
|
-
|
|
121
106
|
def get_template(template: Path, context: dict) -> str:
|
|
122
107
|
with template.open("r") as file:
|
|
123
108
|
content = file.read()
|
|
@@ -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
|
|
|
@@ -200,11 +208,11 @@ class ApiService:
|
|
|
200
208
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
201
209
|
|
|
202
210
|
# validations
|
|
203
|
-
fields = admin_model.get_fields_for_serialize()
|
|
211
|
+
fields = set(admin_model.get_fields_for_serialize())
|
|
204
212
|
|
|
205
213
|
if query_params.search and admin_model.search_fields:
|
|
206
214
|
for field in admin_model.search_fields:
|
|
207
|
-
if
|
|
215
|
+
if not is_allowed_field_or_path(field, fields):
|
|
208
216
|
raise AdminApiException(422, detail=f"Search by {field} is not allowed")
|
|
209
217
|
|
|
210
218
|
exclude_filter_fields = ("search", "sort_by", "offset", "limit")
|
|
@@ -227,7 +235,7 @@ class ApiService:
|
|
|
227
235
|
raise AdminApiException(422, detail=f"Sort by {query_params.sort_by} is not allowed")
|
|
228
236
|
elif admin_model.ordering:
|
|
229
237
|
for ordering_field in admin_model.ordering:
|
|
230
|
-
if ordering_field.strip("-")
|
|
238
|
+
if not is_allowed_field_or_path(ordering_field.strip("-"), fields):
|
|
231
239
|
raise AdminApiException(422, detail=f"Sort by {ordering_field} is not allowed")
|
|
232
240
|
|
|
233
241
|
if admin_model.list_select_related:
|
|
@@ -368,11 +376,11 @@ class ApiService:
|
|
|
368
376
|
raise AdminApiException(404, detail=f"{model} model is not registered.")
|
|
369
377
|
|
|
370
378
|
# validations
|
|
371
|
-
fields = admin_model.get_fields_for_serialize()
|
|
379
|
+
fields = set(admin_model.get_fields_for_serialize())
|
|
372
380
|
|
|
373
381
|
if query_params.search and admin_model.search_fields:
|
|
374
382
|
for field in admin_model.search_fields:
|
|
375
|
-
if
|
|
383
|
+
if not is_allowed_field_or_path(field, fields):
|
|
376
384
|
raise AdminApiException(422, detail=f"Search by {field} is not allowed")
|
|
377
385
|
|
|
378
386
|
exclude_filter_fields = ("search", "sort_by", "offset", "limit")
|
|
@@ -395,7 +403,7 @@ class ApiService:
|
|
|
395
403
|
raise AdminApiException(422, detail=f"Sort by {query_params.sort_by} is not allowed")
|
|
396
404
|
elif admin_model.ordering:
|
|
397
405
|
for ordering_field in admin_model.ordering:
|
|
398
|
-
if ordering_field.strip("-")
|
|
406
|
+
if not is_allowed_field_or_path(ordering_field.strip("-"), fields):
|
|
399
407
|
raise AdminApiException(422, detail=f"Sort by {ordering_field} is not allowed")
|
|
400
408
|
|
|
401
409
|
content_type = "text/plain"
|
|
@@ -247,6 +247,16 @@ class BaseModelAdmin:
|
|
|
247
247
|
"""
|
|
248
248
|
raise NotImplementedError
|
|
249
249
|
|
|
250
|
+
async def orm_serialize_obj_by_id(self, id: UUID | int | str) -> dict | None:
|
|
251
|
+
"""Serialize object by id (e.g. in a single session to avoid DetachedInstanceError).
|
|
252
|
+
|
|
253
|
+
Override in ORM layer to run get+serialize inside one session when needed.
|
|
254
|
+
"""
|
|
255
|
+
obj = await self.orm_get_obj(id)
|
|
256
|
+
if obj is None:
|
|
257
|
+
return None
|
|
258
|
+
return await self.serialize_obj(obj)
|
|
259
|
+
|
|
250
260
|
async def orm_save_obj(self, id: UUID | Any | None, payload: dict) -> Any:
|
|
251
261
|
"""This method is used to save orm/db model object.
|
|
252
262
|
|
|
@@ -361,6 +371,23 @@ class BaseModelAdmin:
|
|
|
361
371
|
serialized_dict["__str__"] = await str_fn()
|
|
362
372
|
return serialized_dict
|
|
363
373
|
|
|
374
|
+
async def _serialize_obj_after_save(self, obj: Any) -> dict:
|
|
375
|
+
"""Serialize object after save; re-fetch if detached/session is over."""
|
|
376
|
+
try:
|
|
377
|
+
return await self.serialize_obj(obj)
|
|
378
|
+
except Exception as exc:
|
|
379
|
+
# ORM-specific detached/session-over cases after commit.
|
|
380
|
+
if exc.__class__.__name__ not in {"DetachedInstanceError", "DatabaseSessionIsOver"}:
|
|
381
|
+
raise
|
|
382
|
+
pk_name = self.get_model_pk_name(self.model_cls)
|
|
383
|
+
pk = getattr(obj, pk_name, None)
|
|
384
|
+
if pk is None:
|
|
385
|
+
raise
|
|
386
|
+
result = await self.orm_serialize_obj_by_id(pk)
|
|
387
|
+
if result is None:
|
|
388
|
+
raise
|
|
389
|
+
return result
|
|
390
|
+
|
|
364
391
|
async def serialize_obj(self, obj: Any, list_view: bool = False) -> dict:
|
|
365
392
|
"""Serialize orm model obj to dict.
|
|
366
393
|
|
|
@@ -404,8 +431,13 @@ class BaseModelAdmin:
|
|
|
404
431
|
return value
|
|
405
432
|
match field.form_widget_type:
|
|
406
433
|
case WidgetType.TimePicker:
|
|
407
|
-
|
|
408
|
-
|
|
434
|
+
try:
|
|
435
|
+
return datetime.datetime.fromisoformat(value).time()
|
|
436
|
+
except ValueError:
|
|
437
|
+
return datetime.time.fromisoformat(value)
|
|
438
|
+
case WidgetType.DatePicker:
|
|
439
|
+
return datetime.datetime.fromisoformat(value).date()
|
|
440
|
+
case WidgetType.DateTimePicker:
|
|
409
441
|
return datetime.datetime.fromisoformat(value)
|
|
410
442
|
case _:
|
|
411
443
|
return value
|
|
@@ -483,7 +515,7 @@ class BaseModelAdmin:
|
|
|
483
515
|
if m2m_field.name in payload:
|
|
484
516
|
await self.orm_save_m2m_ids(obj, m2m_field.column_name, payload[m2m_field.name])
|
|
485
517
|
|
|
486
|
-
return await self.
|
|
518
|
+
return await self._serialize_obj_after_save(obj)
|
|
487
519
|
|
|
488
520
|
async def delete_model(self, id: UUID | int | str) -> None:
|
|
489
521
|
"""This method is used to delete orm/db model object.
|
|
@@ -198,7 +198,7 @@ async def generate_models_schema(
|
|
|
198
198
|
empty_value_display=admin_model_obj.empty_value_display,
|
|
199
199
|
filter_widget_type=None,
|
|
200
200
|
filter_widget_props=None,
|
|
201
|
-
width=None,
|
|
201
|
+
width=admin_model_obj.list_display_widths.get(field_name, None),
|
|
202
202
|
),
|
|
203
203
|
add_configuration=None,
|
|
204
204
|
change_configuration=None,
|
|
@@ -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
|
|
|
@@ -272,8 +274,9 @@ class PonyORMMixin:
|
|
|
272
274
|
if search and self.search_fields:
|
|
273
275
|
ids = []
|
|
274
276
|
for search_field in self.search_fields:
|
|
277
|
+
pony_search_field = search_field.replace("__", ".")
|
|
275
278
|
# Pony string filter for case-insensitive search
|
|
276
|
-
filter_expr = f'"{search.lower()}" in m.{
|
|
279
|
+
filter_expr = f'"{search.lower()}" in m.{pony_search_field}.lower()'
|
|
277
280
|
qs_ids = qs.filter(filter_expr)
|
|
278
281
|
objs = list(qs_ids)
|
|
279
282
|
ids += [o.id for o in objs]
|
|
@@ -309,6 +312,54 @@ class PonyORMMixin:
|
|
|
309
312
|
"""
|
|
310
313
|
return self.model_cls.select(**{self.get_model_pk_name(self.model_cls): id}).first()
|
|
311
314
|
|
|
315
|
+
@sync_to_async
|
|
316
|
+
@db_session
|
|
317
|
+
def orm_serialize_obj_by_id(self, id: UUID | int | str) -> dict | None:
|
|
318
|
+
"""Serialize object by id in one db_session (avoids detached access)."""
|
|
319
|
+
obj = self.model_cls.select(**{self.get_model_pk_name(self.model_cls): id}).first()
|
|
320
|
+
if not obj:
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
fields = self.get_model_fields_with_widget_types()
|
|
324
|
+
fields_for_serialize = self.get_fields_for_serialize()
|
|
325
|
+
obj_dict: dict[str, Any] = {}
|
|
326
|
+
|
|
327
|
+
for field in fields:
|
|
328
|
+
if field.name not in fields_for_serialize:
|
|
329
|
+
continue
|
|
330
|
+
if field.is_m2m:
|
|
331
|
+
rel_model_cls = getattr(self.model_cls, field.column_name).py_type
|
|
332
|
+
rel_key_id = self.get_model_pk_name(rel_model_cls)
|
|
333
|
+
obj_dict[field.name] = [getattr(o, rel_key_id) for o in getattr(obj, field.column_name)]
|
|
334
|
+
else:
|
|
335
|
+
relation_attr = getattr(self.model_cls, field.column_name, None)
|
|
336
|
+
if relation_attr and getattr(relation_attr, "is_relation", False):
|
|
337
|
+
rel_obj = getattr(obj, field.column_name)
|
|
338
|
+
if rel_obj is None:
|
|
339
|
+
obj_dict[field.name] = None
|
|
340
|
+
else:
|
|
341
|
+
rel_model_cls = relation_attr.py_type
|
|
342
|
+
rel_key_id = self.get_model_pk_name(rel_model_cls)
|
|
343
|
+
obj_dict[field.name] = getattr(rel_obj, rel_key_id)
|
|
344
|
+
else:
|
|
345
|
+
obj_dict[field.name] = getattr(obj, field.column_name)
|
|
346
|
+
|
|
347
|
+
obj_dict["__str__"] = str(obj)
|
|
348
|
+
proxy = SimpleNamespace(**obj_dict)
|
|
349
|
+
for field_name in fields_for_serialize:
|
|
350
|
+
display_field_function = getattr(self, field_name, None)
|
|
351
|
+
if not display_field_function or not hasattr(display_field_function, "is_display"):
|
|
352
|
+
continue
|
|
353
|
+
if inspect.iscoroutinefunction(display_field_function):
|
|
354
|
+
# Async display functions are not expected in Pony sync session context.
|
|
355
|
+
continue
|
|
356
|
+
try:
|
|
357
|
+
obj_dict[field_name] = display_field_function(proxy)
|
|
358
|
+
except (AttributeError, TypeError, KeyError):
|
|
359
|
+
obj_dict[field_name] = display_field_function(obj)
|
|
360
|
+
|
|
361
|
+
return obj_dict
|
|
362
|
+
|
|
312
363
|
@sync_to_async
|
|
313
364
|
@db_session
|
|
314
365
|
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.
|
|
@@ -252,6 +312,7 @@ class SqlAlchemyMixin:
|
|
|
252
312
|
"""
|
|
253
313
|
|
|
254
314
|
def convert_sort_by(sort_by: str) -> str:
|
|
315
|
+
sort_by = self._resolve_ordering_field(sort_by)
|
|
255
316
|
if sort_by.startswith("-"):
|
|
256
317
|
return sort_by[1:] + " desc"
|
|
257
318
|
return sort_by
|
|
@@ -296,7 +357,11 @@ class SqlAlchemyMixin:
|
|
|
296
357
|
if search and self.search_fields:
|
|
297
358
|
q = []
|
|
298
359
|
for field in self.search_fields:
|
|
299
|
-
|
|
360
|
+
condition = self._build_search_condition(field, search)
|
|
361
|
+
if condition is not None:
|
|
362
|
+
q.append(condition)
|
|
363
|
+
if not q:
|
|
364
|
+
return [], 0
|
|
300
365
|
qs = qs.where(or_(*q))
|
|
301
366
|
|
|
302
367
|
if sort_by:
|
|
@@ -329,6 +394,15 @@ class SqlAlchemyMixin:
|
|
|
329
394
|
async with sessionmaker() as session:
|
|
330
395
|
return await session.get(self.model_cls, id)
|
|
331
396
|
|
|
397
|
+
async def orm_serialize_obj_by_id(self, id: UUID | int | str) -> dict | None:
|
|
398
|
+
"""Serialize object by id inside a single session to avoid DetachedInstanceError."""
|
|
399
|
+
sessionmaker = self.get_sessionmaker()
|
|
400
|
+
async with sessionmaker() as session:
|
|
401
|
+
obj = await session.get(self.model_cls, id)
|
|
402
|
+
if obj is None:
|
|
403
|
+
return None
|
|
404
|
+
return await self.serialize_obj(obj)
|
|
405
|
+
|
|
332
406
|
def _get_foreign_key_fields(self) -> list[str]:
|
|
333
407
|
"""Returns a list of foreign key fields for the model.
|
|
334
408
|
|
|
@@ -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.
|
|
@@ -257,9 +282,9 @@ class TortoiseMixin:
|
|
|
257
282
|
)
|
|
258
283
|
|
|
259
284
|
if sort_by:
|
|
260
|
-
qs = qs.order_by(sort_by)
|
|
285
|
+
qs = qs.order_by(self._resolve_ordering_field(sort_by))
|
|
261
286
|
elif self.ordering:
|
|
262
|
-
qs = qs.order_by(*self.ordering)
|
|
287
|
+
qs = qs.order_by(*(self._resolve_ordering_field(field) for field in self.ordering))
|
|
263
288
|
|
|
264
289
|
total = await qs.count()
|
|
265
290
|
|