fastapi-panel 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_panel/forms.py ADDED
@@ -0,0 +1,733 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import datetime as dt
5
+ import decimal
6
+ import enum
7
+ import functools
8
+ import json
9
+ import math
10
+ import re
11
+ import secrets
12
+ import uuid
13
+ from collections.abc import Callable, Iterable
14
+ from pathlib import PurePosixPath
15
+ from typing import Any
16
+
17
+ import sqlalchemy as sa
18
+ from markupsafe import Markup
19
+ from sqlalchemy import inspect, select
20
+ from sqlalchemy.orm import MANYTOMANY, MANYTOONE
21
+ from starlette.datastructures import UploadFile
22
+
23
+ from .introspect import coerce_to_column, humanize, object_label
24
+
25
+ SENSITIVE_NAME = re.compile(r"password|passwd|secret|token|api_?key|private_?key", re.I)
26
+ EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
27
+ RELATION_SELECT_LIMIT = 200
28
+ IMAGE_SIGNATURES: dict[str, Callable[[bytes], bool]] = {
29
+ "png": lambda head: head.startswith(b"\x89PNG\r\n\x1a\n"),
30
+ "jpg": lambda head: head.startswith(b"\xff\xd8\xff"),
31
+ "jpeg": lambda head: head.startswith(b"\xff\xd8\xff"),
32
+ "gif": lambda head: head[:6] in (b"GIF87a", b"GIF89a"),
33
+ "webp": lambda head: head[:4] == b"RIFF" and head[8:12] == b"WEBP",
34
+ }
35
+
36
+
37
+ def _extension(name: str) -> str:
38
+ return PurePosixPath(name.replace("\\", "/")).suffix.lstrip(".").lower()
39
+
40
+
41
+ def _size_label(size: int) -> str:
42
+ return f"{size / (1024 * 1024):g} MB" if size >= 1024 * 1024 else f"{size // 1024} KB"
43
+
44
+
45
+ class ValidationError(Exception):
46
+ """Raise from `ModelAdmin.clean` or a validator. `field=None` makes it a form-wide error."""
47
+
48
+ def __init__(self, message: str, field: str | None = None) -> None:
49
+ super().__init__(message)
50
+ self.field = field
51
+
52
+
53
+ class Field:
54
+ widget = "input"
55
+ input_type = "text"
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ label: str | None = None,
61
+ required: bool | None = None,
62
+ help_text: str = "",
63
+ initial: Any = None,
64
+ validators: Iterable[Callable[[Any], None]] = (),
65
+ attrs: dict[str, Any] | None = None,
66
+ widget: str | None = None,
67
+ ) -> None:
68
+ self.label = label
69
+ self.required = required
70
+ self.help_text = help_text
71
+ self.initial = initial
72
+ self.validators = list(validators)
73
+ self.attrs = attrs or {}
74
+ if widget:
75
+ self.widget = widget
76
+ self.attr: str | None = None
77
+ self.empty_value: Any = None
78
+ self.keeps_default = False
79
+ self.unique = False
80
+ self.generated = False
81
+
82
+ def bind(self, name: str, prefix: str = "") -> Field:
83
+ bound = copy.copy(self)
84
+ bound.name = name
85
+ bound.attr = self.attr or name
86
+ bound.html_name = f"{prefix}{name}"
87
+ bound.label = self.label or humanize(name)
88
+ bound.required = bool(self.required)
89
+ bound.errors = []
90
+ bound.value = ""
91
+ bound.choices = list(getattr(self, "choices", []))
92
+ return bound
93
+
94
+ def prepare(self, session: Any, request: Any, site: Any) -> None:
95
+ pass
96
+
97
+ def read(self, obj: Any) -> Any:
98
+ return getattr(obj, self.attr)
99
+
100
+ def write(self, obj: Any, value: Any) -> None:
101
+ setattr(obj, self.attr, value)
102
+
103
+ def format(self, value: Any) -> Any:
104
+ return "" if value is None else str(value)
105
+
106
+ def extract(self, data: Any) -> Any:
107
+ return data.get(self.html_name, "")
108
+
109
+ def to_python(self, raw: str) -> Any:
110
+ return raw
111
+
112
+ def clean(self, raw: Any) -> Any:
113
+ if isinstance(raw, str):
114
+ raw = raw.strip()
115
+ if raw in ("", None, []):
116
+ if self.required:
117
+ raise ValidationError("This field is required.")
118
+ return self.empty_value
119
+ value = self.to_python(raw)
120
+ for validate in self.validators:
121
+ validate(value)
122
+ return value
123
+
124
+
125
+ class CharField(Field):
126
+ def __init__(self, *, max_length: int | None = None, **kwargs: Any) -> None:
127
+ super().__init__(**kwargs)
128
+ self.max_length = max_length
129
+ if max_length:
130
+ self.attrs = {"maxlength": max_length, **self.attrs}
131
+
132
+ def to_python(self, raw: str) -> str:
133
+ if self.max_length and len(raw) > self.max_length:
134
+ raise ValidationError(f"Use at most {self.max_length} characters.")
135
+ return raw
136
+
137
+
138
+ class TextField(CharField):
139
+ widget = "textarea"
140
+
141
+
142
+ class EmailField(CharField):
143
+ input_type = "email"
144
+
145
+ def to_python(self, raw: str) -> str:
146
+ if not EMAIL.match(raw):
147
+ raise ValidationError("Enter a valid email address.")
148
+ return super().to_python(raw)
149
+
150
+
151
+ class _NumberField(Field):
152
+ input_type = "number"
153
+ error = "Enter a number."
154
+
155
+ def __init__(self, *, min_value: Any = None, max_value: Any = None, **kwargs: Any) -> None:
156
+ super().__init__(**kwargs)
157
+ self.min_value = min_value
158
+ self.max_value = max_value
159
+ limits = {"min": min_value, "max": max_value}
160
+ self.attrs = {**{k: v for k, v in limits.items() if v is not None}, **self.attrs}
161
+
162
+ def convert(self, raw: str) -> Any:
163
+ raise NotImplementedError
164
+
165
+ def to_python(self, raw: str) -> Any:
166
+ try:
167
+ value = self.convert(raw)
168
+ except (ValueError, decimal.InvalidOperation):
169
+ raise ValidationError(self.error) from None
170
+ if self.min_value is not None and value < self.min_value:
171
+ raise ValidationError(f"Must be at least {self.min_value}.")
172
+ if self.max_value is not None and value > self.max_value:
173
+ raise ValidationError(f"Must be at most {self.max_value}.")
174
+ return value
175
+
176
+
177
+ class IntegerField(_NumberField):
178
+ error = "Enter a whole number."
179
+ convert = staticmethod(int)
180
+
181
+
182
+ class FloatField(_NumberField):
183
+ def __init__(self, **kwargs: Any) -> None:
184
+ super().__init__(**kwargs)
185
+ self.attrs.setdefault("step", "any")
186
+
187
+ def convert(self, raw: str) -> float:
188
+ value = float(raw)
189
+ if not math.isfinite(value):
190
+ raise ValueError(raw)
191
+ return value
192
+
193
+
194
+ class DecimalField(_NumberField):
195
+ def __init__(self, *, max_digits: int | None = None, decimal_places: int | None = None, **kwargs: Any) -> None:
196
+ super().__init__(**kwargs)
197
+ self.max_digits = max_digits
198
+ self.decimal_places = decimal_places
199
+ self.attrs.setdefault("step", "any" if decimal_places is None else str(10**-decimal_places))
200
+
201
+ def convert(self, raw: str) -> decimal.Decimal:
202
+ value = decimal.Decimal(raw)
203
+ if not value.is_finite():
204
+ raise ValueError(raw)
205
+ _, digits, exponent = value.as_tuple()
206
+ places = max(-exponent, 0)
207
+ if self.decimal_places is not None and places > self.decimal_places:
208
+ raise ValidationError(f"Use at most {self.decimal_places} decimal places.")
209
+ if self.max_digits is not None and max(len(digits), places) > self.max_digits:
210
+ raise ValidationError(f"Use at most {self.max_digits} digits.")
211
+ return value
212
+
213
+
214
+ class BooleanField(Field):
215
+ widget = "checkbox"
216
+
217
+ def format(self, value: Any) -> str:
218
+ return "on" if value else ""
219
+
220
+ def clean(self, raw: Any) -> bool:
221
+ return bool(raw)
222
+
223
+
224
+ class DateField(Field):
225
+ input_type = "date"
226
+
227
+ def to_python(self, raw: str) -> dt.date:
228
+ try:
229
+ return dt.date.fromisoformat(raw)
230
+ except ValueError:
231
+ raise ValidationError("Enter a valid date.") from None
232
+
233
+
234
+ class TimeField(Field):
235
+ input_type = "time"
236
+
237
+ def to_python(self, raw: str) -> dt.time:
238
+ try:
239
+ return dt.time.fromisoformat(raw)
240
+ except ValueError:
241
+ raise ValidationError("Enter a valid time.") from None
242
+
243
+
244
+ class DateTimeField(Field):
245
+ input_type = "datetime-local"
246
+
247
+ def __init__(self, *, aware: bool = False, **kwargs: Any) -> None:
248
+ super().__init__(**kwargs)
249
+ self.aware = aware
250
+ self.site: Any = None
251
+ self.attrs.setdefault("step", "1")
252
+
253
+ def prepare(self, session: Any, request: Any, site: Any) -> None:
254
+ self.site = site
255
+ converted = site.naive_utc and site.timezone != "UTC"
256
+ if not self.help_text and (self.aware or converted):
257
+ self.help_text = f"In {site.timezone}."
258
+
259
+ def format(self, value: Any) -> str:
260
+ if value is None:
261
+ return ""
262
+ return self.site.to_local(value).replace(tzinfo=None).isoformat(timespec="seconds")
263
+
264
+ def to_python(self, raw: str) -> dt.datetime:
265
+ try:
266
+ value = dt.datetime.fromisoformat(raw)
267
+ except ValueError:
268
+ raise ValidationError("Enter a valid date and time.") from None
269
+ return self.site.from_local(value.replace(tzinfo=None), self.aware)
270
+
271
+
272
+ class UUIDField(Field):
273
+ def to_python(self, raw: str) -> uuid.UUID:
274
+ try:
275
+ return uuid.UUID(raw)
276
+ except ValueError:
277
+ raise ValidationError("Enter a valid UUID.") from None
278
+
279
+
280
+ class JSONField(Field):
281
+ widget = "textarea"
282
+
283
+ def format(self, value: Any) -> str:
284
+ return "" if value is None else json.dumps(value, indent=2, ensure_ascii=False, default=str)
285
+
286
+ def to_python(self, raw: str) -> Any:
287
+ try:
288
+ return json.loads(raw)
289
+ except ValueError as exc:
290
+ raise ValidationError(f"Enter valid JSON: {exc}") from None
291
+
292
+
293
+ class ChoiceField(Field):
294
+ widget = "select"
295
+
296
+ def __init__(self, *, choices: Iterable[tuple[Any, str]] = (), **kwargs: Any) -> None:
297
+ super().__init__(**kwargs)
298
+ self.choices = [(str(value), label) for value, label in choices]
299
+ self._values = {str(value): value for value, _ in choices}
300
+
301
+ def to_python(self, raw: str) -> Any:
302
+ if raw not in self._values:
303
+ raise ValidationError("Select a valid choice.")
304
+ return self._values[raw]
305
+
306
+
307
+ class EnumField(ChoiceField):
308
+ def __init__(self, enum_class: type[enum.Enum], **kwargs: Any) -> None:
309
+ super().__init__(**kwargs)
310
+ self.choices = [(m.name, humanize(m.name.lower())) for m in enum_class]
311
+ self._values = {m.name: m for m in enum_class}
312
+
313
+ def format(self, value: Any) -> str:
314
+ return "" if value is None else value.name
315
+
316
+
317
+ class FileField(Field):
318
+ """A column holding the stored file's name. Needs a storage: `AdminSite(storage=LocalStorage(...))`."""
319
+
320
+ widget = "file"
321
+ allowed = ("pdf", "txt", "csv", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "zip", *IMAGE_SIGNATURES)
322
+ current = ""
323
+
324
+ def __init__(
325
+ self,
326
+ *,
327
+ allowed_extensions: Iterable[str] | None = None,
328
+ max_size: int = 5 * 1024 * 1024,
329
+ upload_to: str | None = None,
330
+ **kwargs: Any,
331
+ ) -> None:
332
+ super().__init__(**kwargs)
333
+ self.allowed_extensions = {e.lower().lstrip(".") for e in allowed_extensions or self.allowed}
334
+ self.max_size = max_size
335
+ self.upload_to = upload_to
336
+ self.site: Any = None
337
+ self.request: Any = None
338
+ accept = ",".join(f".{e}" for e in sorted(self.allowed_extensions))
339
+ self.attrs = {"accept": accept, "data-max-size": max_size, **self.attrs}
340
+ self.help_text = self.help_text or f"Up to {_size_label(max_size)}."
341
+
342
+ def prepare(self, session: Any, request: Any, site: Any) -> None:
343
+ self.site = site
344
+ self.request = request
345
+
346
+ def read(self, obj: Any) -> Any:
347
+ self.current = getattr(obj, self.attr) or ""
348
+ return self.current
349
+
350
+ def format(self, value: Any) -> str:
351
+ if isinstance(value, UploadFile):
352
+ return value.filename or ""
353
+ return value or ""
354
+
355
+ def extract(self, data: Any) -> Any:
356
+ upload = data.get(self.html_name)
357
+ if isinstance(upload, UploadFile) and upload.filename:
358
+ return upload
359
+ return "" if data.get(f"{self.html_name}-clear") else self.current
360
+
361
+ def clean(self, raw: Any) -> Any:
362
+ if isinstance(raw, UploadFile):
363
+ self.check_upload(raw)
364
+ return raw
365
+ if not raw and self.required:
366
+ raise ValidationError("Choose a file.")
367
+ return raw or self.empty_value
368
+
369
+ def check_upload(self, upload: UploadFile) -> None:
370
+ extension = _extension(upload.filename or "")
371
+ if extension not in self.allowed_extensions:
372
+ raise ValidationError(f"Use one of these file types: {', '.join(sorted(self.allowed_extensions))}.")
373
+ if upload.size is not None and upload.size > self.max_size:
374
+ raise ValidationError(f"This file is too large. The limit is {_size_label(self.max_size)}.")
375
+ for validate in self.validators:
376
+ validate(upload)
377
+
378
+ def write(self, obj: Any, value: Any) -> None:
379
+ if isinstance(value, UploadFile):
380
+ folder = self.upload_to or type(obj).__name__.lower()
381
+ stem = re.sub(r"[^a-z0-9]+", "-", PurePosixPath(value.filename.replace("\\", "/")).stem.lower()).strip("-")
382
+ # shortcut: replaced and deleted files stay in storage, a save can still roll back and restores need them
383
+ name = f"{folder}/{stem[:40] or 'file'}-{secrets.token_hex(6)}.{_extension(value.filename)}"
384
+ value.file.seek(0)
385
+ self.site.storage.save(value.file, name)
386
+ value = name
387
+ setattr(obj, self.attr, value)
388
+
389
+ @property
390
+ def current_name(self) -> str:
391
+ return PurePosixPath(self.current).name
392
+
393
+ @property
394
+ def current_url(self) -> str:
395
+ return self.site.file_url(self.request, self.current)
396
+
397
+ @property
398
+ def is_image(self) -> bool:
399
+ return _extension(self.current) in IMAGE_SIGNATURES
400
+
401
+ def render(self, site: Any, request: Any, value: str) -> Markup:
402
+ url = site.file_url(request, value)
403
+ if _extension(value) in IMAGE_SIGNATURES:
404
+ return Markup('<a href="{}"><img class="thumb" src="{}" alt="" loading="lazy"></a>').format(url, url)
405
+ return Markup('<a href="{}">{}</a>').format(url, PurePosixPath(value).name)
406
+
407
+
408
+ class ImageField(FileField):
409
+ """Only PNG, JPEG, GIF and WEBP, checked against the file's contents. SVG can run scripts, so it isn't allowed."""
410
+
411
+ allowed = tuple(IMAGE_SIGNATURES)
412
+
413
+ def check_upload(self, upload: UploadFile) -> None:
414
+ super().check_upload(upload)
415
+ head = upload.file.read(16)
416
+ upload.file.seek(0)
417
+ if not IMAGE_SIGNATURES.get(_extension(upload.filename or ""), lambda _: False)(head):
418
+ raise ValidationError("This file isn't a valid image.")
419
+
420
+
421
+ class ReadOnlyField(Field):
422
+ widget = "readonly"
423
+ display: Any = ""
424
+
425
+
426
+ class _RelationBase(Field):
427
+ def __init__(self, target: type, key: str, **kwargs: Any) -> None:
428
+ super().__init__(**kwargs)
429
+ self.target = target
430
+ self.key = key
431
+ self.mode = "select"
432
+ self.autocomplete_url = ""
433
+ self.add_url = ""
434
+ self.add_label = ""
435
+
436
+ @property
437
+ def key_attr(self) -> Any:
438
+ return getattr(self.target, self.key)
439
+
440
+ def _configure(self, session: Any, request: Any, site: Any) -> None:
441
+ self._session = session
442
+ self._queryset = site.queryset_for(self.target, request)
443
+ related = site.admin_for_model(self.target)
444
+ keyed_by_pk = inspect(self.target).primary_key == (self.key_attr.property.columns[0],)
445
+ if related is not None and keyed_by_pk:
446
+ root = request.scope["root_path"]
447
+ if related.search_fields:
448
+ self.autocomplete_url = f"{root}/{related.slug}/autocomplete"
449
+ if not request.query_params.get("_popup") and related.has_add_permission(request):
450
+ self.add_url = f"{root}/{related.slug}/add?_popup=1"
451
+ self.add_label = related.name.lower()
452
+
453
+ if self.mode == "raw":
454
+ self.widget = "input"
455
+ return
456
+ options = None
457
+ if self.mode != "autocomplete":
458
+ stmt = self._queryset.limit(RELATION_SELECT_LIMIT + 1)
459
+ # One query per related model, however many inline rows use it.
460
+ cache = request.state.relation_options
461
+ options = cache.get(self.target)
462
+ if options is None:
463
+ options = cache[self.target] = [
464
+ (str(getattr(o, self.key)), object_label(o)) for o in session.scalars(stmt)
465
+ ]
466
+ if options is None or len(options) > RELATION_SELECT_LIMIT:
467
+ self.widget = "autocomplete" if self.autocomplete_url else "input"
468
+ self.choices = []
469
+ else:
470
+ self.choices = options
471
+ self.widget = self.select_widget
472
+
473
+ def _labels(self, values: list[str]) -> list[tuple[str, str]]:
474
+ known = dict(self.choices)
475
+ missing = [v for v in values if v and v not in known]
476
+ if missing and self.widget == "autocomplete":
477
+ column = self.key_attr
478
+ try:
479
+ wanted = [coerce_to_column(column, v) for v in missing]
480
+ except ValueError:
481
+ wanted = [] # A tampered value. Validation already rejected it, just show it as typed.
482
+ found = self._session.scalars(self._queryset.where(column.in_(wanted)))
483
+ known.update((str(getattr(o, self.key)), object_label(o)) for o in found)
484
+ return [(v, known.get(v, v)) for v in values if v]
485
+
486
+ def _fetch(self, raw_values: list[str]) -> list[Any]:
487
+ column = self.key_attr
488
+ try:
489
+ values = [coerce_to_column(column, raw) for raw in raw_values]
490
+ except ValueError:
491
+ raise ValidationError("Select a valid choice.") from None
492
+ found = list(self._session.scalars(self._queryset.where(column.in_(values))))
493
+ if len(found) != len(set(values)):
494
+ raise ValidationError("Select a valid choice.")
495
+ return found
496
+
497
+
498
+ class RelationField(_RelationBase):
499
+ """A foreign key. It writes the column rather than the relationship object."""
500
+
501
+ select_widget = "select"
502
+
503
+ def prepare(self, session: Any, request: Any, site: Any) -> None:
504
+ self._configure(session, request, site)
505
+
506
+ @property
507
+ def selected(self) -> list[tuple[str, str]]:
508
+ return self._labels([self.value] if self.value else [])
509
+
510
+ def to_python(self, raw: str) -> Any:
511
+ return getattr(self._fetch([raw])[0], self.key)
512
+
513
+
514
+ class ManyToManyField(_RelationBase):
515
+ select_widget = "multiselect"
516
+
517
+ def prepare(self, session: Any, request: Any, site: Any) -> None:
518
+ self._configure(session, request, site)
519
+ if self.widget == "input":
520
+ self.widget = "readonly"
521
+ self.display = "Too many options to list. Add search_fields to the related admin."
522
+
523
+ @property
524
+ def selected(self) -> list[tuple[str, str]]:
525
+ return self._labels(list(self.value or []))
526
+
527
+ def read(self, obj: Any) -> Any:
528
+ return getattr(obj, self.attr)
529
+
530
+ def format(self, value: Any) -> list[str]:
531
+ return [str(getattr(o, self.key)) for o in value or []]
532
+
533
+ def extract(self, data: Any) -> list[str]:
534
+ return data.getlist(self.html_name)
535
+
536
+ def clean(self, raw: Any) -> list[Any]:
537
+ if not raw:
538
+ return []
539
+ found = self._fetch(raw)
540
+ for validate in self.validators:
541
+ validate(found)
542
+ return found
543
+
544
+
545
+ class Form:
546
+ def __init__(self, fields: list[Field], obj: Any = None, persisted: bool = False) -> None:
547
+ self.fields = fields
548
+ self.obj = obj
549
+ self.persisted = persisted
550
+ self.errors: list[str] = []
551
+ self.cleaned: dict[str, Any] = {}
552
+ self.fieldsets: list[dict[str, Any]] = []
553
+ self.delete_requested = False
554
+ for field in self.editable:
555
+ source = field.read(obj) if persisted else field.initial
556
+ field.value = field.format(source)
557
+
558
+ @property
559
+ def editable(self) -> list[Field]:
560
+ return [f for f in self.fields if f.widget != "readonly"]
561
+
562
+ @property
563
+ def is_valid(self) -> bool:
564
+ return not self.errors and not any(f.errors for f in self.fields)
565
+
566
+ def has_input(self, data: Any) -> bool:
567
+ return any(f.extract(data) not in ("", [], None, f.format(f.initial)) for f in self.editable)
568
+
569
+ def add_error(self, error: ValidationError) -> None:
570
+ target = next((f for f in self.fields if f.name == error.field), None)
571
+ if target is not None:
572
+ target.errors.append(str(error))
573
+ else:
574
+ self.errors.append(str(error))
575
+
576
+ def validate(self, data: Any) -> None:
577
+ for field in self.editable:
578
+ field.value = field.extract(data)
579
+ try:
580
+ self.cleaned[field.name] = field.clean(field.value)
581
+ except ValidationError as exc:
582
+ field.errors.append(str(exc))
583
+
584
+ def check_unique(self, session: Any, model: type) -> None:
585
+ for field in self.editable:
586
+ value = self.cleaned.get(field.name)
587
+ if not field.unique or value is None or field.errors:
588
+ continue
589
+ stmt = select(getattr(model, field.attr)).where(getattr(model, field.attr) == value).limit(1)
590
+ with session.no_autoflush:
591
+ if self.persisted:
592
+ stmt = stmt.where(sa.not_(_same_row(model, self.obj)))
593
+ if session.execute(stmt).first():
594
+ field.errors.append(f"{field.label} must be unique. This value is already used.")
595
+
596
+ def apply(self, obj: Any) -> list[str]:
597
+ """Writes the cleaned values and returns the labels of what changed."""
598
+ changed = []
599
+ for field in self.editable:
600
+ if field.name not in self.cleaned:
601
+ continue
602
+ value = self.cleaned[field.name]
603
+ if value is None and field.keeps_default and not self.persisted:
604
+ continue
605
+ before = field.format(field.read(obj)) if self.persisted else None
606
+ field.write(obj, value)
607
+ if self.persisted and before != field.format(value):
608
+ changed.append(field.label)
609
+ return changed
610
+
611
+
612
+ def _same_row(model: type, obj: Any) -> Any:
613
+ mapper = inspect(model)
614
+ keys = [mapper.get_property_by_column(c).key for c in mapper.primary_key]
615
+ return sa.and_(*[getattr(model, k) == getattr(obj, k) for k in keys])
616
+
617
+
618
+ def _field_for_column(key: str, column: sa.Column) -> Field | None:
619
+ type_ = getattr(column.type, "impl_instance", column.type)
620
+ if isinstance(type_, sa.Boolean):
621
+ return BooleanField()
622
+ if isinstance(type_, sa.Enum):
623
+ if type_.enum_class:
624
+ return EnumField(type_.enum_class)
625
+ return ChoiceField(choices=[(e, humanize(e)) for e in type_.enums])
626
+ if isinstance(type_, sa.Integer):
627
+ return IntegerField()
628
+ if isinstance(type_, sa.Float):
629
+ return FloatField()
630
+ if isinstance(type_, sa.Numeric):
631
+ return DecimalField(max_digits=type_.precision, decimal_places=type_.scale)
632
+ if isinstance(type_, sa.DateTime):
633
+ return DateTimeField(aware=bool(type_.timezone))
634
+ if isinstance(type_, sa.Date):
635
+ return DateField()
636
+ if isinstance(type_, sa.Time):
637
+ return TimeField()
638
+ if isinstance(type_, sa.Text):
639
+ return TextField()
640
+ if isinstance(type_, sa.String):
641
+ kind = EmailField if "email" in key.lower() else CharField
642
+ return kind(max_length=type_.length)
643
+ if isinstance(type_, sa.Uuid):
644
+ return UUIDField()
645
+ if isinstance(type_, sa.JSON):
646
+ return JSONField()
647
+ return None
648
+
649
+
650
+ def _is_generated(column: sa.Column) -> bool:
651
+ if column.primary_key:
652
+ is_lone_integer = len(column.table.primary_key.columns) == 1 and isinstance(column.type, sa.Integer)
653
+ return (
654
+ column.autoincrement is True
655
+ or column.default is not None
656
+ or column.server_default is not None
657
+ or (column.autoincrement == "auto" and is_lone_integer)
658
+ )
659
+ has_default = column.default is not None or column.server_default is not None
660
+ is_temporal = isinstance(column.type, (sa.DateTime, sa.Date, sa.Time))
661
+ return column.onupdate is not None or column.computed is not None or (is_temporal and has_default)
662
+
663
+
664
+ def _apply_column_meta(field: Field, attr: str, column: sa.Column) -> Field:
665
+ has_default = column.default is not None or column.server_default is not None
666
+ scalar_default = column.default.arg if column.default is not None and column.default.is_scalar else None
667
+ field.attr = attr
668
+ field.unique = bool(column.unique) and not column.primary_key
669
+ field.help_text = field.help_text or column.doc or column.comment or ""
670
+ field.initial = scalar_default
671
+ field.required = not column.nullable and not has_default
672
+ field.empty_value = None
673
+ field.keeps_default = has_default and not column.nullable
674
+ if isinstance(field, (CharField,)) and not column.nullable and not field.keeps_default:
675
+ field.empty_value = ""
676
+ return field
677
+
678
+
679
+ def _target_of_foreign_key(mapper: Any, column: sa.Column) -> tuple[type, str] | None:
680
+ remote = next(iter(column.foreign_keys)).column
681
+ for candidate in mapper.registry.mappers:
682
+ if remote.table is candidate.local_table:
683
+ return candidate.class_, candidate.get_property_by_column(remote).key
684
+ return None
685
+
686
+
687
+ @functools.cache
688
+ def fields_for_model(model: type) -> tuple[dict[str, Field], set[str]]:
689
+ """Every field the model could expose, plus the names shown by default.
690
+
691
+ Generated columns and anything that looks like a credential exist as fields but
692
+ aren't shown unless a ModelAdmin asks for them by name.
693
+ """
694
+ mapper = inspect(model)
695
+ foreign_keys: dict[Any, Any] = {}
696
+ for rel in mapper.relationships:
697
+ if rel.direction is MANYTOONE and len(rel.local_columns) == 1 and not rel.viewonly:
698
+ foreign_keys[next(iter(rel.local_columns))] = rel
699
+
700
+ fields: dict[str, Field] = {}
701
+ hidden: set[str] = set()
702
+ for prop in mapper.column_attrs:
703
+ column = prop.columns[0]
704
+ if not isinstance(column, sa.Column):
705
+ continue
706
+ rel = foreign_keys.get(column)
707
+ target = None
708
+ if rel is not None:
709
+ remote_key = rel.mapper.get_property_by_column(next(iter(rel.remote_side))).key
710
+ target = (rel.mapper.class_, remote_key)
711
+ elif column.foreign_keys and len(column.foreign_keys) == 1:
712
+ target = _target_of_foreign_key(mapper, column)
713
+
714
+ name = rel.key if rel is not None else prop.key
715
+ if target is not None:
716
+ field: Field | None = RelationField(*target)
717
+ else:
718
+ field = _field_for_column(prop.key, column)
719
+ if field is None:
720
+ continue
721
+ fields[name] = _apply_column_meta(field, prop.key, column)
722
+ field.generated = _is_generated(column)
723
+ if field.generated or SENSITIVE_NAME.search(prop.key):
724
+ hidden.add(name)
725
+
726
+ for rel in mapper.relationships:
727
+ if rel.direction is MANYTOMANY and not rel.viewonly:
728
+ target_pk = rel.mapper.primary_key[0]
729
+ field = ManyToManyField(rel.mapper.class_, rel.mapper.get_property_by_column(target_pk).key)
730
+ field.attr = rel.key
731
+ field.required = False
732
+ fields[rel.key] = field
733
+ return fields, hidden