fastapi-admin-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_admin_kit/__init__.py +73 -0
- fastapi_admin_kit/actions/__init__.py +63 -0
- fastapi_admin_kit/actions/base.py +68 -0
- fastapi_admin_kit/actions/registry.py +43 -0
- fastapi_admin_kit/admin/__init__.py +17 -0
- fastapi_admin_kit/admin/admin_config.py +95 -0
- fastapi_admin_kit/admin/admin_database.py +138 -0
- fastapi_admin_kit/admin/admin_router.py +74 -0
- fastapi_admin_kit/admin/admin_template.py +203 -0
- fastapi_admin_kit/admin/builtin_models.py +284 -0
- fastapi_admin_kit/admin/core.py +1036 -0
- fastapi_admin_kit/admin/decorators.py +70 -0
- fastapi_admin_kit/admin/state.py +76 -0
- fastapi_admin_kit/admin.py +728 -0
- fastapi_admin_kit/api/__init__.py +44 -0
- fastapi_admin_kit/api/auth.py +342 -0
- fastapi_admin_kit/api/crud.py +128 -0
- fastapi_admin_kit/api/deps.py +79 -0
- fastapi_admin_kit/api/roles.py +128 -0
- fastapi_admin_kit/api/schema_generator.py +171 -0
- fastapi_admin_kit/api/schemas.py +81 -0
- fastapi_admin_kit/api/search.py +132 -0
- fastapi_admin_kit/audit/__init__.py +36 -0
- fastapi_admin_kit/audit/context.py +62 -0
- fastapi_admin_kit/audit/diff.py +77 -0
- fastapi_admin_kit/audit/event_bus.py +96 -0
- fastapi_admin_kit/audit/events.py +48 -0
- fastapi_admin_kit/audit/listener.py +159 -0
- fastapi_admin_kit/audit/logger.py +28 -0
- fastapi_admin_kit/audit/middleware.py +39 -0
- fastapi_admin_kit/audit/models.py +53 -0
- fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
- fastapi_admin_kit/auth/__init__.py +34 -0
- fastapi_admin_kit/auth/backend.py +95 -0
- fastapi_admin_kit/auth/csrf.py +240 -0
- fastapi_admin_kit/auth/dependencies.py +150 -0
- fastapi_admin_kit/auth/identity.py +181 -0
- fastapi_admin_kit/auth/models.py +246 -0
- fastapi_admin_kit/auth/password.py +35 -0
- fastapi_admin_kit/auth/permissions.py +205 -0
- fastapi_admin_kit/auth/protocol.py +22 -0
- fastapi_admin_kit/auth/ratelimit.py +88 -0
- fastapi_admin_kit/auth/router.py +10 -0
- fastapi_admin_kit/auth/session.py +79 -0
- fastapi_admin_kit/auth/totp.py +83 -0
- fastapi_admin_kit/auth/views.py +165 -0
- fastapi_admin_kit/cli.py +229 -0
- fastapi_admin_kit/config/__init__.py +19 -0
- fastapi_admin_kit/config/audit.py +18 -0
- fastapi_admin_kit/config/auth.py +54 -0
- fastapi_admin_kit/config/behavior.py +27 -0
- fastapi_admin_kit/config/nav.py +32 -0
- fastapi_admin_kit/config/storage.py +22 -0
- fastapi_admin_kit/config/theme.py +215 -0
- fastapi_admin_kit/config/ui.py +147 -0
- fastapi_admin_kit/dashboard/__init__.py +64 -0
- fastapi_admin_kit/db.py +133 -0
- fastapi_admin_kit/exceptions.py +5 -0
- fastapi_admin_kit/field_types.py +81 -0
- fastapi_admin_kit/filters/__init__.py +21 -0
- fastapi_admin_kit/filters/base.py +170 -0
- fastapi_admin_kit/filters/registry.py +68 -0
- fastapi_admin_kit/flash.py +45 -0
- fastapi_admin_kit/form/__init__.py +1 -0
- fastapi_admin_kit/form/pipeline.py +106 -0
- fastapi_admin_kit/inspection/__init__.py +117 -0
- fastapi_admin_kit/inspection/registry.py +253 -0
- fastapi_admin_kit/inspection.py +115 -0
- fastapi_admin_kit/modeladmin.py +375 -0
- fastapi_admin_kit/models/__init__.py +7 -0
- fastapi_admin_kit/models/base.py +7 -0
- fastapi_admin_kit/nav.py +208 -0
- fastapi_admin_kit/pagination/__init__.py +14 -0
- fastapi_admin_kit/pagination/base.py +40 -0
- fastapi_admin_kit/pagination/cursor.py +97 -0
- fastapi_admin_kit/pagination/dynamic.py +48 -0
- fastapi_admin_kit/pagination/offset.py +42 -0
- fastapi_admin_kit/plugins/__init__.py +1 -0
- fastapi_admin_kit/py.typed +0 -0
- fastapi_admin_kit/registry/__init__.py +5 -0
- fastapi_admin_kit/registry/core.py +287 -0
- fastapi_admin_kit/registry/validation.py +107 -0
- fastapi_admin_kit/registry.py +15 -0
- fastapi_admin_kit/router.py +335 -0
- fastapi_admin_kit/static/css/admin.css +4736 -0
- fastapi_admin_kit/static/css/presets.css +317 -0
- fastapi_admin_kit/static/css/tokens.css +217 -0
- fastapi_admin_kit/static/css/variables.css +74 -0
- fastapi_admin_kit/static/icons/heroicons.svg +160 -0
- fastapi_admin_kit/static/js/admin.js +692 -0
- fastapi_admin_kit/static/js/htmx-config.js +42 -0
- fastapi_admin_kit/storage/__init__.py +6 -0
- fastapi_admin_kit/storage/base.py +48 -0
- fastapi_admin_kit/storage/local.py +73 -0
- fastapi_admin_kit/templates/base.html +142 -0
- fastapi_admin_kit/templates/macros/form_fields.html +660 -0
- fastapi_admin_kit/templates/macros/icons.html +50 -0
- fastapi_admin_kit/templates/macros/table.html +108 -0
- fastapi_admin_kit/templates/macros/widgets.html +159 -0
- fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
- fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
- fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
- fastapi_admin_kit/templates/pages/audit_log.html +102 -0
- fastapi_admin_kit/templates/pages/dashboard.html +295 -0
- fastapi_admin_kit/templates/pages/detail.html +183 -0
- fastapi_admin_kit/templates/pages/form.html +119 -0
- fastapi_admin_kit/templates/pages/list.html +277 -0
- fastapi_admin_kit/templates/pages/login.html +85 -0
- fastapi_admin_kit/templates/pages/profile/password.html +78 -0
- fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
- fastapi_admin_kit/templates/pages/role_form.html +75 -0
- fastapi_admin_kit/templates/pages/roles/form.html +117 -0
- fastapi_admin_kit/templates/pages/roles/list.html +69 -0
- fastapi_admin_kit/templates/pages/roles.html +77 -0
- fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
- fastapi_admin_kit/templates/pages/users/form.html +229 -0
- fastapi_admin_kit/templates/pages/users/list.html +83 -0
- fastapi_admin_kit/templates/partials/command_palette.html +52 -0
- fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
- fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
- fastapi_admin_kit/templates/partials/head.html +21 -0
- fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
- fastapi_admin_kit/templates/partials/list_table.html +178 -0
- fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
- fastapi_admin_kit/templates/partials/pagination.html +82 -0
- fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
- fastapi_admin_kit/templates/partials/scripts.html +13 -0
- fastapi_admin_kit/templates/partials/sidebar.html +94 -0
- fastapi_admin_kit/templates/partials/topbar.html +95 -0
- fastapi_admin_kit/types.py +145 -0
- fastapi_admin_kit/validation.py +43 -0
- fastapi_admin_kit/views/__init__.py +78 -0
- fastapi_admin_kit/views/audit.py +134 -0
- fastapi_admin_kit/views/bulk.py +28 -0
- fastapi_admin_kit/views/class_views.py +1040 -0
- fastapi_admin_kit/views/context.py +588 -0
- fastapi_admin_kit/views/dashboard.py +162 -0
- fastapi_admin_kit/views/delete.py +31 -0
- fastapi_admin_kit/views/extra.py +65 -0
- fastapi_admin_kit/views/factory.py +667 -0
- fastapi_admin_kit/views/form.py +159 -0
- fastapi_admin_kit/views/list.py +28 -0
- fastapi_admin_kit/views/profile.py +219 -0
- fastapi_admin_kit/views/protocols.py +54 -0
- fastapi_admin_kit/views/renderers.py +634 -0
- fastapi_admin_kit/views/roles.py +230 -0
- fastapi_admin_kit/views/search.py +31 -0
- fastapi_admin_kit/views/settings.py +31 -0
- fastapi_admin_kit/views/sidebar.py +101 -0
- fastapi_admin_kit/views/totp.py +249 -0
- fastapi_admin_kit/views/users.py +347 -0
- fastapi_admin_kit/views.py +117 -0
- fastapi_admin_kit/widgets/__init__.py +44 -0
- fastapi_admin_kit/widgets/base.py +44 -0
- fastapi_admin_kit/widgets/inputs.py +363 -0
- fastapi_admin_kit/widgets/registry.py +110 -0
- fastapi_admin_kit/widgets/relation.py +70 -0
- fastapi_admin_kit/widgets/resolver.py +102 -0
- fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
- fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
- fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
- fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
"""Admin class — public API, wires everything at init."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
from jinja2 import Environment
|
|
15
|
+
|
|
16
|
+
from fastapi_admin_kit.admin.admin_config import AdminConfig
|
|
17
|
+
from fastapi_admin_kit.admin.admin_database import AdminDatabase
|
|
18
|
+
from fastapi_admin_kit.admin.admin_router import AdminRouter
|
|
19
|
+
from fastapi_admin_kit.admin.admin_template import AdminTemplate
|
|
20
|
+
from fastapi_admin_kit.config import (
|
|
21
|
+
AuditConfig,
|
|
22
|
+
AuthConfig,
|
|
23
|
+
BehaviorConfig,
|
|
24
|
+
NavConfig,
|
|
25
|
+
StorageConfig,
|
|
26
|
+
ThemeConfig,
|
|
27
|
+
UIConfig,
|
|
28
|
+
)
|
|
29
|
+
from fastapi_admin_kit.exceptions import ConfigError
|
|
30
|
+
from fastapi_admin_kit.registry import AdminRegistry, RegisteredModel
|
|
31
|
+
from fastapi_admin_kit.types import SeedRole
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from sqlalchemy.engine import Engine
|
|
35
|
+
|
|
36
|
+
from fastapi_admin_kit.auth.backend import AuthBackend
|
|
37
|
+
from fastapi_admin_kit.nav import NavGroupConfig, SidebarBuilder
|
|
38
|
+
from fastapi_admin_kit.storage.base import StorageBackend
|
|
39
|
+
from fastapi_admin_kit.views import ModelAdmin
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Default seed roles per AUTH_RBAC_SYSTEM.md §13
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
DEFAULT_SEED_ROLES: list[SeedRole] = [
|
|
47
|
+
SeedRole(
|
|
48
|
+
name="SuperAdmin",
|
|
49
|
+
description="Full system access — equivalent to is_superuser=True",
|
|
50
|
+
permissions={}, # empty = all permissions (superuser)
|
|
51
|
+
),
|
|
52
|
+
SeedRole(
|
|
53
|
+
name="Admin",
|
|
54
|
+
description="Site administration — all permissions except admin_users",
|
|
55
|
+
permissions={
|
|
56
|
+
"admin_users": {
|
|
57
|
+
"view": True,
|
|
58
|
+
"create": False,
|
|
59
|
+
"edit": False,
|
|
60
|
+
"delete": False,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
),
|
|
64
|
+
SeedRole(
|
|
65
|
+
name="Editor",
|
|
66
|
+
description="Content editing — full CRUD on non-system models",
|
|
67
|
+
permissions={}, # non-system models get full CRUD
|
|
68
|
+
),
|
|
69
|
+
SeedRole(
|
|
70
|
+
name="Viewer",
|
|
71
|
+
description="Read-only access",
|
|
72
|
+
permissions={}, # view-only for all models
|
|
73
|
+
),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _RegistrationProxy:
|
|
78
|
+
"""Dual-purpose return value from Admin.register().
|
|
79
|
+
|
|
80
|
+
Acts as a proxy to the underlying RegisteredModel so attribute access
|
|
81
|
+
(``.model``, ``.admin``, etc.) works transparently. Also supports
|
|
82
|
+
use as a class decorator::
|
|
83
|
+
|
|
84
|
+
@admin.register(Product)
|
|
85
|
+
class ProductAdmin(ModelAdmin): ...
|
|
86
|
+
|
|
87
|
+
When called with a class, it re-registers with that admin class and
|
|
88
|
+
returns the resulting RegisteredModel.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, admin: Admin, registered: RegisteredModel) -> None:
|
|
92
|
+
object.__setattr__(self, "_admin", admin)
|
|
93
|
+
object.__setattr__(self, "_registered", registered)
|
|
94
|
+
|
|
95
|
+
def __call__(self, admin_class: type[ModelAdmin]) -> RegisteredModel:
|
|
96
|
+
reg: AdminRegistry = self._admin.registry
|
|
97
|
+
registered = reg.register(self._registered.model, admin_class)
|
|
98
|
+
object.__setattr__(self, "_registered", registered)
|
|
99
|
+
return registered
|
|
100
|
+
|
|
101
|
+
def __getattr__(self, name: str) -> Any:
|
|
102
|
+
return getattr(self._registered, name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Admin:
|
|
106
|
+
"""Main admin interface. Register models and mount to your FastAPI app.
|
|
107
|
+
|
|
108
|
+
Uses component-based architecture with:
|
|
109
|
+
- config: AdminConfig (UI, auth, audit, behavior, storage, nav settings)
|
|
110
|
+
- database: AdminDatabase (engine, table creation, role seeding)
|
|
111
|
+
- router: AdminRouter (routing, static files, Jinja)
|
|
112
|
+
- template: AdminTemplate (branding, sidebar context)
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
app: FastAPI | None = None,
|
|
118
|
+
engine: Engine | None = None,
|
|
119
|
+
*,
|
|
120
|
+
# Component instances (new API)
|
|
121
|
+
config: AdminConfig | None = None,
|
|
122
|
+
database: AdminDatabase | None = None,
|
|
123
|
+
router: AdminRouter | None = None,
|
|
124
|
+
template: AdminTemplate | None = None,
|
|
125
|
+
# Legacy kwargs for backward compatibility
|
|
126
|
+
base: type | None = None,
|
|
127
|
+
title: str = "FastAPI Admin Kit",
|
|
128
|
+
logo_url: str | None = None,
|
|
129
|
+
favicon_url: str | None = None,
|
|
130
|
+
primary_color: str = "#0ea5e9",
|
|
131
|
+
primary_color_dark: str = "#0284c7",
|
|
132
|
+
dark_mode_default: bool = False,
|
|
133
|
+
per_page_default: int = 25,
|
|
134
|
+
session_ttl: int = 28800,
|
|
135
|
+
audit_retention_days: int = 365,
|
|
136
|
+
dashboard_stats: list[str] | None = None,
|
|
137
|
+
dashboard_charts: bool = True,
|
|
138
|
+
admin_path: str = "/admin",
|
|
139
|
+
secret_key: str = "",
|
|
140
|
+
auth_model: type | None = None,
|
|
141
|
+
auth_backend: AuthBackend | None = None,
|
|
142
|
+
session_cookie_name: str = "admin_session",
|
|
143
|
+
session_secure: bool = False,
|
|
144
|
+
session_samesite: str = "strict",
|
|
145
|
+
seed_roles: list[SeedRole] | None = None,
|
|
146
|
+
seed_roles_overwrite: bool = False,
|
|
147
|
+
superuser_emails: list[str] | None = None,
|
|
148
|
+
storage: StorageBackend | None = None,
|
|
149
|
+
uploads_url: str = "/uploads",
|
|
150
|
+
auto_discover: bool = True,
|
|
151
|
+
nav_groups: list[NavGroupConfig] | None = None,
|
|
152
|
+
sidebar_builder: SidebarBuilder | None = None,
|
|
153
|
+
require_tags: bool = False,
|
|
154
|
+
theme: ThemeConfig | None = None,
|
|
155
|
+
# UI component config
|
|
156
|
+
sidebar_style: str = "default",
|
|
157
|
+
sidebar_position: str = "left",
|
|
158
|
+
table_style: str = "default",
|
|
159
|
+
table_row_height: str = "normal",
|
|
160
|
+
form_layout: str = "two-column",
|
|
161
|
+
form_spacing: str = "normal",
|
|
162
|
+
dashboard_grid: str = "auto",
|
|
163
|
+
dashboard_card_style: str = "default",
|
|
164
|
+
dashboard_stat_size: str = "normal",
|
|
165
|
+
content_width: str = "default",
|
|
166
|
+
topbar_style: str = "default",
|
|
167
|
+
custom_css: str = "",
|
|
168
|
+
custom_css_url: str = "",
|
|
169
|
+
custom_js: str = "",
|
|
170
|
+
custom_js_url: str = "",
|
|
171
|
+
show_history: bool = True,
|
|
172
|
+
show_view_on_site: bool = True,
|
|
173
|
+
environment_label: str | None = None,
|
|
174
|
+
environment_color: str = "info",
|
|
175
|
+
mobile_sidebar: str = "overlay",
|
|
176
|
+
dashboard_permission: str | None = None,
|
|
177
|
+
settings_permission: str | None = None,
|
|
178
|
+
):
|
|
179
|
+
self.registry = AdminRegistry()
|
|
180
|
+
self._app: FastAPI | None = app
|
|
181
|
+
|
|
182
|
+
# Add CSRF middleware early (must be before app starts)
|
|
183
|
+
if app is not None:
|
|
184
|
+
from fastapi_admin_kit.auth.csrf import (
|
|
185
|
+
CSRFMiddleware,
|
|
186
|
+
auth_redirect_handler,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
app.add_exception_handler(401, auth_redirect_handler)
|
|
190
|
+
app.add_middleware(CSRFMiddleware)
|
|
191
|
+
self._csrf_middleware_added = True
|
|
192
|
+
else:
|
|
193
|
+
self._csrf_middleware_added = False
|
|
194
|
+
|
|
195
|
+
# Default auth backend if none provided
|
|
196
|
+
if auth_backend is None:
|
|
197
|
+
from fastapi_admin_kit.auth.backend import BuiltinAuthBackend
|
|
198
|
+
|
|
199
|
+
auth_backend = BuiltinAuthBackend()
|
|
200
|
+
|
|
201
|
+
# Build components from legacy kwargs if components not provided
|
|
202
|
+
if config is None:
|
|
203
|
+
config = AdminConfig(
|
|
204
|
+
ui=UIConfig(
|
|
205
|
+
title=title,
|
|
206
|
+
logo_url=logo_url,
|
|
207
|
+
favicon_url=favicon_url,
|
|
208
|
+
primary_color=primary_color,
|
|
209
|
+
primary_color_dark=primary_color_dark,
|
|
210
|
+
dark_mode_default=dark_mode_default,
|
|
211
|
+
per_page_default=per_page_default,
|
|
212
|
+
theme=theme,
|
|
213
|
+
sidebar_style=sidebar_style,
|
|
214
|
+
sidebar_position=sidebar_position,
|
|
215
|
+
table_style=table_style,
|
|
216
|
+
table_row_height=table_row_height,
|
|
217
|
+
form_layout=form_layout,
|
|
218
|
+
form_spacing=form_spacing,
|
|
219
|
+
dashboard_grid=dashboard_grid,
|
|
220
|
+
dashboard_card_style=dashboard_card_style,
|
|
221
|
+
dashboard_stat_size=dashboard_stat_size,
|
|
222
|
+
content_width=content_width,
|
|
223
|
+
topbar_style=topbar_style,
|
|
224
|
+
custom_css=custom_css,
|
|
225
|
+
custom_css_url=custom_css_url,
|
|
226
|
+
custom_js=custom_js,
|
|
227
|
+
custom_js_url=custom_js_url,
|
|
228
|
+
show_history=show_history,
|
|
229
|
+
show_view_on_site=show_view_on_site,
|
|
230
|
+
environment_label=environment_label,
|
|
231
|
+
environment_color=environment_color,
|
|
232
|
+
mobile_sidebar=mobile_sidebar,
|
|
233
|
+
),
|
|
234
|
+
auth=AuthConfig(
|
|
235
|
+
auth_model=auth_model,
|
|
236
|
+
auth_backend=auth_backend,
|
|
237
|
+
session_ttl=session_ttl,
|
|
238
|
+
session_cookie_name=session_cookie_name,
|
|
239
|
+
session_secure=session_secure,
|
|
240
|
+
superuser_emails=superuser_emails,
|
|
241
|
+
session_samesite=session_samesite,
|
|
242
|
+
),
|
|
243
|
+
audit=AuditConfig(audit_retention_days=audit_retention_days),
|
|
244
|
+
behavior=BehaviorConfig(
|
|
245
|
+
auto_discover=auto_discover,
|
|
246
|
+
dashboard_stats=dashboard_stats or [],
|
|
247
|
+
dashboard_charts=dashboard_charts,
|
|
248
|
+
),
|
|
249
|
+
storage=StorageConfig(storage=storage, uploads_url=uploads_url),
|
|
250
|
+
nav=NavConfig(
|
|
251
|
+
nav_groups=nav_groups or [],
|
|
252
|
+
sidebar_builder=sidebar_builder,
|
|
253
|
+
require_tags=require_tags,
|
|
254
|
+
dashboard_permission=dashboard_permission,
|
|
255
|
+
settings_permission=settings_permission,
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if database is None:
|
|
260
|
+
database = AdminDatabase(engine=engine, base=base)
|
|
261
|
+
|
|
262
|
+
if router is None:
|
|
263
|
+
router = AdminRouter(
|
|
264
|
+
admin_path=admin_path,
|
|
265
|
+
secret_key=secret_key or os.environ.get("SECRET_KEY", ""),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if template is None:
|
|
269
|
+
template = AdminTemplate(
|
|
270
|
+
title=config.ui.title,
|
|
271
|
+
logo_url=config.ui.logo_url,
|
|
272
|
+
favicon_url=config.ui.favicon_url,
|
|
273
|
+
primary_color=config.ui.primary_color,
|
|
274
|
+
primary_color_dark=config.ui.primary_color_dark,
|
|
275
|
+
dark_mode_default=config.ui.dark_mode_default,
|
|
276
|
+
dashboard_permission=config.nav.dashboard_permission,
|
|
277
|
+
settings_permission=config.nav.settings_permission,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
self.config = config
|
|
281
|
+
self.database = database
|
|
282
|
+
self.router = router
|
|
283
|
+
self.template = template
|
|
284
|
+
|
|
285
|
+
# RBAC
|
|
286
|
+
self.seed_roles = (
|
|
287
|
+
seed_roles if seed_roles is not None else DEFAULT_SEED_ROLES
|
|
288
|
+
)
|
|
289
|
+
self.seed_roles_overwrite = seed_roles_overwrite
|
|
290
|
+
|
|
291
|
+
# Built sidebar (populated during setup)
|
|
292
|
+
self._nav_groups_built: list[Any] = []
|
|
293
|
+
|
|
294
|
+
# Internal state (populated during setup)
|
|
295
|
+
self._session_backend: Any = None
|
|
296
|
+
self._jinja_env: Environment | None = None
|
|
297
|
+
self._router_built: bool = False
|
|
298
|
+
|
|
299
|
+
if app is not None and engine is not None:
|
|
300
|
+
# Deferred setup — user will call await admin.setup() via lifespan
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
# Backward-compatible property accessors
|
|
305
|
+
# ------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def title(self) -> str:
|
|
309
|
+
return self.config.ui.title
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def logo_url(self) -> str | None:
|
|
313
|
+
return self.config.ui.logo_url
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def favicon_url(self) -> str | None:
|
|
317
|
+
return self.config.ui.favicon_url
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def primary_color(self) -> str:
|
|
321
|
+
return self.config.ui.primary_color
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def primary_color_dark(self) -> str:
|
|
325
|
+
return self.config.ui.primary_color_dark
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def dark_mode_default(self) -> bool:
|
|
329
|
+
return self.config.ui.dark_mode_default
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def per_page_default(self) -> int:
|
|
333
|
+
return self.config.ui.per_page_default
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def admin_path(self) -> str:
|
|
337
|
+
return self.router.admin_path
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def secret_key(self) -> str:
|
|
341
|
+
return self.router.secret_key
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def engine(self) -> Engine | None:
|
|
345
|
+
return self.database.engine
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def base(self) -> type | None:
|
|
349
|
+
return self.database.base
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def session_ttl(self) -> int:
|
|
353
|
+
return self.config.auth.session_ttl
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def audit_retention_days(self) -> int:
|
|
357
|
+
return self.config.audit.audit_retention_days
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def dashboard_stats(self) -> list[str]:
|
|
361
|
+
return self.config.behavior.dashboard_stats
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def dashboard_charts(self) -> bool:
|
|
365
|
+
return self.config.behavior.dashboard_charts
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def auth_model(self) -> type | None:
|
|
369
|
+
return self.config.auth.auth_model
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def auth_backend(self) -> AuthBackend | None:
|
|
373
|
+
return self.config.auth.auth_backend
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def session_cookie_name(self) -> str:
|
|
377
|
+
return self.config.auth.session_cookie_name
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def session_secure(self) -> bool:
|
|
381
|
+
return self.config.auth.session_secure
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def superuser_emails(self) -> list[str]:
|
|
385
|
+
return self.config.auth.superuser_emails
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def storage(self) -> StorageBackend | None:
|
|
389
|
+
return self.config.storage.storage
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def uploads_url(self) -> str:
|
|
393
|
+
return self.config.storage.uploads_url
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def auto_discover(self) -> bool:
|
|
397
|
+
return self.config.behavior.auto_discover
|
|
398
|
+
|
|
399
|
+
@property
|
|
400
|
+
def nav_groups(self) -> list[NavGroupConfig]:
|
|
401
|
+
return self.config.nav.nav_groups
|
|
402
|
+
|
|
403
|
+
@property
|
|
404
|
+
def sidebar_builder(self) -> SidebarBuilder | None:
|
|
405
|
+
return self.config.nav.sidebar_builder
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def require_tags(self) -> bool:
|
|
409
|
+
return self.config.nav.require_tags
|
|
410
|
+
|
|
411
|
+
# ------------------------------------------------------------------
|
|
412
|
+
# Setup (async)
|
|
413
|
+
# ------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
async def setup(self, app: FastAPI | None = None) -> None:
|
|
416
|
+
"""Run all startup wiring: create tables, seed roles, mount assets.
|
|
417
|
+
|
|
418
|
+
This must be called once during application lifespan, typically via
|
|
419
|
+
the :meth:`lifespan` context manager.
|
|
420
|
+
"""
|
|
421
|
+
if app is not None:
|
|
422
|
+
self._app = app
|
|
423
|
+
|
|
424
|
+
if self._app is None:
|
|
425
|
+
raise ConfigError(
|
|
426
|
+
"Admin requires a FastAPI app instance. Pass app= or call setup(app=)."
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if self.database.engine is None:
|
|
430
|
+
raise ConfigError(
|
|
431
|
+
"Admin requires a SQLAlchemy engine. Pass engine= to Admin()."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
app = self._app
|
|
435
|
+
|
|
436
|
+
# Add CSRF middleware if not already added in __init__
|
|
437
|
+
if not getattr(self, "_csrf_middleware_added", False):
|
|
438
|
+
from fastapi_admin_kit.auth.csrf import (
|
|
439
|
+
CSRFMiddleware,
|
|
440
|
+
auth_redirect_handler,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
app.add_exception_handler(401, auth_redirect_handler)
|
|
445
|
+
app.add_middleware(CSRFMiddleware)
|
|
446
|
+
except RuntimeError:
|
|
447
|
+
pass # Already started — middleware was added in __init__
|
|
448
|
+
|
|
449
|
+
# Add per-request session middleware
|
|
450
|
+
if not getattr(self, "_session_middleware_added", False):
|
|
451
|
+
from fastapi_admin_kit.db import SessionMiddleware
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
app.add_middleware(SessionMiddleware)
|
|
455
|
+
self._session_middleware_added = True
|
|
456
|
+
except RuntimeError:
|
|
457
|
+
pass
|
|
458
|
+
|
|
459
|
+
# Add audit context middleware
|
|
460
|
+
if not getattr(self, "_audit_middleware_added", False):
|
|
461
|
+
from fastapi_admin_kit.audit.middleware import (
|
|
462
|
+
AuditContextMiddleware,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
app.add_middleware(AuditContextMiddleware)
|
|
467
|
+
self._audit_middleware_added = True
|
|
468
|
+
except RuntimeError:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
# 0. Validate secret_key strength
|
|
472
|
+
if not self.router.secret_key:
|
|
473
|
+
raise ConfigError(
|
|
474
|
+
"Admin secret_key is required. Pass a strong secret (≥32 chars) "
|
|
475
|
+
"via Admin(secret_key=...) or the SECRET_KEY environment variable."
|
|
476
|
+
)
|
|
477
|
+
if len(self.router.secret_key) < 32:
|
|
478
|
+
raise ConfigError(
|
|
479
|
+
f"Admin secret_key is too short ({len(self.router.secret_key)} chars). "
|
|
480
|
+
"Must be at least 32 characters for secure signing."
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# 1. Validate auth_model satisfies AdminUserProtocol
|
|
484
|
+
self.config.auth.validate_auth_model()
|
|
485
|
+
|
|
486
|
+
# 2. Database tables should be created via Alembic migrations
|
|
487
|
+
skip_create_tables = (
|
|
488
|
+
os.environ.get("SKIP_CREATE_TABLES", "false").lower() == "true"
|
|
489
|
+
)
|
|
490
|
+
if not skip_create_tables:
|
|
491
|
+
await self.database._create_tables()
|
|
492
|
+
|
|
493
|
+
# 3. Seed default roles
|
|
494
|
+
await self.database._seed_roles(
|
|
495
|
+
self.seed_roles, self.seed_roles_overwrite
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# 4. Create and store session backend
|
|
499
|
+
self._session_backend = self.database._init_session_backend(
|
|
500
|
+
secret_key=self.router.secret_key,
|
|
501
|
+
session_ttl=self.config.auth.session_ttl,
|
|
502
|
+
cookie_name=self.config.auth.session_cookie_name,
|
|
503
|
+
secure=self.config.auth.session_secure,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# 5. Store backends and config on app.state
|
|
507
|
+
self._wire_app_state(app)
|
|
508
|
+
|
|
509
|
+
# 6. Mount static files
|
|
510
|
+
self._mount_static(app)
|
|
511
|
+
|
|
512
|
+
# 7. Initialise Jinja2
|
|
513
|
+
self._init_jinja(app)
|
|
514
|
+
|
|
515
|
+
# 8. Auto-register built-in admin models (before auto_discover)
|
|
516
|
+
self._register_builtin_models()
|
|
517
|
+
|
|
518
|
+
# 8.1 Auto-discover user models
|
|
519
|
+
if self.config.behavior.auto_discover:
|
|
520
|
+
self.registry.auto_discover()
|
|
521
|
+
|
|
522
|
+
# 8.2 Attach audit event listeners (after registry is populated)
|
|
523
|
+
from fastapi_admin_kit.audit.listener import attach_audit_listener
|
|
524
|
+
|
|
525
|
+
engine = self.database.engine
|
|
526
|
+
if engine is not None:
|
|
527
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
528
|
+
|
|
529
|
+
if isinstance(engine, AsyncEngine):
|
|
530
|
+
from fastapi_admin_kit.db import create_session_factory
|
|
531
|
+
|
|
532
|
+
session_factory = create_session_factory(engine)
|
|
533
|
+
attach_audit_listener(session_factory, self.registry)
|
|
534
|
+
|
|
535
|
+
# 9. Validate require_tags
|
|
536
|
+
if self.config.nav.require_tags:
|
|
537
|
+
self._validate_tags()
|
|
538
|
+
|
|
539
|
+
# 10. Build sidebar structure (once at startup)
|
|
540
|
+
self._nav_groups_built = self._build_sidebar()
|
|
541
|
+
self.template._nav_groups_built = self._nav_groups_built
|
|
542
|
+
if self._jinja_env:
|
|
543
|
+
self._jinja_env.env.globals["nav_groups"] = self._nav_groups_built
|
|
544
|
+
|
|
545
|
+
# 11. Build and mount routers
|
|
546
|
+
self._build_router(app)
|
|
547
|
+
|
|
548
|
+
# ------------------------------------------------------------------
|
|
549
|
+
# Register
|
|
550
|
+
# ------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
def register(
|
|
553
|
+
self,
|
|
554
|
+
model: type,
|
|
555
|
+
admin_class: type[ModelAdmin] | None = None,
|
|
556
|
+
) -> _RegistrationProxy | RegisteredModel:
|
|
557
|
+
"""Register a model with the admin.
|
|
558
|
+
|
|
559
|
+
Usage::
|
|
560
|
+
|
|
561
|
+
admin.register(Product)
|
|
562
|
+
|
|
563
|
+
@admin.register(Product)
|
|
564
|
+
class ProductAdmin(ModelAdmin):
|
|
565
|
+
list_display = ["name", "price"]
|
|
566
|
+
"""
|
|
567
|
+
if admin_class is not None:
|
|
568
|
+
registered = self.registry.register(model, admin_class)
|
|
569
|
+
else:
|
|
570
|
+
registered = self.registry.register(model)
|
|
571
|
+
if self._jinja_env:
|
|
572
|
+
self._jinja_env.env.globals["registered_models"] = (
|
|
573
|
+
self.registry.all()
|
|
574
|
+
)
|
|
575
|
+
if self._nav_groups_built:
|
|
576
|
+
self._nav_groups_built = self._build_sidebar()
|
|
577
|
+
self.template._nav_groups_built = self._nav_groups_built
|
|
578
|
+
self._jinja_env.env.globals["nav_groups"] = (
|
|
579
|
+
self._nav_groups_built
|
|
580
|
+
)
|
|
581
|
+
if admin_class is not None:
|
|
582
|
+
return registered
|
|
583
|
+
return _RegistrationProxy(self, registered)
|
|
584
|
+
|
|
585
|
+
# ------------------------------------------------------------------
|
|
586
|
+
# Lifespan
|
|
587
|
+
# ------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
@asynccontextmanager
|
|
590
|
+
async def lifespan(self, app: FastAPI) -> AsyncIterator[None]:
|
|
591
|
+
"""FastAPI lifespan context manager.
|
|
592
|
+
|
|
593
|
+
Usage::
|
|
594
|
+
|
|
595
|
+
app = FastAPI(lifespan=admin.lifespan)
|
|
596
|
+
"""
|
|
597
|
+
await self.setup(app)
|
|
598
|
+
yield
|
|
599
|
+
|
|
600
|
+
# ------------------------------------------------------------------
|
|
601
|
+
# Query helpers
|
|
602
|
+
# ------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
def get_registered(self, table_name: str) -> RegisteredModel | None:
|
|
605
|
+
"""Get a registered model by table name."""
|
|
606
|
+
return self.registry.get(table_name)
|
|
607
|
+
|
|
608
|
+
def all_registered(self) -> list[RegisteredModel]:
|
|
609
|
+
"""Get all registered models."""
|
|
610
|
+
return self.registry.all()
|
|
611
|
+
|
|
612
|
+
def unregister(self, model: type) -> None:
|
|
613
|
+
"""Unregister a model so it can be re-registered with a custom admin class.
|
|
614
|
+
|
|
615
|
+
Useful for overriding built-in admin models::
|
|
616
|
+
|
|
617
|
+
from fastapi_admin_kit.auth.models import AdminUser
|
|
618
|
+
from fastapi_admin_kit.admin.builtin_models import AdminUserAdmin
|
|
619
|
+
|
|
620
|
+
class MyAdminUserAdmin(AdminUserAdmin):
|
|
621
|
+
list_display = ["id", "email", "full_name"]
|
|
622
|
+
|
|
623
|
+
admin.unregister(AdminUser)
|
|
624
|
+
admin.register(AdminUser, MyAdminUserAdmin)
|
|
625
|
+
"""
|
|
626
|
+
table_name = model.__tablename__
|
|
627
|
+
self.registry._models.pop(table_name, None)
|
|
628
|
+
|
|
629
|
+
# ------------------------------------------------------------------
|
|
630
|
+
# Internal wiring
|
|
631
|
+
# ------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
def _validate_auth_model(self) -> None:
|
|
634
|
+
"""Validate that auth_model satisfies AdminUserProtocol."""
|
|
635
|
+
self.config.auth.validate_auth_model()
|
|
636
|
+
|
|
637
|
+
def _wire_app_state(self, app: FastAPI) -> None:
|
|
638
|
+
"""Store backends and configuration on app.state as typed AdminState."""
|
|
639
|
+
from fastapi_admin_kit.admin.state import AdminState
|
|
640
|
+
|
|
641
|
+
admin_config = {
|
|
642
|
+
"title": self.config.ui.title,
|
|
643
|
+
"logo_url": self.config.ui.logo_url,
|
|
644
|
+
"favicon_url": self.config.ui.favicon_url,
|
|
645
|
+
"primary_color": self.config.ui.primary_color,
|
|
646
|
+
"primary_color_dark": self.config.ui.primary_color_dark,
|
|
647
|
+
"dark_mode_default": self.config.ui.dark_mode_default,
|
|
648
|
+
"per_page_default": self.config.ui.per_page_default,
|
|
649
|
+
"session_ttl": self.config.auth.session_ttl,
|
|
650
|
+
"audit_retention_days": self.config.audit.audit_retention_days,
|
|
651
|
+
"dashboard_stats": self.config.behavior.dashboard_stats,
|
|
652
|
+
"dashboard_charts": self.config.behavior.dashboard_charts,
|
|
653
|
+
"admin_path": self.router.admin_path,
|
|
654
|
+
"superuser_emails": self.config.auth.superuser_emails,
|
|
655
|
+
"ui_config": self.config.ui.apply_to_template_context(),
|
|
656
|
+
}
|
|
657
|
+
if self.config.ui.theme:
|
|
658
|
+
admin_config.update(self.config.ui.theme.to_context())
|
|
659
|
+
|
|
660
|
+
# Create session factory if engine is available
|
|
661
|
+
db_session = None
|
|
662
|
+
session_factory = None
|
|
663
|
+
engine = self.database.engine
|
|
664
|
+
if engine is not None:
|
|
665
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
666
|
+
|
|
667
|
+
if isinstance(engine, AsyncEngine):
|
|
668
|
+
from fastapi_admin_kit.db import create_session_factory
|
|
669
|
+
|
|
670
|
+
session_factory = create_session_factory(engine)
|
|
671
|
+
# Legacy fallback — a single session for backward compat
|
|
672
|
+
db_session = session_factory()
|
|
673
|
+
else:
|
|
674
|
+
from sqlalchemy.orm import sessionmaker as sync_sessionmaker
|
|
675
|
+
|
|
676
|
+
session_factory = sync_sessionmaker(bind=engine, expire_on_commit=False)
|
|
677
|
+
db_session = session_factory()
|
|
678
|
+
|
|
679
|
+
state = AdminState(
|
|
680
|
+
engine=engine,
|
|
681
|
+
session_backend=self._session_backend,
|
|
682
|
+
auth_backend=self.config.auth.auth_backend,
|
|
683
|
+
storage=self.config.storage.storage,
|
|
684
|
+
registry=self.registry,
|
|
685
|
+
db_session=db_session,
|
|
686
|
+
config=admin_config,
|
|
687
|
+
jinja_env=self._jinja_env,
|
|
688
|
+
admin_instance=self,
|
|
689
|
+
secret_key=self.router.secret_key,
|
|
690
|
+
session_samesite=self.config.auth.session_samesite,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
# Store typed state as single attribute
|
|
694
|
+
app.state.admin_state = state
|
|
695
|
+
|
|
696
|
+
# Also store individual attributes for backward compatibility
|
|
697
|
+
app.state.admin = self # Admin instance (backward compat)
|
|
698
|
+
app.state.admin_engine = state.engine
|
|
699
|
+
app.state.admin_session_backend = state.session_backend
|
|
700
|
+
app.state.admin_auth_backend = state.auth_backend
|
|
701
|
+
app.state.admin_storage = state.storage
|
|
702
|
+
app.state.admin_registry = state.registry
|
|
703
|
+
app.state.admin_db_session = state.db_session
|
|
704
|
+
app.state.admin_session_factory = session_factory
|
|
705
|
+
app.state.admin_config = state.config
|
|
706
|
+
app.state.admin_jinja_env = state.jinja_env
|
|
707
|
+
# Unified signing-key source for sessions, CSRF, and JWT (see AdminState).
|
|
708
|
+
app.state.admin_secret_key = state.secret_key
|
|
709
|
+
|
|
710
|
+
def _mount_static(self, app: FastAPI) -> None:
|
|
711
|
+
"""Mount the static files directory and uploads directory."""
|
|
712
|
+
static_dir = Path(__file__).parent.parent / "static"
|
|
713
|
+
if static_dir.is_dir():
|
|
714
|
+
app.mount(
|
|
715
|
+
"/static",
|
|
716
|
+
StaticFiles(directory=str(static_dir)),
|
|
717
|
+
name="admin_static",
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# Mount uploads directory if using LocalStorageBackend
|
|
721
|
+
from fastapi_admin_kit.storage.local import LocalStorageBackend
|
|
722
|
+
|
|
723
|
+
if isinstance(self.config.storage.storage, LocalStorageBackend):
|
|
724
|
+
self.config.storage.storage.ensure_dir()
|
|
725
|
+
app.mount(
|
|
726
|
+
self.config.storage.uploads_url,
|
|
727
|
+
StaticFiles(
|
|
728
|
+
directory=str(self.config.storage.storage.upload_dir)
|
|
729
|
+
),
|
|
730
|
+
name="admin_uploads",
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
def _init_jinja(self, app: FastAPI) -> None:
|
|
734
|
+
"""Initialise the Jinja2 template environment."""
|
|
735
|
+
from starlette.templating import Jinja2Templates
|
|
736
|
+
|
|
737
|
+
templates_dir = Path(__file__).parent.parent / "templates"
|
|
738
|
+
self._jinja_env = Jinja2Templates(directory=str(templates_dir))
|
|
739
|
+
|
|
740
|
+
# Disable autoescape — templates are server-controlled, no user XSS risk
|
|
741
|
+
self._jinja_env.env.autoescape = False
|
|
742
|
+
|
|
743
|
+
def slugify(s: str) -> str:
|
|
744
|
+
return re.sub(r"[^\w]", "-", s, flags=re.A).strip("-").lower()
|
|
745
|
+
|
|
746
|
+
def _attr(obj: Any, name: str) -> Any:
|
|
747
|
+
return getattr(obj, name, "")
|
|
748
|
+
|
|
749
|
+
self._jinja_env.env.filters["slugify"] = slugify
|
|
750
|
+
self._jinja_env.env.globals["attr"] = _attr
|
|
751
|
+
from fastapi_admin_kit.inspection import model_display_name
|
|
752
|
+
|
|
753
|
+
self._jinja_env.env.globals["model_display_name"] = model_display_name
|
|
754
|
+
self._jinja_env.env.globals["registered_models"] = self.registry.all()
|
|
755
|
+
self._jinja_env.env.globals["admin_path"] = self.router.admin_path
|
|
756
|
+
self._jinja_env.env.globals["nav_groups"] = self._nav_groups_built
|
|
757
|
+
|
|
758
|
+
# CSRF token helper — reads from request.state (set by CSRFMiddleware)
|
|
759
|
+
def _get_csrf_token(request) -> str:
|
|
760
|
+
return getattr(request.state, "csrf_token", "")
|
|
761
|
+
|
|
762
|
+
self._jinja_env.env.globals["get_csrf_token"] = _get_csrf_token
|
|
763
|
+
|
|
764
|
+
# Flash messages helper (reads from session cookie directly)
|
|
765
|
+
def _get_flash_messages(request) -> list[dict[str, str]]:
|
|
766
|
+
try:
|
|
767
|
+
session_backend = request.app.state.admin_session_backend
|
|
768
|
+
cookie_name = getattr(
|
|
769
|
+
session_backend, "cookie_name", "admin_session"
|
|
770
|
+
)
|
|
771
|
+
raw = request.cookies.get(cookie_name)
|
|
772
|
+
if not raw or not hasattr(session_backend, "load"):
|
|
773
|
+
return []
|
|
774
|
+
data = session_backend.load(raw)
|
|
775
|
+
if not isinstance(data, dict):
|
|
776
|
+
return []
|
|
777
|
+
return (
|
|
778
|
+
data.pop("admin_flash", []) if "admin_flash" in data else []
|
|
779
|
+
)
|
|
780
|
+
except Exception:
|
|
781
|
+
return []
|
|
782
|
+
|
|
783
|
+
self._jinja_env.env.globals["get_flash_messages"] = _get_flash_messages
|
|
784
|
+
|
|
785
|
+
# Material Symbols icon helper
|
|
786
|
+
_icon_map = {
|
|
787
|
+
"home": "home",
|
|
788
|
+
"chart-bar": "bar_chart",
|
|
789
|
+
"clock": "schedule",
|
|
790
|
+
"shield-check": "verified_user",
|
|
791
|
+
"users": "group",
|
|
792
|
+
"folder": "folder",
|
|
793
|
+
"cube": "inventory_2",
|
|
794
|
+
"shopping-cart": "shopping_cart",
|
|
795
|
+
"magnifying-glass": "search",
|
|
796
|
+
"chevron-right": "chevron_right",
|
|
797
|
+
"chevron-left": "chevron_left",
|
|
798
|
+
"chevron-up": "expand_less",
|
|
799
|
+
"chevron-down": "expand_more",
|
|
800
|
+
"ellipsis-vertical": "more_vert",
|
|
801
|
+
"pencil": "edit",
|
|
802
|
+
"trash": "delete",
|
|
803
|
+
"x-mark": "close",
|
|
804
|
+
"x-circle": "cancel",
|
|
805
|
+
"check-circle": "check_circle",
|
|
806
|
+
"check": "check",
|
|
807
|
+
"plus": "add",
|
|
808
|
+
"eye": "visibility",
|
|
809
|
+
"bell": "notifications",
|
|
810
|
+
"sun": "light_mode",
|
|
811
|
+
"moon": "dark_mode",
|
|
812
|
+
"bars-": "menu",
|
|
813
|
+
"bars-3": "menu",
|
|
814
|
+
"arrow-down-tray": "download",
|
|
815
|
+
"arrow-path": "refresh",
|
|
816
|
+
"paper-airplane": "send",
|
|
817
|
+
"exclamation-triangle": "warning",
|
|
818
|
+
"information-circle": "info",
|
|
819
|
+
"document-text": "description",
|
|
820
|
+
"arrow-down": "arrow_downward",
|
|
821
|
+
"arrow-up": "arrow_upward",
|
|
822
|
+
"bolt": "bolt",
|
|
823
|
+
"cog-": "settings",
|
|
824
|
+
"cog-6-tooth": "settings",
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
def _icon(name: str, size: str = "", **kwargs) -> str:
|
|
828
|
+
ms_name = _icon_map.get(name, name)
|
|
829
|
+
css_class = kwargs.get("class", kwargs.get("css_class", ""))
|
|
830
|
+
size_style = f' style="font-size: {size};"' if size else ""
|
|
831
|
+
cls = f"material-symbols-outlined {css_class}".strip()
|
|
832
|
+
return f'<span class="{cls}"{size_style}>{ms_name}</span>'
|
|
833
|
+
|
|
834
|
+
self._jinja_env.env.globals["icon"] = _icon
|
|
835
|
+
|
|
836
|
+
# Admin config global (used by templates for branding, dark mode, etc.)
|
|
837
|
+
admin_cfg = {
|
|
838
|
+
"title": self.config.ui.title,
|
|
839
|
+
"logo_url": self.config.ui.logo_url,
|
|
840
|
+
"favicon_url": self.config.ui.favicon_url,
|
|
841
|
+
"primary_color": self.config.ui.primary_color,
|
|
842
|
+
"primary_color_dark": self.config.ui.primary_color_dark,
|
|
843
|
+
"dark_mode_default": self.config.ui.dark_mode_default,
|
|
844
|
+
"admin_path": self.router.admin_path,
|
|
845
|
+
}
|
|
846
|
+
self._jinja_env.env.globals["admin_config"] = admin_cfg
|
|
847
|
+
|
|
848
|
+
# Static file cache-busting version hash
|
|
849
|
+
import hashlib
|
|
850
|
+
from pathlib import Path as _Path
|
|
851
|
+
|
|
852
|
+
_static_dir = _Path(__file__).parent.parent / "static"
|
|
853
|
+
_hash_data = b""
|
|
854
|
+
for _f in (
|
|
855
|
+
"css/tokens.css",
|
|
856
|
+
"css/presets.css",
|
|
857
|
+
"css/admin.css",
|
|
858
|
+
"js/admin.js",
|
|
859
|
+
):
|
|
860
|
+
_fp = _static_dir / _f
|
|
861
|
+
if _fp.is_file():
|
|
862
|
+
_hash_data += _fp.read_bytes()
|
|
863
|
+
_static_version = (
|
|
864
|
+
hashlib.md5(_hash_data).hexdigest()[:12] if _hash_data else "dev"
|
|
865
|
+
)
|
|
866
|
+
self._jinja_env.env.globals["static_version"] = _static_version
|
|
867
|
+
|
|
868
|
+
# Theme config globals
|
|
869
|
+
self._jinja_env.env.globals["theme_preset"] = "editorial"
|
|
870
|
+
if self.config.ui.theme:
|
|
871
|
+
self._jinja_env.env.globals["theme_css"] = (
|
|
872
|
+
self.config.ui.theme.to_css_variables()
|
|
873
|
+
)
|
|
874
|
+
self._jinja_env.env.globals["theme_font_import_url"] = (
|
|
875
|
+
self.config.ui.theme.font_import_url
|
|
876
|
+
)
|
|
877
|
+
self._jinja_env.env.globals["theme_preset"] = (
|
|
878
|
+
self.config.ui.theme.preset
|
|
879
|
+
)
|
|
880
|
+
self._jinja_env.env.globals["ui_config"] = (
|
|
881
|
+
self.config.ui.apply_to_template_context()
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
app.state.admin_jinja_env = self._jinja_env
|
|
885
|
+
|
|
886
|
+
def _build_router(self, app: FastAPI) -> None:
|
|
887
|
+
"""Build and mount routers for all registered models."""
|
|
888
|
+
if self._router_built:
|
|
889
|
+
return
|
|
890
|
+
|
|
891
|
+
from fastapi_admin_kit.auth.router import router as auth_router
|
|
892
|
+
from fastapi_admin_kit.router import build_model_router
|
|
893
|
+
from fastapi_admin_kit.views.audit import router as audit_router
|
|
894
|
+
from fastapi_admin_kit.views.profile import router as profile_router
|
|
895
|
+
from fastapi_admin_kit.views.roles import router as roles_router
|
|
896
|
+
from fastapi_admin_kit.views.settings import router as settings_router
|
|
897
|
+
from fastapi_admin_kit.views.totp import router as totp_router
|
|
898
|
+
from fastapi_admin_kit.views.users import router as users_router
|
|
899
|
+
|
|
900
|
+
for registered in self.registry.all():
|
|
901
|
+
if getattr(registered.admin, "skip_auto_routes", False):
|
|
902
|
+
continue
|
|
903
|
+
model_router = build_model_router(registered)
|
|
904
|
+
app.include_router(model_router, prefix=self.router.admin_path)
|
|
905
|
+
|
|
906
|
+
# Auth routes (login/logout)
|
|
907
|
+
app.include_router(auth_router, prefix=self.router.admin_path)
|
|
908
|
+
|
|
909
|
+
# Global search API
|
|
910
|
+
from fastapi_admin_kit.api.search import router as search_api_router
|
|
911
|
+
|
|
912
|
+
app.include_router(search_api_router, prefix=self.router.admin_path)
|
|
913
|
+
|
|
914
|
+
# Audit, role management, settings, user management, profile, and 2FA routes
|
|
915
|
+
app.include_router(audit_router, prefix=self.router.admin_path)
|
|
916
|
+
app.include_router(roles_router, prefix=self.router.admin_path)
|
|
917
|
+
app.include_router(settings_router, prefix=self.router.admin_path)
|
|
918
|
+
app.include_router(users_router, prefix=self.router.admin_path)
|
|
919
|
+
app.include_router(profile_router, prefix=self.router.admin_path)
|
|
920
|
+
app.include_router(totp_router, prefix=self.router.admin_path)
|
|
921
|
+
|
|
922
|
+
# Dashboard route
|
|
923
|
+
from fastapi_admin_kit.views.dashboard import dashboard_view_factory
|
|
924
|
+
|
|
925
|
+
dashboard_view = dashboard_view_factory(self)
|
|
926
|
+
app.add_api_route(
|
|
927
|
+
self.router.admin_path,
|
|
928
|
+
dashboard_view,
|
|
929
|
+
methods=["GET"],
|
|
930
|
+
tags=["admin"],
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
# JSON API for external frontend apps
|
|
934
|
+
from fastapi_admin_kit.api import AdminAPIRouter
|
|
935
|
+
|
|
936
|
+
api_router = AdminAPIRouter(registry=self.registry)
|
|
937
|
+
app.include_router(api_router.build_router())
|
|
938
|
+
|
|
939
|
+
self._router_built = True
|
|
940
|
+
|
|
941
|
+
# ------------------------------------------------------------------
|
|
942
|
+
# Built-in model registration
|
|
943
|
+
# ------------------------------------------------------------------
|
|
944
|
+
|
|
945
|
+
def _register_builtin_models(self) -> None:
|
|
946
|
+
"""Auto-register built-in admin models with default admin classes."""
|
|
947
|
+
from fastapi_admin_kit.admin.builtin_models import (
|
|
948
|
+
AdminLoginAttemptAdmin,
|
|
949
|
+
AdminPermissionAdmin,
|
|
950
|
+
AdminRefreshTokenAdmin,
|
|
951
|
+
AdminRoleAdmin,
|
|
952
|
+
AdminUserAdmin,
|
|
953
|
+
AdminUserPermissionAdmin,
|
|
954
|
+
AdminUserTOTPAdmin,
|
|
955
|
+
AuditLogAdmin,
|
|
956
|
+
)
|
|
957
|
+
from fastapi_admin_kit.audit.models import AuditLog
|
|
958
|
+
from fastapi_admin_kit.auth.models import (
|
|
959
|
+
AdminLoginAttempt,
|
|
960
|
+
AdminPermission,
|
|
961
|
+
AdminRefreshToken,
|
|
962
|
+
AdminRole,
|
|
963
|
+
AdminUser,
|
|
964
|
+
AdminUserPermission,
|
|
965
|
+
AdminUserTOTP,
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
builtin_models = [
|
|
969
|
+
(AdminUser, AdminUserAdmin),
|
|
970
|
+
(AdminRole, AdminRoleAdmin),
|
|
971
|
+
(AdminRefreshToken, AdminRefreshTokenAdmin),
|
|
972
|
+
(AdminPermission, AdminPermissionAdmin),
|
|
973
|
+
(AdminUserPermission, AdminUserPermissionAdmin),
|
|
974
|
+
(AdminUserTOTP, AdminUserTOTPAdmin),
|
|
975
|
+
(AdminLoginAttempt, AdminLoginAttemptAdmin),
|
|
976
|
+
(AuditLog, AuditLogAdmin),
|
|
977
|
+
]
|
|
978
|
+
|
|
979
|
+
for model, admin_class in builtin_models:
|
|
980
|
+
if model.__tablename__ not in self.registry._models:
|
|
981
|
+
self.registry.register(model, admin_class)
|
|
982
|
+
|
|
983
|
+
# ------------------------------------------------------------------
|
|
984
|
+
# Tags validation
|
|
985
|
+
# ------------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
def _validate_tags(self) -> None:
|
|
988
|
+
"""Raise ConfigError if any registered model has no tag (when require_tags=True)."""
|
|
989
|
+
untagged: list[str] = []
|
|
990
|
+
for registered in self.registry.all():
|
|
991
|
+
admin = registered.admin
|
|
992
|
+
tags = getattr(admin, "tags", None)
|
|
993
|
+
tag = getattr(admin, "tag", None)
|
|
994
|
+
if not tags and not tag:
|
|
995
|
+
untagged.append(registered.table_name)
|
|
996
|
+
if untagged:
|
|
997
|
+
raise ConfigError(
|
|
998
|
+
"require_tags=True but the following models have no tag: "
|
|
999
|
+
+ ", ".join(sorted(untagged))
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
# ------------------------------------------------------------------
|
|
1003
|
+
# Sidebar
|
|
1004
|
+
# ------------------------------------------------------------------
|
|
1005
|
+
|
|
1006
|
+
def _build_sidebar(self) -> list:
|
|
1007
|
+
"""Build the sidebar group structure once at startup."""
|
|
1008
|
+
from fastapi_admin_kit.nav import DefaultSidebarBuilder
|
|
1009
|
+
|
|
1010
|
+
builder = self.config.nav.sidebar_builder or DefaultSidebarBuilder()
|
|
1011
|
+
return builder.build(
|
|
1012
|
+
self.registry.all(),
|
|
1013
|
+
self.config.nav.nav_groups,
|
|
1014
|
+
admin_path=self.router.admin_path,
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
def build_sidebar_context(
|
|
1018
|
+
self,
|
|
1019
|
+
request: Any,
|
|
1020
|
+
user: Any = None,
|
|
1021
|
+
permissions_map: dict | None = None,
|
|
1022
|
+
) -> dict:
|
|
1023
|
+
"""Build per-request sidebar context (RBAC filter + permissions map)."""
|
|
1024
|
+
return self.template.build_sidebar_context(
|
|
1025
|
+
request, user=user, permissions_map=permissions_map
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
def sidebar_template_kwargs(self, request: Any) -> dict[str, Any]:
|
|
1029
|
+
"""Thin wrapper — returns sidebar kwargs for TemplateResponse contexts."""
|
|
1030
|
+
return self.template.sidebar_template_kwargs(request)
|
|
1031
|
+
|
|
1032
|
+
def apply_sidebar_context(
|
|
1033
|
+
self, request: Any, user: Any, context: dict
|
|
1034
|
+
) -> dict:
|
|
1035
|
+
"""Inject nav_groups + permissions_map into a template context dict."""
|
|
1036
|
+
return self.template.apply_sidebar_context(request, user, context)
|