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,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)