Skip to content

Commit 8e3813f

Browse files
authored
Adding the ability to add filters to model views (#906)
1 parent 4f78e70 commit 8e3813f

9 files changed

Lines changed: 795 additions & 205 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ examples/
1313
.vscode/
1414
.uploads
1515
test.db
16-
coverage.xml
16+
coverage.xml
17+
dist/

docs/configurations.md

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ you can visit [API Reference](./api_reference/model_view.md).
66
Let's say you've defined your SQLAlchemy models like this:
77

88
```python
9-
from sqlalchemy import Column, Integer, String, create_engine
9+
from sqlalchemy import Column, Integer, String, Boolean, create_engine
1010
from sqlalchemy.orm import declarative_base
1111

1212

@@ -23,6 +23,7 @@ class User(Base):
2323
id = Column(Integer, primary_key=True)
2424
name = Column(String)
2525
email = Column(String)
26+
is_admin = Column(Boolean, default=False)
2627

2728

2829
Base.metadata.create_all(engine) # Create tables
@@ -107,6 +108,7 @@ or list of the tuple for multiple columns.
107108
* `list_query`: A method with the signature of `(request) -> stmt` which can customize the list query.
108109
* `count_query`: A method with the signature of `(request) -> stmt` which can customize the count query.
109110
* `search_query`: A method with the signature of `(stmt, term) -> stmt` which can customize the search query.
111+
* `column_filters`: A list of objects that implement the `ColumnFilter` protocol to be displayed in the list page. See example below.
110112

111113
!!! example
112114

@@ -117,6 +119,7 @@ or list of the tuple for multiple columns.
117119
column_sortable_list = [User.id]
118120
column_formatters = {User.name: lambda m, a: m.name[:10]}
119121
column_default_sort = [(User.email, True), (User.name, False)]
122+
column_filterable_list = [User.is_admin]
120123
```
121124

122125

@@ -125,6 +128,92 @@ or list of the tuple for multiple columns.
125128
You can use the special keyword `"__all__"` in `column_list` or `column_details_list`
126129
if you don't want to specify all the columns manually. For example: `column_list = "__all__"`
127130

131+
### ColumnFilter
132+
133+
A ColumnFilter is a class that defines a filter for a column. A few standard filters are implemented in `sqladmin.filters` module. Here is an example of a generic ColumnFilter. Note that the fields `title`, `parameter_name`, `lookups` and `get_filtered_query` are required.
134+
135+
```python
136+
class IsAdminFilter:
137+
# Human-readable title which will be displayed in the
138+
# right admin sidebar just above the filter options.
139+
title = "Is Admin"
140+
141+
# Parameter for the filter that will be used in the URL query.
142+
parameter_name = "is_admin"
143+
144+
def lookups(self, request, model) -> list[tuple[str, str]]:
145+
"""
146+
Returns a list of tuples with the filter key and the human-readable label.
147+
"""
148+
return [
149+
("all", "All"),
150+
("true", "Yes"),
151+
("false", "No"),
152+
]
153+
154+
def get_filtered_query(self, query, value):
155+
"""
156+
Returns a filtered query based on the filter value.
157+
"""
158+
if value == "true":
159+
return query.filter(model.is_admin == True)
160+
elif value == "false":
161+
return query.filter(model.is_admin == False)
162+
else:
163+
return query
164+
```
165+
166+
### Built in Column Filters
167+
168+
The following built in column filters are available. All filters have a default value of "all" which allows the user to not filter the column
169+
170+
* BooleanFilter - A filter for boolean columns, with the values of Yes (true) and No (false)
171+
* AllUniqueStringValuesFilter - A filter for string columns, with the values of all unique values in the column
172+
* StaticValuesFilter - A filter for string columns, with the values of a static list of values. This is similar to AllUniqueStringValuesFilter, but instead of getting the list of possible values from the database, you can provide a static list of values.
173+
* ForeignKeyFilter - A filter for foreign key columns, with the values of all unique values in the foreign key column. To make this filter readable, you need to provide the field name from the foreign model that you want to display as the name of the filter.
174+
175+
Here is an example of how to use BooleanFilter, AllUniqueStringValuesFilter and ForeignKeyFilter:
176+
177+
```python
178+
179+
class User(Base):
180+
__tablename__ = "users"
181+
182+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
183+
name: Mapped[str] = mapped_column(String, nullable=False)
184+
email: Mapped[str] = mapped_column(String, nullable=False, index=True, unique=True)
185+
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
186+
site_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("sites.id"), nullable=True, default=None)
187+
site: Mapped[Optional["Site"]] = relationship(back_populates="users")
188+
189+
class Site(Base):
190+
__tablename__ = "sites"
191+
192+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
193+
name: Mapped[str] = mapped_column(String, nullable=False)
194+
users: Mapped[list["User"]] = relationship(back_populates="site")
195+
196+
197+
# Define User Admin View
198+
class UserAdmin(ModelView, model=User):
199+
column_list = ["id", "name", "email", "is_admin"]
200+
column_filters = [
201+
BooleanFilter(User.is_admin),
202+
AllUniqueStringValuesFilter(User.name),
203+
ForeignKeyFilter(User.site_id, Site.name, title="Site")
204+
]
205+
can_create = True
206+
can_edit = True
207+
can_delete = True
208+
can_view_details = True
209+
name = "User"
210+
name_plural = "Users"
211+
icon = "fa-solid fa-user"
212+
identity = "user"
213+
214+
```
215+
216+
128217
## Details page
129218

130219
These options allow configurations in the details page, in the case of this example
@@ -260,6 +349,12 @@ The pages available are:
260349

261350
For more information about working with template see [Working with Templates](./working_with_templates.md).
262351

352+
## Template configurations
353+
354+
The following options are available to configure the templates:
355+
356+
* `show_compact_lists`: If `False`, the list of objects will be displayed in a separate line for each object. Default is `True`.
357+
263358
## Events
264359

265360
There might be some cases which you want to do some actions

sqladmin/_types.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
1-
from typing import Union
1+
from typing import Any, Callable, List, Protocol, Tuple, Union, runtime_checkable
22

33
from sqlalchemy.engine import Engine
44
from sqlalchemy.ext.asyncio import AsyncEngine
55
from sqlalchemy.orm import ColumnProperty, InstrumentedAttribute, RelationshipProperty
6+
from sqlalchemy.sql.expression import Select
7+
from starlette.requests import Request
68

79
MODEL_PROPERTY = Union[ColumnProperty, RelationshipProperty]
810
ENGINE_TYPE = Union[Engine, AsyncEngine]
911
MODEL_ATTR = Union[str, InstrumentedAttribute]
12+
13+
14+
@runtime_checkable
15+
class ColumnFilter(Protocol):
16+
title: str
17+
parameter_name: str
18+
19+
async def lookups(
20+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
21+
) -> List[Tuple[str, str]]:
22+
...
23+
24+
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
25+
...

sqladmin/filters.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import re
2+
from typing import Any, Callable, List, Optional, Tuple
3+
4+
from sqlalchemy import Integer
5+
from sqlalchemy.sql.expression import Select, select
6+
from starlette.requests import Request
7+
8+
from sqladmin._types import MODEL_ATTR
9+
10+
11+
def get_parameter_name(column: MODEL_ATTR) -> str:
12+
if isinstance(column, str):
13+
return column
14+
else:
15+
return column.key
16+
17+
18+
def prettify_attribute_name(name: str) -> str:
19+
return re.sub(r"_([A-Za-z])", r" \1", name).title()
20+
21+
22+
def get_title(column: MODEL_ATTR) -> str:
23+
name = get_parameter_name(column)
24+
return prettify_attribute_name(name)
25+
26+
27+
def get_column_obj(column: MODEL_ATTR, model: Any = None) -> Any:
28+
if isinstance(column, str):
29+
if model is None:
30+
raise ValueError("model is required for string column filters")
31+
return getattr(model, column)
32+
return column
33+
34+
35+
def get_foreign_column_name(column_obj: Any) -> str:
36+
fk = next(iter(column_obj.foreign_keys))
37+
return fk.column.name
38+
39+
40+
def get_model_from_column(column: Any) -> Any:
41+
return column.parent.class_
42+
43+
44+
class BooleanFilter:
45+
def __init__(
46+
self,
47+
column: MODEL_ATTR,
48+
title: Optional[str] = None,
49+
parameter_name: Optional[str] = None,
50+
):
51+
self.column = column
52+
self.title = title or get_title(column)
53+
self.parameter_name = parameter_name or get_parameter_name(column)
54+
55+
async def lookups(
56+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
57+
) -> List[Tuple[str, str]]:
58+
return [
59+
("all", "All"),
60+
("true", "Yes"),
61+
("false", "No"),
62+
]
63+
64+
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
65+
column_obj = get_column_obj(self.column, model)
66+
if value == "true":
67+
return query.filter(column_obj.is_(True))
68+
elif value == "false":
69+
return query.filter(column_obj.is_(False))
70+
else:
71+
return query
72+
73+
74+
class AllUniqueStringValuesFilter:
75+
def __init__(
76+
self,
77+
column: MODEL_ATTR,
78+
title: Optional[str] = None,
79+
parameter_name: Optional[str] = None,
80+
):
81+
self.column = column
82+
self.title = title or get_title(column)
83+
self.parameter_name = parameter_name or get_parameter_name(column)
84+
85+
async def lookups(
86+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
87+
) -> List[Tuple[str, str]]:
88+
column_obj = get_column_obj(self.column, model)
89+
90+
return [("", "All")] + [
91+
(value[0], value[0])
92+
for value in await run_query(select(column_obj).distinct())
93+
]
94+
95+
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
96+
if value == "":
97+
return query
98+
99+
column_obj = get_column_obj(self.column, model)
100+
return query.filter(column_obj == value)
101+
102+
103+
class StaticValuesFilter:
104+
def __init__(
105+
self,
106+
column: MODEL_ATTR,
107+
values: List[Tuple[str, str]],
108+
title: Optional[str] = None,
109+
parameter_name: Optional[str] = None,
110+
):
111+
self.column = column
112+
self.title = title or get_title(column)
113+
self.parameter_name = parameter_name or get_parameter_name(column)
114+
self.values = values
115+
116+
async def lookups(
117+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
118+
) -> List[Tuple[str, str]]:
119+
return [("", "All")] + self.values
120+
121+
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
122+
column_obj = get_column_obj(self.column, model)
123+
if value == "":
124+
return query
125+
return query.filter(column_obj == value)
126+
127+
128+
class ForeignKeyFilter:
129+
def __init__(
130+
self,
131+
foreign_key: MODEL_ATTR,
132+
foreign_display_field: MODEL_ATTR,
133+
foreign_model: Any = None,
134+
title: Optional[str] = None,
135+
parameter_name: Optional[str] = None,
136+
):
137+
self.foreign_key = foreign_key
138+
self.foreign_display_field = foreign_display_field
139+
self.foreign_model = foreign_model
140+
self.title = title or get_title(foreign_key)
141+
self.parameter_name = parameter_name or get_parameter_name(foreign_key)
142+
143+
async def lookups(
144+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
145+
) -> List[Tuple[str, str]]:
146+
foreign_key_obj = get_column_obj(self.foreign_key, model)
147+
if self.foreign_model is None and isinstance(self.foreign_display_field, str):
148+
raise ValueError("foreign_model is required for string foreign key filters")
149+
if self.foreign_model is None:
150+
assert not isinstance(self.foreign_display_field, str)
151+
foreign_display_field_obj = self.foreign_display_field
152+
else:
153+
foreign_display_field_obj = get_column_obj(
154+
self.foreign_display_field, self.foreign_model
155+
)
156+
if not self.foreign_model:
157+
self.foreign_model = get_model_from_column(foreign_display_field_obj)
158+
foreign_model_key_name = get_foreign_column_name(foreign_key_obj)
159+
foreign_model_key_obj = getattr(self.foreign_model, foreign_model_key_name)
160+
161+
return [("", "All")] + [
162+
(str(key), str(value))
163+
for key, value in await run_query(
164+
select(foreign_model_key_obj, foreign_display_field_obj).distinct()
165+
)
166+
]
167+
168+
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
169+
foreign_key_obj = get_column_obj(self.foreign_key, model)
170+
column_type = foreign_key_obj.type
171+
if isinstance(column_type, Integer):
172+
value = int(value)
173+
174+
return query.filter(foreign_key_obj == value)

0 commit comments

Comments
 (0)