diff --git a/CHANGELOG.md b/CHANGELOG.md index 68e6418..e6d64be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Robust Django-style filtering system + ([#52](https://github.com/borhanst/fastapi-admin-kit/issues/52)): + - New lookup types for the JSON API and admin UI list views: + `icontains`, `startswith`, `endswith`, `gt`, `gte`, `lt`, `lte`, + `range`, and `in` — in addition to exact matches. + - New `ChoiceFilter` for relation/FK fields (auto-detected in + `FilterRegistry.auto_generate()`), plus `IntegerFilter` with full + numeric lookups. + - Admin UI now renders text-input filters (case-insensitive contains) + and min/max inputs for numeric fields. + - Filters are ORM-agnostic: `QueryBackend` gained an `and_` combinator + and the in-memory backend now handles `%`-anchored `ilike` patterns. +- `Admin.setup()` now logs a non-blocking preflight warning naming any missing + `admin_ai_*` tables (with the command to fix them) when `ai_enabled=True` + but the schema is absent (Alembic / `SKIP_CREATE_TABLES=true` mode). +- `fastapi_admin_kit.schemas.builtin.AI_TABLE_NAMES` and + `INTERNAL_TABLE_NAMES` constants for gating/identification. +- `AdminRegistry.auto_discover(exclude_tables=...)` accepts a set of table + names to skip during discovery. + ### Changed - **AI is now gated behind `ai_enabled` (default `False`).** When AI is disabled, the `admin_ai_*` tables are not created, and the AI models, nav @@ -17,15 +38,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 JSON API (previously a latent security hole). They remain hidden from the sidebar. -### Added -- `Admin.setup()` now logs a non-blocking preflight warning naming any missing - `admin_ai_*` tables (with the command to fix them) when `ai_enabled=True` - but the schema is absent (Alembic / `SKIP_CREATE_TABLES=true` mode). -- `fastapi_admin_kit.schemas.builtin.AI_TABLE_NAMES` and - `INTERNAL_TABLE_NAMES` constants for gating/identification. -- `AdminRegistry.auto_discover(exclude_tables=...)` accepts a set of table - names to skip during discovery. - ## [0.3.2] - 2026-07-31 ### Changed diff --git a/docs/guide/features.md b/docs/guide/features.md index 7981ff6..32099c1 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -263,11 +263,14 @@ Everything FastAPI Admin Kit offers, in one place. | Feature | Description | Link | |---------|-------------|------| -| TextFilter | Text/substring matching | [Filters](filters.md) | +| TextFilter | Text matching with exact/icontains/startswith/endswith | [Filters](filters.md) | +| ChoiceFilter | FK/relation select-widget filter (auto-detected) | [Filters](filters.md) | | BooleanFilter | True/false toggle | [Filters](filters.md) | -| RelationFilter | Filter by related model | [Filters](filters.md) | +| IntegerFilter / NumericFilter | gt/gte/lt/lte/range/in numeric lookups | [Filters](filters.md) | +| DateRangeFilter / DatetimeRangeFilter | Temporal range + comparison lookups | [Filters](filters.md) | | EnumFilter | Filter by enum choices | [Filters](filters.md) | | Filter Registry | Register custom filter types | [Filters](filters.md) | +| Django-style lookups | `field__icontains`, `__gte`, `__range`, `__in`, ... | [Filters](filters.md) | --- diff --git a/docs/guide/filters.md b/docs/guide/filters.md index d1ada6d..d0370de 100644 --- a/docs/guide/filters.md +++ b/docs/guide/filters.md @@ -1,17 +1,20 @@ # Filters -Filter list views with sidebar filters for text, boolean, relation, and enum fields. +Filter list views with a robust, Django-style filtering system for text, boolean, +relation, and enum fields — in both the admin UI and the JSON API. ## Overview -Filters appear in the sidebar of list views, allowing users to narrow down results. FastAPI Admin Kit includes five built-in filter types (including auto-generated RelationFilter for foreign keys) and a registry for custom filters. - +FastAPI Admin Kit includes a set of built-in filter classes (auto-detected from +your model columns) plus a registry for custom filters. Filters are ORM-agnostic: +they build clauses through the `QueryBackend` protocol and work with both the +SQLAlchemy and the dependency-free in-memory backends. ## Built-in Filters ### TextFilter -Text/substring matching: +Text matching with `exact`, `icontains`, `startswith` and `endswith` lookups: ```python from fastapi_admin_kit.filters import TextFilter @@ -21,24 +24,25 @@ class ProductAdmin(ModelAdmin): list_filter = ["name", "description"] ``` -### BooleanFilter +### ChoiceFilter -True/false toggle: +Filter by a related/foreign-key field, rendered as a select widget. Auto-detected +for FK and ManyToMany columns: ```python @admin.register(Product) class ProductAdmin(ModelAdmin): - list_filter = ["is_active", "is_featured"] + list_filter = ["category", "brand"] ``` -### RelationFilter +### BooleanFilter -Filter by related model: +True/false toggle: ```python @admin.register(Product) class ProductAdmin(ModelAdmin): - list_filter = ["category", "brand"] + list_filter = ["is_active", "is_featured"] ``` ### EnumFilter @@ -56,76 +60,112 @@ class ProductAdmin(ModelAdmin): list_filter = ["status"] ``` -## Configuration +### IntegerFilter / NumericFilter -### Basic Usage +Numeric filters with `gt`, `gte`, `lt`, `lte`, `range` and `in` lookups: ```python @admin.register(Product) class ProductAdmin(ModelAdmin): - list_filter = ["is_active", "category", "status"] + list_filter = ["price", "stock"] ``` -### Foreign Key Auto-Filtering +### DateRangeFilter / DatetimeRangeFilter / TimeFilter -**New Feature** — Foreign key fields automatically include RelationFilter: +Temporal filters with `exact`, `gt`/`gte`/`lt`/`lte`, `range`, `in` and legacy +`from`/`to` lookups: ```python @admin.register(Product) class ProductAdmin(ModelAdmin): - # Automatically includes RelationFilter for 'category' and 'brand' FK - list_filter = ["is_active", "category", "brand"] - # category and brand will also appear as RelationFilter in the UI + list_filter = ["created_at", "published_on"] ``` -### Horizontal Layout +## Registry Auto-Detection + +`FilterRegistry.auto_generate()` maps column types to filter classes: + +| Column type | Filter class | +| ---------------------- | ---------------------- | +| FK / ManyToMany | `ChoiceFilter` | +| Boolean | `BooleanFilter` | +| DateTime / Timestamp | `DatetimeRangeFilter` | +| Date | `DateRangeFilter` | +| Time | `TimeFilter` | +| Integer / Float / ... | `NumericFilter` | +| Enum | `EnumFilter` | +| otherwise | `TextFilter` | -Display filters horizontally instead of vertically: +## Custom Filters + +Register custom filter types per model via `FilterRegistry.register()`: ```python -@admin.register(Product) -class ProductAdmin(ModelAdmin): - list_filter = ["is_active", "category"] - list_filter_horizontal = True -``` +from fastapi_admin_kit.filters import FilterRegistry, NumericFilter -### Per-Filter Options +class RoundedPriceFilter(NumericFilter): + def apply(self, query_adapter, query, model, value): + clause = super().apply(query_adapter, query, model, value) + if clause is None: + return None + return query_adapter.and_(clause, model.price > 0) -Customize individual filter UI: +FilterRegistry().register("product", RoundedPriceFilter("price")) +``` + +Custom filter instances can also be placed directly in `list_filter`: ```python +from fastapi_admin_kit.filters import IntegerFilter + @admin.register(Product) class ProductAdmin(ModelAdmin): - list_filter = ["is_active", "category"] - list_filter_options = { - "is_active": {"label": "Active Only"}, - "category": {"label": "Product Category"}, - } + list_filter = ["name", IntegerFilter("price", label="Price")] ``` -## Filter Registry +## Query Parameter Lookups -Register custom filter types globally: +Filters are applied as query parameters in both the admin UI list view and the +JSON API. Lookups follow the `django-filter` convention (`filter___`): -```python -from fastapi_admin_kit.filters import FilterRegistry +``` +filter_name=value exact match +filter_name__icontains=term case-insensitive contains +filter_name__startswith=Jo starts with +filter_name__endswith=hn ends with +filter_price__gt=100 greater than +filter_price__gte=100 greater than or equal +filter_price__lt=50 less than +filter_price__lte=200 less than or equal +filter_price__range=10,200 range (inclusive) +filter_id__in=1,2,3 in list +filter_is_active=1 boolean (1/true/yes, 0/false/no) +filter_category=1 relation exact match +``` -class DateRangeFilter(Filter): - """Custom date range filter""" - ... +Examples: -FilterRegistry.register("date_range", DateRangeFilter) +``` +/admin/products/?filter_name__icontains=phone&filter_price__gte=100 +/api/products/?filter_category=2&filter_price__range=10,200 ``` -## Query Behavior +Multiple filters are AND'd together. Range values are comma-separated pairs; +`in` values are comma-separated lists. -Filters are applied as query parameters: +## Per-Filter UI Options -``` -/admin/products/?is_active=true&category=electronics -``` +Customize individual filter UI: -Multiple filters are AND'd together. +```python +@admin.register(Product) +class ProductAdmin(ModelAdmin): + list_filter = ["is_active", "category"] + list_filter_options = { + "is_active": {"label": "Active Only"}, + "category": {"label": "Product Category"}, + } +``` ## Next Steps diff --git a/fastapi_admin_kit/backends/memory.py b/fastapi_admin_kit/backends/memory.py index 1868241..f8752b8 100644 --- a/fastapi_admin_kit/backends/memory.py +++ b/fastapi_admin_kit/backends/memory.py @@ -133,8 +133,18 @@ def _matches(record: dict, expr: Any) -> bool: if expr.op == "ilike": if left is None: return False - pat = expr.value.lower().strip("%") - return pat in str(left).lower() + pattern = str(expr.value).lower() + hay = str(left).lower() + starts = pattern.startswith("%") + ends = pattern.endswith("%") + core = pattern.strip("%") + if starts and ends: + return core in hay + if starts: + return hay.endswith(core) + if ends: + return hay.startswith(core) + return hay == core return False if isinstance(expr, MemBool): results = [_matches(record, e) for e in expr.exprs] diff --git a/fastapi_admin_kit/backends/protocols.py b/fastapi_admin_kit/backends/protocols.py index 4e30346..2486a89 100644 --- a/fastapi_admin_kit/backends/protocols.py +++ b/fastapi_admin_kit/backends/protocols.py @@ -198,6 +198,10 @@ def or_(self, *clauses: Any) -> Any: """Compose multiple boolean clauses with OR.""" ... + def and_(self, *clauses: Any) -> Any: + """Compose multiple boolean clauses with AND.""" + ... + @runtime_checkable class AuditBackend(Protocol): diff --git a/fastapi_admin_kit/backends/sqlalchemy.py b/fastapi_admin_kit/backends/sqlalchemy.py index a599b87..0351d6d 100644 --- a/fastapi_admin_kit/backends/sqlalchemy.py +++ b/fastapi_admin_kit/backends/sqlalchemy.py @@ -491,6 +491,12 @@ def or_(self, *clauses: Any) -> Any: return or_(*clauses) + def and_(self, *clauses: Any) -> Any: + """Compose multiple boolean clauses with AND.""" + from sqlalchemy import and_ + + return and_(*clauses) + # --------------------------------------------------------------------------- # #29 — Audit Backend diff --git a/fastapi_admin_kit/filters/__init__.py b/fastapi_admin_kit/filters/__init__.py index fc874cd..8f578bc 100644 --- a/fastapi_admin_kit/filters/__init__.py +++ b/fastapi_admin_kit/filters/__init__.py @@ -5,6 +5,7 @@ from fastapi_admin_kit.filters.base import ( AutocompleteFilter, BooleanFilter, + ChoiceFilter, DateRangeFilter, DatetimeRangeFilter, EnumFilter, @@ -15,11 +16,13 @@ TextFilter, TimeFilter, ) +from fastapi_admin_kit.filters.lookups import parse_filter_params from fastapi_admin_kit.filters.registry import FilterRegistry __all__ = [ "Filter", "TextFilter", + "ChoiceFilter", "BooleanFilter", "RelationFilter", "EnumFilter", @@ -30,4 +33,5 @@ "TimeFilter", "AutocompleteFilter", "FilterRegistry", + "parse_filter_params", ] diff --git a/fastapi_admin_kit/filters/base.py b/fastapi_admin_kit/filters/base.py index 3d89cc2..485fb1b 100644 --- a/fastapi_admin_kit/filters/base.py +++ b/fastapi_admin_kit/filters/base.py @@ -5,14 +5,27 @@ 2. Convert values from query-string strings to Python types. 3. Provide static choices for template rendering. +Values follow the Django ``field__lookup`` convention. They arrive as a +plain string for exact matches or a dict keyed by lookup name (``icontains``, +``startswith``, ``endswith``, ``gt``, ``gte``, ``lt``, ``lte``, ``range``, +``in``, ``from``, ``to``). See :mod:`fastapi_admin_kit.filters.lookups`. + Type detection lives in FilterRegistry.auto_generate(), not here. """ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Callable from typing import Any +from fastapi_admin_kit.filters.lookups import COMPARISON_LOOKUPS, TEXT_LOOKUPS + + +def _split_csv(value: str) -> list[str]: + """Split a comma-separated list value, ignoring empty segments.""" + return [part.strip() for part in value.split(",") if part.strip()] + class Filter(ABC): """Abstract base class for list view filters.""" @@ -28,14 +41,15 @@ def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: """Build a WHERE clause for this filter. Args: - query_adapter: A QueryBackend adapter instance. + query_adapter: A QueryBackend adapter instance (may be None). query: The current query statement (may be None when collecting clauses). model: The ORM model the query selects. - value: The filter value — a plain string for equality - filters, or a dict for range filters. + value: A plain string for an exact match, or a dict keyed by + lookup name for Django-style lookups. Returns: - A SQLAlchemy BinaryExpression condition, or None to skip. + A boolean clause (SQLAlchemy BinaryExpression, MemExpr, ...) or + None to skip the filter. """ ... @@ -48,43 +62,180 @@ def get_choices(self, session: Any = None) -> list[tuple[str, str]]: """ return [("", "All")] + # ------------------------------------------------------------------ + # Shared helpers — kept backend-agnostic + # ------------------------------------------------------------------ + + @staticmethod + def _column(model: Any, field_name: str) -> Any | None: + """Return the model column descriptor or None if absent.""" + if not hasattr(model, field_name): + return None + return getattr(model, field_name) + + @staticmethod + def _coerce(value: str, converter: Callable[[str], Any]) -> Any | None: + """Convert a query-string value, returning None on parse failure.""" + try: + return converter(value) + except (ValueError, TypeError): + return None + + @staticmethod + def _combine(conditions: list, query_adapter: Any = None) -> Any | None: + """Combine zero or more conditions into a single AND clause.""" + if not conditions: + return None + if len(conditions) == 1: + return conditions[0] + if query_adapter is not None and hasattr(query_adapter, "and_"): + return query_adapter.and_(*conditions) + from sqlalchemy import and_ + + return and_(*conditions) + + def _comparison_lookups( + self, + col: Any, + value: dict[str, str], + converter: Callable[[str], Any] | None = None, + query_adapter: Any = None, + ) -> list: + """Build gt/gte/lt/lte/range/in conditions from a lookup dict.""" + conditions: list = [] + + ops = { + "gt": col.__gt__, + "gte": col.__ge__, + "lt": col.__lt__, + "lte": col.__le__, + } + for lookup in COMPARISON_LOOKUPS: + raw = value.get(lookup) + if not raw: + continue + converted = self._coerce(raw, converter) if converter else raw + if converted is not None: + conditions.append(ops[lookup](converted)) + + raw_range = value.get("range") + if raw_range: + parts = _split_csv(raw_range) + if len(parts) == 2: + lo = self._coerce(parts[0], converter) if converter else parts[0] + hi = self._coerce(parts[1], converter) if converter else parts[1] + if lo is not None and hi is not None: + conditions.append(col >= lo) + conditions.append(col <= hi) + + raw_in = value.get("in") + if raw_in: + items: list = [] + for part in _split_csv(raw_in): + converted = self._coerce(part, converter) if converter else part + if converted is not None: + items.append(converted) + if items: + conditions.append(col.in_(items)) + + return conditions + class TextFilter(Filter): - """Simple text equality filter.""" + """Text filter — exact match plus icontains/startswith/endswith lookups.""" field_type = "text" def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: - if isinstance(value, dict): - value = value.get("eq", "") - if not value or not hasattr(model, self.field_name): + col = self._column(model, self.field_name) + if col is None: return None - col = getattr(model, self.field_name) - return col == value + if isinstance(value, dict): + conditions: list = [] + exact = value.get("exact", "") + if exact: + conditions.append(col == exact) + + patterns = { + "icontains": lambda v: f"%{v}%", + "startswith": lambda v: f"{v}%", + "endswith": lambda v: f"%{v}", + } + for lookup in TEXT_LOOKUPS: + raw = value.get(lookup) + if not raw: + continue + if query_adapter is not None: + conditions.append(query_adapter.ilike(col, patterns[lookup](raw))) + else: + conditions.append(col.ilike(patterns[lookup](raw))) + + return self._combine(conditions, query_adapter) + + if value: + return col == value + return None + + +class ChoiceFilter(Filter): + """Choice filter for relation/foreign-key/enum fields rendered as a select. + + Supports exact match and ``in`` list lookups. When no static choices are + provided the list-view pipeline builds dynamic choices (distinct values or + related rows) via :meth:`get_choices`. + """ -class BooleanFilter(Filter): - """Boolean filter — maps '1' to True, '0' to False.""" + field_type = "relation" - field_type = "boolean" + def __init__( + self, + field_name: str, + label: str = "", + resolved_column: str | None = None, + choices: list[str] | None = None, + ) -> None: + super().__init__(field_name, label) + self.resolved_column = resolved_column + self._choices = list(choices or []) def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: - if isinstance(value, dict): - value = value.get("eq", "") - if not value or not hasattr(model, self.field_name): + col_name = self.resolved_column or self.field_name + col = self._column(model, col_name) + if col is None: return None - col = getattr(model, self.field_name) - return col == (value == "1") + + if isinstance(value, dict): + conditions: list = [] + exact = value.get("exact", "") + if exact: + conditions.append(col == exact) + raw_in = value.get("in") + if raw_in: + items = _split_csv(raw_in) + if items: + conditions.append(col.in_(items)) + return self._combine(conditions, query_adapter) + + if value: + return col == value + return None def get_choices(self, session: Any = None) -> list[tuple[str, str]]: - return [("", "All"), ("1", "Yes"), ("0", "No")] + if not self._choices: + return [("", "All")] + choices: list[tuple[str, str]] = [("", "All")] + for val in self._choices: + label = val.replace("_", " ").title() if isinstance(val, str) else str(val) + choices.append((str(val), label)) + return choices -class RelationFilter(Filter): - """Filter by foreign key relationship. +class RelationFilter(ChoiceFilter): + """Filter by foreign key relationship (backwards-compatible alias). - The resolved FK column name is set by FilterRegistry so that - filtering goes through the FK, not the ORM relationship object. + The resolved FK column name is set by FilterRegistry so filtering goes + through the FK, not the ORM relationship object. """ field_type = "relation" @@ -95,23 +246,34 @@ def __init__( label: str = "", resolved_column: str | None = None, ) -> None: - super().__init__(field_name, label) - self.resolved_column = resolved_column + super().__init__(field_name, label, resolved_column=resolved_column) + + +class BooleanFilter(Filter): + """Boolean filter — maps '1'/'true' to True, '0'/'false' to False.""" + + field_type = "boolean" def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: - if isinstance(value, dict): - value = value.get("eq", "") - if not value: + col = self._column(model, self.field_name) + if col is None: return None - col_name = self.resolved_column or self.field_name - if not hasattr(model, col_name): + + raw = value.get("exact") if isinstance(value, dict) else value + if not raw: return None - col = getattr(model, col_name) - return col == value + if raw.lower() in ("1", "true", "yes"): + return col == True # noqa: E712 + if raw.lower() in ("0", "false", "no"): + return col == False # noqa: E712 + return None + + def get_choices(self, session: Any = None) -> list[tuple[str, str]]: + return [("", "All"), ("1", "Yes"), ("0", "No")] class EnumFilter(Filter): - """Filter for enum columns.""" + """Filter for enum columns with static choices.""" field_type = "enum" @@ -125,12 +287,25 @@ def __init__( self._enum_choices = choices or [] def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: - if isinstance(value, dict): - value = value.get("eq", "") - if not value or not hasattr(model, self.field_name): + col = self._column(model, self.field_name) + if col is None: return None - col = getattr(model, self.field_name) - return col == value + + if isinstance(value, dict): + conditions: list = [] + exact = value.get("exact", "") + if exact: + conditions.append(col == exact) + raw_in = value.get("in") + if raw_in: + items = _split_csv(raw_in) + if items: + conditions.append(col.in_(items)) + return self._combine(conditions, query_adapter) + + if value: + return col == value + return None def get_choices(self, session: Any = None) -> list[tuple[str, str]]: choices: list[tuple[str, str]] = [("", "All")] @@ -140,7 +315,7 @@ def get_choices(self, session: Any = None) -> list[tuple[str, str]]: class IntegerFilter(Filter): - """Integer equality filter — converts value to int.""" + """Integer filter — exact plus gt/gte/lt/lte/range/in lookups.""" field_type = "integer" @@ -154,162 +329,178 @@ def __init__( self.resolved_column = resolved_column def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: - if isinstance(value, dict): - value = value.get("eq", "") - if not value: - return None col_name = self.resolved_column or self.field_name - if not hasattr(model, col_name): + col = self._column(model, col_name) + if col is None: return None - try: - int_value = int(value) - except (ValueError, TypeError): - return None - col = getattr(model, col_name) - return col == int_value + if isinstance(value, dict): + conditions: list = [] + exact = value.get("exact", "") + if exact: + converted = self._coerce(exact, int) + if converted is not None: + conditions.append(col == converted) + conditions.extend(self._comparison_lookups(col, value, int, query_adapter)) + return self._combine(conditions, query_adapter) + + if value: + converted = self._coerce(value, int) + if converted is None: + return None + return col == converted + return None -class NumericFilter(Filter): - """Numeric range filter (gte/lte). - Value is a dict with optional 'gte' and 'lte' keys. - Also accepts a plain string for equality. - """ +class NumericFilter(Filter): + """Numeric range filter — exact plus gt/gte/lt/lte/range/in lookups.""" field_type = "numeric" - def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: - if not hasattr(model, self.field_name): + @staticmethod + def _to_number(value: str) -> float | None: + try: + return float(value) + except (ValueError, TypeError): return None - col = getattr(model, self.field_name) - conditions: list = [] - if isinstance(value, dict): - gte = value.get("gte", "") - lte = value.get("lte", "") - if gte: - try: - conditions.append(col >= type(col.type)().coerce(gte)) - except Exception: - pass - if lte: - try: - conditions.append(col <= type(col.type)().coerce(lte)) - except Exception: - pass - elif value: - try: - conditions.append(col == type(col.type)().coerce(value)) - except Exception: - pass - if not conditions: + + def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: + col = self._column(model, self.field_name) + if col is None: return None - if len(conditions) == 1: - return conditions[0] - from sqlalchemy import and_ - return and_(*conditions) + if isinstance(value, dict): + conditions: list = [] + exact = value.get("exact", "") + if exact: + converted = self._to_number(exact) + if converted is not None: + conditions.append(col == converted) + conditions.extend(self._comparison_lookups(col, value, self._to_number, query_adapter)) + return self._combine(conditions, query_adapter) + + if value: + converted = self._to_number(value) + if converted is None: + return None + return col == converted + return None class DateRangeFilter(Filter): - """Date range filter (from/to). - - Value is a dict with optional 'from' and 'to' keys. - Also accepts a plain string for equality. - """ + """Date range filter — exact plus gt/gte/lt/lte/range/in/from/to lookups.""" field_type = "date" def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: from datetime import date - if not hasattr(model, self.field_name): + col = self._column(model, self.field_name) + if col is None: return None - col = getattr(model, self.field_name) - conditions: list = [] - if isinstance(value, dict): - if value.get("from"): - try: - conditions.append(col >= date.fromisoformat(value["from"])) - except (ValueError, TypeError): - pass - if value.get("to"): - try: - conditions.append(col <= date.fromisoformat(value["to"])) - except (ValueError, TypeError): - pass - elif value: - try: - conditions.append(col == date.fromisoformat(value)) - except (ValueError, TypeError): - pass - if not conditions: - return None - if len(conditions) == 1: - return conditions[0] - from sqlalchemy import and_ - return and_(*conditions) + if isinstance(value, dict): + conditions: list = [] + exact = value.get("exact", "") + if exact: + converted = self._coerce(exact, date.fromisoformat) + if converted is not None: + conditions.append(col == converted) + conditions.extend( + self._comparison_lookups(col, value, date.fromisoformat, query_adapter) + ) + from_ = value.get("from", "") + to_ = value.get("to", "") + if from_: + d = self._coerce(from_, date.fromisoformat) + if d is not None: + conditions.append(col >= d) + if to_: + d = self._coerce(to_, date.fromisoformat) + if d is not None: + conditions.append(col <= d) + return self._combine(conditions, query_adapter) + + if value: + converted = self._coerce(value, date.fromisoformat) + if converted is None: + return None + return col == converted + return None class DatetimeRangeFilter(Filter): - """Datetime range filter (from/to). - - Value is a dict with optional 'from' and 'to' keys. - Also accepts a plain string for equality. - """ + """Datetime range filter — exact plus gt/gte/lt/lte/range/in/from/to lookups.""" field_type = "datetime" def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: from datetime import datetime - if not hasattr(model, self.field_name): - return None - col = getattr(model, self.field_name) - conditions: list = [] - if isinstance(value, dict): - if value.get("from"): - try: - conditions.append(col >= datetime.fromisoformat(value["from"])) - except (ValueError, TypeError): - pass - if value.get("to"): - try: - conditions.append(col <= datetime.fromisoformat(value["to"])) - except (ValueError, TypeError): - pass - elif value: - try: - conditions.append(col == datetime.fromisoformat(value)) - except (ValueError, TypeError): - pass - if not conditions: + col = self._column(model, self.field_name) + if col is None: return None - if len(conditions) == 1: - return conditions[0] - from sqlalchemy import and_ - return and_(*conditions) + if isinstance(value, dict): + conditions: list = [] + exact = value.get("exact", "") + if exact: + converted = self._coerce(exact, datetime.fromisoformat) + if converted is not None: + conditions.append(col == converted) + conditions.extend( + self._comparison_lookups(col, value, datetime.fromisoformat, query_adapter) + ) + from_ = value.get("from", "") + to_ = value.get("to", "") + if from_: + dt = self._coerce(from_, datetime.fromisoformat) + if dt is not None: + conditions.append(col >= dt) + if to_: + dt = self._coerce(to_, datetime.fromisoformat) + if dt is not None: + conditions.append(col <= dt) + return self._combine(conditions, query_adapter) + + if value: + converted = self._coerce(value, datetime.fromisoformat) + if converted is None: + return None + return col == converted + return None class TimeFilter(Filter): - """Time equality filter.""" + """Time filter — exact plus gt/gte/lt/lte/range/in lookups.""" field_type = "time" def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: from datetime import time - if isinstance(value, dict): - value = value.get("eq", "") - if not value or not hasattr(model, self.field_name): - return None - col = getattr(model, self.field_name) - try: - t = time.fromisoformat(value) - except (ValueError, TypeError): + col = self._column(model, self.field_name) + if col is None: return None - return col == t + + if isinstance(value, dict): + conditions: list = [] + exact = value.get("exact", "") + if exact: + converted = self._coerce(exact, time.fromisoformat) + if converted is not None: + conditions.append(col == converted) + conditions.extend( + self._comparison_lookups(col, value, time.fromisoformat, query_adapter) + ) + return self._combine(conditions, query_adapter) + + if value: + converted = self._coerce(value, time.fromisoformat) + if converted is None: + return None + return col == converted + return None class AutocompleteFilter(Filter): @@ -327,9 +518,10 @@ def __init__( self.search_fields = search_fields or ["name"] def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: - if isinstance(value, dict): - value = value.get("eq", "") - if not value or not hasattr(model, self.field_name): + col = self._column(model, self.field_name) + if col is None: return None - col = getattr(model, self.field_name) - return col == value + raw = value.get("exact") if isinstance(value, dict) else value + if raw: + return col == raw + return None diff --git a/fastapi_admin_kit/filters/lookups.py b/fastapi_admin_kit/filters/lookups.py new file mode 100644 index 0000000..b418555 --- /dev/null +++ b/fastapi_admin_kit/filters/lookups.py @@ -0,0 +1,76 @@ +"""Django-style lookup parsing for filter query parameters. + +The admin accepts filters as ``filter_`` query parameters with the +same conventions as ``django-filter``:: + + ?filter_name=value exact match + ?filter_name__icontains=term case-insensitive contains + ?filter_name__startswith=Jo starts with + ?filter_name__endswith=hn ends with + ?filter_price__gt=100 greater than + ?filter_price__gte=100 greater than or equal + ?filter_price__lt=50 less than + ?filter_price__lte=200 less than or equal + ?filter_price__range=10,200 range (inclusive) + ?filter_id__in=1,2,3 in list + +This module owns the (query params -> value) mapping so the HTML list views +and the JSON API share one source of truth. +""" + +from __future__ import annotations + +from typing import Any + +# (lookup name, query-string suffix). The empty suffix means "exact match". +LOOKUP_SUFFIXES: tuple[tuple[str, str], ...] = ( + ("exact", ""), + ("icontains", "__icontains"), + ("startswith", "__startswith"), + ("endswith", "__endswith"), + ("gt", "__gt"), + ("gte", "__gte"), + ("lt", "__lt"), + ("lte", "__lte"), + ("range", "__range"), + ("in", "__in"), + ("from", "__from"), + ("to", "__to"), +) + +# Lookups grouped by the kind of condition they build. +COMPARISON_LOOKUPS = ("gt", "gte", "lt", "lte") +LIST_LOOKUPS = ("range", "in") +TEXT_LOOKUPS = ("icontains", "startswith", "endswith") + + +def parse_filter_params( + query_params: Any, + field_name: str, +) -> tuple[Any, dict[str, str]]: + """Read filter query params for *field_name*. + + Args: + query_params: Anything with a ``.get(key, default)`` interface + (Starlette ``QueryParams`` or a plain dict). + field_name: The model field being filtered. + + Returns: + A ``(value, active_pairs)`` tuple. ``value`` is a plain string for an + exact match or a dict keyed by lookup name (``{"icontains": "x"}``) + when a lookup-style parameter is present. ``active_pairs`` maps the + display key (e.g. ``"name__icontains"``) to the raw value so the admin + UI can highlight and clear active filters. + """ + parts: dict[str, str] = {} + active: dict[str, str] = {} + for lookup, suffix in LOOKUP_SUFFIXES: + raw = query_params.get(f"filter_{field_name}{suffix}", "") + if raw: + parts[lookup] = raw + active[f"{field_name}{suffix}"] = raw + if not parts: + return None, active + if "exact" in parts and len(parts) == 1: + return parts["exact"], active + return parts, active diff --git a/fastapi_admin_kit/filters/registry.py b/fastapi_admin_kit/filters/registry.py index f30954a..5721483 100644 --- a/fastapi_admin_kit/filters/registry.py +++ b/fastapi_admin_kit/filters/registry.py @@ -10,19 +10,35 @@ from fastapi_admin_kit.filters.base import ( BooleanFilter, + ChoiceFilter, DateRangeFilter, DatetimeRangeFilter, EnumFilter, Filter, NumericFilter, - RelationFilter, TextFilter, TimeFilter, ) +# Lowercased ORM type names — covers both SQLAlchemy class names +# ("Integer", "DECIMAL") and schema backend names ("integer", "float"). _NUMERIC_TYPE_NAMES = frozenset( - {"Integer", "BigInteger", "SmallInteger", "Float", "Numeric", "DECIMAL"} + { + "integer", + "bigint", + "biginteger", + "smallinteger", + "float", + "double", + "real", + "numeric", + "decimal", + } ) +_BOOLEAN_TYPE_NAMES = frozenset({"boolean", "bool"}) +_DATETIME_TYPE_NAMES = frozenset({"datetime", "timestamp"}) +_DATE_TYPE_NAMES = frozenset({"date"}) +_TIME_TYPE_NAMES = frozenset({"time"}) class FilterRegistry: @@ -49,6 +65,17 @@ def auto_generate( ) -> dict[str, Filter]: """Auto-generate filters for a model's columns. + Type detection follows Django conventions: + + - FK/ManyToMany fields → ``ChoiceFilter`` + - Boolean columns → ``BooleanFilter`` + - DateTime → ``DatetimeRangeFilter`` + - Date → ``DateRangeFilter`` + - Time → ``TimeFilter`` + - Numeric types → ``NumericFilter`` + - Enum columns → ``EnumFilter`` + - Otherwise → ``TextFilter`` + Args: model: The ORM model. columns: List of ColumnMeta for the model. @@ -72,36 +99,42 @@ def auto_generate( if field_name in rel_names: resolved_col = self._resolve_fk_column(model, field_name, introspection) - filters[field_name] = RelationFilter(field_name, resolved_column=resolved_col) + filters[field_name] = ChoiceFilter(field_name, resolved_column=resolved_col) continue type_name = self._get_type_name(model, field_name, introspection) + type_name = (type_name or "").lower() col = self._get_column(model, field_name, introspection) - has_enums = col is not None and hasattr(col.type, "enums") and bool(col.type.enums) - has_fk = col is not None and bool(col.foreign_keys) - - if type_name == "Boolean": + has_enums = ( + col is not None + and hasattr(col, "type") + and hasattr(col.type, "enums") + and bool(col.type.enums) + ) + has_fk = col is not None and hasattr(col, "foreign_keys") and bool(col.foreign_keys) + + if type_name in _BOOLEAN_TYPE_NAMES: filters[field_name] = BooleanFilter(field_name) - elif type_name == "DateTime": + elif type_name in _DATETIME_TYPE_NAMES: filters[field_name] = DatetimeRangeFilter(field_name) - elif type_name == "Date": + elif type_name in _DATE_TYPE_NAMES: filters[field_name] = DateRangeFilter(field_name) - elif type_name == "Time": + elif type_name in _TIME_TYPE_NAMES: filters[field_name] = TimeFilter(field_name) + elif has_fk: + resolved_col = self._resolve_fk_column(model, field_name, introspection) + filters[field_name] = ChoiceFilter(field_name, resolved_column=resolved_col) elif type_name in _NUMERIC_TYPE_NAMES: filters[field_name] = NumericFilter(field_name) elif has_enums: filters[field_name] = EnumFilter(field_name, choices=list(col.type.enums)) - elif has_fk: - resolved_col = self._resolve_fk_column(model, field_name, introspection) - filters[field_name] = RelationFilter(field_name, resolved_column=resolved_col) else: filters[field_name] = TextFilter(field_name) for rel_name in rel_names: if rel_name not in filters: resolved_col = self._resolve_fk_column(model, rel_name, introspection) - filters[rel_name] = RelationFilter(rel_name, resolved_column=resolved_col) + filters[rel_name] = ChoiceFilter(rel_name, resolved_column=resolved_col) return filters @@ -146,7 +179,6 @@ def _resolve_fk_column(model: Any, field_name: str, introspection: Any | None) - local_cols = introspection.get_relationship_local_columns(model, field_name) return local_cols[0] if local_cols else None from sqlalchemy import inspect as sa_inspect - from sqlalchemy.orm import RelationshipProperty mapper = sa_inspect(model) for prop in mapper.column_attrs: @@ -156,8 +188,11 @@ def _resolve_fk_column(model: Any, field_name: str, introspection: Any | None) - for fk in col.foreign_keys: return fk.column.key rel = mapper.relationships.get(field_name) - if rel is not None and isinstance(rel.property, RelationshipProperty): - local_cols = list(rel.local_columns) + if rel is not None: + local_cols = list(getattr(rel, "local_columns", ())) + if not local_cols: + prop = getattr(rel, "property", None) + local_cols = list(getattr(prop, "local_columns", ())) if local_cols: return local_cols[0].key return None diff --git a/fastapi_admin_kit/static/css/admin.css b/fastapi_admin_kit/static/css/admin.css index 576caed..089e08a 100644 --- a/fastapi_admin_kit/static/css/admin.css +++ b/fastapi_admin_kit/static/css/admin.css @@ -1194,7 +1194,9 @@ img { max-width: 100%; display: block; } .filter-input-date, .filter-input-datetime, -.filter-input-time { +.filter-input-time, +.filter-input-text, +.filter-input-number { height: 34px; padding: 0 var(--space-2); border: none; @@ -1208,7 +1210,9 @@ img { max-width: 100%; display: block; } .filter-input-date:focus, .filter-input-datetime:focus, -.filter-input-time:focus { +.filter-input-time:focus, +.filter-input-text:focus, +.filter-input-number:focus { outline: none; } @@ -2302,7 +2306,7 @@ img { max-width: 100%; display: block; } background: none; border: none; color: var(--text-disabled); cursor: pointer; font-size: var(--text-lg); } .filter-clear-btn:hover { color: var(--danger-500); } -.filter-input-date, .filter-input-datetime, .filter-input-time { +.filter-input-date, .filter-input-datetime, .filter-input-time, .filter-input-text, .filter-input-number { padding: var(--space-1) var(--space-2); border: 1px solid var(--surface-border); border-radius: var(--radius-md); font-size: var(--text-xs); background: var(--surface-base); } @@ -3434,7 +3438,9 @@ img { max-width: 100%; display: block; } .filter-input-date, .filter-input-datetime, -.filter-input-time { +.filter-input-time, +.filter-input-text, +.filter-input-number { height: 34px; padding: 0 var(--space-2); border: none; diff --git a/fastapi_admin_kit/templates/admin/base_list.html b/fastapi_admin_kit/templates/admin/base_list.html index aeb7dd3..10f78a2 100644 --- a/fastapi_admin_kit/templates/admin/base_list.html +++ b/fastapi_admin_kit/templates/admin/base_list.html @@ -214,8 +214,59 @@

Import Data

{% endif %} + {% elif field_info.field_type == "text" %} +
+ + + {% if active_filters.get(field_name) or active_filters.get(field_name + '__icontains') %} + + {% endif %} +
+ + {% elif field_info.field_type in ("numeric", "integer") %} +
+ + + + {% if active_filters.get(field_name + '__gte') or active_filters.get(field_name + '__lte') %} + + {% endif %} +
+ {% else %} - {# boolean, enum, relation, text — render as dropdown #} + {# boolean, enum, relation — render as dropdown #}