Traducción automática supervisada
Este contenido se traduce mediante generación automática guiada por glosarios y guías de estilo revisados por personas. Dado que el texto no se revisa manualmente línea por línea, pueden producirse errores ocasionales o expresiones poco naturales.
En caso de cualquier discrepancia, la versión en inglés constituye la autoridad y la fuente de referencia.
Filtros personalizados
Cree una subclase de BaseFilter cuando necesite un operador que el conjunto integrado no cubre: una comprobación específica del dominio como "es divisible por", una condición calculada como "creado este mes", o soporte para un tipo de campo que el registro predeterminado omite. Esta página explica cómo funciona un filtro internamente y muestra las dos formas de registrarlo: ya sea creando una subclase del FilterRegistry de su backend para cubrir todos los tipos de campo coincidentes, o pasando el filtro a la lista filters= de un solo campo. Para los detalles del día a día, incluidos los filtros predeterminados por tipo de campo, las anulaciones manuales y el formato de URL, consulte la Guía de filtros.
La interfaz BaseFilter
Cada filtro, ya sea integrado o personalizado, implementa dos métodos:
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)convierte la cadena sin procesar de la URL al tipo que esperaapply(), como unDecimal, undateo una lista. El valor predeterminado pasa la cadena sin cambios, lo cual es adecuado para los filtrosSTRINGyENUM, pero no para datos numéricos o temporales. También es su hook de validación: lanceFilterValidationErrorpara valores que se analizan pero siguen siendo inaceptables, como entradas fuera de rango o malformadas.apply(ctx)es el único método abstracto. Recibe unFilterApplyContextque contienequery,field_name,value,value2,requestyview, y devuelve un fragmento de consulta para su backend.
Cómo se analizan los valores sin procesar de la URL
Cada parámetro de URL es una cadena, por lo que tanto price__gt=50 como created_at__eq=2026-01-01 llegan como texto sin procesar. Antes de que se ejecute apply(), parse_value() convierte esa cadena en un objeto de Python que coincide con el data_type del filtro:
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)
De este modo, ?filter=price__gt=50 y ?filter=price__gt=50.5 llegan a GreaterThanFilter.apply() como números de Python (50 como int, 50.5 como float) en lugar de las cadenas "50" y "50.5". apply() pasa ese valor analizado directamente al objeto de consulta, y el driver de la base de datos realiza la coerción final contra el tipo real de la columna, como Decimal o Numeric.
data_type |
Valor sin procesar de ejemplo en la URL | Valor de Python analizado | Analizado por |
|---|---|---|---|
number |
50, -3, 50.5 |
int(50), int(-3), float(50.5) |
filters.numeric._parse_number (intenta int(), recurre a float()) |
date |
2026-01-01 |
date(2026, 1, 1) |
filters.date._parse_temporal usando date.fromisoformat() |
datetime |
2026-01-01T14:30:00 |
datetime(2026, 1, 1, 14, 30) |
filters.date._parse_temporal usando datetime.fromisoformat() |
time |
14:30:00 |
time(14, 30) |
filters.date._parse_temporal usando time.fromisoformat() |
array |
ACTIVE,OUT_OF_STOCK |
["ACTIVE", "OUT_OF_STOCK"] |
filters.array._parse_array (divide por comas sin comillas) |
string, enum |
admin |
"admin" |
valor predeterminado de BaseFilter.parse_value (se pasa sin cambios) |
none |
(ningún valor en la URL) | (nunca se llama) | N/A |
Cuando un valor no se puede analizar, como price__gt=abc o created_at__eq=not-a-date, parse_value() lanza una excepción FilterValidationError. El manejador de solicitudes la captura y devuelve HTTP 400 antes de ejecutar cualquier consulta a la base de datos:
GET /admin/product/list?filter=price__gt=abc
Returns: 400 Bad Request: Invalid 'filter' parameter: 'abc' is not a valid number
Los filtros sin valor, aquellos con data_type=none como is_null, is_true o in_past, omiten este paso. parse_value nunca se ejecuta para ellos, razón por la cual field__is_null no necesita =valor en la URL: no hay ninguna cadena de entrada que convertir.
Cómo hacer disponible un filtro personalizado
Puede registrar un filtro personalizado en una vista de dos maneras. Elija la que coincida con el alcance que desea.
Por instancia de campo (alcance reducido)
Pase el filtro a la lista filters= del campo objetivo, ya sea junto con los valores predeterminados o en lugar de ellos. Consulte Anular filtros para un campo específico para ver el mismo patrón con filtros integrados. Utilice esta opción cuando el filtro solo tenga sentido para un campo concreto.
En todo el registro (todos los tipos de campo coincidentes)
Cada backend incluye una subclase de FilterRegistry: SqlaFilterRegistry para SQLAlchemy, BeanieFilterRegistry para Beanie, MongoEngineFilterRegistry para MongoEngine y TortoiseFilterRegistry para Tortoise ORM. Cada uno define los filtros predeterminados para un tipo de campo admitido en un método decorado con @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
Para cambiar los filtros disponibles para un tipo de campo en toda una vista, cree una subclase del registro del backend, anule o añada un método @filters, y devuelva una instancia de su subclase desde 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()
Declare estos métodos de una de dos formas, dependiendo de si desea reemplazar los filtros existentes o ampliarlos:
- Anular: Vuelva a declarar
@filters(StringField)en su subclase y devuelva exactamente las clases que desea. Esto reemplaza la lista de la clase padre, así que incluya cualquier filtro integrado que desee conservar. - Ampliar: Declare
@filters(IntegerField)cuando el registro padre solo registre elNumberFieldmás general. ComoIntegerFieldhereda deNumberField, el orden de resolución de métodos (MRO) resuelveIntegerFieldhacia su nuevo método, mientras queDecimalField, otra subclase deNumberFieldsin registro propio, sigue heredando elnumeric_filtersdel padre sin cambios.
Se trata de una subclase de Python común y corriente, por lo que no muta ningún estado global. Cada llamada a ProductFilterRegistry() construye un registro independiente, y sus cambios permanecen limitados a las vistas que lo devuelven. Todas las demás vistas conservan los valores predeterminados del backend.
Ejemplo completo con SQLAlchemy
El DivisibleByFilter que aparece a continuación toma un valor, el divisor contra el cual se comprobará la columna. Una subclase de SqlaFilterRegistry lo aplica a cada IntegerField de ProductView, en lugar de adjuntarlo a campos individuales:
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)
Consulte examples/02-filters para ver una aplicación ejecutable con una subclase de BaseFilter personalizada registrada de la misma manera.
La opción lot_size__divisible_by ahora aparece como un filtro para IntegerField("lot_size"), sin necesidad de una declaración explícita de filters= en el campo. Por ejemplo, lot_size__divisible_by=6 coincide con los productos cuyo tamaño de lote es múltiplo de 6:
Tip
Utilice una subclase de FilterRegistry cuando un filtro sea lo suficientemente genérico como para aplicarse a todos los campos de un tipo determinado en una vista. Utilice la lista filters= por campo cuando la lógica pertenezca únicamente a un campo. La Guía de filtros contiene ejemplos del patrón por campo.
Opciones dinámicas con get_choices
Por defecto, la entrada de valor de un filtro sigue su data_type: un cuadro de texto simple para STRING, un cuadro numérico para NUMBER, y así sucesivamente. Anule get_choices(request) cuando el valor deba provenir de un menú desplegable alimentado con una lista por solicitud de pares (value, label). Un filtro «es uno de» sobre un campo de relación es el caso típico: el valor que se envía de vuelta es una clave foránea, pero el selector debería mostrar un nombre legible.
get_choices recibe el Request actual y devuelve una secuencia de pares (value, label), o None (el valor predeterminado) para mantener la entrada simple. Un resultado no vacío tiene prioridad tanto sobre la entrada simple como sobre cualquier opción que el propio campo proporcione, como hace EnumField.
El siguiente ejemplo, tomado de examples/advanced/07-hr, añade un par «es uno de» e «is not one of» al campo department de la lista Employee. department es un RelationField, por lo que el registro predeterminado solo le asigna comprobaciones de nulos: no existe una forma genérica de comparar una fila relacionada con una cadena sin procesar. get_choices enumera cada Department por nombre para el menú desplegable, y parse_value convierte los valores enviados de vuelta a enteros para que apply pueda buscar directamente por la clave foránea Department.id en lugar de realizar un join a través de la relación y comparar nombres:
# 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)
Algunos aspectos que debe tener en cuenta sobre este patrón:
- El mixin se sitúa antes de la clase base del filtro en el MRO.
_DepartmentChoicesMixinaparece primero enclass DepartmentInFilter(_DepartmentChoicesMixin, InFilter), de modo que sus métodosget_choicesyparse_valueanulan los que cada filtro heredaría de otro modo.super().parse_value(raw)todavía llega aInFilter.parse_value, que divide el valor sin procesar en una lista antes de que el mixin lo convierta a enteros. get_choicesse ejecuta en cada solicitud, no una sola vez en el momento de la importación, por lo que el menú desplegable siempre refleja las filas actuales. UnDepartmentrecién añadido aparece en el constructor de filtros de inmediato, sin reiniciar el servidor ni invalidar cachés.- Los pares
(value, label)y el tipo de salida deparse_valuedeben ser coherentes. El menú desplegable envía de vuelta elvalueque el usuario seleccionó, por lo queparse_valuelo convierte en aquello que esperaapply. AquíDepartment.idya es unint, por lo que elparse_valuedel mixin reafirma ese tipo y lanza un error de validación ante cualquier otra cosa. InFilteryNotInFilterya tienen por defectodata_type = FilterDataType.ENUM, una selección múltiple, por lo que ninguna de las dos subclases necesita anulardata_type. Basta con anularget_choicespara poblar esa selección múltiple con departamentos en lugar de dejarla vacía.
¿Qué sigue?
- Filtros: Conozca los filtros predeterminados por tipo de campo, el formato de URL y la anulación mediante
filters=. - SQLAlchemy: Explore el backend de SQLAlchemy utilizado en el ejemplo de esta página.
- Puntos de extensión: Consulte la lista completa de métodos que puede anular en
ModelView.