fastapi-admin-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_admin_kit/__init__.py +73 -0
- fastapi_admin_kit/actions/__init__.py +63 -0
- fastapi_admin_kit/actions/base.py +68 -0
- fastapi_admin_kit/actions/registry.py +43 -0
- fastapi_admin_kit/admin/__init__.py +17 -0
- fastapi_admin_kit/admin/admin_config.py +95 -0
- fastapi_admin_kit/admin/admin_database.py +138 -0
- fastapi_admin_kit/admin/admin_router.py +74 -0
- fastapi_admin_kit/admin/admin_template.py +203 -0
- fastapi_admin_kit/admin/builtin_models.py +284 -0
- fastapi_admin_kit/admin/core.py +1036 -0
- fastapi_admin_kit/admin/decorators.py +70 -0
- fastapi_admin_kit/admin/state.py +76 -0
- fastapi_admin_kit/admin.py +728 -0
- fastapi_admin_kit/api/__init__.py +44 -0
- fastapi_admin_kit/api/auth.py +342 -0
- fastapi_admin_kit/api/crud.py +128 -0
- fastapi_admin_kit/api/deps.py +79 -0
- fastapi_admin_kit/api/roles.py +128 -0
- fastapi_admin_kit/api/schema_generator.py +171 -0
- fastapi_admin_kit/api/schemas.py +81 -0
- fastapi_admin_kit/api/search.py +132 -0
- fastapi_admin_kit/audit/__init__.py +36 -0
- fastapi_admin_kit/audit/context.py +62 -0
- fastapi_admin_kit/audit/diff.py +77 -0
- fastapi_admin_kit/audit/event_bus.py +96 -0
- fastapi_admin_kit/audit/events.py +48 -0
- fastapi_admin_kit/audit/listener.py +159 -0
- fastapi_admin_kit/audit/logger.py +28 -0
- fastapi_admin_kit/audit/middleware.py +39 -0
- fastapi_admin_kit/audit/models.py +53 -0
- fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
- fastapi_admin_kit/auth/__init__.py +34 -0
- fastapi_admin_kit/auth/backend.py +95 -0
- fastapi_admin_kit/auth/csrf.py +240 -0
- fastapi_admin_kit/auth/dependencies.py +150 -0
- fastapi_admin_kit/auth/identity.py +181 -0
- fastapi_admin_kit/auth/models.py +246 -0
- fastapi_admin_kit/auth/password.py +35 -0
- fastapi_admin_kit/auth/permissions.py +205 -0
- fastapi_admin_kit/auth/protocol.py +22 -0
- fastapi_admin_kit/auth/ratelimit.py +88 -0
- fastapi_admin_kit/auth/router.py +10 -0
- fastapi_admin_kit/auth/session.py +79 -0
- fastapi_admin_kit/auth/totp.py +83 -0
- fastapi_admin_kit/auth/views.py +165 -0
- fastapi_admin_kit/cli.py +229 -0
- fastapi_admin_kit/config/__init__.py +19 -0
- fastapi_admin_kit/config/audit.py +18 -0
- fastapi_admin_kit/config/auth.py +54 -0
- fastapi_admin_kit/config/behavior.py +27 -0
- fastapi_admin_kit/config/nav.py +32 -0
- fastapi_admin_kit/config/storage.py +22 -0
- fastapi_admin_kit/config/theme.py +215 -0
- fastapi_admin_kit/config/ui.py +147 -0
- fastapi_admin_kit/dashboard/__init__.py +64 -0
- fastapi_admin_kit/db.py +133 -0
- fastapi_admin_kit/exceptions.py +5 -0
- fastapi_admin_kit/field_types.py +81 -0
- fastapi_admin_kit/filters/__init__.py +21 -0
- fastapi_admin_kit/filters/base.py +170 -0
- fastapi_admin_kit/filters/registry.py +68 -0
- fastapi_admin_kit/flash.py +45 -0
- fastapi_admin_kit/form/__init__.py +1 -0
- fastapi_admin_kit/form/pipeline.py +106 -0
- fastapi_admin_kit/inspection/__init__.py +117 -0
- fastapi_admin_kit/inspection/registry.py +253 -0
- fastapi_admin_kit/inspection.py +115 -0
- fastapi_admin_kit/modeladmin.py +375 -0
- fastapi_admin_kit/models/__init__.py +7 -0
- fastapi_admin_kit/models/base.py +7 -0
- fastapi_admin_kit/nav.py +208 -0
- fastapi_admin_kit/pagination/__init__.py +14 -0
- fastapi_admin_kit/pagination/base.py +40 -0
- fastapi_admin_kit/pagination/cursor.py +97 -0
- fastapi_admin_kit/pagination/dynamic.py +48 -0
- fastapi_admin_kit/pagination/offset.py +42 -0
- fastapi_admin_kit/plugins/__init__.py +1 -0
- fastapi_admin_kit/py.typed +0 -0
- fastapi_admin_kit/registry/__init__.py +5 -0
- fastapi_admin_kit/registry/core.py +287 -0
- fastapi_admin_kit/registry/validation.py +107 -0
- fastapi_admin_kit/registry.py +15 -0
- fastapi_admin_kit/router.py +335 -0
- fastapi_admin_kit/static/css/admin.css +4736 -0
- fastapi_admin_kit/static/css/presets.css +317 -0
- fastapi_admin_kit/static/css/tokens.css +217 -0
- fastapi_admin_kit/static/css/variables.css +74 -0
- fastapi_admin_kit/static/icons/heroicons.svg +160 -0
- fastapi_admin_kit/static/js/admin.js +692 -0
- fastapi_admin_kit/static/js/htmx-config.js +42 -0
- fastapi_admin_kit/storage/__init__.py +6 -0
- fastapi_admin_kit/storage/base.py +48 -0
- fastapi_admin_kit/storage/local.py +73 -0
- fastapi_admin_kit/templates/base.html +142 -0
- fastapi_admin_kit/templates/macros/form_fields.html +660 -0
- fastapi_admin_kit/templates/macros/icons.html +50 -0
- fastapi_admin_kit/templates/macros/table.html +108 -0
- fastapi_admin_kit/templates/macros/widgets.html +159 -0
- fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
- fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
- fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
- fastapi_admin_kit/templates/pages/audit_log.html +102 -0
- fastapi_admin_kit/templates/pages/dashboard.html +295 -0
- fastapi_admin_kit/templates/pages/detail.html +183 -0
- fastapi_admin_kit/templates/pages/form.html +119 -0
- fastapi_admin_kit/templates/pages/list.html +277 -0
- fastapi_admin_kit/templates/pages/login.html +85 -0
- fastapi_admin_kit/templates/pages/profile/password.html +78 -0
- fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
- fastapi_admin_kit/templates/pages/role_form.html +75 -0
- fastapi_admin_kit/templates/pages/roles/form.html +117 -0
- fastapi_admin_kit/templates/pages/roles/list.html +69 -0
- fastapi_admin_kit/templates/pages/roles.html +77 -0
- fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
- fastapi_admin_kit/templates/pages/users/form.html +229 -0
- fastapi_admin_kit/templates/pages/users/list.html +83 -0
- fastapi_admin_kit/templates/partials/command_palette.html +52 -0
- fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
- fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
- fastapi_admin_kit/templates/partials/head.html +21 -0
- fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
- fastapi_admin_kit/templates/partials/list_table.html +178 -0
- fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
- fastapi_admin_kit/templates/partials/pagination.html +82 -0
- fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
- fastapi_admin_kit/templates/partials/scripts.html +13 -0
- fastapi_admin_kit/templates/partials/sidebar.html +94 -0
- fastapi_admin_kit/templates/partials/topbar.html +95 -0
- fastapi_admin_kit/types.py +145 -0
- fastapi_admin_kit/validation.py +43 -0
- fastapi_admin_kit/views/__init__.py +78 -0
- fastapi_admin_kit/views/audit.py +134 -0
- fastapi_admin_kit/views/bulk.py +28 -0
- fastapi_admin_kit/views/class_views.py +1040 -0
- fastapi_admin_kit/views/context.py +588 -0
- fastapi_admin_kit/views/dashboard.py +162 -0
- fastapi_admin_kit/views/delete.py +31 -0
- fastapi_admin_kit/views/extra.py +65 -0
- fastapi_admin_kit/views/factory.py +667 -0
- fastapi_admin_kit/views/form.py +159 -0
- fastapi_admin_kit/views/list.py +28 -0
- fastapi_admin_kit/views/profile.py +219 -0
- fastapi_admin_kit/views/protocols.py +54 -0
- fastapi_admin_kit/views/renderers.py +634 -0
- fastapi_admin_kit/views/roles.py +230 -0
- fastapi_admin_kit/views/search.py +31 -0
- fastapi_admin_kit/views/settings.py +31 -0
- fastapi_admin_kit/views/sidebar.py +101 -0
- fastapi_admin_kit/views/totp.py +249 -0
- fastapi_admin_kit/views/users.py +347 -0
- fastapi_admin_kit/views.py +117 -0
- fastapi_admin_kit/widgets/__init__.py +44 -0
- fastapi_admin_kit/widgets/base.py +44 -0
- fastapi_admin_kit/widgets/inputs.py +363 -0
- fastapi_admin_kit/widgets/registry.py +110 -0
- fastapi_admin_kit/widgets/relation.py +70 -0
- fastapi_admin_kit/widgets/resolver.py +102 -0
- fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
- fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
- fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
- fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
}
|