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,349 @@
1
+ """ModelAdmin: the central per-resource abstraction.
2
+
3
+ Resource identity, field resolution, the CRUD lifecycle hooks, search, filters,
4
+ ordering, pagination, relations, actions, exports, and template resolution all
5
+ live here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Any, ClassVar
13
+
14
+ from polyadmin.core.action import DELETE_SELECTED_NAME, Action, action, collect_actions
15
+ from polyadmin.core.auth import Principal
16
+ from polyadmin.core.field import Field
17
+ from polyadmin.core.inline import Inline
18
+ from polyadmin.core.query import DEFAULT_EMPTY_VALUE
19
+ from polyadmin.i18n import N_, gettext, ngettext
20
+
21
+
22
+ @dataclass
23
+ class Fieldset:
24
+ """One titled group of form fields -- Django's `fieldsets`. A title of None
25
+ renders the group with no header, which is how the undeclared default
26
+ renders as a plain flat form. `collapsed` only seeds the initial state; the
27
+ group can always be opened.
28
+ """
29
+
30
+ fields: Sequence[str] = ()
31
+ title: str | None = None
32
+ description: str = ""
33
+ collapsed: bool = False
34
+
35
+ def __post_init__(self) -> None:
36
+ self.fields = list(self.fields)
37
+
38
+
39
+ class ModelAdmin:
40
+ """Base class for declaring how a resource is administered."""
41
+
42
+ model: ClassVar[type]
43
+
44
+ slug: ClassVar[str | None] = None
45
+ # Sidebar grouping: ModelAdmins and AdminPages sharing a category
46
+ # collapse into one section. None keeps a flat top-level link.
47
+ category: ClassVar[str | None] = None
48
+ # Sidebar icon name (see components/icons.html), shown flat or nested
49
+ # inside a category's accordion alike.
50
+ icon: ClassVar[str] = "collection"
51
+
52
+ list_display: ClassVar[Sequence[str]] = ()
53
+ form_fields: ClassVar[Sequence[str]] = ()
54
+ # When set, `fieldsets` defines both the grouping and the field list:
55
+ # get_form_fields() reports it flattened, so the form and the handler
56
+ # agree. `form_fields` is then unused.
57
+ fieldsets: ClassVar[Sequence[Fieldset]] = ()
58
+ # Rendered as values, not inputs, and refused if posted anyway.
59
+ # Override get_readonly_fields to vary by object, which is how
60
+ # "editable on create, frozen afterwards" is expressed.
61
+ readonly_fields: ClassVar[Sequence[str]] = ()
62
+ # The sort applied when a request names none, in the ?sort= syntax
63
+ # ("-field" for descending). Without one, rows arrive in whatever
64
+ # order the data source returned, which for a dict-backed store is not
65
+ # stable between requests.
66
+ ordering: ClassVar[str | None] = None
67
+ # How many rows a list page holds; None means DEFAULT_PAGE_SIZE.
68
+ # Django's list_per_page.
69
+ list_per_page: ClassVar[int | None] = None
70
+ # What a read-only view shows for a None or blank value; None means
71
+ # DEFAULT_EMPTY_VALUE.
72
+ empty_value_display: ClassVar[str | None] = None
73
+ search_fields: ClassVar[Sequence[str]] = ()
74
+ detail_fields: ClassVar[Sequence[str] | None] = None
75
+ filters: ClassVar[Sequence[Any]] = ()
76
+ fields: ClassVar[Sequence[Field]] = ()
77
+ # Names of the actions the detail page offers, in order. None means every
78
+ # action whose `where` includes the detail page; a list overrides `where`
79
+ # (so a "list" action can be named here), and [] offers none.
80
+ detail_actions: ClassVar[Sequence[str] | None] = None
81
+ # Removes the built-in bulk delete. Opt-out, so the default keeps it,
82
+ # as Django does.
83
+ disable_delete_selected: ClassVar[bool] = False
84
+ # Child ModelAdmins whose records point back at this one, managed
85
+ # inline on its create/detail/edit pages. See docs/inlines.md.
86
+ inlines: ClassVar[Sequence[Inline]] = ()
87
+ # Relation fields that render as a lookup-driven search box rather
88
+ # than a <select> over the target's full queryset -- for relations too
89
+ # large, or too principal-sensitive, to dump into a page.
90
+ autocomplete_fields: ClassVar[Sequence[str]] = ()
91
+
92
+ # The small-parity options -- see docs/lists.md and docs/model-admin.md.
93
+ # sortable_by and list_display_links distinguish None ("unset") from []
94
+ # ("none"); is_sortable and links_to_record apply the defaults.
95
+ #
96
+ # Which list columns offer a sort. None leaves every column sortable,
97
+ # [] none of them. The restriction also holds for a hand-typed ?sort=,
98
+ # but not for `ordering`, which is the admin's own choice.
99
+ sortable_by: ClassVar[Sequence[str] | None] = None
100
+ # Which list cells link to the record. None links the first column, []
101
+ # links none, leaving the row menu as the way in.
102
+ list_display_links: ClassVar[Sequence[str] | None] = None
103
+ # Fills a field from others as they are typed: {"slug": ["title"]}
104
+ # slugifies title into slug. Client-side and on the create form only,
105
+ # so an existing record's slug is never rewritten under it.
106
+ prepopulated_fields: ClassVar[dict[str, Sequence[str]]] = {}
107
+ # Prepopulated fields whose letters are kept as they are instead of
108
+ # transliterated to ASCII -- see polyadmin.core.slug.
109
+ prepopulated_unicode: ClassVar[Sequence[str]] = ()
110
+ # Adds "Save as new" to the edit form, which saves the submitted values
111
+ # as a new record and leaves the original alone. Opt-in, as Django's is.
112
+ save_as: ClassVar[bool] = False
113
+ # Whether the list hands its search, filters, sort and page to the pages
114
+ # reached from it, so they lead back into the list as it was left.
115
+ preserve_filters: ClassVar[bool] = True
116
+
117
+ can_view: ClassVar[bool] = True
118
+ can_create: ClassVar[bool] = True
119
+ can_update: ClassVar[bool] = True
120
+ can_delete: ClassVar[bool] = True
121
+ can_export: ClassVar[bool] = True
122
+
123
+ # Shows a drag handle on the list view. Opt-in, because dragging never
124
+ # persists: it reorders the <tr> elements on the page and reverts on
125
+ # the next render. It is for triaging a list by hand without the
126
+ # framework taking a position on how that order would be stored.
127
+ enable_reordering: ClassVar[bool] = False
128
+
129
+ list_template: ClassVar[str | None] = None
130
+ detail_template: ClassVar[str | None] = None
131
+ form_template: ClassVar[str | None] = None
132
+ delete_template: ClassVar[str | None] = None
133
+
134
+ def __init_subclass__(cls, **kwargs: Any) -> None:
135
+ super().__init_subclass__(**kwargs)
136
+ if "actions" in vars(cls):
137
+ raise TypeError(
138
+ f"{cls.__name__}.actions is no longer supported: declare each action as a "
139
+ "method decorated with @action (polyadmin.core.action.action). "
140
+ "See docs/model-admin.md#actions."
141
+ )
142
+
143
+ def __init__(self) -> None:
144
+ if getattr(self, "model", None) is None:
145
+ raise TypeError(f"{type(self).__name__} must define `model`.")
146
+ self._fields: dict[str, Field] = self._build_fields()
147
+
148
+ def get_slug(self) -> str:
149
+ if self.slug:
150
+ return self.slug
151
+ return f"{self.model.__name__.lower()}s"
152
+
153
+ def get_verbose_name(self) -> str:
154
+ return self.model.__name__
155
+
156
+ def _build_fields(self) -> dict[str, Field]:
157
+ declared = {field.name: field for field in self.fields}
158
+ implied_names = [
159
+ *self.list_display,
160
+ *self.get_form_fields(),
161
+ *self.search_fields,
162
+ ]
163
+ for name in implied_names:
164
+ if name not in declared:
165
+ declared[name] = Field(name)
166
+ return declared
167
+
168
+ def get_fields(self) -> dict[str, Field]:
169
+ return self._fields
170
+
171
+ def get_field(self, name: str) -> Field:
172
+ try:
173
+ return self._fields[name]
174
+ except KeyError:
175
+ raise KeyError(
176
+ f"{type(self).__name__} has no field {name!r}; "
177
+ f"add it to `fields`, `list_display`, or `form_fields`."
178
+ ) from None
179
+
180
+ def get_list_display_values(self, obj: Any) -> dict[str, Any]:
181
+ return {name: self.get_field(name).get_value(obj) for name in self.list_display}
182
+
183
+ def get_pk(self, obj: Any) -> Any:
184
+ """Return the primary key used to build this object's URL."""
185
+ return getattr(obj, "id", None)
186
+
187
+ def get_form_fields(self) -> list[str]:
188
+ if not self.fieldsets:
189
+ return list(self.form_fields)
190
+ names: list[str] = []
191
+ for fieldset in self.fieldsets:
192
+ names.extend(fieldset.fields)
193
+ return names
194
+
195
+ def get_readonly_fields(self, obj: Any = None) -> list[str]:
196
+ """Fields rendering as values rather than inputs for this object. `obj` is
197
+ None on the create form, so an override can tell creating from editing.
198
+ """
199
+ return list(self.readonly_fields)
200
+
201
+ def get_page_size(self) -> int:
202
+ """This ModelAdmin's own page size, or 0 to accept the framework
203
+ default. See `list_per_page`."""
204
+ return self.list_per_page or 0
205
+
206
+ def get_empty_value(self) -> str:
207
+ """What stands in for a None or blank value on the list and
208
+ detail views. See `empty_value_display`."""
209
+ return self.empty_value_display or DEFAULT_EMPTY_VALUE
210
+
211
+ def get_default_ordering(self) -> str | None:
212
+ """The sort to use when the request names none."""
213
+ return self.ordering
214
+
215
+ def is_readonly(self, name: str, obj: Any = None) -> bool:
216
+ """The question every call site actually asks."""
217
+ return name in self.get_readonly_fields(obj)
218
+
219
+ def get_fieldsets(self) -> list[Fieldset]:
220
+ """Always at least one group: the form template renders groups
221
+ unconditionally, so "none declared" means one unnamed group holding
222
+ every form field, not zero groups holding nothing.
223
+ """
224
+ if not self.fieldsets:
225
+ return [Fieldset(fields=list(self.form_fields))]
226
+ return list(self.fieldsets)
227
+
228
+ def get_detail_fields(self) -> list[str]:
229
+ if self.detail_fields is not None:
230
+ return list(self.detail_fields)
231
+ return list(dict.fromkeys([*self.list_display, *self.get_form_fields()]))
232
+
233
+ def get_actions(self) -> list[Action]:
234
+ """The @action methods, in definition order, with the built-in bulk
235
+ delete last -- the destructive action should not be the first thing in
236
+ the listbox. `disable_delete_selected` and `can_delete = False` remove
237
+ it however it is defined."""
238
+ actions = collect_actions(self)
239
+ others = [a for a in actions if a.name != DELETE_SELECTED_NAME]
240
+ if self.disable_delete_selected or not self.can_delete:
241
+ return others
242
+ return [*others, *(a for a in actions if a.name == DELETE_SELECTED_NAME)]
243
+
244
+ def get_action(self, name: str) -> Action | None:
245
+ for candidate in self.get_actions():
246
+ if candidate.name == name:
247
+ return candidate
248
+ return None
249
+
250
+ def get_list_actions(self) -> list[Action]:
251
+ """The actions the list page's bulk bar offers."""
252
+ return [a for a in self.get_actions() if a.where in ("list", "both")]
253
+
254
+ def get_detail_actions(self) -> list[Action]:
255
+ """The actions one record's detail page offers. delete_selected is a
256
+ bulk action, so it is never among them -- not by `where`, not by being
257
+ named in `detail_actions`, not when a ModelAdmin overrides it."""
258
+ candidates = [a for a in self.get_actions() if a.name != DELETE_SELECTED_NAME]
259
+ if self.detail_actions is None:
260
+ return [a for a in candidates if a.where in ("detail", "both")]
261
+ by_name = {a.name: a for a in candidates}
262
+ return [by_name[name] for name in self.detail_actions if name in by_name]
263
+
264
+ def validate_detail_actions(self) -> None:
265
+ known = {a.name for a in self.get_actions()}
266
+ for name in self.detail_actions or ():
267
+ if name not in known:
268
+ raise ValueError(
269
+ f"{type(self).__name__}.detail_actions names {name!r}, which is not one of its actions {sorted(known)}."
270
+ )
271
+
272
+ @action(
273
+ label=N_("Delete selected"),
274
+ confirm=N_("Delete the selected records? This cannot be undone."),
275
+ permission="delete",
276
+ where="list",
277
+ )
278
+ def delete_selected(self, objects: Sequence[Any], principal: Principal | None) -> str | None:
279
+ """The bulk delete every admin gets for free.
280
+
281
+ Django ships the same one, and it is the single most common action
282
+ anyone would otherwise write by hand. It is expressed entirely in terms
283
+ of this ModelAdmin's own `delete` hook, so it works against whatever
284
+ storage the application has and honours whatever that hook already
285
+ does (cascades, soft deletes, hooks of its own).
286
+
287
+ permission="delete", not the resource's bare "view": the action route
288
+ checks it on top, so a principal who may look at a list but not destroy
289
+ its rows is refused -- and, because the same check drives the listbox,
290
+ never offered it in the first place.
291
+ """
292
+ deleted = 0
293
+ for obj in objects:
294
+ try:
295
+ self.delete(obj)
296
+ except Exception as exc:
297
+ # Stop at the first failure and report how far it got:
298
+ # silently continuing would leave the user unable to
299
+ # tell which records survived.
300
+ # Translators: a bulk delete stopped part-way. %(deleted)d of
301
+ # %(total)d records were deleted; %(error)s is the underlying error.
302
+ failure = gettext("Deleted %(deleted)d of %(total)d, then failed: %(error)s")
303
+ raise RuntimeError(failure % {"deleted": deleted, "total": len(objects), "error": exc}) from exc
304
+ deleted += 1
305
+ return ngettext("Deleted %(num)d record.", "Deleted %(num)d records.", deleted) % {"num": deleted}
306
+
307
+ def get_template_candidates(self, view: str) -> list[str]:
308
+ """Template lookup order for a view: an explicit `{view}_template`
309
+ override, then a resource-specific template, then the framework
310
+ default.
311
+ """
312
+ candidates: list[str] = []
313
+ explicit = getattr(self, f"{view}_template", None)
314
+ if explicit:
315
+ candidates.append(explicit)
316
+ candidates.append(f"admin/resource/{self.get_slug()}/{view}.html")
317
+ candidates.append(f"admin/resource/{view}.html")
318
+ return candidates
319
+
320
+ def get_queryset(self) -> Any:
321
+ """Return the base, unfiltered collection of records for this resource."""
322
+ raise NotImplementedError(
323
+ f"{type(self).__name__} must implement get_queryset()."
324
+ )
325
+
326
+ def get_object(self, pk: Any) -> Any:
327
+ """Fetch a single record by primary key, or None if not found."""
328
+ raise NotImplementedError(f"{type(self).__name__} must implement get_object().")
329
+
330
+ def create(self, data: dict[str, Any]) -> Any:
331
+ """Create and persist a new record from validated form data."""
332
+ raise NotImplementedError(f"{type(self).__name__} must implement create().")
333
+
334
+ def update(self, obj: Any, data: dict[str, Any]) -> Any:
335
+ """Apply validated form data to an existing record and persist it."""
336
+ raise NotImplementedError(f"{type(self).__name__} must implement update().")
337
+
338
+ def delete(self, obj: Any) -> None:
339
+ """Delete an existing record."""
340
+ raise NotImplementedError(f"{type(self).__name__} must implement delete().")
341
+
342
+ def validate(self, data: dict[str, Any]) -> dict[str, list[str]]:
343
+ """Run field-level validation over form data. Returns name -> errors."""
344
+ errors: dict[str, list[str]] = {}
345
+ for name in self.get_form_fields():
346
+ field_errors = self.get_field(name).validate(data.get(name))
347
+ if field_errors:
348
+ errors[name] = field_errors
349
+ return errors
polyadmin/core/page.py ADDED
@@ -0,0 +1,62 @@
1
+ """AdminPage: an application-registered admin route with its own
2
+ template and handler, for functionality that isn't resource CRUD
3
+ (reports, wizards, internal tools) -- see docs/routing.md.
4
+
5
+ Named `AdminPage`, not `Page`, to avoid colliding with
6
+ `polyadmin.core.pagination.Page` (a paginated slice of list results).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Awaitable, Callable
12
+ from typing import Any
13
+
14
+ from polyadmin.i18n import N_
15
+
16
+ # String-quoted forward ref: PageContext lives in the FastAPI adapter
17
+ # (polyadmin.fastapi.pages), and core must not import adapter code.
18
+ # Handlers are async, matching every other FastAPI adapter handler in
19
+ # this framework (see fastapi/handlers.py's build_* functions).
20
+ PageHandler = Callable[["PageContext"], Awaitable[Any]] # noqa: F821
21
+
22
+
23
+ class AdminPage:
24
+ """A custom admin page: a path, an application-supplied handler,
25
+ and the same sidebar-grouping/permission shape a ModelAdmin has.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ path: str,
31
+ handler: PageHandler,
32
+ *,
33
+ label: str | None = None,
34
+ category: str | None = None,
35
+ icon: str = "collection",
36
+ permission: str | None = None,
37
+ methods: tuple[str, ...] = ("GET", "POST"),
38
+ show_in_nav: bool = True,
39
+ ) -> None:
40
+ if not path.startswith("/"):
41
+ raise ValueError(f"AdminPage path must start with '/': {path!r}")
42
+ self.path = path
43
+ self.handler = handler
44
+ self.label = label or _default_label(path)
45
+ self.category = category
46
+ self.icon = icon
47
+ self.permission = permission or _default_permission(path)
48
+ self.methods = tuple(m.upper() for m in methods)
49
+ self.show_in_nav = show_in_nav
50
+
51
+
52
+ def _default_label(path: str) -> str:
53
+ last = path.strip("/").rsplit("/", 1)[-1]
54
+ if not last:
55
+ return N_("Page")
56
+ return last.replace("-", " ").replace("_", " ").title()
57
+
58
+
59
+ def _default_permission(path: str) -> str:
60
+ # "/reports/contracts" -> "page.reports.contracts", the same
61
+ # dotted shape as resource_permission's "{slug}.{action}".
62
+ return "page." + path.strip("/").replace("/", ".")
@@ -0,0 +1,70 @@
1
+ """In-memory pagination for the list view.
2
+
3
+ This paginates an already-materialized sequence. Once the query
4
+ pipeline lands, ORM/repository integrations will typically
5
+ paginate at the query level instead and only use `Page` as the result
6
+ shape.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+
16
+ @dataclass
17
+ class Page:
18
+ items: list[Any]
19
+ number: int
20
+ page_size: int
21
+ total_count: int
22
+
23
+ @property
24
+ def num_pages(self) -> int:
25
+ if self.page_size <= 0 or self.total_count <= 0:
26
+ return 1
27
+ return -(-self.total_count // self.page_size) # ceil division
28
+
29
+ @property
30
+ def has_previous(self) -> bool:
31
+ return self.number > 1
32
+
33
+ @property
34
+ def has_next(self) -> bool:
35
+ return self.number < self.num_pages
36
+
37
+ @property
38
+ def previous_page(self) -> int | None:
39
+ return self.number - 1 if self.has_previous else None
40
+
41
+ @property
42
+ def next_page(self) -> int | None:
43
+ return self.number + 1 if self.has_next else None
44
+
45
+
46
+ def page_of(items: Sequence[Any], total: int, list_request: Any) -> Page:
47
+ """Build the result shape around an already-windowed slice -- for a
48
+ data source that did its own LIMIT/OFFSET and reported the total
49
+ separately. `paginate` is the in-memory equivalent, which slices and
50
+ counts for you.
51
+ """
52
+ page = max(list_request.page or 1, 1)
53
+ page_size = list_request.page_size if (list_request.page_size or 0) >= 1 else 25
54
+ if getattr(list_request, "unlimited", False):
55
+ # One page holding everything, so num_pages/has_next stay honest.
56
+ page, page_size = 1, max(total, 1)
57
+ return Page(items=list(items), number=page, page_size=page_size, total_count=total)
58
+
59
+
60
+ def paginate(items: Sequence[Any], *, page: int = 1, page_size: int = 25) -> Page:
61
+ page = max(page, 1)
62
+ page_size = max(page_size, 1)
63
+ total_count = len(items)
64
+ start = (page - 1) * page_size
65
+ return Page(
66
+ items=list(items[start : start + page_size]),
67
+ number=page,
68
+ page_size=page_size,
69
+ total_count=total_count,
70
+ )