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,528 @@
1
+ """Renderer: turns a ModelAdmin plus data into an HTML page.
2
+
3
+ Templates resolve in three levels: an explicit override, then the application
4
+ directories in the order given, then this package's own. `render_list_fragment`
5
+ and `render_form_fragment` render the inner region alone for htmx swaps,
6
+ sharing the full page's TemplateContext builder so the two never drift apart.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ from collections.abc import Iterable, Sequence
13
+ from dataclasses import dataclass
14
+ from datetime import date, datetime
15
+ from decimal import Decimal
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import jinja2
20
+
21
+ from polyadmin.core.admin import Admin
22
+ from polyadmin.core.inline import INLINE_MULTISELECT_ROWS, Inline
23
+ from polyadmin.core.model_admin import ModelAdmin
24
+ from polyadmin.core.pagination import Page
25
+ from polyadmin.core.query import DEFAULT_EMPTY_VALUE, ListRequest
26
+ from polyadmin.core.template_context import (
27
+ dashboard_context,
28
+ delete_context,
29
+ delete_selected_context,
30
+ detail_context,
31
+ form_context,
32
+ list_context,
33
+ )
34
+ from polyadmin.i18n import I18n, LocaleOption, get_locale, gettext
35
+ from polyadmin.ui import ui
36
+
37
+ # Imported lazily, not at module level: polyadmin.fastapi.__init__ imports
38
+ # router -> handlers -> Renderer, so an eager import here would close the
39
+ # cycle.
40
+
41
+ FRAMEWORK_TEMPLATES_DIR = Path(__file__).parent / "templates"
42
+
43
+ _ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
44
+ _ISO_DATETIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$")
45
+
46
+
47
+ def iso_date(value: Any) -> str | None:
48
+ """YYYY-MM-DD, the only form the browser-side formatter reads for a date."""
49
+ if isinstance(value, datetime):
50
+ return value.date().isoformat()
51
+ if isinstance(value, date):
52
+ return value.isoformat()
53
+ if isinstance(value, str) and _ISO_DATE.match(value):
54
+ return value
55
+ return None
56
+
57
+
58
+ def iso_datetime(value: Any) -> str | None:
59
+ """ISO 8601 with its offset when aware; a naive value stays naive and is
60
+ shown as wall-clock time.
61
+ """
62
+ if isinstance(value, datetime):
63
+ return value.isoformat(timespec="seconds")
64
+ if isinstance(value, str) and _ISO_DATETIME.match(value):
65
+ return value
66
+ return None
67
+
68
+
69
+ def form_value(value: Any, field_type: str) -> str:
70
+ """A field's value as its form control's value attribute.
71
+
72
+ A date or datetime-local input discards anything but its own ISO form
73
+ -- str() of an aware datetime carries an offset and a space -- so a
74
+ date is written as YYYY-MM-DD and a datetime as YYYY-MM-DDTHH:MM, its
75
+ wall-clock time, as the input shows it. None is empty; anything else
76
+ (a string posted back after a validation error) is shown as given.
77
+ """
78
+ if value is None:
79
+ return ""
80
+ if field_type == "date" and isinstance(value, date):
81
+ return (value.date() if isinstance(value, datetime) else value).isoformat()
82
+ if field_type == "datetime" and isinstance(value, datetime):
83
+ # isoformat, not strftime: glibc's %Y does not zero-pad years below 1000.
84
+ return value.replace(tzinfo=None).isoformat(timespec="minutes")
85
+ return str(value)
86
+
87
+
88
+ def decimal_display(value: Any) -> str:
89
+ """Fixed-point text for a decimal value -- never the scientific notation
90
+ plain str() can fall into (a Decimal built from scientific-notation
91
+ input, or any sufficiently large/small float), which would throw off
92
+ the browser-side formatter's fraction-digit count.
93
+
94
+ A Decimal is shown exactly as the host gave it -- format(value, "f")
95
+ only changes notation, never precision, so a host's Decimal("12.50")
96
+ keeps its two places. A float carries no such intended precision (it
97
+ is what Field.parse_form_value hands back for the "decimal" field
98
+ type -- see core/field.py), so it is shown with the shortest digit
99
+ string that round-trips back to the same float, in fixed-point, with
100
+ no trailing ".0" -- the same contract as Go's decimalText
101
+ (fiber/render_helpers.go), which uses strconv.FormatFloat(f, 'f', -1,
102
+ 64). Anything else (an int) falls back to plain str().
103
+ """
104
+ if isinstance(value, Decimal):
105
+ return format(value, "f")
106
+ if isinstance(value, float):
107
+ return _shortest_fixed_point(value)
108
+ return str(value)
109
+
110
+
111
+ def _shortest_fixed_point(value: float) -> str:
112
+ """The shortest decimal string that round-trips back to `value`, in
113
+ fixed-point notation, with no forced trailing zero.
114
+
115
+ repr(value) is Python's own shortest round-tripping form, but it can
116
+ be scientific notation for a large/small magnitude (repr(1e21) ==
117
+ "1e+21"); routing it through Decimal expands that to fixed-point
118
+ without losing or adding digits. What Decimal's own "f" format does
119
+ add back is a trailing ".0" for a whole number (Decimal("1E+2") formats
120
+ as "100", but Decimal("100.0") -- what repr(100.0) produces -- formats
121
+ as "100.0"), so a fractional part of all zeros is stripped afterwards.
122
+ """
123
+ text = format(Decimal(repr(value)), "f")
124
+ if "." in text:
125
+ text = text.rstrip("0").rstrip(".")
126
+ return text
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class LocaleSwitcher:
131
+ """The language switcher's data; None when it is off."""
132
+
133
+ options: list[LocaleOption]
134
+ current: str
135
+
136
+
137
+ class Renderer:
138
+ def __init__(
139
+ self,
140
+ *,
141
+ template_dirs: Iterable[str | Path] = (),
142
+ i18n: I18n | None = None,
143
+ switcher_enabled: bool = False,
144
+ ) -> None:
145
+ self.switcher_enabled = switcher_enabled
146
+ self.i18n = i18n or I18n.from_admin(Admin())
147
+ search_dirs = [str(d) for d in template_dirs] + [str(FRAMEWORK_TEMPLATES_DIR)]
148
+ loader = jinja2.FileSystemLoader(search_dirs)
149
+ # One environment per locale: the i18n extension installs gettext
150
+ # per environment, and the request's locale picks one (see `env`).
151
+ self._envs = {locale: self._make_env(loader, locale) for locale in self.i18n.supported}
152
+
153
+ def _make_env(self, loader: jinja2.BaseLoader, locale: str) -> jinja2.Environment:
154
+ env = jinja2.Environment(
155
+ loader=loader,
156
+ autoescape=jinja2.select_autoescape(["html"]),
157
+ trim_blocks=True,
158
+ lstrip_blocks=True,
159
+ extensions=["jinja2.ext.i18n"],
160
+ )
161
+ translator = self.i18n.translator
162
+
163
+ # Our own callables, not Jinja's newstyle wrappers: those mark a
164
+ # translation as Markup, which let a host string through `_()`
165
+ # unescaped, and always %-format, which broke on a label containing
166
+ # "%". These return plain text, so autoescape escapes it at output
167
+ # and `|tojson` sees the raw string. `_()` formats only when given
168
+ # arguments -- a literal % needs %% only then, as with Go's t.
169
+ def translate(message: str, **variables: Any) -> str:
170
+ text = str(translator.gettext(locale, message))
171
+ return text % variables if variables else text
172
+
173
+ def translate_plural(singular: str, plural: str, num: int, **variables: Any) -> str:
174
+ variables.setdefault("num", num)
175
+ return str(translator.ngettext(locale, singular, plural, num)) % variables
176
+
177
+ # newstyle=False installs them as they are. `_` is the extension's
178
+ # alias for `gettext`; pgettext/npgettext are left unset (no template
179
+ # uses them), as is `{% trans %}`, which nothing uses either.
180
+ env.install_gettext_callables(translate, translate_plural, newstyle=False)
181
+ # A global rather than a per-template import, so overrides and
182
+ # custom page/widget templates style with the same vocabulary for
183
+ # free.
184
+ env.globals["ui"] = ui
185
+ # The macro default in components/field.html.
186
+ env.globals["DEFAULT_EMPTY_VALUE"] = DEFAULT_EMPTY_VALUE
187
+ # How many options a tabular inline's listbox shows before it
188
+ # scrolls. Four keeps the row near the height of the controls
189
+ # beside it.
190
+ env.globals["INLINE_MULTISELECT_ROWS"] = INLINE_MULTISELECT_ROWS
191
+ env.globals["locale"] = locale
192
+ env.globals["locale_switcher"] = self._switcher(locale)
193
+ env.filters["iso_date"] = iso_date
194
+ env.filters["iso_datetime"] = iso_datetime
195
+ env.filters["decimal_display"] = decimal_display
196
+ env.filters["form_value"] = form_value
197
+ return env
198
+
199
+ def _switcher(self, locale: str) -> LocaleSwitcher | None:
200
+ # Set by create_router from Admin.locale_switcher; a bare Renderer
201
+ # (tests) shows none.
202
+ if not self.switcher_enabled or len(self.i18n.supported) < 2:
203
+ return None
204
+ return LocaleSwitcher(options=self.i18n.options(), current=locale)
205
+
206
+ @property
207
+ def env(self) -> jinja2.Environment:
208
+ """The environment for the current request's locale."""
209
+ return self._envs.get(get_locale()) or self._envs[self.i18n.default]
210
+
211
+ def render(self, template_name: str, context: dict[str, Any]) -> str:
212
+ return self.env.get_template(template_name).render(**context)
213
+
214
+ def render_candidates(self, candidates: Sequence[str], context: dict[str, Any]) -> str:
215
+ return self.env.select_template(candidates).render(**context)
216
+
217
+ def render_list(
218
+ self,
219
+ admin: Admin,
220
+ model_admin: ModelAdmin,
221
+ page: Page,
222
+ *,
223
+ list_request: ListRequest | None = None,
224
+ permissions: dict[str, bool] | None = None,
225
+ relation_permissions: dict[str, bool] | None = None,
226
+ base_path: str = "/admin",
227
+ messages: list[dict[str, Any]] | None = None,
228
+ principal: Any = None,
229
+ csrf_token: str = "",
230
+ relation_filter_choices: dict[str, Any] | None = None,
231
+ ) -> str:
232
+ context = list_context(
233
+ admin,
234
+ model_admin,
235
+ page,
236
+ list_request=list_request,
237
+ permissions=permissions,
238
+ relation_permissions=relation_permissions,
239
+ base_path=base_path,
240
+ messages=messages,
241
+ principal=principal,
242
+ csrf_token=csrf_token,
243
+ relation_filter_choices=relation_filter_choices,
244
+ )
245
+ return self.render_candidates(model_admin.get_template_candidates("list"), context)
246
+
247
+ def render_list_fragment(
248
+ self,
249
+ admin: Admin,
250
+ model_admin: ModelAdmin,
251
+ page: Page,
252
+ *,
253
+ list_request: ListRequest | None = None,
254
+ permissions: dict[str, bool] | None = None,
255
+ relation_permissions: dict[str, bool] | None = None,
256
+ base_path: str = "/admin",
257
+ principal: Any = None,
258
+ csrf_token: str = "",
259
+ relation_filter_choices: dict[str, Any] | None = None,
260
+ ) -> str:
261
+ context = list_context(
262
+ admin,
263
+ model_admin,
264
+ page,
265
+ list_request=list_request,
266
+ permissions=permissions,
267
+ relation_permissions=relation_permissions,
268
+ base_path=base_path,
269
+ principal=principal,
270
+ csrf_token=csrf_token,
271
+ relation_filter_choices=relation_filter_choices,
272
+ )
273
+ return self.render("admin/components/list_content.html", context)
274
+
275
+ def render_detail(
276
+ self,
277
+ admin: Admin,
278
+ model_admin: ModelAdmin,
279
+ obj: Any,
280
+ *,
281
+ principal: Any = None,
282
+ csrf_token: str = "",
283
+ permissions: dict[str, bool] | None = None,
284
+ relation_permissions: dict[str, bool] | None = None,
285
+ base_path: str = "/admin",
286
+ messages: list[dict[str, Any]] | None = None,
287
+ list_token: str = "",
288
+ inlines: list[dict[str, Any]] | None = None,
289
+ ) -> str:
290
+ context = detail_context(
291
+ admin,
292
+ model_admin,
293
+ obj,
294
+ permissions=permissions,
295
+ relation_permissions=relation_permissions,
296
+ base_path=base_path,
297
+ messages=messages,
298
+ principal=principal,
299
+ csrf_token=csrf_token,
300
+ list_token=list_token,
301
+ )
302
+ context["inlines"] = inlines or []
303
+ context["wide_body"] = _wide_body(context["inlines"])
304
+ context["history"] = _history_for(admin, model_admin, obj)
305
+ return self.render_candidates(model_admin.get_template_candidates("detail"), context)
306
+
307
+ def render_form(
308
+ self,
309
+ admin: Admin,
310
+ model_admin: ModelAdmin,
311
+ *,
312
+ principal: Any = None,
313
+ csrf_token: str = "",
314
+ obj: Any | None = None,
315
+ data: dict[str, Any] | None = None,
316
+ errors: dict[str, list[str]] | None = None,
317
+ non_field_errors: list[str] | None = None,
318
+ relation_options: dict[str, list[tuple[Any, Any]]] | None = None,
319
+ base_path: str = "/admin",
320
+ messages: list[dict[str, Any]] | None = None,
321
+ list_token: str = "",
322
+ inlines: list[dict[str, Any]] | None = None,
323
+ ) -> str:
324
+ from polyadmin.fastapi.auth import compute_permissions
325
+
326
+ context = form_context(
327
+ admin,
328
+ model_admin,
329
+ obj=obj,
330
+ data=data,
331
+ errors=errors,
332
+ non_field_errors=non_field_errors,
333
+ relation_options=relation_options,
334
+ permissions=compute_permissions(admin, principal, model_admin, obj),
335
+ base_path=base_path,
336
+ messages=messages,
337
+ principal=principal,
338
+ csrf_token=csrf_token,
339
+ list_token=list_token,
340
+ )
341
+ context["inlines"] = inlines or []
342
+ context["wide_body"] = _wide_body(context["inlines"])
343
+ return self.render_candidates(model_admin.get_template_candidates("form"), context)
344
+
345
+ def render_form_fragment(
346
+ self,
347
+ admin: Admin,
348
+ model_admin: ModelAdmin,
349
+ *,
350
+ principal: Any = None,
351
+ csrf_token: str = "",
352
+ obj: Any | None = None,
353
+ data: dict[str, Any] | None = None,
354
+ errors: dict[str, list[str]] | None = None,
355
+ non_field_errors: list[str] | None = None,
356
+ relation_options: dict[str, list[tuple[Any, Any]]] | None = None,
357
+ base_path: str = "/admin",
358
+ inlines: list[dict[str, Any]] | None = None,
359
+ ) -> str:
360
+ from polyadmin.fastapi.auth import compute_permissions
361
+
362
+ context = form_context(
363
+ admin,
364
+ model_admin,
365
+ obj=obj,
366
+ data=data,
367
+ errors=errors,
368
+ non_field_errors=non_field_errors,
369
+ relation_options=relation_options,
370
+ permissions=compute_permissions(admin, principal, model_admin, obj),
371
+ base_path=base_path,
372
+ principal=principal,
373
+ csrf_token=csrf_token,
374
+ )
375
+ context["inlines"] = inlines or []
376
+ return self.render("admin/components/form_wrapper.html", context)
377
+
378
+ def render_inline_fragment(
379
+ self,
380
+ admin: Admin,
381
+ inline: Inline,
382
+ sections: list[dict[str, Any]],
383
+ *,
384
+ base_path: str = "/admin",
385
+ refusal: dict[str, Any] | None = None,
386
+ ) -> str:
387
+ """Renders one inline section standalone: the response body for the inline
388
+ create/update/delete routes, swapped into `#inline-{child_slug}` by
389
+ htmx. `sections` is the parent's awaited inline context (built in edit mode,
390
+ with any redisplay already applied); `refusal` explains a Remove the
391
+ child's delete_preview blocked.
392
+ """
393
+ section = next(s for s in sections if s["slug"] == inline.child)
394
+ return self.render(
395
+ "admin/components/inline_fragment.html",
396
+ {"admin": admin, "base_path": base_path, "inline": section, "refusal": refusal},
397
+ )
398
+
399
+ def render_dashboard(
400
+ self,
401
+ admin: Admin,
402
+ dashboard: Any,
403
+ widgets: list[Any],
404
+ *,
405
+ base_path: str = "/admin",
406
+ messages: list[dict[str, Any]] | None = None,
407
+ principal: Any = None,
408
+ csrf_token: str = "",
409
+ ) -> str:
410
+ context = dashboard_context(admin, dashboard, widgets, base_path=base_path, messages=messages, principal=principal, csrf_token=csrf_token)
411
+ return self.render("admin/dashboard.html", context)
412
+
413
+ def render_login(
414
+ self,
415
+ admin: Admin,
416
+ *,
417
+ csrf_token: str = "",
418
+ identifier: str = "",
419
+ error: str = "",
420
+ notice: str = "",
421
+ base_path: str = "",
422
+ ) -> str:
423
+ """The login page. Deliberately not built on base_context: there is no
424
+ principal (that is the point), no nav, and no trail to sit in.
425
+ """
426
+ return self.render(
427
+ "admin/login.html",
428
+ {
429
+ "csrf_token": csrf_token,
430
+ "site_title": admin.site_title,
431
+ "site_logo_url": admin.site_logo_url,
432
+ "identifier": identifier,
433
+ "error": error,
434
+ "notice": notice,
435
+ "base_path": base_path,
436
+ },
437
+ )
438
+
439
+ def render_lookup(self, options: list[tuple[Any, Any]]) -> str:
440
+ return self.render("admin/components/lookup_results.html", {"options": options})
441
+
442
+ def render_delete(
443
+ self,
444
+ admin: Admin,
445
+ model_admin: ModelAdmin,
446
+ obj: Any,
447
+ *,
448
+ base_path: str = "/admin",
449
+ messages: list[dict[str, Any]] | None = None,
450
+ principal: Any = None,
451
+ csrf_token: str = "",
452
+ preview: Any = None,
453
+ list_token: str = "",
454
+ ) -> str:
455
+ context = delete_context(admin, model_admin, obj, base_path=base_path, messages=messages, principal=principal, csrf_token=csrf_token, preview=preview, list_token=list_token)
456
+ return self.render_candidates(model_admin.get_template_candidates("delete"), context)
457
+
458
+ def render_delete_selected(
459
+ self,
460
+ admin: Admin,
461
+ model_admin: ModelAdmin,
462
+ selection: dict[str, Any],
463
+ preview: Any,
464
+ *,
465
+ base_path: str = "/admin",
466
+ principal: Any = None,
467
+ csrf_token: str = "",
468
+ ) -> str:
469
+ context = delete_selected_context(admin, model_admin, selection, preview, base_path=base_path, principal=principal, csrf_token=csrf_token)
470
+ return self.render_candidates(model_admin.get_template_candidates("delete_selected"), context)
471
+
472
+
473
+ # A summary of recent activity, not an audit browser: a logger wanting the
474
+ # full trail exposed can surface it where it already lives.
475
+ HISTORY_LIMIT = 10
476
+
477
+
478
+ def _wide_body(inlines) -> bool:
479
+ """Whether any inline section is tabular. A table needs more than the max-w-xl
480
+ a column of form fields wants -- squeezing one in is what clipped its row
481
+ actions -- so such a page gets ui("page", "body-wide").
482
+ """
483
+ return any(inline.get("layout") == "tabular" for inline in inlines or [])
484
+
485
+
486
+ def _audit_action(action: str) -> str:
487
+ """How the History panel names what happened. The framework's own verbs are
488
+ translated; anything else is the name of the Action that ran, an
489
+ identifier stored in the log, and stays as it is.
490
+ """
491
+ from polyadmin.core.audit import AUDIT_CREATE, AUDIT_DELETE, AUDIT_UPDATE
492
+
493
+ verbs = {AUDIT_CREATE: gettext("create"), AUDIT_UPDATE: gettext("update"), AUDIT_DELETE: gettext("delete")}
494
+ return verbs.get(action, action)
495
+
496
+
497
+ def _history_for(admin: Any, model_admin: Any, obj: Any) -> list[dict[str, str]]:
498
+ """The record's recent audit entries, or [] when no logger is configured or
499
+ the one configured cannot read back. Built in the request's locale:
500
+ "system" and the framework's verbs are translated here; who did it, and
501
+ when, are data.
502
+ """
503
+ from polyadmin.core.audit import AuditReader
504
+
505
+ reader = admin.audit_logger
506
+ if reader is None or not isinstance(reader, AuditReader):
507
+ return []
508
+ try:
509
+ entries = reader.history(model_admin.get_slug(), model_admin.get_pk(obj), HISTORY_LIMIT)
510
+ except Exception as exc: # noqa: BLE001
511
+ # A history panel is not worth failing a page render over.
512
+ logging.getLogger("polyadmin").warning(
513
+ "audit history unavailable for %s: %s", model_admin.get_slug(), exc
514
+ )
515
+ return []
516
+ rendered = []
517
+ system = gettext("system")
518
+ for entry in entries:
519
+ who = system
520
+ principal = entry.principal
521
+ if principal is not None:
522
+ who = getattr(principal, "display_name", None) or str(
523
+ getattr(principal, "id", "") or system
524
+ )
525
+ rendered.append(
526
+ {"when": entry.at.strftime("%Y-%m-%d %H:%M"), "who": who, "what": _audit_action(entry.action)}
527
+ )
528
+ return rendered