Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions docs/guides/model-admins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions fastadmin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions fastadmin/api/encoders.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion fastadmin/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
66 changes: 66 additions & 0 deletions tests/api/test_encoders.py
Original file line number Diff line number Diff line change
@@ -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 == {}
71 changes: 71 additions & 0 deletions tests/models/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading