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,667 @@
1
+ """ViewFactory — unified factory for CRUD view handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import HTTPException, Request
8
+ from fastapi.responses import RedirectResponse
9
+ from starlette.datastructures import UploadFile
10
+
11
+ from fastapi_admin_kit.db import get_db_session
12
+ from fastapi_admin_kit.flash import add_flash
13
+ from fastapi_admin_kit.registry import RegisteredModel
14
+ from fastapi_admin_kit.validation import FormValidator
15
+ from fastapi_admin_kit.views.context import ViewContextBuilder
16
+ from fastapi_admin_kit.widgets.inputs import FileUploadWidget, ImageUploadWidget
17
+
18
+ _FILE_WIDGET_TYPES = (FileUploadWidget, ImageUploadWidget)
19
+
20
+
21
+ def _apply_parsed_to_obj(
22
+ obj: Any, parsed: dict[str, Any], registered: RegisteredModel
23
+ ) -> None:
24
+ """Apply parsed data to an ORM object, mapping relationship names to FK columns."""
25
+ from sqlalchemy import inspect as sa_inspect
26
+
27
+ col_names = {c.name for c in registered.columns}
28
+ rel_fk_map: dict[str, str] = {}
29
+ try:
30
+ mapper = sa_inspect(type(obj))
31
+ except Exception:
32
+ mapper = None
33
+ if mapper is not None:
34
+ for rel_key, rel_prop in mapper.relationships.items():
35
+ if rel_prop.direction.name == "MANYTOMANY":
36
+ continue
37
+ local_cols = [c.key for c in rel_prop.local_columns]
38
+ if local_cols:
39
+ rel_fk_map[rel_key] = local_cols[0]
40
+
41
+ for key, value in parsed.items():
42
+ if key in col_names:
43
+ setattr(obj, key, value)
44
+ elif key in rel_fk_map:
45
+ setattr(obj, rel_fk_map[key], value)
46
+
47
+
48
+ def _pop_manytomany_keys(
49
+ obj: Any, parsed: dict[str, Any], registered: RegisteredModel
50
+ ) -> dict[str, dict[str, Any]]:
51
+ """Remove MANYTOMANY relationship keys from parsed dict in-place.
52
+
53
+ Returns a dict mapping rel_key -> raw parsed value for M2M fields.
54
+ """
55
+ from sqlalchemy import inspect as sa_inspect
56
+
57
+ m2m_data: dict[str, Any] = {}
58
+ try:
59
+ model_class = type(obj) if not isinstance(obj, type) else obj
60
+ mapper = sa_inspect(model_class)
61
+ except Exception:
62
+ return m2m_data
63
+ for rel_key, rel_prop in mapper.relationships.items():
64
+ if rel_prop.direction.name == "MANYTOMANY" and rel_key in parsed:
65
+ m2m_data[rel_key] = parsed.pop(rel_key)
66
+ return m2m_data
67
+
68
+
69
+ async def _apply_m2m_from_data(
70
+ obj: Any, m2m_data: dict[str, Any], registered: RegisteredModel, session: Any
71
+ ) -> None:
72
+ """Apply MANYTOMANY data extracted by _pop_manytomany_keys."""
73
+ import json as _json
74
+
75
+ from sqlalchemy import inspect as sa_inspect
76
+
77
+ if not m2m_data:
78
+ return
79
+ try:
80
+ mapper = sa_inspect(type(obj))
81
+ except Exception:
82
+ return
83
+ for rel_key, rel_prop in mapper.relationships.items():
84
+ if rel_prop.direction.name != "MANYTOMANY":
85
+ continue
86
+ if rel_key not in m2m_data:
87
+ continue
88
+ raw = m2m_data[rel_key]
89
+ pk_list = []
90
+ if isinstance(raw, list):
91
+ for item in raw:
92
+ if isinstance(item, str) and item.startswith("["):
93
+ try:
94
+ pk_list.extend(_json.loads(item))
95
+ except (ValueError, TypeError):
96
+ pk_list.append(item)
97
+ else:
98
+ pk_list.append(item)
99
+ else:
100
+ pk_list = [raw]
101
+ target_model = rel_prop.mapper.class_
102
+ objs = []
103
+ for pk in pk_list:
104
+ if not pk:
105
+ continue
106
+ try:
107
+ loaded = await session.get(target_model, int(pk))
108
+ if loaded:
109
+ objs.append(loaded)
110
+ except (ValueError, TypeError):
111
+ pass
112
+ await session.refresh(obj, [rel_key])
113
+ setattr(obj, rel_key, objs)
114
+
115
+
116
+ def _resolve_rel_keys(
117
+ parsed: dict[str, Any], registered: RegisteredModel
118
+ ) -> dict[str, Any]:
119
+ """Convert relationship keys in parsed data to their FK column names."""
120
+ from sqlalchemy import inspect as sa_inspect
121
+
122
+ col_names = {c.name for c in registered.columns}
123
+ rel_fk_map: dict[str, str] = {}
124
+ try:
125
+ model = registered.model
126
+ mapper = sa_inspect(model)
127
+ except Exception:
128
+ mapper = None
129
+ if mapper is not None:
130
+ for rel_key, rel_prop in mapper.relationships.items():
131
+ if rel_prop.direction.name == "MANYTOMANY":
132
+ continue
133
+ local_cols = [c.key for c in rel_prop.local_columns]
134
+ if local_cols:
135
+ rel_fk_map[rel_key] = local_cols[0]
136
+
137
+ resolved: dict[str, Any] = {}
138
+ for key, value in parsed.items():
139
+ if key in rel_fk_map and key not in col_names:
140
+ resolved[rel_fk_map[key]] = value
141
+ else:
142
+ resolved[key] = value
143
+ return resolved
144
+
145
+
146
+ async def _resolve_rel_labels(
147
+ obj: Any, registered: RegisteredModel, request: Any
148
+ ) -> dict[str, str]:
149
+ """Resolve display labels for relationship fields from FK values."""
150
+ from sqlalchemy import inspect as sa_inspect
151
+
152
+ from fastapi_admin_kit.inspection import model_display_name
153
+
154
+ labels: dict[str, str] = {}
155
+ if obj is None:
156
+ return labels
157
+ try:
158
+ mapper = sa_inspect(registered.model)
159
+ except Exception:
160
+ return labels
161
+ session = get_db_session(request)
162
+ for rel_key, rel_prop in mapper.relationships.items():
163
+ local_cols = [c.key for c in rel_prop.local_columns]
164
+ if not local_cols:
165
+ continue
166
+ fk_val = getattr(obj, local_cols[0], None)
167
+ if fk_val is None:
168
+ continue
169
+ target_cls = rel_prop.mapper.class_
170
+ try:
171
+ target = await session.get(target_cls, fk_val)
172
+ if target is not None:
173
+ labels[rel_key] = model_display_name(target)
174
+ except Exception:
175
+ labels[rel_key] = str(fk_val)
176
+ return labels
177
+
178
+
179
+ def _get_storage(request: Request):
180
+ """Get the storage backend from app.state, or None."""
181
+ return getattr(request.app.state, "admin_storage", None)
182
+
183
+
184
+ async def _resolve_permission_checker(request: Request) -> Any:
185
+ """Resolve a PermissionChecker for the current request.
186
+
187
+ Returns None if the user is not authenticated or the checker cannot be built.
188
+ Delegates current-user resolution to :mod:`fastapi_admin_kit.auth.identity`, the
189
+ single request-authentication seam — so the user is loaded per request on
190
+ ``request.state.admin_user`` and never cached on ``app.state`` (which
191
+ previously leaked identity across concurrent requests).
192
+ """
193
+ from fastapi_admin_kit.auth.identity import get_current_user_from_cookie
194
+ from fastapi_admin_kit.auth.permissions import PermissionChecker
195
+
196
+ user = await get_current_user_from_cookie(request)
197
+ if user is None:
198
+ return None
199
+
200
+ async_session = get_db_session(request)
201
+ if async_session is None:
202
+ return None
203
+
204
+ snapshot = getattr(request.state, "admin_user_snapshot", None)
205
+ return PermissionChecker(session=async_session, user=user, user_snapshot=snapshot)
206
+
207
+
208
+ async def _handle_file_field(
209
+ request: Request,
210
+ widget: Any,
211
+ field_meta: Any,
212
+ form_data: Any,
213
+ obj: Any | None,
214
+ action: str | None,
215
+ parsed: dict[str, Any],
216
+ errors: dict[str, list[str]],
217
+ ) -> None:
218
+ """Handle a file upload field during form submission."""
219
+ storage = _get_storage(request)
220
+ field_name = field_meta.name
221
+ raw = form_data.get(field_name)
222
+
223
+ if isinstance(raw, UploadFile) and raw.filename:
224
+ if widget.max_size_mb is not None:
225
+ content = await raw.read()
226
+ max_bytes = int(widget.max_size_mb * 1024 * 1024)
227
+ if len(content) > max_bytes:
228
+ errors[field_name] = [
229
+ f"File size exceeds maximum allowed size ({widget.max_size_mb} MB)."
230
+ ]
231
+ await raw.seek(0)
232
+ return
233
+ await raw.seek(0)
234
+
235
+ if storage is None:
236
+ errors[field_name] = ["No storage backend configured."]
237
+ return
238
+
239
+ try:
240
+ path = await storage.save(raw, directory=field_meta.name)
241
+ except ValueError as exc:
242
+ errors[field_name] = [str(exc)]
243
+ return
244
+
245
+ if action == "replace" and obj is not None:
246
+ old_path = getattr(obj, field_name, None)
247
+ if old_path:
248
+ await storage.delete(old_path)
249
+
250
+ parsed[field_name] = path
251
+
252
+ elif action == "clear":
253
+ if storage is not None and obj is not None:
254
+ old_path = getattr(obj, field_name, None)
255
+ if old_path:
256
+ await storage.delete(old_path)
257
+ parsed[field_name] = None
258
+
259
+ elif action == "keep" or action is None:
260
+ if obj is not None:
261
+ parsed[field_name] = getattr(obj, field_name, None)
262
+
263
+ else:
264
+ if obj is not None:
265
+ parsed[field_name] = getattr(obj, field_name, None)
266
+
267
+
268
+ class ViewFactory:
269
+ """Unified factory for creating CRUD view handlers.
270
+
271
+ Centralizes view creation logic that was previously duplicated across
272
+ multiple standalone factory functions.
273
+ """
274
+
275
+ def __init__(
276
+ self,
277
+ context_builder: ViewContextBuilder | None = None,
278
+ form_pipeline: Any = None,
279
+ validation_engine: Any = None,
280
+ ):
281
+ self.context_builder = context_builder or ViewContextBuilder()
282
+ self.form_pipeline = form_pipeline
283
+ self.validation_engine = validation_engine or FormValidator()
284
+
285
+ async def _parse_form_fields(
286
+ self,
287
+ registered: RegisteredModel,
288
+ form_data: Any,
289
+ request: Request,
290
+ obj: Any | None = None,
291
+ ) -> tuple[dict[str, Any], dict[str, list[str]]]:
292
+ """Parse and validate form fields from request data.
293
+
294
+ Returns (parsed_values, errors).
295
+ """
296
+ parsed: dict[str, Any] = {}
297
+ errors: dict[str, list[str]] = {}
298
+
299
+ for field_meta in registered.form_fields:
300
+ if field_meta.readonly:
301
+ continue
302
+ widget = registered.get_widget(field_meta.name)
303
+
304
+ if isinstance(widget, _FILE_WIDGET_TYPES):
305
+ action = (
306
+ form_data.get(f"_action_{field_meta.name}", "keep")
307
+ if obj
308
+ else None
309
+ )
310
+ await _handle_file_field(
311
+ request,
312
+ widget,
313
+ field_meta,
314
+ form_data,
315
+ obj=obj,
316
+ action=action,
317
+ parsed=parsed,
318
+ errors=errors,
319
+ )
320
+ if (
321
+ obj is None
322
+ and field_meta.name not in errors
323
+ and field_meta.name not in parsed
324
+ ):
325
+ parsed[field_meta.name] = None
326
+ continue
327
+
328
+ raw = form_data.get(field_meta.name)
329
+ value = widget.parse(raw)
330
+ required_on_create = (field_meta.extra or {}).get("required_on_create")
331
+ if obj is None and required_on_create is not None:
332
+ from fastapi_admin_kit.types import FieldMeta
333
+ effective_field = FieldMeta(
334
+ name=field_meta.name,
335
+ label=field_meta.label,
336
+ required=required_on_create,
337
+ readonly=field_meta.readonly,
338
+ extra=field_meta.extra,
339
+ )
340
+ else:
341
+ effective_field = field_meta
342
+ field_errors = widget.validate(value, effective_field)
343
+ if field_errors:
344
+ errors[field_meta.name] = field_errors
345
+ else:
346
+ parsed[field_meta.name] = value
347
+
348
+ if not errors:
349
+ errors = self.validation_engine.run(registered, parsed, obj=obj)
350
+
351
+ return parsed, errors
352
+
353
+ def create_list_view(self, registered: RegisteredModel):
354
+ """Create a list view handler for the given model."""
355
+
356
+ async def list_view(
357
+ request: Request, q: str = "", page: int = 1, _: Any = None
358
+ ):
359
+ templates = request.app.state.admin_jinja_env
360
+ checker = await _resolve_permission_checker(request)
361
+ if checker:
362
+ await checker.load_permissions(registered.table_name)
363
+ ctx = await self.context_builder.build_list_context(
364
+ registered, request, q=q, page=page, permission_checker=checker
365
+ )
366
+ is_htmx = request.headers.get("HX-Request") == "true"
367
+ if is_htmx:
368
+ return templates.TemplateResponse(
369
+ request, "partials/list_table.html", ctx
370
+ )
371
+ return templates.TemplateResponse(request, "pages/list.html", ctx)
372
+
373
+ list_view.__name__ = f"list_{registered.table_name}"
374
+ return list_view
375
+
376
+ def create_create_form_view(self, registered: RegisteredModel):
377
+ """Create a form display handler for creating new objects."""
378
+
379
+ async def create_form(request: Request, _: Any = None):
380
+ templates = request.app.state.admin_jinja_env
381
+ checker = await _resolve_permission_checker(request)
382
+ if checker:
383
+ await checker.load_permissions(registered.table_name)
384
+ ctx = await self.context_builder.build_form_context(
385
+ registered, request, is_create=True, permission_checker=checker
386
+ )
387
+ return templates.TemplateResponse(request, "pages/form.html", ctx)
388
+
389
+ create_form.__name__ = f"create_form_{registered.table_name}"
390
+ return create_form
391
+
392
+ def create_create_submit_view(self, registered: RegisteredModel):
393
+ """Create a form submission handler for creating new objects."""
394
+
395
+ async def create_submit(request: Request, _: Any = None):
396
+ templates = request.app.state.admin_jinja_env
397
+ session = get_db_session(request)
398
+ form_data = await request.form()
399
+ checker = await _resolve_permission_checker(request)
400
+ if checker:
401
+ await checker.load_permissions(registered.table_name)
402
+
403
+ parsed, errors = await self._parse_form_fields(
404
+ registered, form_data, request, obj=None
405
+ )
406
+
407
+ if errors:
408
+ await session.rollback()
409
+ ctx = await self.context_builder.build_form_context(
410
+ registered,
411
+ request,
412
+ values=parsed,
413
+ errors=errors,
414
+ is_create=True,
415
+ permission_checker=checker,
416
+ )
417
+ return templates.TemplateResponse(
418
+ request, "pages/form.html", ctx, status_code=422
419
+ )
420
+
421
+ try:
422
+ parsed = registered.admin.validate_create(parsed, request)
423
+ except ValueError as e:
424
+ await session.rollback()
425
+ ctx = await self.context_builder.build_form_context(
426
+ registered,
427
+ request,
428
+ values=parsed,
429
+ errors={"__all__": [str(e)]},
430
+ is_create=True,
431
+ permission_checker=checker,
432
+ )
433
+ return templates.TemplateResponse(
434
+ request, "pages/form.html", ctx, status_code=422
435
+ )
436
+
437
+ parsed = registered.admin.process_form_data(parsed, request)
438
+
439
+ m2m_data = _pop_manytomany_keys(registered.model, parsed, registered)
440
+ parsed = _resolve_rel_keys(parsed, registered)
441
+ obj = registered.model(**parsed)
442
+ registered.admin.on_create(obj, request)
443
+ session.add(obj)
444
+ await _apply_m2m_from_data(obj, m2m_data, registered, session)
445
+ await session.flush()
446
+ registered.admin.after_create(obj, request)
447
+ await add_flash(request, "success", f"{registered.verbose_name} created.")
448
+ url = f"{request.app.state.admin_config['admin_path']}/{registered.table_name}/"
449
+ return RedirectResponse(url=url, status_code=303)
450
+
451
+ create_submit.__name__ = f"create_submit_{registered.table_name}"
452
+ return create_submit
453
+
454
+ def create_edit_form_view(self, registered: RegisteredModel):
455
+ """Create a form display handler for editing existing objects."""
456
+
457
+ async def edit_form(request: Request, id: str, _: Any = None):
458
+ templates = request.app.state.admin_jinja_env
459
+ session = get_db_session(request)
460
+ obj = await session.get(registered.model, int(id))
461
+ if not obj:
462
+ raise HTTPException(status_code=404, detail="Not found")
463
+ checker = await _resolve_permission_checker(request)
464
+ if checker:
465
+ await checker.load_permissions(registered.table_name)
466
+ rel_labels = await _resolve_rel_labels(obj, registered, request)
467
+ ctx = await self.context_builder.build_form_context(
468
+ registered,
469
+ request,
470
+ obj=obj,
471
+ is_create=False,
472
+ permission_checker=checker,
473
+ rel_labels=rel_labels,
474
+ )
475
+ return templates.TemplateResponse(request, "pages/form.html", ctx)
476
+
477
+ edit_form.__name__ = f"edit_form_{registered.table_name}"
478
+ return edit_form
479
+
480
+ def create_edit_submit_view(self, registered: RegisteredModel):
481
+ """Create a form submission handler for editing existing objects."""
482
+
483
+ async def edit_submit(request: Request, id: str, _: Any = None):
484
+ templates = request.app.state.admin_jinja_env
485
+ session = get_db_session(request)
486
+ obj = await session.get(registered.model, int(id))
487
+ if not obj:
488
+ raise HTTPException(status_code=404, detail="Not found")
489
+ form_data = await request.form()
490
+ checker = await _resolve_permission_checker(request)
491
+ if checker:
492
+ await checker.load_permissions(registered.table_name)
493
+
494
+ parsed, errors = await self._parse_form_fields(
495
+ registered, form_data, request, obj=obj
496
+ )
497
+
498
+ if errors:
499
+ await session.rollback()
500
+ rel_labels = await _resolve_rel_labels(obj, registered, request)
501
+ ctx = await self.context_builder.build_form_context(
502
+ registered,
503
+ request,
504
+ obj=obj,
505
+ values=parsed,
506
+ errors=errors,
507
+ is_create=False,
508
+ permission_checker=checker,
509
+ rel_labels=rel_labels,
510
+ )
511
+ return templates.TemplateResponse(
512
+ request, "pages/form.html", ctx, status_code=422
513
+ )
514
+
515
+ try:
516
+ parsed = registered.admin.validate_update(obj, parsed, request)
517
+ except ValueError as e:
518
+ await session.rollback()
519
+ rel_labels = await _resolve_rel_labels(obj, registered, request)
520
+ ctx = await self.context_builder.build_form_context(
521
+ registered,
522
+ request,
523
+ obj=obj,
524
+ values=parsed,
525
+ errors={"__all__": [str(e)]},
526
+ is_create=False,
527
+ permission_checker=checker,
528
+ rel_labels=rel_labels,
529
+ )
530
+ return templates.TemplateResponse(
531
+ request, "pages/form.html", ctx, status_code=422
532
+ )
533
+
534
+ parsed = registered.admin.process_form_data(parsed, request)
535
+
536
+ registered.admin.on_update(obj, parsed, request)
537
+ m2m_data = _pop_manytomany_keys(obj, parsed, registered)
538
+ _apply_parsed_to_obj(obj, parsed, registered)
539
+ await _apply_m2m_from_data(obj, m2m_data, registered, session)
540
+ await session.flush()
541
+ registered.admin.after_update(obj, request)
542
+ await add_flash(request, "success", f"{registered.verbose_name} updated.")
543
+ url = f"{request.app.state.admin_config['admin_path']}/{registered.table_name}/"
544
+ return RedirectResponse(url=url, status_code=303)
545
+
546
+ edit_submit.__name__ = f"edit_submit_{registered.table_name}"
547
+ return edit_submit
548
+
549
+ def create_delete_view(self, registered: RegisteredModel):
550
+ """Create a delete handler for removing objects."""
551
+
552
+ async def delete_submit(request: Request, id: str, _: Any = None):
553
+ session = get_db_session(request)
554
+ try:
555
+ await session.rollback()
556
+ except Exception:
557
+ pass
558
+ obj = await session.get(registered.model, int(id))
559
+ if not obj:
560
+ raise HTTPException(status_code=404, detail="Not found")
561
+ try:
562
+ registered.admin.on_delete(obj, request)
563
+ await session.delete(obj)
564
+ await session.flush()
565
+ registered.admin.after_delete(obj, request)
566
+ await add_flash(
567
+ request, "success", f"{registered.verbose_name} deleted."
568
+ )
569
+ except Exception as e:
570
+ await session.rollback()
571
+ await add_flash(request, "error", f"Cannot delete: {str(e)}")
572
+ url = f"{request.app.state.admin_config['admin_path']}/{registered.table_name}/"
573
+ return RedirectResponse(url=url, status_code=303)
574
+
575
+ delete_submit.__name__ = f"delete_{registered.table_name}"
576
+ return delete_submit
577
+
578
+ def create_bulk_view(self, registered: RegisteredModel):
579
+ """Create a bulk action handler for multiple objects."""
580
+
581
+ async def bulk_action(request: Request, _: Any = None):
582
+ templates = request.app.state.admin_jinja_env
583
+ session = get_db_session(request)
584
+ try:
585
+ await session.rollback()
586
+ except Exception:
587
+ pass
588
+ form = await request.form()
589
+ action = form.get("action", "")
590
+ ids = form.getlist("ids[]")
591
+
592
+ is_htmx = request.headers.get("HX-Request") == "true"
593
+
594
+ if not ids:
595
+ if is_htmx:
596
+ ctx = await self.context_builder.build_list_context(
597
+ registered, request, permission_checker=None
598
+ )
599
+ return templates.TemplateResponse(
600
+ request, "partials/list_table.html", ctx
601
+ )
602
+ url = f"{request.app.state.admin_config['admin_path']}/{registered.table_name}/"
603
+ return RedirectResponse(url=url, status_code=303)
604
+
605
+ try:
606
+ if action == "delete_selected":
607
+ for pid in ids:
608
+ obj = await session.get(registered.model, int(pid))
609
+ if obj:
610
+ registered.admin.on_delete(obj, request)
611
+ await session.delete(obj)
612
+ await session.flush()
613
+ await add_flash(
614
+ request,
615
+ "success",
616
+ f"{len(ids)} {registered.verbose_name}(s) deleted.",
617
+ )
618
+ else:
619
+ action_fn = getattr(
620
+ registered.admin, f"action_{action}", None
621
+ )
622
+ if not action_fn:
623
+ raise HTTPException(
624
+ status_code=400, detail=f"Unknown action: {action}"
625
+ )
626
+ for pid in ids:
627
+ obj = await session.get(registered.model, int(pid))
628
+ if obj:
629
+ action_fn(obj)
630
+ await session.flush()
631
+ await add_flash(
632
+ request,
633
+ "success",
634
+ f"Action '{action}' applied to {len(ids)} item(s).",
635
+ )
636
+ except Exception as e:
637
+ await session.rollback()
638
+ await add_flash(request, "error", f"Action failed: {str(e)}")
639
+
640
+ if is_htmx:
641
+ ctx = await self.context_builder.build_list_context(
642
+ registered, request, permission_checker=None
643
+ )
644
+ return templates.TemplateResponse(
645
+ request, "partials/list_table.html", ctx
646
+ )
647
+
648
+ url = f"{request.app.state.admin_config['admin_path']}/{registered.table_name}/"
649
+ return RedirectResponse(url=url, status_code=303)
650
+
651
+ bulk_action.__name__ = f"bulk_{registered.table_name}"
652
+ return bulk_action
653
+
654
+ def create_all_views(self, registered: RegisteredModel) -> dict[str, Any]:
655
+ """Create all standard CRUD views for a registered model.
656
+
657
+ Returns a dict mapping view names to handler functions.
658
+ """
659
+ return {
660
+ "list": self.create_list_view(registered),
661
+ "create_form": self.create_create_form_view(registered),
662
+ "create_submit": self.create_create_submit_view(registered),
663
+ "edit_form": self.create_edit_form_view(registered),
664
+ "edit_submit": self.create_edit_submit_view(registered),
665
+ "delete": self.create_delete_view(registered),
666
+ "bulk": self.create_bulk_view(registered),
667
+ }