Skip to content
Merged
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
30 changes: 21 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

---

Expand Down
132 changes: 86 additions & 46 deletions docs/guide/filters.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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_<field>__<lookup>`):

```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

Expand Down
14 changes: 12 additions & 2 deletions fastapi_admin_kit/backends/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions fastapi_admin_kit/backends/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions fastapi_admin_kit/backends/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions fastapi_admin_kit/filters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi_admin_kit.filters.base import (
AutocompleteFilter,
BooleanFilter,
ChoiceFilter,
DateRangeFilter,
DatetimeRangeFilter,
EnumFilter,
Expand All @@ -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",
Expand All @@ -30,4 +33,5 @@
"TimeFilter",
"AutocompleteFilter",
"FilterRegistry",
"parse_filter_params",
]
Loading
Loading