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
polyadmin/core/filter.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Filter: a list-view constraint the user can toggle.
|
|
2
|
+
|
|
3
|
+
`filter.apply(objects, raw_value, model_admin)` is deliberately given
|
|
4
|
+
the raw, still-a-string query-param value -- parsing is the filter's
|
|
5
|
+
job, since only it knows what its own values mean.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from datetime import date, datetime, timedelta
|
|
12
|
+
from typing import Any, ClassVar
|
|
13
|
+
|
|
14
|
+
from polyadmin.core.relation import MANY
|
|
15
|
+
from polyadmin.i18n import N_
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Filter:
|
|
19
|
+
# How this filter's control is sourced and drawn. It answers two
|
|
20
|
+
# questions at once -- who supplies the choices, and what the panel
|
|
21
|
+
# renders -- because they are the same decision: a filter that cannot
|
|
22
|
+
# enumerate its own values is exactly the filter that needs a control
|
|
23
|
+
# other than a list of links.
|
|
24
|
+
#
|
|
25
|
+
# "" is the default and is never declared: the filter supplies its own
|
|
26
|
+
# choices and the panel draws links.
|
|
27
|
+
control_kind: ClassVar[str] = ""
|
|
28
|
+
|
|
29
|
+
def __init__(self, name: str, *, label: str | None = None):
|
|
30
|
+
self.name = name
|
|
31
|
+
self.label = label or name.replace("_", " ").title()
|
|
32
|
+
|
|
33
|
+
def choices_with_labels(self) -> list[tuple[str, str]]:
|
|
34
|
+
"""(value, label) pairs for a <select>, "" always meaning "no filter"."""
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
def apply(self, objects: list[Any], raw_value: str, model_admin: Any) -> list[Any]:
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BooleanFilter(Filter):
|
|
42
|
+
def choices_with_labels(self) -> list[tuple[str, str]]:
|
|
43
|
+
return [("", N_("All")), ("true", N_("Yes")), ("false", N_("No"))]
|
|
44
|
+
|
|
45
|
+
def apply(self, objects, raw_value, model_admin):
|
|
46
|
+
if not raw_value:
|
|
47
|
+
return objects
|
|
48
|
+
field = model_admin.get_field(self.name)
|
|
49
|
+
want = raw_value.lower() in ("true", "1", "on", "yes")
|
|
50
|
+
return [obj for obj in objects if bool(field.get_value(obj)) == want]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ChoiceFilter(Filter):
|
|
54
|
+
def __init__(self, name: str, *, choices: Sequence[Any], label: str | None = None):
|
|
55
|
+
super().__init__(name, label=label)
|
|
56
|
+
self.choices = list(choices)
|
|
57
|
+
|
|
58
|
+
def choices_with_labels(self) -> list[tuple[str, str]]:
|
|
59
|
+
return [("", N_("All"))] + [(str(choice), str(choice)) for choice in self.choices]
|
|
60
|
+
|
|
61
|
+
def apply(self, objects, raw_value, model_admin):
|
|
62
|
+
if not raw_value:
|
|
63
|
+
return objects
|
|
64
|
+
field = model_admin.get_field(self.name)
|
|
65
|
+
return [obj for obj in objects if str(field.get_value(obj)) == raw_value]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# The adapter supplies the choices, from the target ModelAdmin --
|
|
69
|
+
# choices_with_labels cannot, because it reaches neither the registry nor
|
|
70
|
+
# the principal.
|
|
71
|
+
FILTER_KIND_RELATION = "relation"
|
|
72
|
+
# The filter's own choices are presets, and the panel adds two date inputs
|
|
73
|
+
# below them.
|
|
74
|
+
FILTER_KIND_DATE_RANGE = "daterange"
|
|
75
|
+
|
|
76
|
+
# The empty filter's values. Stable strings, because they end up in URLs.
|
|
77
|
+
EMPTY_FILTER_EMPTY = "empty"
|
|
78
|
+
EMPTY_FILTER_NOT_EMPTY = "notempty"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_empty_value(value: Any) -> bool:
|
|
82
|
+
"""Whether a field's value counts as empty: unset or blank, never
|
|
83
|
+
merely zero. None, "", an empty collection and a zero datetime are
|
|
84
|
+
empty; 0, False and "0" are values somebody chose, and are not.
|
|
85
|
+
"""
|
|
86
|
+
if value is None:
|
|
87
|
+
return True
|
|
88
|
+
# Before the length checks below, because False is not empty and bool
|
|
89
|
+
# is a subclass of int.
|
|
90
|
+
if isinstance(value, bool | int | float):
|
|
91
|
+
return False
|
|
92
|
+
# datetime.min/date.min are sentinels here, not moments in time: Go's
|
|
93
|
+
# zero time.Time is how that side represents an unset date, and the
|
|
94
|
+
# two implementations agree on what empty means. DTZ901 is about
|
|
95
|
+
# building naive datetimes for logic; an equality test against the
|
|
96
|
+
# sentinel is exactly what is meant, and comparing an aware value
|
|
97
|
+
# against a naive one returns False rather than raising.
|
|
98
|
+
if isinstance(value, datetime):
|
|
99
|
+
return value == datetime.min # noqa: DTZ901
|
|
100
|
+
if isinstance(value, date):
|
|
101
|
+
return value == date.min
|
|
102
|
+
if isinstance(value, str | list | tuple | set | dict):
|
|
103
|
+
return len(value) == 0
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class EmptyFilter(Filter):
|
|
108
|
+
"""Splits a list on whether a field has a value at all. It applies to
|
|
109
|
+
relation fields too -- "organization is empty" is the case that earns
|
|
110
|
+
it -- where a `many` relation is empty when it has no members.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def choices_with_labels(self) -> list[tuple[str, str]]:
|
|
114
|
+
return [
|
|
115
|
+
("", N_("All")),
|
|
116
|
+
(EMPTY_FILTER_EMPTY, N_("Empty")),
|
|
117
|
+
(EMPTY_FILTER_NOT_EMPTY, N_("Not empty")),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
def apply(self, objects, raw_value, model_admin):
|
|
121
|
+
if raw_value == EMPTY_FILTER_EMPTY:
|
|
122
|
+
want_empty = True
|
|
123
|
+
elif raw_value == EMPTY_FILTER_NOT_EMPTY:
|
|
124
|
+
want_empty = False
|
|
125
|
+
else:
|
|
126
|
+
# "" and anything crafted narrow nothing.
|
|
127
|
+
return objects
|
|
128
|
+
try:
|
|
129
|
+
field = model_admin.get_field(self.name)
|
|
130
|
+
except KeyError:
|
|
131
|
+
return objects
|
|
132
|
+
return [obj for obj in objects if is_empty_value(field.get_value(obj)) is want_empty]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# The date filter's values. Stable strings, because they end up in URLs.
|
|
136
|
+
DATE_FILTER_TODAY = "today"
|
|
137
|
+
DATE_FILTER_PAST_7_DAYS = "7d"
|
|
138
|
+
DATE_FILTER_THIS_MONTH = "month"
|
|
139
|
+
DATE_FILTER_THIS_YEAR = "year"
|
|
140
|
+
|
|
141
|
+
# The date format a custom range uses, in the URL and in the panel's two
|
|
142
|
+
# inputs. It is what <input type="date"> posts, so the form needs no
|
|
143
|
+
# reformatting.
|
|
144
|
+
DATE_FILTER_RANGE_FORMAT = "%Y-%m-%d"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def date_filter_range(raw_value: str, now: datetime) -> tuple[date, date] | None:
|
|
148
|
+
"""The half-open window [from, to) a value means, relative to now. None
|
|
149
|
+
for "" and for anything unrecognised, so a crafted URL narrows nothing
|
|
150
|
+
rather than failing."""
|
|
151
|
+
day = now.date()
|
|
152
|
+
if raw_value == DATE_FILTER_TODAY:
|
|
153
|
+
return day, day + timedelta(days=1)
|
|
154
|
+
if raw_value == DATE_FILTER_PAST_7_DAYS:
|
|
155
|
+
# Inclusive of today, so "past 7 days" counts seven days, not eight.
|
|
156
|
+
return day - timedelta(days=6), day + timedelta(days=1)
|
|
157
|
+
if raw_value == DATE_FILTER_THIS_MONTH:
|
|
158
|
+
start = day.replace(day=1)
|
|
159
|
+
return start, (start + timedelta(days=32)).replace(day=1)
|
|
160
|
+
if raw_value == DATE_FILTER_THIS_YEAR:
|
|
161
|
+
return day.replace(month=1, day=1), day.replace(year=day.year + 1, month=1, day=1)
|
|
162
|
+
|
|
163
|
+
# Not a preset, so try a custom range: "<from>:<to>", each YYYY-MM-DD.
|
|
164
|
+
# It is inclusive of both endpoints, because that is what "from X to
|
|
165
|
+
# Y" means to a reader, and is converted here to the same half-open
|
|
166
|
+
# window the presets produce.
|
|
167
|
+
start_text, separator, end_text = raw_value.partition(":")
|
|
168
|
+
# An empty start is rejected: "through today" has a natural
|
|
169
|
+
# resolution, "since the beginning of time" does not.
|
|
170
|
+
if not separator or not start_text:
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
start = datetime.strptime(start_text, DATE_FILTER_RANGE_FORMAT).date() # noqa: DTZ007
|
|
174
|
+
# An empty end means "through today", resolved here rather than in
|
|
175
|
+
# the panel: the form posts the blank when scripting is off, and a
|
|
176
|
+
# value the parser rejected would break the one path the panel's
|
|
177
|
+
# links exist to protect.
|
|
178
|
+
end = (
|
|
179
|
+
datetime.strptime(end_text, DATE_FILTER_RANGE_FORMAT).date() # noqa: DTZ007
|
|
180
|
+
if end_text
|
|
181
|
+
else day
|
|
182
|
+
)
|
|
183
|
+
except ValueError:
|
|
184
|
+
return None
|
|
185
|
+
if end < start:
|
|
186
|
+
return None
|
|
187
|
+
return start, end + timedelta(days=1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def date_filter_range_values(raw_value: str) -> tuple[str, str] | None:
|
|
191
|
+
"""The two strings a custom range carries, so the panel can prefill its
|
|
192
|
+
inputs. Parses nothing: None for a preset and for anything without a
|
|
193
|
+
separator.
|
|
194
|
+
"""
|
|
195
|
+
start_text, separator, end_text = raw_value.partition(":")
|
|
196
|
+
if not separator or not start_text:
|
|
197
|
+
return None
|
|
198
|
+
return start_text, end_text
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class DateFilter(Filter):
|
|
202
|
+
"""Narrows a list to a window around today: the presets a reader actually
|
|
203
|
+
asks for ("what came in this week?"), rather than two date boxes to fill
|
|
204
|
+
in. Declared like any other filter -- filters = [DateFilter("founded")] --
|
|
205
|
+
and so it renders in the same filter panel and rides in the same
|
|
206
|
+
ListRequest, which means exports and delete_selected narrow with it.
|
|
207
|
+
|
|
208
|
+
A `list_page` implementation reads the raw value and resolves it in its
|
|
209
|
+
own query; `date_filter_range` turns a value into the same window this
|
|
210
|
+
applies in memory, so the two cannot drift.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
# Its own choices are the presets; the panel draws two date inputs
|
|
214
|
+
# below them.
|
|
215
|
+
control_kind: ClassVar[str] = FILTER_KIND_DATE_RANGE
|
|
216
|
+
|
|
217
|
+
def choices_with_labels(self) -> list[tuple[str, str]]:
|
|
218
|
+
return [
|
|
219
|
+
("", N_("Any date")),
|
|
220
|
+
(DATE_FILTER_TODAY, N_("Today")),
|
|
221
|
+
(DATE_FILTER_PAST_7_DAYS, N_("Past 7 days")),
|
|
222
|
+
(DATE_FILTER_THIS_MONTH, N_("This month")),
|
|
223
|
+
(DATE_FILTER_THIS_YEAR, N_("This year")),
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
def apply(self, objects, raw_value, model_admin):
|
|
227
|
+
window = date_filter_range(raw_value, datetime.now()) # noqa: DTZ005 -- the reader's own day
|
|
228
|
+
if window is None:
|
|
229
|
+
return objects
|
|
230
|
+
start, end = window
|
|
231
|
+
try:
|
|
232
|
+
field = model_admin.get_field(self.name)
|
|
233
|
+
except KeyError:
|
|
234
|
+
return objects
|
|
235
|
+
kept = []
|
|
236
|
+
for obj in objects:
|
|
237
|
+
value = field.get_value(obj)
|
|
238
|
+
# Compared as a date: a datetime's clock time must not decide
|
|
239
|
+
# whether it falls in "today".
|
|
240
|
+
if isinstance(value, datetime):
|
|
241
|
+
value = value.date()
|
|
242
|
+
if isinstance(value, date) and start <= value < end:
|
|
243
|
+
kept.append(obj)
|
|
244
|
+
return kept
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class RelationFilter(Filter):
|
|
248
|
+
"""Narrows a list to rows pointing at one related record. The URL
|
|
249
|
+
carries the target's primary key, so a filtered list is a link like
|
|
250
|
+
any other.
|
|
251
|
+
|
|
252
|
+
Its choices come from the adapter, not from here: choices_with_labels
|
|
253
|
+
reaches neither the registry nor the principal, and a filter must not
|
|
254
|
+
offer records from a target the reader may not view.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
control_kind: ClassVar[str] = FILTER_KIND_RELATION
|
|
258
|
+
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
name: str,
|
|
262
|
+
*,
|
|
263
|
+
label: str | None = None,
|
|
264
|
+
related_pk: Callable[[Any], Any] | None = None,
|
|
265
|
+
):
|
|
266
|
+
super().__init__(name, label=label)
|
|
267
|
+
# Resolves the related object's primary key. Defaults to the lookup
|
|
268
|
+
# ModelAdmin.get_pk uses. Set it when the target declares its own:
|
|
269
|
+
# apply receives the parent ModelAdmin, not the registry, and
|
|
270
|
+
# Relation.target is a slug, so core cannot call the target's
|
|
271
|
+
# get_pk for you.
|
|
272
|
+
self.related_pk = related_pk
|
|
273
|
+
|
|
274
|
+
def choices_with_labels(self) -> list[tuple[str, str]]:
|
|
275
|
+
return [("", N_("All"))]
|
|
276
|
+
|
|
277
|
+
def _pk(self, related: Any) -> str:
|
|
278
|
+
if related is None:
|
|
279
|
+
return ""
|
|
280
|
+
pk = self.related_pk(related) if self.related_pk else getattr(related, "id", None)
|
|
281
|
+
return "" if pk is None else str(pk)
|
|
282
|
+
|
|
283
|
+
def apply(self, objects, raw_value, model_admin):
|
|
284
|
+
if not raw_value:
|
|
285
|
+
return objects
|
|
286
|
+
try:
|
|
287
|
+
field = model_admin.get_field(self.name)
|
|
288
|
+
except KeyError:
|
|
289
|
+
return objects
|
|
290
|
+
relation = getattr(field, "relation", None)
|
|
291
|
+
if relation is None:
|
|
292
|
+
return objects
|
|
293
|
+
many = relation.cardinality == MANY
|
|
294
|
+
kept = []
|
|
295
|
+
for obj in objects:
|
|
296
|
+
related = relation.get_value(obj)
|
|
297
|
+
if related is None:
|
|
298
|
+
continue
|
|
299
|
+
if not many:
|
|
300
|
+
if self._pk(related) == raw_value:
|
|
301
|
+
kept.append(obj)
|
|
302
|
+
# A `many` relation matches when any member does.
|
|
303
|
+
elif any(self._pk(member) == raw_value for member in related):
|
|
304
|
+
kept.append(obj)
|
|
305
|
+
return kept
|
polyadmin/core/inline.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Inline: a reverse-relation admin declaration -- lets a parent
|
|
2
|
+
ModelAdmin manage/display a child ModelAdmin's records that point back
|
|
3
|
+
at it via one FK/OneToOne field, Django-admin TabularInline/StackedInline
|
|
4
|
+
style. See docs/inlines.md.
|
|
5
|
+
|
|
6
|
+
`layout` is presentation-only, not behavioral, so there is one Inline
|
|
7
|
+
type with a layout discriminator, not two structurally different
|
|
8
|
+
classes -- StackedInline/TabularInline are just layout-preset
|
|
9
|
+
subclasses, for a Django-familiar spelling.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from polyadmin.core._async import maybe_await
|
|
17
|
+
from polyadmin.core.query import ListRequest, alist_objects
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
# Only needed for type hints -- inline.py must not import
|
|
21
|
+
# model_admin.py at runtime, since model_admin.py imports Inline
|
|
22
|
+
# (a real ClassVar default, not just a hint) from here. Guarding
|
|
23
|
+
# only this side is enough to avoid a circular import.
|
|
24
|
+
from polyadmin.core.model_admin import ModelAdmin
|
|
25
|
+
|
|
26
|
+
STACKED = "stacked"
|
|
27
|
+
TABULAR = "tabular"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Inline:
|
|
31
|
+
"""child is the target (child) ModelAdmin's slug; fk_field is the
|
|
32
|
+
name of the field on the child that points back at this parent.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
child: str,
|
|
38
|
+
fk_field: str,
|
|
39
|
+
*,
|
|
40
|
+
layout: str = STACKED,
|
|
41
|
+
label: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.child = child
|
|
44
|
+
self.fk_field = fk_field
|
|
45
|
+
self.layout = layout
|
|
46
|
+
# None -> the adapter derives a label from the child's own
|
|
47
|
+
# verbose name (needs the Admin registry, so resolved there).
|
|
48
|
+
self.label = label
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class StackedInline(Inline):
|
|
52
|
+
def __init__(self, child: str, fk_field: str, *, label: str | None = None) -> None:
|
|
53
|
+
super().__init__(child, fk_field, layout=STACKED, label=label)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TabularInline(Inline):
|
|
57
|
+
def __init__(self, child: str, fk_field: str, *, label: str | None = None) -> None:
|
|
58
|
+
super().__init__(child, fk_field, layout=TABULAR, label=label)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def afilter_inline_children(
|
|
62
|
+
child_admin: ModelAdmin, fk_field: str, parent_admin: ModelAdmin, parent_pk: Any
|
|
63
|
+
) -> list[Any]:
|
|
64
|
+
"""Children of `child_admin` that point at the object identified by
|
|
65
|
+
`parent_pk` on `parent_admin`.
|
|
66
|
+
|
|
67
|
+
A child that implements `list_page` answers for itself: it is asked for
|
|
68
|
+
every row (`unlimited`) with `filters[fk_field] = str(parent_pk)` and its
|
|
69
|
+
rows are used as returned, so an HTTP-backed child never has to download
|
|
70
|
+
its whole table to be filtered here. That is the contract such a child
|
|
71
|
+
implements. Any other child loads its queryset (which may be async) and is
|
|
72
|
+
filtered in memory by its fk field: PKs compare as strings to dodge int/str
|
|
73
|
+
mismatches, and the fk field's value is the related object itself (per
|
|
74
|
+
Relation.get_value), so `parent_admin.get_pk(...)` resolves its PK.
|
|
75
|
+
"""
|
|
76
|
+
if hasattr(child_admin, "list_page"):
|
|
77
|
+
objects, _ = await alist_objects(
|
|
78
|
+
child_admin, ListRequest(filters={fk_field: str(parent_pk)}, unlimited=True)
|
|
79
|
+
)
|
|
80
|
+
return list(objects)
|
|
81
|
+
field = child_admin.get_field(fk_field)
|
|
82
|
+
parent_pk_str = str(parent_pk)
|
|
83
|
+
result = []
|
|
84
|
+
for obj in await maybe_await(child_admin.get_queryset()):
|
|
85
|
+
related = field.get_value(obj)
|
|
86
|
+
if related is None:
|
|
87
|
+
continue
|
|
88
|
+
if str(parent_admin.get_pk(related)) == parent_pk_str:
|
|
89
|
+
result.append(obj)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# How many options a tabular inline's many-to-many listbox shows before
|
|
94
|
+
# it scrolls. Four keeps the row close to the height of the single-line
|
|
95
|
+
# controls beside it, which is what makes the table read as rows rather
|
|
96
|
+
# than as stacked blocks.
|
|
97
|
+
INLINE_MULTISELECT_ROWS = 4
|
polyadmin/core/login.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Login: the write side of authentication.
|
|
2
|
+
|
|
3
|
+
Authenticator (auth.py) answers "who is this request?"; a LoginBackend answers
|
|
4
|
+
"are these credentials good?" and creates or destroys the session the
|
|
5
|
+
Authenticator reads.
|
|
6
|
+
|
|
7
|
+
The split keeps the framework out of key management. It owns the login page --
|
|
8
|
+
form, error state, redirect, CSRF -- because that is presentation. It never
|
|
9
|
+
mints a token, so it never needs a signing secret, and how a session is stored
|
|
10
|
+
stays the application's decision. See examples/fastapi/session.py.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
# The routes the adapters mount, relative to the base path. Constants, not
|
|
18
|
+
# settings: the page is the framework's, and every link to it would
|
|
19
|
+
# otherwise have to thread the value through.
|
|
20
|
+
LOGIN_PATH = "/login"
|
|
21
|
+
LOGOUT_PATH = "/logout"
|
|
22
|
+
LOCALE_PATH = "/locale"
|
|
23
|
+
|
|
24
|
+
# Carries the URL an unauthenticated visitor was trying to reach, so
|
|
25
|
+
# signing in returns them there instead of dumping them on the dashboard.
|
|
26
|
+
NEXT_QUERY_PARAM = "next"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class LoginBackend(Protocol):
|
|
31
|
+
"""What an application implements to turn on the admin's built-in login page.
|
|
32
|
+
|
|
33
|
+
Passing one to `Admin(login_backend=...)` is the switch: without it the
|
|
34
|
+
login routes are never mounted and an unauthenticated request gets a 401.
|
|
35
|
+
`request` is untyped because core must not know what a fastapi.Request is.
|
|
36
|
+
|
|
37
|
+
Any of the three methods may also be `async def` -- the FastAPI adapter
|
|
38
|
+
awaits whichever ones are coroutine functions, so a backend may mix
|
|
39
|
+
sync and async methods freely (e.g. an async verify_credentials against
|
|
40
|
+
a database alongside a sync, in-memory begin_session/end_session).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def verify_credentials(self, request: Any, identifier: str, password: str) -> Any:
|
|
44
|
+
"""Return the Principal these credentials identify, or None if they are
|
|
45
|
+
not valid. None is an ordinary outcome, not an error.
|
|
46
|
+
|
|
47
|
+
Implementations must compare in constant time and must not distinguish
|
|
48
|
+
"no such user" from "wrong password": the admin renders one message for
|
|
49
|
+
both, and a backend leaking the difference through timing undoes that.
|
|
50
|
+
"""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def begin_session(self, request: Any, principal: Any, response: Any) -> None:
|
|
54
|
+
"""Persist the sign-in so the Authenticator recognises subsequent
|
|
55
|
+
requests. Called only after verify_credentials returned a Principal.
|
|
56
|
+
|
|
57
|
+
`response` is the redirect the visitor is about to receive, so a
|
|
58
|
+
cookie-backed implementation has something to set the cookie on. This
|
|
59
|
+
is the one place the Go and Python contracts differ: a *fiber.Ctx is
|
|
60
|
+
both request and response, while Starlette has no current response to
|
|
61
|
+
reach for.
|
|
62
|
+
|
|
63
|
+
Raise to report that the session could not be stored; the admin then
|
|
64
|
+
refuses the sign-in rather than telling the visitor they are signed in
|
|
65
|
+
when they are not.
|
|
66
|
+
"""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def end_session(self, request: Any, response: Any) -> None:
|
|
70
|
+
"""Clear it. Called by the logout route, and expected to succeed even when
|
|
71
|
+
there is no session to clear.
|
|
72
|
+
"""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def safe_next_url(next_url: str | None, base_path: str) -> str:
|
|
77
|
+
"""Guard the open redirect a `next` parameter opens if echoed into a Location
|
|
78
|
+
header unchecked: ?next=https://evil.example would have the admin's own
|
|
79
|
+
domain bounce the visitor somewhere hostile after a real login.
|
|
80
|
+
|
|
81
|
+
A destination must be a path inside this admin; anything else falls back to
|
|
82
|
+
base_path. Callers use the return value directly, so there is no "invalid"
|
|
83
|
+
signal to forget to check.
|
|
84
|
+
"""
|
|
85
|
+
if not next_url or not next_url.startswith("/"):
|
|
86
|
+
return base_path
|
|
87
|
+
# Scheme-relative ("//evil.example") is a URL, not a path, and
|
|
88
|
+
# browsers treat it as one.
|
|
89
|
+
if next_url[1:2] in ("/", "\\"):
|
|
90
|
+
return base_path
|
|
91
|
+
# A backslash anywhere is rejected rather than normalised: some
|
|
92
|
+
# browsers fold it to a forward slash, so "/\evil.example" escapes the
|
|
93
|
+
# checks above.
|
|
94
|
+
if "\\" in next_url:
|
|
95
|
+
return base_path
|
|
96
|
+
if not _is_under_base_path(next_url, base_path):
|
|
97
|
+
return base_path
|
|
98
|
+
return next_url
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_under_base_path(path: str, base_path: str) -> bool:
|
|
102
|
+
"""Whether path is base_path or sits beneath it. The boundary check matters:
|
|
103
|
+
"/adminutes" starts with "/admin" as a string but is a different route.
|
|
104
|
+
"""
|
|
105
|
+
trimmed = base_path.rstrip("/")
|
|
106
|
+
if trimmed in ("", "/"):
|
|
107
|
+
return True
|
|
108
|
+
if not path.startswith(trimmed):
|
|
109
|
+
return False
|
|
110
|
+
return len(path) == len(trimmed) or path[len(trimmed)] in ("/", "?")
|