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,1040 @@
|
|
|
1
|
+
"""Class-based view orchestrators for CRUD operations.
|
|
2
|
+
|
|
3
|
+
SRP: Each view class coordinates a single workflow.
|
|
4
|
+
OCP: Extend by subclassing, never modify the base.
|
|
5
|
+
DIP: Compose via protocol abstractions (renderer, parser, query provider).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import HTTPException, Request
|
|
14
|
+
from fastapi.responses import RedirectResponse, Response
|
|
15
|
+
|
|
16
|
+
from fastapi_admin_kit.db import get_db_session
|
|
17
|
+
from fastapi_admin_kit.flash import add_flash
|
|
18
|
+
from fastapi_admin_kit.registry import RegisteredModel
|
|
19
|
+
from fastapi_admin_kit.views.context import DisplayColumn
|
|
20
|
+
from fastapi_admin_kit.views.renderers import (
|
|
21
|
+
DefaultQueryProvider,
|
|
22
|
+
FormHTMLRenderer,
|
|
23
|
+
HTMLFormParser,
|
|
24
|
+
ItemAPIRenderer,
|
|
25
|
+
JSONBodyParser,
|
|
26
|
+
ListAPIRenderer,
|
|
27
|
+
ListHTMLRenderer,
|
|
28
|
+
_resolve_permission_checker,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_view_class(admin: Any, attr: str, default: type) -> type:
|
|
33
|
+
"""Resolve a view class from ModelAdmin, falling back to default."""
|
|
34
|
+
cls = getattr(admin, attr, None)
|
|
35
|
+
return cls if cls is not None else default
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BaseView:
|
|
39
|
+
"""Base class — holds registered model, provides dependency injection.
|
|
40
|
+
|
|
41
|
+
Subclass and override class attributes to swap implementations (DIP).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# Class-level defaults — override per-model or subclass (OCP)
|
|
45
|
+
query_provider_class: type = DefaultQueryProvider
|
|
46
|
+
form_parser_class: type = HTMLFormParser
|
|
47
|
+
html_renderer_class: type | None = None
|
|
48
|
+
api_renderer_class: type | None = None
|
|
49
|
+
|
|
50
|
+
def __init__(self, registered: RegisteredModel):
|
|
51
|
+
self.registered = registered
|
|
52
|
+
self.admin = registered.admin
|
|
53
|
+
# Instantiate dependencies — DIP: inject via class attributes
|
|
54
|
+
self.query_provider = self.query_provider_class(registered)
|
|
55
|
+
self.form_parser = self.form_parser_class(registered)
|
|
56
|
+
self.html_renderer = (
|
|
57
|
+
self.html_renderer_class() if self.html_renderer_class else None
|
|
58
|
+
)
|
|
59
|
+
self.api_renderer = (
|
|
60
|
+
self.api_renderer_class(registered)
|
|
61
|
+
if self.api_renderer_class
|
|
62
|
+
else None
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _get_extra_context(self, request: Request) -> dict[str, Any]:
|
|
66
|
+
"""Inject AdminExtra CSS/JS into template context.
|
|
67
|
+
|
|
68
|
+
SRP: Only collects extra assets from model admin config.
|
|
69
|
+
"""
|
|
70
|
+
extra = getattr(self.admin, "extra", None)
|
|
71
|
+
if extra is None:
|
|
72
|
+
return {}
|
|
73
|
+
admin_path = request.app.state.admin_config.get("admin_path", "/admin")
|
|
74
|
+
return extra.to_context(admin_path)
|
|
75
|
+
|
|
76
|
+
def _serialize(self, obj: Any) -> dict[str, Any]:
|
|
77
|
+
"""Serialize an object to a dict using registered columns."""
|
|
78
|
+
if self.api_renderer and hasattr(self.api_renderer, "serialize"):
|
|
79
|
+
return self.api_renderer.serialize(obj)
|
|
80
|
+
item_dict: dict[str, Any] = {"id": getattr(obj, "id", None)}
|
|
81
|
+
for col in self.registered.columns:
|
|
82
|
+
if col.name != "id":
|
|
83
|
+
item_dict[col.name] = str(getattr(obj, col.name, ""))
|
|
84
|
+
return item_dict
|
|
85
|
+
|
|
86
|
+
async def html_response(self, request: Request) -> Response:
|
|
87
|
+
raise NotImplementedError
|
|
88
|
+
|
|
89
|
+
async def api_response(self, request: Request) -> Response:
|
|
90
|
+
raise NotImplementedError
|
|
91
|
+
|
|
92
|
+
def _resolve_rel_keys(self, parsed: dict[str, Any]) -> dict[str, Any]:
|
|
93
|
+
"""Convert relationship keys in parsed data to their FK column names."""
|
|
94
|
+
from sqlalchemy import inspect as sa_inspect
|
|
95
|
+
|
|
96
|
+
col_names = {c.name for c in self.registered.columns}
|
|
97
|
+
rel_fk_map: dict[str, str] = {}
|
|
98
|
+
try:
|
|
99
|
+
mapper = sa_inspect(self.registered.model)
|
|
100
|
+
except Exception:
|
|
101
|
+
mapper = None
|
|
102
|
+
if mapper is not None:
|
|
103
|
+
for rel_key, rel_prop in mapper.relationships.items():
|
|
104
|
+
if rel_prop.direction.name == "MANYTOMANY":
|
|
105
|
+
continue
|
|
106
|
+
local_cols = [c.key for c in rel_prop.local_columns]
|
|
107
|
+
if local_cols:
|
|
108
|
+
rel_fk_map[rel_key] = local_cols[0]
|
|
109
|
+
|
|
110
|
+
resolved: dict[str, Any] = {}
|
|
111
|
+
for key, value in parsed.items():
|
|
112
|
+
if key in rel_fk_map and key not in col_names:
|
|
113
|
+
resolved[rel_fk_map[key]] = value
|
|
114
|
+
else:
|
|
115
|
+
resolved[key] = value
|
|
116
|
+
return resolved
|
|
117
|
+
|
|
118
|
+
def _pop_manytomany_keys(self, obj: Any, parsed: dict[str, Any]) -> dict[str, Any]:
|
|
119
|
+
"""Remove MANYTOMANY relationship keys from parsed dict in-place.
|
|
120
|
+
|
|
121
|
+
Returns a dict mapping rel_key -> raw parsed value for M2M fields.
|
|
122
|
+
"""
|
|
123
|
+
from sqlalchemy import inspect as sa_inspect
|
|
124
|
+
|
|
125
|
+
m2m_data: dict[str, Any] = {}
|
|
126
|
+
try:
|
|
127
|
+
model_class = type(obj) if not isinstance(obj, type) else obj
|
|
128
|
+
mapper = sa_inspect(model_class)
|
|
129
|
+
except Exception:
|
|
130
|
+
return m2m_data
|
|
131
|
+
for rel_key, rel_prop in mapper.relationships.items():
|
|
132
|
+
if rel_prop.direction.name == "MANYTOMANY" and rel_key in parsed:
|
|
133
|
+
m2m_data[rel_key] = parsed.pop(rel_key)
|
|
134
|
+
return m2m_data
|
|
135
|
+
|
|
136
|
+
async def _apply_m2m_from_data(
|
|
137
|
+
self, obj: Any, m2m_data: dict[str, Any], session: Any
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Apply MANYTOMANY data extracted by _pop_manytomany_keys."""
|
|
140
|
+
import json as _json
|
|
141
|
+
|
|
142
|
+
from sqlalchemy import inspect as sa_inspect
|
|
143
|
+
|
|
144
|
+
if not m2m_data:
|
|
145
|
+
return
|
|
146
|
+
try:
|
|
147
|
+
mapper = sa_inspect(type(obj))
|
|
148
|
+
except Exception:
|
|
149
|
+
return
|
|
150
|
+
for rel_key, rel_prop in mapper.relationships.items():
|
|
151
|
+
if rel_prop.direction.name != "MANYTOMANY":
|
|
152
|
+
continue
|
|
153
|
+
if rel_key not in m2m_data:
|
|
154
|
+
continue
|
|
155
|
+
raw = m2m_data[rel_key]
|
|
156
|
+
pk_list = []
|
|
157
|
+
if isinstance(raw, list):
|
|
158
|
+
for item in raw:
|
|
159
|
+
if isinstance(item, str) and item.startswith("["):
|
|
160
|
+
try:
|
|
161
|
+
pk_list.extend(_json.loads(item))
|
|
162
|
+
except (ValueError, TypeError):
|
|
163
|
+
pk_list.append(item)
|
|
164
|
+
else:
|
|
165
|
+
pk_list.append(item)
|
|
166
|
+
else:
|
|
167
|
+
pk_list = [raw]
|
|
168
|
+
target_model = rel_prop.mapper.class_
|
|
169
|
+
objs = []
|
|
170
|
+
for pk in pk_list:
|
|
171
|
+
if not pk:
|
|
172
|
+
continue
|
|
173
|
+
try:
|
|
174
|
+
loaded = await session.get(target_model, int(pk))
|
|
175
|
+
if loaded:
|
|
176
|
+
objs.append(loaded)
|
|
177
|
+
except (ValueError, TypeError):
|
|
178
|
+
pass
|
|
179
|
+
await session.refresh(obj, [rel_key])
|
|
180
|
+
setattr(obj, rel_key, objs)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ListView(BaseView):
|
|
184
|
+
"""Orchestrates list view: query -> render HTML or API."""
|
|
185
|
+
|
|
186
|
+
html_renderer_class = ListHTMLRenderer
|
|
187
|
+
api_renderer_class = ListAPIRenderer
|
|
188
|
+
|
|
189
|
+
def _build_display_columns(self) -> list[DisplayColumn]:
|
|
190
|
+
"""Build display column metadata."""
|
|
191
|
+
from sqlalchemy import inspect as sa_inspect
|
|
192
|
+
|
|
193
|
+
model = self.registered.model
|
|
194
|
+
mapper = sa_inspect(model)
|
|
195
|
+
rel_names = {r.key for r in mapper.relationships}
|
|
196
|
+
|
|
197
|
+
list_display = self.admin.list_display or [
|
|
198
|
+
c.name for c in self.registered.columns if c.name != "id"
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
# Collect @column decorated methods (check _column_options attribute)
|
|
202
|
+
decorated_columns: dict[str, Any] = {}
|
|
203
|
+
for attr_name in list_display:
|
|
204
|
+
method = getattr(self.admin, attr_name, None)
|
|
205
|
+
if method and hasattr(method, "_column_options"):
|
|
206
|
+
decorated_columns[attr_name] = method._column_options
|
|
207
|
+
|
|
208
|
+
display_columns = []
|
|
209
|
+
for col_name in list_display:
|
|
210
|
+
label = col_name.replace("_", " ").title()
|
|
211
|
+
display_fn = None
|
|
212
|
+
options = None
|
|
213
|
+
|
|
214
|
+
# Check @column decorator first
|
|
215
|
+
if col_name in decorated_columns:
|
|
216
|
+
options = decorated_columns[col_name]
|
|
217
|
+
display_fn = getattr(self.admin, col_name)
|
|
218
|
+
label = options.header or label
|
|
219
|
+
# Check display_functions dict fallback
|
|
220
|
+
elif self.admin.display_functions and col_name in self.admin.display_functions:
|
|
221
|
+
display_fn = self.admin.display_functions[col_name]
|
|
222
|
+
|
|
223
|
+
display_columns.append(
|
|
224
|
+
DisplayColumn(col_name, label, col_name in rel_names, display_fn, options)
|
|
225
|
+
)
|
|
226
|
+
return display_columns
|
|
227
|
+
|
|
228
|
+
async def _build_filter_fields(
|
|
229
|
+
self, request: Request
|
|
230
|
+
) -> dict[str, dict[str, Any]]:
|
|
231
|
+
"""Build filter field metadata."""
|
|
232
|
+
if not self.admin.list_filter:
|
|
233
|
+
return {}
|
|
234
|
+
session = get_db_session(request)
|
|
235
|
+
model = self.registered.model
|
|
236
|
+
filter_fields: dict[str, dict[str, Any]] = {}
|
|
237
|
+
for filter_field in self.admin.list_filter:
|
|
238
|
+
filter_fields[
|
|
239
|
+
filter_field
|
|
240
|
+
] = await self.query_provider._get_filter_choices(
|
|
241
|
+
model, filter_field, session
|
|
242
|
+
)
|
|
243
|
+
return filter_fields
|
|
244
|
+
|
|
245
|
+
async def get_context(
|
|
246
|
+
self, request: Request, q: str, page: int, checker: Any
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
"""Build template context — override to add custom context."""
|
|
249
|
+
from fastapi_admin_kit.types import PermissionSet
|
|
250
|
+
from fastapi_admin_kit.views.sidebar import inject_sidebar_context
|
|
251
|
+
|
|
252
|
+
(
|
|
253
|
+
items,
|
|
254
|
+
total,
|
|
255
|
+
page,
|
|
256
|
+
per_page,
|
|
257
|
+
next_cursor,
|
|
258
|
+
has_next,
|
|
259
|
+
pagination_mode,
|
|
260
|
+
) = await self.query_provider.get_list(request, q, page)
|
|
261
|
+
|
|
262
|
+
active_filters: dict[str, str] = {}
|
|
263
|
+
if self.admin.list_filter:
|
|
264
|
+
for filter_field in self.admin.list_filter:
|
|
265
|
+
val = request.query_params.get(f"filter_{filter_field}", "")
|
|
266
|
+
if val:
|
|
267
|
+
active_filters[filter_field] = val
|
|
268
|
+
for suffix in ("__gte", "__lte", "__from", "__to"):
|
|
269
|
+
val = request.query_params.get(
|
|
270
|
+
f"filter_{filter_field}{suffix}", ""
|
|
271
|
+
)
|
|
272
|
+
if val:
|
|
273
|
+
active_filters[f"{filter_field}{suffix}"] = val
|
|
274
|
+
|
|
275
|
+
display_columns = self._build_display_columns()
|
|
276
|
+
filter_fields = await self._build_filter_fields(request)
|
|
277
|
+
ordering = request.query_params.get("ordering", "")
|
|
278
|
+
if not ordering and self.admin.ordering:
|
|
279
|
+
ordering = self.admin.ordering[0]
|
|
280
|
+
|
|
281
|
+
template_context = {
|
|
282
|
+
"model": self.registered,
|
|
283
|
+
"registered": self.registered,
|
|
284
|
+
"display_columns": display_columns,
|
|
285
|
+
"items": items,
|
|
286
|
+
"search_query": q,
|
|
287
|
+
"page": page,
|
|
288
|
+
"total_pages": max(1, math.ceil(total / per_page)) if per_page else 1,
|
|
289
|
+
"total": total,
|
|
290
|
+
"per_page": per_page,
|
|
291
|
+
"next_cursor": next_cursor,
|
|
292
|
+
"has_next": has_next,
|
|
293
|
+
"pagination_mode": pagination_mode,
|
|
294
|
+
"filter_fields": filter_fields,
|
|
295
|
+
"active_filters": active_filters,
|
|
296
|
+
"ordering": ordering,
|
|
297
|
+
"permissions": checker.permission_set(self.registered.table_name)
|
|
298
|
+
if checker
|
|
299
|
+
else PermissionSet(
|
|
300
|
+
can_view=True, can_create=True, can_edit=True, can_delete=True
|
|
301
|
+
),
|
|
302
|
+
"list_actions": self.admin.get_list_actions(),
|
|
303
|
+
"row_actions": self.admin.get_row_actions(),
|
|
304
|
+
"list_tabs": getattr(self.admin, "list_tabs", []),
|
|
305
|
+
"list_sections": getattr(self.admin, "list_sections", []),
|
|
306
|
+
"ordering_field": getattr(self.admin, "ordering_field", None),
|
|
307
|
+
"hide_ordering_field": getattr(
|
|
308
|
+
self.admin, "hide_ordering_field", False
|
|
309
|
+
),
|
|
310
|
+
"list_filter_options": getattr(
|
|
311
|
+
self.admin, "list_filter_options", {}
|
|
312
|
+
),
|
|
313
|
+
"list_filter_horizontal": getattr(
|
|
314
|
+
self.admin, "list_filter_horizontal", False
|
|
315
|
+
),
|
|
316
|
+
}
|
|
317
|
+
template_context.update(self._get_extra_context(request))
|
|
318
|
+
await inject_sidebar_context(request, template_context)
|
|
319
|
+
return template_context
|
|
320
|
+
|
|
321
|
+
async def html_response(
|
|
322
|
+
self, request: Request, q: str = "", page: int = 1
|
|
323
|
+
) -> Response:
|
|
324
|
+
checker = await _resolve_permission_checker(request)
|
|
325
|
+
if checker:
|
|
326
|
+
await checker.load_permissions(self.registered.table_name)
|
|
327
|
+
ctx = await self.get_context(request, q, page, checker)
|
|
328
|
+
return await self.html_renderer.render(request, ctx)
|
|
329
|
+
|
|
330
|
+
async def api_response(
|
|
331
|
+
self,
|
|
332
|
+
request: Request,
|
|
333
|
+
page: int = 1,
|
|
334
|
+
per_page: int = 25,
|
|
335
|
+
q: str = "",
|
|
336
|
+
order: str = "",
|
|
337
|
+
after: str | None = None,
|
|
338
|
+
before: str | None = None,
|
|
339
|
+
) -> Any:
|
|
340
|
+
(
|
|
341
|
+
items,
|
|
342
|
+
total,
|
|
343
|
+
page,
|
|
344
|
+
per_page,
|
|
345
|
+
next_cursor,
|
|
346
|
+
has_next,
|
|
347
|
+
pagination_mode,
|
|
348
|
+
) = await self.query_provider.get_list(request, q, page)
|
|
349
|
+
item_list = [self._serialize(item) for item in items]
|
|
350
|
+
return await self.api_renderer.render(
|
|
351
|
+
request,
|
|
352
|
+
{
|
|
353
|
+
"items": item_list,
|
|
354
|
+
"total": total,
|
|
355
|
+
"page": page,
|
|
356
|
+
"per_page": per_page,
|
|
357
|
+
"total_pages": math.ceil(total / per_page) if per_page else 1,
|
|
358
|
+
"next_cursor": next_cursor,
|
|
359
|
+
"has_next": has_next,
|
|
360
|
+
},
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class CreateView(BaseView):
|
|
365
|
+
"""Orchestrates create: parse -> validate -> save -> respond."""
|
|
366
|
+
|
|
367
|
+
html_renderer_class = FormHTMLRenderer
|
|
368
|
+
form_parser_class = HTMLFormParser
|
|
369
|
+
api_renderer_class = ItemAPIRenderer
|
|
370
|
+
|
|
371
|
+
async def _build_form_context(
|
|
372
|
+
self,
|
|
373
|
+
request: Request,
|
|
374
|
+
obj: Any | None = None,
|
|
375
|
+
values: dict[str, Any] | None = None,
|
|
376
|
+
errors: dict[str, list[str]] | None = None,
|
|
377
|
+
is_create: bool = True,
|
|
378
|
+
checker: Any = None,
|
|
379
|
+
) -> dict[str, Any]:
|
|
380
|
+
"""Build form template context."""
|
|
381
|
+
from fastapi_admin_kit.form.pipeline import (
|
|
382
|
+
build_form_context as _build_form_ctx,
|
|
383
|
+
)
|
|
384
|
+
from fastapi_admin_kit.types import PermissionSet
|
|
385
|
+
from fastapi_admin_kit.views.sidebar import inject_sidebar_context
|
|
386
|
+
|
|
387
|
+
ctx = _build_form_ctx(
|
|
388
|
+
self.registered,
|
|
389
|
+
obj=obj,
|
|
390
|
+
values=values,
|
|
391
|
+
errors=errors,
|
|
392
|
+
request=request,
|
|
393
|
+
is_create=is_create,
|
|
394
|
+
)
|
|
395
|
+
template_context = {
|
|
396
|
+
"form_context": ctx,
|
|
397
|
+
"registered": self.registered,
|
|
398
|
+
"obj": ctx.obj,
|
|
399
|
+
"form_fields": ctx.fieldsets[0].fields if ctx.fieldsets else [],
|
|
400
|
+
"fieldsets": ctx.fieldsets,
|
|
401
|
+
"errors": ctx.errors,
|
|
402
|
+
"is_create": is_create,
|
|
403
|
+
"permissions": checker.permission_set(self.registered.table_name)
|
|
404
|
+
if checker
|
|
405
|
+
else PermissionSet(
|
|
406
|
+
can_view=True, can_create=True, can_edit=True, can_delete=True
|
|
407
|
+
),
|
|
408
|
+
"detail_actions": self.admin.get_detail_actions(),
|
|
409
|
+
"submit_line_actions": self.admin.get_submit_line_actions(),
|
|
410
|
+
"conditional_fields": getattr(self.admin, "conditional_fields", {}),
|
|
411
|
+
"warn_unsaved_form": getattr(self.admin, "warn_unsaved_form", True),
|
|
412
|
+
"compressed_fields": getattr(self.admin, "compressed_fields", True),
|
|
413
|
+
"change_form_show_cancel_button": getattr(
|
|
414
|
+
self.admin, "change_form_show_cancel_button", True
|
|
415
|
+
),
|
|
416
|
+
}
|
|
417
|
+
template_context.update(self._get_extra_context(request))
|
|
418
|
+
await inject_sidebar_context(request, template_context)
|
|
419
|
+
|
|
420
|
+
template_context = self.admin.get_form_context(template_context, obj, request)
|
|
421
|
+
|
|
422
|
+
return template_context
|
|
423
|
+
|
|
424
|
+
async def _create_object(
|
|
425
|
+
self, request: Request, parsed: dict[str, Any]
|
|
426
|
+
) -> RedirectResponse:
|
|
427
|
+
"""Create object in database."""
|
|
428
|
+
try:
|
|
429
|
+
session = get_db_session(request)
|
|
430
|
+
m2m_data = self._pop_manytomany_keys(self.registered.model, parsed)
|
|
431
|
+
resolved = self._resolve_rel_keys(parsed)
|
|
432
|
+
resolved = self.admin.prepare_create_data(resolved, request)
|
|
433
|
+
obj = self.registered.model(**resolved)
|
|
434
|
+
self.admin.on_create(obj, request)
|
|
435
|
+
session.add(obj)
|
|
436
|
+
await self._apply_m2m_from_data(obj, m2m_data, session)
|
|
437
|
+
await session.flush()
|
|
438
|
+
self.admin.after_create(obj, request)
|
|
439
|
+
await add_flash(
|
|
440
|
+
request, "success", f"{self.registered.verbose_name} created."
|
|
441
|
+
)
|
|
442
|
+
except Exception:
|
|
443
|
+
session = get_db_session(request)
|
|
444
|
+
await session.rollback()
|
|
445
|
+
raise
|
|
446
|
+
url = (
|
|
447
|
+
f"{request.app.state.admin_config['admin_path']}"
|
|
448
|
+
f"/{self.registered.table_name}/"
|
|
449
|
+
)
|
|
450
|
+
return RedirectResponse(url=url, status_code=303)
|
|
451
|
+
|
|
452
|
+
async def html_response(self, request: Request) -> Response:
|
|
453
|
+
checker = await _resolve_permission_checker(request)
|
|
454
|
+
if checker:
|
|
455
|
+
await checker.load_permissions(self.registered.table_name)
|
|
456
|
+
|
|
457
|
+
if request.method == "GET":
|
|
458
|
+
ctx = await self._build_form_context(
|
|
459
|
+
request, is_create=True, checker=checker
|
|
460
|
+
)
|
|
461
|
+
return await self.html_renderer.render(request, ctx)
|
|
462
|
+
|
|
463
|
+
# POST
|
|
464
|
+
parsed, errors = await self.form_parser.parse(request)
|
|
465
|
+
if errors:
|
|
466
|
+
session = get_db_session(request)
|
|
467
|
+
await session.rollback()
|
|
468
|
+
user = getattr(request.state, "admin_user", None)
|
|
469
|
+
if user is not None:
|
|
470
|
+
await session.refresh(user)
|
|
471
|
+
ctx = await self._build_form_context(
|
|
472
|
+
request,
|
|
473
|
+
values=parsed,
|
|
474
|
+
errors=errors,
|
|
475
|
+
is_create=True,
|
|
476
|
+
checker=checker,
|
|
477
|
+
)
|
|
478
|
+
return await self.html_renderer.render(request, ctx)
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
parsed = self.admin.validate_create(parsed, request)
|
|
482
|
+
except ValueError as e:
|
|
483
|
+
session = get_db_session(request)
|
|
484
|
+
await session.rollback()
|
|
485
|
+
ctx = await self._build_form_context(
|
|
486
|
+
request,
|
|
487
|
+
values=parsed,
|
|
488
|
+
errors={"__all__": [str(e)]},
|
|
489
|
+
is_create=True,
|
|
490
|
+
checker=checker,
|
|
491
|
+
)
|
|
492
|
+
return await self.html_renderer.render(request, ctx)
|
|
493
|
+
|
|
494
|
+
parsed = self.admin.process_form_data(parsed, request)
|
|
495
|
+
|
|
496
|
+
return await self._create_object(request, parsed)
|
|
497
|
+
|
|
498
|
+
async def api_response(self, request: Request) -> Any:
|
|
499
|
+
parser = JSONBodyParser(self.registered)
|
|
500
|
+
parsed, errors = await parser.parse(request)
|
|
501
|
+
if errors:
|
|
502
|
+
raise HTTPException(status_code=422, detail=errors)
|
|
503
|
+
session = get_db_session(request)
|
|
504
|
+
obj = self.registered.model(**parsed)
|
|
505
|
+
self.admin.on_create(obj, request)
|
|
506
|
+
session.add(obj)
|
|
507
|
+
await session.flush()
|
|
508
|
+
self.admin.after_create(obj, request)
|
|
509
|
+
return await self.api_renderer.render(request, self._serialize(obj))
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class EditView(BaseView):
|
|
513
|
+
"""Orchestrates edit: fetch -> parse -> validate -> update -> respond."""
|
|
514
|
+
|
|
515
|
+
html_renderer_class = FormHTMLRenderer
|
|
516
|
+
form_parser_class = HTMLFormParser
|
|
517
|
+
api_renderer_class = ItemAPIRenderer
|
|
518
|
+
|
|
519
|
+
async def _resolve_rel_labels(
|
|
520
|
+
self, obj: Any, request: Request
|
|
521
|
+
) -> dict[str, str]:
|
|
522
|
+
"""Resolve display labels for relationship fields from FK values."""
|
|
523
|
+
from sqlalchemy import inspect as sa_inspect
|
|
524
|
+
|
|
525
|
+
from fastapi_admin_kit.inspection import model_display_name
|
|
526
|
+
|
|
527
|
+
labels: dict[str, str] = {}
|
|
528
|
+
if obj is None:
|
|
529
|
+
return labels
|
|
530
|
+
try:
|
|
531
|
+
mapper = sa_inspect(type(obj))
|
|
532
|
+
except Exception:
|
|
533
|
+
return labels
|
|
534
|
+
session = get_db_session(request)
|
|
535
|
+
for rel_key, rel_prop in mapper.relationships.items():
|
|
536
|
+
local_cols = [c.key for c in rel_prop.local_columns]
|
|
537
|
+
if not local_cols:
|
|
538
|
+
continue
|
|
539
|
+
fk_val = getattr(obj, local_cols[0], None)
|
|
540
|
+
if fk_val is None:
|
|
541
|
+
continue
|
|
542
|
+
target_cls = rel_prop.mapper.class_
|
|
543
|
+
try:
|
|
544
|
+
target = await session.get(target_cls, fk_val)
|
|
545
|
+
if target is not None:
|
|
546
|
+
labels[rel_key] = model_display_name(target)
|
|
547
|
+
except Exception:
|
|
548
|
+
labels[rel_key] = str(fk_val)
|
|
549
|
+
return labels
|
|
550
|
+
|
|
551
|
+
async def _build_form_context(
|
|
552
|
+
self,
|
|
553
|
+
request: Request,
|
|
554
|
+
obj: Any | None = None,
|
|
555
|
+
values: dict[str, Any] | None = None,
|
|
556
|
+
errors: dict[str, list[str]] | None = None,
|
|
557
|
+
is_create: bool = False,
|
|
558
|
+
checker: Any = None,
|
|
559
|
+
rel_labels: dict[str, str] | None = None,
|
|
560
|
+
) -> dict[str, Any]:
|
|
561
|
+
"""Build form template context."""
|
|
562
|
+
from fastapi_admin_kit.form.pipeline import (
|
|
563
|
+
build_form_context as _build_form_ctx,
|
|
564
|
+
)
|
|
565
|
+
from fastapi_admin_kit.types import PermissionSet
|
|
566
|
+
from fastapi_admin_kit.views.sidebar import inject_sidebar_context
|
|
567
|
+
|
|
568
|
+
ctx = _build_form_ctx(
|
|
569
|
+
self.registered,
|
|
570
|
+
obj=obj,
|
|
571
|
+
values=values,
|
|
572
|
+
errors=errors,
|
|
573
|
+
request=request,
|
|
574
|
+
is_create=is_create,
|
|
575
|
+
rel_labels=rel_labels,
|
|
576
|
+
)
|
|
577
|
+
template_context = {
|
|
578
|
+
"form_context": ctx,
|
|
579
|
+
"registered": self.registered,
|
|
580
|
+
"obj": ctx.obj,
|
|
581
|
+
"form_fields": ctx.fieldsets[0].fields if ctx.fieldsets else [],
|
|
582
|
+
"fieldsets": ctx.fieldsets,
|
|
583
|
+
"errors": ctx.errors,
|
|
584
|
+
"is_create": is_create,
|
|
585
|
+
"permissions": checker.permission_set(self.registered.table_name)
|
|
586
|
+
if checker
|
|
587
|
+
else PermissionSet(
|
|
588
|
+
can_view=True, can_create=True, can_edit=True, can_delete=True
|
|
589
|
+
),
|
|
590
|
+
"detail_actions": self.admin.get_detail_actions(),
|
|
591
|
+
"submit_line_actions": self.admin.get_submit_line_actions(),
|
|
592
|
+
"conditional_fields": getattr(self.admin, "conditional_fields", {}),
|
|
593
|
+
"warn_unsaved_form": getattr(self.admin, "warn_unsaved_form", True),
|
|
594
|
+
"compressed_fields": getattr(self.admin, "compressed_fields", True),
|
|
595
|
+
"change_form_show_cancel_button": getattr(
|
|
596
|
+
self.admin, "change_form_show_cancel_button", True
|
|
597
|
+
),
|
|
598
|
+
}
|
|
599
|
+
template_context.update(self._get_extra_context(request))
|
|
600
|
+
await inject_sidebar_context(request, template_context)
|
|
601
|
+
|
|
602
|
+
template_context = self.admin.get_form_context(template_context, obj, request)
|
|
603
|
+
|
|
604
|
+
return template_context
|
|
605
|
+
|
|
606
|
+
def _apply_parsed(self, obj: Any, parsed: dict[str, Any]) -> None:
|
|
607
|
+
"""Apply parsed form/JSON data to an ORM object.
|
|
608
|
+
|
|
609
|
+
Relationship fields (e.g. ``"user"``) are resolved to their
|
|
610
|
+
local foreign-key column (e.g. ``"user_id"``) so that the
|
|
611
|
+
correct column is persisted by SQLAlchemy.
|
|
612
|
+
"""
|
|
613
|
+
from sqlalchemy import inspect as sa_inspect
|
|
614
|
+
|
|
615
|
+
col_names = {c.name for c in self.registered.columns}
|
|
616
|
+
|
|
617
|
+
# Build mapping: relationship key -> local FK column key
|
|
618
|
+
rel_fk_map: dict[str, str] = {}
|
|
619
|
+
try:
|
|
620
|
+
mapper = sa_inspect(type(obj))
|
|
621
|
+
except Exception:
|
|
622
|
+
mapper = None
|
|
623
|
+
if mapper is not None:
|
|
624
|
+
for rel_key, rel_prop in mapper.relationships.items():
|
|
625
|
+
if rel_prop.direction.name == "MANYTOMANY":
|
|
626
|
+
continue
|
|
627
|
+
local_cols = [c.key for c in rel_prop.local_columns]
|
|
628
|
+
if local_cols:
|
|
629
|
+
rel_fk_map[rel_key] = local_cols[0]
|
|
630
|
+
|
|
631
|
+
for key, value in parsed.items():
|
|
632
|
+
if key in col_names:
|
|
633
|
+
setattr(obj, key, value)
|
|
634
|
+
elif key in rel_fk_map:
|
|
635
|
+
setattr(obj, rel_fk_map[key], value)
|
|
636
|
+
|
|
637
|
+
async def _update_object(
|
|
638
|
+
self, request: Request, obj: Any, parsed: dict[str, Any]
|
|
639
|
+
) -> RedirectResponse:
|
|
640
|
+
"""Update object in database."""
|
|
641
|
+
try:
|
|
642
|
+
parsed = self.admin.prepare_update_data(parsed, request)
|
|
643
|
+
m2m_data = self._pop_manytomany_keys(obj, parsed)
|
|
644
|
+
self._apply_parsed(obj, parsed)
|
|
645
|
+
session = get_db_session(request)
|
|
646
|
+
await self._apply_m2m_from_data(obj, m2m_data, session)
|
|
647
|
+
self.admin.on_update(obj, parsed, request)
|
|
648
|
+
await session.flush()
|
|
649
|
+
self.admin.after_update(obj, request)
|
|
650
|
+
await add_flash(
|
|
651
|
+
request, "success", f"{self.registered.verbose_name} updated."
|
|
652
|
+
)
|
|
653
|
+
except Exception:
|
|
654
|
+
session = get_db_session(request)
|
|
655
|
+
await session.rollback()
|
|
656
|
+
raise
|
|
657
|
+
url = (
|
|
658
|
+
f"{request.app.state.admin_config['admin_path']}"
|
|
659
|
+
f"/{self.registered.table_name}/"
|
|
660
|
+
)
|
|
661
|
+
return RedirectResponse(url=url, status_code=303)
|
|
662
|
+
|
|
663
|
+
async def _build_detail_context(
|
|
664
|
+
self,
|
|
665
|
+
request: Request,
|
|
666
|
+
obj: Any,
|
|
667
|
+
checker: Any = None,
|
|
668
|
+
) -> dict[str, Any]:
|
|
669
|
+
"""Build read-only detail view context with all fields."""
|
|
670
|
+
from fastapi_admin_kit.form.pipeline import (
|
|
671
|
+
build_form_context as _build_form_ctx,
|
|
672
|
+
)
|
|
673
|
+
from fastapi_admin_kit.types import PermissionSet
|
|
674
|
+
from fastapi_admin_kit.views.sidebar import inject_sidebar_context
|
|
675
|
+
|
|
676
|
+
rel_labels = await self._resolve_rel_labels(obj, request)
|
|
677
|
+
ctx = _build_form_ctx(
|
|
678
|
+
self.registered,
|
|
679
|
+
obj=obj,
|
|
680
|
+
request=request,
|
|
681
|
+
is_create=False,
|
|
682
|
+
rel_labels=rel_labels,
|
|
683
|
+
)
|
|
684
|
+
template_context = {
|
|
685
|
+
"form_context": ctx,
|
|
686
|
+
"registered": self.registered,
|
|
687
|
+
"obj": obj,
|
|
688
|
+
"form_fields": ctx.fieldsets[0].fields if ctx.fieldsets else [],
|
|
689
|
+
"fieldsets": ctx.fieldsets,
|
|
690
|
+
"is_create": False,
|
|
691
|
+
"permissions": checker.permission_set(self.registered.table_name)
|
|
692
|
+
if checker
|
|
693
|
+
else PermissionSet(
|
|
694
|
+
can_view=True, can_create=True, can_edit=True, can_delete=True
|
|
695
|
+
),
|
|
696
|
+
}
|
|
697
|
+
template_context.update(self._get_extra_context(request))
|
|
698
|
+
await inject_sidebar_context(request, template_context)
|
|
699
|
+
return template_context
|
|
700
|
+
|
|
701
|
+
async def html_response(self, request: Request, id: Any = None) -> Response:
|
|
702
|
+
obj = await self.query_provider.get_object(request, id)
|
|
703
|
+
if not obj:
|
|
704
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
705
|
+
|
|
706
|
+
checker = await _resolve_permission_checker(request)
|
|
707
|
+
if checker:
|
|
708
|
+
await checker.load_permissions(self.registered.table_name)
|
|
709
|
+
|
|
710
|
+
perms = (
|
|
711
|
+
checker.permission_set(self.registered.table_name)
|
|
712
|
+
if checker
|
|
713
|
+
else None
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
if request.method == "GET":
|
|
717
|
+
if perms and not perms.can_edit and perms.can_view:
|
|
718
|
+
ctx = await self._build_detail_context(request, obj, checker)
|
|
719
|
+
return request.app.state.admin_jinja_env.TemplateResponse(
|
|
720
|
+
request, "pages/detail.html", ctx
|
|
721
|
+
)
|
|
722
|
+
rel_labels = await self._resolve_rel_labels(obj, request)
|
|
723
|
+
ctx = await self._build_form_context(
|
|
724
|
+
request,
|
|
725
|
+
obj=obj,
|
|
726
|
+
is_create=False,
|
|
727
|
+
checker=checker,
|
|
728
|
+
rel_labels=rel_labels,
|
|
729
|
+
)
|
|
730
|
+
return await self.html_renderer.render(request, ctx)
|
|
731
|
+
|
|
732
|
+
# POST
|
|
733
|
+
parsed, errors = await self.form_parser.parse(request, obj=obj)
|
|
734
|
+
if errors:
|
|
735
|
+
session = get_db_session(request)
|
|
736
|
+
await session.rollback()
|
|
737
|
+
await session.refresh(obj)
|
|
738
|
+
user = getattr(request.state, "admin_user", None)
|
|
739
|
+
if user is not None:
|
|
740
|
+
await session.refresh(user)
|
|
741
|
+
rel_labels = await self._resolve_rel_labels(obj, request)
|
|
742
|
+
ctx = await self._build_form_context(
|
|
743
|
+
request,
|
|
744
|
+
obj=obj,
|
|
745
|
+
values=parsed,
|
|
746
|
+
errors=errors,
|
|
747
|
+
checker=checker,
|
|
748
|
+
rel_labels=rel_labels,
|
|
749
|
+
)
|
|
750
|
+
return await self.html_renderer.render(request, ctx)
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
parsed = self.admin.validate_update(obj, parsed, request)
|
|
754
|
+
except ValueError as e:
|
|
755
|
+
session = get_db_session(request)
|
|
756
|
+
await session.rollback()
|
|
757
|
+
await session.refresh(obj)
|
|
758
|
+
rel_labels = await self._resolve_rel_labels(obj, request)
|
|
759
|
+
ctx = await self._build_form_context(
|
|
760
|
+
request,
|
|
761
|
+
obj=obj,
|
|
762
|
+
values=parsed,
|
|
763
|
+
errors={"__all__": [str(e)]},
|
|
764
|
+
checker=checker,
|
|
765
|
+
rel_labels=rel_labels,
|
|
766
|
+
)
|
|
767
|
+
return await self.html_renderer.render(request, ctx)
|
|
768
|
+
|
|
769
|
+
parsed = self.admin.process_form_data(parsed, request)
|
|
770
|
+
|
|
771
|
+
return await self._update_object(request, obj, parsed)
|
|
772
|
+
|
|
773
|
+
async def api_response(
|
|
774
|
+
self,
|
|
775
|
+
request: Request,
|
|
776
|
+
id: Any = None,
|
|
777
|
+
item_id: Any = None,
|
|
778
|
+
) -> Any:
|
|
779
|
+
pk = id or item_id
|
|
780
|
+
obj = await self.query_provider.get_object(request, pk)
|
|
781
|
+
if not obj:
|
|
782
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
783
|
+
|
|
784
|
+
if request.method == "GET":
|
|
785
|
+
return self._serialize(obj)
|
|
786
|
+
|
|
787
|
+
# PUT
|
|
788
|
+
parser = JSONBodyParser(self.registered)
|
|
789
|
+
parsed, _ = await parser.parse(request, obj)
|
|
790
|
+
try:
|
|
791
|
+
m2m_data = self._pop_manytomany_keys(obj, parsed)
|
|
792
|
+
self._apply_parsed(obj, parsed)
|
|
793
|
+
session = get_db_session(request)
|
|
794
|
+
await self._apply_m2m_from_data(obj, m2m_data, session)
|
|
795
|
+
self.admin.on_update(obj, parsed, request)
|
|
796
|
+
await session.flush()
|
|
797
|
+
self.admin.after_update(obj, request)
|
|
798
|
+
except Exception:
|
|
799
|
+
session = get_db_session(request)
|
|
800
|
+
await session.rollback()
|
|
801
|
+
raise
|
|
802
|
+
return self._serialize(obj)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
class DeleteView(BaseView):
|
|
806
|
+
"""Orchestrates delete: fetch -> delete -> respond."""
|
|
807
|
+
|
|
808
|
+
async def html_response(self, request: Request, id: Any = None) -> Response:
|
|
809
|
+
obj = await self.query_provider.get_object(request, id)
|
|
810
|
+
if not obj:
|
|
811
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
812
|
+
try:
|
|
813
|
+
self.admin.on_delete(obj, request)
|
|
814
|
+
session = get_db_session(request)
|
|
815
|
+
await session.delete(obj)
|
|
816
|
+
await session.flush()
|
|
817
|
+
self.admin.after_delete(obj, request)
|
|
818
|
+
await add_flash(
|
|
819
|
+
request, "success", f"{self.registered.verbose_name} deleted."
|
|
820
|
+
)
|
|
821
|
+
except Exception:
|
|
822
|
+
session = get_db_session(request)
|
|
823
|
+
await session.rollback()
|
|
824
|
+
raise
|
|
825
|
+
url = (
|
|
826
|
+
f"{request.app.state.admin_config['admin_path']}"
|
|
827
|
+
f"/{self.registered.table_name}/"
|
|
828
|
+
)
|
|
829
|
+
return RedirectResponse(url=url, status_code=303)
|
|
830
|
+
|
|
831
|
+
async def api_response(
|
|
832
|
+
self,
|
|
833
|
+
request: Request,
|
|
834
|
+
id: Any = None,
|
|
835
|
+
item_id: Any = None,
|
|
836
|
+
) -> Response:
|
|
837
|
+
pk = id or item_id
|
|
838
|
+
obj = await self.query_provider.get_object(request, pk)
|
|
839
|
+
if not obj:
|
|
840
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
841
|
+
self.admin.on_delete(obj, request)
|
|
842
|
+
session = get_db_session(request)
|
|
843
|
+
await session.delete(obj)
|
|
844
|
+
await session.flush()
|
|
845
|
+
self.admin.after_delete(obj, request)
|
|
846
|
+
return Response(status_code=204)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
class BulkView(BaseView):
|
|
850
|
+
"""Orchestrates bulk actions on multiple objects."""
|
|
851
|
+
|
|
852
|
+
html_renderer_class = ListHTMLRenderer
|
|
853
|
+
|
|
854
|
+
async def html_response(self, request: Request) -> Response:
|
|
855
|
+
session = get_db_session(request)
|
|
856
|
+
form = await request.form()
|
|
857
|
+
action = form.get("action", "")
|
|
858
|
+
ids = form.getlist("ids[]")
|
|
859
|
+
|
|
860
|
+
is_htmx = request.headers.get("HX-Request") == "true"
|
|
861
|
+
|
|
862
|
+
if not ids:
|
|
863
|
+
if is_htmx:
|
|
864
|
+
list_view = ListView(self.registered)
|
|
865
|
+
checker = await _resolve_permission_checker(request)
|
|
866
|
+
ctx = await list_view.get_context(request, "", 1, checker)
|
|
867
|
+
return await self.html_renderer.render(request, ctx)
|
|
868
|
+
url = (
|
|
869
|
+
f"{request.app.state.admin_config['admin_path']}"
|
|
870
|
+
f"/{self.registered.table_name}/"
|
|
871
|
+
)
|
|
872
|
+
return RedirectResponse(url=url, status_code=303)
|
|
873
|
+
|
|
874
|
+
if action == "delete_selected":
|
|
875
|
+
for pid in ids:
|
|
876
|
+
obj = await session.get(self.registered.model, pid)
|
|
877
|
+
if obj:
|
|
878
|
+
self.admin.on_delete(obj, request)
|
|
879
|
+
await session.delete(obj)
|
|
880
|
+
await session.flush()
|
|
881
|
+
else:
|
|
882
|
+
action_obj = None
|
|
883
|
+
for a in self.admin.get_list_actions():
|
|
884
|
+
if a.name == action:
|
|
885
|
+
action_obj = a
|
|
886
|
+
break
|
|
887
|
+
|
|
888
|
+
if action_obj:
|
|
889
|
+
objects = []
|
|
890
|
+
for pid in ids:
|
|
891
|
+
obj = await session.get(self.registered.model, pid)
|
|
892
|
+
if obj:
|
|
893
|
+
objects.append(obj)
|
|
894
|
+
if objects:
|
|
895
|
+
await action_obj.execute(objects, request)
|
|
896
|
+
await session.flush()
|
|
897
|
+
else:
|
|
898
|
+
action_fn = getattr(self.admin, f"action_{action}", None)
|
|
899
|
+
if not action_fn:
|
|
900
|
+
raise HTTPException(
|
|
901
|
+
status_code=400, detail=f"Unknown action: {action}"
|
|
902
|
+
)
|
|
903
|
+
for pid in ids:
|
|
904
|
+
obj = await session.get(self.registered.model, pid)
|
|
905
|
+
if obj:
|
|
906
|
+
action_fn(obj)
|
|
907
|
+
await session.flush()
|
|
908
|
+
|
|
909
|
+
if is_htmx:
|
|
910
|
+
list_view = ListView(self.registered)
|
|
911
|
+
checker = await _resolve_permission_checker(request)
|
|
912
|
+
ctx = await list_view.get_context(request, "", 1, checker)
|
|
913
|
+
return await self.html_renderer.render(request, ctx)
|
|
914
|
+
|
|
915
|
+
url = (
|
|
916
|
+
f"{request.app.state.admin_config['admin_path']}"
|
|
917
|
+
f"/{self.registered.table_name}/"
|
|
918
|
+
)
|
|
919
|
+
return RedirectResponse(url=url, status_code=303)
|
|
920
|
+
|
|
921
|
+
async def api_response(self, request: Request) -> Any:
|
|
922
|
+
from fastapi.responses import JSONResponse
|
|
923
|
+
|
|
924
|
+
session = get_db_session(request)
|
|
925
|
+
content_type = request.headers.get("content-type", "")
|
|
926
|
+
is_json = content_type.startswith("application/json")
|
|
927
|
+
body = await request.json() if is_json else {}
|
|
928
|
+
action = body.get("action", "")
|
|
929
|
+
ids = body.get("ids", [])
|
|
930
|
+
|
|
931
|
+
if action == "delete_selected":
|
|
932
|
+
deleted = 0
|
|
933
|
+
for pid in ids:
|
|
934
|
+
obj = await session.get(self.registered.model, pid)
|
|
935
|
+
if obj:
|
|
936
|
+
self.admin.on_delete(obj, request)
|
|
937
|
+
await session.delete(obj)
|
|
938
|
+
deleted += 1
|
|
939
|
+
await session.flush()
|
|
940
|
+
return JSONResponse({"deleted": deleted})
|
|
941
|
+
|
|
942
|
+
action_obj = None
|
|
943
|
+
for a in self.admin.get_list_actions():
|
|
944
|
+
if a.name == action:
|
|
945
|
+
action_obj = a
|
|
946
|
+
break
|
|
947
|
+
|
|
948
|
+
if action_obj:
|
|
949
|
+
objects = []
|
|
950
|
+
for pid in ids:
|
|
951
|
+
obj = await session.get(self.registered.model, pid)
|
|
952
|
+
if obj:
|
|
953
|
+
objects.append(obj)
|
|
954
|
+
if objects:
|
|
955
|
+
await action_obj.execute(objects, request)
|
|
956
|
+
await session.flush()
|
|
957
|
+
return JSONResponse({"executed": len(objects)})
|
|
958
|
+
|
|
959
|
+
action_fn = getattr(self.admin, f"action_{action}", None)
|
|
960
|
+
if not action_fn:
|
|
961
|
+
raise HTTPException(
|
|
962
|
+
status_code=400, detail=f"Unknown action: {action}"
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
executed = 0
|
|
966
|
+
for pid in ids:
|
|
967
|
+
obj = await session.get(self.registered.model, pid)
|
|
968
|
+
if obj:
|
|
969
|
+
action_fn(obj)
|
|
970
|
+
executed += 1
|
|
971
|
+
await session.flush()
|
|
972
|
+
return JSONResponse({"executed": executed})
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
class SearchView(BaseView):
|
|
976
|
+
"""Orchestrates search/autocomplete for relation pickers."""
|
|
977
|
+
|
|
978
|
+
async def html_response(
|
|
979
|
+
self,
|
|
980
|
+
request: Request,
|
|
981
|
+
q: str = "",
|
|
982
|
+
limit: int = 20,
|
|
983
|
+
exclude_id: str = "",
|
|
984
|
+
) -> Any:
|
|
985
|
+
return await self._search(request, q, limit, exclude_id)
|
|
986
|
+
|
|
987
|
+
async def api_response(
|
|
988
|
+
self,
|
|
989
|
+
request: Request,
|
|
990
|
+
q: str = "",
|
|
991
|
+
limit: int = 20,
|
|
992
|
+
exclude_id: str = "",
|
|
993
|
+
) -> Any:
|
|
994
|
+
return await self._search(request, q, limit, exclude_id)
|
|
995
|
+
|
|
996
|
+
async def _search(
|
|
997
|
+
self, request: Request, q: str, limit: int = 20, exclude_id: str = ""
|
|
998
|
+
) -> Any:
|
|
999
|
+
from fastapi.responses import JSONResponse
|
|
1000
|
+
from sqlalchemy import or_, select
|
|
1001
|
+
|
|
1002
|
+
session = get_db_session(request)
|
|
1003
|
+
model = self.registered.model
|
|
1004
|
+
base = select(model)
|
|
1005
|
+
|
|
1006
|
+
clauses = []
|
|
1007
|
+
if q:
|
|
1008
|
+
search_fields = getattr(self.admin, "search_fields", None) or [
|
|
1009
|
+
"name",
|
|
1010
|
+
"title",
|
|
1011
|
+
]
|
|
1012
|
+
for sf in search_fields:
|
|
1013
|
+
if hasattr(model, sf):
|
|
1014
|
+
col = getattr(model, sf)
|
|
1015
|
+
if hasattr(col, "ilike"):
|
|
1016
|
+
clauses.append(col.ilike(f"%{q}%"))
|
|
1017
|
+
|
|
1018
|
+
if clauses:
|
|
1019
|
+
base = base.where(or_(*clauses))
|
|
1020
|
+
|
|
1021
|
+
if exclude_id:
|
|
1022
|
+
pk_col = getattr(model, self.registered.pk_field, None)
|
|
1023
|
+
if pk_col is not None:
|
|
1024
|
+
base = base.where(pk_col != int(exclude_id))
|
|
1025
|
+
|
|
1026
|
+
base = base.limit(limit)
|
|
1027
|
+
result = session.execute(base)
|
|
1028
|
+
if hasattr(result, "__await__"):
|
|
1029
|
+
result = await result
|
|
1030
|
+
rows = result.scalars().all()
|
|
1031
|
+
|
|
1032
|
+
results = []
|
|
1033
|
+
for row in rows:
|
|
1034
|
+
pk = getattr(row, self.registered.pk_field)
|
|
1035
|
+
from fastapi_admin_kit.inspection import model_display_name
|
|
1036
|
+
|
|
1037
|
+
label = model_display_name(row)
|
|
1038
|
+
results.append({"id": str(pk), "label": label})
|
|
1039
|
+
|
|
1040
|
+
return JSONResponse(results)
|