fastadmin 0.3.3__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.3 → fastadmin-0.3.4}/PKG-INFO +2 -2
  2. {fastadmin-0.3.3 → fastadmin-0.3.4}/README.md +1 -1
  3. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/service.py +14 -6
  4. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/models/base.py +3 -3
  5. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/models/orms/ponyorm.py +52 -1
  6. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/models/orms/sqlalchemy.py +66 -1
  7. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/models/orms/tortoise.py +27 -2
  8. {fastadmin-0.3.3 → fastadmin-0.3.4}/pyproject.toml +2 -1
  9. {fastadmin-0.3.3 → fastadmin-0.3.4}/LICENSE +0 -0
  10. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/__init__.py +0 -0
  11. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/__init__.py +0 -0
  12. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/exceptions.py +0 -0
  13. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/__init__.py +0 -0
  14. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/__init__.py +0 -0
  15. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/__init__.py +0 -0
  16. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/api.py +0 -0
  17. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/urls.py +0 -0
  18. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/django/app/views.py +0 -0
  19. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/__init__.py +0 -0
  20. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/api.py +0 -0
  21. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/app.py +0 -0
  22. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/fastapi/views.py +0 -0
  23. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/__init__.py +0 -0
  24. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/api.py +0 -0
  25. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/app.py +0 -0
  26. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/frameworks/flask/views.py +0 -0
  27. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/helpers.py +0 -0
  28. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/api/schemas.py +0 -0
  29. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/models/__init__.py +0 -0
  30. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/models/decorators.py +0 -0
  31. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/models/helpers.py +0 -0
  32. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/models/orms/__init__.py +0 -0
  33. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/models/orms/django.py +0 -0
  34. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/models/schemas.py +0 -0
  35. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/settings.py +0 -0
  36. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/assets/worker-6Z7niv9l.js +0 -0
  37. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/assets/worker-C151k0-L.js +0 -0
  38. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/assets/worker-C9KPOOKD.js +0 -0
  39. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/assets/worker-D1fCgYB7.js +0 -0
  40. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/assets/worker-D3TWcJOI.js +0 -0
  41. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/assets/worker-DRNcaZ-V.js +0 -0
  42. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/assets/worker-MF2p-l5_.js +0 -0
  43. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/images/favicon.png +0 -0
  44. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/images/header-logo.svg +0 -0
  45. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/images/sign-in-logo.svg +0 -0
  46. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/index.html +0 -0
  47. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/index.min.css +0 -0
  48. {fastadmin-0.3.3 → fastadmin-0.3.4}/fastadmin/static/index.min.js +0 -0
  49. {fastadmin-0.3.3 → 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
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/)
@@ -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"
@@ -372,12 +372,12 @@ class BaseModelAdmin:
372
372
  return serialized_dict
373
373
 
374
374
  async def _serialize_obj_after_save(self, obj: Any) -> dict:
375
- """Serialize object after save; re-fetch if detached (e.g. SQLAlchemy after commit)."""
375
+ """Serialize object after save; re-fetch if detached/session is over."""
376
376
  try:
377
377
  return await self.serialize_obj(obj)
378
378
  except Exception as exc:
379
- # SQLAlchemy DetachedInstanceError when session is closed after commit
380
- if exc.__class__.__name__ != "DetachedInstanceError":
379
+ # ORM-specific detached/session-over cases after commit.
380
+ if exc.__class__.__name__ not in {"DetachedInstanceError", "DatabaseSessionIsOver"}:
381
381
  raise
382
382
  pk_name = self.get_model_pk_name(self.model_cls)
383
383
  pk = getattr(obj, pk_name, 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:
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "fastadmin"
3
- version = "0.3.3"
3
+ version = "0.3.4"
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