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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

- feat: add Django-style `SimpleFilter` base class — class-configured filters, pass the class itself in `list_filter`
- feat: list filters configured in `list_filter` are documented as query params on the JSON API list endpoint in the OpenAPI/Swagger schema (lookups limited to the field type's supported lookups)
- change: filter query params dropped the `filter_` prefix — they now use the bare field name and lookup (e.g. `name__icontains=wid`) in both the admin UI and the JSON API
- feat: enum columns map to real enum types in the API create/update schemas so Swagger shows them as dropdowns; models with file/image fields expose create/update as multipart forms with file pickers, saved through the same storage backend as the HTML form
- fix: `SelectWidget` validation accepted only member names, rejecting the stored values of str-enum members

## [0.6.0] - 2026-09-03

- feat: improve form rendering — WYSIWYG and array input widgets ([#59](https://github.com/borhanst/fastapi-admin-kit/pull/59))
Expand Down
35 changes: 21 additions & 14 deletions docs/guide/custom-auth-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import uuid
from typing import Optional

from fastapi import FastAPI
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, relationship
from sqlalchemy.types import Uuid

Expand All @@ -37,11 +37,15 @@ class User(AuthModelMixin, Base):

id = Column(Uuid, primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
name = Column(String(255), nullable=True)

# AuthModelMixin provides the rest of the protocol surface
# (is_active, is_superuser, role_ids, verify_password, etc.).
# Required: AuthModelMixin is behavior-only — add these 4 fields
# with your ORM (SQLAlchemy shown; SQLModel/Tortoise declare the
# same 4 names with their own Field types).
password = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
last_login = Column(DateTime(timezone=True), nullable=True)


app = FastAPI()
Expand Down Expand Up @@ -124,16 +128,19 @@ Your `auth_model` must satisfy `AdminUserProtocol` (validated at
|-----------|------|-------|
| `id` | any | The PK type — `int`, `UUID`, etc. |
| `email` | `str` | Used as the login identifier |
| `is_active` | `bool` | Inactive users cannot log in |
| `is_superuser` | `bool` | Bypasses all RBAC checks |
| `hashed_password` | `str` | bcrypt / argon2 hash |
| `role_ids` | `list[int]` | Property that returns role IDs |
| `roles` | relationship | M2M to `Role` model |
| `verify_password(plain)` | method | Returns `bool` |

`fastapi_admin_kit.auth.mixins.AuthModelMixin` provides all of the
above for SQLAlchemy declarative models — inherit from it to get
`is_active`, `is_superuser`, `role_ids`, and password helpers for free.
| `password` | `str` | Hashed password (bcrypt / argon2), `String(255)`, `nullable=False` recommended |
| `is_active` | `bool` | Inactive users cannot log in (`default=True` recommended) |
| `is_superuser` | `bool` | Bypasses all RBAC checks (`default=False` recommended) |
| `last_login` | `datetime \| None` | Tz-aware, nullable |
| `role_ids` | `list[int]` | Property that returns role IDs (provided by mixin) |
| `roles` | relationship | M2M to `Role` model (optional if you only need `role_ids`) |
| `verify_password(plain)` | method | Returns `bool` (provided by mixin) |

`fastapi_admin_kit.auth.mixins.AuthModelMixin` is behavior-only — it
provides `role_ids`, `verify_password`, `hash_password`, `has_perm`, etc.
You must add the 4 fields (`password`, `is_active`, `is_superuser`,
`last_login`) with your ORM on your model. The same 4 names apply to
SQLAlchemy, SQLModel, Tortoise, and other Python ORMs.

## Troubleshooting

Expand Down
65 changes: 50 additions & 15 deletions docs/guide/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,36 +123,71 @@ class ProductAdmin(ModelAdmin):
list_filter = ["name", IntegerFilter("price", label="Price")]
```

### SimpleFilter

Django `SimpleListFilter` style: declare `parameter_name` + `title` as class
attributes and pass the *class itself* in `list_filter` — no constructor args,
no instance:

```python
from fastapi_admin_kit.filters import SimpleFilter

class InStockFilter(SimpleFilter):
parameter_name = "in_stock"
title = "Stock Status"
field_type = "boolean"

def apply(self, query_adapter, query, model, value):
raw = value.get("exact") if isinstance(value, dict) else value
if raw and raw.lower() in ("1", "true"):
return model.stock > 0
if raw:
return model.stock <= 0
return None

def get_choices(self, session=None):
return [("", "All"), ("1", "In stock"), ("0", "Out of stock")]

@admin.register(Product)
class ProductAdmin(ModelAdmin):
list_filter = [InStockFilter] # class itself, no instantiation
```

## Query Parameter Lookups

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>`):
JSON API. Lookups follow the field-name convention (`<field>__<lookup>`):

```
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
name=value exact match
name__icontains=term case-insensitive contains
name__startswith=Jo starts with
name__endswith=hn ends with
price__gt=100 greater than
price__gte=100 greater than or equal
price__lt=50 less than
price__lte=200 less than or equal
price__range=10,200 range (inclusive)
id__in=1,2,3 in list
is_active=1 boolean (1/true/yes, 0/false/no)
category=1 relation exact match
```

Examples:

```
/admin/products/?filter_name__icontains=phone&filter_price__gte=100
/api/products/?filter_category=2&filter_price__range=10,200
/admin/products/?name__icontains=phone&price__gte=100
/api/products/?category=2&price__range=10,200
```

Multiple filters are AND'd together. Range values are comma-separated pairs;
`in` values are comma-separated lists.

On the JSON API, the list endpoint documents every configured filter as
optional query parameters in the OpenAPI/Swagger schema — add a filter to
`list_filter` and it (plus the lookups its field type supports) shows up in
`/openapi.json` automatically.

## Per-Filter UI Options

Customize individual filter UI:
Expand Down
14 changes: 14 additions & 0 deletions docs/guide/json-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,20 @@ Auto-generated JSON schemas are available for each model:
curl http://localhost:8000/admin/api/schema/products/
```

### Enum fields render as dropdowns

`Enum` columns map to real enum types in the create/update schemas, so
Swagger shows them as dropdowns (both Python-enum classes and plain
`Enum("a", "b")` columns).

### File/image fields use multipart forms

When a model has file/image upload fields (`LargeBinary` columns or
`formfield_overrides` with `FileUploadWidget`/`ImageUploadWidget`), the
create/update endpoints accept `multipart/form-data` instead of JSON so files
can be picked directly in Swagger. Uploads go through the same storage
backend as the admin HTML form.

## Next Steps

- [Authentication & RBAC](auth-rbac.md) — Set up permissions
Expand Down
26 changes: 19 additions & 7 deletions example/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@
from fastapi_admin_kit.inline import StackedInline, TabularInline
from fastapi_admin_kit.models import Base as AdminBase
from fastapi_admin_kit.pagination.cursor import CursorPagination
from fastapi_admin_kit.storage.local import LocalStorageBackend
from fastapi_admin_kit.types import TabConfig, TableSection
from fastapi_admin_kit.widgets.inputs import ArrayWidget, WysiwygWidget
from fastapi_admin_kit.widgets.inputs import ArrayWidget, ImageUploadWidget, WysiwygWidget

# ============================================================================
# SQLAlchemy Models
Expand Down Expand Up @@ -83,6 +84,7 @@ class Product(Base):
description = Column(Text, nullable=True)
price = Column(Float, nullable=False)
stock = Column(Integer, default=0)
image = Column(String(500), nullable=True) # uploaded product photo (path)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
is_active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
Expand All @@ -105,7 +107,12 @@ class User(AuthModelMixin, Base):
id = Column(Integer, primary_key=True)
email = Column(String(255), nullable=False, unique=True)
full_name = Column(String(255), nullable=True)
# Required auth fields — AuthModelMixin is behavior-only, declare
# these 4 with your ORM.
password = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
last_login = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())

# Relationships
Expand Down Expand Up @@ -290,7 +297,7 @@ class CategoryAdmin(ModelAdmin):
TabConfig(title="All", url="/admin/categories/"),
TabConfig(
title="Active",
url="/admin/categories/?filter_created_at__gte=2025-01-01",
url="/admin/categories/?created_at__gte=2025-01-01",
),
]

Expand Down Expand Up @@ -327,6 +334,7 @@ class ProductAdmin(ModelAdmin):
"category",
"price",
"stock",
"image",
"is_active",
]
readonly_fields = ["created_at", "updated_at"]
Expand All @@ -349,8 +357,8 @@ class ProductAdmin(ModelAdmin):
# Tabs
list_tabs = [
TabConfig(title="All Products", url="/admin/products/"),
TabConfig(title="Active", url="/admin/products/?filter_is_active=1"),
TabConfig(title="Out of Stock", url="/admin/products/?filter_stock__lte=0"),
TabConfig(title="Active", url="/admin/products/?is_active=1"),
TabConfig(title="Out of Stock", url="/admin/products/?stock__lte=0"),
]

# Sortable
Expand Down Expand Up @@ -380,6 +388,7 @@ def status(self, obj):
formfield_overrides = {
"description": WysiwygWidget(),
# "tags": ArrayWidget(),
"image": ImageUploadWidget(max_size_mb=5), # product photo upload
}

@action(
Expand Down Expand Up @@ -440,7 +449,7 @@ class UserAdmin(ModelAdmin):
# Tabs
list_tabs = [
TabConfig(title="All Users", url="/admin/users/"),
TabConfig(title="Active", url="/admin/users/?filter_is_active=1"),
TabConfig(title="Active", url="/admin/users/?is_active=1"),
]

# Form UX
Expand Down Expand Up @@ -496,8 +505,8 @@ class OrderAdmin(ModelAdmin):
# Tabs
list_tabs = [
TabConfig(title="All Orders", url="/admin/orders/"),
TabConfig(title="Pending", url="/admin/orders/?filter_status=pending"),
TabConfig(title="Completed", url="/admin/orders/?filter_status=completed"),
TabConfig(title="Pending", url="/admin/orders/?status=pending"),
TabConfig(title="Completed", url="/admin/orders/?status=completed"),
]

# Expandable sections
Expand Down Expand Up @@ -814,6 +823,9 @@ async def lifespan(app: FastAPI):
per_page_default=25,
secret_key=SECRET_KEY,
auth_backend=BuiltinAuthBackend(),
# File/image uploads (used by the Product image field + the JSON API
# multipart endpoints); served from /uploads by admin.setup().
storage=LocalStorageBackend(upload_dir=str(EXAMPLE_DIR / "uploads")),
sidebar_bottom_links=[
{"label": "Settings", "url": "/admin/users/", "icon": "cog-6-tooth"},
{"label": "Help", "url": "https://docs.example.com"},
Expand Down
2 changes: 1 addition & 1 deletion fastapi_admin_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,4 @@
"configure_notifications",
"notifications_router",
]
__version__ = "0.6.1"
__version__ = "0.6.2"
70 changes: 62 additions & 8 deletions fastapi_admin_kit/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,64 @@ def decode_access_token(token: str, secret_key: str) -> dict[str, Any] | None:
return None


def parse_jwt_subject(sub: Any) -> int | str | None:
"""Normalize a JWT ``sub`` claim to a DB-usable user id.

The ``AdminUserProtocol`` allows any PK type (``int``, ``str``, ``UUID``),
and :func:`create_access_token` stores ``str(user.id)``. Earlier code did
``int(sub)`` and dropped every non-integer subject, so a valid token
minted for a UUID/str-PK user could be issued but never validated
(``Account not found or inactive`` on every subsequent call).

Returns ``int`` for digit strings (keeps integer-PK queries exact),
the stripped string otherwise, and ``None`` for missing/empty subjects.
"""
if sub is None:
return None
if isinstance(sub, bool):
return None
if isinstance(sub, int):
return sub
# UUID objects (or any non-str scalar) — stringify; get_user() coerces back.
try:
text = str(sub).strip()
except Exception:
return None
if not text:
return None
try:
return int(text)
except (TypeError, ValueError):
return text


def extract_bearer_token(auth_header: str | None) -> str | None:
"""Extract the raw JWT from an ``Authorization`` header value.

Tolerates the two most common Swagger copy-paste mistakes:

- pasting ``Bearer <token>`` into the ``BearerAuth`` value field, which
Swagger then sends as ``Bearer Bearer <token>``;
- surrounding whitespace/quotes from copying a JSON response body.
"""
if not auth_header or not auth_header.startswith("Bearer "):
return None
token = auth_header[7:].strip()
if not token:
return None
# Tolerate a duplicated scheme prefix (case-insensitive).
if len(token) > 7 and token[:7].lower() == "bearer ":
token = token[7:].strip()
if not token:
return None
# Tolerate surrounding quotes from JSON copy-paste.
if len(token) >= 2 and (
(token[0] == '"' and token[-1] == '"') or (token[0] == "'" and token[-1] == "'")
):
token = token[1:-1].strip()
return token or None


def _hash_token(token: str) -> str:
"""SHA256 hash of a token for storage."""
return hashlib.sha256(token.encode()).hexdigest()
Expand Down Expand Up @@ -455,22 +513,18 @@ async def get_current_user_info(
immediately.
"""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
token = extract_bearer_token(auth_header)
if token is None:
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header.")

token = auth_header[7:]
secret_key = _get_secret_key(request)
payload = decode_access_token(token, secret_key)
if payload is None:
raise HTTPException(status_code=401, detail="Invalid or expired token.")

sub = payload.get("sub")
if sub is None:
user_id = parse_jwt_subject(payload.get("sub"))
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid or expired token.")
try:
user_id: int | str = int(sub)
except (TypeError, ValueError):
raise HTTPException(status_code=401, detail="Invalid or expired token.") from None

# Resolve through the AuthBackend seam: honours BYO user models and
# returns None for deleted/deactivated accounts.
Expand Down
Loading
Loading