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.
@@ -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">&ndash;</span>')
93
+ if value:
94
+ return Markup('<span class="bool bool-yes" title="Yes">&#10003;</span>')
95
+ return Markup('<span class="bool bool-no" title="No">&#10005;</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