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/__init__.py +77 -0
- fastapi_panel/__main__.py +97 -0
- fastapi_panel/auth.py +189 -0
- fastapi_panel/db.py +41 -0
- fastapi_panel/filters.py +215 -0
- fastapi_panel/forms.py +733 -0
- fastapi_panel/imports.py +264 -0
- fastapi_panel/introspect.py +111 -0
- fastapi_panel/log.py +52 -0
- fastapi_panel/options.py +717 -0
- fastapi_panel/py.typed +0 -0
- fastapi_panel/security.py +139 -0
- fastapi_panel/site.py +602 -0
- fastapi_panel/static/panel.css +420 -0
- fastapi_panel/static/panel.js +404 -0
- fastapi_panel/static/theme.js +5 -0
- fastapi_panel/storage.py +38 -0
- fastapi_panel/templates/fastapi_panel/base.html +76 -0
- fastapi_panel/templates/fastapi_panel/delete.html +25 -0
- fastapi_panel/templates/fastapi_panel/error.html +10 -0
- fastapi_panel/templates/fastapi_panel/form.html +113 -0
- fastapi_panel/templates/fastapi_panel/history.html +23 -0
- fastapi_panel/templates/fastapi_panel/import.html +76 -0
- fastapi_panel/templates/fastapi_panel/index.html +43 -0
- fastapi_panel/templates/fastapi_panel/list.html +131 -0
- fastapi_panel/templates/fastapi_panel/login.html +15 -0
- fastapi_panel/templates/fastapi_panel/password.html +17 -0
- fastapi_panel/templates/fastapi_panel/popup_done.html +4 -0
- fastapi_panel/templates/fastapi_panel/widgets.html +74 -0
- fastapi_panel/views.py +710 -0
- fastapi_panel-0.1.0.dist-info/METADATA +151 -0
- fastapi_panel-0.1.0.dist-info/RECORD +34 -0
- fastapi_panel-0.1.0.dist-info/WHEEL +4 -0
- fastapi_panel-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|