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/widget.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Widget: a single dashboard tile.
|
|
2
|
+
|
|
3
|
+
Each type computes its own data via `get_data()` and names the template that
|
|
4
|
+
renders it, so a custom widget is a subclass pointing `template` at its own
|
|
5
|
+
file -- no framework change required. Every widget takes either a static value
|
|
6
|
+
or a `get_*` callable, so an application can wire in live data without the
|
|
7
|
+
widget caring where it came from.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Widget:
|
|
16
|
+
template = "admin/widgets/widget.html"
|
|
17
|
+
|
|
18
|
+
def __init__(self, title: str, *, size: str = "md", permission: str | None = None) -> None:
|
|
19
|
+
self.title = title
|
|
20
|
+
self.size = size
|
|
21
|
+
self.permission = permission
|
|
22
|
+
|
|
23
|
+
def get_data(self) -> Any:
|
|
24
|
+
raise NotImplementedError(f"{type(self).__name__} must implement get_data().")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Metric(Widget):
|
|
28
|
+
"""A single headline number, e.g. "1,204 users"."""
|
|
29
|
+
|
|
30
|
+
template = "admin/widgets/metric.html"
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self, title: str, *, value: Any = None, get_value: Callable[[], Any] | None = None, **kwargs: Any
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(title, **kwargs)
|
|
36
|
+
self._value = value
|
|
37
|
+
self._get_value = get_value
|
|
38
|
+
|
|
39
|
+
def get_data(self) -> dict[str, Any]:
|
|
40
|
+
value = self._get_value() if self._get_value is not None else self._value
|
|
41
|
+
return {"value": value}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Stat(Widget):
|
|
45
|
+
"""A headline number paired with its change against the previous period.
|
|
46
|
+
Metric answers "what is it now?"; Stat also answers "which way is it
|
|
47
|
+
moving?".
|
|
48
|
+
|
|
49
|
+
`delta` is the signed percentage change. Up is assumed good; for an
|
|
50
|
+
inverted metric such as an error rate, negate the delta and say so in the
|
|
51
|
+
title.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
template = "admin/widgets/stat.html"
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
title: str,
|
|
59
|
+
*,
|
|
60
|
+
value: Any = None,
|
|
61
|
+
delta: float = 0.0,
|
|
62
|
+
get_stat: Callable[[], tuple[Any, float]] | None = None,
|
|
63
|
+
**kwargs: Any,
|
|
64
|
+
) -> None:
|
|
65
|
+
super().__init__(title, **kwargs)
|
|
66
|
+
self._value = value
|
|
67
|
+
self._delta = delta
|
|
68
|
+
self._get_stat = get_stat
|
|
69
|
+
|
|
70
|
+
def get_data(self) -> dict[str, Any]:
|
|
71
|
+
value, delta = (
|
|
72
|
+
self._get_stat() if self._get_stat is not None else (self._value, self._delta)
|
|
73
|
+
)
|
|
74
|
+
# The template branches on `direction`, not the sign of `delta`,
|
|
75
|
+
# keeping the arrow and colour choice out of the markup. `delta`
|
|
76
|
+
# is reported unsigned, since the arrow carries the direction.
|
|
77
|
+
direction = "up" if delta > 0 else "down" if delta < 0 else "flat"
|
|
78
|
+
return {"value": value, "delta": round(abs(delta), 1), "direction": direction}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Progress(Widget):
|
|
82
|
+
"""A value against a target, e.g. "42 / 100 tasks complete"."""
|
|
83
|
+
|
|
84
|
+
template = "admin/widgets/progress.html"
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
title: str,
|
|
89
|
+
*,
|
|
90
|
+
value: float = 0,
|
|
91
|
+
target: float = 100,
|
|
92
|
+
get_data: Callable[[], tuple[float, float]] | None = None,
|
|
93
|
+
**kwargs: Any,
|
|
94
|
+
) -> None:
|
|
95
|
+
super().__init__(title, **kwargs)
|
|
96
|
+
self._value = value
|
|
97
|
+
self._target = target
|
|
98
|
+
self._get_data = get_data
|
|
99
|
+
|
|
100
|
+
def get_data(self) -> dict[str, Any]:
|
|
101
|
+
value, target = self._get_data() if self._get_data is not None else (self._value, self._target)
|
|
102
|
+
percent = 0 if target <= 0 else min(100, round(value / target * 100))
|
|
103
|
+
return {"value": value, "target": target, "percent": percent}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Table(Widget):
|
|
107
|
+
"""Small tabular data: columns + rows (each row a dict keyed by column)."""
|
|
108
|
+
|
|
109
|
+
template = "admin/widgets/table.html"
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
title: str,
|
|
114
|
+
*,
|
|
115
|
+
columns: Sequence[str] = (),
|
|
116
|
+
rows: Sequence[dict[str, Any]] = (),
|
|
117
|
+
get_rows: Callable[[], Sequence[dict[str, Any]]] | None = None,
|
|
118
|
+
**kwargs: Any,
|
|
119
|
+
) -> None:
|
|
120
|
+
super().__init__(title, **kwargs)
|
|
121
|
+
self.columns = list(columns)
|
|
122
|
+
self._rows = list(rows)
|
|
123
|
+
self._get_rows = get_rows
|
|
124
|
+
|
|
125
|
+
def get_data(self) -> dict[str, Any]:
|
|
126
|
+
rows = self._get_rows() if self._get_rows is not None else self._rows
|
|
127
|
+
return {"columns": self.columns, "rows": list(rows)}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Chart(Widget):
|
|
131
|
+
"""Labeled values rendered as simple CSS bars -- no charting-library
|
|
132
|
+
dependency, since the framework ships with none."""
|
|
133
|
+
|
|
134
|
+
template = "admin/widgets/chart.html"
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
title: str,
|
|
139
|
+
*,
|
|
140
|
+
series: Sequence[tuple[str, float]] = (),
|
|
141
|
+
get_series: Callable[[], Sequence[tuple[str, float]]] | None = None,
|
|
142
|
+
**kwargs: Any,
|
|
143
|
+
) -> None:
|
|
144
|
+
super().__init__(title, **kwargs)
|
|
145
|
+
self._series = list(series)
|
|
146
|
+
self._get_series = get_series
|
|
147
|
+
|
|
148
|
+
def get_data(self) -> dict[str, Any]:
|
|
149
|
+
series = list(self._get_series() if self._get_series is not None else self._series)
|
|
150
|
+
maximum = max((value for _, value in series), default=0) or 1
|
|
151
|
+
return {"series": [(label, value, round(value / maximum * 100)) for label, value in series]}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# The qualitative palette for Donut slices, spaced around the wheel so six
|
|
155
|
+
# categories stay distinguishable. They name theme.html's --chart-*
|
|
156
|
+
# variables rather than literal shades, so a Donut follows the active
|
|
157
|
+
# theme and is re-tuned for dark mode. No slice lands on the
|
|
158
|
+
# success/warning/danger hues, so it cannot be mistaken for a status.
|
|
159
|
+
_DONUT_COLORS = ("chart-1", "chart-2", "chart-3", "chart-4", "chart-5", "chart-6")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Donut(Widget):
|
|
163
|
+
"""A share-of-total breakdown drawn as an SVG ring with a legend, built from
|
|
164
|
+
<circle> arcs -- the same no-charting-library stance as Chart.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
template = "admin/widgets/donut.html"
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
title: str,
|
|
172
|
+
*,
|
|
173
|
+
series: Sequence[tuple[str, float]] = (),
|
|
174
|
+
get_series: Callable[[], Sequence[tuple[str, float]]] | None = None,
|
|
175
|
+
**kwargs: Any,
|
|
176
|
+
) -> None:
|
|
177
|
+
super().__init__(title, **kwargs)
|
|
178
|
+
self._series = list(series)
|
|
179
|
+
self._get_series = get_series
|
|
180
|
+
|
|
181
|
+
def get_data(self) -> dict[str, Any]:
|
|
182
|
+
series = list(self._get_series() if self._get_series is not None else self._series)
|
|
183
|
+
total = sum(value for _, value in series)
|
|
184
|
+
slices = []
|
|
185
|
+
cumulative = 0.0
|
|
186
|
+
for i, (label, value) in enumerate(series):
|
|
187
|
+
percent = 0.0 if total <= 0 else value / total * 100
|
|
188
|
+
slices.append(
|
|
189
|
+
{
|
|
190
|
+
"label": label,
|
|
191
|
+
"value": value,
|
|
192
|
+
"percent": round(percent, 1),
|
|
193
|
+
# A circle of circumference 100 (r=15.9155) lets
|
|
194
|
+
# stroke-dasharray take percentages directly. 25
|
|
195
|
+
# rotates the first slice to 12 o'clock; each later
|
|
196
|
+
# one is pushed by its predecessors' combined share.
|
|
197
|
+
"dash_offset": round(25 - cumulative, 4),
|
|
198
|
+
"color": _DONUT_COLORS[i % len(_DONUT_COLORS)],
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
cumulative += percent
|
|
202
|
+
return {"slices": slices, "total": total}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Activity(Widget):
|
|
206
|
+
"""A recent-activity feed: a list of short text entries."""
|
|
207
|
+
|
|
208
|
+
template = "admin/widgets/activity.html"
|
|
209
|
+
|
|
210
|
+
def __init__(
|
|
211
|
+
self,
|
|
212
|
+
title: str,
|
|
213
|
+
*,
|
|
214
|
+
entries: Sequence[str] = (),
|
|
215
|
+
get_entries: Callable[[], Sequence[str]] | None = None,
|
|
216
|
+
**kwargs: Any,
|
|
217
|
+
) -> None:
|
|
218
|
+
super().__init__(title, **kwargs)
|
|
219
|
+
self._entries = list(entries)
|
|
220
|
+
self._get_entries = get_entries
|
|
221
|
+
|
|
222
|
+
def get_data(self) -> dict[str, Any]:
|
|
223
|
+
entries = self._get_entries() if self._get_entries is not None else self._entries
|
|
224
|
+
return {"entries": list(entries)}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Timeline(Widget):
|
|
228
|
+
"""A vertical feed of dated events drawn as a rail of dots. Activity's flat
|
|
229
|
+
strings suit a short "who did what" list; Timeline is for entries needing a
|
|
230
|
+
timestamp and a body of their own.
|
|
231
|
+
|
|
232
|
+
Entries are `(time, title, description)` triples. `time` arrives already
|
|
233
|
+
formatted: the widget never parses or localizes it, so the application
|
|
234
|
+
controls how its timestamps read.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
template = "admin/widgets/timeline.html"
|
|
238
|
+
|
|
239
|
+
def __init__(
|
|
240
|
+
self,
|
|
241
|
+
title: str,
|
|
242
|
+
*,
|
|
243
|
+
entries: Sequence[tuple[str, str, str]] = (),
|
|
244
|
+
get_entries: Callable[[], Sequence[tuple[str, str, str]]] | None = None,
|
|
245
|
+
**kwargs: Any,
|
|
246
|
+
) -> None:
|
|
247
|
+
super().__init__(title, **kwargs)
|
|
248
|
+
self._entries = list(entries)
|
|
249
|
+
self._get_entries = get_entries
|
|
250
|
+
|
|
251
|
+
def get_data(self) -> dict[str, Any]:
|
|
252
|
+
entries = self._get_entries() if self._get_entries is not None else self._entries
|
|
253
|
+
return {
|
|
254
|
+
"entries": [
|
|
255
|
+
{"time": time, "title": title, "description": description}
|
|
256
|
+
for time, title, description in entries
|
|
257
|
+
]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class Tabs(Widget):
|
|
262
|
+
"""Several widgets stacked into one card, one visible at a time.
|
|
263
|
+
|
|
264
|
+
Panels are `(label, widget)` pairs. Tabs holds no data itself, and every
|
|
265
|
+
panel is computed on render rather than on first click, so a panel backed
|
|
266
|
+
by a slow query costs the same whether or not anyone opens it.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
template = "admin/widgets/tabs.html"
|
|
270
|
+
|
|
271
|
+
def __init__(
|
|
272
|
+
self, title: str, *, panels: Sequence[tuple[str, Widget]] = (), **kwargs: Any
|
|
273
|
+
) -> None:
|
|
274
|
+
super().__init__(title, **kwargs)
|
|
275
|
+
self.panels = list(panels)
|
|
276
|
+
|
|
277
|
+
def get_data(self) -> dict[str, Any]:
|
|
278
|
+
# Handed to the template, which renders each through the same `{%
|
|
279
|
+
# include widget.template %}` the dashboard uses.
|
|
280
|
+
return {"panels": [{"label": label, "widget": widget} for label, widget in self.panels]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Recording changes into the configured audit logger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from polyadmin.core.audit import AuditEntry
|
|
10
|
+
from polyadmin.core.model_admin import ModelAdmin
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("polyadmin")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def object_label(model_admin: ModelAdmin, obj: Any) -> str:
|
|
16
|
+
"""A short human label for an object, matching the breadcrumb rule:
|
|
17
|
+
the first search field, else the first list column, else the pk.
|
|
18
|
+
"""
|
|
19
|
+
names = list(model_admin.search_fields) or list(model_admin.list_display)
|
|
20
|
+
for name in names:
|
|
21
|
+
try:
|
|
22
|
+
return str(model_admin.get_field(name).get_value(obj))
|
|
23
|
+
except KeyError:
|
|
24
|
+
continue
|
|
25
|
+
return str(model_admin.get_pk(obj))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def record_audit(admin: Any, principal: Any, model_admin: ModelAdmin, action: str, obj: Any) -> None:
|
|
29
|
+
"""Write one entry, if a logger is configured at all.
|
|
30
|
+
|
|
31
|
+
Called after the change has already succeeded, so a logger error is
|
|
32
|
+
reported and dropped rather than raised: failing the request here
|
|
33
|
+
would show the user an error beside a change that did happen, which
|
|
34
|
+
is worse than a missing log line. The label is captured now because
|
|
35
|
+
the record may be gone by the time anyone reads the entry.
|
|
36
|
+
"""
|
|
37
|
+
if admin.audit_logger is None:
|
|
38
|
+
return
|
|
39
|
+
entry = AuditEntry(
|
|
40
|
+
at=datetime.now(timezone.utc),
|
|
41
|
+
principal=principal,
|
|
42
|
+
action=action,
|
|
43
|
+
resource=model_admin.get_slug(),
|
|
44
|
+
object_pk=model_admin.get_pk(obj) if obj is not None else None,
|
|
45
|
+
object_label=object_label(model_admin, obj) if obj is not None else "",
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
admin.audit_logger.record(entry)
|
|
49
|
+
except Exception as exc: # noqa: BLE001 -- see the docstring
|
|
50
|
+
logger.warning(
|
|
51
|
+
"audit log rejected a %s on %s: %s", action, model_admin.get_slug(), exc
|
|
52
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Authentication and authorization wiring for the FastAPI adapter. With no
|
|
2
|
+
authenticator or authorizer configured, these are no-ops: every request is
|
|
3
|
+
treated as authenticated and permitted.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from fastapi import Request
|
|
11
|
+
from fastapi.responses import Response
|
|
12
|
+
|
|
13
|
+
from polyadmin.core.admin import Admin
|
|
14
|
+
from polyadmin.core.authorization import resource_permission
|
|
15
|
+
from polyadmin.core.login import LOGIN_PATH, NEXT_QUERY_PARAM
|
|
16
|
+
from polyadmin.core.model_admin import ModelAdmin
|
|
17
|
+
from polyadmin.fastapi.errors import forbidden, unauthenticated
|
|
18
|
+
from polyadmin.fastapi.locale import acached_principal
|
|
19
|
+
from polyadmin.fastapi.responses import redirect
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def authorize(
|
|
23
|
+
admin: Admin, request: Request, base_path: str, permission: str, resource: Any = None
|
|
24
|
+
) -> tuple[Any, Response | None]:
|
|
25
|
+
"""Returns (principal, None) if the request may proceed, or (None,
|
|
26
|
+
error_response) if it was rejected.
|
|
27
|
+
|
|
28
|
+
Which answer the unauthenticated case gets depends on whether there is a
|
|
29
|
+
login page to offer: with a login_backend the browser is redirected there
|
|
30
|
+
carrying where it was going, without one 401 is the whole story. Forbidden
|
|
31
|
+
never redirects -- the visitor is signed in and simply may not do this, so
|
|
32
|
+
a login form would invite them to re-authenticate as the same person to the
|
|
33
|
+
same refusal.
|
|
34
|
+
"""
|
|
35
|
+
principal = None
|
|
36
|
+
if admin.authenticator is not None:
|
|
37
|
+
principal = await acached_principal(admin, request)
|
|
38
|
+
if principal is None:
|
|
39
|
+
if admin.login_backend is None:
|
|
40
|
+
return None, unauthenticated(request, admin, base_path)
|
|
41
|
+
# redirect(), not a bare 303: an expired session usually
|
|
42
|
+
# surfaces mid-page on an htmx request, where a 303 would be
|
|
43
|
+
# swapped in as content.
|
|
44
|
+
return None, redirect(request, login_url(base_path, _requested_url(request)))
|
|
45
|
+
|
|
46
|
+
if admin.authorizer is not None and not admin.authorizer.can(principal, permission, resource):
|
|
47
|
+
return None, forbidden(request, admin, base_path)
|
|
48
|
+
|
|
49
|
+
return principal, None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def login_url(base_path: str, next_url: str = "") -> str:
|
|
53
|
+
"""The path an unauthenticated visitor is sent to, carrying where
|
|
54
|
+
they were headed so signing in resumes it."""
|
|
55
|
+
target = f"{base_path}{LOGIN_PATH}"
|
|
56
|
+
if not next_url:
|
|
57
|
+
return target
|
|
58
|
+
return f"{target}?{NEXT_QUERY_PARAM}={quote(next_url, safe='')}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _requested_url(request: Request) -> str:
|
|
62
|
+
"""The path (with query) the current request was for -- what a
|
|
63
|
+
redirect to login should come back to."""
|
|
64
|
+
target = request.url.path
|
|
65
|
+
if request.url.query:
|
|
66
|
+
target += f"?{request.url.query}"
|
|
67
|
+
return target
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def authorize_object(admin: Admin, principal: Any, permission: str, obj: Any) -> bool:
|
|
71
|
+
"""Re-run a permission check with the loaded record as the resource, so an
|
|
72
|
+
Authorizer can answer "may this principal touch this record" and not only
|
|
73
|
+
"this model at all".
|
|
74
|
+
|
|
75
|
+
The narrower of two gates: the coarse check already ran before the record
|
|
76
|
+
was fetched, so an unauthorized principal never costs a lookup.
|
|
77
|
+
"""
|
|
78
|
+
if admin.authorizer is None:
|
|
79
|
+
return True
|
|
80
|
+
return admin.authorizer.can(principal, permission, obj)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compute_permissions(
|
|
84
|
+
admin: Admin, principal: Any, model_admin: ModelAdmin, obj: Any = None
|
|
85
|
+
) -> dict[str, bool]:
|
|
86
|
+
"""What the principal may do with this resource, combining the ModelAdmin's
|
|
87
|
+
static can_* toggles with the Authorizer's per-request decision. It decides
|
|
88
|
+
which controls the templates show; the routes enforce this independently,
|
|
89
|
+
so hiding a control is a UX nicety, not the security boundary.
|
|
90
|
+
"""
|
|
91
|
+
slug = model_admin.get_slug()
|
|
92
|
+
|
|
93
|
+
def allowed(capability: bool, action: str) -> bool:
|
|
94
|
+
if not capability:
|
|
95
|
+
return False
|
|
96
|
+
if admin.authorizer is None:
|
|
97
|
+
return True
|
|
98
|
+
# obj is the record in view, or None on a list or create page.
|
|
99
|
+
# When present it is what the authorizer is asked about, so per-
|
|
100
|
+
# object rules decide which controls that record's pages show.
|
|
101
|
+
resource = model_admin if obj is None else obj
|
|
102
|
+
return admin.authorizer.can(principal, resource_permission(slug, action), resource)
|
|
103
|
+
|
|
104
|
+
# Keys are "can_view" etc -- see default_permissions in
|
|
105
|
+
# template_context.py for why "update" alone is unsafe here.
|
|
106
|
+
return {
|
|
107
|
+
"can_view": allowed(model_admin.can_view, "view"),
|
|
108
|
+
"can_create": allowed(model_admin.can_create, "create"),
|
|
109
|
+
"can_update": allowed(model_admin.can_update, "update"),
|
|
110
|
+
"can_delete": allowed(model_admin.can_delete, "delete"),
|
|
111
|
+
"can_export": allowed(model_admin.can_export, "export"),
|
|
112
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Double-submit CSRF protection for the admin router.
|
|
2
|
+
|
|
3
|
+
Implemented as a custom APIRoute rather than a dependency: every handler
|
|
4
|
+
here returns an HTMLResponse or RedirectResponse *directly*, and FastAPI
|
|
5
|
+
does not merge a dependency's response headers into a response the
|
|
6
|
+
handler returned itself -- the cookie would be silently dropped. A route
|
|
7
|
+
class wraps the endpoint and post-processes the real response, which is
|
|
8
|
+
the idiomatic seam for this.
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
from fastapi import Request, Response
|
|
17
|
+
from fastapi.routing import APIRoute
|
|
18
|
+
|
|
19
|
+
from polyadmin.core.csrf import (
|
|
20
|
+
CSRF_COOKIE_NAME,
|
|
21
|
+
CSRF_FIELD_NAME,
|
|
22
|
+
CSRF_HEADER_NAME,
|
|
23
|
+
csrf_tokens_match,
|
|
24
|
+
is_safe_method,
|
|
25
|
+
new_csrf_token,
|
|
26
|
+
)
|
|
27
|
+
from polyadmin.fastapi.errors import csrf_failure
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_csrf_route(admin, base_path: str) -> type[APIRoute]:
|
|
31
|
+
"""Build the APIRoute subclass for one mounted admin."""
|
|
32
|
+
|
|
33
|
+
class CSRFRoute(APIRoute):
|
|
34
|
+
def get_route_handler(self) -> Callable:
|
|
35
|
+
original = super().get_route_handler()
|
|
36
|
+
|
|
37
|
+
async def handler(request: Request) -> Response:
|
|
38
|
+
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
|
39
|
+
token = cookie_token or new_csrf_token()
|
|
40
|
+
# Handlers and the renderer read the token from here.
|
|
41
|
+
request.state.csrf_token = token
|
|
42
|
+
|
|
43
|
+
if not admin.disable_csrf and not is_safe_method(request.method):
|
|
44
|
+
submitted = request.headers.get(CSRF_HEADER_NAME)
|
|
45
|
+
if not submitted:
|
|
46
|
+
# Starlette caches the parsed form, so the handler
|
|
47
|
+
# can still read it afterwards.
|
|
48
|
+
form = await request.form()
|
|
49
|
+
submitted = form.get(CSRF_FIELD_NAME)
|
|
50
|
+
# Compared against the cookie, not against `token`:
|
|
51
|
+
# `token` falls back to a freshly minted value, which
|
|
52
|
+
# an attacker could never have echoed back but which a
|
|
53
|
+
# confused client might. No cookie means no match.
|
|
54
|
+
if not csrf_tokens_match(submitted, cookie_token):
|
|
55
|
+
response = csrf_failure(request, admin, base_path)
|
|
56
|
+
_decorate(response, token, cookie_token, request, base_path)
|
|
57
|
+
return response
|
|
58
|
+
|
|
59
|
+
response = await original(request)
|
|
60
|
+
_decorate(response, token, cookie_token, request, base_path)
|
|
61
|
+
return response
|
|
62
|
+
|
|
63
|
+
return handler
|
|
64
|
+
|
|
65
|
+
return CSRFRoute
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _decorate(
|
|
69
|
+
response: Response,
|
|
70
|
+
token: str,
|
|
71
|
+
cookie_token: str | None,
|
|
72
|
+
request: Request,
|
|
73
|
+
base_path: str,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Set the token cookie (when new) and the clickjacking headers.
|
|
76
|
+
|
|
77
|
+
The headers are unconditional: framing is a different attack from
|
|
78
|
+
forgery, so the CSRF opt-out does not disable them.
|
|
79
|
+
"""
|
|
80
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
81
|
+
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
|
|
82
|
+
if cookie_token is None:
|
|
83
|
+
response.set_cookie(
|
|
84
|
+
CSRF_COOKIE_NAME,
|
|
85
|
+
token,
|
|
86
|
+
path=base_path,
|
|
87
|
+
httponly=True,
|
|
88
|
+
samesite="lax",
|
|
89
|
+
# Secure only over TLS: a Secure cookie is not sent over plain
|
|
90
|
+
# HTTP, which would break running the example on a LAN
|
|
91
|
+
# address. Behind a proxy this needs X-Forwarded-Proto.
|
|
92
|
+
secure=request.url.scheme == "https",
|
|
93
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""delete_selected's confirmation step when the ModelAdmin previews deletes
|
|
2
|
+
(docs/deletes.md)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
from fastapi.responses import HTMLResponse
|
|
10
|
+
|
|
11
|
+
from polyadmin.core.delete import (
|
|
12
|
+
DELETE_PREVIEW_SAMPLE,
|
|
13
|
+
resolve_delete_preview,
|
|
14
|
+
selection_fingerprint,
|
|
15
|
+
)
|
|
16
|
+
from polyadmin.core.template_context import _object_label
|
|
17
|
+
from polyadmin.fastapi.auth import compute_permissions
|
|
18
|
+
|
|
19
|
+
CONFIRMED_FIELD = "_confirmed"
|
|
20
|
+
FINGERPRINT_FIELD = "_fingerprint"
|
|
21
|
+
RETURN_FIELD = "_return"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def confirm_delete_selected(
|
|
25
|
+
request: Request,
|
|
26
|
+
form: Any,
|
|
27
|
+
admin: Any,
|
|
28
|
+
model_admin: Any,
|
|
29
|
+
renderer: Any,
|
|
30
|
+
principal: Any,
|
|
31
|
+
objects: list[Any],
|
|
32
|
+
select_all: bool,
|
|
33
|
+
return_to: str,
|
|
34
|
+
list_request: Any,
|
|
35
|
+
base_path: str,
|
|
36
|
+
) -> HTMLResponse | None:
|
|
37
|
+
"""Answer with the confirmation page (deleting nothing), or return None when
|
|
38
|
+
the confirmed, unchanged, unblocked selection may be deleted."""
|
|
39
|
+
fingerprint = selection_fingerprint(model_admin, objects)
|
|
40
|
+
confirmed = bool(form.get(CONFIRMED_FIELD))
|
|
41
|
+
changed = confirmed and select_all and form.get(FINGERPRINT_FIELD) != fingerprint
|
|
42
|
+
preview = resolve_delete_preview(admin, model_admin, principal, objects)
|
|
43
|
+
if confirmed and not changed and not preview.blocked:
|
|
44
|
+
return None
|
|
45
|
+
slug = model_admin.get_slug()
|
|
46
|
+
# Built here rather than in the context builder: the per-object view check
|
|
47
|
+
# lives in the adapter, and core must not import one.
|
|
48
|
+
items = [
|
|
49
|
+
{
|
|
50
|
+
"label": _object_label(model_admin, obj),
|
|
51
|
+
"url": f"{base_path}/{slug}/{model_admin.get_pk(obj)}"
|
|
52
|
+
if compute_permissions(admin, principal, model_admin, obj)["can_view"]
|
|
53
|
+
else None,
|
|
54
|
+
}
|
|
55
|
+
for obj in objects[:DELETE_PREVIEW_SAMPLE]
|
|
56
|
+
]
|
|
57
|
+
selection = {
|
|
58
|
+
"objects": objects,
|
|
59
|
+
"select_all": select_all,
|
|
60
|
+
"pks": [] if select_all else [str(model_admin.get_pk(obj)) for obj in objects],
|
|
61
|
+
"list_request": list_request,
|
|
62
|
+
"fingerprint": fingerprint,
|
|
63
|
+
"return_to": return_to,
|
|
64
|
+
"changed": changed,
|
|
65
|
+
"items": items,
|
|
66
|
+
}
|
|
67
|
+
html = renderer.render_delete_selected(
|
|
68
|
+
admin,
|
|
69
|
+
model_admin,
|
|
70
|
+
selection,
|
|
71
|
+
preview,
|
|
72
|
+
base_path=base_path,
|
|
73
|
+
principal=principal,
|
|
74
|
+
csrf_token=request.state.csrf_token,
|
|
75
|
+
)
|
|
76
|
+
return HTMLResponse(html)
|