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,243 @@
1
+ """The list query pipeline: search -> filters -> ordering.
2
+
3
+ `execute_list_query` is independent of pagination so the same filtered and
4
+ ordered result set can back the list view, an export, or a custom action, each
5
+ deciding separately whether to paginate it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field, replace
10
+ from typing import Any, Protocol
11
+ from urllib.parse import quote
12
+
13
+ from polyadmin.core._async import maybe_await
14
+
15
+ # The size assumed when a request names none, matching the handlers'
16
+ # query-string default so a hand-built ListRequest pages like a parsed
17
+ # one.
18
+ DEFAULT_PAGE_SIZE = 25
19
+
20
+ # Stands in for a None or blank value: an em dash reads as "nothing here"
21
+ # rather than as a cell that failed to render.
22
+ DEFAULT_EMPTY_VALUE = "\u2014"
23
+
24
+
25
+ @dataclass
26
+ class ListRequest:
27
+ search: str | None = None
28
+ filters: dict[str, str] = field(default_factory=dict)
29
+ ordering: str | None = None
30
+ page: int = 1
31
+ page_size: int = 0
32
+ # unlimited asks for every matching row, which is what an export
33
+ # wants. It overrides page/page_size rather than being page_size 0, so
34
+ # "unset" and "all" stay distinguishable.
35
+ unlimited: bool = False
36
+
37
+ def window(self) -> tuple[int, int]:
38
+ """The (offset, limit) pair a data source wants. A limit of 0 means no
39
+ limit -- see `unlimited`.
40
+ """
41
+ if self.unlimited:
42
+ return 0, 0
43
+ size = self.page_size if self.page_size and self.page_size >= 1 else DEFAULT_PAGE_SIZE
44
+ page = self.page if self.page and self.page >= 1 else 1
45
+ return (page - 1) * size, size
46
+
47
+
48
+ class ListQuerier(Protocol):
49
+ """An optional ModelAdmin capability: implement `list_page` to resolve search,
50
+ filters, ordering and the page window in the data source itself, rather
51
+ than in memory over everything get_queryset returns.
52
+
53
+ All-or-nothing by design. The framework applies nothing further, because it
54
+ cannot tell what the implementation already did and re-applying would
55
+ double-filter. The returned total counts rows matching search+filters
56
+ before the window. Not implementing it keeps the in-memory path.
57
+ """
58
+
59
+ def list_page(self, list_request: ListRequest) -> tuple[list[Any], int]:
60
+ ...
61
+
62
+
63
+ def apply_search(model_admin: Any, objects: list[Any], search: str | None) -> list[Any]:
64
+ if not search:
65
+ return objects
66
+ term = search.lower()
67
+ fields = [model_admin.get_field(name) for name in model_admin.search_fields]
68
+ if not fields:
69
+ return objects
70
+
71
+ def matches(obj: Any) -> bool:
72
+ return any(term in str(f.get_value(obj)).lower() for f in fields if f.get_value(obj) is not None)
73
+
74
+ return [obj for obj in objects if matches(obj)]
75
+
76
+
77
+ def apply_filters(model_admin: Any, objects: list[Any], raw_filters: dict[str, str]) -> list[Any]:
78
+ for filt in model_admin.filters:
79
+ if filt.name in raw_filters:
80
+ objects = filt.apply(objects, raw_filters[filt.name], model_admin)
81
+ return objects
82
+
83
+
84
+ def is_sortable(model_admin: Any, name: str) -> bool:
85
+ """Whether a list column offers a sort. An unset sortable_by leaves every
86
+ column sortable; an empty one leaves none."""
87
+ sortable = model_admin.sortable_by
88
+ return True if sortable is None else name in sortable
89
+
90
+
91
+ def links_to_record(model_admin: Any, name: str) -> bool:
92
+ """Whether a list cell links to the record. An unset list_display_links
93
+ links the first column, as Django does; an empty one links nothing and
94
+ leaves the row menu as the way in."""
95
+ linked = model_admin.list_display_links
96
+ if linked is None:
97
+ display = list(model_admin.list_display)
98
+ return bool(display) and display[0] == name
99
+ return name in linked
100
+
101
+
102
+ def apply_ordering(model_admin: Any, objects: list[Any], ordering: str | None) -> list[Any]:
103
+ if not ordering:
104
+ return objects
105
+ reverse = ordering.startswith("-")
106
+ name = ordering[1:] if reverse else ordering
107
+ try:
108
+ target_field = model_admin.get_field(name)
109
+ except KeyError:
110
+ return objects
111
+
112
+ def sort_key(obj: Any) -> tuple[bool, Any]:
113
+ value = target_field.get_value(obj)
114
+ # None-safe: push None values to the end regardless of direction.
115
+ return (value is None, value)
116
+
117
+ return sorted(objects, key=sort_key, reverse=reverse)
118
+
119
+
120
+ def execute_list_query(model_admin: Any, objects: list[Any], list_request: ListRequest) -> list[Any]:
121
+ objects = apply_search(model_admin, objects, list_request.search)
122
+ objects = apply_filters(model_admin, objects, list_request.filters)
123
+ objects = apply_ordering(model_admin, objects, list_request.ordering)
124
+ return objects
125
+
126
+
127
+ def apply_defaults(model_admin: Any, list_request: ListRequest) -> ListRequest:
128
+ """Fill in the ModelAdmin's ordering and page size where the request named
129
+ neither.
130
+
131
+ Resolved before the query runs so a `list_page` implementation is told
132
+ about them too: they are part of the question, not the answer. Idempotent,
133
+ so a caller needing the resolved values can apply it once and pass the same
134
+ request on.
135
+ """
136
+ changes = {}
137
+ # A ?sort= naming a column the admin does not offer is dropped, so the
138
+ # restriction holds for a hand-typed URL too. The default ordering below
139
+ # is exempt: it is the admin's own choice, not user input.
140
+ if list_request.ordering and not is_sortable(model_admin, list_request.ordering.lstrip("-")):
141
+ list_request = replace(list_request, ordering=None)
142
+ if not list_request.ordering:
143
+ changes["ordering"] = model_admin.get_default_ordering()
144
+ # Not for an unlimited request: that deliberately has no page.
145
+ if not list_request.unlimited and list_request.page_size < 1:
146
+ changes["page_size"] = model_admin.get_page_size()
147
+ return replace(list_request, **changes) if changes else list_request
148
+
149
+
150
+ def list_objects(model_admin: Any, list_request: ListRequest) -> tuple[list[Any], int]:
151
+ """Resolve a list query, and the only place that decides how: a ModelAdmin
152
+ implementing `list_page` answers it itself, everything else loads the
153
+ queryset and filters in memory.
154
+
155
+ Every consumer goes through here -- list view, both exports, the
156
+ autocomplete lookup, relation option lists -- so the paths cannot drift;
157
+ the request's window is what separates "one page" from "capped at 20" from
158
+ "every matching row". Returns the window's objects and the total before it.
159
+ """
160
+ list_request = apply_defaults(model_admin, list_request)
161
+ if hasattr(model_admin, "list_page"):
162
+ return model_admin.list_page(list_request)
163
+ objects = execute_list_query(model_admin, model_admin.get_queryset(), list_request)
164
+ total = len(objects)
165
+ offset, limit = list_request.window()
166
+ offset = min(offset, total)
167
+ end = total if limit == 0 else min(offset + limit, total)
168
+ return objects[offset:end], total
169
+
170
+
171
+ async def alist_objects(model_admin: Any, list_request: ListRequest) -> tuple[list[Any], int]:
172
+ """Async counterpart of list_objects, for a ModelAdmin whose list_page or
173
+ get_queryset is a coroutine function. It is what every request path uses --
174
+ the list view, exports, the lookup route, relation option lists and inline
175
+ child loading -- so async hooks work everywhere, on sync ModelAdmins too.
176
+ """
177
+ list_request = apply_defaults(model_admin, list_request)
178
+ if hasattr(model_admin, "list_page"):
179
+ return await maybe_await(model_admin.list_page(list_request))
180
+ objects = execute_list_query(model_admin, await maybe_await(model_admin.get_queryset()), list_request)
181
+ total = len(objects)
182
+ offset, limit = list_request.window()
183
+ offset = min(offset, total)
184
+ end = total if limit == 0 else min(offset + limit, total)
185
+ return objects[offset:end], total
186
+
187
+
188
+ # The reserved query parameter and form field carrying the list a page was
189
+ # reached from -- its search, filters, sort and page (docs/lists.md). It is
190
+ # what preserve_filters preserves: the pages reached from a list hand it
191
+ # back, so the trail out of a filtered list leads into it rather than into
192
+ # the bare one.
193
+ LIST_TOKEN_FIELD = "_list"
194
+
195
+
196
+ def safe_list_token(token: str | None, host: str, base_path: str) -> str:
197
+ """Validate a token the way safe_redirect_path validates a Referer: same
198
+ host, under the admin's base path. An invalid one yields "", which every
199
+ caller reads as "no list to go back to"."""
200
+ from polyadmin.core.csrf import safe_redirect_path
201
+
202
+ if not token:
203
+ return ""
204
+ return safe_redirect_path(token, host, base_path, "") or ""
205
+
206
+
207
+ def with_list_token(url: str, token: str) -> str:
208
+ """Append a validated token to a URL as the _list parameter, leaving the
209
+ URL alone when there is none."""
210
+ if not token:
211
+ return url
212
+ separator = "&" if "?" in url else "?"
213
+ return f"{url}{separator}{LIST_TOKEN_FIELD}={quote(token, safe='')}"
214
+
215
+
216
+ # The panel's range form posts these three rather than one filter value,
217
+ # because a form cannot concatenate two inputs. Reserved names, in the
218
+ # same family as _list and _return.
219
+ RANGE_FOR_FIELD = "_range_for"
220
+ RANGE_FROM_FIELD = "_range_from"
221
+ RANGE_TO_FIELD = "_range_to"
222
+
223
+
224
+ def fold_range_params(
225
+ model_admin: Any, filters: dict[str, str], range_for: str, from_value: str, to_value: str
226
+ ) -> None:
227
+ """Turn a range form's submission into the single filter value the
228
+ grammar defines, in place. Folding here rather than in the browser is
229
+ what keeps the range working with scripting off.
230
+
231
+ `range_for` arrives from the client, so it is honoured only when the
232
+ ModelAdmin actually declares a filter by that name -- otherwise a
233
+ crafted form could inject any key into filters. Submitting both inputs
234
+ empty clears the filter instead of setting an empty range.
235
+ """
236
+ if not range_for:
237
+ return
238
+ if not any(filt.name == range_for for filt in model_admin.filters):
239
+ return
240
+ if not from_value and not to_value:
241
+ filters.pop(range_for, None)
242
+ return
243
+ filters[range_for] = f"{from_value}:{to_value}"
@@ -0,0 +1,40 @@
1
+ """Relation: a ForeignKey/OneToOne/ManyToMany field's link to another
2
+ ModelAdmin.
3
+
4
+ A relation knows how to resolve: which ModelAdmin the related
5
+ object(s) belong to (`target`, a slug looked up on the registry at
6
+ render time -- never a direct reference, so relations don't force
7
+ import-order coupling between ModelAdmins), which of the target's
8
+ fields to show as the display label, and how many related objects
9
+ there are.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+
17
+ ONE = "one" # ForeignKey / OneToOne: a single related object (or None)
18
+ MANY = "many" # ManyToMany: a collection of related objects
19
+
20
+
21
+ class Relation:
22
+ def __init__(
23
+ self,
24
+ name: str,
25
+ *,
26
+ target: str,
27
+ display_field: str = "id",
28
+ cardinality: str = ONE,
29
+ get_related: Callable[[Any], Any] | None = None,
30
+ ) -> None:
31
+ self.name = name
32
+ self.target = target
33
+ self.display_field = display_field
34
+ self.cardinality = cardinality
35
+ self.get_related = get_related
36
+
37
+ def get_value(self, obj: Any) -> Any:
38
+ if self.get_related is not None:
39
+ return self.get_related(obj)
40
+ return getattr(obj, self.name, None)
polyadmin/core/slug.py ADDED
@@ -0,0 +1,57 @@
1
+ """Slugification for prepopulated_fields (docs/model-admin.md).
2
+
3
+ Mirrors the client-side rule, so what the browser filled in and what a
4
+ script here produces agree.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import unicodedata
11
+
12
+ # The Russian alphabet in the BGN/PCGN-flavoured romanisation most readable
13
+ # to a Latin-alphabet reader. Keyed on the lowercase letter; transliterate
14
+ # lowercases before looking up, so one entry per letter serves both cases.
15
+ CYRILLIC_ASCII = {
16
+ "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "yo",
17
+ "ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m",
18
+ "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
19
+ "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch",
20
+ "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya",
21
+ } # fmt: skip
22
+
23
+ _NON_SLUG = re.compile(r"[^\w]+", re.UNICODE)
24
+
25
+
26
+ def slugify(value: str) -> str:
27
+ """A URL-safe ASCII slug: "Café du Coin" becomes "cafe-du-coin" and
28
+ "Привет мир" becomes "privet-mir".
29
+
30
+ Letters outside the transliteration table drop out, so a title written
31
+ wholly in one -- CJK, say -- slugifies to "". `slugify_unicode` is the
32
+ way out for an application that wants those letters kept.
33
+ """
34
+ return _slugify(_transliterate(value))
35
+
36
+
37
+ def slugify_unicode(value: str) -> str:
38
+ """slugify without the ASCII step: punctuation and spacing are still
39
+ normalised, but the letters are kept as they are."""
40
+ return _slugify(value)
41
+
42
+
43
+ def _slugify(value: str) -> str:
44
+ """Lowercase, collapse every run of non-alphanumerics into one hyphen,
45
+ and trim the hyphens from both ends."""
46
+ # \w keeps the underscore, which is not slug punctuation here.
47
+ return _NON_SLUG.sub("-", value.lower().replace("_", "-")).strip("-")
48
+
49
+
50
+ def _transliterate(value: str) -> str:
51
+ """Map the letters PolyAdmin ships locales for onto ASCII: Cyrillic
52
+ through the table above, Latin-1's accents by decomposing and dropping
53
+ the combining marks. Anything left non-ASCII is dropped."""
54
+ mapped = "".join(CYRILLIC_ASCII.get(ch.lower(), ch) for ch in value)
55
+ decomposed = unicodedata.normalize("NFD", mapped)
56
+ without_marks = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
57
+ return "".join(ch for ch in without_marks if ch.isascii())