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,363 @@
1
+ """Built-in widget classes — all standard form widgets per spec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import date, datetime
7
+ from typing import Any
8
+
9
+ from fastapi_admin_kit.types import FieldMeta
10
+ from fastapi_admin_kit.widgets.base import Widget
11
+
12
+
13
+ class TextInputWidget(Widget):
14
+ macro_name = "text_input"
15
+
16
+ def __init__(self, maxlength: int | None = None):
17
+ self.maxlength = maxlength
18
+
19
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
20
+ ctx = super().render_context(field, value)
21
+ ctx["maxlength"] = self.maxlength
22
+ return ctx
23
+
24
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
25
+ errors = super().validate(value, field)
26
+ if value and self.maxlength and len(value) > self.maxlength:
27
+ errors.append(f"{field.label} must be {self.maxlength} characters or fewer.")
28
+ return errors
29
+
30
+
31
+ class TextareaWidget(Widget):
32
+ macro_name = "textarea"
33
+
34
+ def __init__(self, rows: int = 5):
35
+ self.rows = rows
36
+
37
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
38
+ ctx = super().render_context(field, value)
39
+ ctx["rows"] = self.rows
40
+ return ctx
41
+
42
+
43
+ class NumberInputWidget(Widget):
44
+ macro_name = "number_input"
45
+
46
+ def __init__(self, step: str = "1", min: str | None = None, max: str | None = None):
47
+ self.step = step
48
+ self.min = min
49
+ self.max = max
50
+
51
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
52
+ ctx = super().render_context(field, value)
53
+ ctx.update({"step": self.step, "min": self.min, "max": self.max})
54
+ return ctx
55
+
56
+ def parse(self, raw: str | None) -> int | float | None:
57
+ if raw is None or raw == "":
58
+ return None
59
+ try:
60
+ return int(raw) if "." not in str(raw) else float(raw)
61
+ except ValueError:
62
+ return raw
63
+
64
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
65
+ errors = super().validate(value, field)
66
+ if value is not None:
67
+ try:
68
+ float(value)
69
+ except (TypeError, ValueError):
70
+ errors.append(f"{field.label} must be a number.")
71
+ return errors
72
+
73
+
74
+ class ToggleWidget(Widget):
75
+ macro_name = "toggle"
76
+
77
+ def parse(self, raw: str | None) -> bool:
78
+ if raw is None:
79
+ return False
80
+ if isinstance(raw, bool):
81
+ return raw
82
+ return str(raw).lower() in ("on", "true", "1", "yes")
83
+
84
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
85
+ return []
86
+
87
+
88
+ class SelectWidget(Widget):
89
+ macro_name = "select"
90
+
91
+ def __init__(self, choices: list[tuple[str, str]] | None = None):
92
+ self.choices = choices or []
93
+
94
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
95
+ ctx = super().render_context(field, value)
96
+ ctx["choices"] = self.choices
97
+ return ctx
98
+
99
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
100
+ errors = super().validate(value, field)
101
+ if value and self.choices:
102
+ valid = {c[0] for c in self.choices}
103
+ if value not in valid:
104
+ errors.append(f"'{value}' is not a valid choice for {field.label}.")
105
+ return errors
106
+
107
+
108
+ class DatePickerWidget(Widget):
109
+ macro_name = "date_picker"
110
+
111
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
112
+ ctx = super().render_context(field, value)
113
+ if isinstance(value, date) and not isinstance(value, datetime):
114
+ ctx["value"] = value.isoformat()
115
+ elif isinstance(value, datetime):
116
+ ctx["value"] = value.date().isoformat()
117
+ return ctx
118
+
119
+ def parse(self, raw: str | None) -> date | str | None:
120
+ if not raw:
121
+ return None
122
+ try:
123
+ return date.fromisoformat(raw)
124
+ except ValueError:
125
+ return raw
126
+
127
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
128
+ errors = super().validate(value, field)
129
+ if value is not None and not isinstance(value, date):
130
+ errors.append(f"{field.label} must be a valid date.")
131
+ return errors
132
+
133
+
134
+ class DateTimePickerWidget(Widget):
135
+ macro_name = "datetime_picker"
136
+
137
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
138
+ ctx = super().render_context(field, value)
139
+ if isinstance(value, datetime):
140
+ ctx["value"] = value.replace(tzinfo=None).isoformat(timespec="minutes")
141
+ elif isinstance(value, date):
142
+ combined = datetime.combine(value, datetime.min.time())
143
+ ctx["value"] = combined.isoformat(timespec="minutes")
144
+ return ctx
145
+
146
+ def parse(self, raw: str | None) -> datetime | str | None:
147
+ if not raw:
148
+ return None
149
+ try:
150
+ return datetime.fromisoformat(raw)
151
+ except ValueError:
152
+ return raw
153
+
154
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
155
+ errors = super().validate(value, field)
156
+ if value is not None and not isinstance(value, datetime):
157
+ errors.append(f"{field.label} must be a valid date and time.")
158
+ return errors
159
+
160
+
161
+ class JsonEditorWidget(Widget):
162
+ macro_name = "json_editor"
163
+
164
+ def parse(self, raw: str | None) -> Any:
165
+ if not raw:
166
+ return None
167
+ if isinstance(raw, str):
168
+ try:
169
+ return json.loads(raw)
170
+ except (json.JSONDecodeError, TypeError):
171
+ return None
172
+ return raw
173
+
174
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
175
+ errors = super().validate(value, field)
176
+ if isinstance(value, str):
177
+ try:
178
+ json.loads(value)
179
+ except json.JSONDecodeError as e:
180
+ errors.append(f"{field.label} contains invalid JSON: {e}")
181
+ return errors
182
+
183
+
184
+ class AutocompleteWidget(Widget):
185
+ """Text input with datalist autocomplete suggestions."""
186
+
187
+ macro_name = "autocomplete"
188
+
189
+ def __init__(
190
+ self,
191
+ suggestions: list[str] | None = None,
192
+ suggestions_fn: Any = None,
193
+ ):
194
+ self.suggestions = suggestions
195
+ self.suggestions_fn = suggestions_fn
196
+
197
+ def _get_suggestions(self) -> list[str]:
198
+ if self.suggestions_fn is not None:
199
+ return self.suggestions_fn()
200
+ return self.suggestions or []
201
+
202
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
203
+ ctx = super().render_context(field, value)
204
+ ctx["suggestions"] = self._get_suggestions()
205
+ return ctx
206
+
207
+
208
+ class PasswordWidget(Widget):
209
+ macro_name = "password_input"
210
+
211
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
212
+ ctx = super().render_context(field, value)
213
+ ctx["value"] = "" # NEVER pre-fill passwords
214
+ return ctx
215
+
216
+ def parse(self, raw: str | None) -> str | None:
217
+ if not raw:
218
+ return None
219
+ return raw
220
+
221
+
222
+ class ReadOnlyWidget(Widget):
223
+ macro_name = "readonly"
224
+
225
+ def parse(self, raw: str | None) -> None:
226
+ return None
227
+
228
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
229
+ return []
230
+
231
+
232
+ class HiddenWidget(Widget):
233
+ macro_name = "hidden"
234
+
235
+
236
+ class FileUploadWidget(Widget):
237
+ """File upload widget — stores file via StorageBackend, saves path string."""
238
+
239
+ macro_name = "file_upload"
240
+
241
+ def __init__(
242
+ self,
243
+ max_size_mb: float | None = None,
244
+ accept: str | None = None,
245
+ ) -> None:
246
+ self.max_size_mb = max_size_mb
247
+ self.accept = accept # e.g. ".pdf,.docx" or "application/pdf"
248
+
249
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
250
+ ctx = super().render_context(field, value)
251
+ ctx["max_size_mb"] = self.max_size_mb
252
+ ctx["accept"] = self.accept
253
+ ctx["current_file"] = value if value else ""
254
+ return ctx
255
+
256
+ def parse(self, raw: Any) -> Any:
257
+ """Raw form data — the actual UploadFile handling happens in the
258
+ form submit factory because ``UploadFile`` objects need async read."""
259
+ if raw is None or raw == "":
260
+ return None
261
+ return raw
262
+
263
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
264
+ errors = super().validate(value, field)
265
+ # Size validation happens at save time in the form submit factory
266
+ # because reading the file requires async I/O.
267
+ return errors
268
+
269
+
270
+ class ImageUploadWidget(Widget):
271
+ """Image upload widget — like FileUploadWidget but restricted to images."""
272
+
273
+ macro_name = "image_upload"
274
+
275
+ def __init__(
276
+ self,
277
+ max_size_mb: float | None = None,
278
+ accept: str = "image/*",
279
+ ) -> None:
280
+ self.max_size_mb = max_size_mb
281
+ self.accept = accept
282
+
283
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
284
+ ctx = super().render_context(field, value)
285
+ ctx["max_size_mb"] = self.max_size_mb
286
+ ctx["accept"] = self.accept
287
+ ctx["current_file"] = value if value else ""
288
+ return ctx
289
+
290
+ def parse(self, raw: Any) -> Any:
291
+ if raw is None or raw == "":
292
+ return None
293
+ return raw
294
+
295
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
296
+ errors = super().validate(value, field)
297
+ return errors
298
+
299
+
300
+ class WysiwygWidget(Widget):
301
+ """Wysiwyg rich text editor widget (contenteditable-based)."""
302
+
303
+ macro_name = "wysiwyg"
304
+
305
+ def __init__(self, height: int = 200):
306
+ self.height = height
307
+
308
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
309
+ ctx = super().render_context(field, value)
310
+ ctx["height"] = self.height
311
+ return ctx
312
+
313
+ def parse(self, raw: str | None) -> str | None:
314
+ if not raw:
315
+ return None
316
+ return raw
317
+
318
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
319
+ return []
320
+
321
+
322
+ class ArrayWidget(Widget):
323
+ """Array/list input widget — dynamic add/remove items."""
324
+
325
+ macro_name = "array_input"
326
+
327
+ def __init__(self, min_items: int = 0, max_items: int | None = None):
328
+ self.min_items = min_items
329
+ self.max_items = max_items
330
+
331
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
332
+ ctx = super().render_context(field, value)
333
+ if isinstance(value, str):
334
+ try:
335
+ import json
336
+ ctx["value"] = json.loads(value)
337
+ except (json.JSONDecodeError, TypeError):
338
+ ctx["value"] = []
339
+ ctx["min_items"] = self.min_items
340
+ ctx["max_items"] = self.max_items
341
+ return ctx
342
+
343
+ def parse(self, raw: str | list | None) -> list:
344
+ if raw is None:
345
+ return []
346
+ if isinstance(raw, list):
347
+ return raw
348
+ if isinstance(raw, str):
349
+ try:
350
+ import json
351
+ return json.loads(raw)
352
+ except (json.JSONDecodeError, TypeError):
353
+ return [raw] if raw else []
354
+ return []
355
+
356
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
357
+ errors = super().validate(value, field)
358
+ if isinstance(value, list):
359
+ if self.min_items and len(value) < self.min_items:
360
+ errors.append(f"{field.label} requires at least {self.min_items} items.")
361
+ if self.max_items and len(value) > self.max_items:
362
+ errors.append(f"{field.label} allows at most {self.max_items} items.")
363
+ return errors
@@ -0,0 +1,110 @@
1
+ """WidgetRegistry — stores type-to-widget and name-to-widget mappings.
2
+
3
+ This class handles ONLY registration/storage of widget mappings.
4
+ Resolution logic lives in WidgetResolver (fastapi_admin_kit/widgets/resolver.py).
5
+
6
+ Resolution order (documented here for reference, implemented in WidgetResolver):
7
+ 1. Exact field name pattern -> e.g. "password" -> PasswordWidget
8
+ 2. FK column present -> RelationPickerWidget
9
+ 3. Enum type -> SelectWidget
10
+ 4. SQLAlchemy type match -> via type_map dict
11
+ 5. Fallback -> TextInputWidget
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from sqlalchemy import (
17
+ Boolean,
18
+ Date,
19
+ DateTime,
20
+ Float,
21
+ Integer,
22
+ LargeBinary,
23
+ Numeric,
24
+ String,
25
+ Text,
26
+ Uuid,
27
+ )
28
+
29
+ from fastapi_admin_kit.widgets.base import Widget
30
+ from fastapi_admin_kit.widgets.inputs import (
31
+ DatePickerWidget,
32
+ DateTimePickerWidget,
33
+ FileUploadWidget,
34
+ NumberInputWidget,
35
+ PasswordWidget,
36
+ TextareaWidget,
37
+ TextInputWidget,
38
+ ToggleWidget,
39
+ )
40
+
41
+
42
+ class WidgetRegistry:
43
+ """Stores SQLAlchemy column type and name pattern mappings to widget classes.
44
+
45
+ This class is responsible ONLY for registration and storage.
46
+ Use WidgetResolver to determine which widget to use for a column.
47
+ """
48
+
49
+ def __init__(self) -> None:
50
+ self._type_map: dict[type, type[Widget]] = {}
51
+ self._name_patterns: list[tuple[str, type[Widget]]] = []
52
+
53
+ @property
54
+ def type_map(self) -> dict[type, type[Widget]]:
55
+ """Read-only access to the type-to-widget mapping."""
56
+ return dict(self._type_map)
57
+
58
+ @property
59
+ def name_patterns(self) -> list[tuple[str, type[Widget]]]:
60
+ """Read-only access to the name pattern list."""
61
+ return list(self._name_patterns)
62
+
63
+ def register_type(self, sa_type: type, widget_cls: type[Widget]) -> None:
64
+ """Register a widget class for a SQLAlchemy column type."""
65
+ self._type_map[sa_type] = widget_cls
66
+
67
+ def unregister_type(self, sa_type: type) -> None:
68
+ """Remove a registered type mapping."""
69
+ self._type_map.pop(sa_type, None)
70
+
71
+ def register_name(self, pattern: str, widget_cls: type[Widget]) -> None:
72
+ """Register a widget class for a name pattern (case-insensitive substring)."""
73
+ self._name_patterns.append((pattern.lower(), widget_cls))
74
+
75
+ def unregister_name(self, pattern: str) -> None:
76
+ """Remove all registrations for a name pattern."""
77
+ self._name_patterns = [
78
+ (p, w) for p, w in self._name_patterns if p != pattern.lower()
79
+ ]
80
+
81
+ def clear(self) -> None:
82
+ """Remove all registered mappings."""
83
+ self._type_map.clear()
84
+ self._name_patterns.clear()
85
+
86
+ def has_type(self, sa_type: type) -> bool:
87
+ """Check if a type is registered."""
88
+ return sa_type in self._type_map
89
+
90
+ def has_name(self, pattern: str) -> bool:
91
+ """Check if a name pattern is registered."""
92
+ return any(p == pattern.lower() for p, _ in self._name_patterns)
93
+
94
+
95
+ widget_registry = WidgetRegistry()
96
+
97
+ widget_registry.register_type(String, TextInputWidget)
98
+ widget_registry.register_type(Text, TextareaWidget)
99
+ widget_registry.register_type(Integer, NumberInputWidget)
100
+ widget_registry.register_type(Float, NumberInputWidget)
101
+ widget_registry.register_type(Numeric, NumberInputWidget)
102
+ widget_registry.register_type(Boolean, ToggleWidget)
103
+ widget_registry.register_type(Date, DatePickerWidget)
104
+ widget_registry.register_type(DateTime, DateTimePickerWidget)
105
+ widget_registry.register_type(LargeBinary, FileUploadWidget)
106
+ widget_registry.register_type(Uuid, TextInputWidget)
107
+
108
+ widget_registry.register_name("password", PasswordWidget)
109
+ widget_registry.register_name("secret", PasswordWidget)
110
+ widget_registry.register_name("token", PasswordWidget)
@@ -0,0 +1,70 @@
1
+ """Relation widgets — ForeignKey and many-to-many pickers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi_admin_kit.types import FieldMeta
8
+ from fastapi_admin_kit.widgets.base import Widget
9
+
10
+
11
+ class RelationPickerWidget(Widget):
12
+ """ForeignKey (many-to-one) picker — HTMX async searchable select with autocomplete."""
13
+
14
+ macro_name = "relation_picker"
15
+
16
+ def __init__(
17
+ self,
18
+ related_table: str = "",
19
+ related_verbose: str = "",
20
+ autocomplete: bool = True,
21
+ search_url: str | None = None,
22
+ ):
23
+ self.related_table = related_table
24
+ self.related_verbose = related_verbose
25
+ self.autocomplete = autocomplete
26
+ self.search_url = search_url
27
+
28
+ def parse(self, raw: str | None) -> int | str | None:
29
+ if not raw:
30
+ return None
31
+ try:
32
+ return int(raw)
33
+ except ValueError:
34
+ return raw
35
+
36
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
37
+ ctx = super().render_context(field, value)
38
+ ctx["related_table"] = self.related_table
39
+ ctx["related_verbose"] = self.related_verbose
40
+ ctx["autocomplete"] = self.autocomplete
41
+ ctx["search_url"] = self.search_url or f"/admin/{self.related_table}/autocomplete/"
42
+ return ctx
43
+
44
+
45
+ class MultiRelationWidget(Widget):
46
+ """Many-to-many tag-style multi-select."""
47
+
48
+ macro_name = "multi_relation"
49
+
50
+ def __init__(self, related_table: str = "", related_verbose: str = "", search_url: str = ""):
51
+ self.related_table = related_table
52
+ self.related_verbose = related_verbose
53
+ self.search_url = search_url
54
+
55
+ def render_context(self, field: FieldMeta, value: Any) -> dict:
56
+ ctx = super().render_context(field, value)
57
+ ctx["related_table"] = self.related_table
58
+ ctx["related_verbose"] = self.related_verbose
59
+ ctx["search_url"] = self.search_url
60
+ return ctx
61
+
62
+ def parse(self, raw: str | list | None) -> list[str]:
63
+ if raw is None:
64
+ return []
65
+ if isinstance(raw, list):
66
+ return [str(v) for v in raw if v]
67
+ return [str(raw)]
68
+
69
+ def validate(self, value: Any, field: FieldMeta) -> list[str]:
70
+ return []
@@ -0,0 +1,102 @@
1
+ """WidgetResolver — dedicated class for resolving which widget to use for a column.
2
+
3
+ Resolution order (fixed, cannot be short-circuited):
4
+ 1. Exact field name pattern -> e.g. "password" -> PasswordWidget
5
+ 2. FK column present -> RelationPickerWidget
6
+ 3. Enum type -> SelectWidget
7
+ 4. SQLAlchemy type match -> via type_registry
8
+ 5. Fallback -> TextInputWidget
9
+
10
+ This class separates resolution logic from registration (WidgetRegistry),
11
+ creating a clear seam between "what widgets exist" and "which widget to use."
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ from fastapi_admin_kit.types import ColumnMeta
19
+
20
+ if TYPE_CHECKING:
21
+ from fastapi_admin_kit.widgets.base import Widget
22
+ from fastapi_admin_kit.widgets.registry import WidgetRegistry
23
+
24
+
25
+ class WidgetResolver:
26
+ """Resolves which widget instance to use for a given ColumnMeta.
27
+
28
+ Uses dependency injection for the WidgetRegistry, making resolution
29
+ testable and separable from registration concerns.
30
+
31
+ Supports both SQLAlchemy types and Python built-in types (used by SQLModel).
32
+ """
33
+
34
+ # Python type to SQLAlchemy type name mapping for SQLModel support
35
+ PYTHON_TYPE_MAP: dict[type, str] = {
36
+ int: "Integer",
37
+ str: "String",
38
+ float: "Float",
39
+ bool: "Boolean",
40
+ }
41
+
42
+ def __init__(self, registry: WidgetRegistry) -> None:
43
+ self._registry = registry
44
+
45
+ @property
46
+ def registry(self) -> WidgetRegistry:
47
+ """Get the underlying widget registry."""
48
+ return self._registry
49
+
50
+ def resolve(self, col: ColumnMeta) -> Widget:
51
+ """Resolve which widget instance to use for a given column.
52
+
53
+ Resolution priority:
54
+ 1. Name pattern match (case-insensitive substring)
55
+ 2. Foreign key column -> RelationPickerWidget
56
+ 3. Enum type -> SelectWidget with choices
57
+ 4. SQLAlchemy type match via registry
58
+ 5. Python type match (for SQLModel)
59
+ 6. Fallback -> TextInputWidget
60
+ """
61
+ for pattern, widget_cls in self._registry.name_patterns:
62
+ if pattern in col.name.lower():
63
+ return widget_cls()
64
+
65
+ if col.foreign_keys:
66
+ from fastapi_admin_kit.widgets.relation import RelationPickerWidget
67
+
68
+ return RelationPickerWidget()
69
+
70
+ col_type = col.type
71
+ if hasattr(col_type, "enums") and col_type.enums:
72
+ choices = [(v, v.replace("_", " ").title()) for v in col_type.enums]
73
+ from fastapi_admin_kit.widgets.inputs import SelectWidget
74
+
75
+ return SelectWidget(choices=choices)
76
+
77
+ # Try SQLAlchemy type match via registry
78
+ for sa_type, widget_cls in self._registry.type_map.items():
79
+ if isinstance(col_type, sa_type):
80
+ from fastapi_admin_kit.widgets.inputs import TextInputWidget
81
+
82
+ has_length = hasattr(col_type, "length") and col_type.length
83
+ if widget_cls == TextInputWidget and has_length:
84
+ return TextInputWidget(maxlength=col_type.length)
85
+ return widget_cls()
86
+
87
+ # Handle Python built-in types (used by SQLModel)
88
+ if col_type in self.PYTHON_TYPE_MAP:
89
+ import sqlalchemy as sa
90
+
91
+ sa_type_name = self.PYTHON_TYPE_MAP[col_type]
92
+ sa_type = getattr(sa, sa_type_name, None)
93
+ if sa_type is not None:
94
+ for sa_registered_type, widget_cls in self._registry.type_map.items():
95
+ if sa_type is sa_registered_type or (
96
+ isinstance(sa_type, type) and isinstance(sa_type(), sa_registered_type)
97
+ ):
98
+ return widget_cls()
99
+
100
+ from fastapi_admin_kit.widgets.inputs import TextInputWidget
101
+
102
+ return TextInputWidget()