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'
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"" 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