Skip to content
Open
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
56 changes: 56 additions & 0 deletions docs/working_with_templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,62 @@ SQLAdmin and in the `content` block it adds custom HTML tags:
details_template = "custom_details.html"
```

### Customizing column filter templates

Each built-in column filter declares a `template` attribute which defaults to one of
`sqladmin/filters/lookup_filter.html` or `sqladmin/filters/operation_filter.html`.
You can point a filter to your own template for full control over its UI—for example,
to render lookup values in a drop-down instead of a long list of links:

!!! example

```python title="admin.py"
from sqladmin.filters import StaticValuesFilter


class StatusDropdownFilter(StaticValuesFilter):
template = "filters/status_dropdown_filter.html"

def __init__(self):
super().__init__(
column=Article.status,
values=[("draft", "Draft"), ("published", "Published")],
title="Status",
)
```

```html title="templates/filters/status_dropdown_filter.html"
{% extends "sqladmin/filters/base_filter.html" %}

{% block filter_body %}
{% set current_value = request.query_params.get(filter.parameter_name, '') %}

<form method="get" class="d-flex flex-column" style="gap: 8px;">
{% for key, value in request.query_params.items() %}
{% if key != filter.parameter_name %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}

<select name="{{ filter.parameter_name }}" class="form-select form-select-sm">
{% for value, label in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
<option value="{{ value }}" {% if current_value == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>

<div class="d-flex align-items-center" style="gap: 8px;">
<button type="submit" class="btn btn-sm btn-outline-primary">Apply</button>
{% if current_value %}
<a href="{{ request.url.remove_query_params(filter.parameter_name) }}" class="text-decoration-none small">Clear</a>
{% endif %}
</div>
</form>
{% endblock %}
```

This makes it possible to ship custom filter widgets by subclassing an existing filter
and only overriding its template.

## Overriding default templates

If you need to change one of the existing default templates in SQLAdmin such that it affects multiple pages, you can do so by copying the existing template from `templates/sqladmin` into your `templates/sqladmin` directory. It will then be used instead of the one in the package. For example if there is some Javascript you want to run on every page you may want to do it in layout.html like so:
Expand Down
2 changes: 2 additions & 0 deletions sqladmin/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class SimpleColumnFilter(Protocol):

title: str
parameter_name: str
template: str

async def lookups(
self, request: Request, model: Any, run_query: Callable[[Select], Any]
Expand All @@ -42,6 +43,7 @@ class OperationColumnFilter(Protocol):
title: str
parameter_name: str
has_operator: bool
template: str

async def lookups(
self, request: Request, model: Any, run_query: Callable[[Select], Any]
Expand Down
5 changes: 5 additions & 0 deletions sqladmin/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def get_model_from_column(column: Any) -> Any:

class BooleanFilter:
has_operator = False
template = "sqladmin/filters/lookup_filter.html"

def __init__(
self,
Expand Down Expand Up @@ -96,6 +97,7 @@ async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Sel

class AllUniqueStringValuesFilter:
has_operator = False
template = "sqladmin/filters/lookup_filter.html"

def __init__(
self,
Expand Down Expand Up @@ -127,6 +129,7 @@ async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Sel

class StaticValuesFilter:
has_operator = False
template = "sqladmin/filters/lookup_filter.html"

def __init__(
self,
Expand Down Expand Up @@ -154,6 +157,7 @@ async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Sel

class ForeignKeyFilter:
has_operator = False
template = "sqladmin/filters/lookup_filter.html"

def __init__(
self,
Expand Down Expand Up @@ -207,6 +211,7 @@ class OperationColumnFilter:
"""Universal filter that provides appropriate filter types based on column type"""

has_operator = True
template = "sqladmin/filters/operation_filter.html"

def __init__(
self,
Expand Down
6 changes: 6 additions & 0 deletions sqladmin/templates/sqladmin/filters/base_filter.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="mb-3">
<div class="{% block filter_title_classes %}fw-bold text-truncate{% endblock %}">{{ filter.title }}</div>
<div>
{% block filter_body %}{% endblock %}
</div>
</div>
22 changes: 22 additions & 0 deletions sqladmin/templates/sqladmin/filters/lookup_filter.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% extends "sqladmin/filters/base_filter.html" %}

{% block filter_title_classes %}fw-bold text-truncate fs-3 mb-2{% endblock %}

{% block filter_body %}
{% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
{% if request.query_params.get(filter.parameter_name) == lookup[0] %}
<div class="d-flex align-items-center justify-content-between bg-secondary-lt px-2 py-1 rounded">
<span class="text-truncate fw-bold text-dark">
{{ lookup[1] }}
</span>
<a href="{{ request.url.remove_query_params(filter.parameter_name) }}" class="text-decoration-none ms-2" title="Clear filter">
<i class="fa-solid fa-times"></i>
</a>
</div>
{% else %}
<a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate px-2 py-1">
{{ lookup[1] }}
</a>
{% endif %}
{% endfor %}
{% endblock %}
38 changes: 38 additions & 0 deletions sqladmin/templates/sqladmin/filters/operation_filter.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{% extends "sqladmin/filters/base_filter.html" %}

{% block filter_body %}
{% set current_filter = request.query_params.get(filter.parameter_name, '') %}
{% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}

{% if current_filter %}
<div class="mb-2 text-muted small">
Current: {{ current_op }} {{ current_filter }}
<a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a>
</div>
{% endif %}

<form method="get" class="d-flex flex-column" style="gap: 8px;">
{% for key, value in request.query_params.items() %}
{% if key != filter.parameter_name and key != filter.parameter_name + '_op' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}

<select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
<option value="">Select operation...</option>
{% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
<option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
{% endfor %}
</select>

<input
type="text"
name="{{ filter.parameter_name }}"
placeholder="Enter value"
class="form-control form-control-sm"
value="{{ current_filter }}"
required>

<button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button>
</form>
{% endblock %}
64 changes: 1 addition & 63 deletions sqladmin/templates/sqladmin/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -223,69 +223,7 @@ <h3 class="card-title">Filters</h3>
</div>
<div class="card-body">
{% for filter in model_view.get_filters() %}
{% if filter.has_operator %}
<div class="mb-3">
<div class="fw-bold text-truncate">{{ filter.title }}</div>
<div>
<!-- Show current filter if active -->
{% set current_filter = request.query_params.get(filter.parameter_name, '') %}
{% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}
{% if current_filter %}
<div class="mb-2 text-muted small">
Current: {{ current_op }} {{ current_filter }}
<a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a>
</div>
{% endif %}
<!-- Single form with dropdown for operations -->
<form method="get" class="d-flex flex-column" style="gap: 8px;">
<!-- Preserve existing query parameters -->
{% for key, value in request.query_params.items() %}
{% if key != filter.parameter_name and key != filter.parameter_name + '_op' %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
<!-- Operation dropdown -->
<select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
<option value="">Select operation...</option>
{% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
<option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
{% endfor %}
</select>
<!-- Value input -->
<input type="text"
name="{{ filter.parameter_name }}"
placeholder="Enter value"
class="form-control form-control-sm"
value="{{ current_filter }}"
required>
<button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button>
</form>
</div>
</div>
{% else %}
<!-- Fallback for other filter types -->
<div class="mb-3">
<div class="fw-bold text-truncate fs-3 mb-2">{{ filter.title }}</div>
<div>
{% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
{% if request.query_params.get(filter.parameter_name) == lookup[0] %}
<div class="d-flex align-items-center justify-content-between bg-secondary-lt px-2 py-1 rounded">
<span class="text-truncate fw-bold text-dark">
{{ lookup[1] }}
</span>
<a href="{{ request.url.remove_query_params(filter.parameter_name) }}" class="text-decoration-none ms-2" title="Clear filter">
<i class="fa-solid fa-times"></i>
</a>
</div>
{% else %}
<a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate px-2 py-1">
{{ lookup[1] }}
</a>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% include filter.template %}
{% endfor %}
</div>
</div>
Expand Down
7 changes: 7 additions & 0 deletions tests/templates/sqladmin/filters/custom_lookup_filter.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% extends "sqladmin/filters/lookup_filter.html" %}

{% block filter_body %}
<div data-testid="custom-lookup-filter">
{{ super() }}
</div>
{% endblock %}
7 changes: 7 additions & 0 deletions tests/templates/sqladmin/filters/custom_operation_filter.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% extends "sqladmin/filters/operation_filter.html" %}

{% block filter_body %}
<div data-testid="custom-operation-filter">
{{ super() }}
</div>
{% endblock %}
49 changes: 48 additions & 1 deletion tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
session_maker = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)

app = Starlette()
admin = Admin(app=app, engine=engine)
admin = Admin(app=app, engine=engine, templates_dir="tests/templates")


def create_user_table():
Expand Down Expand Up @@ -73,6 +73,22 @@ class Office(Base):
name = Column(String)


class Project(Base):
__tablename__ = "projects"

id = Column(Integer, primary_key=True)
name = Column(String)
priority = Column(Integer)


class CustomLookupFilter(StaticValuesFilter):
template = "sqladmin/filters/custom_lookup_filter.html"


class CustomOperationFilter(OperationColumnFilter):
template = "sqladmin/filters/custom_operation_filter.html"


class UserAdmin(ModelView, model=User):
column_list = [User.name, User.title, User.age, User.salary, User.description]
can_create = True
Expand Down Expand Up @@ -108,8 +124,25 @@ class AddressAdmin(ModelView, model=Address):
# This admin will NOT have filters defined


class ProjectAdmin(ModelView, model=Project):
column_list = [Project.name, Project.priority]
can_create = True
can_edit = True
can_delete = True
can_view_details = True
column_filters = [
CustomLookupFilter(
Project.name,
[("Alpha", "Alpha"), ("Beta", "Beta")],
parameter_name="project_name",
),
CustomOperationFilter(Project.priority),
]


admin.add_view(UserAdmin)
admin.add_view(AddressAdmin)
admin.add_view(ProjectAdmin)


@pytest.fixture
Expand Down Expand Up @@ -161,6 +194,11 @@ async def prepare_data(prepare_database: Any) -> AsyncGenerator[None, None]:
session.add_all([user1, user2, user3])
await session.commit()

project1 = Project(name="Alpha", priority=1)
project2 = Project(name="Beta", priority=2)
session.add_all([project1, project2])
await session.commit()

yield


Expand Down Expand Up @@ -207,6 +245,15 @@ async def test_column_filters_sidebar_existence(client: AsyncClient) -> None:
assert '<div id="filter-sidebar"' not in response.text


@pytest.mark.anyio
async def test_column_filter_custom_templates(client: AsyncClient) -> None:
"""Custom templates set on filters should be rendered instead of defaults."""
response = await client.get("/admin/project/list")
assert response.status_code == 200
assert 'data-testid="custom-lookup-filter"' in response.text
assert 'data-testid="custom-operation-filter"' in response.text


@pytest.mark.anyio
async def test_filter_lookups(client: AsyncClient) -> None:
"""Test that the filter lookups are correct."""
Expand Down