fastadmin 0.7.0__tar.gz → 0.8.1__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 (51) hide show
  1. {fastadmin-0.7.0 → fastadmin-0.8.1}/PKG-INFO +1 -1
  2. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/__init__.py +1 -0
  3. fastadmin-0.8.1/fastadmin/api/encoders.py +70 -0
  4. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/flask/app.py +8 -5
  5. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/helpers.py +54 -5
  6. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/service.py +11 -11
  7. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/base.py +9 -4
  8. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/orms/ponyorm.py +16 -8
  9. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/orms/sqlalchemy.py +15 -0
  10. {fastadmin-0.7.0 → fastadmin-0.8.1}/pyproject.toml +1 -1
  11. {fastadmin-0.7.0 → fastadmin-0.8.1}/LICENSE.md +0 -0
  12. {fastadmin-0.7.0 → fastadmin-0.8.1}/README.md +0 -0
  13. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/__init__.py +0 -0
  14. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/exceptions.py +0 -0
  15. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/__init__.py +0 -0
  16. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/django/__init__.py +0 -0
  17. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/django/app/__init__.py +0 -0
  18. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/django/app/api.py +0 -0
  19. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/django/app/urls.py +0 -0
  20. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/django/app/views.py +0 -0
  21. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/fastapi/__init__.py +0 -0
  22. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/fastapi/api.py +0 -0
  23. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/fastapi/app.py +0 -0
  24. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/fastapi/views.py +0 -0
  25. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/flask/__init__.py +0 -0
  26. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/flask/api.py +0 -0
  27. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/frameworks/flask/views.py +0 -0
  28. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/api/schemas.py +0 -0
  29. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/__init__.py +0 -0
  30. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/decorators.py +0 -0
  31. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/helpers.py +0 -0
  32. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/orms/__init__.py +0 -0
  33. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/orms/django.py +0 -0
  34. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/orms/tortoise.py +0 -0
  35. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/orms/yaraorm.py +0 -0
  36. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/models/schemas.py +0 -0
  37. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/settings.py +0 -0
  38. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/assets/worker-6Z7niv9l.js +0 -0
  39. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/assets/worker-C151k0-L.js +0 -0
  40. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/assets/worker-C9KPOOKD.js +0 -0
  41. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/assets/worker-D1fCgYB7.js +0 -0
  42. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/assets/worker-D3TWcJOI.js +0 -0
  43. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/assets/worker-DRNcaZ-V.js +0 -0
  44. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/assets/worker-MF2p-l5_.js +0 -0
  45. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/images/favicon.png +0 -0
  46. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/images/header-logo.svg +0 -0
  47. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/images/sign-in-logo.svg +0 -0
  48. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/index.html +0 -0
  49. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/index.min.css +0 -0
  50. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/static/index.min.js +0 -0
  51. {fastadmin-0.7.0 → fastadmin-0.8.1}/fastadmin/templates/index.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastadmin
3
- Version: 0.7.0
3
+ Version: 0.8.1
4
4
  Summary: FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Flask/Django inspired by Django Admin.
5
5
  Keywords: admin,dashboard,fastapi,flask,django,tortoise-orm,sqlalchemy,pony,yara-orm,admin-dashboard,django-admin
6
6
  Author: Seva D
@@ -38,6 +38,7 @@ except ModuleNotFoundError: # pragma: no cover
38
38
  logging.info("Yara ORM is not installed") # pragma: no cover
39
39
 
40
40
  # api
41
+ from fastadmin.api.encoders import register_encoder, unregister_encoder # noqa: F401
41
42
  from fastadmin.api.exceptions import AdminApiException # noqa: F401
42
43
 
43
44
  # models
@@ -0,0 +1,70 @@
1
+ """User-registerable custom encoders for admin API serialization.
2
+
3
+ Model field values are converted to JSON-serializable representations during
4
+ serialization (see :meth:`fastadmin.models.base.BaseModelAdmin.serialize_obj_attributes`).
5
+ Register a custom encoder to control how instances of a given type are represented
6
+ in every admin API response — e.g. a custom datetime format, an ``Enum``'s label,
7
+ or a domain value object.
8
+
9
+ Example:
10
+
11
+ .. code-block:: python
12
+
13
+ import datetime
14
+
15
+ from fastadmin import register_encoder
16
+
17
+ # Render every datetime as "2024-01-31 14:05" instead of ISO 8601.
18
+ register_encoder(datetime.datetime, lambda dt: dt.strftime("%Y-%m-%d %H:%M"))
19
+
20
+ Encoders are matched via ``isinstance`` in registration order, so register more
21
+ specific types before their base types.
22
+ """
23
+
24
+ from collections.abc import Callable
25
+ from typing import Any
26
+
27
+ # Maps a type to a function converting an instance of that type to a JSON-serializable value.
28
+ CUSTOM_ENCODERS: dict[type, Callable[[Any], Any]] = {}
29
+
30
+
31
+ def register_encoder(type_: type, encoder: Callable[[Any], Any]) -> None:
32
+ """Register a custom encoder for a type used across all admin API responses.
33
+
34
+ :params type_: the type to encode (matched via ``isinstance``).
35
+ :params encoder: a callable converting an instance to a JSON-serializable value.
36
+ :return: None.
37
+ """
38
+ CUSTOM_ENCODERS[type_] = encoder
39
+
40
+
41
+ def unregister_encoder(type_: type) -> None:
42
+ """Remove a previously registered encoder for a type (no-op if absent).
43
+
44
+ :params type_: the type whose encoder should be removed.
45
+ :return: None.
46
+ """
47
+ CUSTOM_ENCODERS.pop(type_, None)
48
+
49
+
50
+ def clear_encoders() -> None:
51
+ """Remove all registered custom encoders.
52
+
53
+ :return: None.
54
+ """
55
+ CUSTOM_ENCODERS.clear()
56
+
57
+
58
+ def apply_custom_encoders(value: Any) -> Any:
59
+ """Return ``value`` encoded by the first matching custom encoder, else unchanged.
60
+
61
+ Types are matched via ``isinstance`` in registration order, so a more specific
62
+ type registered before its base type wins.
63
+
64
+ :params value: the value to encode.
65
+ :return: the encoded value, or the original value if no encoder matches.
66
+ """
67
+ for type_, encoder in CUSTOM_ENCODERS.items():
68
+ if isinstance(value, type_):
69
+ return encoder(value)
70
+ return value
@@ -33,8 +33,11 @@ app.register_blueprint(api_router)
33
33
  @app.errorhandler(Exception)
34
34
  def exception_handler(exc):
35
35
  if isinstance(exc, HTTPException):
36
- return exc
37
- return {
38
- "status_code": 500,
39
- "content": {"exception": str(exc)},
40
- }
36
+ # Return API errors as JSON {"detail": ...} with the proper HTTP status
37
+ # so the shared React frontend can read the message (matching the Django
38
+ # and FastAPI integrations), instead of werkzeug's default HTML page.
39
+ return {"detail": exc.description}, exc.code or 500
40
+ # Unhandled server error: log it server-side but never leak internals to the
41
+ # client, and return a real HTTP 500 (a bare dict would be sent as HTTP 200).
42
+ logger.error("Unhandled admin error: %s", exc)
43
+ return {"detail": "Internal server error."}, 500
@@ -1,17 +1,41 @@
1
1
  from pathlib import Path
2
2
  from uuid import UUID
3
3
 
4
- from fastadmin.models.schemas import ModelFieldWidgetSchema
5
-
6
-
7
- def sanitize_filter_value(value: str | list) -> bool | None | str | list:
4
+ from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType
5
+
6
+ # Text-like filter widgets whose values are free-form strings. For these the
7
+ # literals "true"/"false"/"null" are legitimate content and must NOT be coerced
8
+ # to bool/None (otherwise a Char column can never be filtered for those words).
9
+ TEXT_FILTER_WIDGET_TYPES = frozenset(
10
+ {
11
+ WidgetType.Input,
12
+ WidgetType.TextArea,
13
+ WidgetType.RichTextArea,
14
+ WidgetType.JsonTextArea,
15
+ WidgetType.SlugInput,
16
+ WidgetType.EmailInput,
17
+ WidgetType.PhoneInput,
18
+ WidgetType.UrlInput,
19
+ WidgetType.PasswordInput,
20
+ }
21
+ )
22
+
23
+
24
+ def sanitize_filter_value(
25
+ value: str | list,
26
+ field: ModelFieldWidgetSchema | None = None,
27
+ ) -> bool | None | str | list:
8
28
  """Sanitize value (string or list for __in filters).
9
29
 
10
30
  :params value: a value (str or list of str for __in).
31
+ :params field: the field being filtered, used to decide whether the
32
+ "true"/"false"/"null" literals should be coerced (skipped for text fields).
11
33
  :return: A sanitized value.
12
34
  """
13
35
  if isinstance(value, list):
14
- return [sanitize_filter_value(v) for v in value]
36
+ return [sanitize_filter_value(v, field) for v in value]
37
+ if field is not None and field.filter_widget_type in TEXT_FILTER_WIDGET_TYPES:
38
+ return value
15
39
  match value:
16
40
  case "false":
17
41
  return False
@@ -64,6 +88,31 @@ def sanitize_filter_key(key: str, fields: list[ModelFieldWidgetSchema]) -> tuple
64
88
  return field_name, condition
65
89
 
66
90
 
91
+ def build_query_filters(
92
+ filters: dict,
93
+ fields: list[ModelFieldWidgetSchema],
94
+ exclude: tuple[str, ...],
95
+ ) -> dict[tuple[str, str], bool | None | str | list]:
96
+ """Build the sanitized ``{(field_name, condition): value}`` filter dict.
97
+
98
+ Resolves each key's field so value coercion (true/false/null) can be applied
99
+ in a type-aware way (see :func:`sanitize_filter_value`).
100
+
101
+ :param filters: raw filters mapping ``key -> value``.
102
+ :param fields: model fields with widget types.
103
+ :param exclude: keys to skip (search, sort_by, offset, limit).
104
+ :return: sanitized filters dict.
105
+ """
106
+ result: dict[tuple[str, str], bool | None | str | list] = {}
107
+ for key, value in filters.items():
108
+ if key in exclude:
109
+ continue
110
+ field_name = key.partition("__")[0]
111
+ field = next((f for f in fields if f.name == field_name), None)
112
+ result[sanitize_filter_key(key, fields)] = sanitize_filter_value(value, field)
113
+ return result
114
+
115
+
67
116
  def is_valid_uuid(uuid_to_test: str) -> bool:
68
117
  """Check if uuid_to_test is a valid uuid.
69
118
 
@@ -10,7 +10,7 @@ import jwt
10
10
  from asgiref.sync import sync_to_async
11
11
 
12
12
  from fastadmin.api.exceptions import AdminApiException
13
- from fastadmin.api.helpers import sanitize_filter_key, sanitize_filter_value
13
+ from fastadmin.api.helpers import build_query_filters
14
14
  from fastadmin.api.schemas import (
15
15
  ChangePasswordInputSchema,
16
16
  ExportFormat,
@@ -233,11 +233,11 @@ class ApiService:
233
233
  query_filters: dict[tuple[str, str], bool | str | None | list] | None = None
234
234
  if query_params.filters:
235
235
  self._validate_filters(admin_model, query_params.filters, exclude_filter_fields, fields)
236
- query_filters = {
237
- sanitize_filter_key(k, admin_model.get_model_fields_with_widget_types()): sanitize_filter_value(v)
238
- for k, v in query_params.filters.items()
239
- if k not in exclude_filter_fields
240
- }
236
+ query_filters = build_query_filters(
237
+ query_params.filters,
238
+ admin_model.get_model_fields_with_widget_types(),
239
+ exclude_filter_fields,
240
+ )
241
241
 
242
242
  if query_params.sort_by:
243
243
  if query_params.sort_by.strip("-") not in fields:
@@ -456,11 +456,11 @@ class ApiService:
456
456
  query_filters: dict[tuple[str, str], bool | str | None | list] | None = None
457
457
  if query_params.filters:
458
458
  self._validate_filters(admin_model, query_params.filters, exclude_filter_fields, fields)
459
- query_filters = {
460
- sanitize_filter_key(k, admin_model.get_model_fields_with_widget_types()): sanitize_filter_value(v)
461
- for k, v in query_params.filters.items()
462
- if k not in exclude_filter_fields
463
- }
459
+ query_filters = build_query_filters(
460
+ query_params.filters,
461
+ admin_model.get_model_fields_with_widget_types(),
462
+ exclude_filter_fields,
463
+ )
464
464
 
465
465
  if query_params.sort_by:
466
466
  if query_params.sort_by.strip("-") not in fields:
@@ -11,6 +11,7 @@ from uuid import UUID
11
11
 
12
12
  from asgiref.sync import sync_to_async
13
13
 
14
+ from fastadmin.api.encoders import apply_custom_encoders
14
15
  from fastadmin.api.schemas import ExportFormat
15
16
  from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType
16
17
 
@@ -201,10 +202,9 @@ class BaseModelAdmin:
201
202
  # Example of usage: show_full_result_count = True
202
203
  show_full_result_count: bool = False
203
204
 
204
- # By default, the list page allows sorting by all model fields
205
+ # By default (an empty collection), the list page allows sorting by all model fields.
205
206
  # If you want to disable sorting for some columns, set sortable_by to a collection (e.g. list, tuple, or set)
206
- # of the subset of list_display that you want to be sortable.
207
- # An empty collection disables sorting for all columns.
207
+ # of the subset of list_display that you want to be sortable; columns not listed become non-sortable.
208
208
  # Example of usage: sortable_by = ("mobile_number", "email")
209
209
  sortable_by: Sequence[str] = ()
210
210
 
@@ -419,7 +419,12 @@ class BaseModelAdmin:
419
419
  serialized_dict: dict[str, Any] = {}
420
420
  for field in attributes_to_serizalize:
421
421
  value = getattr(obj, field.column_name)
422
- if isinstance(value, Decimal):
422
+ # Let user-registered encoders (e.g. a custom datetime format) take
423
+ # precedence over the built-in handling below.
424
+ encoded = apply_custom_encoders(value)
425
+ if encoded is not value:
426
+ value = encoded
427
+ elif isinstance(value, Decimal):
423
428
  # Avoid scientific notation for Decimal values in API responses,
424
429
  # e.g. 3.75E+3 -> "3750"
425
430
  value = format(value, "f")
@@ -272,6 +272,7 @@ class PonyORMMixin:
272
272
 
273
273
  search_fields = list(self.search_fields)
274
274
  if search and search_fields:
275
+ model_pk_name = self.get_model_pk_name(self.model_cls)
275
276
  ids = []
276
277
  # Bind the user-supplied search term as a local so Pony resolves it
277
278
  # as a parameter. Only the field path (from the admin's trusted
@@ -281,17 +282,24 @@ class PonyORMMixin:
281
282
  pony_search_field = search_field.replace("__", ".")
282
283
  qs_ids = qs.filter(f"search_term in m.{pony_search_field}.lower()")
283
284
  objs = list(qs_ids)
284
- ids += [o.id for o in objs]
285
- qs = qs.filter(lambda m: m.id in set(ids))
285
+ ids += [getattr(o, model_pk_name) for o in objs]
286
+ # Bind the collected pks as a local so Pony resolves it as a
287
+ # parameter; only the trusted pk field path is interpolated.
288
+ pk_ids = set(ids) # noqa: F841 (referenced by Pony filter string)
289
+ qs = qs.filter(f"m.{model_pk_name} in pk_ids")
286
290
 
287
291
  ordering = [sort_by] if sort_by else self.ordering
288
292
  if ordering:
289
- desc_fields = [o[1:] for o in ordering if o.startswith("-")]
290
- asc_fields = [o for o in ordering if not o.startswith("-")]
291
- if asc_fields:
292
- qs = qs.order_by(*(getattr(self.model_cls, o) for o in asc_fields))
293
- if desc_fields:
294
- qs = qs.order_by(*(desc(getattr(self.model_cls, o)) for o in desc_fields))
293
+ # Build a single order_by() call that preserves the declared field
294
+ # order. Pony prepends the fields of each separate order_by() call,
295
+ # so applying asc and desc fields in two calls would invert their
296
+ # relative priority (a later desc field wrongly outranks an earlier
297
+ # asc one).
298
+ order_exprs = [
299
+ desc(getattr(self.model_cls, o[1:])) if o.startswith("-") else getattr(self.model_cls, o)
300
+ for o in ordering
301
+ ]
302
+ qs = qs.order_by(*order_exprs)
295
303
 
296
304
  total = qs.count()
297
305
 
@@ -335,6 +335,21 @@ class SqlAlchemyMixin:
335
335
  condition = field_with_condition[1]
336
336
  model_field = getattr(self.model_cls, field)
337
337
 
338
+ rel_property = getattr(model_field, "property", None)
339
+ related_mapper = getattr(rel_property, "mapper", None)
340
+ if related_mapper is not None:
341
+ # Relationship field (e.g. m2m): SQLAlchemy rejects a
342
+ # scalar comparison against a collection, so match on the
343
+ # related row's pk via any()/has() instead.
344
+ related_cls = related_mapper.class_
345
+ related_pk = getattr(related_cls, self.get_model_pk_name(related_cls))
346
+ match_expr = related_pk.in_(value) if condition == "in" else related_pk == value
347
+ if getattr(rel_property, "uselist", True):
348
+ q.append(model_field.any(match_expr))
349
+ else:
350
+ q.append(model_field.has(match_expr))
351
+ continue
352
+
338
353
  if condition != "in" and isinstance(model_field.expression.type, BIGINT | Integer):
339
354
  with contextlib.suppress(ValueError, TypeError):
340
355
  value = int(value)
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "fastadmin"
7
- version = "0.7.0"
7
+ version = "0.8.1"
8
8
  description = "FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Flask/Django inspired by Django Admin."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
File without changes
File without changes