From 3459da559f7c74acd5b661355390f07049dad15f Mon Sep 17 00:00:00 2001 From: Seva D Date: Sun, 5 Jul 2026 16:49:26 +0400 Subject: [PATCH] Add register_encoder hook for custom API value serialization (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model field values are serialized to JSON-safe representations in BaseModelAdmin.serialize_obj_attributes. Add a small user-facing registry (fastadmin/api/encoders.py) so users can control how a given type is rendered in every admin API response — e.g. a custom datetime format, an Enum's label, or a domain value object. Custom encoders are matched via isinstance in registration order and take precedence over the built-in datetime/UUID/Decimal handling, which also provides the configurable datetime output requested in #135. - fastadmin/api/encoders.py: register_encoder / unregister_encoder / clear_encoders / apply_custom_encoders + CUSTOM_ENCODERS registry - base.py: apply custom encoders before the built-in Decimal formatting - expose register_encoder / unregister_encoder from the package root - docs + changelog + unit/integration tests Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++++ docs/guides/model-admins.md | 23 ++++++++++++ fastadmin/__init__.py | 1 + fastadmin/api/encoders.py | 70 ++++++++++++++++++++++++++++++++++++ fastadmin/models/base.py | 8 ++++- tests/api/test_encoders.py | 66 ++++++++++++++++++++++++++++++++++ tests/models/test_base.py | 71 +++++++++++++++++++++++++++++++++++++ 7 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 fastadmin/api/encoders.py create mode 100644 tests/api/test_encoders.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e9dd3..b223dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to FastAdmin are documented in this file. +## Unreleased + +- Add a `register_encoder(type_, encoder)` hook to control how a type is + serialized in every admin API response (e.g. a custom datetime format, an + `Enum`'s label, or a domain value object). Custom encoders take precedence over + the built-in `datetime`/`UUID`/`Decimal` handling (#136). + ## 0.7.0 - Add support for [Yara ORM](https://github.com/vsdudakov/yara-orm) — a fast, diff --git a/docs/guides/model-admins.md b/docs/guides/model-admins.md index 3335505..51b8771 100644 --- a/docs/guides/model-admins.md +++ b/docs/guides/model-admins.md @@ -128,6 +128,29 @@ Other useful hooks: - `get_export(...)` — CSV/JSON export used by the export button (gate it with `has_export_permission`). +## Custom value encoders + +By default, model field values are serialized to JSON with sensible built-ins: +`datetime`/`date`/`time` become ISO 8601 strings, `UUID`s become strings, and +`Decimal`s are rendered without scientific notation. + +Register a custom encoder to control how a given type is represented in **every** +admin API response — for example a custom datetime format, an `Enum`'s label, or a +domain value object: + +```python +import datetime + +from fastadmin import register_encoder + +# Render every datetime as "2024-01-31 14:05" instead of ISO 8601. +register_encoder(datetime.datetime, lambda dt: dt.strftime("%Y-%m-%d %H:%M")) +``` + +Encoders are matched via `isinstance` in registration order (register more +specific types before their base types) and take precedence over the built-in +handling. Use `unregister_encoder(type_)` to remove one. + ## Permissions and request context See [Authentication](authentication.md#permissions) for diff --git a/fastadmin/__init__.py b/fastadmin/__init__.py index 61ddba2..8013f97 100644 --- a/fastadmin/__init__.py +++ b/fastadmin/__init__.py @@ -38,6 +38,7 @@ logging.info("Yara ORM is not installed") # pragma: no cover # api +from fastadmin.api.encoders import register_encoder, unregister_encoder # noqa: F401 from fastadmin.api.exceptions import AdminApiException # noqa: F401 # models diff --git a/fastadmin/api/encoders.py b/fastadmin/api/encoders.py new file mode 100644 index 0000000..679829f --- /dev/null +++ b/fastadmin/api/encoders.py @@ -0,0 +1,70 @@ +"""User-registerable custom encoders for admin API serialization. + +Model field values are converted to JSON-serializable representations during +serialization (see :meth:`fastadmin.models.base.BaseModelAdmin.serialize_obj_attributes`). +Register a custom encoder to control how instances of a given type are represented +in every admin API response — e.g. a custom datetime format, an ``Enum``'s label, +or a domain value object. + +Example: + +.. code-block:: python + + import datetime + + from fastadmin import register_encoder + + # Render every datetime as "2024-01-31 14:05" instead of ISO 8601. + register_encoder(datetime.datetime, lambda dt: dt.strftime("%Y-%m-%d %H:%M")) + +Encoders are matched via ``isinstance`` in registration order, so register more +specific types before their base types. +""" + +from collections.abc import Callable +from typing import Any + +# Maps a type to a function converting an instance of that type to a JSON-serializable value. +CUSTOM_ENCODERS: dict[type, Callable[[Any], Any]] = {} + + +def register_encoder(type_: type, encoder: Callable[[Any], Any]) -> None: + """Register a custom encoder for a type used across all admin API responses. + + :params type_: the type to encode (matched via ``isinstance``). + :params encoder: a callable converting an instance to a JSON-serializable value. + :return: None. + """ + CUSTOM_ENCODERS[type_] = encoder + + +def unregister_encoder(type_: type) -> None: + """Remove a previously registered encoder for a type (no-op if absent). + + :params type_: the type whose encoder should be removed. + :return: None. + """ + CUSTOM_ENCODERS.pop(type_, None) + + +def clear_encoders() -> None: + """Remove all registered custom encoders. + + :return: None. + """ + CUSTOM_ENCODERS.clear() + + +def apply_custom_encoders(value: Any) -> Any: + """Return ``value`` encoded by the first matching custom encoder, else unchanged. + + Types are matched via ``isinstance`` in registration order, so a more specific + type registered before its base type wins. + + :params value: the value to encode. + :return: the encoded value, or the original value if no encoder matches. + """ + for type_, encoder in CUSTOM_ENCODERS.items(): + if isinstance(value, type_): + return encoder(value) + return value diff --git a/fastadmin/models/base.py b/fastadmin/models/base.py index dff8371..2f97aa4 100644 --- a/fastadmin/models/base.py +++ b/fastadmin/models/base.py @@ -11,6 +11,7 @@ from asgiref.sync import sync_to_async +from fastadmin.api.encoders import apply_custom_encoders from fastadmin.api.schemas import ExportFormat from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType @@ -419,7 +420,12 @@ async def serialize_obj_attributes( serialized_dict: dict[str, Any] = {} for field in attributes_to_serizalize: value = getattr(obj, field.column_name) - if isinstance(value, Decimal): + # Let user-registered encoders (e.g. a custom datetime format) take + # precedence over the built-in handling below. + encoded = apply_custom_encoders(value) + if encoded is not value: + value = encoded + elif isinstance(value, Decimal): # Avoid scientific notation for Decimal values in API responses, # e.g. 3.75E+3 -> "3750" value = format(value, "f") diff --git a/tests/api/test_encoders.py b/tests/api/test_encoders.py new file mode 100644 index 0000000..c11eecf --- /dev/null +++ b/tests/api/test_encoders.py @@ -0,0 +1,66 @@ +import datetime +from decimal import Decimal + +import pytest + +from fastadmin.api.encoders import ( + CUSTOM_ENCODERS, + apply_custom_encoders, + clear_encoders, + register_encoder, + unregister_encoder, +) + + +@pytest.fixture(autouse=True) +def _clear_encoders(): + """Keep the global registry isolated between tests.""" + clear_encoders() + yield + clear_encoders() + + +def test_apply_custom_encoders_no_match_returns_unchanged(): + value = object() + assert apply_custom_encoders(value) is value + + +def test_register_and_apply_encoder(): + register_encoder(datetime.datetime, lambda dt: dt.strftime("%Y-%m-%d %H:%M")) + result = apply_custom_encoders(datetime.datetime(2024, 1, 31, 14, 5, 9, tzinfo=datetime.UTC)) + assert result == "2024-01-31 14:05" + + +def test_encoder_matches_by_isinstance_registration_order(): + class Base: + pass + + class Child(Base): + pass + + register_encoder(Child, lambda _: "child") + register_encoder(Base, lambda _: "base") + # Child is registered first, so it wins for a Child instance. + assert apply_custom_encoders(Child()) == "child" + assert apply_custom_encoders(Base()) == "base" + + +def test_register_encoder_overrides_previous(): + register_encoder(Decimal, lambda _: "first") + register_encoder(Decimal, lambda _: "second") + assert apply_custom_encoders(Decimal("1")) == "second" + + +def test_unregister_encoder(): + register_encoder(Decimal, lambda _: "x") + unregister_encoder(Decimal) + assert apply_custom_encoders(Decimal("1")) == Decimal("1") + # unregistering an absent type is a no-op + unregister_encoder(Decimal) + + +def test_clear_encoders(): + register_encoder(Decimal, lambda _: "x") + register_encoder(datetime.date, lambda _: "y") + clear_encoders() + assert CUSTOM_ENCODERS == {} diff --git a/tests/models/test_base.py b/tests/models/test_base.py index ba3758a..d6d877e 100644 --- a/tests/models/test_base.py +++ b/tests/models/test_base.py @@ -260,6 +260,77 @@ class Model: assert "E" not in result["value"] +async def test_serialize_obj_attributes_applies_custom_encoder(): + import datetime + + from fastadmin.api.encoders import clear_encoders, register_encoder + + class Obj: + value = datetime.datetime(2024, 1, 31, 14, 5, 9, tzinfo=datetime.UTC) + + def __str__(self): # type: ignore[override] + return "obj" + + class Model: + pass + + base = ModelAdmin(Model) + fields = [ + ModelFieldWidgetSchema( + name="value", + column_name="value", + is_m2m=False, + is_pk=False, + is_immutable=False, + form_widget_type=WidgetType.DateTimePicker, + form_widget_props={}, + filter_widget_type=WidgetType.DateTimePicker, + filter_widget_props={}, + ) + ] + register_encoder(datetime.datetime, lambda dt: dt.strftime("%Y-%m-%d %H:%M")) + try: + result = await base.serialize_obj_attributes(Obj(), fields) + finally: + clear_encoders() + assert result["value"] == "2024-01-31 14:05" + + +async def test_serialize_obj_attributes_custom_encoder_overrides_decimal(): + from fastadmin.api.encoders import clear_encoders, register_encoder + + class Obj: + value = Decimal("3.75E+3") + + def __str__(self): # type: ignore[override] + return "obj" + + class Model: + pass + + base = ModelAdmin(Model) + fields = [ + ModelFieldWidgetSchema( + name="value", + column_name="value", + is_m2m=False, + is_pk=False, + is_immutable=False, + form_widget_type=WidgetType.InputNumber, + form_widget_props={}, + filter_widget_type=WidgetType.InputNumber, + filter_widget_props={}, + ) + ] + register_encoder(Decimal, lambda d: float(d)) + try: + result = await base.serialize_obj_attributes(Obj(), fields) + finally: + clear_encoders() + # Custom encoder wins over the built-in Decimal-to-string formatting. + assert result["value"] == 3750.0 + + async def test_get_file_url_default_returns_value_unchanged(): """Default get_file_url is a no-op — returns the stored value as-is."""