From 1015d36745e00707d44ef93fefc10e803d0b55c3 Mon Sep 17 00:00:00 2001
From: maxim-f1 <“maxconal228@gmail.com”>
Date: Sun, 21 Dec 2025 12:33:27 +0300
Subject: [PATCH 1/7] [Feature] Column type formatters are now separated for
list and detail pages. * Added `column_type_formatters_detail`. * Added
`_default_formatter_detail` to separate detail-view processing. * Added
built-in formatters for `StrEnum`, `datetime`, and "copy to clipboard"
functionality. * Updated documentation and added tests.
---
docs/api_reference/model_view.md | 1 +
docs/configurations.md | 54 +++++-
sqladmin/_types.py | 9 +
sqladmin/formatters.py | 66 ++++++-
sqladmin/models.py | 62 ++++++-
sqladmin/statics/js/main.js | 20 +++
tests/test_formatters.py | 290 +++++++++++++++++++++++++++++++
7 files changed, 491 insertions(+), 11 deletions(-)
create mode 100644 tests/test_formatters.py
diff --git a/docs/api_reference/model_view.md b/docs/api_reference/model_view.md
index 5d1b89a8..8534bdcc 100644
--- a/docs/api_reference/model_view.md
+++ b/docs/api_reference/model_view.md
@@ -50,6 +50,7 @@
- form_create_rules
- form_edit_rules
- column_type_formatters
+ - column_type_formatters_detail
- list_query
- count_query
- search_query
diff --git a/docs/configurations.md b/docs/configurations.md
index 57c1f4c4..3e474547 100644
--- a/docs/configurations.md
+++ b/docs/configurations.md
@@ -297,8 +297,6 @@ The pagination options in the list page can be configured. The available options
There are a few options which apply to both List and Detail pages. They include:
- `column_labels`: A mapping of column labels, used to map column names to new names in all places.
-- `column_type_formatters`: A mapping of type keys and callable values to format in all places.
- For example you can add custom date formatter to be used in both list and detail pages.
- `save_as`: A boolean to enable "save as new" option when editing an object.
- `save_as_continue`: A boolean to control the redirect URL if `save_as` is enabled.
@@ -314,6 +312,58 @@ There are a few options which apply to both List and Detail pages. They include:
save_as = True
```
+## Type formatters
+
+You can create formatters for data types without specifying field names in both `column_formatters` and `column_formatters_detail`. The following options are suitable for this:
+
+- `column_type_formatters`: Mapping type keys to callable values for formatting on list pages.
+- `column_type_formatters_detail`: A mapping of type keys and callable values to format in details pages.
+
+!!! example
+ ```python
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters = {
+ type(None): lambda x: 'Empty',
+ str: lambda x: x[:10]
+ }
+ column_type_formatters_detail = {
+ type(None): lambda x: 'Null',
+ str: lambda x: x.title()
+ }
+ ```
+
+!!! tip
+
+ If `column_type_formatters_detail` is not explicitly specified, the `column_type_formatters` mapping is used for the detail page.
+
+??? example "Example with build-in formatters"
+
+ ```python
+ import enum
+ import datetime
+ import uuid
+
+ from sqladmin.formatters import (
+ str_enum_formatter,
+ datetime_formatter,
+ copy_to_clipboard_formatter,
+ )
+
+
+ custom_column_type_formatters_detail = ModelView.column_type_formatters_detail.copy()
+ custom_column_type_formatters_detail.update(
+ {
+ enum.StrEnum: str_enum_formatter,
+ datetime.datetime: datetime_formatter,
+ uuid.UUID: copy_to_clipboard_formatter,
+ }
+ )
+
+
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters_detail = custom_column_type_formatters_detail
+ ```
+
## Form options
SQLAdmin allows customizing how forms work with your models.
diff --git a/sqladmin/_types.py b/sqladmin/_types.py
index d0c057de..93355c41 100644
--- a/sqladmin/_types.py
+++ b/sqladmin/_types.py
@@ -1,13 +1,18 @@
from typing import (
Any,
+ AnyStr,
Callable,
+ Dict,
+ Iterable,
List,
Protocol,
Tuple,
+ Type,
Union,
runtime_checkable,
)
+from markupsafe import Markup
from sqlalchemy.engine import Engine
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.orm import ColumnProperty, InstrumentedAttribute, RelationshipProperty
@@ -55,3 +60,7 @@ async def get_filtered_query(
ColumnFilter = Union[SimpleColumnFilter, OperationColumnFilter]
+
+BASE_FORMATTERS_TYPE = Dict[
+ Type[Any], Callable[[Any], Markup | Iterable[Markup] | AnyStr | Iterable[AnyStr]]
+]
diff --git a/sqladmin/formatters.py b/sqladmin/formatters.py
index c0a746e9..1266ac84 100644
--- a/sqladmin/formatters.py
+++ b/sqladmin/formatters.py
@@ -1,20 +1,80 @@
+import datetime
+from enum import StrEnum
from typing import Any
from markupsafe import Markup
+from sqladmin._types import BASE_FORMATTERS_TYPE
-def empty_formatter(value: Any) -> str:
+
+def empty_formatter(value: Any) -> Markup:
"""Return empty string for `None` value"""
- return ""
+
+ return Markup("")
def bool_formatter(value: bool) -> Markup:
"""Return check icon if value is `True` or X otherwise."""
+
icon_class = "fa-check text-success" if value else "fa-times text-danger"
return Markup(f" ")
-BASE_FORMATTERS = {
+def str_enum_formatter(value: StrEnum) -> Markup:
+ """Return badge for value and list of available StrEnum values in tooltip."""
+
+ title = ""
+ if hasattr(value, "_member_names_") and len(value._member_names_) > 0:
+ title = f'title="Available values: {", ".join(value._member_names_)}">'
+
+ return Markup(
+ f'"
+ )
+
+
+def datetime_formatter(value: datetime.datetime) -> Markup:
+ """Return badge for easy viewing of datetime."""
+
+ return Markup(
+ f''
+ f' '
+ f'{value.strftime('%d %B %Y %H:%M:%S')}'
+ f' '
+ )
+
+
+def copy_to_clipboard_formatter(value: Any) -> Markup:
+ """Return value with copy to clipboard button and alert."""
+
+ return Markup(
+ f''
+ f'
{value}
'
+ f"
"
+ f' '
+ f" "
+ f'
Copied!
'
+ f"
"
+ )
+
+
+BASE_FORMATTERS: BASE_FORMATTERS_TYPE = {
type(None): empty_formatter,
bool: bool_formatter,
}
diff --git a/sqladmin/models.py b/sqladmin/models.py
index e3a94e62..df0402d9 100644
--- a/sqladmin/models.py
+++ b/sqladmin/models.py
@@ -3,7 +3,7 @@
import json
import time
import warnings
-from enum import Enum
+from enum import Enum, StrEnum
from typing import (
TYPE_CHECKING,
Any,
@@ -38,6 +38,7 @@
from sqladmin._queries import Query
from sqladmin._types import (
+ BASE_FORMATTERS_TYPE,
MODEL_ATTR,
ColumnFilter,
OperationColumnFilter,
@@ -675,7 +676,7 @@ class UserAdmin(ModelView, model=User):
```
"""
- column_type_formatters: ClassVar[Dict[Type, Callable]] = BASE_FORMATTERS
+ column_type_formatters: ClassVar[BASE_FORMATTERS_TYPE] = BASE_FORMATTERS
"""Dictionary of value type formatters to be used in the list view.
By default, two types are formatted:
@@ -693,6 +694,24 @@ class UserAdmin(ModelView, model=User):
```
"""
+ column_type_formatters_detail: ClassVar[BASE_FORMATTERS_TYPE] = BASE_FORMATTERS
+ """Dictionary of value type formatters to be used in the details view.
+
+ By default, two types are formatted:
+
+ - None will be displayed as an empty string
+ - bool will be displayed as a checkmark if it is True otherwise as an X.
+
+ If you don't like the default behavior and don't want any type formatters applied,
+ just override this property with an empty dictionary:
+
+ ???+ example
+ ```python
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters_detail = dict()
+ ```
+ """
+
def __init__(self) -> None:
self._mapper = inspect(self.model)
self._prop_names = [attr.key for attr in self._mapper.attrs]
@@ -756,6 +775,18 @@ def __init__(self) -> None:
self._custom_actions_in_detail: Dict[str, str] = {}
self._custom_actions_confirmation: Dict[str, str] = {}
+ self._column_type_formatters = self.column_type_formatters.copy()
+ if (
+ self.column_type_formatters != BASE_FORMATTERS
+ and self.column_type_formatters_detail == BASE_FORMATTERS
+ ):
+ # If you want to apply filters for types on all pages
+ self._column_type_formatters_detail = self.column_type_formatters.copy()
+ else:
+ self._column_type_formatters_detail = (
+ self.column_type_formatters_detail.copy()
+ )
+
def _run_arbitrary_query_sync(self, stmt: ClauseElement) -> Any:
with self.session_maker(expire_on_commit=False) as session:
result = session.execute(stmt)
@@ -821,10 +852,29 @@ def _get_default_sort(self) -> List[Tuple[str, bool]]:
return [(pk.name, False) for pk in self.pk_columns]
def _default_formatter(self, value: Any) -> Any:
- if type(value) in self.column_type_formatters:
- formatter = self.column_type_formatters[type(value)]
+ if type(value) in self._column_type_formatters:
+ formatter = self._column_type_formatters[type(value)]
+ return formatter(value)
+
+ parents = value.__class__.__bases__
+ for parent_class in parents:
+ if parent_class in self._column_type_formatters_detail:
+ formatter = self._column_type_formatters_detail[parent_class]
+ return formatter(value)
+
+ return value
+
+ def _default_formatter_detail(self, value: Any) -> Any:
+ if type(value) in self._column_type_formatters_detail:
+ formatter = self._column_type_formatters_detail[type(value)]
return formatter(value)
+ parents = value.__class__.__bases__
+ for parent_class in parents:
+ if parent_class in self._column_type_formatters_detail:
+ formatter = self._column_type_formatters_detail[parent_class]
+ return formatter(value)
+
return value
def validate_page_number(self, number: Union[str, None], default: int) -> int:
@@ -939,7 +989,7 @@ async def get_prop_value(self, obj: Any, prop: str) -> Any:
except DetachedInstanceError:
obj = await self._lazyload_prop(obj, part)
- if obj and isinstance(obj, Enum):
+ if obj and isinstance(obj, Enum) and not isinstance(obj, StrEnum):
obj = obj.name
return obj
@@ -970,7 +1020,7 @@ async def get_detail_value(self, obj: Any, prop: str) -> Tuple[Any, Any]:
value = await self.get_prop_value(obj, prop)
formatter = self._detail_formatters.get(prop)
formatted_value = (
- formatter(obj, prop) if formatter else self._default_formatter(value)
+ formatter(obj, prop) if formatter else self._default_formatter_detail(value)
)
return value, formatted_value
diff --git a/sqladmin/statics/js/main.js b/sqladmin/statics/js/main.js
index 4176a288..be21f216 100644
--- a/sqladmin/statics/js/main.js
+++ b/sqladmin/statics/js/main.js
@@ -150,3 +150,23 @@ $(':input[data-role="select2-tags"]').each(function () {
$(this).append(option).trigger('change');
}
});
+
+function copyToClipboard(element, value) {
+ navigator.clipboard.writeText(value)
+ .then(() => {
+ const alertElement = element.nextElementSibling;
+ if (
+ alertElement &&
+ alertElement.classList.contains('alert') &&
+ alertElement.classList.contains('alert-primary')
+ ) {
+ alertElement.classList.remove('fade');
+ setTimeout(() => {
+ alertElement.classList.add('fade');
+ }, 2000);
+ }
+ })
+ .catch(err => {
+ console.error('Failed to copy text: ', err);
+ });
+}
diff --git a/tests/test_formatters.py b/tests/test_formatters.py
new file mode 100644
index 00000000..58abc3ec
--- /dev/null
+++ b/tests/test_formatters.py
@@ -0,0 +1,290 @@
+import datetime
+import enum
+import uuid
+from typing import Generator
+
+import pytest
+from markupsafe import Markup
+from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String
+from sqlalchemy.orm import (
+ declarative_base,
+ relationship,
+ sessionmaker,
+)
+from starlette.applications import Starlette
+from starlette.testclient import TestClient
+
+from sqladmin import Admin, ModelView
+from sqladmin.formatters import (
+ BASE_FORMATTERS,
+ copy_to_clipboard_formatter,
+ datetime_formatter,
+ str_enum_formatter,
+)
+from tests.common import sync_engine as engine
+
+# Try to import UUID type for SQLAlchemy 2.0+
+try:
+ from sqlalchemy import Uuid
+
+ HAS_UUID_SUPPORT = True
+except ImportError:
+ HAS_UUID_SUPPORT = False
+ Uuid = None
+
+pytestmark = pytest.mark.anyio
+
+Base = declarative_base() # type: ignore
+session_maker = sessionmaker(bind=engine)
+
+app = Starlette()
+admin = Admin(app=app, session_maker=session_maker)
+
+
+@pytest.fixture(autouse=True)
+def prepare_database() -> Generator[None, None, None]:
+ Base.metadata.create_all(engine)
+ yield
+ Base.metadata.drop_all(engine)
+
+
+@pytest.fixture
+def client() -> Generator[TestClient, None, None]:
+ with TestClient(app=app, base_url="http://testserver") as c:
+ yield c
+
+
+class Status(enum.Enum):
+ ACTIVE = "ACTIVE"
+ DEACTIVE = "DEACTIVE"
+
+
+class Role(enum.StrEnum):
+ ADMIN = "ADMIN"
+ USER = "USER"
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String)
+ role = Column(Enum(Role))
+ registered_at = Column(DateTime)
+
+ profile = relationship("Profile", back_populates="user", uselist=False)
+
+ if HAS_UUID_SUPPORT:
+ user_uuid = Column(Uuid, nullable=True)
+
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id = Column(Integer, primary_key=True)
+ is_active = Column(Boolean)
+ role = Column(Enum(Role))
+ status = Column(Enum(Status))
+ user_id = Column(Integer, ForeignKey("users.id"), unique=True)
+
+ user = relationship("User", back_populates="profile")
+
+
+NOW = datetime.datetime.now()
+VALID_DATETIME_HTML = (
+ f''
+ f' '
+ f'{NOW.strftime('%d %B %Y %H:%M:%S')}'
+ f' '
+)
+
+VALID_STR_ENUM_HTML = Markup(
+ "ADMIN '
+)
+
+
+async def test_column_formatters_different_details_and_list() -> None:
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update(
+ {str: lambda x: Markup(x[:16] + "...") if len(x) > 16 else Markup(x)}
+ )
+
+ custom_column_type_formatters_detail = BASE_FORMATTERS.copy()
+ custom_column_type_formatters_detail.update({str: lambda x: Markup(x)})
+
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters = custom_column_type_formatters
+ column_type_formatters_detail = custom_column_type_formatters_detail
+
+ user = User(name="very " * 10 + "long string")
+
+ assert await UserAdmin().get_detail_value(user, "name") == (
+ user.name,
+ Markup(user.name),
+ )
+
+ assert await UserAdmin().get_list_value(user, "name") == (
+ user.name,
+ Markup("very very very v..."),
+ )
+
+ if HAS_UUID_SUPPORT:
+ user_uuid = uuid.uuid4()
+ user.user_uuid = user_uuid
+ assert await UserAdmin().get_list_value(user, "user_uuid") == (
+ user.user_uuid,
+ user_uuid,
+ )
+
+
+async def test_column_formatters_list_with_inheritance_type() -> None:
+ class CustomStr(str):
+ ...
+
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update(
+ {CustomStr: lambda x: Markup(x[:16] + "...") if len(x) > 16 else Markup(x)}
+ )
+
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters = custom_column_type_formatters
+
+ user = User(name="very " * 10 + "long string")
+
+ assert await UserAdmin().get_detail_value(user, "name") == (
+ user.name,
+ Markup(user.name),
+ )
+
+
+async def test_column_formatters_list_with_inheritance_type_and_parent() -> None:
+ class CustomStr(str):
+ ...
+
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update(
+ {
+ str: lambda x: Markup(x + " str class"),
+ CustomStr: lambda x: Markup(x + " CustomStr class"),
+ }
+ )
+
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters = custom_column_type_formatters
+
+ user = User(name="Max")
+ user_1 = User(name=CustomStr("Max"))
+
+ assert await UserAdmin().get_list_value(user, "name") == (
+ user.name,
+ Markup("Max str class"),
+ )
+
+ assert await UserAdmin().get_list_value(user_1, "name") == (
+ user_1.name,
+ Markup("Max CustomStr class"),
+ )
+
+
+async def test_column_formatters_str_enum() -> None:
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update({enum.StrEnum: str_enum_formatter})
+
+ class ProfileAdmin(ModelView, model=Profile):
+ column_type_formatters = custom_column_type_formatters
+
+ user = User()
+ profile = Profile(user=user, role=Role.ADMIN, status=None, is_active=True)
+
+ assert await ProfileAdmin().get_detail_value(profile, "role") == (
+ Role.ADMIN,
+ VALID_STR_ENUM_HTML,
+ )
+
+ assert await ProfileAdmin().get_detail_value(profile, "status") == (
+ None,
+ Markup(""),
+ )
+
+
+async def test_column_formatters_list_page_str_enum(client: TestClient) -> None:
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update({enum.StrEnum: str_enum_formatter})
+
+ class ProfileAdmin(ModelView, model=Profile):
+ column_list = [Profile.id, Profile.role]
+
+ column_type_formatters = custom_column_type_formatters
+
+ user = User()
+ profile = Profile(user=user, role=Role.ADMIN, is_active=True)
+
+ session = session_maker()
+ session.add_all([user, profile])
+ session.commit()
+
+ admin.add_model_view(ProfileAdmin)
+ response = client.get("/admin/profile/list")
+
+ assert VALID_STR_ENUM_HTML in response.text
+
+
+async def test_column_formatters_details_page_str_enum(client: TestClient) -> None:
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update({enum.StrEnum: str_enum_formatter})
+
+ class ProfileAdmin(ModelView, model=Profile):
+ column_details_list = [Profile.id, Profile.role]
+
+ column_type_formatters = custom_column_type_formatters
+
+ user = User()
+ profile = Profile(user=user, role=Role.ADMIN, is_active=True)
+
+ session = session_maker()
+ session.add_all([user, profile])
+ session.commit()
+
+ admin.add_model_view(ProfileAdmin)
+ response = client.get("/admin/profile/details/1")
+
+ assert VALID_STR_ENUM_HTML in response.text
+
+
+async def test_column_formatters_details_datetime(client: TestClient) -> None:
+ custom_column_type_formatters = ModelView.column_type_formatters_detail.copy()
+ custom_column_type_formatters.update(
+ {
+ datetime.datetime: datetime_formatter,
+ str: copy_to_clipboard_formatter,
+ }
+ )
+
+ class UserAdmin(ModelView, model=User):
+ column_details_list = [User.id, User.registered_at]
+
+ column_type_formatters_detail = custom_column_type_formatters
+
+ user = User(registered_at=NOW)
+
+ session = session_maker()
+ session.add(user)
+ session.commit()
+
+ admin.add_model_view(UserAdmin)
+ response = client.get("/admin/user/details/1")
+
+ assert VALID_DATETIME_HTML in response.text
From 30bba276494004072c9638c3c17a6d9b285a2eb3 Mon Sep 17 00:00:00 2001
From: maxim-f1 <“maxconal228@gmail.com”>
Date: Wed, 1 Apr 2026 10:01:35 +0300
Subject: [PATCH 2/7] * Now lists from `relationship` fields will be
automatically unpacked.
---
sqladmin/formatters.py | 18 +++++++--------
sqladmin/models.py | 47 ++++++++++++++++++++++++++++------------
sqladmin/widgets.py | 2 +-
tests/test_formatters.py | 14 +++++-------
4 files changed, 49 insertions(+), 32 deletions(-)
diff --git a/sqladmin/formatters.py b/sqladmin/formatters.py
index 1918e64b..ba25c8e7 100644
--- a/sqladmin/formatters.py
+++ b/sqladmin/formatters.py
@@ -10,14 +10,14 @@
def empty_formatter(value: Any) -> Markup:
"""Return empty string for `None` value"""
- return Markup("")
+ return Markup("") # nosec
def bool_formatter(value: bool) -> Markup:
"""Return check icon if value is `True` or X otherwise."""
icon_class = "fa-check text-success" if value else "fa-times text-danger"
- return Markup(" ").format(icon_class)
+ return Markup(" ").format(icon_class) # nosec
def str_enum_formatter(value: StrEnum) -> Markup:
@@ -36,25 +36,25 @@ def str_enum_formatter(value: StrEnum) -> Markup:
f"{title}"
f"{value}"
f" "
- )
+ ) # nosec
def datetime_formatter(value: datetime.datetime) -> Markup:
"""Return badge for easy viewing of datetime."""
return Markup(
- f''
+ f">"
f' '
- f'{value.strftime('%d %B %Y %H:%M:%S')}'
- f' '
- )
+ f"{value.strftime('%d %B %Y %H:%M:%S')}"
+ f""
+ ) # nosec
def copy_to_clipboard_formatter(value: Any) -> Markup:
@@ -71,7 +71,7 @@ def copy_to_clipboard_formatter(value: Any) -> Markup:
f""
f'
Copied!
'
f""
- )
+ ) # nosec
BASE_FORMATTERS: BASE_FORMATTERS_TYPE = {
diff --git a/sqladmin/models.py b/sqladmin/models.py
index 236901c9..fbfcb44a 100644
--- a/sqladmin/models.py
+++ b/sqladmin/models.py
@@ -26,6 +26,7 @@
from sqlalchemy import Column, String, asc, cast, desc, func, inspect, or_
from sqlalchemy.exc import NoInspectionAvailable
from sqlalchemy.orm import selectinload, sessionmaker
+from sqlalchemy.orm.collections import InstrumentedList, InstrumentedSet
from sqlalchemy.orm.exc import DetachedInstanceError
from sqlalchemy.sql.elements import ClauseElement
from sqlalchemy.sql.expression import Select, select
@@ -862,28 +863,46 @@ def _get_default_sort(self) -> List[Tuple[str, bool]]:
return [(pk.name, False) for pk in self.pk_columns]
def _default_formatter(self, value: Any) -> Any:
- if type(value) in self._column_type_formatters:
- formatter = self._column_type_formatters[type(value)]
+ value_class = type(value)
+
+ if value_class in self._column_type_formatters:
+ formatter = self._column_type_formatters[value_class]
return formatter(value)
- parents = value.__class__.__bases__
- for parent_class in parents:
- if parent_class in self._column_type_formatters_detail:
- formatter = self._column_type_formatters_detail[parent_class]
- return formatter(value)
+ elif value_class is InstrumentedList:
+ return [self._default_formatter(item) for item in value]
+
+ elif value_class is InstrumentedSet:
+ return {self._default_formatter(item) for item in value}
+
+ if hasattr(value, "__class__") and hasattr(value.__class__, "__bases__"):
+ parents = value.__class__.__bases__
+ for parent_class in parents:
+ if parent_class in self._column_type_formatters_detail:
+ formatter = self._column_type_formatters_detail[parent_class]
+ return formatter(value)
return value
def _default_formatter_detail(self, value: Any) -> Any:
- if type(value) in self._column_type_formatters_detail:
- formatter = self._column_type_formatters_detail[type(value)]
+ value_class = type(value)
+
+ if value_class in self._column_type_formatters_detail:
+ formatter = self._column_type_formatters_detail[value_class]
return formatter(value)
- parents = value.__class__.__bases__
- for parent_class in parents:
- if parent_class in self._column_type_formatters_detail:
- formatter = self._column_type_formatters_detail[parent_class]
- return formatter(value)
+ elif value_class is InstrumentedList:
+ return [self._default_formatter_detail(item) for item in value]
+
+ elif value_class is InstrumentedSet:
+ return {self._default_formatter_detail(item) for item in value}
+
+ if hasattr(value, "__class__") and hasattr(value.__class__, "__bases__"):
+ parents = value.__class__.__bases__
+ for parent_class in parents:
+ if parent_class in self._column_type_formatters_detail:
+ formatter = self._column_type_formatters_detail[parent_class]
+ return formatter(value)
return value
diff --git a/sqladmin/widgets.py b/sqladmin/widgets.py
index 832d2f44..93dc8177 100644
--- a/sqladmin/widgets.py
+++ b/sqladmin/widgets.py
@@ -123,4 +123,4 @@ def __call__(self, field: Field, **kwargs: Any) -> Markup:
''
+ str(Markup.escape(super().__call__(field, **kwargs)))
+ "
"
- )
+ ) # nosec
diff --git a/tests/test_formatters.py b/tests/test_formatters.py
index 58abc3ec..09126793 100644
--- a/tests/test_formatters.py
+++ b/tests/test_formatters.py
@@ -92,17 +92,17 @@ class Profile(Base):
NOW = datetime.datetime.now()
VALID_DATETIME_HTML = (
- f''
+ f">"
f' '
- f'{NOW.strftime('%d %B %Y %H:%M:%S')}'
- f' '
+ f"{NOW.strftime('%d %B %Y %H:%M:%S')}"
+ f""
)
VALID_STR_ENUM_HTML = Markup(
@@ -151,8 +151,7 @@ class UserAdmin(ModelView, model=User):
async def test_column_formatters_list_with_inheritance_type() -> None:
- class CustomStr(str):
- ...
+ class CustomStr(str): ...
custom_column_type_formatters = BASE_FORMATTERS.copy()
custom_column_type_formatters.update(
@@ -171,8 +170,7 @@ class UserAdmin(ModelView, model=User):
async def test_column_formatters_list_with_inheritance_type_and_parent() -> None:
- class CustomStr(str):
- ...
+ class CustomStr(str): ...
custom_column_type_formatters = BASE_FORMATTERS.copy()
custom_column_type_formatters.update(
From 477f62d9c14962962afbd7c51b7e7ef421579a79 Mon Sep 17 00:00:00 2001
From: maxim-f1 <“maxconal228@gmail.com”>
Date: Wed, 1 Apr 2026 10:26:30 +0300
Subject: [PATCH 3/7] * Fix `StrEnum` compatibility
---
sqladmin/formatters.py | 11 ++++++++++-
tests/test_filters.py | 16 ++++++++++++----
tests/test_formatters.py | 11 +++++++++++
tests/test_models.py | 3 +--
4 files changed, 34 insertions(+), 7 deletions(-)
diff --git a/sqladmin/formatters.py b/sqladmin/formatters.py
index ba25c8e7..92ce9d91 100644
--- a/sqladmin/formatters.py
+++ b/sqladmin/formatters.py
@@ -1,11 +1,20 @@
import datetime
-from enum import StrEnum
+import enum
+import sys
from typing import Any
from markupsafe import Markup
from sqladmin._types import BASE_FORMATTERS_TYPE
+if sys.version_info < (3, 11):
+
+ class StrEnum(str, enum.Enum):
+ __str__ = str.__str__
+ __repr__ = enum.Enum.__repr__
+else:
+ from enum import StrEnum as StrEnum # noqa: F401
+
def empty_formatter(value: Any) -> Markup:
"""Return empty string for `None` value"""
diff --git a/tests/test_filters.py b/tests/test_filters.py
index 20514007..fadd13fa 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -162,7 +162,9 @@ async def prepare_data(prepare_database: Any) -> AsyncGenerator[None, None]:
salary=80000.50,
description="Senior administrator with management responsibilities",
birthdate=datetime.date(2001, 7, 14),
- created_at=datetime.datetime(2024, 11, 12, 3, 4, 5, tzinfo=datetime.timezone.utc),
+ created_at=datetime.datetime(
+ 2024, 11, 12, 3, 4, 5, tzinfo=datetime.timezone.utc
+ ),
)
user2 = User(
name="Regular User",
@@ -173,7 +175,9 @@ async def prepare_data(prepare_database: Any) -> AsyncGenerator[None, None]:
salary=55000.75,
description="Software developer specializing in web applications",
birthdate=datetime.date(1994, 5, 31),
- created_at=datetime.datetime(2024, 12, 31, 23, 59, 58, tzinfo=datetime.timezone.utc),
+ created_at=datetime.datetime(
+ 2024, 12, 31, 23, 59, 58, tzinfo=datetime.timezone.utc
+ ),
)
user3 = User(
name="Test User",
@@ -184,7 +188,9 @@ async def prepare_data(prepare_database: Any) -> AsyncGenerator[None, None]:
salary=65000.00,
description="Data analyst working on business intelligence",
birthdate=datetime.date(1998, 10, 31),
- created_at=datetime.datetime(2023, 3, 14, 12, 30, 0, tzinfo=datetime.timezone.utc),
+ created_at=datetime.datetime(
+ 2023, 3, 14, 12, 30, 0, tzinfo=datetime.timezone.utc
+ ),
)
session.add_all([user1, user2, user3])
await session.commit()
@@ -918,7 +924,9 @@ async def test_column_filter_conversion_edge_cases():
result = created_at_filter._convert_value_for_column(
"2021-11-30T22:33:43+00:00", User.created_at.property.columns[0]
)
- assert result == datetime.datetime(2021, 11, 30, 22, 33, 43, tzinfo=datetime.timezone.utc)
+ assert result == datetime.datetime(
+ 2021, 11, 30, 22, 33, 43, tzinfo=datetime.timezone.utc
+ )
# Test valid date conversion
birthdate_filter = OperationColumnFilter(User.birthdate)
diff --git a/tests/test_formatters.py b/tests/test_formatters.py
index 09126793..62c6af57 100644
--- a/tests/test_formatters.py
+++ b/tests/test_formatters.py
@@ -1,5 +1,6 @@
import datetime
import enum
+import sys
import uuid
from typing import Generator
@@ -32,6 +33,16 @@
HAS_UUID_SUPPORT = False
Uuid = None
+# Import StrEnum for python < 3.10 and > 3.11
+if sys.version_info < (3, 11):
+
+ class StrEnum(str, enum.Enum):
+ __str__ = str.__str__
+ __repr__ = enum.Enum.__repr__
+else:
+ from enum import StrEnum as StrEnum # noqa: F401
+
+
pytestmark = pytest.mark.anyio
Base = declarative_base() # type: ignore
diff --git a/tests/test_models.py b/tests/test_models.py
index 47a695fa..5e895048 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -569,8 +569,7 @@ class AddressAdmin(ModelView, model=Address): ...
def test_count_query() -> None:
- class AddressAdmin(ModelView, model=Address):
- ...
+ class AddressAdmin(ModelView, model=Address): ...
request = Request({"type": "http"})
stmt = AddressAdmin().count_query(request)
From a411380f89650a61ad1a276136a4f554dc1a83f1 Mon Sep 17 00:00:00 2001
From: maxim-f1 <“maxconal228@gmail.com”>
Date: Wed, 1 Apr 2026 10:31:05 +0300
Subject: [PATCH 4/7] * Fix `StrEnum` compatibility
---
sqladmin/models.py | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/sqladmin/models.py b/sqladmin/models.py
index fbfcb44a..540d44c2 100644
--- a/sqladmin/models.py
+++ b/sqladmin/models.py
@@ -1,9 +1,10 @@
from __future__ import annotations
import json
+import sys
import time
import warnings
-from enum import Enum, StrEnum
+from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
@@ -65,6 +66,16 @@
from sqladmin.pretty_export import PrettyExport
from sqladmin.templating import Jinja2Templates
+# Import StrEnum for python < 3.10 and > 3.11
+if sys.version_info < (3, 11):
+
+ class StrEnum(str, Enum):
+ __str__ = str.__str__
+ __repr__ = Enum.__repr__
+else:
+ from enum import StrEnum as StrEnum # noqa: F401
+
+
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import async_sessionmaker # type: ignore[attr-defined]
From 0ce4141103fcf75db2e03e2fb5e408b662071bd2 Mon Sep 17 00:00:00 2001
From: maxim-f1 <“maxconal228@gmail.com”>
Date: Wed, 1 Apr 2026 10:34:29 +0300
Subject: [PATCH 5/7] * Fix `StrEnum` compatibility
---
tests/test_formatters.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_formatters.py b/tests/test_formatters.py
index 62c6af57..b5dc6191 100644
--- a/tests/test_formatters.py
+++ b/tests/test_formatters.py
@@ -70,7 +70,7 @@ class Status(enum.Enum):
DEACTIVE = "DEACTIVE"
-class Role(enum.StrEnum):
+class Role(StrEnum):
ADMIN = "ADMIN"
USER = "USER"
@@ -210,7 +210,7 @@ class UserAdmin(ModelView, model=User):
async def test_column_formatters_str_enum() -> None:
custom_column_type_formatters = BASE_FORMATTERS.copy()
- custom_column_type_formatters.update({enum.StrEnum: str_enum_formatter})
+ custom_column_type_formatters.update({StrEnum: str_enum_formatter})
class ProfileAdmin(ModelView, model=Profile):
column_type_formatters = custom_column_type_formatters
@@ -231,7 +231,7 @@ class ProfileAdmin(ModelView, model=Profile):
async def test_column_formatters_list_page_str_enum(client: TestClient) -> None:
custom_column_type_formatters = BASE_FORMATTERS.copy()
- custom_column_type_formatters.update({enum.StrEnum: str_enum_formatter})
+ custom_column_type_formatters.update({StrEnum: str_enum_formatter})
class ProfileAdmin(ModelView, model=Profile):
column_list = [Profile.id, Profile.role]
@@ -253,7 +253,7 @@ class ProfileAdmin(ModelView, model=Profile):
async def test_column_formatters_details_page_str_enum(client: TestClient) -> None:
custom_column_type_formatters = BASE_FORMATTERS.copy()
- custom_column_type_formatters.update({enum.StrEnum: str_enum_formatter})
+ custom_column_type_formatters.update({StrEnum: str_enum_formatter})
class ProfileAdmin(ModelView, model=Profile):
column_details_list = [Profile.id, Profile.role]
From 1ad7ed7e41ea03cc0adc7f7e500a5b274ed0454a Mon Sep 17 00:00:00 2001
From: maxim-f1 <“maxconal228@gmail.com”>
Date: Wed, 1 Apr 2026 10:44:34 +0300
Subject: [PATCH 6/7] * Fix `StrEnum` compatibility
---
sqladmin/_types.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/sqladmin/_types.py b/sqladmin/_types.py
index fe6eac6e..436b7333 100644
--- a/sqladmin/_types.py
+++ b/sqladmin/_types.py
@@ -18,6 +18,7 @@
from sqlalchemy.orm import ColumnProperty, InstrumentedAttribute, RelationshipProperty
from sqlalchemy.sql.expression import Select
from starlette.requests import Request
+from typing_extensions import TypeAlias
MODEL_PROPERTY = Union[ColumnProperty, RelationshipProperty]
ENGINE_TYPE = Union[Engine, AsyncEngine]
@@ -59,6 +60,7 @@ async def get_filtered_query(
ColumnFilter = Union[SimpleColumnFilter, OperationColumnFilter]
-BASE_FORMATTERS_TYPE = Dict[
- Type[Any], Callable[[Any], Markup | Iterable[Markup] | AnyStr | Iterable[AnyStr]]
+BASE_FORMATTERS_TYPE: TypeAlias = Dict[
+ Type[Any],
+ Callable[[Any], Union[Markup, Iterable[Markup], AnyStr, Iterable[AnyStr]]],
]
From 030e92579626b3d187649a5770d24c6c933b9e7f Mon Sep 17 00:00:00 2001
From: Miradil Zeynalli
Date: Wed, 8 Jul 2026 16:06:07 +0200
Subject: [PATCH 7/7] fix: made copy safer and fixed _default_formatter
---
docs/configurations.md | 36 ++++++++++++++++--------------------
sqladmin/formatters.py | 7 +++++--
sqladmin/models.py | 4 ++--
tests/test_formatters.py | 7 +++++++
4 files changed, 30 insertions(+), 24 deletions(-)
diff --git a/docs/configurations.md b/docs/configurations.md
index 071bdfcc..80ba190a 100644
--- a/docs/configurations.md
+++ b/docs/configurations.md
@@ -267,7 +267,7 @@ The filter UI provides a dropdown for operation selection and a text input for t
- **AllUniqueStringValuesFilter/StaticValuesFilter/ForeignKeyFilter**: Shows all possible values as links (good for columns with few unique values)
- **OperationColumnFilter**: Provides operation dropdown + text input (good for columns with many possible values or numeric/date operations)
-
+
Choose OperationColumnFilter when you want users to type custom search terms with operation flexibility, and AllUniqueStringValuesFilter when you want to show all available options as clickable links.
@@ -352,9 +352,9 @@ You can create formatters for data types without specifying field names in both
str: lambda x: x.title()
}
```
-
+
!!! tip
-
+
If `column_type_formatters_detail` is not explicitly specified, the `column_type_formatters` mapping is used for the detail page.
??? example "Example with build-in formatters"
@@ -364,9 +364,11 @@ You can create formatters for data types without specifying field names in both
import datetime
import uuid
+ from enum import StrEnum
+ # from sqladmin._types import StrEnum # for python <3.11
from sqladmin.formatters import (
- str_enum_formatter,
- datetime_formatter,
+ str_enum_formatter,
+ datetime_formatter,
copy_to_clipboard_formatter,
)
@@ -374,7 +376,7 @@ You can create formatters for data types without specifying field names in both
custom_column_type_formatters_detail = ModelView.column_type_formatters_detail.copy()
custom_column_type_formatters_detail.update(
{
- enum.StrEnum: str_enum_formatter,
+ StrEnum: str_enum_formatter,
datetime.datetime: datetime_formatter,
uuid.UUID: copy_to_clipboard_formatter,
}
@@ -442,13 +444,11 @@ The export options can be set per model and includes the following options:
## Pretty CSV Export
- `ModelView.use_pretty_export`: Default value is `False`
-Enables exporting CSV files with user-friendly column labels and formatted cell values
-matching the UI list view.
-When enabled, exports utilize the `column_formatters` and `column_labels` defined in the admin view,
-improving readability and ensuring consistency between the UI and exported data.
+Enables exporting CSV files with user-friendly column labels and formatted cell values matching the UI list view.
+When enabled, exports utilize the `column_formatters` and `column_labels` defined in the admin view,
+improving readability and ensuring consistency between the UI and exported data.
-Custom cell formatting can be implemented in the ModelView class by overriding the async method
-`custom_export_cell`, otherwise basic cell formatting is used by default.
+Custom cell formatting can be implemented in the ModelView class by overriding the async method custom_export_cell`, otherwise basic cell formatting is used by default.
Example of usage:
```python
@@ -457,10 +457,10 @@ class ExamResultAdmin(ModelView, model=ExamResult):
column_list = ["score", "instructors", "course.title", "course.instructors", "created_at"]
column_labels = {
- "score": "Score",
- "instructors": "Exam Instructors",
- "course.title": "Course Title",
- "course.instructors": "Course Instructors",
+ "score": "Score",
+ "instructors": "Exam Instructors",
+ "course.title": "Course Title",
+ "course.instructors": "Course Instructors",
"created_at": "Exam Time",
}
column_formatters = {
@@ -484,9 +484,6 @@ class ExamResultAdmin(ModelView, model=ExamResult):
return None
```
-
-
-
## Templates
The template files are built using Jinja2 and can be completely overridden in the configurations.
@@ -596,7 +593,6 @@ The available options for `action` are:
- `add_in_detail`: A boolean indicating if this action should be available in detail page.
- `confirmation_message`: A string message that if defined, will open a modal to ask for confirmation before calling the action method.
-
### Toast Notifications
You can display toast notifications after a custom action completes using
diff --git a/sqladmin/formatters.py b/sqladmin/formatters.py
index 95f5449c..adda9c7c 100644
--- a/sqladmin/formatters.py
+++ b/sqladmin/formatters.py
@@ -59,12 +59,15 @@ def datetime_formatter(value: datetime.datetime) -> Markup:
def copy_to_clipboard_formatter(value: Any) -> Markup:
"""Return value with copy to clipboard button and alert."""
+ escaped_value = Markup.escape(str(value))
+
return Markup(
f''
- f'
{value}
'
+ f'
{escaped_value}
'
f"
"
f' '
f" "
diff --git a/sqladmin/models.py b/sqladmin/models.py
index e9cc424c..efea3e46 100644
--- a/sqladmin/models.py
+++ b/sqladmin/models.py
@@ -885,8 +885,8 @@ def _default_formatter(self, value: Any) -> Any:
if hasattr(value, "__class__") and hasattr(value.__class__, "__bases__"):
parents = value.__class__.__bases__
for parent_class in parents:
- if parent_class in self._column_type_formatters_detail:
- formatter = self._column_type_formatters_detail[parent_class]
+ if parent_class in self._column_type_formatters:
+ formatter = self._column_type_formatters[parent_class]
return formatter(value)
return value
diff --git a/tests/test_formatters.py b/tests/test_formatters.py
index e97e763f..1f2e25bf 100644
--- a/tests/test_formatters.py
+++ b/tests/test_formatters.py
@@ -283,3 +283,10 @@ class UserAdmin(ModelView, model=User):
response = client.get("/admin/user/details/1")
assert VALID_DATETIME_HTML in response.text
+
+
+async def test_copy_to_clipboard_formatter_escapes_value() -> None:
+ formatted = copy_to_clipboard_formatter('"')
+
+ assert 'data-copy-value=""<script>alert(1)</script>"' in formatted
+ assert "<script>alert(1)</script>" in formatted