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,728 @@
|
|
|
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.exceptions import ConfigError
|
|
17
|
+
from fastapi_admin_kit.registry import AdminRegistry, RegisteredModel
|
|
18
|
+
from fastapi_admin_kit.types import SeedRole
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from sqlalchemy.engine import Engine
|
|
22
|
+
|
|
23
|
+
from fastapi_admin_kit.auth.backend import AuthBackend
|
|
24
|
+
from fastapi_admin_kit.nav import NavGroupConfig, SidebarBuilder
|
|
25
|
+
from fastapi_admin_kit.storage.base import StorageBackend
|
|
26
|
+
from fastapi_admin_kit.views import ModelAdmin
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Default seed roles per AUTH_RBAC_SYSTEM.md §13
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
DEFAULT_SEED_ROLES: list[SeedRole] = [
|
|
34
|
+
SeedRole(
|
|
35
|
+
name="SuperAdmin",
|
|
36
|
+
description="Full system access — equivalent to is_superuser=True",
|
|
37
|
+
permissions={}, # empty = all permissions (superuser)
|
|
38
|
+
),
|
|
39
|
+
SeedRole(
|
|
40
|
+
name="Admin",
|
|
41
|
+
description="Site administration — all permissions except admin_users",
|
|
42
|
+
permissions={
|
|
43
|
+
"admin_users": {
|
|
44
|
+
"view": True,
|
|
45
|
+
"create": False,
|
|
46
|
+
"edit": False,
|
|
47
|
+
"delete": False,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
),
|
|
51
|
+
SeedRole(
|
|
52
|
+
name="Editor",
|
|
53
|
+
description="Content editing — full CRUD on non-system models",
|
|
54
|
+
permissions={}, # non-system models get full CRUD
|
|
55
|
+
),
|
|
56
|
+
SeedRole(
|
|
57
|
+
name="Viewer",
|
|
58
|
+
description="Read-only access",
|
|
59
|
+
permissions={}, # view-only for all models
|
|
60
|
+
),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _RegistrationProxy:
|
|
65
|
+
"""Dual-purpose return value from Admin.register().
|
|
66
|
+
|
|
67
|
+
Acts as a proxy to the underlying RegisteredModel so attribute access
|
|
68
|
+
(``.model``, ``.admin``, etc.) works transparently. Also supports
|
|
69
|
+
use as a class decorator::
|
|
70
|
+
|
|
71
|
+
@admin.register(Product)
|
|
72
|
+
class ProductAdmin(ModelAdmin): ...
|
|
73
|
+
|
|
74
|
+
When called with a class, it re-registers with that admin class and
|
|
75
|
+
returns the resulting RegisteredModel.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, admin: Admin, registered: RegisteredModel) -> None:
|
|
79
|
+
object.__setattr__(self, "_admin", admin)
|
|
80
|
+
object.__setattr__(self, "_registered", registered)
|
|
81
|
+
|
|
82
|
+
def __call__(self, admin_class: type[ModelAdmin]) -> RegisteredModel:
|
|
83
|
+
reg: AdminRegistry = self._admin.registry
|
|
84
|
+
registered = reg.register(self._registered.model, admin_class)
|
|
85
|
+
object.__setattr__(self, "_registered", registered)
|
|
86
|
+
return registered
|
|
87
|
+
|
|
88
|
+
def __getattr__(self, name: str) -> Any:
|
|
89
|
+
return getattr(self._registered, name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Admin:
|
|
93
|
+
"""Main admin interface. Register models and mount to your FastAPI app.
|
|
94
|
+
|
|
95
|
+
Full configuration kwargs from ``AUTH_RBAC_SYSTEM.md`` §17 and
|
|
96
|
+
``fastapi_admin_kit_core_spec.md`` §3.12.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
app: FastAPI | None = None,
|
|
102
|
+
engine: Engine | None = None,
|
|
103
|
+
*,
|
|
104
|
+
# Database
|
|
105
|
+
base: type | None = None, # User's SQLAlchemy DeclarativeBase
|
|
106
|
+
# Branding
|
|
107
|
+
title: str = "FastAPI Admin",
|
|
108
|
+
logo_url: str | None = None,
|
|
109
|
+
favicon_url: str | None = None,
|
|
110
|
+
primary_color: str = "#0ea5e9",
|
|
111
|
+
primary_color_dark: str = "#0284c7",
|
|
112
|
+
# Behavior
|
|
113
|
+
dark_mode_default: bool = False,
|
|
114
|
+
per_page_default: int = 25,
|
|
115
|
+
session_ttl: int = 28800,
|
|
116
|
+
audit_retention_days: int = 365,
|
|
117
|
+
# Dashboard
|
|
118
|
+
dashboard_stats: list[str] | None = None,
|
|
119
|
+
dashboard_charts: bool = True,
|
|
120
|
+
# Security
|
|
121
|
+
admin_path: str = "/admin",
|
|
122
|
+
secret_key: str = "",
|
|
123
|
+
# Auth
|
|
124
|
+
auth_model: type | None = None,
|
|
125
|
+
auth_backend: AuthBackend | None = None,
|
|
126
|
+
session_cookie_name: str = "admin_session",
|
|
127
|
+
session_secure: bool = False,
|
|
128
|
+
# RBAC
|
|
129
|
+
seed_roles: list[SeedRole] | None = None,
|
|
130
|
+
seed_roles_overwrite: bool = False,
|
|
131
|
+
superuser_emails: list[str] | None = None,
|
|
132
|
+
# Storage
|
|
133
|
+
storage: StorageBackend | None = None,
|
|
134
|
+
uploads_url: str = "/uploads",
|
|
135
|
+
# Behavior flags
|
|
136
|
+
auto_discover: bool = True,
|
|
137
|
+
# Nav / sidebar
|
|
138
|
+
nav_groups: list[NavGroupConfig] | None = None,
|
|
139
|
+
sidebar_builder: SidebarBuilder | None = None,
|
|
140
|
+
require_tags: bool = False,
|
|
141
|
+
):
|
|
142
|
+
self.registry = AdminRegistry()
|
|
143
|
+
self.engine = engine
|
|
144
|
+
self.base = base # Store user's Base for table creation
|
|
145
|
+
self._app: FastAPI | None = app
|
|
146
|
+
|
|
147
|
+
# Branding
|
|
148
|
+
self.title = title
|
|
149
|
+
self.logo_url = logo_url
|
|
150
|
+
self.favicon_url = favicon_url
|
|
151
|
+
self.primary_color = primary_color
|
|
152
|
+
self.primary_color_dark = primary_color_dark
|
|
153
|
+
|
|
154
|
+
# Behavior
|
|
155
|
+
self.dark_mode_default = dark_mode_default
|
|
156
|
+
self.per_page_default = per_page_default
|
|
157
|
+
self.session_ttl = session_ttl
|
|
158
|
+
self.audit_retention_days = audit_retention_days
|
|
159
|
+
|
|
160
|
+
# Dashboard
|
|
161
|
+
self.dashboard_stats = dashboard_stats or []
|
|
162
|
+
self.dashboard_charts = dashboard_charts
|
|
163
|
+
|
|
164
|
+
# Security
|
|
165
|
+
self.admin_path = admin_path.rstrip("/")
|
|
166
|
+
self.secret_key = secret_key or os.environ.get("SECRET_KEY", "")
|
|
167
|
+
|
|
168
|
+
# Auth
|
|
169
|
+
self.auth_model = auth_model
|
|
170
|
+
self.auth_backend = auth_backend
|
|
171
|
+
self.session_cookie_name = session_cookie_name
|
|
172
|
+
self.session_secure = session_secure
|
|
173
|
+
|
|
174
|
+
# RBAC
|
|
175
|
+
self.seed_roles = (
|
|
176
|
+
seed_roles if seed_roles is not None else DEFAULT_SEED_ROLES
|
|
177
|
+
)
|
|
178
|
+
self.seed_roles_overwrite = seed_roles_overwrite
|
|
179
|
+
self.superuser_emails = superuser_emails or []
|
|
180
|
+
|
|
181
|
+
# Storage
|
|
182
|
+
self.storage = storage
|
|
183
|
+
self.uploads_url = uploads_url
|
|
184
|
+
|
|
185
|
+
# Flags
|
|
186
|
+
self.auto_discover = auto_discover
|
|
187
|
+
|
|
188
|
+
# Nav / sidebar
|
|
189
|
+
self.nav_groups = nav_groups or []
|
|
190
|
+
self.sidebar_builder = sidebar_builder
|
|
191
|
+
self.require_tags = require_tags
|
|
192
|
+
|
|
193
|
+
# Built sidebar (populated during setup)
|
|
194
|
+
self._nav_groups_built: list[Any] = []
|
|
195
|
+
|
|
196
|
+
# Internal state (populated during setup)
|
|
197
|
+
self._session_backend: Any = None
|
|
198
|
+
self._jinja_env: Environment | None = None
|
|
199
|
+
self._router_built: bool = False
|
|
200
|
+
|
|
201
|
+
if app is not None and engine is not None:
|
|
202
|
+
# Deferred setup — user will call await admin.setup() via lifespan
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# Setup (async)
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
async def setup(self, app: FastAPI | None = None) -> None:
|
|
210
|
+
"""Run all startup wiring: create tables, seed roles, mount assets.
|
|
211
|
+
|
|
212
|
+
This must be called once during application lifespan, typically via
|
|
213
|
+
the :meth:`lifespan` context manager.
|
|
214
|
+
"""
|
|
215
|
+
if app is not None:
|
|
216
|
+
self._app = app
|
|
217
|
+
|
|
218
|
+
if self._app is None:
|
|
219
|
+
raise ConfigError(
|
|
220
|
+
"Admin requires a FastAPI app instance. Pass app= or call setup(app=)."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if self.engine is None:
|
|
224
|
+
raise ConfigError(
|
|
225
|
+
"Admin requires a SQLAlchemy engine. Pass engine= to Admin()."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
app = self._app
|
|
229
|
+
|
|
230
|
+
# 1. Validate auth_model satisfies AdminUserProtocol
|
|
231
|
+
self._validate_auth_model()
|
|
232
|
+
|
|
233
|
+
# 2. Database tables should be created via Alembic migrations
|
|
234
|
+
# (Skip _create_tables if using migrations)
|
|
235
|
+
skip_create_tables = (
|
|
236
|
+
os.environ.get("SKIP_CREATE_TABLES", "true").lower() == "true"
|
|
237
|
+
)
|
|
238
|
+
if not skip_create_tables:
|
|
239
|
+
await self._create_tables()
|
|
240
|
+
|
|
241
|
+
# 3. Seed default roles
|
|
242
|
+
await self._seed_roles()
|
|
243
|
+
|
|
244
|
+
# 4. Create and store session backend
|
|
245
|
+
self._init_session_backend()
|
|
246
|
+
|
|
247
|
+
# 5. Store backends and config on app.state
|
|
248
|
+
self._wire_app_state(app)
|
|
249
|
+
|
|
250
|
+
# 6. Mount static files
|
|
251
|
+
self._mount_static(app)
|
|
252
|
+
|
|
253
|
+
# 7. Initialise Jinja2
|
|
254
|
+
self._init_jinja(app)
|
|
255
|
+
|
|
256
|
+
# 8. Auto-discover models
|
|
257
|
+
if self.auto_discover:
|
|
258
|
+
self.registry.auto_discover()
|
|
259
|
+
|
|
260
|
+
# 9. Validate require_tags
|
|
261
|
+
if self.require_tags:
|
|
262
|
+
self._validate_tags()
|
|
263
|
+
|
|
264
|
+
# 10. Build sidebar structure (once at startup)
|
|
265
|
+
self._nav_groups_built = self._build_sidebar()
|
|
266
|
+
if self._jinja_env:
|
|
267
|
+
self._jinja_env.env.globals["nav_groups"] = self._nav_groups_built
|
|
268
|
+
|
|
269
|
+
# 11. Build and mount routers
|
|
270
|
+
self._build_router(app)
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
# Register
|
|
274
|
+
# ------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def register(
|
|
277
|
+
self,
|
|
278
|
+
model: type,
|
|
279
|
+
admin_class: type[ModelAdmin] | None = None,
|
|
280
|
+
) -> _RegistrationProxy | RegisteredModel:
|
|
281
|
+
"""Register a model with the admin.
|
|
282
|
+
|
|
283
|
+
Usage::
|
|
284
|
+
|
|
285
|
+
admin.register(Product)
|
|
286
|
+
|
|
287
|
+
@admin.register(Product)
|
|
288
|
+
class ProductAdmin(ModelAdmin):
|
|
289
|
+
list_display = ["name", "price"]
|
|
290
|
+
"""
|
|
291
|
+
if admin_class is not None:
|
|
292
|
+
registered = self.registry.register(model, admin_class)
|
|
293
|
+
else:
|
|
294
|
+
registered = self.registry.register(model)
|
|
295
|
+
if self._jinja_env:
|
|
296
|
+
self._jinja_env.env.globals["registered_models"] = (
|
|
297
|
+
self.registry.all()
|
|
298
|
+
)
|
|
299
|
+
if self._nav_groups_built:
|
|
300
|
+
self._nav_groups_built = self._build_sidebar()
|
|
301
|
+
self._jinja_env.env.globals["nav_groups"] = (
|
|
302
|
+
self._nav_groups_built
|
|
303
|
+
)
|
|
304
|
+
if admin_class is not None:
|
|
305
|
+
return registered
|
|
306
|
+
return _RegistrationProxy(self, registered)
|
|
307
|
+
|
|
308
|
+
# ------------------------------------------------------------------
|
|
309
|
+
# Lifespan
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
@asynccontextmanager
|
|
313
|
+
async def lifespan(self, app: FastAPI) -> AsyncIterator[None]:
|
|
314
|
+
"""FastAPI lifespan context manager.
|
|
315
|
+
|
|
316
|
+
Usage::
|
|
317
|
+
|
|
318
|
+
app = FastAPI(lifespan=admin.lifespan)
|
|
319
|
+
"""
|
|
320
|
+
await self.setup(app)
|
|
321
|
+
yield
|
|
322
|
+
|
|
323
|
+
# ------------------------------------------------------------------
|
|
324
|
+
# Query helpers
|
|
325
|
+
# ------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def get_registered(self, table_name: str) -> RegisteredModel | None:
|
|
328
|
+
"""Get a registered model by table name."""
|
|
329
|
+
return self.registry.get(table_name)
|
|
330
|
+
|
|
331
|
+
def all_registered(self) -> list[RegisteredModel]:
|
|
332
|
+
"""Get all registered models."""
|
|
333
|
+
return self.registry.all()
|
|
334
|
+
|
|
335
|
+
# ------------------------------------------------------------------
|
|
336
|
+
# Internal wiring
|
|
337
|
+
# ------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
def _validate_auth_model(self) -> None:
|
|
340
|
+
"""Validate that auth_model satisfies AdminUserProtocol."""
|
|
341
|
+
model = self.auth_model
|
|
342
|
+
if model is None:
|
|
343
|
+
# Default — no validation needed, built-in AdminUser is used
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
required_attrs = ["id", "email", "is_active", "is_superuser", "roles"]
|
|
347
|
+
missing = [attr for attr in required_attrs if not hasattr(model, attr)]
|
|
348
|
+
if missing:
|
|
349
|
+
raise ConfigError(
|
|
350
|
+
f"auth_model {model.__name__!r} does not satisfy AdminUserProtocol. "
|
|
351
|
+
f"Missing attributes: {', '.join(missing)}"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
async def _create_tables(self) -> None:
|
|
355
|
+
"""Create all admin database tables (async-safe)."""
|
|
356
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
357
|
+
|
|
358
|
+
from fastapi_admin_kit.audit import (
|
|
359
|
+
models as _audit_models, # noqa: F401
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Import models to register them with metadata
|
|
363
|
+
from fastapi_admin_kit.auth import models as _auth_models # noqa: F401
|
|
364
|
+
from fastapi_admin_kit.models.base import Base as AdminBase
|
|
365
|
+
|
|
366
|
+
if isinstance(self.engine, AsyncEngine):
|
|
367
|
+
# Async engine - use run_sync
|
|
368
|
+
async with self.engine.begin() as conn:
|
|
369
|
+
# Create admin tables
|
|
370
|
+
await conn.run_sync(AdminBase.metadata.create_all)
|
|
371
|
+
# Create user tables if Base is provided
|
|
372
|
+
if self.base is not None:
|
|
373
|
+
await conn.run_sync(self.base.metadata.create_all)
|
|
374
|
+
else:
|
|
375
|
+
# Sync engine - direct call
|
|
376
|
+
AdminBase.metadata.create_all(bind=self.engine)
|
|
377
|
+
if self.base is not None:
|
|
378
|
+
self.base.metadata.create_all(bind=self.engine)
|
|
379
|
+
|
|
380
|
+
async def _seed_roles(self) -> None:
|
|
381
|
+
"""Seed default roles if none exist (or if overwrite is enabled)."""
|
|
382
|
+
from sqlalchemy import select
|
|
383
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
384
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
385
|
+
|
|
386
|
+
from fastapi_admin_kit.auth.models import AdminPermission, AdminRole
|
|
387
|
+
|
|
388
|
+
is_async = isinstance(self.engine, AsyncEngine)
|
|
389
|
+
|
|
390
|
+
if is_async:
|
|
391
|
+
# Use AsyncSession for async engine
|
|
392
|
+
session_local = sessionmaker(
|
|
393
|
+
self.engine, class_=AsyncSession, expire_on_commit=False
|
|
394
|
+
)
|
|
395
|
+
async with session_local() as session:
|
|
396
|
+
# Check existing count
|
|
397
|
+
result = await session.execute(select(AdminRole))
|
|
398
|
+
existing_count = len(result.scalars().all())
|
|
399
|
+
|
|
400
|
+
if existing_count > 0 and not self.seed_roles_overwrite:
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
if self.seed_roles_overwrite:
|
|
404
|
+
await session.execute(select(AdminRole).delete())
|
|
405
|
+
|
|
406
|
+
for role_spec in self.seed_roles:
|
|
407
|
+
role = AdminRole(
|
|
408
|
+
name=role_spec.name, description=role_spec.description
|
|
409
|
+
)
|
|
410
|
+
session.add(role)
|
|
411
|
+
await session.flush() # get role.id
|
|
412
|
+
|
|
413
|
+
if role_spec.permissions:
|
|
414
|
+
for table_name, perms in role_spec.permissions.items():
|
|
415
|
+
perm = AdminPermission(
|
|
416
|
+
role_id=role.id,
|
|
417
|
+
table_name=table_name,
|
|
418
|
+
can_view=perms.get("view", False),
|
|
419
|
+
can_create=perms.get("create", False),
|
|
420
|
+
can_edit=perms.get("edit", False),
|
|
421
|
+
can_delete=perms.get("delete", False),
|
|
422
|
+
)
|
|
423
|
+
session.add(perm)
|
|
424
|
+
|
|
425
|
+
await session.commit()
|
|
426
|
+
else:
|
|
427
|
+
# Use sync Session for sync engine
|
|
428
|
+
session = Session(bind=self.engine)
|
|
429
|
+
try:
|
|
430
|
+
existing_count = session.query(AdminRole).count()
|
|
431
|
+
|
|
432
|
+
if existing_count > 0 and not self.seed_roles_overwrite:
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
if self.seed_roles_overwrite:
|
|
436
|
+
session.query(AdminRole).delete()
|
|
437
|
+
|
|
438
|
+
for role_spec in self.seed_roles:
|
|
439
|
+
role = AdminRole(
|
|
440
|
+
name=role_spec.name, description=role_spec.description
|
|
441
|
+
)
|
|
442
|
+
session.add(role)
|
|
443
|
+
session.flush() # get role.id
|
|
444
|
+
|
|
445
|
+
if role_spec.permissions:
|
|
446
|
+
for table_name, perms in role_spec.permissions.items():
|
|
447
|
+
perm = AdminPermission(
|
|
448
|
+
role_id=role.id,
|
|
449
|
+
table_name=table_name,
|
|
450
|
+
can_view=perms.get("view", False),
|
|
451
|
+
can_create=perms.get("create", False),
|
|
452
|
+
can_edit=perms.get("edit", False),
|
|
453
|
+
can_delete=perms.get("delete", False),
|
|
454
|
+
)
|
|
455
|
+
session.add(perm)
|
|
456
|
+
|
|
457
|
+
session.commit()
|
|
458
|
+
finally:
|
|
459
|
+
session.close()
|
|
460
|
+
|
|
461
|
+
def _init_session_backend(self) -> None:
|
|
462
|
+
"""Create and store the signed-cookie session backend."""
|
|
463
|
+
from fastapi_admin_kit.auth.session import SignedCookieSessionBackend
|
|
464
|
+
|
|
465
|
+
self._session_backend = SignedCookieSessionBackend(
|
|
466
|
+
secret_key=self.secret_key,
|
|
467
|
+
session_ttl=self.session_ttl,
|
|
468
|
+
cookie_name=self.session_cookie_name,
|
|
469
|
+
secure=self.session_secure,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def _wire_app_state(self, app: FastAPI) -> None:
|
|
473
|
+
"""Store backends and configuration on app.state."""
|
|
474
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
475
|
+
|
|
476
|
+
app.state.admin_engine = self.engine
|
|
477
|
+
app.state.admin_session_backend = self._session_backend
|
|
478
|
+
app.state.admin_auth_backend = self.auth_backend
|
|
479
|
+
app.state.admin_storage = self.storage
|
|
480
|
+
app.state.admin_registry = self.registry
|
|
481
|
+
|
|
482
|
+
# Async session for views (reused per-request via dependency)
|
|
483
|
+
app.state.admin_db_session = AsyncSession(
|
|
484
|
+
self.engine, expire_on_commit=False
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
app.state.admin_config = {
|
|
488
|
+
"title": self.title,
|
|
489
|
+
"logo_url": self.logo_url,
|
|
490
|
+
"favicon_url": self.favicon_url,
|
|
491
|
+
"primary_color": self.primary_color,
|
|
492
|
+
"primary_color_dark": self.primary_color_dark,
|
|
493
|
+
"dark_mode_default": self.dark_mode_default,
|
|
494
|
+
"per_page_default": self.per_page_default,
|
|
495
|
+
"session_ttl": self.session_ttl,
|
|
496
|
+
"audit_retention_days": self.audit_retention_days,
|
|
497
|
+
"dashboard_stats": self.dashboard_stats,
|
|
498
|
+
"dashboard_charts": self.dashboard_charts,
|
|
499
|
+
"admin_path": self.admin_path,
|
|
500
|
+
"superuser_emails": self.superuser_emails,
|
|
501
|
+
}
|
|
502
|
+
app.state.admin = self
|
|
503
|
+
|
|
504
|
+
def _mount_static(self, app: FastAPI) -> None:
|
|
505
|
+
"""Mount the static files directory and uploads directory.
|
|
506
|
+
|
|
507
|
+
Templates reference ``/static/...`` directly, so we mount at the
|
|
508
|
+
root ``/static`` path. The ``{admin_path}/static`` alias is kept
|
|
509
|
+
for backwards compatibility.
|
|
510
|
+
"""
|
|
511
|
+
static_dir = Path(__file__).parent / "static"
|
|
512
|
+
if static_dir.is_dir():
|
|
513
|
+
# Primary mount — matches template references (/static/...)
|
|
514
|
+
app.mount(
|
|
515
|
+
"/static",
|
|
516
|
+
StaticFiles(directory=str(static_dir)),
|
|
517
|
+
name="admin_static",
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Mount uploads directory if using LocalStorageBackend
|
|
521
|
+
from fastapi_admin_kit.storage.local import LocalStorageBackend
|
|
522
|
+
|
|
523
|
+
if isinstance(self.storage, LocalStorageBackend):
|
|
524
|
+
self.storage.ensure_dir()
|
|
525
|
+
app.mount(
|
|
526
|
+
self.uploads_url,
|
|
527
|
+
StaticFiles(directory=str(self.storage.upload_dir)),
|
|
528
|
+
name="admin_uploads",
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
def _init_jinja(self, app: FastAPI) -> None:
|
|
532
|
+
"""Initialise the Jinja2 template environment."""
|
|
533
|
+
from starlette.templating import Jinja2Templates
|
|
534
|
+
|
|
535
|
+
templates_dir = Path(__file__).parent / "templates"
|
|
536
|
+
self._jinja_env = Jinja2Templates(directory=str(templates_dir))
|
|
537
|
+
|
|
538
|
+
def slugify(s: str) -> str:
|
|
539
|
+
return re.sub(r"[^\w]", "-", s, flags=re.A).strip("-").lower()
|
|
540
|
+
|
|
541
|
+
self._jinja_env.env.filters["slugify"] = slugify
|
|
542
|
+
self._jinja_env.env.globals["registered_models"] = self.registry.all()
|
|
543
|
+
self._jinja_env.env.globals["admin_path"] = self.admin_path
|
|
544
|
+
self._jinja_env.env.globals["nav_groups"] = self._nav_groups_built
|
|
545
|
+
app.state.admin_jinja_env = self._jinja_env
|
|
546
|
+
|
|
547
|
+
def _build_router(self, app: FastAPI) -> None:
|
|
548
|
+
"""Build and mount routers for all registered models."""
|
|
549
|
+
if self._router_built:
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
from fastapi_admin_kit.auth.router import router as auth_router
|
|
553
|
+
from fastapi_admin_kit.router import build_model_router
|
|
554
|
+
from fastapi_admin_kit.views.audit import router as audit_router
|
|
555
|
+
from fastapi_admin_kit.views.roles import router as roles_router
|
|
556
|
+
|
|
557
|
+
for registered in self.registry.all():
|
|
558
|
+
model_router = build_model_router(registered)
|
|
559
|
+
app.include_router(model_router, prefix=self.admin_path)
|
|
560
|
+
|
|
561
|
+
# Auth routes (login/logout)
|
|
562
|
+
app.include_router(auth_router, prefix=self.admin_path)
|
|
563
|
+
|
|
564
|
+
# Global search API
|
|
565
|
+
from fastapi_admin_kit.api.search import router as search_api_router
|
|
566
|
+
|
|
567
|
+
app.include_router(search_api_router, prefix=self.admin_path)
|
|
568
|
+
|
|
569
|
+
# Audit & role management routes
|
|
570
|
+
app.include_router(audit_router, prefix=self.admin_path)
|
|
571
|
+
app.include_router(roles_router, prefix=self.admin_path)
|
|
572
|
+
|
|
573
|
+
# Dashboard route
|
|
574
|
+
from fastapi_admin_kit.views.dashboard import dashboard_view_factory
|
|
575
|
+
|
|
576
|
+
dashboard_view = dashboard_view_factory(self)
|
|
577
|
+
app.add_api_route(
|
|
578
|
+
self.admin_path,
|
|
579
|
+
dashboard_view,
|
|
580
|
+
methods=["GET"],
|
|
581
|
+
tags=["admin"],
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
self._router_built = True
|
|
585
|
+
|
|
586
|
+
# ------------------------------------------------------------------
|
|
587
|
+
# Tags validation
|
|
588
|
+
# ------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
def _validate_tags(self) -> None:
|
|
591
|
+
"""Raise ConfigError if any registered model has no tag (when require_tags=True)."""
|
|
592
|
+
untagged: list[str] = []
|
|
593
|
+
for registered in self.registry.all():
|
|
594
|
+
admin = registered.admin
|
|
595
|
+
tags = getattr(admin, "tags", None)
|
|
596
|
+
tag = getattr(admin, "tag", None)
|
|
597
|
+
if not tags and not tag:
|
|
598
|
+
untagged.append(registered.table_name)
|
|
599
|
+
if untagged:
|
|
600
|
+
raise ConfigError(
|
|
601
|
+
"require_tags=True but the following models have no tag: "
|
|
602
|
+
+ ", ".join(sorted(untagged))
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# ------------------------------------------------------------------
|
|
606
|
+
# Sidebar
|
|
607
|
+
# ------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
def _build_sidebar(self) -> list:
|
|
610
|
+
"""Build the sidebar group structure once at startup."""
|
|
611
|
+
from fastapi_admin_kit.nav import DefaultSidebarBuilder
|
|
612
|
+
|
|
613
|
+
builder = self.sidebar_builder or DefaultSidebarBuilder()
|
|
614
|
+
return builder.build(
|
|
615
|
+
self.registry.all(), self.nav_groups, admin_path=self.admin_path
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def build_sidebar_context(
|
|
619
|
+
self,
|
|
620
|
+
request: Any,
|
|
621
|
+
user: Any = None,
|
|
622
|
+
permissions_map: dict | None = None,
|
|
623
|
+
) -> dict:
|
|
624
|
+
"""Build per-request sidebar context (RBAC filter + permissions map)."""
|
|
625
|
+
if user is None:
|
|
626
|
+
user = getattr(request.state, "admin_user", None)
|
|
627
|
+
|
|
628
|
+
snapshot = getattr(request.state, "admin_user_snapshot", None)
|
|
629
|
+
is_superuser = (
|
|
630
|
+
(
|
|
631
|
+
bool(snapshot.get("is_superuser", False))
|
|
632
|
+
if snapshot
|
|
633
|
+
else bool(getattr(user, "is_superuser", False))
|
|
634
|
+
)
|
|
635
|
+
if user
|
|
636
|
+
else False
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
from fastapi_admin_kit.types import PermissionSet
|
|
640
|
+
|
|
641
|
+
nav_groups = self._nav_groups_built
|
|
642
|
+
|
|
643
|
+
if permissions_map is None:
|
|
644
|
+
permissions_map = {}
|
|
645
|
+
|
|
646
|
+
if user and not is_superuser:
|
|
647
|
+
role_ids = (
|
|
648
|
+
snapshot.get("role_ids", [])
|
|
649
|
+
if snapshot
|
|
650
|
+
else getattr(user, "role_ids", [])
|
|
651
|
+
)
|
|
652
|
+
if role_ids:
|
|
653
|
+
try:
|
|
654
|
+
from sqlalchemy import select
|
|
655
|
+
from sqlalchemy.orm import Session
|
|
656
|
+
|
|
657
|
+
from fastapi_admin_kit.auth.models import (
|
|
658
|
+
AdminPermission,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
engine = request.app.state.admin_engine
|
|
662
|
+
with Session(engine) as s:
|
|
663
|
+
result = s.execute(
|
|
664
|
+
select(AdminPermission).filter(
|
|
665
|
+
AdminPermission.role_id.in_(role_ids)
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
rows = result.scalars().all()
|
|
669
|
+
for perm in rows:
|
|
670
|
+
if perm.table_name in permissions_map:
|
|
671
|
+
existing = permissions_map[perm.table_name]
|
|
672
|
+
permissions_map[perm.table_name] = (
|
|
673
|
+
PermissionSet(
|
|
674
|
+
can_view=existing.can_view
|
|
675
|
+
or perm.can_view,
|
|
676
|
+
can_create=existing.can_create
|
|
677
|
+
or perm.can_create,
|
|
678
|
+
can_edit=existing.can_edit
|
|
679
|
+
or perm.can_edit,
|
|
680
|
+
can_delete=existing.can_delete
|
|
681
|
+
or perm.can_delete,
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
else:
|
|
685
|
+
permissions_map[perm.table_name] = (
|
|
686
|
+
PermissionSet(
|
|
687
|
+
can_view=perm.can_view,
|
|
688
|
+
can_create=perm.can_create,
|
|
689
|
+
can_edit=perm.can_edit,
|
|
690
|
+
can_delete=perm.can_delete,
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
except Exception:
|
|
694
|
+
pass
|
|
695
|
+
|
|
696
|
+
from dataclasses import replace
|
|
697
|
+
|
|
698
|
+
filtered_groups: list[Any] = []
|
|
699
|
+
for group in nav_groups:
|
|
700
|
+
visible = [
|
|
701
|
+
item
|
|
702
|
+
for item in group.items
|
|
703
|
+
if item.permission_table is None
|
|
704
|
+
or is_superuser
|
|
705
|
+
or (
|
|
706
|
+
permissions_map.get(item.permission_table)
|
|
707
|
+
and permissions_map[item.permission_table].can_view
|
|
708
|
+
)
|
|
709
|
+
]
|
|
710
|
+
if visible:
|
|
711
|
+
filtered_groups.append(replace(group, items=visible))
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
"nav_groups": filtered_groups,
|
|
715
|
+
"permissions_map": permissions_map,
|
|
716
|
+
"current_user": user,
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
def sidebar_template_kwargs(self, request: Any) -> dict[str, Any]:
|
|
720
|
+
"""Thin wrapper — returns sidebar kwargs for TemplateResponse contexts."""
|
|
721
|
+
return self.build_sidebar_context(request)
|
|
722
|
+
|
|
723
|
+
def apply_sidebar_context(
|
|
724
|
+
self, request: Any, user: Any, context: dict
|
|
725
|
+
) -> dict:
|
|
726
|
+
"""Inject nav_groups + permissions_map into a template context dict."""
|
|
727
|
+
context.update(self.build_sidebar_context(request, user=user))
|
|
728
|
+
return context
|