Skip to content

Commit 848771a

Browse files
authored
Merge branch 'main' into feat/security
2 parents 1c01b0b + 862746a commit 848771a

44 files changed

Lines changed: 1576 additions & 215 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/guide/json-api.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ The API is mounted at `/admin/api/` by default.
2727
| `POST` | `/admin/api/{model}/` | Create record |
2828
| `GET` | `/admin/api/{model}/{id}` | Get single record |
2929
| `PUT` | `/admin/api/{model}/{id}` | Update record |
30+
| `PATCH` | `/admin/api/{model}/{id}` | Partially update record (only provided fields) |
3031
| `DELETE` | `/admin/api/{model}/{id}` | Delete record |
3132

3233
### Roles
@@ -59,6 +60,49 @@ class SecretAdmin(ModelAdmin):
5960
skip_auto_routes = True
6061
```
6162

63+
## Per-model endpoint control (`export_endpoint`)
64+
65+
`ModelAdmin.export_endpoint` controls **which routers are auto-built** for a
66+
model. It only affects the routers generated by `admin.setup()` — admin HTML
67+
routes are never shown in the `/openapi.json` Swagger doc, only JSON API routes
68+
are.
69+
70+
| Value | Admin (HTML) router | JSON API router |
71+
|---------|---------------------|-----------------|
72+
| `None` | built | built |
73+
| `"html"`| built | skipped |
74+
| `"api"` | skipped | built |
75+
76+
```python
77+
@admin.register(Product)
78+
class ProductAdmin(ModelAdmin):
79+
export_endpoint = "api" # JSON API only — no /admin/products pages
80+
```
81+
82+
With `export_endpoint = "api"` the model is also hidden from the sidebar and
83+
topbar search suggestions (it has no HTML pages).
84+
85+
### Standalone router export
86+
87+
You can build a model's routers **without** calling `admin.register()` at all
88+
using `export_api_route()` / `export_admin_route()`:
89+
90+
```python
91+
from fastapi_admin_kit import ModelAdmin
92+
93+
class ProductAdmin(ModelAdmin):
94+
export_endpoint = "api"
95+
96+
app.include_router(ProductAdmin().export_api_route(Product))
97+
app.include_router(ProductAdmin().export_admin_route(Product), prefix="/admin")
98+
```
99+
100+
- `export_api_route(model, prefix="")` — JSON CRUD router (appears in Swagger).
101+
- `export_admin_route(model, prefix="")` — HTML admin router (hidden from Swagger).
102+
103+
These helpers build the routers directly and do **not** write to the admin
104+
registry, so no `admin.register()` (and no sidebar entry) is created.
105+
62106
## Authentication
63107

64108
### Token Obtain

docs/guide/model-registration.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,36 @@ When `inline_edit = True`, a 3-dot menu appears per row with an "Edit" option th
157157
| `nav_order` | `int` | `999` | Sidebar ordering (lower = higher) |
158158
| `nav_children` | `list[NavItemConfig]` | `None` | Nested nav items |
159159
| `skip_auto_routes` | `bool` | `False` | Skip automatic route generation |
160+
| `export_endpoint` | `str \| None` | `None` | Control which routers are auto-built (`None`, `"html"`, or `"api"`) |
161+
162+
### Endpoint Export Control
163+
164+
`export_endpoint` controls which routers are auto-built for a model:
165+
166+
| Value | Admin (HTML) router | JSON API router |
167+
|---------|---------------------|-----------------|
168+
| `None` | built | built |
169+
| `"html"`| built | skipped |
170+
| `"api"` | skipped | built |
171+
172+
```python
173+
@admin.register(Product)
174+
class ProductAdmin(ModelAdmin):
175+
export_endpoint = "api" # JSON API only
176+
```
177+
178+
Admin HTML routes are never shown in `/openapi.json`; only JSON API routes are.
179+
180+
You can also build routers for a model **without** `admin.register()` using the
181+
standalone `export_api_route()` / `export_admin_route()` helpers:
182+
183+
```python
184+
class ProductAdmin(ModelAdmin):
185+
export_endpoint = "api"
186+
187+
app.include_router(ProductAdmin().export_api_route(Product))
188+
app.include_router(ProductAdmin().export_admin_route(Product), prefix="/admin")
189+
```
160190

161191
### Pagination Strategies
162192

example/example.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
AuditLog, # noqa: F401 — ensure table is created
3030
)
3131
from fastapi_admin_kit.auth.backend import BuiltinAuthBackend
32-
from fastapi_admin_kit.auth.models import User
32+
from fastapi_admin_kit.auth.mixins import AuthModelMixin
33+
from fastapi_admin_kit.auth.password import password_manager
3334
from fastapi_admin_kit.backends import SqlAlchemyBackend
3435
from fastapi_admin_kit.config import ThemeConfig
3536
from fastapi_admin_kit.dashboard import (
@@ -40,6 +41,7 @@
4041
)
4142
from fastapi_admin_kit.inline import StackedInline, TabularInline
4243
from fastapi_admin_kit.models import Base as AdminBase
44+
from fastapi_admin_kit.pagination.cursor import CursorPagination
4345
from fastapi_admin_kit.types import TabConfig, TableSection
4446
from fastapi_admin_kit.widgets.inputs import ArrayWidget, WysiwygWidget
4547

@@ -95,7 +97,7 @@ def __str__(self) -> str:
9597
return self.name
9698

9799

98-
class User(Base):
100+
class User(AuthModelMixin, Base):
99101
"""User model."""
100102

101103
__tablename__ = "users"
@@ -313,6 +315,7 @@ class ProductAdmin(ModelAdmin):
313315
"status",
314316
"created_at",
315317
]
318+
pagination = CursorPagination(cursor_column="id")
316319
list_filter = ["is_active", "category"]
317320
search_fields = ["name", "description"]
318321
ordering = ["-created_at"]
@@ -711,8 +714,14 @@ async def seed_demo_data(session: AsyncSession) -> None:
711714
session.add_all(products)
712715
await session.flush()
713716

714-
user1 = User(email="alice@example.com", full_name="Alice Johnson", is_active=True)
715-
user2 = User(email="bob@example.com", full_name="Bob Smith", is_active=True)
717+
user1 = User(
718+
email="alice@example.com", full_name="Alice Johnson", is_active=True,
719+
hashed_password=password_manager.hash("alice"),
720+
)
721+
user2 = User(
722+
email="bob@example.com", full_name="Bob Smith", is_active=True,
723+
hashed_password=password_manager.hash("bob"),
724+
)
716725
session.add_all([user1, user2])
717726
await session.flush()
718727

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

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

fastapi_admin_kit/admin/admin_router.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ def _build_router(self, app: Any) -> None:
3131
for registered in registry.all():
3232
if getattr(registered.admin, "skip_auto_routes", False):
3333
continue
34+
# API-only models (export_endpoint="api") get no admin HTML router.
35+
if getattr(registered.admin, "export_endpoint", None) == "api":
36+
continue
3437
model_router = build_model_router(registered)
38+
if model_router is None:
39+
continue
3540
app.include_router(model_router, prefix=self.admin_path)
3641

3742
# Auth routes (login/logout)

fastapi_admin_kit/admin/builtin_models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ async def flush_pending_perm_ops(request):
7070
from fastapi_admin_kit.db import get_db_session
7171

7272
perm_ids = getattr(request.state, "_admin_perm_perm_ids", None)
73-
if not perm_ids or not isinstance(perm_ids, list):
73+
if perm_ids is None or not isinstance(perm_ids, list):
7474
return
7575

7676
# Get the user object from request state
@@ -169,15 +169,15 @@ def after_create(self, obj, request=None):
169169
if request is None:
170170
return
171171
perm_data = getattr(request.state, "_admin_perm_data", None)
172-
if perm_data:
172+
if perm_data is not None:
173173
request.state._admin_perm_perm_ids = perm_data
174174
request.state._admin_perm_user_obj = obj
175175

176176
def after_update(self, obj, request=None):
177177
if request is None:
178178
return
179179
perm_data = getattr(request.state, "_admin_perm_data", None)
180-
if perm_data:
180+
if perm_data is not None:
181181
request.state._admin_perm_perm_ids = perm_data
182182
request.state._admin_perm_user_obj = obj
183183

fastapi_admin_kit/admin/core.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ def __init__(
468468

469469
# Store notification paths on config for template access
470470
default_notifications_path = f"{self.router.admin_path}/notifications"
471-
default_notifications_list = f"{default_notifications_path}/"
471+
default_notifications_list = f"{self.router.admin_path}/admin_notifications/"
472472
self.config.notifications_api_path = notifications_api_path or default_notifications_path
473473
self.config.notifications_list_path = notifications_list_path or default_notifications_list
474474

@@ -1032,7 +1032,7 @@ def _attr(obj: Any, name: str) -> Any:
10321032
self.config, "notifications_api_path", f"{self.router.admin_path}/notifications"
10331033
)
10341034
self._jinja_env.env.globals["notifications_list_path"] = getattr(
1035-
self.config, "notifications_list_path", f"{self.router.admin_path}/notifications/"
1035+
self.config, "notifications_list_path", f"{self.router.admin_path}/admin_notifications/"
10361036
)
10371037
self._jinja_env.env.globals["notifications_enabled"] = self._enable_notification
10381038
self._jinja_env.env.globals["nav_groups"] = self._nav_groups_built
@@ -1207,7 +1207,12 @@ def _build_router(self, app: FastAPI) -> None:
12071207
for registered in self.registry.all():
12081208
if getattr(registered.admin, "skip_auto_routes", False):
12091209
continue
1210+
# API-only models (export_endpoint="api") get no admin HTML router.
1211+
if getattr(registered.admin, "export_endpoint", None) == "api":
1212+
continue
12101213
model_router = build_model_router(registered)
1214+
if model_router is None:
1215+
continue
12111216
app.include_router(model_router, prefix=self.router.admin_path)
12121217

12131218
# Auth routes (login/logout)
@@ -1235,6 +1240,7 @@ def _build_router(self, app: FastAPI) -> None:
12351240
dashboard_view,
12361241
methods=["GET"],
12371242
tags=["admin"],
1243+
include_in_schema=False,
12381244
)
12391245

12401246
# JSON API for external frontend apps

fastapi_admin_kit/api/__init__.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44

55
from typing import Any
66

7-
from fastapi import APIRouter
7+
from fastapi import APIRouter, Depends
88

99
from fastapi_admin_kit.api.auth import router as auth_router
1010
from fastapi_admin_kit.api.crud import build_api_router
1111
from fastapi_admin_kit.api.roles import router as roles_router
12+
from fastapi_admin_kit.api.security import bearer_scheme
1213

1314

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

36-
# Role management routes (superuser only)
37-
router.include_router(roles_router)
37+
# Role management routes (superuser only) — bearer protected
38+
router.include_router(roles_router, dependencies=[Depends(bearer_scheme)])
3839

39-
# CRUD routes for all registered models
40+
# CRUD routes for all registered models — bearer protected
4041
if self.registry is not None:
4142
crud_router = build_api_router(self.registry)
42-
router.include_router(crud_router)
43+
router.include_router(crud_router, dependencies=[Depends(bearer_scheme)])
4344

4445
return router

fastapi_admin_kit/api/auth.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
from typing import Any
99

1010
import jwt
11-
from fastapi import APIRouter, HTTPException, Request
11+
from fastapi import APIRouter, Depends, HTTPException, Request
12+
from fastapi.security import HTTPBasicCredentials
1213

1314
from fastapi_admin_kit.api.schemas import (
1415
RefreshRequest,
1516
RefreshResponse,
1617
TokenRequest,
1718
TokenResponse,
1819
)
20+
from fastapi_admin_kit.api.security import basic_scheme, bearer_scheme
1921
from fastapi_admin_kit.auth.ratelimit import RateLimiter, check_rate_limit
2022
from fastapi_admin_kit.db import get_db_session
2123

@@ -161,10 +163,22 @@ def _hash_token(token: str) -> str:
161163
@router.post("/token", response_model=TokenResponse)
162164
async def obtain_token(
163165
request: Request,
164-
body: TokenRequest,
166+
body: TokenRequest | None = None,
167+
credentials: HTTPBasicCredentials | None = Depends(basic_scheme),
165168
) -> TokenResponse:
166-
"""POST /api/auth/token — obtain JWT access + refresh tokens."""
167-
check_rate_limit(_api_rate_limiter, body.email)
169+
"""POST /api/auth/token — obtain JWT access + refresh tokens.
170+
171+
Credentials may be provided either as a JSON body (``email``/``password``)
172+
or via HTTP Basic auth. Basic auth takes precedence when both are given.
173+
"""
174+
if credentials is not None:
175+
email, password = credentials.username, credentials.password
176+
elif body is not None:
177+
email, password = body.email, body.password
178+
else:
179+
raise HTTPException(status_code=422, detail="Credentials required.")
180+
181+
check_rate_limit(_api_rate_limiter, email)
168182

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

177-
user = await auth_backend.authenticate(body.email, body.password, db_session)
191+
user = await auth_backend.authenticate(email, password, db_session)
178192
if user is None:
179-
_api_rate_limiter.record_attempt(body.email)
193+
_api_rate_limiter.record_attempt(email)
180194
raise HTTPException(status_code=401, detail="Invalid credentials.")
181195

182-
_api_rate_limiter.reset(body.email)
196+
_api_rate_limiter.reset(email)
183197

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

223237
from sqlalchemy import select
238+
from sqlalchemy.orm import selectinload
224239

225240
from fastapi_admin_kit.auth.models import RefreshToken, User
226241

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

238-
if refresh_record.expires_at < datetime.now(UTC):
253+
expires_at = refresh_record.expires_at
254+
if expires_at.tzinfo is None:
255+
expires_at = expires_at.replace(tzinfo=UTC)
256+
if expires_at < datetime.now(UTC):
239257
raise HTTPException(status_code=401, detail="Refresh token expired.")
240258

241-
# Load user
259+
# Load user (eagerly load roles to avoid lazy-load in async session)
242260
user = await db_session.scalar_one_or_none(
243-
select(User).where(
261+
select(User)
262+
.options(selectinload(User.roles))
263+
.where(
244264
User.id == refresh_record.user_id,
245265
User.is_active,
246266
)
@@ -305,6 +325,7 @@ async def api_logout(
305325
@router.get("/me")
306326
async def get_current_user_info(
307327
request: Request,
328+
_: Any = Depends(bearer_scheme),
308329
) -> dict[str, Any]:
309330
"""GET /api/auth/me — return current user info from JWT (no DB hit)."""
310331
auth_header = request.headers.get("Authorization", "")

0 commit comments

Comments
 (0)