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.
Files changed (49) hide show
  1. {fastadmin-0.3.2 → fastadmin-0.3.4}/PKG-INFO +2 -2
  2. {fastadmin-0.3.2 → fastadmin-0.3.4}/README.md +1 -1
  3. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/views.py +8 -1
  4. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/views.py +7 -1
  5. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/views.py +7 -1
  6. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/helpers.py +0 -15
  7. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/service.py +14 -6
  8. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/base.py +35 -3
  9. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/helpers.py +1 -1
  10. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/orms/ponyorm.py +52 -1
  11. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/orms/sqlalchemy.py +75 -1
  12. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/orms/tortoise.py +27 -2
  13. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/index.min.js +70 -70
  14. {fastadmin-0.3.2 → fastadmin-0.3.4}/pyproject.toml +2 -1
  15. {fastadmin-0.3.2 → fastadmin-0.3.4}/LICENSE +0 -0
  16. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/__init__.py +0 -0
  17. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/__init__.py +0 -0
  18. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/exceptions.py +0 -0
  19. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/__init__.py +0 -0
  20. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/__init__.py +0 -0
  21. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/__init__.py +0 -0
  22. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/api.py +0 -0
  23. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/urls.py +0 -0
  24. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/__init__.py +0 -0
  25. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/api.py +0 -0
  26. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/app.py +0 -0
  27. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/__init__.py +0 -0
  28. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/api.py +0 -0
  29. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/app.py +0 -0
  30. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/api/schemas.py +0 -0
  31. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/__init__.py +0 -0
  32. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/decorators.py +0 -0
  33. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/orms/__init__.py +0 -0
  34. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/orms/django.py +0 -0
  35. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/models/schemas.py +0 -0
  36. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/settings.py +0 -0
  37. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-6Z7niv9l.js +0 -0
  38. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-C151k0-L.js +0 -0
  39. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-C9KPOOKD.js +0 -0
  40. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-D1fCgYB7.js +0 -0
  41. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-D3TWcJOI.js +0 -0
  42. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-DRNcaZ-V.js +0 -0
  43. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/assets/worker-MF2p-l5_.js +0 -0
  44. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/images/favicon.png +0 -0
  45. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/images/header-logo.svg +0 -0
  46. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/images/sign-in-logo.svg +0 -0
  47. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/index.html +0 -0
  48. {fastadmin-0.3.2 → fastadmin-0.3.4}/fastadmin/static/index.min.css +0 -0
  49. {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.2
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
- [![codecov](https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T)](https://codecov.io/gh/vsdudakov/fastadmin)
45
+ [![codecov](https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T)](https://app.codecov.io/gh/vsdudakov/fastadmin)
46
46
  [![License](https://img.shields.io/github/license/vsdudakov/fastadmin)](https://github.com/vsdudakov/fastadmin/blob/master/LICENSE)
47
47
  [![PyPi](https://badgen.net/pypi/v/fastadmin)](https://pypi.org/project/fastadmin/)
48
48
  [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/release/python-3120/)
@@ -1,6 +1,6 @@
1
1
  # Admin Dashboard for FastAPI / Flask / Django
2
2
 
3
- [![codecov](https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T)](https://codecov.io/gh/vsdudakov/fastadmin)
3
+ [![codecov](https://codecov.io/gh/vsdudakov/fastadmin/branch/main/graph/badge.svg?token=RNGX5HOW3T)](https://app.codecov.io/gh/vsdudakov/fastadmin)
4
4
  [![License](https://img.shields.io/github/license/vsdudakov/fastadmin)](https://github.com/vsdudakov/fastadmin/blob/master/LICENSE)
5
5
  [![PyPi](https://badgen.net/pypi/v/fastadmin)](https://pypi.org/project/fastadmin/)
6
6
  [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](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": settings.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": settings.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": settings.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 field not in fields:
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("-") not in fields:
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 field not in fields:
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("-") not in fields:
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
- return datetime.datetime.fromisoformat(value).time()
408
- case WidgetType.DatePicker | WidgetType.DateTimePicker:
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.serialize_obj(obj)
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.{search_field}.lower()'
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
- q.append(getattr(self.model_cls, field).ilike(f"%{search}%"))
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