fastapi-admin-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. fastapi_admin_kit/__init__.py +73 -0
  2. fastapi_admin_kit/actions/__init__.py +63 -0
  3. fastapi_admin_kit/actions/base.py +68 -0
  4. fastapi_admin_kit/actions/registry.py +43 -0
  5. fastapi_admin_kit/admin/__init__.py +17 -0
  6. fastapi_admin_kit/admin/admin_config.py +95 -0
  7. fastapi_admin_kit/admin/admin_database.py +138 -0
  8. fastapi_admin_kit/admin/admin_router.py +74 -0
  9. fastapi_admin_kit/admin/admin_template.py +203 -0
  10. fastapi_admin_kit/admin/builtin_models.py +284 -0
  11. fastapi_admin_kit/admin/core.py +1036 -0
  12. fastapi_admin_kit/admin/decorators.py +70 -0
  13. fastapi_admin_kit/admin/state.py +76 -0
  14. fastapi_admin_kit/admin.py +728 -0
  15. fastapi_admin_kit/api/__init__.py +44 -0
  16. fastapi_admin_kit/api/auth.py +342 -0
  17. fastapi_admin_kit/api/crud.py +128 -0
  18. fastapi_admin_kit/api/deps.py +79 -0
  19. fastapi_admin_kit/api/roles.py +128 -0
  20. fastapi_admin_kit/api/schema_generator.py +171 -0
  21. fastapi_admin_kit/api/schemas.py +81 -0
  22. fastapi_admin_kit/api/search.py +132 -0
  23. fastapi_admin_kit/audit/__init__.py +36 -0
  24. fastapi_admin_kit/audit/context.py +62 -0
  25. fastapi_admin_kit/audit/diff.py +77 -0
  26. fastapi_admin_kit/audit/event_bus.py +96 -0
  27. fastapi_admin_kit/audit/events.py +48 -0
  28. fastapi_admin_kit/audit/listener.py +159 -0
  29. fastapi_admin_kit/audit/logger.py +28 -0
  30. fastapi_admin_kit/audit/middleware.py +39 -0
  31. fastapi_admin_kit/audit/models.py +53 -0
  32. fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
  33. fastapi_admin_kit/auth/__init__.py +34 -0
  34. fastapi_admin_kit/auth/backend.py +95 -0
  35. fastapi_admin_kit/auth/csrf.py +240 -0
  36. fastapi_admin_kit/auth/dependencies.py +150 -0
  37. fastapi_admin_kit/auth/identity.py +181 -0
  38. fastapi_admin_kit/auth/models.py +246 -0
  39. fastapi_admin_kit/auth/password.py +35 -0
  40. fastapi_admin_kit/auth/permissions.py +205 -0
  41. fastapi_admin_kit/auth/protocol.py +22 -0
  42. fastapi_admin_kit/auth/ratelimit.py +88 -0
  43. fastapi_admin_kit/auth/router.py +10 -0
  44. fastapi_admin_kit/auth/session.py +79 -0
  45. fastapi_admin_kit/auth/totp.py +83 -0
  46. fastapi_admin_kit/auth/views.py +165 -0
  47. fastapi_admin_kit/cli.py +229 -0
  48. fastapi_admin_kit/config/__init__.py +19 -0
  49. fastapi_admin_kit/config/audit.py +18 -0
  50. fastapi_admin_kit/config/auth.py +54 -0
  51. fastapi_admin_kit/config/behavior.py +27 -0
  52. fastapi_admin_kit/config/nav.py +32 -0
  53. fastapi_admin_kit/config/storage.py +22 -0
  54. fastapi_admin_kit/config/theme.py +215 -0
  55. fastapi_admin_kit/config/ui.py +147 -0
  56. fastapi_admin_kit/dashboard/__init__.py +64 -0
  57. fastapi_admin_kit/db.py +133 -0
  58. fastapi_admin_kit/exceptions.py +5 -0
  59. fastapi_admin_kit/field_types.py +81 -0
  60. fastapi_admin_kit/filters/__init__.py +21 -0
  61. fastapi_admin_kit/filters/base.py +170 -0
  62. fastapi_admin_kit/filters/registry.py +68 -0
  63. fastapi_admin_kit/flash.py +45 -0
  64. fastapi_admin_kit/form/__init__.py +1 -0
  65. fastapi_admin_kit/form/pipeline.py +106 -0
  66. fastapi_admin_kit/inspection/__init__.py +117 -0
  67. fastapi_admin_kit/inspection/registry.py +253 -0
  68. fastapi_admin_kit/inspection.py +115 -0
  69. fastapi_admin_kit/modeladmin.py +375 -0
  70. fastapi_admin_kit/models/__init__.py +7 -0
  71. fastapi_admin_kit/models/base.py +7 -0
  72. fastapi_admin_kit/nav.py +208 -0
  73. fastapi_admin_kit/pagination/__init__.py +14 -0
  74. fastapi_admin_kit/pagination/base.py +40 -0
  75. fastapi_admin_kit/pagination/cursor.py +97 -0
  76. fastapi_admin_kit/pagination/dynamic.py +48 -0
  77. fastapi_admin_kit/pagination/offset.py +42 -0
  78. fastapi_admin_kit/plugins/__init__.py +1 -0
  79. fastapi_admin_kit/py.typed +0 -0
  80. fastapi_admin_kit/registry/__init__.py +5 -0
  81. fastapi_admin_kit/registry/core.py +287 -0
  82. fastapi_admin_kit/registry/validation.py +107 -0
  83. fastapi_admin_kit/registry.py +15 -0
  84. fastapi_admin_kit/router.py +335 -0
  85. fastapi_admin_kit/static/css/admin.css +4736 -0
  86. fastapi_admin_kit/static/css/presets.css +317 -0
  87. fastapi_admin_kit/static/css/tokens.css +217 -0
  88. fastapi_admin_kit/static/css/variables.css +74 -0
  89. fastapi_admin_kit/static/icons/heroicons.svg +160 -0
  90. fastapi_admin_kit/static/js/admin.js +692 -0
  91. fastapi_admin_kit/static/js/htmx-config.js +42 -0
  92. fastapi_admin_kit/storage/__init__.py +6 -0
  93. fastapi_admin_kit/storage/base.py +48 -0
  94. fastapi_admin_kit/storage/local.py +73 -0
  95. fastapi_admin_kit/templates/base.html +142 -0
  96. fastapi_admin_kit/templates/macros/form_fields.html +660 -0
  97. fastapi_admin_kit/templates/macros/icons.html +50 -0
  98. fastapi_admin_kit/templates/macros/table.html +108 -0
  99. fastapi_admin_kit/templates/macros/widgets.html +159 -0
  100. fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
  101. fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
  102. fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
  103. fastapi_admin_kit/templates/pages/audit_log.html +102 -0
  104. fastapi_admin_kit/templates/pages/dashboard.html +295 -0
  105. fastapi_admin_kit/templates/pages/detail.html +183 -0
  106. fastapi_admin_kit/templates/pages/form.html +119 -0
  107. fastapi_admin_kit/templates/pages/list.html +277 -0
  108. fastapi_admin_kit/templates/pages/login.html +85 -0
  109. fastapi_admin_kit/templates/pages/profile/password.html +78 -0
  110. fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
  111. fastapi_admin_kit/templates/pages/role_form.html +75 -0
  112. fastapi_admin_kit/templates/pages/roles/form.html +117 -0
  113. fastapi_admin_kit/templates/pages/roles/list.html +69 -0
  114. fastapi_admin_kit/templates/pages/roles.html +77 -0
  115. fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
  116. fastapi_admin_kit/templates/pages/users/form.html +229 -0
  117. fastapi_admin_kit/templates/pages/users/list.html +83 -0
  118. fastapi_admin_kit/templates/partials/command_palette.html +52 -0
  119. fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
  120. fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
  121. fastapi_admin_kit/templates/partials/head.html +21 -0
  122. fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
  123. fastapi_admin_kit/templates/partials/list_table.html +178 -0
  124. fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
  125. fastapi_admin_kit/templates/partials/pagination.html +82 -0
  126. fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
  127. fastapi_admin_kit/templates/partials/scripts.html +13 -0
  128. fastapi_admin_kit/templates/partials/sidebar.html +94 -0
  129. fastapi_admin_kit/templates/partials/topbar.html +95 -0
  130. fastapi_admin_kit/types.py +145 -0
  131. fastapi_admin_kit/validation.py +43 -0
  132. fastapi_admin_kit/views/__init__.py +78 -0
  133. fastapi_admin_kit/views/audit.py +134 -0
  134. fastapi_admin_kit/views/bulk.py +28 -0
  135. fastapi_admin_kit/views/class_views.py +1040 -0
  136. fastapi_admin_kit/views/context.py +588 -0
  137. fastapi_admin_kit/views/dashboard.py +162 -0
  138. fastapi_admin_kit/views/delete.py +31 -0
  139. fastapi_admin_kit/views/extra.py +65 -0
  140. fastapi_admin_kit/views/factory.py +667 -0
  141. fastapi_admin_kit/views/form.py +159 -0
  142. fastapi_admin_kit/views/list.py +28 -0
  143. fastapi_admin_kit/views/profile.py +219 -0
  144. fastapi_admin_kit/views/protocols.py +54 -0
  145. fastapi_admin_kit/views/renderers.py +634 -0
  146. fastapi_admin_kit/views/roles.py +230 -0
  147. fastapi_admin_kit/views/search.py +31 -0
  148. fastapi_admin_kit/views/settings.py +31 -0
  149. fastapi_admin_kit/views/sidebar.py +101 -0
  150. fastapi_admin_kit/views/totp.py +249 -0
  151. fastapi_admin_kit/views/users.py +347 -0
  152. fastapi_admin_kit/views.py +117 -0
  153. fastapi_admin_kit/widgets/__init__.py +44 -0
  154. fastapi_admin_kit/widgets/base.py +44 -0
  155. fastapi_admin_kit/widgets/inputs.py +363 -0
  156. fastapi_admin_kit/widgets/registry.py +110 -0
  157. fastapi_admin_kit/widgets/relation.py +70 -0
  158. fastapi_admin_kit/widgets/resolver.py +102 -0
  159. fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
  160. fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
  161. fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
  162. fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
  163. fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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)