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,634 @@
|
|
|
1
|
+
"""Concrete implementations of protocol interfaces.
|
|
2
|
+
|
|
3
|
+
SRP: Each class has a single responsibility.
|
|
4
|
+
DIP: View classes depend on these via protocol abstractions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import Request
|
|
12
|
+
from fastapi.responses import Response
|
|
13
|
+
from starlette.datastructures import UploadFile
|
|
14
|
+
|
|
15
|
+
from fastapi_admin_kit.db import get_db_session
|
|
16
|
+
from fastapi_admin_kit.registry import RegisteredModel
|
|
17
|
+
from fastapi_admin_kit.types import FieldMeta
|
|
18
|
+
from fastapi_admin_kit.validation import FormValidator
|
|
19
|
+
from fastapi_admin_kit.widgets.inputs import FileUploadWidget, ImageUploadWidget
|
|
20
|
+
|
|
21
|
+
_FILE_WIDGET_TYPES = (FileUploadWidget, ImageUploadWidget)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_storage(request: Request):
|
|
25
|
+
"""Get the storage backend from app.state, or None."""
|
|
26
|
+
return getattr(request.app.state, "admin_storage", None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _resolve_permission_checker(request: Request) -> Any:
|
|
30
|
+
"""Resolve a PermissionChecker for the current request."""
|
|
31
|
+
from fastapi_admin_kit.auth.identity import get_current_user_from_cookie
|
|
32
|
+
from fastapi_admin_kit.auth.permissions import PermissionChecker
|
|
33
|
+
|
|
34
|
+
user = await get_current_user_from_cookie(request)
|
|
35
|
+
if user is None:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
async_session = get_db_session(request)
|
|
39
|
+
if async_session is None:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
snapshot = getattr(request.state, "admin_user_snapshot", None)
|
|
43
|
+
return PermissionChecker(session=async_session, user=user, user_snapshot=snapshot)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# HTML Renderers (SRP: only HTML template logic)
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ListHTMLRenderer:
|
|
52
|
+
"""SRP: Render list view as HTML template."""
|
|
53
|
+
|
|
54
|
+
async def render(
|
|
55
|
+
self, request: Request, context: dict[str, Any]
|
|
56
|
+
) -> Response:
|
|
57
|
+
templates = request.app.state.admin_jinja_env
|
|
58
|
+
is_htmx = request.headers.get("HX-Request") == "true"
|
|
59
|
+
template = "partials/list_table.html" if is_htmx else "pages/list.html"
|
|
60
|
+
return templates.TemplateResponse(request, template, context)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FormHTMLRenderer:
|
|
64
|
+
"""SRP: Render create/edit form as HTML template."""
|
|
65
|
+
|
|
66
|
+
async def render(
|
|
67
|
+
self, request: Request, context: dict[str, Any]
|
|
68
|
+
) -> Response:
|
|
69
|
+
templates = request.app.state.admin_jinja_env
|
|
70
|
+
status = 422 if context.get("errors") else 200
|
|
71
|
+
return templates.TemplateResponse(
|
|
72
|
+
request, "pages/form.html", context, status_code=status
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# API Renderers (SRP: only JSON serialization logic)
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ListAPIRenderer:
|
|
82
|
+
"""SRP: Render list view as paginated JSON."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, registered: RegisteredModel | None = None):
|
|
85
|
+
self.registered = registered
|
|
86
|
+
|
|
87
|
+
async def render(self, request: Request, data: Any) -> Response:
|
|
88
|
+
from fastapi_admin_kit.api.schemas import PaginatedResponse
|
|
89
|
+
|
|
90
|
+
return PaginatedResponse(**data)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ItemAPIRenderer:
|
|
94
|
+
"""SRP: Render single object as JSON dict."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, registered: RegisteredModel):
|
|
97
|
+
self.registered = registered
|
|
98
|
+
|
|
99
|
+
def serialize(self, obj: Any) -> dict[str, Any]:
|
|
100
|
+
"""Serialize an object to a dict using registered columns."""
|
|
101
|
+
item_dict: dict[str, Any] = {"id": getattr(obj, "id", None)}
|
|
102
|
+
for col in self.registered.columns:
|
|
103
|
+
if col.name != "id":
|
|
104
|
+
item_dict[col.name] = str(getattr(obj, col.name, ""))
|
|
105
|
+
return item_dict
|
|
106
|
+
|
|
107
|
+
async def render(self, request: Request, data: Any) -> Any:
|
|
108
|
+
if isinstance(data, dict):
|
|
109
|
+
return data
|
|
110
|
+
return self.serialize(data)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class DeleteAPIRenderer:
|
|
114
|
+
"""SRP: Return 204 No Content."""
|
|
115
|
+
|
|
116
|
+
async def render(self, request: Request, data: Any = None) -> Response:
|
|
117
|
+
return Response(status_code=204)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Form Parsers (SRP: only form data parsing + validation)
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _handle_file_field(
|
|
126
|
+
request: Request,
|
|
127
|
+
widget: Any,
|
|
128
|
+
field_meta: Any,
|
|
129
|
+
form_data: Any,
|
|
130
|
+
obj: Any | None,
|
|
131
|
+
action: str | None,
|
|
132
|
+
parsed: dict[str, Any],
|
|
133
|
+
errors: dict[str, list[str]],
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Handle a file upload field during form submission."""
|
|
136
|
+
storage = _get_storage(request)
|
|
137
|
+
field_name = field_meta.name
|
|
138
|
+
raw = form_data.get(field_name)
|
|
139
|
+
|
|
140
|
+
if isinstance(raw, UploadFile) and raw.filename:
|
|
141
|
+
if widget.max_size_mb is not None:
|
|
142
|
+
content = await raw.read()
|
|
143
|
+
max_bytes = int(widget.max_size_mb * 1024 * 1024)
|
|
144
|
+
if len(content) > max_bytes:
|
|
145
|
+
errors[field_name] = [
|
|
146
|
+
f"File size exceeds maximum allowed size ({widget.max_size_mb} MB)."
|
|
147
|
+
]
|
|
148
|
+
await raw.seek(0)
|
|
149
|
+
return
|
|
150
|
+
await raw.seek(0)
|
|
151
|
+
|
|
152
|
+
if storage is None:
|
|
153
|
+
errors[field_name] = ["No storage backend configured."]
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
path = await storage.save(raw, directory=field_meta.name)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
errors[field_name] = [str(exc)]
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if action == "replace" and obj is not None:
|
|
163
|
+
old_path = getattr(obj, field_name, None)
|
|
164
|
+
if old_path:
|
|
165
|
+
await storage.delete(old_path)
|
|
166
|
+
|
|
167
|
+
parsed[field_name] = path
|
|
168
|
+
|
|
169
|
+
elif action == "clear":
|
|
170
|
+
if storage is not None and obj is not None:
|
|
171
|
+
old_path = getattr(obj, field_name, None)
|
|
172
|
+
if old_path:
|
|
173
|
+
await storage.delete(old_path)
|
|
174
|
+
parsed[field_name] = None
|
|
175
|
+
|
|
176
|
+
elif action == "keep" or action is None:
|
|
177
|
+
if obj is not None:
|
|
178
|
+
parsed[field_name] = getattr(obj, field_name, None)
|
|
179
|
+
|
|
180
|
+
else:
|
|
181
|
+
if obj is not None:
|
|
182
|
+
parsed[field_name] = getattr(obj, field_name, None)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class HTMLFormParser:
|
|
186
|
+
"""SRP: Parse multipart/form-data from HTML forms."""
|
|
187
|
+
|
|
188
|
+
def __init__(self, registered: RegisteredModel):
|
|
189
|
+
self.registered = registered
|
|
190
|
+
self.validator = FormValidator()
|
|
191
|
+
|
|
192
|
+
async def parse(
|
|
193
|
+
self, request: Request, obj: Any | None = None
|
|
194
|
+
) -> tuple[dict[str, Any], dict[str, list[str]]]:
|
|
195
|
+
form_data = await request.form()
|
|
196
|
+
parsed: dict[str, Any] = {}
|
|
197
|
+
errors: dict[str, list[str]] = {}
|
|
198
|
+
|
|
199
|
+
for field_meta in self.registered.form_fields:
|
|
200
|
+
if field_meta.readonly:
|
|
201
|
+
continue
|
|
202
|
+
widget = self.registered.get_widget(field_meta.name)
|
|
203
|
+
|
|
204
|
+
if isinstance(widget, _FILE_WIDGET_TYPES):
|
|
205
|
+
action = (
|
|
206
|
+
form_data.get(f"_action_{field_meta.name}", "keep")
|
|
207
|
+
if obj
|
|
208
|
+
else None
|
|
209
|
+
)
|
|
210
|
+
await _handle_file_field(
|
|
211
|
+
request,
|
|
212
|
+
widget,
|
|
213
|
+
field_meta,
|
|
214
|
+
form_data,
|
|
215
|
+
obj=obj,
|
|
216
|
+
action=action,
|
|
217
|
+
parsed=parsed,
|
|
218
|
+
errors=errors,
|
|
219
|
+
)
|
|
220
|
+
if (
|
|
221
|
+
obj is None
|
|
222
|
+
and field_meta.name not in errors
|
|
223
|
+
and field_meta.name not in parsed
|
|
224
|
+
):
|
|
225
|
+
parsed[field_meta.name] = None
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
raw = form_data.get(field_meta.name)
|
|
229
|
+
value = widget.parse(raw)
|
|
230
|
+
required_on_create = (field_meta.extra or {}).get("required_on_create")
|
|
231
|
+
if obj is None and required_on_create is not None:
|
|
232
|
+
effective_field = FieldMeta(
|
|
233
|
+
name=field_meta.name,
|
|
234
|
+
label=field_meta.label,
|
|
235
|
+
required=required_on_create,
|
|
236
|
+
readonly=field_meta.readonly,
|
|
237
|
+
extra=field_meta.extra,
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
effective_field = field_meta
|
|
241
|
+
field_errors = widget.validate(value, effective_field)
|
|
242
|
+
if field_errors:
|
|
243
|
+
errors[field_meta.name] = field_errors
|
|
244
|
+
else:
|
|
245
|
+
parsed[field_meta.name] = value
|
|
246
|
+
|
|
247
|
+
if not errors:
|
|
248
|
+
errors = self.validator.run(self.registered, parsed, obj=obj)
|
|
249
|
+
|
|
250
|
+
return parsed, errors
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class JSONBodyParser:
|
|
254
|
+
"""SRP: Parse JSON body from API requests."""
|
|
255
|
+
|
|
256
|
+
def __init__(self, registered: RegisteredModel):
|
|
257
|
+
self.registered = registered
|
|
258
|
+
|
|
259
|
+
async def parse(
|
|
260
|
+
self, request: Request, obj: Any | None = None
|
|
261
|
+
) -> tuple[dict[str, Any], dict[str, list[str]]]:
|
|
262
|
+
body = await request.json()
|
|
263
|
+
valid_fields = {col.name for col in self.registered.columns}
|
|
264
|
+
filtered = {
|
|
265
|
+
k: v for k, v in body.items() if k in valid_fields and k != "id"
|
|
266
|
+
}
|
|
267
|
+
return filtered, {}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
# Query Providers (SRP: only database query logic)
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class DefaultQueryProvider:
|
|
276
|
+
"""SRP: Build and execute SQLAlchemy queries with filtering, search, pagination."""
|
|
277
|
+
|
|
278
|
+
def __init__(self, registered: RegisteredModel):
|
|
279
|
+
self.registered = registered
|
|
280
|
+
|
|
281
|
+
def _get_eager_loads(self, model: Any, list_display: list[str]) -> list:
|
|
282
|
+
"""Build eager load options for relationship columns."""
|
|
283
|
+
from sqlalchemy import inspect as sa_inspect
|
|
284
|
+
from sqlalchemy.orm import joinedload
|
|
285
|
+
|
|
286
|
+
mapper = sa_inspect(model)
|
|
287
|
+
rel_names = {r.key for r in mapper.relationships}
|
|
288
|
+
options = []
|
|
289
|
+
for col_name in list_display:
|
|
290
|
+
if col_name in rel_names:
|
|
291
|
+
options.append(joinedload(getattr(model, col_name)))
|
|
292
|
+
return options
|
|
293
|
+
|
|
294
|
+
def _get_field_type(self, model: Any, field_name: str) -> str:
|
|
295
|
+
"""Detect the abstract field type for a model field."""
|
|
296
|
+
from sqlalchemy import inspect as sa_inspect
|
|
297
|
+
|
|
298
|
+
mapper = sa_inspect(model)
|
|
299
|
+
rel_names = {r.key for r in mapper.relationships}
|
|
300
|
+
|
|
301
|
+
if field_name in rel_names:
|
|
302
|
+
return "relation"
|
|
303
|
+
|
|
304
|
+
for prop in mapper.column_attrs:
|
|
305
|
+
if prop.key == field_name:
|
|
306
|
+
col = prop.columns[0] if prop.columns else None
|
|
307
|
+
if col is None:
|
|
308
|
+
break
|
|
309
|
+
type_name = col.type.__class__.__name__
|
|
310
|
+
if type_name == "Boolean":
|
|
311
|
+
return "boolean"
|
|
312
|
+
if type_name == "DateTime":
|
|
313
|
+
return "datetime"
|
|
314
|
+
if type_name == "Date":
|
|
315
|
+
return "date"
|
|
316
|
+
if type_name == "Time":
|
|
317
|
+
return "time"
|
|
318
|
+
if hasattr(col.type, "enums") and col.type.enums:
|
|
319
|
+
return "enum"
|
|
320
|
+
if col.foreign_keys:
|
|
321
|
+
return "relation"
|
|
322
|
+
return "text"
|
|
323
|
+
return "text"
|
|
324
|
+
|
|
325
|
+
async def _get_filter_choices(
|
|
326
|
+
self, model: Any, field_name: str, session: Any = None
|
|
327
|
+
) -> dict[str, Any]:
|
|
328
|
+
"""Get filter field type and available choices for a field."""
|
|
329
|
+
from sqlalchemy import inspect as sa_inspect
|
|
330
|
+
from sqlalchemy import select
|
|
331
|
+
|
|
332
|
+
mapper = sa_inspect(model)
|
|
333
|
+
field_type = self._get_field_type(model, field_name)
|
|
334
|
+
|
|
335
|
+
if field_type == "relation":
|
|
336
|
+
rel_map = {r.key: r for r in mapper.relationships}
|
|
337
|
+
target_model = None
|
|
338
|
+
if field_name in rel_map:
|
|
339
|
+
target_model = rel_map[field_name].mapper.class_
|
|
340
|
+
else:
|
|
341
|
+
for rel in mapper.relationships:
|
|
342
|
+
if rel.direction.name == "MANYTOONE":
|
|
343
|
+
for prop in mapper.column_attrs:
|
|
344
|
+
if prop.key == field_name:
|
|
345
|
+
col = prop.columns[0] if prop.columns else None
|
|
346
|
+
if col is not None:
|
|
347
|
+
for fk in col.foreign_keys:
|
|
348
|
+
if (
|
|
349
|
+
fk.column.table
|
|
350
|
+
== rel.mapper.persist_selectable
|
|
351
|
+
):
|
|
352
|
+
target_model = rel.mapper.class_
|
|
353
|
+
break
|
|
354
|
+
if target_model is not None:
|
|
355
|
+
break
|
|
356
|
+
|
|
357
|
+
choices: list[tuple[str, str]] = [("", "All")]
|
|
358
|
+
if target_model is not None and session is not None:
|
|
359
|
+
try:
|
|
360
|
+
order_col = getattr(target_model, "name", None) or getattr(
|
|
361
|
+
target_model, "title", None
|
|
362
|
+
)
|
|
363
|
+
if order_col is not None:
|
|
364
|
+
q = select(target_model).order_by(order_col).limit(100)
|
|
365
|
+
else:
|
|
366
|
+
pk = sa_inspect(target_model).primary_key[0]
|
|
367
|
+
q = select(target_model).order_by(pk).limit(100)
|
|
368
|
+
result = await session.execute(q)
|
|
369
|
+
for obj in result.scalars():
|
|
370
|
+
label = str(
|
|
371
|
+
getattr(obj, "name", None)
|
|
372
|
+
or getattr(obj, "title", None)
|
|
373
|
+
or f"#{getattr(obj, 'id', '?')}"
|
|
374
|
+
)
|
|
375
|
+
choices.append((str(obj.id), label))
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
return {"field_type": field_type, "choices": choices}
|
|
379
|
+
|
|
380
|
+
if field_type == "boolean":
|
|
381
|
+
return {
|
|
382
|
+
"field_type": "boolean",
|
|
383
|
+
"choices": [("", "All"), ("1", "Yes"), ("0", "No")],
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if field_type == "enum":
|
|
387
|
+
for prop in mapper.column_attrs:
|
|
388
|
+
if prop.key == field_name:
|
|
389
|
+
col = prop.columns[0] if prop.columns else None
|
|
390
|
+
if col is not None and hasattr(col.type, "enums"):
|
|
391
|
+
choices = [("", "All")]
|
|
392
|
+
for val in col.type.enums:
|
|
393
|
+
choices.append((val, val.replace("_", " ").title()))
|
|
394
|
+
return {"field_type": field_type, "choices": choices}
|
|
395
|
+
|
|
396
|
+
if field_type in ("date", "datetime", "time"):
|
|
397
|
+
return {"field_type": field_type, "choices": [("", "All")]}
|
|
398
|
+
|
|
399
|
+
choices = [("", "All")]
|
|
400
|
+
for prop in mapper.column_attrs:
|
|
401
|
+
if prop.key == field_name:
|
|
402
|
+
col = prop.columns[0] if prop.columns else None
|
|
403
|
+
if col is not None and session is not None:
|
|
404
|
+
try:
|
|
405
|
+
q = (
|
|
406
|
+
select(col)
|
|
407
|
+
.where(col.isnot(None))
|
|
408
|
+
.group_by(col)
|
|
409
|
+
.order_by(col)
|
|
410
|
+
.limit(100)
|
|
411
|
+
)
|
|
412
|
+
result = session.execute(q)
|
|
413
|
+
for (val,) in result:
|
|
414
|
+
label = str(val).replace("_", " ").title()
|
|
415
|
+
choices.append((str(val), label))
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
return {"field_type": "text", "choices": choices}
|
|
419
|
+
|
|
420
|
+
async def get_list(
|
|
421
|
+
self, request: Request, q: str = "", page: int = 1
|
|
422
|
+
) -> tuple[list[Any], int, int, int]:
|
|
423
|
+
"""Execute list query with filtering, search, pagination.
|
|
424
|
+
|
|
425
|
+
Returns (items, total, page, per_page).
|
|
426
|
+
"""
|
|
427
|
+
from sqlalchemy import and_, asc, desc, or_, select
|
|
428
|
+
|
|
429
|
+
session = get_db_session(request)
|
|
430
|
+
registered = self.registered
|
|
431
|
+
model = registered.model
|
|
432
|
+
base = select(model)
|
|
433
|
+
|
|
434
|
+
list_display = registered.admin.list_display or [
|
|
435
|
+
c.name for c in registered.columns if c.name != "id"
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
eager_loads = self._get_eager_loads(model, list_display)
|
|
439
|
+
for opt in eager_loads:
|
|
440
|
+
base = base.options(opt)
|
|
441
|
+
|
|
442
|
+
if registered.admin.list_filter:
|
|
443
|
+
filter_clauses = []
|
|
444
|
+
for filter_field in registered.admin.list_filter:
|
|
445
|
+
param_key = f"filter_{filter_field}"
|
|
446
|
+
filter_value = request.query_params.get(param_key, "")
|
|
447
|
+
if filter_value and hasattr(model, filter_field):
|
|
448
|
+
field_type = self._get_field_type(model, filter_field)
|
|
449
|
+
col = getattr(model, filter_field)
|
|
450
|
+
|
|
451
|
+
if field_type == "boolean":
|
|
452
|
+
bool_val = filter_value == "1"
|
|
453
|
+
filter_clauses.append(col == bool_val)
|
|
454
|
+
elif field_type == "datetime":
|
|
455
|
+
from datetime import datetime as _dt
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
parsed = _dt.fromisoformat(filter_value)
|
|
459
|
+
except (ValueError, TypeError):
|
|
460
|
+
parsed = None
|
|
461
|
+
if parsed is not None:
|
|
462
|
+
filter_clauses.append(col == parsed)
|
|
463
|
+
elif field_type == "date":
|
|
464
|
+
from datetime import date as _date
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
parsed = _date.fromisoformat(filter_value)
|
|
468
|
+
except (ValueError, TypeError):
|
|
469
|
+
parsed = None
|
|
470
|
+
if parsed is not None:
|
|
471
|
+
filter_clauses.append(col == parsed)
|
|
472
|
+
elif field_type == "time":
|
|
473
|
+
from datetime import time as _time
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
parsed = _time.fromisoformat(filter_value)
|
|
477
|
+
except (ValueError, TypeError):
|
|
478
|
+
parsed = None
|
|
479
|
+
if parsed is not None:
|
|
480
|
+
filter_clauses.append(col == parsed)
|
|
481
|
+
else:
|
|
482
|
+
filter_clauses.append(col == filter_value)
|
|
483
|
+
|
|
484
|
+
# Range filters
|
|
485
|
+
for filter_field in registered.admin.list_filter:
|
|
486
|
+
gte_val = request.query_params.get(
|
|
487
|
+
f"filter_{filter_field}__gte", ""
|
|
488
|
+
)
|
|
489
|
+
lte_val = request.query_params.get(
|
|
490
|
+
f"filter_{filter_field}__lte", ""
|
|
491
|
+
)
|
|
492
|
+
from_val = request.query_params.get(
|
|
493
|
+
f"filter_{filter_field}__from", ""
|
|
494
|
+
)
|
|
495
|
+
to_val = request.query_params.get(
|
|
496
|
+
f"filter_{filter_field}__to", ""
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
if (gte_val or lte_val) and hasattr(model, filter_field):
|
|
500
|
+
col = getattr(model, filter_field)
|
|
501
|
+
if gte_val:
|
|
502
|
+
try:
|
|
503
|
+
filter_clauses.append(
|
|
504
|
+
col
|
|
505
|
+
>= type(col.property.columns[0].type)().coerce(
|
|
506
|
+
gte_val
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
except Exception:
|
|
510
|
+
pass
|
|
511
|
+
if lte_val:
|
|
512
|
+
try:
|
|
513
|
+
filter_clauses.append(
|
|
514
|
+
col
|
|
515
|
+
<= type(col.property.columns[0].type)().coerce(
|
|
516
|
+
lte_val
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
if (from_val or to_val) and hasattr(model, filter_field):
|
|
523
|
+
col = getattr(model, filter_field)
|
|
524
|
+
field_type = self._get_field_type(model, filter_field)
|
|
525
|
+
if field_type == "date" and from_val:
|
|
526
|
+
try:
|
|
527
|
+
from datetime import date as _date
|
|
528
|
+
|
|
529
|
+
d = _date.fromisoformat(from_val)
|
|
530
|
+
filter_clauses.append(col >= d)
|
|
531
|
+
except (ValueError, TypeError):
|
|
532
|
+
pass
|
|
533
|
+
if field_type == "date" and to_val:
|
|
534
|
+
try:
|
|
535
|
+
from datetime import date as _date
|
|
536
|
+
|
|
537
|
+
d = _date.fromisoformat(to_val)
|
|
538
|
+
filter_clauses.append(col <= d)
|
|
539
|
+
except (ValueError, TypeError):
|
|
540
|
+
pass
|
|
541
|
+
if field_type == "datetime" and from_val:
|
|
542
|
+
try:
|
|
543
|
+
from datetime import datetime as _dt
|
|
544
|
+
|
|
545
|
+
dt = _dt.fromisoformat(from_val)
|
|
546
|
+
filter_clauses.append(col >= dt)
|
|
547
|
+
except (ValueError, TypeError):
|
|
548
|
+
pass
|
|
549
|
+
if field_type == "datetime" and to_val:
|
|
550
|
+
try:
|
|
551
|
+
from datetime import datetime as _dt
|
|
552
|
+
|
|
553
|
+
dt = _dt.fromisoformat(to_val)
|
|
554
|
+
filter_clauses.append(col <= dt)
|
|
555
|
+
except (ValueError, TypeError):
|
|
556
|
+
pass
|
|
557
|
+
|
|
558
|
+
if filter_clauses:
|
|
559
|
+
base = base.where(and_(*filter_clauses))
|
|
560
|
+
|
|
561
|
+
if q and registered.admin.search_fields:
|
|
562
|
+
clauses = []
|
|
563
|
+
for sf in registered.admin.search_fields:
|
|
564
|
+
if hasattr(model, sf):
|
|
565
|
+
col = getattr(model, sf)
|
|
566
|
+
if hasattr(col, "ilike"):
|
|
567
|
+
clauses.append(col.ilike(f"%{q}%"))
|
|
568
|
+
if clauses:
|
|
569
|
+
base = base.where(or_(*clauses))
|
|
570
|
+
|
|
571
|
+
query_ordering = request.query_params.get("ordering", "")
|
|
572
|
+
if query_ordering:
|
|
573
|
+
order = [query_ordering]
|
|
574
|
+
else:
|
|
575
|
+
order = registered.admin.ordering or []
|
|
576
|
+
if order:
|
|
577
|
+
col_name = order[0].lstrip("-")
|
|
578
|
+
col = (
|
|
579
|
+
getattr(model, col_name, None)
|
|
580
|
+
if hasattr(model, col_name)
|
|
581
|
+
else None
|
|
582
|
+
)
|
|
583
|
+
if col is not None:
|
|
584
|
+
base = base.order_by(
|
|
585
|
+
desc(col) if order[0].startswith("-") else asc(col)
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
per_page = registered.admin.per_page
|
|
589
|
+
|
|
590
|
+
from fastapi_admin_kit.pagination import OffsetPagination, PaginationResult
|
|
591
|
+
|
|
592
|
+
pagination = getattr(registered.admin, "pagination", None) or OffsetPagination()
|
|
593
|
+
pk_col = getattr(model, self.registered.pk_field) if self.registered.pk_field else None
|
|
594
|
+
pagination_result: PaginationResult = await pagination.paginate(
|
|
595
|
+
base,
|
|
596
|
+
session,
|
|
597
|
+
per_page=per_page,
|
|
598
|
+
page=page,
|
|
599
|
+
after=request.query_params.get("after"),
|
|
600
|
+
before=request.query_params.get("before"),
|
|
601
|
+
pk_col=pk_col,
|
|
602
|
+
model=model,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
return (
|
|
606
|
+
pagination_result.items,
|
|
607
|
+
pagination_result.total,
|
|
608
|
+
pagination_result.page or page,
|
|
609
|
+
per_page,
|
|
610
|
+
pagination_result.next_cursor,
|
|
611
|
+
pagination_result.has_next,
|
|
612
|
+
pagination_result.mode,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
async def get_object(self, request: Request, id: Any) -> Any | None:
|
|
616
|
+
"""Return a single object by primary key, eagerly loading M2M relationships."""
|
|
617
|
+
from sqlalchemy import inspect as sa_inspect
|
|
618
|
+
from sqlalchemy.orm import selectinload
|
|
619
|
+
|
|
620
|
+
session = get_db_session(request)
|
|
621
|
+
mapper = sa_inspect(self.registered.model)
|
|
622
|
+
options = []
|
|
623
|
+
for rel in mapper.relationships:
|
|
624
|
+
if rel.direction.name == "MANYTOMANY":
|
|
625
|
+
options.append(selectinload(getattr(self.registered.model, rel.key)))
|
|
626
|
+
int_id = int(id) if id is not None else None
|
|
627
|
+
if options:
|
|
628
|
+
from sqlalchemy import select
|
|
629
|
+
stmt = select(self.registered.model).options(*options).where(
|
|
630
|
+
getattr(self.registered.model, self.registered.pk_field) == int_id
|
|
631
|
+
)
|
|
632
|
+
result = await session.execute(stmt)
|
|
633
|
+
return result.scalar_one_or_none()
|
|
634
|
+
return await session.get(self.registered.model, int_id)
|