Traduction automatique supervisée
Ce contenu est traduit à l'aide d'une génération automatique guidée par des glossaires et des guides de style élaborés par des humains. Le texte n'étant pas relu manuellement ligne par ligne, des erreurs ou des tournures maladroites peuvent occasionnellement apparaître.
En cas de divergence, la version anglaise constitue la source de référence.
Filtres personnalisés
Sous-classez BaseFilter lorsque vous avez besoin d'un opérateur non couvert par l'ensemble intégré : une vérification propre à votre domaine comme «est divisible par », une condition calculée comme «créé ce mois-ci », ou la prise en charge d'un type de champ ignoré par le registre par défaut. Cette page explique comment fonctionne un filtre en interne et présente les deux façons d'en enregistrer un : soit en sous-classant le FilterRegistry de votre backend pour couvrir tous les types de champs correspondants, soit en passant le filtre à la liste filters= d'un seul champ. Pour les détails du quotidien, notamment les filtres par défaut par type de champ, les surcharges manuelles et le format des URL, consultez le guide des filtres.
L'interface BaseFilter
Chaque filtre, qu'il soit intégré ou personnalisé, implémente deux méthodes :
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)convertit la chaîne brute issue de l'URL dans le type attendu parapply(), tel qu'unDecimal, unedateou une liste. L'implémentation par défaut transmet la chaîne inchangée, ce qui convient aux filtresSTRINGetENUM, mais pas aux données numériques ou temporelles. C'est également votre hook de validation : levez uneFilterValidationErrorpour les valeurs qui s'analysent mais restent inacceptables, comme une entrée hors plage ou mal formée.apply(ctx)est la seule méthode abstraite. Elle reçoit unFilterApplyContextcontenantquery,field_name,value,value2,requestetview, et renvoie un fragment de requête pour votre backend.
Comment les valeurs brutes des URL sont analysées
Chaque paramètre d'URL est une chaîne, donc price__gt=50 et created_at__eq=2026-01-01 arrivent tous deux sous forme de texte brut. Avant l'exécution de apply(), parse_value() convertit cette chaîne en un objet Python correspondant au data_type du filtre :
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)
Ainsi, ?filter=price__gt=50 et ?filter=price__gt=50.5 atteignent GreaterThanFilter.apply() sous forme de nombres Python (50 en int, 50.5 en float) plutôt que sous forme des chaînes "50" et "50.5". apply() transmet cette valeur analysée directement à l'objet de requête, et le driver de base de données gère la conversion finale vers le type réel de la colonne, tel que Decimal ou Numeric.
data_type |
Exemple de valeur brute dans l'URL | Valeur Python analysée | Analysé par |
|---|---|---|---|
number |
50, -3, 50.5 |
int(50), int(-3), float(50.5) |
filters.numeric._parse_number (essaie int(), sinon bascule vers float()) |
date |
2026-01-01 |
date(2026, 1, 1) |
filters.date._parse_temporal avec date.fromisoformat() |
datetime |
2026-01-01T14:30:00 |
datetime(2026, 1, 1, 14, 30) |
filters.date._parse_temporal avec datetime.fromisoformat() |
time |
14:30:00 |
time(14, 30) |
filters.date._parse_temporal avec time.fromisoformat() |
array |
ACTIVE,OUT_OF_STOCK |
["ACTIVE", "OUT_OF_STOCK"] |
filters.array._parse_array (sépare sur les virgules non entre guillemets) |
string, enum |
admin |
"admin" |
Valeur par défaut de BaseFilter.parse_value (transmise telle quelle) |
none |
(aucune valeur dans l'URL) | (jamais appelé) | N/A |
Lorsqu'une valeur ne peut être analysée, comme price__gt=abc ou created_at__eq=not-a-date, parse_value() lève une FilterValidationError. Le gestionnaire de requête la capture et renvoie HTTP 400 avant d'exécuter toute requête en base de données :
GET /admin/product/list?filter=price__gt=abc
Returns: 400 Bad Request: Invalid 'filter' parameter: 'abc' is not a valid number
Les filtres sans valeur, ceux dont data_type=none tels que is_null, is_true ou in_past, ignorent cette étape. parse_value n'est jamais exécuté pour eux, c'est pourquoi field__is_null ne nécessite aucun =valeur dans l'URL : il n'y a aucune chaîne d'entrée à convertir.
Rendre un filtre personnalisé disponible
Vous pouvez enregistrer un filtre personnalisé auprès d'une vue de deux manières. Choisissez celle qui correspond au périmètre souhaité.
Par instance de champ (périmètre restreint)
Passez le filtre dans la liste filters= du champ ciblé, soit à côté des valeurs par défaut, soit à leur place. Consultez Overriding filters for a specific field pour le même schéma avec des filtres intégrés. Utilisez cette approche lorsque le filtre n'a de sens que pour un seul champ.
À l'échelle du registre (tous les types de champs correspondants)
Chaque backend fournit une sous-classe de FilterRegistry : SqlaFilterRegistry pour SQLAlchemy, BeanieFilterRegistry pour Beanie, MongoEngineFilterRegistry pour MongoEngine et TortoiseFilterRegistry pour Tortoise ORM. Chacune définit les filtres par défaut d'un type de champ pris en charge dans une méthode décorée avec @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
Pour modifier les filtres disponibles pour un type de champ sur l'ensemble d'une vue, sous-classez le registre du backend, surchargez ou ajoutez une méthode @filters, puis renvoyez une instance de votre sous-classe depuis 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()
Déclarez ces méthodes de deux façons possibles, selon que vous souhaitez remplacer les filtres existants ou les étendre :
- Remplacement : redéclarez
@filters(StringField)dans votre sous-classe et renvoyez exactement les classes souhaitées. Cela remplace la liste du parent ; incluez donc tout filtre intégré que vous souhaitez conserver. - Extension : déclarez
@filters(IntegerField)lorsque le registre parent n'enregistre que le type plus généralNumberField. CommeIntegerFieldest une sous-classe deNumberField, l'ordre de résolution des méthodes (MRO) associeIntegerFieldà votre nouvelle méthode, tandis queDecimalField, autre sous-classe deNumberFieldsans enregistrement propre, continue d'hériter dunumeric_filtersparent sans modification.
Il s'agit d'une simple sous-classe Python : elle ne modifie aucun état global. Chaque appel à ProductFilterRegistry() construit un registre indépendant, et vos modifications restent limitées aux vues qui le renvoient. Toutes les autres vues conservent les valeurs par défaut du backend.
Exemple complet avec SQLAlchemy
Le DivisibleByFilter ci-dessous prend une valeur : le diviseur à appliquer à la colonne. Une sous-classe de SqlaFilterRegistry l'applique à chaque IntegerField de ProductView, plutôt que de l'attacher à des champs individuels :
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)
Consultez examples/02-filters pour une application exécutable contenant une sous-classe personnalisée de BaseFilter enregistrée de la même manière.
L'option lot_size__divisible_by apparaît désormais comme un filtre pour IntegerField("lot_size"), sans déclaration explicite de filters= sur le champ. Par exemple, lot_size__divisible_by=6 sélectionne les produits dont la taille de lot est un multiple de 6 :
Tip
Utilisez une sous-classe de FilterRegistry lorsqu'un filtre est suffisamment générique pour s'appliquer à chaque champ d'un type donné dans une vue. Utilisez la liste filters= par champ lorsque la logique se rapporte à un seul champ. Le guide des filtres propose des exemples du schéma par champ.
Choix dynamiques avec get_choices
Par défaut, le champ de saisie de valeur d'un filtre suit son data_type : une zone de texte simple pour STRING, une zone numérique pour NUMBER, etc. Surchargez get_choices(request) lorsque la valeur doit provenir d'une liste déroulante alimentée par une liste de paires (value, label) spécifique à chaque requête. Un filtre « is one of » sur un champ de relation est le cas typique : la valeur renvoyée est une clé étrangère, mais le sélecteur doit afficher un nom lisible.
get_choices reçoit la Request courante et renvoie une séquence de paires (value, label), ou None (la valeur par défaut) pour conserver la saisie simple. Un résultat non vide prime à la fois sur la saisie simple et sur tout choix fourni par le champ lui-même, comme le fait EnumField.
L'exemple ci-dessous, tiré de examples/advanced/07-hr, ajoute une paire « is one of » / « is not one of » au champ department de la liste Employee. Comme department est une RelationField, le registre par défaut ne lui offre que des vérifications de nullité : il n'existe aucun moyen générique de comparer une ligne liée à une chaîne brute. get_choices liste chaque Department par nom pour la liste déroulante, et parse_value convertit les valeurs renvoyées en entiers afin que apply puisse faire correspondre directement la clé étrangère Department.id au lieu de passer par la relation et de comparer des noms :
# 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)
Quelques points à retenir concernant ce schéma :
- Le mixin précède la classe de base du filtre dans le MRO.
_DepartmentChoicesMixinapparaît en premier dansclass DepartmentInFilter(_DepartmentChoicesMixin, InFilter), si bien que ses méthodesget_choicesetparse_valuesurchargent celles que chaque filtre hériterait sinon.super().parse_value(raw)atteint toujoursInFilter.parse_value, qui scinde la valeur brute en liste avant que le mixin ne la convertisse en entiers. get_choicess'exécute à chaque requête, et non une seule fois à l'importation, de sorte que la liste déroulante reflète toujours les lignes actuelles. UnDepartmentnouvellement ajouté apparaît immédiatement dans le générateur de filtres, sans redémarrage du serveur ni cache à invalider.- Les paires
(value, label)et le type de sortie deparse_valuedoivent concorder. La liste déroulante renvoie lavaluechoisie par l'utilisateur ;parse_valuela convertit donc dans ce qu'attendapply. Ici,Department.idest déjà unint, leparse_valuedu mixin réaffirme donc ce type et lève une erreur de validation dans tout autre cas. InFilteretNotInFilterutilisent déjàdata_type = FilterDataType.ENUMpar défaut, une sélection multiple : aucune des deux sous-classes n'a besoin de surchargerdata_type. Surchargerget_choicessuffit pour alimenter cette sélection multiple avec les départements au lieu de la laisser vide.
Et ensuite
- Filtres : découvrez les filtres par défaut par type de champ, le format des URL et la surcharge via
filters=. - SQLAlchemy : explorez le backend SQLAlchemy utilisé dans l'exemple de cette page.
- Points d'extension : consultez la liste complète des méthodes que vous pouvez surcharger sur
ModelView.