Машинный перевод под контролем человека
Этот контент переведён с помощью машинной генерации, направляемой составленными людьми глоссариями и руководствами по стилю. Поскольку текст не проверяется вручную построчно, возможны отдельные ошибки или неестественные формулировки.
В случае любых расхождений авторитетным источником считается оригинальная версия на английском языке.
Пользовательские фильтры
Создавайте подкласс BaseFilter, когда вам нужен оператор, которого нет во встроенном наборе: специфичная для предметной области проверка вроде «делится на», вычисляемое условие вроде «создано в этом месяце» или поддержка типа поля, который пропускает реестр по умолчанию. На этой странице объясняется, как фильтр устроен внутри, и показаны два способа его регистрации: либо через создание подкласса FilterRegistry вашего backend'а, чтобы охватить все поля соответствующего типа, либо через передачу фильтра в список filters= конкретного поля. О повседневных деталях — фильтрах по умолчанию для каждого типа поля, ручных переопределениях и формате URL — читайте в руководстве по фильтрам.
Интерфейс BaseFilter
Каждый фильтр, встроенный или пользовательский, реализует два метода:
from typing import Any
from starlette_admin.filters.base import BaseFilter, FilterApplyContext, FilterDataType
class MyFilter(BaseFilter):
name = "my_filter"
label = "My filter"
data_type = FilterDataType.STRING
def parse_value(self, raw: str) -> Any:
"""Convert the raw string from the URL into the value apply() expects.
Raise FilterValidationError if the value isn't acceptable.
"""
return raw
def apply(self, ctx: FilterApplyContext) -> Any:
"""Return a query fragment for this filter's condition."""
raise NotImplementedError()
parse_value(raw)преобразует исходную строку из URL в тип, который ожидаетapply(), напримерDecimal,dateили список. Реализация по умолчанию передаёт строку без изменений, что подходит для фильтровSTRINGиENUM, но не для числовых или временных данных. Это также ваша точка валидации: вызывайтеFilterValidationErrorдля значений, которые синтаксически корректны, но всё же неприемлемы, например выходят за допустимый диапазон или имеют неверный формат.apply(ctx)— единственный абстрактный метод. Он получает объектFilterApplyContext, содержащийquery,field_name,value,value2,requestиview, и возвращает фрагмент запроса для вашего backend'а.
Как разбираются исходные значения из URL
Каждый параметр URL является строкой, поэтому и price__gt=50, и created_at__eq=2026-01-01 приходят в виде необработанного текста. Перед запуском apply() метод parse_value() преобразует эту строку в Python-объект, соответствующий data_type фильтра:
def _parse_number(raw: Any) -> int | float:
text = str(raw).strip()
try:
return int(text)
except ValueError:
pass
try:
return float(text)
except ValueError:
raise FilterValidationError(f"{raw!r} is not a valid number") from None
class GreaterThanFilter(BaseFilter):
name = "gt"
data_type = FilterDataType.NUMBER
def parse_value(self, raw: Any) -> int | float:
return _parse_number(raw)
Таким образом, ?filter=price__gt=50 и ?filter=price__gt=50.5 достигают GreaterThanFilter.apply() уже как числа Python (50 как int, 50.5 как float), а не как строки "50" и "50.5". Метод apply() передаёт разобранное значение напрямую объекту запроса, а драйвер базы данных выполняет финальное приведение типов к фактическому типу столбца, например Decimal или Numeric.
data_type |
Пример исходного значения URL | Разобранное значение Python | Чем разбирается |
|---|---|---|---|
number |
50, -3, 50.5 |
int(50), int(-3), float(50.5) |
filters.numeric._parse_number (пытается int(), при неудаче переходит к float()) |
date |
2026-01-01 |
date(2026, 1, 1) |
filters.date._parse_temporal с использованием date.fromisoformat() |
datetime |
2026-01-01T14:30:00 |
datetime(2026, 1, 1, 14, 30) |
filters.date._parse_temporal с использованием datetime.fromisoformat() |
time |
14:30:00 |
time(14, 30) |
filters.date._parse_temporal с использованием time.fromisoformat() |
array |
ACTIVE,OUT_OF_STOCK |
["ACTIVE", "OUT_OF_STOCK"] |
filters.array._parse_array (разделяет по незакавыченным запятым) |
string, enum |
admin |
"admin" |
Реализация BaseFilter.parse_value по умолчанию (передаётся без изменений) |
none |
(значение в URL отсутствует) | (никогда не вызывается) | Н/Д |
Если значение не удаётся разобрать, например price__gt=abc или created_at__eq=not-a-date, метод parse_value() вызывает исключение FilterValidationError. Обработчик запроса перехватывает его и возвращает HTTP 400 ещё до выполнения любого запроса к базе данных:
GET /admin/product/list?filter=price__gt=abc
Returns: 400 Bad Request: Invalid 'filter' parameter: 'abc' is not a valid number
Фильтры без значения — те, у которых data_type=none, такие как is_null, is_true или in_past, — этот шаг пропускают. Для них parse_value никогда не выполняется, поэтому field__is_null не требует =value в URL: нет входной строки, которую нужно было бы преобразовать.
Как сделать пользовательский фильтр доступным
Зарегистрировать пользовательский фильтр для view можно двумя способами. Выберите тот, который соответствует нужному вам охвату.
Для конкретного экземпляра поля (узкий охват)
Передайте фильтр в список filters= целевого поля — либо вместе с фильтрами по умолчанию, либо вместо них. Тот же шаблон со встроенными фильтрами описан в разделе Переопределение фильтров для конкретного поля. Используйте этот способ, когда фильтр имеет смысл только для одного поля.
На уровне реестра (для всех полей соответствующего типа)
Каждый backend поставляется с подклассом FilterRegistry: SqlaFilterRegistry для SQLAlchemy, BeanieFilterRegistry для Beanie, MongoEngineFilterRegistry для MongoEngine и TortoiseFilterRegistry для Tortoise ORM. Каждый из них определяет фильтры по умолчанию для поддерживаемого типа поля в методе, декорированном с помощью @filters(FieldType, ...):
# starlette_admin/contrib/sqla/filters.py
class SqlaFilterRegistry(FilterRegistry):
@filters(StringField)
def string_filters(self, field: BaseField) -> list[type[BaseFilter]]:
return [
ContainsFilter,
NotContainsFilter,
EqualFilter,
IsNullFilter,
IsNotNullFilter,
]
@filters(NumberField, FloatField)
def numeric_filters(self, field: BaseField) -> list[type[BaseFilter]]:
return [
NumericEqualFilter,
GreaterThanFilter,
LessThanFilter,
IsNullFilter,
IsNotNullFilter,
]
# ... one method per field type
Чтобы изменить набор фильтров, доступных для типа поля во всём view, создайте подкласс реестра backend'а, переопределите или добавьте метод @filters и возвращайте экземпляр вашего подкласса из get_filter_registry():
class ProductFilterRegistry(SqlaFilterRegistry):
@filters(IntegerField)
def integer_filters(self, field: BaseField) -> list[type[BaseFilter]]:
return [*self.numeric_filters(field), DivisibleByFilter]
class ProductView(ModelView):
def get_filter_registry(self) -> FilterRegistry:
return ProductFilterRegistry()
Объявлять эти методы можно одним из двух способов — в зависимости от того, хотите ли вы заменить существующие фильтры или дополнить их:
- Переопределение: повторно объявите
@filters(StringField)в своём подклассе и верните ровно те классы, которые вам нужны. Это заменит список родительского класса, поэтому включите все встроенные фильтры, которые хотите сохранить. - Расширение: объявите
@filters(IntegerField), если родительский реестр регистрирует только более общийNumberField. ПосколькуIntegerFieldнаследуется отNumberField, порядок разрешения методов (MRO) свяжетIntegerFieldс вашим новым методом, тогда какDecimalField— другой подклассNumberFieldбез собственной регистрации — продолжит наследовать родительскийnumeric_filtersбез изменений.
Это обычный подкласс Python, поэтому глобальное состояние он не изменяет. Каждый вызов ProductFilterRegistry() создаёт независимый реестр, а ваши изменения остаются локальными для тех view, которые его возвращают. Все остальные view сохраняют настройки backend'а по умолчанию.
Полный пример на SQLAlchemy
Приведённый ниже DivisibleByFilter принимает значение — делитель, на кратность которому проверяется столбец. Подкласс SqlaFilterRegistry применяет его ко всем IntegerField на ProductView, а не прикрепляет к отдельным полям:
import uuid
from contextlib import asynccontextmanager
from datetime import datetime
from decimal import Decimal
from typing import Any
from fastapi import FastAPI
from sqlalchemy import Integer, Numeric, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from starlette.requests import Request
from starlette_admin import IntegerField
from starlette_admin.contrib.sqla import Admin, ModelView
from starlette_admin.contrib.sqla.filters import SqlaFilterRegistry
from starlette_admin.fields import BaseField
from starlette_admin.filters import (
BaseFilter,
FilterApplyContext,
FilterDataType,
FilterRegistry,
FilterValidationError,
filters,
)
engine = create_engine(
"sqlite:///product.db", connect_args={"check_same_thread": False}, echo=True
)
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "products"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str]
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
lot_size: Mapped[int] = mapped_column(Integer, default=1)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
async def __admin_repr__(self, request: Request) -> str:
return self.name
class DivisibleByFilter(BaseFilter):
"""
Filters database rows where the column value is an exact multiple of a given divisor.
"""
name = "divisible_by"
label = "Is divisible by"
data_type = FilterDataType.NUMBER
def parse_value(self, raw: str) -> int:
"""Validates and converts the raw admin UI input into an integer divisor."""
try:
divisor = int(raw)
except ValueError:
raise FilterValidationError(f"{raw!r} is not a valid integer") from None
if divisor == 0:
raise FilterValidationError("divisor must not be 0")
return divisor
def apply(self, ctx: FilterApplyContext) -> Any:
"""Applies the modulus condition to the underlying SQLAlchemy query context."""
column = getattr(ctx.view.model, ctx.field_name)
return column % ctx.value == 0
class ProductFilterRegistry(SqlaFilterRegistry):
"""
Custom filter registry that injects `DivisibleByFilter` into integer fields.
Overriding `integer_filters` gives every IntegerField the divisibility filter
on top of the standard numeric defaults. Other numeric fields, such as
DecimalField, are unaffected.
"""
@filters(IntegerField)
def integer_filters(self, field: BaseField) -> list[type[BaseFilter]]:
return [*self.numeric_filters(field), DivisibleByFilter]
class ProductView(ModelView):
fields = [
"id",
"name",
"price",
# Note: Passing just the string "lot_size" would also work, as SQLAlchemy's
# default converter automatically maps integer columns to IntegerField.
IntegerField("lot_size"),
]
def get_filter_registry(self) -> FilterRegistry:
"""Binds the custom filter registry to this specific view."""
return ProductFilterRegistry()
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(engine)
yield
app = FastAPI(lifespan=lifespan)
admin = Admin(engine, title="Blog Admin", secret_key="change-me")
admin.add_view(ProductView(Product, icon="fa fa-product"))
admin.mount_to(app)
Рабочее приложение с пользовательским подклассом BaseFilter, зарегистрированным тем же способом, можно найти в examples/02-filters.
Опция lot_size__divisible_by теперь отображается как фильтр для IntegerField("lot_size") — без явного объявления filters= на поле. Например, lot_size__divisible_by=6 отбирает товары, размер партии которых кратен 6:
Tip
Используйте подкласс FilterRegistry, когда фильтр достаточно универсален, чтобы применяться ко всем полям данного типа в view. Используйте список filters= на уровне поля, когда логика относится только к одному полю. Примеры шаблона «на уровне поля» приведены в руководстве по фильтрам.
Динамические варианты выбора с помощью get_choices
По умолчанию поле ввода значения фильтра определяется его data_type: простое текстовое поле для STRING, числовое поле для NUMBER и так далее. Переопределите get_choices(request), когда значение должно выбираться из выпадающего списка, заполняемого списком пар (value, label) для каждого запроса. Типичный случай — фильтр «входит в» по полю связи: обратно отправляется внешний ключ, но селектор должен показывать читаемое имя.
Метод get_choices получает текущий Request и возвращает последовательность пар (value, label) либо None (по умолчанию), чтобы оставить обычное поле ввода. Непустой результат имеет приоритет и над обычным полем ввода, и над любыми вариантами, которые предоставляет само поле, как это делает EnumField.
Пример ниже, взятый из examples/advanced/07-hr, добавляет пару фильтров «входит в» и «не входит в» к полю department в списке Employee. Поле department является RelationField, поэтому реестр по умолчанию даёт ему только проверки на NULL: универсального способа сравнить связанную строку с исходной строкой не существует. Метод get_choices выводит все Department по имени в выпадающий список, а parse_value преобразует отправленные обратно значения в целые числа, чтобы apply мог сопоставлять непосредственно по внешнему ключу Department.id, вместо того чтобы соединять таблицы через связь и сравнивать имена:
# examples/advanced/07-hr/filters.py
from typing import Any
from models import Department, Employee
from sqlalchemy import select
from sqlalchemy.orm import Session
from starlette.requests import Request
from starlette_admin.filters.base import FilterApplyContext, FilterValidationError
from starlette_admin.filters.enum import InFilter, NotInFilter
class _DepartmentChoicesMixin:
"""Shared `get_choices`/`parse_value` for the two filters below: the
filter builder's dropdown lists every department by name, and posts back
the department's `id` rather than its name, so `apply` can match on the
primary key instead of an `ilike` comparison.
"""
def get_choices(self, request: Request) -> list[tuple[int, str]]:
session: Session = request.state.session
return list(
session.execute(
select(Department.id, Department.name).order_by(Department.name)
).all()
)
def parse_value(self, raw: Any) -> list[int]:
values = super().parse_value(raw) # type: ignore[misc]
try:
return [int(v) for v in values]
except ValueError as err:
raise FilterValidationError("Department id must be an integer") from err
class DepartmentInFilter(_DepartmentChoicesMixin, InFilter):
"""Employees in one of the selected departments."""
name = "department_in"
label = "is one of"
def apply(self, ctx: FilterApplyContext) -> Any:
return Employee.department_id.in_(ctx.value)
class DepartmentNotInFilter(_DepartmentChoicesMixin, NotInFilter):
"""Employees not in any of the selected departments"""
name = "department_not_in"
label = "is not one of"
def apply(self, ctx: FilterApplyContext) -> Any:
return ~Employee.department_id.in_(ctx.value)
Несколько замечаний об этом шаблоне:
- Миксин стоит раньше базового класса фильтра в MRO.
_DepartmentChoicesMixinуказан первым вclass DepartmentInFilter(_DepartmentChoicesMixin, InFilter), поэтому егоget_choicesиparse_valueпереопределяют те методы, которые каждый фильтр иначе унаследовал бы. При этомsuper().parse_value(raw)всё равно обращается кInFilter.parse_value, который разбивает исходное значение на список до того, как миксин преобразует его в целые числа. get_choicesвыполняется при каждом запросе, а не один раз при импорте, поэтому выпадающий список всегда отражает текущие строки. Вновь добавленныйDepartmentпоявляется в конструкторе фильтров сразу — без перезапуска сервера и без кеша, который нужно сбрасывать.- Пары
(value, label)и тип выводаparse_valueдолжны быть согласованы. Выпадающий список отправляет обратно то значениеvalue, которое выбрал пользователь, поэтомуparse_valueпреобразует его в то, что ожидаетapply. ЗдесьDepartment.idуже являетсяint, поэтомуparse_valueмиксина лишь подтверждает это и вызывает ошибку валидации для всего остального. InFilterиNotInFilterпо умолчанию уже используютdata_type = FilterDataType.ENUM— множественный выбор, поэтому ни одному из подклассов не нужно переопределятьdata_type. Достаточно переопределитьget_choices, чтобы наполнить этот список выбора отделами вместо пустого состояния.
Что дальше
- Фильтры: узнайте о фильтрах по умолчанию для каждого типа поля, о формате URL и о переопределении через
filters=. - SQLAlchemy: изучите backend SQLAlchemy, использованный в примере на этой странице.
- Точки расширения: просмотрите полный список методов, которые можно переопределить в
ModelView.