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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api_reference/model_view.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
- form_create_rules
- form_edit_rules
- column_type_formatters
- column_type_formatters_detail
- list_query
- count_query
- search_query
Expand Down
54 changes: 52 additions & 2 deletions docs/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,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.

Expand All @@ -322,6 +320,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.
Expand Down
11 changes: 11 additions & 0 deletions sqladmin/_types.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
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
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]
Expand Down Expand Up @@ -53,3 +59,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]]],
]
77 changes: 73 additions & 4 deletions sqladmin/formatters.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,89 @@
import datetime
import enum
import sys
from typing import Any

from markupsafe import Markup

from sqladmin._types import BASE_FORMATTERS_TYPE

def empty_formatter(value: Any) -> str:
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"""
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("<i class='fa {}'></i>").format(icon_class)
return Markup("<i class='fa {}'></i>").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'<span class="my-1 py-1 px-2 badge bg-secondary '
f'text-light lead d-inline-block text-truncate" '
f'data-bs-toggle="tooltip" '
f'data-bs-html="true" '
f'data-bs-placement="bottom" '
f"{title}"
f"{value}"
f"</span>"
) # nosec


def datetime_formatter(value: datetime.datetime) -> Markup:
"""Return badge for easy viewing of datetime."""

return Markup(
f"<span "
f'class="my-1 py-1 px-2 badge bg-secondary text-light '
f'lead d-inline-block text-truncate" '
f'data-bs-toggle="tooltip" '
f'data-bs-html="true" '
f'data-bs-placement="bottom" '
f'title="{value}"'
f">"
f'<i class="fa-solid fa-calendar-days"></i> '
f"{value.strftime('%d %B %Y %H:%M:%S')}"
f"</span>"
) # nosec


def copy_to_clipboard_formatter(value: Any) -> Markup:
"""Return value with copy to clipboard button and alert."""

return Markup(
f'<div class="d-flex justify-content-start align-items-center">'
f'<div class="me-2">{value}</div>'
f"<button "
f'class="btn btn-link p-2 me-2" '
f"""onclick='copyToClipboard(this, "{value}")'"""
f">"
f'<i class="fas fa-copy"></i>'
f"</button>"
f'<div class="alert alert-primary fade mb-0 p-1">Copied!</div>'
f"</div>"
) # nosec


BASE_FORMATTERS = {
BASE_FORMATTERS: BASE_FORMATTERS_TYPE = {
type(None): empty_formatter,
bool: bool_formatter,
}
90 changes: 85 additions & 5 deletions sqladmin/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import sys
import time
import warnings
from enum import Enum
Expand All @@ -26,6 +27,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
Expand All @@ -38,6 +40,7 @@

from sqladmin._queries import Query
from sqladmin._types import (
BASE_FORMATTERS_TYPE,
MODEL_ATTR,
ColumnFilter,
OperationColumnFilter,
Expand All @@ -63,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]

Expand Down Expand Up @@ -685,7 +698,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:
Expand All @@ -703,6 +716,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]
Expand Down Expand Up @@ -766,6 +797,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)
Expand Down Expand Up @@ -831,10 +874,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_detail:
formatter = self._column_type_formatters_detail[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 validate_page_number(self, number: Union[str, None], default: int) -> int:
Expand Down Expand Up @@ -951,7 +1031,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
Expand Down Expand Up @@ -982,7 +1062,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

Expand Down
20 changes: 20 additions & 0 deletions sqladmin/statics/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,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);
});
}
2 changes: 1 addition & 1 deletion sqladmin/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,4 @@ def __call__(self, field: Field, **kwargs: Any) -> Markup:
'<div class="form-switch d-flex align-items-center h-100">'
+ str(Markup.escape(super().__call__(field, **kwargs)))
+ "</div>"
)
) # nosec
Loading
Loading