diff --git a/docs/api_reference/model_view.md b/docs/api_reference/model_view.md index 6df8c210..4a46657f 100644 --- a/docs/api_reference/model_view.md +++ b/docs/api_reference/model_view.md @@ -58,6 +58,7 @@ - form_create_rules - form_edit_rules - column_type_formatters + - column_type_formatters_detail - non_link_related_fields - list_query - count_query diff --git a/docs/configurations.md b/docs/configurations.md index 1f7d0534..4d66aa34 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -268,7 +268,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. @@ -319,8 +319,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. @@ -336,6 +334,60 @@ 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 enum import StrEnum + # from sqladmin._types import StrEnum # for python <3.11 + 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( + { + 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. @@ -393,13 +445,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 @@ -408,10 +458,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 = { @@ -435,8 +485,6 @@ class ExamResultAdmin(ModelView, model=ExamResult): return None ``` - - ## Import options SQLAdmin supports importing data from a UTF-8 CSV file on the list page. @@ -632,7 +680,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/_types.py b/sqladmin/_types.py index 8a1224b5..663ac005 100644 --- a/sqladmin/_types.py +++ b/sqladmin/_types.py @@ -1,14 +1,21 @@ +import sys +from enum import Enum from typing import ( Any, + AnyStr, Callable, + Dict, + Iterable, List, Protocol, Tuple, + Type, TypeVar, Union, runtime_checkable, ) +from markupsafe import Markup from sqlalchemy.engine import Engine from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker from sqlalchemy.orm import ( @@ -19,6 +26,15 @@ ) from sqlalchemy.sql.expression import Select from starlette.requests import Request +from typing_extensions import TypeAlias + +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 MODEL_PROPERTY = Union[ColumnProperty, RelationshipProperty] ENGINE_TYPE = Union[Engine, AsyncEngine] @@ -79,3 +95,8 @@ async def get_filtered_query( ColumnFilter = Union[SimpleColumnFilter, OperationColumnFilter] + +BASE_FORMATTERS_TYPE: TypeAlias = Dict[ + Type[Any], + Callable[[Any], Union[Markup, Iterable[Markup], AnyStr, Iterable[AnyStr]]], +] diff --git a/sqladmin/formatters.py b/sqladmin/formatters.py index 7c00ad57..adda9c7c 100644 --- a/sqladmin/formatters.py +++ b/sqladmin/formatters.py @@ -1,20 +1,82 @@ +import datetime from typing import Any from markupsafe import Markup +from sqladmin._types import BASE_FORMATTERS_TYPE, StrEnum -def empty_formatter(value: Any) -> str: + +def empty_formatter(value: Any) -> Markup: """Return empty string for `None` value""" - return "" + + 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: + """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'" + ) # nosec + + +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"" + ) # nosec + + +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'
{escaped_value}
' + f"" + f'
Copied!
' + f"
" + ) # nosec -BASE_FORMATTERS = { +BASE_FORMATTERS: BASE_FORMATTERS_TYPE = { type(None): empty_formatter, bool: bool_formatter, } diff --git a/sqladmin/models.py b/sqladmin/models.py index a325bfaf..8c62773c 100644 --- a/sqladmin/models.py +++ b/sqladmin/models.py @@ -29,6 +29,7 @@ from sqlalchemy import Column, String, asc, cast, desc, false, func, inspect, or_ from sqlalchemy.exc import NoInspectionAvailable from sqlalchemy.orm import selectinload +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 @@ -42,11 +43,13 @@ from sqladmin._queries import Query from sqladmin._types import ( _UNSET, + BASE_FORMATTERS_TYPE, MODEL_ATTR, SESSION_MAKER, ColumnFilter, OperationColumnFilter, SimpleColumnFilter, + StrEnum, ) from sqladmin.ajax import create_ajax_loader from sqladmin.exceptions import InvalidModelError @@ -732,7 +735,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: @@ -750,6 +753,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() + ``` + """ + non_link_related_fields: ClassVar[List[MODEL_ATTR]] = [] """Relationship fields that should be rendered as plain text instead of links. @@ -840,6 +861,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) @@ -908,10 +941,47 @@ 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) + 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: + formatter = self._column_type_formatters[parent_class] + return formatter(value) + + return value + + def _default_formatter_detail(self, value: Any) -> Any: + value_class = type(value) + + if value_class in self._column_type_formatters_detail: + formatter = self._column_type_formatters_detail[value_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 def _formatter_accepts_request(self, formatter: Callable[..., Any]) -> bool: @@ -1137,7 +1207,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 @@ -1188,7 +1258,7 @@ async def get_detail_value( self._detail_formatter_accepts_request.get(prop, False), ) if formatter - else self._default_formatter(value) + 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 a7f9559a..c6361352 100644 --- a/sqladmin/statics/js/main.js +++ b/sqladmin/statics/js/main.js @@ -539,8 +539,28 @@ $(':input[data-role="select2-tags"]').each(function () { } }); +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); + }); +} + // Automatically resize textarea on input events. -$('.autoresize-textarea').each(function() { +$('.autoresize-textarea').each(function () { const $textarea = $(this); if (!$textarea) return; @@ -549,7 +569,7 @@ $('.autoresize-textarea').each(function() { $textarea.css('height', $textarea[0].scrollHeight + 'px'); }; - $textarea.on('input propertychange', function() { + $textarea.on('input propertychange', function () { updateTextareaHeight(); }); @@ -558,7 +578,7 @@ $('.autoresize-textarea').each(function() { // Displays the number of characters in the text field. -$('.chars-count-label').each(function() { +$('.chars-count-label').each(function () { const $charsCountLabel = $(this); if (!$charsCountLabel) return; @@ -575,7 +595,7 @@ $('.chars-count-label').each(function() { $charsCountLabel.toggleClass('warning', $maxLength > 0 && $currentLength >= $maxLength); }; - $textarea.on('input propertychange', function() { + $textarea.on('input propertychange', function () { updateCharsCountLabel(); }); diff --git a/tests/test_formatters.py b/tests/test_formatters.py new file mode 100644 index 00000000..1f2e25bf --- /dev/null +++ b/tests/test_formatters.py @@ -0,0 +1,292 @@ +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._types import StrEnum +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(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({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({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({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 + + +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