Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
44 changes: 44 additions & 0 deletions docs/guide/json-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The API is mounted at `/admin/api/` by default.
| `POST` | `/admin/api/{model}/` | Create record |
| `GET` | `/admin/api/{model}/{id}` | Get single record |
| `PUT` | `/admin/api/{model}/{id}` | Update record |
| `PATCH` | `/admin/api/{model}/{id}` | Partially update record (only provided fields) |
| `DELETE` | `/admin/api/{model}/{id}` | Delete record |

### Roles
Expand Down Expand Up @@ -59,6 +60,49 @@ class SecretAdmin(ModelAdmin):
skip_auto_routes = True
```

## Per-model endpoint control (`export_endpoint`)

`ModelAdmin.export_endpoint` controls **which routers are auto-built** for a
model. It only affects the routers generated by `admin.setup()` — admin HTML
routes are never shown in the `/openapi.json` Swagger doc, only JSON API routes
are.

| Value | Admin (HTML) router | JSON API router |
|---------|---------------------|-----------------|
| `None` | built | built |
| `"html"`| built | skipped |
| `"api"` | skipped | built |

```python
@admin.register(Product)
class ProductAdmin(ModelAdmin):
export_endpoint = "api" # JSON API only — no /admin/products pages
```

With `export_endpoint = "api"` the model is also hidden from the sidebar and
topbar search suggestions (it has no HTML pages).

### Standalone router export

You can build a model's routers **without** calling `admin.register()` at all
using `export_api_route()` / `export_admin_route()`:

```python
from fastapi_admin_kit import ModelAdmin

class ProductAdmin(ModelAdmin):
export_endpoint = "api"

app.include_router(ProductAdmin().export_api_route(Product))
app.include_router(ProductAdmin().export_admin_route(Product), prefix="/admin")
```

- `export_api_route(model, prefix="")` — JSON CRUD router (appears in Swagger).
- `export_admin_route(model, prefix="")` — HTML admin router (hidden from Swagger).

These helpers build the routers directly and do **not** write to the admin
registry, so no `admin.register()` (and no sidebar entry) is created.

## Authentication

### Token Obtain
Expand Down
30 changes: 30 additions & 0 deletions docs/guide/model-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,36 @@ When `inline_edit = True`, a 3-dot menu appears per row with an "Edit" option th
| `nav_order` | `int` | `999` | Sidebar ordering (lower = higher) |
| `nav_children` | `list[NavItemConfig]` | `None` | Nested nav items |
| `skip_auto_routes` | `bool` | `False` | Skip automatic route generation |
| `export_endpoint` | `str \| None` | `None` | Control which routers are auto-built (`None`, `"html"`, or `"api"`) |

### Endpoint Export Control

`export_endpoint` controls which routers are auto-built for a model:

| Value | Admin (HTML) router | JSON API router |
|---------|---------------------|-----------------|
| `None` | built | built |
| `"html"`| built | skipped |
| `"api"` | skipped | built |

```python
@admin.register(Product)
class ProductAdmin(ModelAdmin):
export_endpoint = "api" # JSON API only
```

Admin HTML routes are never shown in `/openapi.json`; only JSON API routes are.

You can also build routers for a model **without** `admin.register()` using the
standalone `export_api_route()` / `export_admin_route()` helpers:

```python
class ProductAdmin(ModelAdmin):
export_endpoint = "api"

app.include_router(ProductAdmin().export_api_route(Product))
app.include_router(ProductAdmin().export_admin_route(Product), prefix="/admin")
```

### Pagination Strategies

Expand Down
19 changes: 14 additions & 5 deletions example/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
AuditLog, # noqa: F401 — ensure table is created
)
from fastapi_admin_kit.auth.backend import BuiltinAuthBackend
from fastapi_admin_kit.auth.models import User
from fastapi_admin_kit.auth.mixins import AuthModelMixin
from fastapi_admin_kit.auth.password import password_manager
from fastapi_admin_kit.backends import SqlAlchemyBackend
from fastapi_admin_kit.config import ThemeConfig
from fastapi_admin_kit.dashboard import (
Expand All @@ -40,6 +41,7 @@
)
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.types import TabConfig, TableSection
from fastapi_admin_kit.widgets.inputs import ArrayWidget, WysiwygWidget

Expand Down Expand Up @@ -95,7 +97,7 @@ def __str__(self) -> str:
return self.name


class User(Base):
class User(AuthModelMixin, Base):
"""User model."""

__tablename__ = "users"
Expand Down Expand Up @@ -313,6 +315,7 @@ class ProductAdmin(ModelAdmin):
"status",
"created_at",
]
pagination = CursorPagination(cursor_column="id")
list_filter = ["is_active", "category"]
search_fields = ["name", "description"]
ordering = ["-created_at"]
Expand Down Expand Up @@ -711,8 +714,14 @@ async def seed_demo_data(session: AsyncSession) -> None:
session.add_all(products)
await session.flush()

user1 = User(email="alice@example.com", full_name="Alice Johnson", is_active=True)
user2 = User(email="bob@example.com", full_name="Bob Smith", is_active=True)
user1 = User(
email="alice@example.com", full_name="Alice Johnson", is_active=True,
hashed_password=password_manager.hash("alice"),
)
user2 = User(
email="bob@example.com", full_name="Bob Smith", is_active=True,
hashed_password=password_manager.hash("bob"),
)
session.add_all([user1, user2])
await session.flush()

Expand All @@ -739,7 +748,7 @@ async def seed_demo_data(session: AsyncSession) -> None:

async def seed_admin_user(session: AsyncSession) -> None:
"""Create a default superadmin if none exists."""
result = await session.execute(select(User).limit(1))
result = await session.execute(select(User).where(User.email == "admin@example.com"))
if result.scalars().first() is not None:
return

Expand Down
5 changes: 5 additions & 0 deletions fastapi_admin_kit/admin/admin_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ def _build_router(self, app: Any) -> None:
for registered in registry.all():
if getattr(registered.admin, "skip_auto_routes", False):
continue
# API-only models (export_endpoint="api") get no admin HTML router.
if getattr(registered.admin, "export_endpoint", None) == "api":
continue
model_router = build_model_router(registered)
if model_router is None:
continue
app.include_router(model_router, prefix=self.admin_path)

# Auth routes (login/logout)
Expand Down
6 changes: 3 additions & 3 deletions fastapi_admin_kit/admin/builtin_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async def flush_pending_perm_ops(request):
from fastapi_admin_kit.db import get_db_session

perm_ids = getattr(request.state, "_admin_perm_perm_ids", None)
if not perm_ids or not isinstance(perm_ids, list):
if perm_ids is None or not isinstance(perm_ids, list):
return

# Get the user object from request state
Expand Down Expand Up @@ -169,15 +169,15 @@ def after_create(self, obj, request=None):
if request is None:
return
perm_data = getattr(request.state, "_admin_perm_data", None)
if perm_data:
if perm_data is not None:
request.state._admin_perm_perm_ids = perm_data
request.state._admin_perm_user_obj = obj

def after_update(self, obj, request=None):
if request is None:
return
perm_data = getattr(request.state, "_admin_perm_data", None)
if perm_data:
if perm_data is not None:
request.state._admin_perm_perm_ids = perm_data
request.state._admin_perm_user_obj = obj

Expand Down
10 changes: 8 additions & 2 deletions fastapi_admin_kit/admin/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ def __init__(

# Store notification paths on config for template access
default_notifications_path = f"{self.router.admin_path}/notifications"
default_notifications_list = f"{default_notifications_path}/"
default_notifications_list = f"{self.router.admin_path}/admin_notifications/"
self.config.notifications_api_path = notifications_api_path or default_notifications_path
self.config.notifications_list_path = notifications_list_path or default_notifications_list

Expand Down Expand Up @@ -1032,7 +1032,7 @@ def _attr(obj: Any, name: str) -> Any:
self.config, "notifications_api_path", f"{self.router.admin_path}/notifications"
)
self._jinja_env.env.globals["notifications_list_path"] = getattr(
self.config, "notifications_list_path", f"{self.router.admin_path}/notifications/"
self.config, "notifications_list_path", f"{self.router.admin_path}/admin_notifications/"
)
self._jinja_env.env.globals["notifications_enabled"] = self._enable_notification
self._jinja_env.env.globals["nav_groups"] = self._nav_groups_built
Expand Down Expand Up @@ -1207,7 +1207,12 @@ def _build_router(self, app: FastAPI) -> None:
for registered in self.registry.all():
if getattr(registered.admin, "skip_auto_routes", False):
continue
# API-only models (export_endpoint="api") get no admin HTML router.
if getattr(registered.admin, "export_endpoint", None) == "api":
continue
model_router = build_model_router(registered)
if model_router is None:
continue
app.include_router(model_router, prefix=self.router.admin_path)

# Auth routes (login/logout)
Expand Down Expand Up @@ -1235,6 +1240,7 @@ def _build_router(self, app: FastAPI) -> None:
dashboard_view,
methods=["GET"],
tags=["admin"],
include_in_schema=False,
)

# JSON API for external frontend apps
Expand Down
11 changes: 6 additions & 5 deletions fastapi_admin_kit/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

from typing import Any

from fastapi import APIRouter
from fastapi import APIRouter, Depends

from fastapi_admin_kit.api.auth import router as auth_router
from fastapi_admin_kit.api.crud import build_api_router
from fastapi_admin_kit.api.roles import router as roles_router
from fastapi_admin_kit.api.security import bearer_scheme


class AdminAPIRouter:
Expand All @@ -33,12 +34,12 @@ def build_router(self) -> APIRouter:
# Auth routes (token obtain, refresh, logout, me)
router.include_router(auth_router)

# Role management routes (superuser only)
router.include_router(roles_router)
# Role management routes (superuser only) — bearer protected
router.include_router(roles_router, dependencies=[Depends(bearer_scheme)])

# CRUD routes for all registered models
# CRUD routes for all registered models — bearer protected
if self.registry is not None:
crud_router = build_api_router(self.registry)
router.include_router(crud_router)
router.include_router(crud_router, dependencies=[Depends(bearer_scheme)])

return router
41 changes: 31 additions & 10 deletions fastapi_admin_kit/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@
from typing import Any

import jwt
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import HTTPBasicCredentials

from fastapi_admin_kit.api.schemas import (
RefreshRequest,
RefreshResponse,
TokenRequest,
TokenResponse,
)
from fastapi_admin_kit.api.security import basic_scheme, bearer_scheme
from fastapi_admin_kit.auth.ratelimit import RateLimiter, check_rate_limit
from fastapi_admin_kit.db import get_db_session

Expand Down Expand Up @@ -161,10 +163,22 @@ def _hash_token(token: str) -> str:
@router.post("/token", response_model=TokenResponse)
async def obtain_token(
request: Request,
body: TokenRequest,
body: TokenRequest | None = None,
credentials: HTTPBasicCredentials | None = Depends(basic_scheme),
) -> TokenResponse:
"""POST /api/auth/token — obtain JWT access + refresh tokens."""
check_rate_limit(_api_rate_limiter, body.email)
"""POST /api/auth/token — obtain JWT access + refresh tokens.

Credentials may be provided either as a JSON body (``email``/``password``)
or via HTTP Basic auth. Basic auth takes precedence when both are given.
"""
if credentials is not None:
email, password = credentials.username, credentials.password
elif body is not None:
email, password = body.email, body.password
else:
raise HTTPException(status_code=422, detail="Credentials required.")

check_rate_limit(_api_rate_limiter, email)

auth_backend = getattr(request.app.state, "admin_auth_backend", None)
if auth_backend is None:
Expand All @@ -174,12 +188,12 @@ async def obtain_token(
if db_session is None:
raise HTTPException(status_code=500, detail="Database session not available.")

user = await auth_backend.authenticate(body.email, body.password, db_session)
user = await auth_backend.authenticate(email, password, db_session)
if user is None:
_api_rate_limiter.record_attempt(body.email)
_api_rate_limiter.record_attempt(email)
raise HTTPException(status_code=401, detail="Invalid credentials.")

_api_rate_limiter.reset(body.email)
_api_rate_limiter.reset(email)

secret_key = _get_secret_key(request)
ttl = _get_token_ttl(request)
Expand Down Expand Up @@ -221,6 +235,7 @@ async def refresh_token(
raise HTTPException(status_code=500, detail="Database session not available.")

from sqlalchemy import select
from sqlalchemy.orm import selectinload

from fastapi_admin_kit.auth.models import RefreshToken, User

Expand All @@ -235,12 +250,17 @@ async def refresh_token(
if refresh_record is None:
raise HTTPException(status_code=401, detail="Invalid refresh token.")

if refresh_record.expires_at < datetime.now(UTC):
expires_at = refresh_record.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < datetime.now(UTC):
raise HTTPException(status_code=401, detail="Refresh token expired.")

# Load user
# Load user (eagerly load roles to avoid lazy-load in async session)
user = await db_session.scalar_one_or_none(
select(User).where(
select(User)
.options(selectinload(User.roles))
.where(
User.id == refresh_record.user_id,
User.is_active,
)
Expand Down Expand Up @@ -305,6 +325,7 @@ async def api_logout(
@router.get("/me")
async def get_current_user_info(
request: Request,
_: Any = Depends(bearer_scheme),
) -> dict[str, Any]:
"""GET /api/auth/me — return current user info from JWT (no DB hit)."""
auth_header = request.headers.get("Authorization", "")
Expand Down
Loading
Loading