fastapi-admin-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_admin_kit/__init__.py +73 -0
- fastapi_admin_kit/actions/__init__.py +63 -0
- fastapi_admin_kit/actions/base.py +68 -0
- fastapi_admin_kit/actions/registry.py +43 -0
- fastapi_admin_kit/admin/__init__.py +17 -0
- fastapi_admin_kit/admin/admin_config.py +95 -0
- fastapi_admin_kit/admin/admin_database.py +138 -0
- fastapi_admin_kit/admin/admin_router.py +74 -0
- fastapi_admin_kit/admin/admin_template.py +203 -0
- fastapi_admin_kit/admin/builtin_models.py +284 -0
- fastapi_admin_kit/admin/core.py +1036 -0
- fastapi_admin_kit/admin/decorators.py +70 -0
- fastapi_admin_kit/admin/state.py +76 -0
- fastapi_admin_kit/admin.py +728 -0
- fastapi_admin_kit/api/__init__.py +44 -0
- fastapi_admin_kit/api/auth.py +342 -0
- fastapi_admin_kit/api/crud.py +128 -0
- fastapi_admin_kit/api/deps.py +79 -0
- fastapi_admin_kit/api/roles.py +128 -0
- fastapi_admin_kit/api/schema_generator.py +171 -0
- fastapi_admin_kit/api/schemas.py +81 -0
- fastapi_admin_kit/api/search.py +132 -0
- fastapi_admin_kit/audit/__init__.py +36 -0
- fastapi_admin_kit/audit/context.py +62 -0
- fastapi_admin_kit/audit/diff.py +77 -0
- fastapi_admin_kit/audit/event_bus.py +96 -0
- fastapi_admin_kit/audit/events.py +48 -0
- fastapi_admin_kit/audit/listener.py +159 -0
- fastapi_admin_kit/audit/logger.py +28 -0
- fastapi_admin_kit/audit/middleware.py +39 -0
- fastapi_admin_kit/audit/models.py +53 -0
- fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
- fastapi_admin_kit/auth/__init__.py +34 -0
- fastapi_admin_kit/auth/backend.py +95 -0
- fastapi_admin_kit/auth/csrf.py +240 -0
- fastapi_admin_kit/auth/dependencies.py +150 -0
- fastapi_admin_kit/auth/identity.py +181 -0
- fastapi_admin_kit/auth/models.py +246 -0
- fastapi_admin_kit/auth/password.py +35 -0
- fastapi_admin_kit/auth/permissions.py +205 -0
- fastapi_admin_kit/auth/protocol.py +22 -0
- fastapi_admin_kit/auth/ratelimit.py +88 -0
- fastapi_admin_kit/auth/router.py +10 -0
- fastapi_admin_kit/auth/session.py +79 -0
- fastapi_admin_kit/auth/totp.py +83 -0
- fastapi_admin_kit/auth/views.py +165 -0
- fastapi_admin_kit/cli.py +229 -0
- fastapi_admin_kit/config/__init__.py +19 -0
- fastapi_admin_kit/config/audit.py +18 -0
- fastapi_admin_kit/config/auth.py +54 -0
- fastapi_admin_kit/config/behavior.py +27 -0
- fastapi_admin_kit/config/nav.py +32 -0
- fastapi_admin_kit/config/storage.py +22 -0
- fastapi_admin_kit/config/theme.py +215 -0
- fastapi_admin_kit/config/ui.py +147 -0
- fastapi_admin_kit/dashboard/__init__.py +64 -0
- fastapi_admin_kit/db.py +133 -0
- fastapi_admin_kit/exceptions.py +5 -0
- fastapi_admin_kit/field_types.py +81 -0
- fastapi_admin_kit/filters/__init__.py +21 -0
- fastapi_admin_kit/filters/base.py +170 -0
- fastapi_admin_kit/filters/registry.py +68 -0
- fastapi_admin_kit/flash.py +45 -0
- fastapi_admin_kit/form/__init__.py +1 -0
- fastapi_admin_kit/form/pipeline.py +106 -0
- fastapi_admin_kit/inspection/__init__.py +117 -0
- fastapi_admin_kit/inspection/registry.py +253 -0
- fastapi_admin_kit/inspection.py +115 -0
- fastapi_admin_kit/modeladmin.py +375 -0
- fastapi_admin_kit/models/__init__.py +7 -0
- fastapi_admin_kit/models/base.py +7 -0
- fastapi_admin_kit/nav.py +208 -0
- fastapi_admin_kit/pagination/__init__.py +14 -0
- fastapi_admin_kit/pagination/base.py +40 -0
- fastapi_admin_kit/pagination/cursor.py +97 -0
- fastapi_admin_kit/pagination/dynamic.py +48 -0
- fastapi_admin_kit/pagination/offset.py +42 -0
- fastapi_admin_kit/plugins/__init__.py +1 -0
- fastapi_admin_kit/py.typed +0 -0
- fastapi_admin_kit/registry/__init__.py +5 -0
- fastapi_admin_kit/registry/core.py +287 -0
- fastapi_admin_kit/registry/validation.py +107 -0
- fastapi_admin_kit/registry.py +15 -0
- fastapi_admin_kit/router.py +335 -0
- fastapi_admin_kit/static/css/admin.css +4736 -0
- fastapi_admin_kit/static/css/presets.css +317 -0
- fastapi_admin_kit/static/css/tokens.css +217 -0
- fastapi_admin_kit/static/css/variables.css +74 -0
- fastapi_admin_kit/static/icons/heroicons.svg +160 -0
- fastapi_admin_kit/static/js/admin.js +692 -0
- fastapi_admin_kit/static/js/htmx-config.js +42 -0
- fastapi_admin_kit/storage/__init__.py +6 -0
- fastapi_admin_kit/storage/base.py +48 -0
- fastapi_admin_kit/storage/local.py +73 -0
- fastapi_admin_kit/templates/base.html +142 -0
- fastapi_admin_kit/templates/macros/form_fields.html +660 -0
- fastapi_admin_kit/templates/macros/icons.html +50 -0
- fastapi_admin_kit/templates/macros/table.html +108 -0
- fastapi_admin_kit/templates/macros/widgets.html +159 -0
- fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
- fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
- fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
- fastapi_admin_kit/templates/pages/audit_log.html +102 -0
- fastapi_admin_kit/templates/pages/dashboard.html +295 -0
- fastapi_admin_kit/templates/pages/detail.html +183 -0
- fastapi_admin_kit/templates/pages/form.html +119 -0
- fastapi_admin_kit/templates/pages/list.html +277 -0
- fastapi_admin_kit/templates/pages/login.html +85 -0
- fastapi_admin_kit/templates/pages/profile/password.html +78 -0
- fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
- fastapi_admin_kit/templates/pages/role_form.html +75 -0
- fastapi_admin_kit/templates/pages/roles/form.html +117 -0
- fastapi_admin_kit/templates/pages/roles/list.html +69 -0
- fastapi_admin_kit/templates/pages/roles.html +77 -0
- fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
- fastapi_admin_kit/templates/pages/users/form.html +229 -0
- fastapi_admin_kit/templates/pages/users/list.html +83 -0
- fastapi_admin_kit/templates/partials/command_palette.html +52 -0
- fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
- fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
- fastapi_admin_kit/templates/partials/head.html +21 -0
- fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
- fastapi_admin_kit/templates/partials/list_table.html +178 -0
- fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
- fastapi_admin_kit/templates/partials/pagination.html +82 -0
- fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
- fastapi_admin_kit/templates/partials/scripts.html +13 -0
- fastapi_admin_kit/templates/partials/sidebar.html +94 -0
- fastapi_admin_kit/templates/partials/topbar.html +95 -0
- fastapi_admin_kit/types.py +145 -0
- fastapi_admin_kit/validation.py +43 -0
- fastapi_admin_kit/views/__init__.py +78 -0
- fastapi_admin_kit/views/audit.py +134 -0
- fastapi_admin_kit/views/bulk.py +28 -0
- fastapi_admin_kit/views/class_views.py +1040 -0
- fastapi_admin_kit/views/context.py +588 -0
- fastapi_admin_kit/views/dashboard.py +162 -0
- fastapi_admin_kit/views/delete.py +31 -0
- fastapi_admin_kit/views/extra.py +65 -0
- fastapi_admin_kit/views/factory.py +667 -0
- fastapi_admin_kit/views/form.py +159 -0
- fastapi_admin_kit/views/list.py +28 -0
- fastapi_admin_kit/views/profile.py +219 -0
- fastapi_admin_kit/views/protocols.py +54 -0
- fastapi_admin_kit/views/renderers.py +634 -0
- fastapi_admin_kit/views/roles.py +230 -0
- fastapi_admin_kit/views/search.py +31 -0
- fastapi_admin_kit/views/settings.py +31 -0
- fastapi_admin_kit/views/sidebar.py +101 -0
- fastapi_admin_kit/views/totp.py +249 -0
- fastapi_admin_kit/views/users.py +347 -0
- fastapi_admin_kit/views.py +117 -0
- fastapi_admin_kit/widgets/__init__.py +44 -0
- fastapi_admin_kit/widgets/base.py +44 -0
- fastapi_admin_kit/widgets/inputs.py +363 -0
- fastapi_admin_kit/widgets/registry.py +110 -0
- fastapi_admin_kit/widgets/relation.py +70 -0
- fastapi_admin_kit/widgets/resolver.py +102 -0
- fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
- fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
- fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
- fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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()
|