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.
Files changed (104) hide show
  1. polyadmin/__init__.py +47 -0
  2. polyadmin/core/__init__.py +0 -0
  3. polyadmin/core/_async.py +21 -0
  4. polyadmin/core/action.py +135 -0
  5. polyadmin/core/admin.py +129 -0
  6. polyadmin/core/audit.py +65 -0
  7. polyadmin/core/auth.py +52 -0
  8. polyadmin/core/authorization.py +48 -0
  9. polyadmin/core/csrf.py +70 -0
  10. polyadmin/core/dashboard.py +41 -0
  11. polyadmin/core/delete.py +146 -0
  12. polyadmin/core/exporter.py +136 -0
  13. polyadmin/core/field.py +201 -0
  14. polyadmin/core/filter.py +305 -0
  15. polyadmin/core/inline.py +97 -0
  16. polyadmin/core/login.py +110 -0
  17. polyadmin/core/model_admin.py +349 -0
  18. polyadmin/core/page.py +62 -0
  19. polyadmin/core/pagination.py +70 -0
  20. polyadmin/core/query.py +243 -0
  21. polyadmin/core/relation.py +40 -0
  22. polyadmin/core/slug.py +57 -0
  23. polyadmin/core/template_context.py +679 -0
  24. polyadmin/core/widget.py +280 -0
  25. polyadmin/fastapi/__init__.py +3 -0
  26. polyadmin/fastapi/audit.py +52 -0
  27. polyadmin/fastapi/auth.py +112 -0
  28. polyadmin/fastapi/csrf.py +93 -0
  29. polyadmin/fastapi/deletes.py +76 -0
  30. polyadmin/fastapi/errors.py +93 -0
  31. polyadmin/fastapi/handlers.py +798 -0
  32. polyadmin/fastapi/inlines.py +177 -0
  33. polyadmin/fastapi/locale.py +128 -0
  34. polyadmin/fastapi/login.py +110 -0
  35. polyadmin/fastapi/pages.py +97 -0
  36. polyadmin/fastapi/relations.py +264 -0
  37. polyadmin/fastapi/responses.py +55 -0
  38. polyadmin/fastapi/router.py +174 -0
  39. polyadmin/fastapi/static.py +21 -0
  40. polyadmin/i18n/__init__.py +50 -0
  41. polyadmin/i18n/context.py +54 -0
  42. polyadmin/i18n/negotiation.py +56 -0
  43. polyadmin/i18n/setup.py +81 -0
  44. polyadmin/i18n/translator.py +124 -0
  45. polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
  46. polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
  47. polyadmin/locale/polyadmin.pot +485 -0
  48. polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
  49. polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
  50. polyadmin/templates/admin/base.html +91 -0
  51. polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
  52. polyadmin/templates/admin/components/csrf-field.html +5 -0
  53. polyadmin/templates/admin/components/error_fragment.html +6 -0
  54. polyadmin/templates/admin/components/field.html +60 -0
  55. polyadmin/templates/admin/components/form_wrapper.html +131 -0
  56. polyadmin/templates/admin/components/icons.html +67 -0
  57. polyadmin/templates/admin/components/inline.html +251 -0
  58. polyadmin/templates/admin/components/inline_fragment.html +2 -0
  59. polyadmin/templates/admin/components/list_content.html +54 -0
  60. polyadmin/templates/admin/components/lookup_results.html +19 -0
  61. polyadmin/templates/admin/components/search.html +19 -0
  62. polyadmin/templates/admin/components/toasts.html +151 -0
  63. polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
  64. polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
  65. polyadmin/templates/admin/components/ui/calendar.html +175 -0
  66. polyadmin/templates/admin/components/ui/combobox.html +82 -0
  67. polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
  68. polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
  69. polyadmin/templates/admin/components/ui/field.html +110 -0
  70. polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
  71. polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
  72. polyadmin/templates/admin/components/ui/multi-select.html +253 -0
  73. polyadmin/templates/admin/components/ui/pagination.html +81 -0
  74. polyadmin/templates/admin/components/ui/radio-group.html +28 -0
  75. polyadmin/templates/admin/components/ui/select.html +165 -0
  76. polyadmin/templates/admin/components/ui/sidebar.html +175 -0
  77. polyadmin/templates/admin/components/ui/slider.html +22 -0
  78. polyadmin/templates/admin/components/ui/switch.html +36 -0
  79. polyadmin/templates/admin/components/ui/table.html +221 -0
  80. polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
  81. polyadmin/templates/admin/dashboard.html +35 -0
  82. polyadmin/templates/admin/error.html +33 -0
  83. polyadmin/templates/admin/login.html +94 -0
  84. polyadmin/templates/admin/resource/delete.html +29 -0
  85. polyadmin/templates/admin/resource/delete_selected.html +49 -0
  86. polyadmin/templates/admin/resource/detail.html +78 -0
  87. polyadmin/templates/admin/resource/form.html +5 -0
  88. polyadmin/templates/admin/resource/list.html +5 -0
  89. polyadmin/templates/admin/theme.html +372 -0
  90. polyadmin/templates/admin/widgets/activity.html +8 -0
  91. polyadmin/templates/admin/widgets/chart.html +15 -0
  92. polyadmin/templates/admin/widgets/donut.html +59 -0
  93. polyadmin/templates/admin/widgets/metric.html +1 -0
  94. polyadmin/templates/admin/widgets/progress.html +7 -0
  95. polyadmin/templates/admin/widgets/stat.html +22 -0
  96. polyadmin/templates/admin/widgets/table.html +29 -0
  97. polyadmin/templates/admin/widgets/tabs.html +34 -0
  98. polyadmin/templates/admin/widgets/timeline.html +21 -0
  99. polyadmin/templating.py +528 -0
  100. polyadmin/ui.py +817 -0
  101. polyadmin-0.1.0b1.dist-info/METADATA +239 -0
  102. polyadmin-0.1.0b1.dist-info/RECORD +104 -0
  103. polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
  104. polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
@@ -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,3 @@
1
+ from polyadmin.fastapi.router import create_router
2
+
3
+ __all__ = ["create_router"]
@@ -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)