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/options.py
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import decimal
|
|
6
|
+
import enum
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pydantic
|
|
13
|
+
from markupsafe import Markup
|
|
14
|
+
from sqlalchemy import Boolean, DateTime, inspect, or_, select
|
|
15
|
+
from sqlalchemy.exc import NoInspectionAvailable
|
|
16
|
+
from sqlalchemy.orm import selectinload
|
|
17
|
+
from starlette.datastructures import UploadFile
|
|
18
|
+
from starlette.exceptions import HTTPException
|
|
19
|
+
from starlette.responses import Response
|
|
20
|
+
|
|
21
|
+
from .filters import DateHierarchy, Filter, make_filter
|
|
22
|
+
from .forms import (
|
|
23
|
+
Field,
|
|
24
|
+
FileField,
|
|
25
|
+
Form,
|
|
26
|
+
ManyToManyField,
|
|
27
|
+
ReadOnlyField,
|
|
28
|
+
RelationField,
|
|
29
|
+
ValidationError,
|
|
30
|
+
fields_for_model,
|
|
31
|
+
)
|
|
32
|
+
from .introspect import (
|
|
33
|
+
humanize,
|
|
34
|
+
object_label,
|
|
35
|
+
pk_criterion,
|
|
36
|
+
pluralize,
|
|
37
|
+
relationship_paths,
|
|
38
|
+
resolve_attr,
|
|
39
|
+
search_criterion,
|
|
40
|
+
split_camel,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
MAX_CELL_LENGTH = 120
|
|
44
|
+
GROUP_NOISE = {"models", "model", "db", "database", "schemas", "tables", "entities", "app", "src", "__main__"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ImproperlyConfigured(Exception):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def display(
|
|
52
|
+
description: str | None = None,
|
|
53
|
+
*,
|
|
54
|
+
ordering: str | None = None,
|
|
55
|
+
boolean: bool = False,
|
|
56
|
+
empty_value: str | None = None,
|
|
57
|
+
) -> Callable[[Callable], Callable]:
|
|
58
|
+
"""Label a computed column. `ordering` is the attribute to sort by, like `customer__name`."""
|
|
59
|
+
|
|
60
|
+
def decorate(fn: Callable) -> Callable:
|
|
61
|
+
fn.description = description
|
|
62
|
+
fn.ordering = ordering
|
|
63
|
+
fn.boolean = boolean
|
|
64
|
+
fn.empty_value = empty_value
|
|
65
|
+
return fn
|
|
66
|
+
|
|
67
|
+
return decorate
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def action(
|
|
71
|
+
description: str | None = None,
|
|
72
|
+
*,
|
|
73
|
+
permissions: Iterable[str] = ("change",),
|
|
74
|
+
confirm: str | None = None,
|
|
75
|
+
) -> Callable[[Callable], Callable]:
|
|
76
|
+
"""Mark a bulk action. It's called as `fn(self, session, request, objects)`."""
|
|
77
|
+
|
|
78
|
+
def decorate(fn: Callable) -> Callable:
|
|
79
|
+
fn.description = description
|
|
80
|
+
fn.permissions = tuple(permissions)
|
|
81
|
+
fn.confirm = confirm
|
|
82
|
+
return fn
|
|
83
|
+
|
|
84
|
+
return decorate
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_value(value: Any, empty: str = "-", boolean: bool = False) -> Any:
|
|
88
|
+
if isinstance(value, Markup):
|
|
89
|
+
return value
|
|
90
|
+
if boolean or isinstance(value, bool):
|
|
91
|
+
if value is None:
|
|
92
|
+
return Markup('<span class="bool bool-none" title="Unknown">–</span>')
|
|
93
|
+
if value:
|
|
94
|
+
return Markup('<span class="bool bool-yes" title="Yes">✓</span>')
|
|
95
|
+
return Markup('<span class="bool bool-no" title="No">✕</span>')
|
|
96
|
+
plain = plain_value(value)
|
|
97
|
+
if plain == "":
|
|
98
|
+
return empty
|
|
99
|
+
return plain if len(plain) <= MAX_CELL_LENGTH else plain[: MAX_CELL_LENGTH - 1] + "…"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def plain_value(value: Any) -> str:
|
|
103
|
+
if value is None:
|
|
104
|
+
return ""
|
|
105
|
+
if isinstance(value, Markup):
|
|
106
|
+
return value.striptags()
|
|
107
|
+
if isinstance(value, dt.datetime):
|
|
108
|
+
return value.strftime("%Y-%m-%d %H:%M")
|
|
109
|
+
if isinstance(value, enum.Enum):
|
|
110
|
+
return humanize(value.name.lower())
|
|
111
|
+
if isinstance(value, decimal.Decimal):
|
|
112
|
+
return format(value, "f")
|
|
113
|
+
if isinstance(value, dict):
|
|
114
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
115
|
+
if isinstance(value, (list, tuple, set)) or type(value).__name__ == "InstrumentedList":
|
|
116
|
+
return ", ".join(plain_value(item) for item in value)
|
|
117
|
+
if hasattr(value, "__mapper__"):
|
|
118
|
+
return object_label(value)
|
|
119
|
+
return str(value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def csv_safe(text: str) -> str:
|
|
123
|
+
# Spreadsheets run cells starting with these as formulas.
|
|
124
|
+
return "'" + text if text[:1] in ("=", "+", "-", "@", "\t", "\r") else text
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class FormOptions:
|
|
128
|
+
"""What ModelAdmin and inlines have in common: turning a model into a form."""
|
|
129
|
+
|
|
130
|
+
model: type
|
|
131
|
+
fields: Iterable[Any] | None = None
|
|
132
|
+
exclude: Iterable[str] = ()
|
|
133
|
+
readonly_fields: Iterable[str] = ()
|
|
134
|
+
autocomplete_fields: Iterable[str] = ()
|
|
135
|
+
raw_id_fields: Iterable[str] = ()
|
|
136
|
+
filter_horizontal: Iterable[str] = ()
|
|
137
|
+
prepopulated_fields: dict[str, Iterable[str]] = {}
|
|
138
|
+
field_overrides: dict[str, Field] = {}
|
|
139
|
+
form_schema: type[pydantic.BaseModel] | None = None
|
|
140
|
+
empty_value_display = "-"
|
|
141
|
+
verbose_name: str | None = None
|
|
142
|
+
verbose_name_plural: str | None = None
|
|
143
|
+
|
|
144
|
+
site: Any
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def name(self) -> str:
|
|
148
|
+
return self.verbose_name or split_camel(self.model.__name__)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def name_plural(self) -> str:
|
|
152
|
+
return self.verbose_name_plural or pluralize(self.name)
|
|
153
|
+
|
|
154
|
+
def _available_fields(self) -> tuple[dict[str, Field], set[str]]:
|
|
155
|
+
cached, hidden = fields_for_model(self.model)
|
|
156
|
+
fields = dict(cached)
|
|
157
|
+
for name, override in self.field_overrides.items():
|
|
158
|
+
original = fields.get(name)
|
|
159
|
+
if original is not None:
|
|
160
|
+
override.attr = override.attr or original.attr
|
|
161
|
+
override.initial = override.initial if override.initial is not None else original.initial
|
|
162
|
+
if override.required is None:
|
|
163
|
+
override.required = original.required
|
|
164
|
+
override.empty_value = original.empty_value
|
|
165
|
+
override.keeps_default = original.keeps_default
|
|
166
|
+
override.unique = original.unique
|
|
167
|
+
fields[name] = override
|
|
168
|
+
return fields, hidden
|
|
169
|
+
|
|
170
|
+
def get_readonly_fields(self, request: Any, obj: Any = None) -> Iterable[str]:
|
|
171
|
+
return self.readonly_fields
|
|
172
|
+
|
|
173
|
+
def get_fields(self, request: Any, obj: Any = None) -> list[Any]:
|
|
174
|
+
if self.fields is not None:
|
|
175
|
+
return list(self.fields)
|
|
176
|
+
available, hidden = self._available_fields()
|
|
177
|
+
names = [n for n in available if n not in hidden and n not in self.exclude]
|
|
178
|
+
names += [n for n in self.get_readonly_fields(request, obj) if n not in names]
|
|
179
|
+
return names
|
|
180
|
+
|
|
181
|
+
def build_form(
|
|
182
|
+
self,
|
|
183
|
+
session: Any,
|
|
184
|
+
request: Any,
|
|
185
|
+
obj: Any,
|
|
186
|
+
*,
|
|
187
|
+
persisted: bool,
|
|
188
|
+
names: list[str],
|
|
189
|
+
prefix: str = "",
|
|
190
|
+
view_only: bool = False,
|
|
191
|
+
) -> Form:
|
|
192
|
+
available, _ = self._available_fields()
|
|
193
|
+
readonly = set(self.get_readonly_fields(request, obj))
|
|
194
|
+
pk_attrs = {inspect(self.model).get_property_by_column(c).key for c in inspect(self.model).primary_key}
|
|
195
|
+
fields = []
|
|
196
|
+
for name in names:
|
|
197
|
+
source = available.get(name)
|
|
198
|
+
locked = (
|
|
199
|
+
view_only
|
|
200
|
+
or source is None
|
|
201
|
+
or name in readonly
|
|
202
|
+
or source.generated
|
|
203
|
+
or (persisted and source.attr in pk_attrs)
|
|
204
|
+
)
|
|
205
|
+
if locked:
|
|
206
|
+
field = ReadOnlyField(label=self.column_label(name)).bind(name, prefix)
|
|
207
|
+
field.display = (
|
|
208
|
+
self.cell(obj, name, request) if obj is not None and persisted else self.empty_value_display
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
field = source.bind(name, prefix)
|
|
212
|
+
if isinstance(field, (RelationField, ManyToManyField)):
|
|
213
|
+
if name in self.raw_id_fields:
|
|
214
|
+
field.mode = "raw"
|
|
215
|
+
elif name in self.autocomplete_fields:
|
|
216
|
+
field.mode = "autocomplete"
|
|
217
|
+
if name in self.filter_horizontal:
|
|
218
|
+
field.select_widget = "dual"
|
|
219
|
+
field.prepare(session, request, self.site)
|
|
220
|
+
source_names = self.prepopulated_fields.get(name)
|
|
221
|
+
if source_names:
|
|
222
|
+
field.attrs = {**field.attrs, "data-prepopulate": ",".join(f"{prefix}{n}" for n in source_names)}
|
|
223
|
+
fields.append(field)
|
|
224
|
+
return Form(fields, obj, persisted)
|
|
225
|
+
|
|
226
|
+
def clean(self, data: dict[str, Any], obj: Any = None) -> None:
|
|
227
|
+
"""Cross-field validation. Mutate `data` or raise ValidationError(message, field=...)."""
|
|
228
|
+
|
|
229
|
+
def validate_form(self, session: Any, form: Form, data: Any) -> None:
|
|
230
|
+
form.validate(data)
|
|
231
|
+
if form.is_valid:
|
|
232
|
+
form.check_unique(session, self.model)
|
|
233
|
+
if form.is_valid:
|
|
234
|
+
try:
|
|
235
|
+
self.clean(form.cleaned, form.obj if form.persisted else None)
|
|
236
|
+
except ValidationError as exc:
|
|
237
|
+
form.add_error(exc)
|
|
238
|
+
if form.is_valid and self.form_schema is not None:
|
|
239
|
+
try:
|
|
240
|
+
values = {k: v.filename if isinstance(v, UploadFile) else v for k, v in form.cleaned.items()}
|
|
241
|
+
self.form_schema.model_validate(values)
|
|
242
|
+
except pydantic.ValidationError as exc:
|
|
243
|
+
for error in exc.errors():
|
|
244
|
+
name = str(error["loc"][0]) if error["loc"] else None
|
|
245
|
+
form.add_error(ValidationError(error["msg"], field=name))
|
|
246
|
+
|
|
247
|
+
def field_value(self, obj: Any, name: Any) -> Any:
|
|
248
|
+
if callable(name):
|
|
249
|
+
return name(obj)
|
|
250
|
+
if name == "__str__":
|
|
251
|
+
return object_label(obj)
|
|
252
|
+
if name in inspect(type(obj)).attrs:
|
|
253
|
+
return getattr(obj, name)
|
|
254
|
+
method = getattr(self, name, None)
|
|
255
|
+
if callable(method):
|
|
256
|
+
return method(obj)
|
|
257
|
+
value = obj
|
|
258
|
+
for part in name.split("__"):
|
|
259
|
+
value = getattr(value, part, None)
|
|
260
|
+
if value is None:
|
|
261
|
+
return None
|
|
262
|
+
return value() if callable(value) else value
|
|
263
|
+
|
|
264
|
+
def _display_attribute(self, name: Any, attribute: str) -> Any:
|
|
265
|
+
if callable(name):
|
|
266
|
+
return getattr(name, attribute, None)
|
|
267
|
+
if name == "__str__" or name in inspect(self.model).attrs:
|
|
268
|
+
return None
|
|
269
|
+
target = getattr(self, name, None) or getattr(self.model, name, None)
|
|
270
|
+
return getattr(target, attribute, None)
|
|
271
|
+
|
|
272
|
+
def column_label(self, name: Any) -> str:
|
|
273
|
+
if name == "__str__":
|
|
274
|
+
return self.name
|
|
275
|
+
description = self._display_attribute(name, "description")
|
|
276
|
+
if description:
|
|
277
|
+
return description
|
|
278
|
+
return humanize(name if isinstance(name, str) else getattr(name, "__name__", ""))
|
|
279
|
+
|
|
280
|
+
def display_value(self, obj: Any, name: Any) -> Any:
|
|
281
|
+
value = self.field_value(obj, name)
|
|
282
|
+
return self.site.to_local(value) if isinstance(value, dt.datetime) else value
|
|
283
|
+
|
|
284
|
+
def cell(self, obj: Any, name: Any, request: Any) -> Any:
|
|
285
|
+
value = self.display_value(obj, name)
|
|
286
|
+
field = self._available_fields()[0].get(name) if isinstance(name, str) else None
|
|
287
|
+
if isinstance(field, FileField) and value:
|
|
288
|
+
return field.render(self.site, request, value)
|
|
289
|
+
return format_value(
|
|
290
|
+
value,
|
|
291
|
+
self._display_attribute(name, "empty_value") or self.empty_value_display,
|
|
292
|
+
bool(self._display_attribute(name, "boolean")),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class InlineAdmin(FormOptions):
|
|
297
|
+
"""Edit related rows on the parent's page. Subclass TabularInline or StackedInline."""
|
|
298
|
+
|
|
299
|
+
template = ""
|
|
300
|
+
fk_name: str | None = None
|
|
301
|
+
extra = 1
|
|
302
|
+
min_num = 0
|
|
303
|
+
max_num: int | None = None
|
|
304
|
+
can_delete = True
|
|
305
|
+
ordering: Iterable[str] = ()
|
|
306
|
+
|
|
307
|
+
def __init__(self, parent: ModelAdmin) -> None:
|
|
308
|
+
self.parent = parent
|
|
309
|
+
self.site = parent.site
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def prefix(self) -> str:
|
|
313
|
+
return self.model.__name__.lower()
|
|
314
|
+
|
|
315
|
+
def foreign_key(self) -> tuple[str, str]:
|
|
316
|
+
"""(attribute on this model, attribute on the parent) linking the two."""
|
|
317
|
+
mapper = inspect(self.model)
|
|
318
|
+
parent_table = inspect(self.parent.model).local_table
|
|
319
|
+
matches = []
|
|
320
|
+
for prop in mapper.column_attrs:
|
|
321
|
+
for fk in prop.columns[0].foreign_keys:
|
|
322
|
+
if fk.column.table is parent_table and self.fk_name in (None, prop.key):
|
|
323
|
+
matches.append((prop.key, fk.column))
|
|
324
|
+
if len(matches) != 1:
|
|
325
|
+
raise ImproperlyConfigured(
|
|
326
|
+
f"{type(self).__name__}: expected one foreign key from {self.model.__name__} to "
|
|
327
|
+
f"{self.parent.model.__name__}, found {len(matches)}. Set fk_name."
|
|
328
|
+
)
|
|
329
|
+
key, column = matches[0]
|
|
330
|
+
return key, inspect(self.parent.model).get_property_by_column(column).key
|
|
331
|
+
|
|
332
|
+
def get_fields(self, request: Any, obj: Any = None) -> list[Any]:
|
|
333
|
+
names = super().get_fields(request, obj)
|
|
334
|
+
fk, _ = self.foreign_key()
|
|
335
|
+
available, _ = self._available_fields()
|
|
336
|
+
skip = {n for n, f in available.items() if f.attr == fk}
|
|
337
|
+
return [n for n in names if n not in skip]
|
|
338
|
+
|
|
339
|
+
def get_queryset(self, request: Any) -> Any:
|
|
340
|
+
stmt = select(self.model)
|
|
341
|
+
return stmt.order_by(*[_order_expression(self.model, o) for o in self.ordering]) if self.ordering else stmt
|
|
342
|
+
|
|
343
|
+
def has_add_permission(self, request: Any) -> bool:
|
|
344
|
+
return self.parent.has_change_permission(request)
|
|
345
|
+
|
|
346
|
+
def has_change_permission(self, request: Any, obj: Any = None) -> bool:
|
|
347
|
+
return self.parent.has_change_permission(request)
|
|
348
|
+
|
|
349
|
+
def has_delete_permission(self, request: Any, obj: Any = None) -> bool:
|
|
350
|
+
return self.can_delete and self.parent.has_change_permission(request)
|
|
351
|
+
|
|
352
|
+
def save_model(self, session: Any, request: Any, obj: Any, change: bool) -> None:
|
|
353
|
+
session.add(obj)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class TabularInline(InlineAdmin):
|
|
357
|
+
template = "tabular"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class StackedInline(InlineAdmin):
|
|
361
|
+
template = "stacked"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _order_expression(model: type, spec: str) -> Any:
|
|
365
|
+
descending = spec.startswith("-")
|
|
366
|
+
column = resolve_attr(model, spec.lstrip("-"))
|
|
367
|
+
return column.desc() if descending else column.asc()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class ModelAdmin(FormOptions):
|
|
371
|
+
# Changelist
|
|
372
|
+
list_display: Iterable[Any] = ("__str__",)
|
|
373
|
+
list_display_links: Iterable[Any] | None = None
|
|
374
|
+
list_editable: Iterable[str] = ()
|
|
375
|
+
list_filter: Iterable[Any] = ()
|
|
376
|
+
list_per_page = 50
|
|
377
|
+
search_fields: Iterable[str] = ()
|
|
378
|
+
search_help_text = ""
|
|
379
|
+
ordering: Iterable[str] = ()
|
|
380
|
+
date_hierarchy: str | None = None
|
|
381
|
+
show_full_result_count = True
|
|
382
|
+
|
|
383
|
+
# Actions
|
|
384
|
+
actions: Iterable[Any] = ()
|
|
385
|
+
builtin_actions: Iterable[str] = ("delete_selected", "export_csv")
|
|
386
|
+
action_max_rows = 10_000
|
|
387
|
+
export_fields: Iterable[Any] | None = None
|
|
388
|
+
csv_import = False
|
|
389
|
+
import_fields: Iterable[str] | None = None
|
|
390
|
+
import_max_rows = 5_000
|
|
391
|
+
|
|
392
|
+
# Form page
|
|
393
|
+
fieldsets: Iterable[tuple[str | None, dict[str, Any]]] | None = None
|
|
394
|
+
inlines: Iterable[type[InlineAdmin]] = ()
|
|
395
|
+
save_as = False
|
|
396
|
+
save_on_top = False
|
|
397
|
+
|
|
398
|
+
# A column that marks a row as deleted: a nullable datetime or a boolean. Deletes then only set it.
|
|
399
|
+
soft_delete_field: str | None = None
|
|
400
|
+
|
|
401
|
+
# Navigation
|
|
402
|
+
group: str | None = None
|
|
403
|
+
slug: str
|
|
404
|
+
list_template = "fastapi_panel/list.html"
|
|
405
|
+
form_template = "fastapi_panel/form.html"
|
|
406
|
+
|
|
407
|
+
def __init__(self, model: type, site: Any) -> None:
|
|
408
|
+
self.model = model
|
|
409
|
+
self.site = site
|
|
410
|
+
self.slug = getattr(type(self), "slug", model.__name__.lower())
|
|
411
|
+
self.group_name = self.group or self._group_from_module()
|
|
412
|
+
|
|
413
|
+
def _group_from_module(self) -> str:
|
|
414
|
+
parts = [p for p in self.model.__module__.split(".") if p not in GROUP_NOISE]
|
|
415
|
+
return humanize(parts[-1]) if parts else "Models"
|
|
416
|
+
|
|
417
|
+
# Permissions. Override any of these. Row-level rules can look at `obj`.
|
|
418
|
+
|
|
419
|
+
def _granted(self, request: Any, action_name: str) -> bool:
|
|
420
|
+
return self.site.auth.has_perm(request.state.user, f"{self.slug}.{action_name}")
|
|
421
|
+
|
|
422
|
+
def has_module_permission(self, request: Any) -> bool:
|
|
423
|
+
return any(
|
|
424
|
+
check(request) for check in (self.has_view_permission, self.has_add_permission, self.has_change_permission)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def has_view_permission(self, request: Any, obj: Any = None) -> bool:
|
|
428
|
+
return self._granted(request, "view") or self.has_change_permission(request, obj)
|
|
429
|
+
|
|
430
|
+
def has_add_permission(self, request: Any) -> bool:
|
|
431
|
+
return self._granted(request, "add")
|
|
432
|
+
|
|
433
|
+
def has_change_permission(self, request: Any, obj: Any = None) -> bool:
|
|
434
|
+
return self._granted(request, "change")
|
|
435
|
+
|
|
436
|
+
def has_delete_permission(self, request: Any, obj: Any = None) -> bool:
|
|
437
|
+
return self._granted(request, "delete")
|
|
438
|
+
|
|
439
|
+
def require(self, allowed: bool) -> None:
|
|
440
|
+
if not allowed:
|
|
441
|
+
raise HTTPException(403, "You don't have permission to do that.")
|
|
442
|
+
|
|
443
|
+
# Querying
|
|
444
|
+
|
|
445
|
+
def get_queryset(self, request: Any) -> Any:
|
|
446
|
+
"""Rows this request may reach. Every view goes through it, so it's the place for row-level scoping."""
|
|
447
|
+
stmt = select(self.model)
|
|
448
|
+
if not self.soft_delete_field:
|
|
449
|
+
return stmt
|
|
450
|
+
column = getattr(self.model, self.soft_delete_field)
|
|
451
|
+
if isinstance(column.type, Boolean):
|
|
452
|
+
return stmt.where(column.is_(True) if request.state.trash else column.is_not(True))
|
|
453
|
+
return stmt.where(column.is_not(None) if request.state.trash else column.is_(None))
|
|
454
|
+
|
|
455
|
+
def get_object(self, session: Any, request: Any, pk: str) -> Any:
|
|
456
|
+
try:
|
|
457
|
+
criterion = pk_criterion(self.model, pk)
|
|
458
|
+
except ValueError:
|
|
459
|
+
raise HTTPException(404, "Not found.") from None
|
|
460
|
+
obj = session.scalars(self.get_queryset(request).where(criterion)).first()
|
|
461
|
+
if obj is None:
|
|
462
|
+
raise HTTPException(404, f"This {self.name.lower()} doesn't exist or you can't see it.")
|
|
463
|
+
return obj
|
|
464
|
+
|
|
465
|
+
def get_ordering(self, request: Any) -> list[str]:
|
|
466
|
+
return list(self.ordering)
|
|
467
|
+
|
|
468
|
+
def get_list_display(self, request: Any) -> list[Any]:
|
|
469
|
+
return list(self.list_display)
|
|
470
|
+
|
|
471
|
+
def get_list_filters(self, request: Any) -> list[Filter]:
|
|
472
|
+
return [make_filter(self.model, spec, self.site) for spec in self.list_filter]
|
|
473
|
+
|
|
474
|
+
def date_hierarchy_helper(self) -> DateHierarchy | None:
|
|
475
|
+
return DateHierarchy(self.model, self.date_hierarchy) if self.date_hierarchy else None
|
|
476
|
+
|
|
477
|
+
def apply_search(self, stmt: Any, query: str) -> Any:
|
|
478
|
+
for term in query.split()[:10]:
|
|
479
|
+
stmt = stmt.where(or_(*[search_criterion(self.model, spec, term) for spec in self.search_fields]))
|
|
480
|
+
return stmt
|
|
481
|
+
|
|
482
|
+
def eager_load(self, stmt: Any, request: Any) -> Any:
|
|
483
|
+
names = [n for n in self.get_list_display(request) if isinstance(n, str)]
|
|
484
|
+
for path in relationship_paths(self.model, names):
|
|
485
|
+
current, option = self.model, None
|
|
486
|
+
for part in path.split("__"):
|
|
487
|
+
attr = getattr(current, part)
|
|
488
|
+
option = option.selectinload(attr) if option is not None else selectinload(attr)
|
|
489
|
+
current = attr.property.mapper.class_
|
|
490
|
+
stmt = stmt.options(option)
|
|
491
|
+
return stmt
|
|
492
|
+
|
|
493
|
+
def sort_expression(self, name: Any) -> str | None:
|
|
494
|
+
"""The attribute path a list_display column sorts by, or None if it can't sort."""
|
|
495
|
+
ordering = self._display_attribute(name, "ordering")
|
|
496
|
+
if ordering:
|
|
497
|
+
return ordering
|
|
498
|
+
if isinstance(name, str) and name in inspect(self.model).column_attrs:
|
|
499
|
+
return name
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
# Forms
|
|
503
|
+
|
|
504
|
+
def get_fields(self, request: Any, obj: Any = None) -> list[Any]:
|
|
505
|
+
return [name for name in super().get_fields(request, obj) if name != self.soft_delete_field]
|
|
506
|
+
|
|
507
|
+
def get_fieldsets(self, request: Any, obj: Any = None) -> list[tuple[str | None, dict[str, Any]]]:
|
|
508
|
+
if self.fieldsets is not None:
|
|
509
|
+
return list(self.fieldsets)
|
|
510
|
+
return [(None, {"fields": self.get_fields(request, obj)})]
|
|
511
|
+
|
|
512
|
+
def get_inlines(self, request: Any, obj: Any = None) -> list[InlineAdmin]:
|
|
513
|
+
return [inline(self) for inline in self.inlines]
|
|
514
|
+
|
|
515
|
+
def get_form(self, session: Any, request: Any, obj: Any, *, persisted: bool, view_only: bool = False) -> Form:
|
|
516
|
+
fieldsets = self.get_fieldsets(request, obj)
|
|
517
|
+
names: list[str] = []
|
|
518
|
+
for _, options in fieldsets:
|
|
519
|
+
for row in options["fields"]:
|
|
520
|
+
names.extend(row if isinstance(row, (tuple, list)) else [row])
|
|
521
|
+
form = self.build_form(session, request, obj, persisted=persisted, names=names, view_only=view_only)
|
|
522
|
+
by_name = {field.name: field for field in form.fields}
|
|
523
|
+
for title, options in fieldsets:
|
|
524
|
+
rows = [
|
|
525
|
+
[by_name[n] for n in (row if isinstance(row, (tuple, list)) else [row])] for row in options["fields"]
|
|
526
|
+
]
|
|
527
|
+
form.fieldsets.append(
|
|
528
|
+
{
|
|
529
|
+
"title": title,
|
|
530
|
+
"description": options.get("description", ""),
|
|
531
|
+
"collapsed": "collapse" in options.get("classes", ()),
|
|
532
|
+
"rows": rows,
|
|
533
|
+
}
|
|
534
|
+
)
|
|
535
|
+
return form
|
|
536
|
+
|
|
537
|
+
def get_list_form(self, session: Any, request: Any, obj: Any, prefix: str) -> Form:
|
|
538
|
+
return self.build_form(session, request, obj, persisted=True, names=list(self.list_editable), prefix=prefix)
|
|
539
|
+
|
|
540
|
+
def get_import_fields(self, request: Any) -> list[str]:
|
|
541
|
+
if self.import_fields is not None:
|
|
542
|
+
return list(self.import_fields)
|
|
543
|
+
readonly = set(self.get_readonly_fields(request))
|
|
544
|
+
names: list[str] = []
|
|
545
|
+
for row in self.get_fields(request):
|
|
546
|
+
names.extend(row if isinstance(row, (tuple, list)) else [row])
|
|
547
|
+
available, _ = self._available_fields()
|
|
548
|
+
return [name for name in names if name not in readonly and not isinstance(available.get(name), FileField)]
|
|
549
|
+
|
|
550
|
+
def can_import(self, request: Any) -> bool:
|
|
551
|
+
return self.csv_import and (self.has_add_permission(request) or self.has_change_permission(request))
|
|
552
|
+
|
|
553
|
+
def view_on_site(self, obj: Any) -> str | None:
|
|
554
|
+
"""Return the public URL of `obj` to get a "View on site" button."""
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
# Saving
|
|
558
|
+
|
|
559
|
+
def save_model(self, session: Any, request: Any, obj: Any, change: bool) -> None:
|
|
560
|
+
session.add(obj)
|
|
561
|
+
|
|
562
|
+
def delete_model(self, session: Any, request: Any, obj: Any) -> None:
|
|
563
|
+
if not self.soft_deletes(request):
|
|
564
|
+
session.delete(obj)
|
|
565
|
+
return
|
|
566
|
+
column = getattr(self.model, self.soft_delete_field)
|
|
567
|
+
if isinstance(column.type, Boolean):
|
|
568
|
+
setattr(obj, self.soft_delete_field, True)
|
|
569
|
+
else:
|
|
570
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
571
|
+
setattr(obj, self.soft_delete_field, now if column.type.timezone else now.replace(tzinfo=None))
|
|
572
|
+
|
|
573
|
+
def restore_model(self, session: Any, request: Any, obj: Any) -> None:
|
|
574
|
+
column = getattr(self.model, self.soft_delete_field)
|
|
575
|
+
setattr(obj, self.soft_delete_field, False if isinstance(column.type, Boolean) else None)
|
|
576
|
+
|
|
577
|
+
def soft_deletes(self, request: Any) -> bool:
|
|
578
|
+
"""Whether deleting right now keeps the row. On the deleted items page, deleting is for good."""
|
|
579
|
+
return bool(self.soft_delete_field) and not request.state.trash
|
|
580
|
+
|
|
581
|
+
def can_see_deleted(self, request: Any) -> bool:
|
|
582
|
+
if not self.soft_delete_field:
|
|
583
|
+
return False
|
|
584
|
+
return self.has_change_permission(request) or self.has_delete_permission(request)
|
|
585
|
+
|
|
586
|
+
def message_user(self, request: Any, text: str, level: str = "success") -> None:
|
|
587
|
+
self.site.message(request, text, level)
|
|
588
|
+
|
|
589
|
+
# Actions
|
|
590
|
+
|
|
591
|
+
def get_actions(self, request: Any) -> dict[str, dict[str, Any]]:
|
|
592
|
+
found: dict[str, dict[str, Any]] = {}
|
|
593
|
+
specs = (
|
|
594
|
+
("restore_selected", "delete_selected") if request.state.trash else (*self.actions, *self.builtin_actions)
|
|
595
|
+
)
|
|
596
|
+
for spec in specs:
|
|
597
|
+
name = spec if isinstance(spec, str) else spec.__name__
|
|
598
|
+
fn = getattr(self, spec) if isinstance(spec, str) else (lambda *a, _f=spec: _f(self, *a))
|
|
599
|
+
source = getattr(type(self), spec) if isinstance(spec, str) else spec
|
|
600
|
+
needed = getattr(source, "permissions", ("change",))
|
|
601
|
+
if not all(getattr(self, f"has_{perm}_permission")(request) for perm in needed):
|
|
602
|
+
continue
|
|
603
|
+
found[name] = {
|
|
604
|
+
"fn": fn,
|
|
605
|
+
"description": getattr(source, "description", None) or humanize(name),
|
|
606
|
+
"confirm": getattr(source, "confirm", None),
|
|
607
|
+
}
|
|
608
|
+
if request.state.trash and "delete_selected" in found:
|
|
609
|
+
found["delete_selected"]["description"] = "Delete selected permanently"
|
|
610
|
+
return found
|
|
611
|
+
|
|
612
|
+
@action("Delete selected", permissions=("delete",))
|
|
613
|
+
def delete_selected(self, session: Any, request: Any, objects: list[Any]) -> Response | None:
|
|
614
|
+
from . import views
|
|
615
|
+
|
|
616
|
+
return views.delete_selected(session, request, self, objects)
|
|
617
|
+
|
|
618
|
+
@action("Restore selected", permissions=("change",))
|
|
619
|
+
def restore_selected(self, session: Any, request: Any, objects: list[Any]) -> None:
|
|
620
|
+
from . import views
|
|
621
|
+
|
|
622
|
+
views.restore_objects(session, request, self, objects)
|
|
623
|
+
|
|
624
|
+
@action("Export selected to CSV", permissions=("view",))
|
|
625
|
+
def export_csv(self, session: Any, request: Any, objects: list[Any]) -> Response:
|
|
626
|
+
columns = list(self.export_fields or self.get_list_display(request))
|
|
627
|
+
buffer = io.StringIO()
|
|
628
|
+
writer = csv.writer(buffer)
|
|
629
|
+
writer.writerow([self.column_label(c) for c in columns])
|
|
630
|
+
for obj in objects:
|
|
631
|
+
writer.writerow([csv_safe(plain_value(self.display_value(obj, c))) for c in columns])
|
|
632
|
+
filename = f"{self.slug}.csv"
|
|
633
|
+
return Response(
|
|
634
|
+
buffer.getvalue(),
|
|
635
|
+
media_type="text/csv; charset=utf-8",
|
|
636
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Extra pages under /<slug>/. Return (path, view, methods) tuples; views take (session, request).
|
|
640
|
+
|
|
641
|
+
def get_routes(self) -> list[tuple[str, Callable[[Any, Any], Response], list[str]]]:
|
|
642
|
+
return []
|
|
643
|
+
|
|
644
|
+
# Startup checks, so a typo fails on boot rather than on the first page view.
|
|
645
|
+
|
|
646
|
+
def check(self) -> None:
|
|
647
|
+
def fail(option: str, value: Any) -> None:
|
|
648
|
+
raise ImproperlyConfigured(
|
|
649
|
+
f"{type(self).__name__}.{option}: {value!r} is not valid for {self.model.__name__}"
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
attrs = inspect(self.model).attrs
|
|
654
|
+
except NoInspectionAvailable:
|
|
655
|
+
raise ImproperlyConfigured(f"{self.model!r} is not a SQLAlchemy mapped class") from None
|
|
656
|
+
|
|
657
|
+
def known(name: Any) -> bool:
|
|
658
|
+
return (
|
|
659
|
+
callable(name)
|
|
660
|
+
or name == "__str__"
|
|
661
|
+
or name in attrs
|
|
662
|
+
or hasattr(self, name)
|
|
663
|
+
or _has_path(self.model, name)
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
for name in self.list_display:
|
|
667
|
+
if not known(name):
|
|
668
|
+
fail("list_display", name)
|
|
669
|
+
for name in self.list_editable:
|
|
670
|
+
if name not in self.list_display:
|
|
671
|
+
fail("list_editable", name)
|
|
672
|
+
for spec in self.list_filter:
|
|
673
|
+
try:
|
|
674
|
+
make_filter(self.model, spec, self.site)
|
|
675
|
+
except AttributeError:
|
|
676
|
+
fail("list_filter", spec)
|
|
677
|
+
for spec in self.search_fields:
|
|
678
|
+
if not _has_path(self.model, spec.lstrip("^=")):
|
|
679
|
+
fail("search_fields", spec)
|
|
680
|
+
for spec in self.ordering:
|
|
681
|
+
if not _has_path(self.model, spec.lstrip("-")):
|
|
682
|
+
fail("ordering", spec)
|
|
683
|
+
if self.date_hierarchy and self.date_hierarchy not in attrs:
|
|
684
|
+
fail("date_hierarchy", self.date_hierarchy)
|
|
685
|
+
if self.soft_delete_field:
|
|
686
|
+
column = inspect(self.model).column_attrs.get(self.soft_delete_field)
|
|
687
|
+
kind = column.columns[0] if column else None
|
|
688
|
+
usable = kind is not None and (
|
|
689
|
+
isinstance(kind.type, Boolean) or (isinstance(kind.type, DateTime) and kind.nullable)
|
|
690
|
+
)
|
|
691
|
+
if not usable:
|
|
692
|
+
fail("soft_delete_field", self.soft_delete_field)
|
|
693
|
+
available, _ = self._available_fields()
|
|
694
|
+
for spec in (*self.actions, *self.builtin_actions):
|
|
695
|
+
if isinstance(spec, str) and not callable(getattr(self, spec, None)):
|
|
696
|
+
fail("actions", spec)
|
|
697
|
+
for inline_class in self.inlines:
|
|
698
|
+
inline_class(self).foreign_key()
|
|
699
|
+
for options in (self, *[inline_class(self) for inline_class in self.inlines]):
|
|
700
|
+
if not self.site.storage and any(isinstance(f, FileField) for f in options._available_fields()[0].values()):
|
|
701
|
+
raise ImproperlyConfigured(
|
|
702
|
+
f"{type(options).__name__} has a file field, so the site needs a storage: "
|
|
703
|
+
'AdminSite(storage=LocalStorage("media")).'
|
|
704
|
+
)
|
|
705
|
+
declared = self.fields or []
|
|
706
|
+
for name in declared:
|
|
707
|
+
for part in name if isinstance(name, (tuple, list)) else [name]:
|
|
708
|
+
if part not in available and not known(part):
|
|
709
|
+
fail("fields", part)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _has_path(model: type, path: str) -> bool:
|
|
713
|
+
try:
|
|
714
|
+
resolve_attr(model, path)
|
|
715
|
+
except AttributeError:
|
|
716
|
+
return False
|
|
717
|
+
return True
|