polyadmin 0.1.0b1__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.
- polyadmin/__init__.py +47 -0
- polyadmin/core/__init__.py +0 -0
- polyadmin/core/_async.py +21 -0
- polyadmin/core/action.py +135 -0
- polyadmin/core/admin.py +129 -0
- polyadmin/core/audit.py +65 -0
- polyadmin/core/auth.py +52 -0
- polyadmin/core/authorization.py +48 -0
- polyadmin/core/csrf.py +70 -0
- polyadmin/core/dashboard.py +41 -0
- polyadmin/core/delete.py +146 -0
- polyadmin/core/exporter.py +136 -0
- polyadmin/core/field.py +201 -0
- polyadmin/core/filter.py +305 -0
- polyadmin/core/inline.py +97 -0
- polyadmin/core/login.py +110 -0
- polyadmin/core/model_admin.py +349 -0
- polyadmin/core/page.py +62 -0
- polyadmin/core/pagination.py +70 -0
- polyadmin/core/query.py +243 -0
- polyadmin/core/relation.py +40 -0
- polyadmin/core/slug.py +57 -0
- polyadmin/core/template_context.py +679 -0
- polyadmin/core/widget.py +280 -0
- polyadmin/fastapi/__init__.py +3 -0
- polyadmin/fastapi/audit.py +52 -0
- polyadmin/fastapi/auth.py +112 -0
- polyadmin/fastapi/csrf.py +93 -0
- polyadmin/fastapi/deletes.py +76 -0
- polyadmin/fastapi/errors.py +93 -0
- polyadmin/fastapi/handlers.py +798 -0
- polyadmin/fastapi/inlines.py +177 -0
- polyadmin/fastapi/locale.py +128 -0
- polyadmin/fastapi/login.py +110 -0
- polyadmin/fastapi/pages.py +97 -0
- polyadmin/fastapi/relations.py +264 -0
- polyadmin/fastapi/responses.py +55 -0
- polyadmin/fastapi/router.py +174 -0
- polyadmin/fastapi/static.py +21 -0
- polyadmin/i18n/__init__.py +50 -0
- polyadmin/i18n/context.py +54 -0
- polyadmin/i18n/negotiation.py +56 -0
- polyadmin/i18n/setup.py +81 -0
- polyadmin/i18n/translator.py +124 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
- polyadmin/locale/polyadmin.pot +485 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
- polyadmin/templates/admin/base.html +91 -0
- polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
- polyadmin/templates/admin/components/csrf-field.html +5 -0
- polyadmin/templates/admin/components/error_fragment.html +6 -0
- polyadmin/templates/admin/components/field.html +60 -0
- polyadmin/templates/admin/components/form_wrapper.html +131 -0
- polyadmin/templates/admin/components/icons.html +67 -0
- polyadmin/templates/admin/components/inline.html +251 -0
- polyadmin/templates/admin/components/inline_fragment.html +2 -0
- polyadmin/templates/admin/components/list_content.html +54 -0
- polyadmin/templates/admin/components/lookup_results.html +19 -0
- polyadmin/templates/admin/components/search.html +19 -0
- polyadmin/templates/admin/components/toasts.html +151 -0
- polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
- polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
- polyadmin/templates/admin/components/ui/calendar.html +175 -0
- polyadmin/templates/admin/components/ui/combobox.html +82 -0
- polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
- polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
- polyadmin/templates/admin/components/ui/field.html +110 -0
- polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
- polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
- polyadmin/templates/admin/components/ui/multi-select.html +253 -0
- polyadmin/templates/admin/components/ui/pagination.html +81 -0
- polyadmin/templates/admin/components/ui/radio-group.html +28 -0
- polyadmin/templates/admin/components/ui/select.html +165 -0
- polyadmin/templates/admin/components/ui/sidebar.html +175 -0
- polyadmin/templates/admin/components/ui/slider.html +22 -0
- polyadmin/templates/admin/components/ui/switch.html +36 -0
- polyadmin/templates/admin/components/ui/table.html +221 -0
- polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
- polyadmin/templates/admin/dashboard.html +35 -0
- polyadmin/templates/admin/error.html +33 -0
- polyadmin/templates/admin/login.html +94 -0
- polyadmin/templates/admin/resource/delete.html +29 -0
- polyadmin/templates/admin/resource/delete_selected.html +49 -0
- polyadmin/templates/admin/resource/detail.html +78 -0
- polyadmin/templates/admin/resource/form.html +5 -0
- polyadmin/templates/admin/resource/list.html +5 -0
- polyadmin/templates/admin/theme.html +372 -0
- polyadmin/templates/admin/widgets/activity.html +8 -0
- polyadmin/templates/admin/widgets/chart.html +15 -0
- polyadmin/templates/admin/widgets/donut.html +59 -0
- polyadmin/templates/admin/widgets/metric.html +1 -0
- polyadmin/templates/admin/widgets/progress.html +7 -0
- polyadmin/templates/admin/widgets/stat.html +22 -0
- polyadmin/templates/admin/widgets/table.html +29 -0
- polyadmin/templates/admin/widgets/tabs.html +34 -0
- polyadmin/templates/admin/widgets/timeline.html +21 -0
- polyadmin/templating.py +528 -0
- polyadmin/ui.py +817 -0
- polyadmin-0.1.0b1.dist-info/METADATA +239 -0
- polyadmin-0.1.0b1.dist-info/RECORD +104 -0
- polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
- polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""ModelAdmin: the central per-resource abstraction.
|
|
2
|
+
|
|
3
|
+
Resource identity, field resolution, the CRUD lifecycle hooks, search, filters,
|
|
4
|
+
ordering, pagination, relations, actions, exports, and template resolution all
|
|
5
|
+
live here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, ClassVar
|
|
13
|
+
|
|
14
|
+
from polyadmin.core.action import DELETE_SELECTED_NAME, Action, action, collect_actions
|
|
15
|
+
from polyadmin.core.auth import Principal
|
|
16
|
+
from polyadmin.core.field import Field
|
|
17
|
+
from polyadmin.core.inline import Inline
|
|
18
|
+
from polyadmin.core.query import DEFAULT_EMPTY_VALUE
|
|
19
|
+
from polyadmin.i18n import N_, gettext, ngettext
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Fieldset:
|
|
24
|
+
"""One titled group of form fields -- Django's `fieldsets`. A title of None
|
|
25
|
+
renders the group with no header, which is how the undeclared default
|
|
26
|
+
renders as a plain flat form. `collapsed` only seeds the initial state; the
|
|
27
|
+
group can always be opened.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
fields: Sequence[str] = ()
|
|
31
|
+
title: str | None = None
|
|
32
|
+
description: str = ""
|
|
33
|
+
collapsed: bool = False
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
self.fields = list(self.fields)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ModelAdmin:
|
|
40
|
+
"""Base class for declaring how a resource is administered."""
|
|
41
|
+
|
|
42
|
+
model: ClassVar[type]
|
|
43
|
+
|
|
44
|
+
slug: ClassVar[str | None] = None
|
|
45
|
+
# Sidebar grouping: ModelAdmins and AdminPages sharing a category
|
|
46
|
+
# collapse into one section. None keeps a flat top-level link.
|
|
47
|
+
category: ClassVar[str | None] = None
|
|
48
|
+
# Sidebar icon name (see components/icons.html), shown flat or nested
|
|
49
|
+
# inside a category's accordion alike.
|
|
50
|
+
icon: ClassVar[str] = "collection"
|
|
51
|
+
|
|
52
|
+
list_display: ClassVar[Sequence[str]] = ()
|
|
53
|
+
form_fields: ClassVar[Sequence[str]] = ()
|
|
54
|
+
# When set, `fieldsets` defines both the grouping and the field list:
|
|
55
|
+
# get_form_fields() reports it flattened, so the form and the handler
|
|
56
|
+
# agree. `form_fields` is then unused.
|
|
57
|
+
fieldsets: ClassVar[Sequence[Fieldset]] = ()
|
|
58
|
+
# Rendered as values, not inputs, and refused if posted anyway.
|
|
59
|
+
# Override get_readonly_fields to vary by object, which is how
|
|
60
|
+
# "editable on create, frozen afterwards" is expressed.
|
|
61
|
+
readonly_fields: ClassVar[Sequence[str]] = ()
|
|
62
|
+
# The sort applied when a request names none, in the ?sort= syntax
|
|
63
|
+
# ("-field" for descending). Without one, rows arrive in whatever
|
|
64
|
+
# order the data source returned, which for a dict-backed store is not
|
|
65
|
+
# stable between requests.
|
|
66
|
+
ordering: ClassVar[str | None] = None
|
|
67
|
+
# How many rows a list page holds; None means DEFAULT_PAGE_SIZE.
|
|
68
|
+
# Django's list_per_page.
|
|
69
|
+
list_per_page: ClassVar[int | None] = None
|
|
70
|
+
# What a read-only view shows for a None or blank value; None means
|
|
71
|
+
# DEFAULT_EMPTY_VALUE.
|
|
72
|
+
empty_value_display: ClassVar[str | None] = None
|
|
73
|
+
search_fields: ClassVar[Sequence[str]] = ()
|
|
74
|
+
detail_fields: ClassVar[Sequence[str] | None] = None
|
|
75
|
+
filters: ClassVar[Sequence[Any]] = ()
|
|
76
|
+
fields: ClassVar[Sequence[Field]] = ()
|
|
77
|
+
# Names of the actions the detail page offers, in order. None means every
|
|
78
|
+
# action whose `where` includes the detail page; a list overrides `where`
|
|
79
|
+
# (so a "list" action can be named here), and [] offers none.
|
|
80
|
+
detail_actions: ClassVar[Sequence[str] | None] = None
|
|
81
|
+
# Removes the built-in bulk delete. Opt-out, so the default keeps it,
|
|
82
|
+
# as Django does.
|
|
83
|
+
disable_delete_selected: ClassVar[bool] = False
|
|
84
|
+
# Child ModelAdmins whose records point back at this one, managed
|
|
85
|
+
# inline on its create/detail/edit pages. See docs/inlines.md.
|
|
86
|
+
inlines: ClassVar[Sequence[Inline]] = ()
|
|
87
|
+
# Relation fields that render as a lookup-driven search box rather
|
|
88
|
+
# than a <select> over the target's full queryset -- for relations too
|
|
89
|
+
# large, or too principal-sensitive, to dump into a page.
|
|
90
|
+
autocomplete_fields: ClassVar[Sequence[str]] = ()
|
|
91
|
+
|
|
92
|
+
# The small-parity options -- see docs/lists.md and docs/model-admin.md.
|
|
93
|
+
# sortable_by and list_display_links distinguish None ("unset") from []
|
|
94
|
+
# ("none"); is_sortable and links_to_record apply the defaults.
|
|
95
|
+
#
|
|
96
|
+
# Which list columns offer a sort. None leaves every column sortable,
|
|
97
|
+
# [] none of them. The restriction also holds for a hand-typed ?sort=,
|
|
98
|
+
# but not for `ordering`, which is the admin's own choice.
|
|
99
|
+
sortable_by: ClassVar[Sequence[str] | None] = None
|
|
100
|
+
# Which list cells link to the record. None links the first column, []
|
|
101
|
+
# links none, leaving the row menu as the way in.
|
|
102
|
+
list_display_links: ClassVar[Sequence[str] | None] = None
|
|
103
|
+
# Fills a field from others as they are typed: {"slug": ["title"]}
|
|
104
|
+
# slugifies title into slug. Client-side and on the create form only,
|
|
105
|
+
# so an existing record's slug is never rewritten under it.
|
|
106
|
+
prepopulated_fields: ClassVar[dict[str, Sequence[str]]] = {}
|
|
107
|
+
# Prepopulated fields whose letters are kept as they are instead of
|
|
108
|
+
# transliterated to ASCII -- see polyadmin.core.slug.
|
|
109
|
+
prepopulated_unicode: ClassVar[Sequence[str]] = ()
|
|
110
|
+
# Adds "Save as new" to the edit form, which saves the submitted values
|
|
111
|
+
# as a new record and leaves the original alone. Opt-in, as Django's is.
|
|
112
|
+
save_as: ClassVar[bool] = False
|
|
113
|
+
# Whether the list hands its search, filters, sort and page to the pages
|
|
114
|
+
# reached from it, so they lead back into the list as it was left.
|
|
115
|
+
preserve_filters: ClassVar[bool] = True
|
|
116
|
+
|
|
117
|
+
can_view: ClassVar[bool] = True
|
|
118
|
+
can_create: ClassVar[bool] = True
|
|
119
|
+
can_update: ClassVar[bool] = True
|
|
120
|
+
can_delete: ClassVar[bool] = True
|
|
121
|
+
can_export: ClassVar[bool] = True
|
|
122
|
+
|
|
123
|
+
# Shows a drag handle on the list view. Opt-in, because dragging never
|
|
124
|
+
# persists: it reorders the <tr> elements on the page and reverts on
|
|
125
|
+
# the next render. It is for triaging a list by hand without the
|
|
126
|
+
# framework taking a position on how that order would be stored.
|
|
127
|
+
enable_reordering: ClassVar[bool] = False
|
|
128
|
+
|
|
129
|
+
list_template: ClassVar[str | None] = None
|
|
130
|
+
detail_template: ClassVar[str | None] = None
|
|
131
|
+
form_template: ClassVar[str | None] = None
|
|
132
|
+
delete_template: ClassVar[str | None] = None
|
|
133
|
+
|
|
134
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
135
|
+
super().__init_subclass__(**kwargs)
|
|
136
|
+
if "actions" in vars(cls):
|
|
137
|
+
raise TypeError(
|
|
138
|
+
f"{cls.__name__}.actions is no longer supported: declare each action as a "
|
|
139
|
+
"method decorated with @action (polyadmin.core.action.action). "
|
|
140
|
+
"See docs/model-admin.md#actions."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def __init__(self) -> None:
|
|
144
|
+
if getattr(self, "model", None) is None:
|
|
145
|
+
raise TypeError(f"{type(self).__name__} must define `model`.")
|
|
146
|
+
self._fields: dict[str, Field] = self._build_fields()
|
|
147
|
+
|
|
148
|
+
def get_slug(self) -> str:
|
|
149
|
+
if self.slug:
|
|
150
|
+
return self.slug
|
|
151
|
+
return f"{self.model.__name__.lower()}s"
|
|
152
|
+
|
|
153
|
+
def get_verbose_name(self) -> str:
|
|
154
|
+
return self.model.__name__
|
|
155
|
+
|
|
156
|
+
def _build_fields(self) -> dict[str, Field]:
|
|
157
|
+
declared = {field.name: field for field in self.fields}
|
|
158
|
+
implied_names = [
|
|
159
|
+
*self.list_display,
|
|
160
|
+
*self.get_form_fields(),
|
|
161
|
+
*self.search_fields,
|
|
162
|
+
]
|
|
163
|
+
for name in implied_names:
|
|
164
|
+
if name not in declared:
|
|
165
|
+
declared[name] = Field(name)
|
|
166
|
+
return declared
|
|
167
|
+
|
|
168
|
+
def get_fields(self) -> dict[str, Field]:
|
|
169
|
+
return self._fields
|
|
170
|
+
|
|
171
|
+
def get_field(self, name: str) -> Field:
|
|
172
|
+
try:
|
|
173
|
+
return self._fields[name]
|
|
174
|
+
except KeyError:
|
|
175
|
+
raise KeyError(
|
|
176
|
+
f"{type(self).__name__} has no field {name!r}; "
|
|
177
|
+
f"add it to `fields`, `list_display`, or `form_fields`."
|
|
178
|
+
) from None
|
|
179
|
+
|
|
180
|
+
def get_list_display_values(self, obj: Any) -> dict[str, Any]:
|
|
181
|
+
return {name: self.get_field(name).get_value(obj) for name in self.list_display}
|
|
182
|
+
|
|
183
|
+
def get_pk(self, obj: Any) -> Any:
|
|
184
|
+
"""Return the primary key used to build this object's URL."""
|
|
185
|
+
return getattr(obj, "id", None)
|
|
186
|
+
|
|
187
|
+
def get_form_fields(self) -> list[str]:
|
|
188
|
+
if not self.fieldsets:
|
|
189
|
+
return list(self.form_fields)
|
|
190
|
+
names: list[str] = []
|
|
191
|
+
for fieldset in self.fieldsets:
|
|
192
|
+
names.extend(fieldset.fields)
|
|
193
|
+
return names
|
|
194
|
+
|
|
195
|
+
def get_readonly_fields(self, obj: Any = None) -> list[str]:
|
|
196
|
+
"""Fields rendering as values rather than inputs for this object. `obj` is
|
|
197
|
+
None on the create form, so an override can tell creating from editing.
|
|
198
|
+
"""
|
|
199
|
+
return list(self.readonly_fields)
|
|
200
|
+
|
|
201
|
+
def get_page_size(self) -> int:
|
|
202
|
+
"""This ModelAdmin's own page size, or 0 to accept the framework
|
|
203
|
+
default. See `list_per_page`."""
|
|
204
|
+
return self.list_per_page or 0
|
|
205
|
+
|
|
206
|
+
def get_empty_value(self) -> str:
|
|
207
|
+
"""What stands in for a None or blank value on the list and
|
|
208
|
+
detail views. See `empty_value_display`."""
|
|
209
|
+
return self.empty_value_display or DEFAULT_EMPTY_VALUE
|
|
210
|
+
|
|
211
|
+
def get_default_ordering(self) -> str | None:
|
|
212
|
+
"""The sort to use when the request names none."""
|
|
213
|
+
return self.ordering
|
|
214
|
+
|
|
215
|
+
def is_readonly(self, name: str, obj: Any = None) -> bool:
|
|
216
|
+
"""The question every call site actually asks."""
|
|
217
|
+
return name in self.get_readonly_fields(obj)
|
|
218
|
+
|
|
219
|
+
def get_fieldsets(self) -> list[Fieldset]:
|
|
220
|
+
"""Always at least one group: the form template renders groups
|
|
221
|
+
unconditionally, so "none declared" means one unnamed group holding
|
|
222
|
+
every form field, not zero groups holding nothing.
|
|
223
|
+
"""
|
|
224
|
+
if not self.fieldsets:
|
|
225
|
+
return [Fieldset(fields=list(self.form_fields))]
|
|
226
|
+
return list(self.fieldsets)
|
|
227
|
+
|
|
228
|
+
def get_detail_fields(self) -> list[str]:
|
|
229
|
+
if self.detail_fields is not None:
|
|
230
|
+
return list(self.detail_fields)
|
|
231
|
+
return list(dict.fromkeys([*self.list_display, *self.get_form_fields()]))
|
|
232
|
+
|
|
233
|
+
def get_actions(self) -> list[Action]:
|
|
234
|
+
"""The @action methods, in definition order, with the built-in bulk
|
|
235
|
+
delete last -- the destructive action should not be the first thing in
|
|
236
|
+
the listbox. `disable_delete_selected` and `can_delete = False` remove
|
|
237
|
+
it however it is defined."""
|
|
238
|
+
actions = collect_actions(self)
|
|
239
|
+
others = [a for a in actions if a.name != DELETE_SELECTED_NAME]
|
|
240
|
+
if self.disable_delete_selected or not self.can_delete:
|
|
241
|
+
return others
|
|
242
|
+
return [*others, *(a for a in actions if a.name == DELETE_SELECTED_NAME)]
|
|
243
|
+
|
|
244
|
+
def get_action(self, name: str) -> Action | None:
|
|
245
|
+
for candidate in self.get_actions():
|
|
246
|
+
if candidate.name == name:
|
|
247
|
+
return candidate
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
def get_list_actions(self) -> list[Action]:
|
|
251
|
+
"""The actions the list page's bulk bar offers."""
|
|
252
|
+
return [a for a in self.get_actions() if a.where in ("list", "both")]
|
|
253
|
+
|
|
254
|
+
def get_detail_actions(self) -> list[Action]:
|
|
255
|
+
"""The actions one record's detail page offers. delete_selected is a
|
|
256
|
+
bulk action, so it is never among them -- not by `where`, not by being
|
|
257
|
+
named in `detail_actions`, not when a ModelAdmin overrides it."""
|
|
258
|
+
candidates = [a for a in self.get_actions() if a.name != DELETE_SELECTED_NAME]
|
|
259
|
+
if self.detail_actions is None:
|
|
260
|
+
return [a for a in candidates if a.where in ("detail", "both")]
|
|
261
|
+
by_name = {a.name: a for a in candidates}
|
|
262
|
+
return [by_name[name] for name in self.detail_actions if name in by_name]
|
|
263
|
+
|
|
264
|
+
def validate_detail_actions(self) -> None:
|
|
265
|
+
known = {a.name for a in self.get_actions()}
|
|
266
|
+
for name in self.detail_actions or ():
|
|
267
|
+
if name not in known:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"{type(self).__name__}.detail_actions names {name!r}, which is not one of its actions {sorted(known)}."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
@action(
|
|
273
|
+
label=N_("Delete selected"),
|
|
274
|
+
confirm=N_("Delete the selected records? This cannot be undone."),
|
|
275
|
+
permission="delete",
|
|
276
|
+
where="list",
|
|
277
|
+
)
|
|
278
|
+
def delete_selected(self, objects: Sequence[Any], principal: Principal | None) -> str | None:
|
|
279
|
+
"""The bulk delete every admin gets for free.
|
|
280
|
+
|
|
281
|
+
Django ships the same one, and it is the single most common action
|
|
282
|
+
anyone would otherwise write by hand. It is expressed entirely in terms
|
|
283
|
+
of this ModelAdmin's own `delete` hook, so it works against whatever
|
|
284
|
+
storage the application has and honours whatever that hook already
|
|
285
|
+
does (cascades, soft deletes, hooks of its own).
|
|
286
|
+
|
|
287
|
+
permission="delete", not the resource's bare "view": the action route
|
|
288
|
+
checks it on top, so a principal who may look at a list but not destroy
|
|
289
|
+
its rows is refused -- and, because the same check drives the listbox,
|
|
290
|
+
never offered it in the first place.
|
|
291
|
+
"""
|
|
292
|
+
deleted = 0
|
|
293
|
+
for obj in objects:
|
|
294
|
+
try:
|
|
295
|
+
self.delete(obj)
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
# Stop at the first failure and report how far it got:
|
|
298
|
+
# silently continuing would leave the user unable to
|
|
299
|
+
# tell which records survived.
|
|
300
|
+
# Translators: a bulk delete stopped part-way. %(deleted)d of
|
|
301
|
+
# %(total)d records were deleted; %(error)s is the underlying error.
|
|
302
|
+
failure = gettext("Deleted %(deleted)d of %(total)d, then failed: %(error)s")
|
|
303
|
+
raise RuntimeError(failure % {"deleted": deleted, "total": len(objects), "error": exc}) from exc
|
|
304
|
+
deleted += 1
|
|
305
|
+
return ngettext("Deleted %(num)d record.", "Deleted %(num)d records.", deleted) % {"num": deleted}
|
|
306
|
+
|
|
307
|
+
def get_template_candidates(self, view: str) -> list[str]:
|
|
308
|
+
"""Template lookup order for a view: an explicit `{view}_template`
|
|
309
|
+
override, then a resource-specific template, then the framework
|
|
310
|
+
default.
|
|
311
|
+
"""
|
|
312
|
+
candidates: list[str] = []
|
|
313
|
+
explicit = getattr(self, f"{view}_template", None)
|
|
314
|
+
if explicit:
|
|
315
|
+
candidates.append(explicit)
|
|
316
|
+
candidates.append(f"admin/resource/{self.get_slug()}/{view}.html")
|
|
317
|
+
candidates.append(f"admin/resource/{view}.html")
|
|
318
|
+
return candidates
|
|
319
|
+
|
|
320
|
+
def get_queryset(self) -> Any:
|
|
321
|
+
"""Return the base, unfiltered collection of records for this resource."""
|
|
322
|
+
raise NotImplementedError(
|
|
323
|
+
f"{type(self).__name__} must implement get_queryset()."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def get_object(self, pk: Any) -> Any:
|
|
327
|
+
"""Fetch a single record by primary key, or None if not found."""
|
|
328
|
+
raise NotImplementedError(f"{type(self).__name__} must implement get_object().")
|
|
329
|
+
|
|
330
|
+
def create(self, data: dict[str, Any]) -> Any:
|
|
331
|
+
"""Create and persist a new record from validated form data."""
|
|
332
|
+
raise NotImplementedError(f"{type(self).__name__} must implement create().")
|
|
333
|
+
|
|
334
|
+
def update(self, obj: Any, data: dict[str, Any]) -> Any:
|
|
335
|
+
"""Apply validated form data to an existing record and persist it."""
|
|
336
|
+
raise NotImplementedError(f"{type(self).__name__} must implement update().")
|
|
337
|
+
|
|
338
|
+
def delete(self, obj: Any) -> None:
|
|
339
|
+
"""Delete an existing record."""
|
|
340
|
+
raise NotImplementedError(f"{type(self).__name__} must implement delete().")
|
|
341
|
+
|
|
342
|
+
def validate(self, data: dict[str, Any]) -> dict[str, list[str]]:
|
|
343
|
+
"""Run field-level validation over form data. Returns name -> errors."""
|
|
344
|
+
errors: dict[str, list[str]] = {}
|
|
345
|
+
for name in self.get_form_fields():
|
|
346
|
+
field_errors = self.get_field(name).validate(data.get(name))
|
|
347
|
+
if field_errors:
|
|
348
|
+
errors[name] = field_errors
|
|
349
|
+
return errors
|
polyadmin/core/page.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""AdminPage: an application-registered admin route with its own
|
|
2
|
+
template and handler, for functionality that isn't resource CRUD
|
|
3
|
+
(reports, wizards, internal tools) -- see docs/routing.md.
|
|
4
|
+
|
|
5
|
+
Named `AdminPage`, not `Page`, to avoid colliding with
|
|
6
|
+
`polyadmin.core.pagination.Page` (a paginated slice of list results).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from polyadmin.i18n import N_
|
|
15
|
+
|
|
16
|
+
# String-quoted forward ref: PageContext lives in the FastAPI adapter
|
|
17
|
+
# (polyadmin.fastapi.pages), and core must not import adapter code.
|
|
18
|
+
# Handlers are async, matching every other FastAPI adapter handler in
|
|
19
|
+
# this framework (see fastapi/handlers.py's build_* functions).
|
|
20
|
+
PageHandler = Callable[["PageContext"], Awaitable[Any]] # noqa: F821
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AdminPage:
|
|
24
|
+
"""A custom admin page: a path, an application-supplied handler,
|
|
25
|
+
and the same sidebar-grouping/permission shape a ModelAdmin has.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
path: str,
|
|
31
|
+
handler: PageHandler,
|
|
32
|
+
*,
|
|
33
|
+
label: str | None = None,
|
|
34
|
+
category: str | None = None,
|
|
35
|
+
icon: str = "collection",
|
|
36
|
+
permission: str | None = None,
|
|
37
|
+
methods: tuple[str, ...] = ("GET", "POST"),
|
|
38
|
+
show_in_nav: bool = True,
|
|
39
|
+
) -> None:
|
|
40
|
+
if not path.startswith("/"):
|
|
41
|
+
raise ValueError(f"AdminPage path must start with '/': {path!r}")
|
|
42
|
+
self.path = path
|
|
43
|
+
self.handler = handler
|
|
44
|
+
self.label = label or _default_label(path)
|
|
45
|
+
self.category = category
|
|
46
|
+
self.icon = icon
|
|
47
|
+
self.permission = permission or _default_permission(path)
|
|
48
|
+
self.methods = tuple(m.upper() for m in methods)
|
|
49
|
+
self.show_in_nav = show_in_nav
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _default_label(path: str) -> str:
|
|
53
|
+
last = path.strip("/").rsplit("/", 1)[-1]
|
|
54
|
+
if not last:
|
|
55
|
+
return N_("Page")
|
|
56
|
+
return last.replace("-", " ").replace("_", " ").title()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _default_permission(path: str) -> str:
|
|
60
|
+
# "/reports/contracts" -> "page.reports.contracts", the same
|
|
61
|
+
# dotted shape as resource_permission's "{slug}.{action}".
|
|
62
|
+
return "page." + path.strip("/").replace("/", ".")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""In-memory pagination for the list view.
|
|
2
|
+
|
|
3
|
+
This paginates an already-materialized sequence. Once the query
|
|
4
|
+
pipeline lands, ORM/repository integrations will typically
|
|
5
|
+
paginate at the query level instead and only use `Page` as the result
|
|
6
|
+
shape.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Page:
|
|
18
|
+
items: list[Any]
|
|
19
|
+
number: int
|
|
20
|
+
page_size: int
|
|
21
|
+
total_count: int
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def num_pages(self) -> int:
|
|
25
|
+
if self.page_size <= 0 or self.total_count <= 0:
|
|
26
|
+
return 1
|
|
27
|
+
return -(-self.total_count // self.page_size) # ceil division
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def has_previous(self) -> bool:
|
|
31
|
+
return self.number > 1
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def has_next(self) -> bool:
|
|
35
|
+
return self.number < self.num_pages
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def previous_page(self) -> int | None:
|
|
39
|
+
return self.number - 1 if self.has_previous else None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def next_page(self) -> int | None:
|
|
43
|
+
return self.number + 1 if self.has_next else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def page_of(items: Sequence[Any], total: int, list_request: Any) -> Page:
|
|
47
|
+
"""Build the result shape around an already-windowed slice -- for a
|
|
48
|
+
data source that did its own LIMIT/OFFSET and reported the total
|
|
49
|
+
separately. `paginate` is the in-memory equivalent, which slices and
|
|
50
|
+
counts for you.
|
|
51
|
+
"""
|
|
52
|
+
page = max(list_request.page or 1, 1)
|
|
53
|
+
page_size = list_request.page_size if (list_request.page_size or 0) >= 1 else 25
|
|
54
|
+
if getattr(list_request, "unlimited", False):
|
|
55
|
+
# One page holding everything, so num_pages/has_next stay honest.
|
|
56
|
+
page, page_size = 1, max(total, 1)
|
|
57
|
+
return Page(items=list(items), number=page, page_size=page_size, total_count=total)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def paginate(items: Sequence[Any], *, page: int = 1, page_size: int = 25) -> Page:
|
|
61
|
+
page = max(page, 1)
|
|
62
|
+
page_size = max(page_size, 1)
|
|
63
|
+
total_count = len(items)
|
|
64
|
+
start = (page - 1) * page_size
|
|
65
|
+
return Page(
|
|
66
|
+
items=list(items[start : start + page_size]),
|
|
67
|
+
number=page,
|
|
68
|
+
page_size=page_size,
|
|
69
|
+
total_count=total_count,
|
|
70
|
+
)
|