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,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
@@ -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
@@ -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 ("/", "?")