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,588 @@
1
+ """ViewContextBuilder — builds template contexts for CRUD views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import Request
8
+ from sqlalchemy import and_, asc, desc, or_, select
9
+ from sqlalchemy.orm import joinedload
10
+
11
+ from fastapi_admin_kit.db import get_db_session
12
+ from fastapi_admin_kit.registry import RegisteredModel
13
+ from fastapi_admin_kit.types import PermissionSet
14
+ from fastapi_admin_kit.views.sidebar import inject_sidebar_context
15
+
16
+
17
+ class DisplayColumn:
18
+ """Helper to render a column in the list view."""
19
+
20
+ def __init__(
21
+ self,
22
+ name: str,
23
+ label: str,
24
+ is_relation: bool = False,
25
+ display_fn: Any = None,
26
+ options: Any = None,
27
+ ):
28
+ self.name = name
29
+ self.label = label
30
+ self.is_relation = is_relation
31
+ self.display_fn = display_fn
32
+ self.options = options
33
+ self.boolean = options.boolean if options else False
34
+ self.css_class = options.css_class if options else ""
35
+ self.width = options.width if options else None
36
+ self.icon = options.icon if options else ""
37
+
38
+ def value(self, obj: Any) -> Any:
39
+ if self.display_fn:
40
+ result = self.display_fn(obj)
41
+ if result is None:
42
+ return self.options.empty_value if self.options else "-"
43
+ if self.options and self.options.format and result is not None:
44
+ try:
45
+ return self.options.format.format(result)
46
+ except (ValueError, IndexError):
47
+ return str(result)
48
+ return result
49
+
50
+ val = getattr(obj, self.name, "")
51
+
52
+ if self.is_relation and val is not None:
53
+ from fastapi_admin_kit.inspection import model_display_name
54
+
55
+ return model_display_name(val)
56
+
57
+ return val
58
+
59
+
60
+ class ViewContextBuilder:
61
+ """Builds template contexts for list, form, and delete views.
62
+
63
+ Centralizes context construction logic that was previously duplicated
64
+ across multiple factory functions.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ registry: Any = None,
70
+ permission_checker: Any = None,
71
+ widget_resolver: Any = None,
72
+ ):
73
+ self.registry = registry
74
+ self.permission_checker = permission_checker
75
+ self.widget_resolver = widget_resolver
76
+
77
+ def _get_eager_loads(self, model: Any, list_display: list[str]) -> list:
78
+ """Build eager load options for relationship columns."""
79
+ from sqlalchemy import inspect as sa_inspect
80
+
81
+ mapper = sa_inspect(model)
82
+ rel_names = {r.key for r in mapper.relationships}
83
+ options = []
84
+ for col_name in list_display:
85
+ if col_name in rel_names:
86
+ options.append(joinedload(getattr(model, col_name)))
87
+ return options
88
+
89
+ def _get_field_type(self, model: Any, field_name: str) -> str:
90
+ """Detect the abstract field type for a model field."""
91
+ from sqlalchemy import inspect as sa_inspect
92
+
93
+ mapper = sa_inspect(model)
94
+ rel_names = {r.key for r in mapper.relationships}
95
+
96
+ if field_name in rel_names:
97
+ return "relation"
98
+
99
+ for prop in mapper.column_attrs:
100
+ if prop.key == field_name:
101
+ col = prop.columns[0] if prop.columns else None
102
+ if col is None:
103
+ break
104
+ type_name = col.type.__class__.__name__
105
+ if type_name == "Boolean":
106
+ return "boolean"
107
+ if type_name == "DateTime":
108
+ return "datetime"
109
+ if type_name == "Date":
110
+ return "date"
111
+ if type_name == "Time":
112
+ return "time"
113
+ if hasattr(col.type, "enums") and col.type.enums:
114
+ return "enum"
115
+ if col.foreign_keys:
116
+ return "relation"
117
+ return "text"
118
+ return "text"
119
+
120
+ async def _get_filter_choices(
121
+ self, model: Any, field_name: str, session: Any = None
122
+ ) -> dict[str, Any]:
123
+ """Get filter field type and available choices for a field."""
124
+ from sqlalchemy import inspect as sa_inspect
125
+ from sqlalchemy import select as sa_select
126
+
127
+ mapper = sa_inspect(model)
128
+ field_type = self._get_field_type(model, field_name)
129
+
130
+ if field_type == "relation":
131
+ rel_map = {r.key: r for r in mapper.relationships}
132
+ target_model = None
133
+ if field_name in rel_map:
134
+ target_model = rel_map[field_name].mapper.class_
135
+ else:
136
+ for rel in mapper.relationships:
137
+ if rel.direction.name == "MANYTOONE":
138
+ for prop in mapper.column_attrs:
139
+ if prop.key == field_name:
140
+ col = prop.columns[0] if prop.columns else None
141
+ if col is not None:
142
+ for fk in col.foreign_keys:
143
+ if (
144
+ fk.column.table
145
+ == rel.mapper.persist_selectable
146
+ ):
147
+ target_model = rel.mapper.class_
148
+ break
149
+ if target_model is not None:
150
+ break
151
+
152
+ choices: list[tuple[str, str]] = [("", "All")]
153
+ if target_model is not None and session is not None:
154
+ try:
155
+ order_col = getattr(target_model, "name", None) or getattr(
156
+ target_model, "title", None
157
+ )
158
+ if order_col is not None:
159
+ q = (
160
+ sa_select(target_model)
161
+ .order_by(order_col)
162
+ .limit(100)
163
+ )
164
+ else:
165
+ pk = sa_inspect(target_model).primary_key[0]
166
+ q = sa_select(target_model).order_by(pk).limit(100)
167
+ result = await session.execute(q)
168
+ for obj in result.scalars():
169
+ label = str(
170
+ getattr(obj, "name", None)
171
+ or getattr(obj, "title", None)
172
+ or f"#{getattr(obj, 'id', '?')}"
173
+ )
174
+ choices.append((str(obj.id), label))
175
+ except Exception:
176
+ pass
177
+ return {"field_type": field_type, "choices": choices}
178
+
179
+ if field_type == "boolean":
180
+ return {
181
+ "field_type": "boolean",
182
+ "choices": [("", "All"), ("1", "Yes"), ("0", "No")],
183
+ }
184
+
185
+ if field_type == "enum":
186
+ for prop in mapper.column_attrs:
187
+ if prop.key == field_name:
188
+ col = prop.columns[0] if prop.columns else None
189
+ if col is not None and hasattr(col.type, "enums"):
190
+ choices = [("", "All")]
191
+ for val in col.type.enums:
192
+ choices.append((val, val.replace("_", " ").title()))
193
+ return {"field_type": "enum", "choices": choices}
194
+
195
+ if field_type in ("date", "datetime", "time"):
196
+ return {"field_type": field_type, "choices": [("", "All")]}
197
+
198
+ choices = [("", "All")]
199
+ for prop in mapper.column_attrs:
200
+ if prop.key == field_name:
201
+ col = prop.columns[0] if prop.columns else None
202
+ if col is not None and session is not None:
203
+ try:
204
+ q = (
205
+ select(col)
206
+ .where(col.isnot(None))
207
+ .group_by(col)
208
+ .order_by(col)
209
+ .limit(100)
210
+ )
211
+ result = session.execute(q)
212
+ for (val,) in result:
213
+ label = str(val).replace("_", " ").title()
214
+ choices.append((str(val), label))
215
+ except Exception:
216
+ pass
217
+ return {"field_type": "text", "choices": choices}
218
+
219
+ async def build_list_context(
220
+ self,
221
+ registered: RegisteredModel,
222
+ request: Request,
223
+ q: str = "",
224
+ page: int = 1,
225
+ permission_checker: Any = None,
226
+ ) -> dict[str, Any]:
227
+ """Build the template context for a list view.
228
+
229
+ Returns a dict suitable for passing to TemplateResponse.
230
+ """
231
+ session = get_db_session(request)
232
+ model = registered.model
233
+ base = select(model)
234
+
235
+ list_display = registered.admin.list_display or [
236
+ c.name for c in registered.columns if c.name != "id"
237
+ ]
238
+
239
+ eager_loads = self._get_eager_loads(model, list_display)
240
+ for opt in eager_loads:
241
+ base = base.options(opt)
242
+
243
+ active_filters: dict[str, str] = {}
244
+ if registered.admin.list_filter:
245
+ filter_clauses = []
246
+ for filter_field in registered.admin.list_filter:
247
+ param_key = f"filter_{filter_field}"
248
+ filter_value = request.query_params.get(param_key, "")
249
+ if filter_value and hasattr(model, filter_field):
250
+ field_type = self._get_field_type(model, filter_field)
251
+ col = getattr(model, filter_field)
252
+
253
+ if field_type == "boolean":
254
+ bool_val = filter_value == "1"
255
+ filter_clauses.append(col == bool_val)
256
+ elif field_type == "datetime":
257
+ from datetime import datetime as _dt
258
+
259
+ try:
260
+ parsed = _dt.fromisoformat(filter_value)
261
+ except (ValueError, TypeError):
262
+ parsed = None
263
+ if parsed is not None:
264
+ filter_clauses.append(col == parsed)
265
+ elif field_type == "date":
266
+ from datetime import date as _date
267
+
268
+ try:
269
+ parsed = _date.fromisoformat(filter_value)
270
+ except (ValueError, TypeError):
271
+ parsed = None
272
+ if parsed is not None:
273
+ filter_clauses.append(col == parsed)
274
+ elif field_type == "time":
275
+ from datetime import time as _time
276
+
277
+ try:
278
+ parsed = _time.fromisoformat(filter_value)
279
+ except (ValueError, TypeError):
280
+ parsed = None
281
+ if parsed is not None:
282
+ filter_clauses.append(col == parsed)
283
+ else:
284
+ filter_clauses.append(col == filter_value)
285
+
286
+ if filter_value:
287
+ active_filters[filter_field] = filter_value
288
+
289
+ # Support range filters: filter_field__gte, filter_field__lte, etc.
290
+ for filter_field in registered.admin.list_filter:
291
+ gte_val = request.query_params.get(
292
+ f"filter_{filter_field}__gte", ""
293
+ )
294
+ lte_val = request.query_params.get(
295
+ f"filter_{filter_field}__lte", ""
296
+ )
297
+ from_val = request.query_params.get(
298
+ f"filter_{filter_field}__from", ""
299
+ )
300
+ to_val = request.query_params.get(
301
+ f"filter_{filter_field}__to", ""
302
+ )
303
+
304
+ if (gte_val or lte_val) and hasattr(model, filter_field):
305
+ col = getattr(model, filter_field)
306
+ if gte_val:
307
+ try:
308
+ col_type = type(col.property.columns[0].type)
309
+ filter_clauses.append(
310
+ col >= col_type().coerce(gte_val)
311
+ )
312
+ except Exception:
313
+ pass
314
+ if lte_val:
315
+ try:
316
+ col_type = type(col.property.columns[0].type)
317
+ filter_clauses.append(
318
+ col <= col_type().coerce(lte_val)
319
+ )
320
+ except Exception:
321
+ pass
322
+
323
+ if (from_val or to_val) and hasattr(model, filter_field):
324
+ col = getattr(model, filter_field)
325
+ field_type = self._get_field_type(model, filter_field)
326
+ if field_type == "date" and from_val:
327
+ try:
328
+ from datetime import date as _date
329
+
330
+ d = _date.fromisoformat(from_val)
331
+ filter_clauses.append(col >= d)
332
+ except (ValueError, TypeError):
333
+ pass
334
+ if field_type == "date" and to_val:
335
+ try:
336
+ from datetime import date as _date
337
+
338
+ d = _date.fromisoformat(to_val)
339
+ filter_clauses.append(col <= d)
340
+ except (ValueError, TypeError):
341
+ pass
342
+ if field_type == "datetime" and from_val:
343
+ try:
344
+ from datetime import datetime as _dt
345
+
346
+ dt = _dt.fromisoformat(from_val)
347
+ filter_clauses.append(col >= dt)
348
+ except (ValueError, TypeError):
349
+ pass
350
+ if field_type == "datetime" and to_val:
351
+ try:
352
+ from datetime import datetime as _dt
353
+
354
+ dt = _dt.fromisoformat(to_val)
355
+ filter_clauses.append(col <= dt)
356
+ except (ValueError, TypeError):
357
+ pass
358
+
359
+ if gte_val:
360
+ active_filters[f"{filter_field}__gte"] = gte_val
361
+ if lte_val:
362
+ active_filters[f"{filter_field}__lte"] = lte_val
363
+ if from_val:
364
+ active_filters[f"{filter_field}__from"] = from_val
365
+ if to_val:
366
+ active_filters[f"{filter_field}__to"] = to_val
367
+
368
+ if filter_clauses:
369
+ base = base.where(and_(*filter_clauses))
370
+
371
+ if q and registered.admin.search_fields:
372
+ clauses = []
373
+ for sf in registered.admin.search_fields:
374
+ if hasattr(model, sf):
375
+ col = getattr(model, sf)
376
+ if hasattr(col, "ilike"):
377
+ clauses.append(col.ilike(f"%{q}%"))
378
+ if clauses:
379
+ base = base.where(or_(*clauses))
380
+
381
+ query_ordering = request.query_params.get("ordering", "")
382
+ if query_ordering:
383
+ order = [query_ordering]
384
+ else:
385
+ order = registered.admin.ordering or []
386
+ if order:
387
+ col_name = order[0].lstrip("-")
388
+ col = (
389
+ getattr(model, col_name, None)
390
+ if hasattr(model, col_name)
391
+ else None
392
+ )
393
+ if col is not None:
394
+ base = base.order_by(
395
+ desc(col) if order[0].startswith("-") else asc(col)
396
+ )
397
+
398
+ per_page = registered.admin.per_page
399
+
400
+ from fastapi_admin_kit.pagination import OffsetPagination, PaginationResult
401
+
402
+ pagination = getattr(registered.admin, "pagination", None) or OffsetPagination()
403
+ pk_col = getattr(model, registered.pk_field) if registered.pk_field else None
404
+ pagination_result: PaginationResult = await pagination.paginate(
405
+ base,
406
+ session,
407
+ per_page=per_page,
408
+ page=page,
409
+ after=request.query_params.get("after"),
410
+ before=request.query_params.get("before"),
411
+ pk_col=pk_col,
412
+ model=model,
413
+ )
414
+ items = pagination_result.items
415
+ total = pagination_result.total
416
+ page = pagination_result.page or 1
417
+ total_pages = pagination_result.total_pages or 1
418
+
419
+ from sqlalchemy import inspect as sa_inspect
420
+
421
+ mapper = sa_inspect(model)
422
+ rel_names = {r.key for r in mapper.relationships}
423
+
424
+ # Collect @column decorated methods (check _column_options attribute)
425
+ decorated_columns: dict[str, Any] = {}
426
+ for col_name in list_display:
427
+ method = getattr(registered.admin, col_name, None)
428
+ if method and hasattr(method, "_column_options"):
429
+ decorated_columns[col_name] = method._column_options
430
+
431
+ display_columns = []
432
+ for col_name in list_display:
433
+ label = col_name.replace("_", " ").title()
434
+ display_fn = None
435
+ options = None
436
+
437
+ # Check @column decorator first
438
+ if col_name in decorated_columns:
439
+ options = decorated_columns[col_name]
440
+ display_fn = getattr(registered.admin, col_name)
441
+ label = options.header or label
442
+ # Check display_functions dict fallback
443
+ elif (
444
+ registered.admin.display_functions
445
+ and col_name in registered.admin.display_functions
446
+ ):
447
+ display_fn = registered.admin.display_functions[col_name]
448
+
449
+ display_columns.append(
450
+ DisplayColumn(col_name, label, col_name in rel_names, display_fn, options)
451
+ )
452
+
453
+ filter_fields: dict[str, dict[str, Any]] = {}
454
+ if registered.admin.list_filter:
455
+ for filter_field in registered.admin.list_filter:
456
+ filter_fields[filter_field] = await self._get_filter_choices(
457
+ model, filter_field, session
458
+ )
459
+
460
+ ordering = request.query_params.get("ordering", "")
461
+ if not ordering and registered.admin.ordering:
462
+ ordering = registered.admin.ordering[0]
463
+
464
+ template_context = {
465
+ "model": registered,
466
+ "registered": registered,
467
+ "display_columns": display_columns,
468
+ "items": items,
469
+ "search_query": q,
470
+ "page": page,
471
+ "total_pages": total_pages,
472
+ "total": total,
473
+ "per_page": per_page,
474
+ "next_cursor": pagination_result.next_cursor,
475
+ "has_next": pagination_result.has_next,
476
+ "pagination_mode": pagination_result.mode,
477
+ "filter_fields": filter_fields,
478
+ "active_filters": active_filters,
479
+ "ordering": ordering,
480
+ "permissions": permission_checker.permission_set(
481
+ registered.table_name
482
+ )
483
+ if permission_checker
484
+ else PermissionSet(
485
+ can_view=True, can_create=True, can_edit=True, can_delete=True
486
+ ),
487
+ "list_actions": registered.admin.get_list_actions(),
488
+ "row_actions": registered.admin.get_row_actions(),
489
+ "list_tabs": getattr(registered.admin, "list_tabs", []),
490
+ "list_sections": getattr(registered.admin, "list_sections", []),
491
+ "ordering_field": getattr(registered.admin, "ordering_field", None),
492
+ "hide_ordering_field": getattr(
493
+ registered.admin, "hide_ordering_field", False
494
+ ),
495
+ "list_filter_options": getattr(
496
+ registered.admin, "list_filter_options", {}
497
+ ),
498
+ "list_filter_horizontal": getattr(
499
+ registered.admin, "list_filter_horizontal", False
500
+ ),
501
+ }
502
+ await inject_sidebar_context(request, template_context)
503
+ return template_context
504
+
505
+ async def build_form_context(
506
+ self,
507
+ registered: RegisteredModel,
508
+ request: Request,
509
+ obj: Any | None = None,
510
+ values: dict[str, Any] | None = None,
511
+ errors: dict[str, list[str]] | None = None,
512
+ is_create: bool = False,
513
+ permission_checker: Any = None,
514
+ rel_labels: dict[str, str] | None = None,
515
+ ) -> dict[str, Any]:
516
+ """Build the template context for a form view.
517
+
518
+ Returns a dict suitable for passing to TemplateResponse.
519
+ """
520
+ from fastapi_admin_kit.form.pipeline import (
521
+ build_form_context as _build_form_ctx,
522
+ )
523
+
524
+ ctx = _build_form_ctx(
525
+ registered,
526
+ obj=obj,
527
+ values=values,
528
+ errors=errors,
529
+ request=request,
530
+ is_create=is_create,
531
+ rel_labels=rel_labels,
532
+ )
533
+ template_context = {
534
+ "form_context": ctx,
535
+ "registered": registered,
536
+ "obj": ctx.obj,
537
+ "form_fields": ctx.fieldsets[0].fields if ctx.fieldsets else [],
538
+ "fieldsets": ctx.fieldsets,
539
+ "errors": ctx.errors,
540
+ "is_create": is_create,
541
+ "permissions": permission_checker.permission_set(
542
+ registered.table_name
543
+ )
544
+ if permission_checker
545
+ else PermissionSet(
546
+ can_view=True, can_create=True, can_edit=True, can_delete=True
547
+ ),
548
+ "detail_actions": registered.admin.get_detail_actions(),
549
+ "submit_line_actions": registered.admin.get_submit_line_actions(),
550
+ "conditional_fields": getattr(
551
+ registered.admin, "conditional_fields", {}
552
+ ),
553
+ "warn_unsaved_form": getattr(
554
+ registered.admin, "warn_unsaved_form", True
555
+ ),
556
+ "compressed_fields": getattr(
557
+ registered.admin, "compressed_fields", True
558
+ ),
559
+ "change_form_show_cancel_button": getattr(
560
+ registered.admin, "change_form_show_cancel_button", True
561
+ ),
562
+ }
563
+ await inject_sidebar_context(request, template_context)
564
+ return template_context
565
+
566
+ async def build_delete_context(
567
+ self,
568
+ registered: RegisteredModel,
569
+ request: Request,
570
+ permission_checker: Any = None,
571
+ ) -> dict[str, Any]:
572
+ """Build the template context for a delete confirmation view.
573
+
574
+ Returns a dict suitable for passing to TemplateResponse.
575
+ """
576
+ template_context = {
577
+ "model": registered,
578
+ "registered": registered,
579
+ "permissions": permission_checker.permission_set(
580
+ registered.table_name
581
+ )
582
+ if permission_checker
583
+ else PermissionSet(
584
+ can_view=True, can_create=True, can_edit=True, can_delete=True
585
+ ),
586
+ }
587
+ await inject_sidebar_context(request, template_context)
588
+ return template_context