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,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)