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.
- polyadmin/__init__.py +47 -0
- polyadmin/core/__init__.py +0 -0
- polyadmin/core/_async.py +21 -0
- polyadmin/core/action.py +135 -0
- polyadmin/core/admin.py +129 -0
- polyadmin/core/audit.py +65 -0
- polyadmin/core/auth.py +52 -0
- polyadmin/core/authorization.py +48 -0
- polyadmin/core/csrf.py +70 -0
- polyadmin/core/dashboard.py +41 -0
- polyadmin/core/delete.py +146 -0
- polyadmin/core/exporter.py +136 -0
- polyadmin/core/field.py +201 -0
- polyadmin/core/filter.py +305 -0
- polyadmin/core/inline.py +97 -0
- polyadmin/core/login.py +110 -0
- polyadmin/core/model_admin.py +349 -0
- polyadmin/core/page.py +62 -0
- polyadmin/core/pagination.py +70 -0
- polyadmin/core/query.py +243 -0
- polyadmin/core/relation.py +40 -0
- polyadmin/core/slug.py +57 -0
- polyadmin/core/template_context.py +679 -0
- polyadmin/core/widget.py +280 -0
- polyadmin/fastapi/__init__.py +3 -0
- polyadmin/fastapi/audit.py +52 -0
- polyadmin/fastapi/auth.py +112 -0
- polyadmin/fastapi/csrf.py +93 -0
- polyadmin/fastapi/deletes.py +76 -0
- polyadmin/fastapi/errors.py +93 -0
- polyadmin/fastapi/handlers.py +798 -0
- polyadmin/fastapi/inlines.py +177 -0
- polyadmin/fastapi/locale.py +128 -0
- polyadmin/fastapi/login.py +110 -0
- polyadmin/fastapi/pages.py +97 -0
- polyadmin/fastapi/relations.py +264 -0
- polyadmin/fastapi/responses.py +55 -0
- polyadmin/fastapi/router.py +174 -0
- polyadmin/fastapi/static.py +21 -0
- polyadmin/i18n/__init__.py +50 -0
- polyadmin/i18n/context.py +54 -0
- polyadmin/i18n/negotiation.py +56 -0
- polyadmin/i18n/setup.py +81 -0
- polyadmin/i18n/translator.py +124 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
- polyadmin/locale/polyadmin.pot +485 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
- polyadmin/templates/admin/base.html +91 -0
- polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
- polyadmin/templates/admin/components/csrf-field.html +5 -0
- polyadmin/templates/admin/components/error_fragment.html +6 -0
- polyadmin/templates/admin/components/field.html +60 -0
- polyadmin/templates/admin/components/form_wrapper.html +131 -0
- polyadmin/templates/admin/components/icons.html +67 -0
- polyadmin/templates/admin/components/inline.html +251 -0
- polyadmin/templates/admin/components/inline_fragment.html +2 -0
- polyadmin/templates/admin/components/list_content.html +54 -0
- polyadmin/templates/admin/components/lookup_results.html +19 -0
- polyadmin/templates/admin/components/search.html +19 -0
- polyadmin/templates/admin/components/toasts.html +151 -0
- polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
- polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
- polyadmin/templates/admin/components/ui/calendar.html +175 -0
- polyadmin/templates/admin/components/ui/combobox.html +82 -0
- polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
- polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
- polyadmin/templates/admin/components/ui/field.html +110 -0
- polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
- polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
- polyadmin/templates/admin/components/ui/multi-select.html +253 -0
- polyadmin/templates/admin/components/ui/pagination.html +81 -0
- polyadmin/templates/admin/components/ui/radio-group.html +28 -0
- polyadmin/templates/admin/components/ui/select.html +165 -0
- polyadmin/templates/admin/components/ui/sidebar.html +175 -0
- polyadmin/templates/admin/components/ui/slider.html +22 -0
- polyadmin/templates/admin/components/ui/switch.html +36 -0
- polyadmin/templates/admin/components/ui/table.html +221 -0
- polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
- polyadmin/templates/admin/dashboard.html +35 -0
- polyadmin/templates/admin/error.html +33 -0
- polyadmin/templates/admin/login.html +94 -0
- polyadmin/templates/admin/resource/delete.html +29 -0
- polyadmin/templates/admin/resource/delete_selected.html +49 -0
- polyadmin/templates/admin/resource/detail.html +78 -0
- polyadmin/templates/admin/resource/form.html +5 -0
- polyadmin/templates/admin/resource/list.html +5 -0
- polyadmin/templates/admin/theme.html +372 -0
- polyadmin/templates/admin/widgets/activity.html +8 -0
- polyadmin/templates/admin/widgets/chart.html +15 -0
- polyadmin/templates/admin/widgets/donut.html +59 -0
- polyadmin/templates/admin/widgets/metric.html +1 -0
- polyadmin/templates/admin/widgets/progress.html +7 -0
- polyadmin/templates/admin/widgets/stat.html +22 -0
- polyadmin/templates/admin/widgets/table.html +29 -0
- polyadmin/templates/admin/widgets/tabs.html +34 -0
- polyadmin/templates/admin/widgets/timeline.html +21 -0
- polyadmin/templating.py +528 -0
- polyadmin/ui.py +817 -0
- polyadmin-0.1.0b1.dist-info/METADATA +239 -0
- polyadmin-0.1.0b1.dist-info/RECORD +104 -0
- polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
- polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
polyadmin/core/query.py
ADDED
|
@@ -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())
|