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.
Files changed (163) hide show
  1. fastapi_admin_kit/__init__.py +73 -0
  2. fastapi_admin_kit/actions/__init__.py +63 -0
  3. fastapi_admin_kit/actions/base.py +68 -0
  4. fastapi_admin_kit/actions/registry.py +43 -0
  5. fastapi_admin_kit/admin/__init__.py +17 -0
  6. fastapi_admin_kit/admin/admin_config.py +95 -0
  7. fastapi_admin_kit/admin/admin_database.py +138 -0
  8. fastapi_admin_kit/admin/admin_router.py +74 -0
  9. fastapi_admin_kit/admin/admin_template.py +203 -0
  10. fastapi_admin_kit/admin/builtin_models.py +284 -0
  11. fastapi_admin_kit/admin/core.py +1036 -0
  12. fastapi_admin_kit/admin/decorators.py +70 -0
  13. fastapi_admin_kit/admin/state.py +76 -0
  14. fastapi_admin_kit/admin.py +728 -0
  15. fastapi_admin_kit/api/__init__.py +44 -0
  16. fastapi_admin_kit/api/auth.py +342 -0
  17. fastapi_admin_kit/api/crud.py +128 -0
  18. fastapi_admin_kit/api/deps.py +79 -0
  19. fastapi_admin_kit/api/roles.py +128 -0
  20. fastapi_admin_kit/api/schema_generator.py +171 -0
  21. fastapi_admin_kit/api/schemas.py +81 -0
  22. fastapi_admin_kit/api/search.py +132 -0
  23. fastapi_admin_kit/audit/__init__.py +36 -0
  24. fastapi_admin_kit/audit/context.py +62 -0
  25. fastapi_admin_kit/audit/diff.py +77 -0
  26. fastapi_admin_kit/audit/event_bus.py +96 -0
  27. fastapi_admin_kit/audit/events.py +48 -0
  28. fastapi_admin_kit/audit/listener.py +159 -0
  29. fastapi_admin_kit/audit/logger.py +28 -0
  30. fastapi_admin_kit/audit/middleware.py +39 -0
  31. fastapi_admin_kit/audit/models.py +53 -0
  32. fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
  33. fastapi_admin_kit/auth/__init__.py +34 -0
  34. fastapi_admin_kit/auth/backend.py +95 -0
  35. fastapi_admin_kit/auth/csrf.py +240 -0
  36. fastapi_admin_kit/auth/dependencies.py +150 -0
  37. fastapi_admin_kit/auth/identity.py +181 -0
  38. fastapi_admin_kit/auth/models.py +246 -0
  39. fastapi_admin_kit/auth/password.py +35 -0
  40. fastapi_admin_kit/auth/permissions.py +205 -0
  41. fastapi_admin_kit/auth/protocol.py +22 -0
  42. fastapi_admin_kit/auth/ratelimit.py +88 -0
  43. fastapi_admin_kit/auth/router.py +10 -0
  44. fastapi_admin_kit/auth/session.py +79 -0
  45. fastapi_admin_kit/auth/totp.py +83 -0
  46. fastapi_admin_kit/auth/views.py +165 -0
  47. fastapi_admin_kit/cli.py +229 -0
  48. fastapi_admin_kit/config/__init__.py +19 -0
  49. fastapi_admin_kit/config/audit.py +18 -0
  50. fastapi_admin_kit/config/auth.py +54 -0
  51. fastapi_admin_kit/config/behavior.py +27 -0
  52. fastapi_admin_kit/config/nav.py +32 -0
  53. fastapi_admin_kit/config/storage.py +22 -0
  54. fastapi_admin_kit/config/theme.py +215 -0
  55. fastapi_admin_kit/config/ui.py +147 -0
  56. fastapi_admin_kit/dashboard/__init__.py +64 -0
  57. fastapi_admin_kit/db.py +133 -0
  58. fastapi_admin_kit/exceptions.py +5 -0
  59. fastapi_admin_kit/field_types.py +81 -0
  60. fastapi_admin_kit/filters/__init__.py +21 -0
  61. fastapi_admin_kit/filters/base.py +170 -0
  62. fastapi_admin_kit/filters/registry.py +68 -0
  63. fastapi_admin_kit/flash.py +45 -0
  64. fastapi_admin_kit/form/__init__.py +1 -0
  65. fastapi_admin_kit/form/pipeline.py +106 -0
  66. fastapi_admin_kit/inspection/__init__.py +117 -0
  67. fastapi_admin_kit/inspection/registry.py +253 -0
  68. fastapi_admin_kit/inspection.py +115 -0
  69. fastapi_admin_kit/modeladmin.py +375 -0
  70. fastapi_admin_kit/models/__init__.py +7 -0
  71. fastapi_admin_kit/models/base.py +7 -0
  72. fastapi_admin_kit/nav.py +208 -0
  73. fastapi_admin_kit/pagination/__init__.py +14 -0
  74. fastapi_admin_kit/pagination/base.py +40 -0
  75. fastapi_admin_kit/pagination/cursor.py +97 -0
  76. fastapi_admin_kit/pagination/dynamic.py +48 -0
  77. fastapi_admin_kit/pagination/offset.py +42 -0
  78. fastapi_admin_kit/plugins/__init__.py +1 -0
  79. fastapi_admin_kit/py.typed +0 -0
  80. fastapi_admin_kit/registry/__init__.py +5 -0
  81. fastapi_admin_kit/registry/core.py +287 -0
  82. fastapi_admin_kit/registry/validation.py +107 -0
  83. fastapi_admin_kit/registry.py +15 -0
  84. fastapi_admin_kit/router.py +335 -0
  85. fastapi_admin_kit/static/css/admin.css +4736 -0
  86. fastapi_admin_kit/static/css/presets.css +317 -0
  87. fastapi_admin_kit/static/css/tokens.css +217 -0
  88. fastapi_admin_kit/static/css/variables.css +74 -0
  89. fastapi_admin_kit/static/icons/heroicons.svg +160 -0
  90. fastapi_admin_kit/static/js/admin.js +692 -0
  91. fastapi_admin_kit/static/js/htmx-config.js +42 -0
  92. fastapi_admin_kit/storage/__init__.py +6 -0
  93. fastapi_admin_kit/storage/base.py +48 -0
  94. fastapi_admin_kit/storage/local.py +73 -0
  95. fastapi_admin_kit/templates/base.html +142 -0
  96. fastapi_admin_kit/templates/macros/form_fields.html +660 -0
  97. fastapi_admin_kit/templates/macros/icons.html +50 -0
  98. fastapi_admin_kit/templates/macros/table.html +108 -0
  99. fastapi_admin_kit/templates/macros/widgets.html +159 -0
  100. fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
  101. fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
  102. fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
  103. fastapi_admin_kit/templates/pages/audit_log.html +102 -0
  104. fastapi_admin_kit/templates/pages/dashboard.html +295 -0
  105. fastapi_admin_kit/templates/pages/detail.html +183 -0
  106. fastapi_admin_kit/templates/pages/form.html +119 -0
  107. fastapi_admin_kit/templates/pages/list.html +277 -0
  108. fastapi_admin_kit/templates/pages/login.html +85 -0
  109. fastapi_admin_kit/templates/pages/profile/password.html +78 -0
  110. fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
  111. fastapi_admin_kit/templates/pages/role_form.html +75 -0
  112. fastapi_admin_kit/templates/pages/roles/form.html +117 -0
  113. fastapi_admin_kit/templates/pages/roles/list.html +69 -0
  114. fastapi_admin_kit/templates/pages/roles.html +77 -0
  115. fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
  116. fastapi_admin_kit/templates/pages/users/form.html +229 -0
  117. fastapi_admin_kit/templates/pages/users/list.html +83 -0
  118. fastapi_admin_kit/templates/partials/command_palette.html +52 -0
  119. fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
  120. fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
  121. fastapi_admin_kit/templates/partials/head.html +21 -0
  122. fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
  123. fastapi_admin_kit/templates/partials/list_table.html +178 -0
  124. fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
  125. fastapi_admin_kit/templates/partials/pagination.html +82 -0
  126. fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
  127. fastapi_admin_kit/templates/partials/scripts.html +13 -0
  128. fastapi_admin_kit/templates/partials/sidebar.html +94 -0
  129. fastapi_admin_kit/templates/partials/topbar.html +95 -0
  130. fastapi_admin_kit/types.py +145 -0
  131. fastapi_admin_kit/validation.py +43 -0
  132. fastapi_admin_kit/views/__init__.py +78 -0
  133. fastapi_admin_kit/views/audit.py +134 -0
  134. fastapi_admin_kit/views/bulk.py +28 -0
  135. fastapi_admin_kit/views/class_views.py +1040 -0
  136. fastapi_admin_kit/views/context.py +588 -0
  137. fastapi_admin_kit/views/dashboard.py +162 -0
  138. fastapi_admin_kit/views/delete.py +31 -0
  139. fastapi_admin_kit/views/extra.py +65 -0
  140. fastapi_admin_kit/views/factory.py +667 -0
  141. fastapi_admin_kit/views/form.py +159 -0
  142. fastapi_admin_kit/views/list.py +28 -0
  143. fastapi_admin_kit/views/profile.py +219 -0
  144. fastapi_admin_kit/views/protocols.py +54 -0
  145. fastapi_admin_kit/views/renderers.py +634 -0
  146. fastapi_admin_kit/views/roles.py +230 -0
  147. fastapi_admin_kit/views/search.py +31 -0
  148. fastapi_admin_kit/views/settings.py +31 -0
  149. fastapi_admin_kit/views/sidebar.py +101 -0
  150. fastapi_admin_kit/views/totp.py +249 -0
  151. fastapi_admin_kit/views/users.py +347 -0
  152. fastapi_admin_kit/views.py +117 -0
  153. fastapi_admin_kit/widgets/__init__.py +44 -0
  154. fastapi_admin_kit/widgets/base.py +44 -0
  155. fastapi_admin_kit/widgets/inputs.py +363 -0
  156. fastapi_admin_kit/widgets/registry.py +110 -0
  157. fastapi_admin_kit/widgets/relation.py +70 -0
  158. fastapi_admin_kit/widgets/resolver.py +102 -0
  159. fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
  160. fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
  161. fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
  162. fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
  163. 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