djhtmx 1.0.0__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.
- djhtmx/__init__.py +4 -0
- djhtmx/apps.py +11 -0
- djhtmx/command_queue.py +142 -0
- djhtmx/commands.py +49 -0
- djhtmx/component.py +511 -0
- djhtmx/consumer.py +84 -0
- djhtmx/context.py +7 -0
- djhtmx/exceptions.py +2 -0
- djhtmx/global_events.py +14 -0
- djhtmx/introspection.py +427 -0
- djhtmx/json.py +56 -0
- djhtmx/management/commands/htmx.py +71 -0
- djhtmx/middleware.py +36 -0
- djhtmx/query.py +177 -0
- djhtmx/repo.py +596 -0
- djhtmx/settings.py +47 -0
- djhtmx/static/htmx/2.0.4/ext/ws.js +467 -0
- djhtmx/static/htmx/2.0.4/htmx.amd.js +5264 -0
- djhtmx/static/htmx/2.0.4/htmx.cjs.js +5262 -0
- djhtmx/static/htmx/2.0.4/htmx.esm.d.ts +206 -0
- djhtmx/static/htmx/2.0.4/htmx.esm.js +5262 -0
- djhtmx/static/htmx/2.0.4/htmx.js +5261 -0
- djhtmx/static/htmx/2.0.4/htmx.min.js +1 -0
- djhtmx/static/htmx/django.js +209 -0
- djhtmx/templates/htmx/headers.html +9 -0
- djhtmx/templates/htmx/lazy.html +3 -0
- djhtmx/templatetags/__init__.py +0 -0
- djhtmx/templatetags/htmx.py +289 -0
- djhtmx/testing.py +194 -0
- djhtmx/tracing.py +52 -0
- djhtmx/urls.py +107 -0
- djhtmx/utils.py +117 -0
- djhtmx-1.0.0.dist-info/METADATA +879 -0
- djhtmx-1.0.0.dist-info/RECORD +36 -0
- djhtmx-1.0.0.dist-info/WHEEL +4 -0
- djhtmx-1.0.0.dist-info/licenses/LICENSE +22 -0
djhtmx/testing.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from collections.abc import Callable, Iterable
|
|
3
|
+
from functools import reduce
|
|
4
|
+
from typing import Any, ParamSpec, TypeVar
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from django.contrib.auth.models import AnonymousUser
|
|
8
|
+
from django.test import Client
|
|
9
|
+
from lxml import html
|
|
10
|
+
from pygments import highlight
|
|
11
|
+
from pygments.formatters import TerminalTrueColorFormatter
|
|
12
|
+
from pygments.lexers import HtmlLexer
|
|
13
|
+
|
|
14
|
+
from . import json
|
|
15
|
+
from .commands import PushURL, ReplaceURL, SendHtml
|
|
16
|
+
from .component import Destroy, DispatchDOMEvent, Focus, HtmxComponent, Open, Redirect
|
|
17
|
+
from .introspection import parse_request_data
|
|
18
|
+
from .repo import Repository, Session, signer
|
|
19
|
+
from .utils import get_params
|
|
20
|
+
|
|
21
|
+
P = ParamSpec("P")
|
|
22
|
+
TPComponent = TypeVar("TPComponent", bound=HtmxComponent)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Htmx:
|
|
26
|
+
def __init__(self, client: Client):
|
|
27
|
+
self.client = client
|
|
28
|
+
|
|
29
|
+
def navigate_to(self, url: str, *args, **kwargs):
|
|
30
|
+
kwargs.setdefault("follow", True)
|
|
31
|
+
response = self.client.get(url, *args, **kwargs)
|
|
32
|
+
assert 200 <= response.status_code < 300
|
|
33
|
+
self.path = response.request["PATH_INFO"]
|
|
34
|
+
self.query_string = response.request["QUERY_STRING"]
|
|
35
|
+
|
|
36
|
+
self.dom = html.fromstring(response.content)
|
|
37
|
+
session_id = reduce(
|
|
38
|
+
lambda session, element: (
|
|
39
|
+
session or json.loads(element.attrib["hx-headers"]).get("HX-Session")
|
|
40
|
+
),
|
|
41
|
+
self.dom.cssselect("[hx-headers]"),
|
|
42
|
+
None,
|
|
43
|
+
)
|
|
44
|
+
assert session_id, "Can't find djhtmx session id"
|
|
45
|
+
session_id = signer.unsign(session_id)
|
|
46
|
+
|
|
47
|
+
self.repo = Repository(
|
|
48
|
+
user=response.context.get("user") or AnonymousUser(),
|
|
49
|
+
session=Session(session_id),
|
|
50
|
+
params=get_params(self.query_string),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def get_component_by_type(self, component_type: type[TPComponent]) -> TPComponent:
|
|
54
|
+
[component] = self.repo.get_components_by_names(component_type.__name__)
|
|
55
|
+
return component # type: ignore
|
|
56
|
+
|
|
57
|
+
def get_components_by_type(self, component_type: type[TPComponent]) -> Iterable[TPComponent]:
|
|
58
|
+
return self.repo.get_components_by_names(component_type.__name__) # type: ignore
|
|
59
|
+
|
|
60
|
+
def get_component_by_id(self, component_id: str):
|
|
61
|
+
component = self.repo.get_component_by_id(component_id)
|
|
62
|
+
assert isinstance(component, HtmxComponent)
|
|
63
|
+
return component
|
|
64
|
+
|
|
65
|
+
def type(self, selector: str | html.HtmlElement, text: str, clear=False):
|
|
66
|
+
"""Sets the value of an input, by "typing" in to it"""
|
|
67
|
+
element = self._select(selector)
|
|
68
|
+
if (
|
|
69
|
+
element.tag == "input" and element.attrib.get("type", "text") == "text"
|
|
70
|
+
) or element.tag == "textarea":
|
|
71
|
+
if clear:
|
|
72
|
+
element.attrib["value"] = text
|
|
73
|
+
else:
|
|
74
|
+
element.attrib["value"] = element.attrib.get("value", "") + text
|
|
75
|
+
else:
|
|
76
|
+
assert False, f"Can't type in element {element}"
|
|
77
|
+
|
|
78
|
+
def find_by_text(self, text: str) -> html.HtmlElement:
|
|
79
|
+
return self.dom.xpath(f"//*[text()='{text}']")
|
|
80
|
+
|
|
81
|
+
def select(self, selector: str) -> list[html.HtmlElement]:
|
|
82
|
+
return self.dom.cssselect(selector)
|
|
83
|
+
|
|
84
|
+
def print(self, element: html.HtmlElement):
|
|
85
|
+
print(
|
|
86
|
+
highlight(
|
|
87
|
+
html.tostring(element, pretty_print=True, encoding="unicode"),
|
|
88
|
+
HtmlLexer(),
|
|
89
|
+
TerminalTrueColorFormatter(),
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _select(self, selector: str | html.HtmlElement) -> html.HtmlElement:
|
|
94
|
+
if isinstance(selector, str):
|
|
95
|
+
[element] = self.dom.cssselect(selector)
|
|
96
|
+
else:
|
|
97
|
+
element = selector
|
|
98
|
+
return element
|
|
99
|
+
|
|
100
|
+
def trigger(self, selector: str | html.HtmlElement):
|
|
101
|
+
element = self._select(selector)
|
|
102
|
+
|
|
103
|
+
# mutate in case of a checkbox and radios
|
|
104
|
+
match element.tag, element.attrib.get("type"):
|
|
105
|
+
case "input", "checkbox":
|
|
106
|
+
if "checked" in element.attrib:
|
|
107
|
+
element.attrib.pop("checked")
|
|
108
|
+
else:
|
|
109
|
+
element.attrib["checked"] = ""
|
|
110
|
+
case "input", "radio":
|
|
111
|
+
if name := element.attrib.get("name"):
|
|
112
|
+
for radio in self.dom.cssselect(f'input[type=radio][name="{name}"]'):
|
|
113
|
+
radio.attrib.pop("checked", None)
|
|
114
|
+
element.attrib["checked"] = ""
|
|
115
|
+
case _:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
[_, component_id, event_handler] = element.attrib["hx-post"].rsplit("/", 2)
|
|
119
|
+
|
|
120
|
+
# gather values
|
|
121
|
+
vals = defaultdict(list)
|
|
122
|
+
if include := element.attrib.get("hx-include"):
|
|
123
|
+
for element in self.dom.cssselect(include):
|
|
124
|
+
name = element.attrib["name"]
|
|
125
|
+
value = element.attrib.get("value", "")
|
|
126
|
+
match element.tag, element.attrib.get("type"):
|
|
127
|
+
case _, "checkbox":
|
|
128
|
+
if "checked" in element.attrib:
|
|
129
|
+
vals[name].append(value or "on")
|
|
130
|
+
case _, "radio":
|
|
131
|
+
if "checked" in element.attrib:
|
|
132
|
+
vals[name].append(value)
|
|
133
|
+
case "select", _:
|
|
134
|
+
for option in element.cssselect("option[selected]"):
|
|
135
|
+
vals[name].append(option.attrib.get("value", ""))
|
|
136
|
+
case _, _:
|
|
137
|
+
vals[name].append(value)
|
|
138
|
+
|
|
139
|
+
vals |= json.loads(element.attrib.get("hx-vals", "{}"))
|
|
140
|
+
self.dispatch_event(component_id, event_handler, parse_request_data(vals))
|
|
141
|
+
|
|
142
|
+
def send(self, method: Callable[P, Any], *args: P.args, **kwargs: P.kwargs):
|
|
143
|
+
assert not args, "All parameters have to be passed by name"
|
|
144
|
+
self.dispatch_event(method.__self__.id, method.__name__, kwargs) # type: ignore
|
|
145
|
+
|
|
146
|
+
def dispatch_event(self, component_id: str, event_handler: str, kwargs: dict[str, Any]):
|
|
147
|
+
commands = self.repo.dispatch_event(component_id, event_handler, kwargs)
|
|
148
|
+
navigate_to_url = None
|
|
149
|
+
for command in commands:
|
|
150
|
+
match command:
|
|
151
|
+
case SendHtml(content):
|
|
152
|
+
incoming = html.fromstring(content)
|
|
153
|
+
oob: str = incoming.attrib["hx-swap-oob"]
|
|
154
|
+
if oob == "true":
|
|
155
|
+
target = self.dom.get_element_by_id(incoming.attrib["id"])
|
|
156
|
+
if parent := target.getparent():
|
|
157
|
+
parent.replace(target, incoming)
|
|
158
|
+
elif oob.startswith("beforeend: "):
|
|
159
|
+
target_selector = oob.removeprefix("beforeend: ")
|
|
160
|
+
[target] = self.dom.cssselect(target_selector)
|
|
161
|
+
target.append(incoming.getchildren()[0])
|
|
162
|
+
elif oob.startswith("afterbegin: "):
|
|
163
|
+
target_selector = oob.removeprefix("afterbegin: ")
|
|
164
|
+
[target] = self.dom.cssselect(target_selector)
|
|
165
|
+
target.insert(0, incoming.getchildren()[0])
|
|
166
|
+
elif oob.startswith("afterend: "):
|
|
167
|
+
target_selector = oob.removeprefix("afterend: ")
|
|
168
|
+
[target] = self.dom.cssselect(target_selector)
|
|
169
|
+
target.addnext(incoming.getchildren()[0])
|
|
170
|
+
elif oob.startswith("beforebegin: "):
|
|
171
|
+
target_selector = oob.removeprefix("afterend: ")
|
|
172
|
+
[target] = self.dom.cssselect(target_selector)
|
|
173
|
+
target.addprevious(incoming.getchildren()[0])
|
|
174
|
+
else:
|
|
175
|
+
assert False, "Unknown swap strategy, please define it here"
|
|
176
|
+
|
|
177
|
+
case Destroy(component_id):
|
|
178
|
+
target = self.dom.get_element_by_id(component_id)
|
|
179
|
+
if parent := target.getparent():
|
|
180
|
+
parent.remove(target)
|
|
181
|
+
|
|
182
|
+
case Redirect(url) | Open(url):
|
|
183
|
+
navigate_to_url = url
|
|
184
|
+
|
|
185
|
+
case PushURL(url) | ReplaceURL(url):
|
|
186
|
+
parsed_url = urlparse(url)
|
|
187
|
+
self.path = parsed_url.path
|
|
188
|
+
self.query_string = parsed_url.query
|
|
189
|
+
|
|
190
|
+
case Focus() | DispatchDOMEvent():
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
if navigate_to_url:
|
|
194
|
+
self.navigate_to(navigate_to_url)
|
djhtmx/tracing.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
|
|
3
|
+
from djhtmx import settings
|
|
4
|
+
|
|
5
|
+
# pragma: no cover
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import sentry_sdk # pyright: ignore[reportMissingImports]
|
|
9
|
+
except ImportError:
|
|
10
|
+
sentry_sdk = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if settings.ENABLE_SENTRY_TRACING and sentry_sdk is not None:
|
|
14
|
+
sentry_start_span = sentry_sdk.start_span
|
|
15
|
+
|
|
16
|
+
@contextlib.contextmanager
|
|
17
|
+
def _sentry_span(name: str, **tags: str): # pyright: ignore[reportRedeclaration]
|
|
18
|
+
with sentry_start_span(op="djhtmx", name=name) as span:
|
|
19
|
+
for tag, value in tags.items():
|
|
20
|
+
span.set_tag(tag, value)
|
|
21
|
+
yield span
|
|
22
|
+
|
|
23
|
+
else:
|
|
24
|
+
|
|
25
|
+
@contextlib.contextmanager
|
|
26
|
+
def _sentry_span(description, **tags):
|
|
27
|
+
yield
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import logfire # pyright: ignore[reportMissingImports]
|
|
32
|
+
except ImportError:
|
|
33
|
+
logfire = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if settings.ENABLE_LOGFIRE_TRACING and logfire is not None:
|
|
37
|
+
logfire_span = logfire.span
|
|
38
|
+
|
|
39
|
+
def _logfire_span(name: str, **tags): # pyright: ignore[reportRedeclaration]
|
|
40
|
+
return logfire_span(name, op="djhtmx", **tags)
|
|
41
|
+
|
|
42
|
+
else:
|
|
43
|
+
|
|
44
|
+
@contextlib.contextmanager
|
|
45
|
+
def _logfire_span(description, **tags):
|
|
46
|
+
yield
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextlib.contextmanager
|
|
50
|
+
def tracing_span(name: str, **tags: str):
|
|
51
|
+
with _sentry_span(name, **tags), _logfire_span(name, **tags):
|
|
52
|
+
yield
|
djhtmx/urls.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from functools import partial
|
|
2
|
+
from http import HTTPStatus
|
|
3
|
+
from typing import assert_never
|
|
4
|
+
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.core.signing import Signer
|
|
7
|
+
from django.http.request import HttpRequest
|
|
8
|
+
from django.http.response import HttpResponse
|
|
9
|
+
from django.urls import path, re_path
|
|
10
|
+
from django.utils.html import format_html
|
|
11
|
+
|
|
12
|
+
from .commands import PushURL, ReplaceURL, SendHtml
|
|
13
|
+
from .component import REGISTRY, Destroy, DispatchDOMEvent, Focus, Open, Redirect, Triggers
|
|
14
|
+
from .consumer import Consumer
|
|
15
|
+
from .introspection import parse_request_data
|
|
16
|
+
from .repo import Repository
|
|
17
|
+
from .tracing import tracing_span
|
|
18
|
+
|
|
19
|
+
signer = Signer()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def endpoint(request: HttpRequest, component_name: str, component_id: str, event_handler: str):
|
|
23
|
+
if "HTTP_HX_SESSION" not in request.META:
|
|
24
|
+
return HttpResponse("Missing header HX-Session", status=HTTPStatus.BAD_REQUEST)
|
|
25
|
+
|
|
26
|
+
with tracing_span(f"{component_name}.{event_handler}"):
|
|
27
|
+
repo = Repository.from_request(request)
|
|
28
|
+
content: list[str] = []
|
|
29
|
+
headers: dict[str, str] = {}
|
|
30
|
+
triggers = Triggers()
|
|
31
|
+
|
|
32
|
+
for command in repo.dispatch_event(
|
|
33
|
+
component_id,
|
|
34
|
+
event_handler,
|
|
35
|
+
parse_request_data(request.POST | request.FILES) # type: ignore[reportOperatorIssues]
|
|
36
|
+
| (
|
|
37
|
+
{"prompt": prompt}
|
|
38
|
+
if (prompt := request.META.get("HTTP_HX_PROMPT", None)) is not None
|
|
39
|
+
else {}
|
|
40
|
+
),
|
|
41
|
+
):
|
|
42
|
+
# Command loop
|
|
43
|
+
match command:
|
|
44
|
+
case Destroy(component_id):
|
|
45
|
+
content.append(
|
|
46
|
+
format_html(
|
|
47
|
+
'<div hx-swap-oob="outerHtml:#{component_id}"></div>',
|
|
48
|
+
component_id=component_id,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
case Redirect(url):
|
|
52
|
+
headers["HX-Redirect"] = url
|
|
53
|
+
case Focus(selector):
|
|
54
|
+
triggers.after_settle("hxFocus", selector)
|
|
55
|
+
case Open(url, name, target, rel):
|
|
56
|
+
triggers.after_settle(
|
|
57
|
+
"hxOpenURL",
|
|
58
|
+
{"url": url, "name": name, "target": target, "rel": rel},
|
|
59
|
+
)
|
|
60
|
+
case DispatchDOMEvent(target, event, detail, bubbles, cancelable, composed):
|
|
61
|
+
triggers.after_settle(
|
|
62
|
+
"hxDispatchDOMEvent",
|
|
63
|
+
{
|
|
64
|
+
"event": event,
|
|
65
|
+
"target": target,
|
|
66
|
+
"detail": detail,
|
|
67
|
+
"bubbles": bubbles,
|
|
68
|
+
"cancelable": cancelable,
|
|
69
|
+
"composed": composed,
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
case SendHtml(html):
|
|
73
|
+
content.append(html)
|
|
74
|
+
case PushURL(url):
|
|
75
|
+
headers["HX-Push-Url"] = url
|
|
76
|
+
case ReplaceURL(url):
|
|
77
|
+
headers["HX-Replace-Url"] = url
|
|
78
|
+
case _ as unreachable:
|
|
79
|
+
assert_never(unreachable)
|
|
80
|
+
|
|
81
|
+
return HttpResponse("\n\n".join(content), headers=headers | triggers.headers)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
APP_CONFIGS = sorted(apps.app_configs.values(), key=lambda app_config: -len(app_config.name))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def app_name_of_component(cls: type):
|
|
88
|
+
cls_module = cls.__module__
|
|
89
|
+
for app_config in APP_CONFIGS:
|
|
90
|
+
if cls_module.startswith(app_config.name):
|
|
91
|
+
return app_config.label
|
|
92
|
+
return cls_module
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
urlpatterns = [
|
|
96
|
+
path(
|
|
97
|
+
f"{app_name_of_component(component)}/{component_name}/<component_id>/<event_handler>",
|
|
98
|
+
partial(endpoint, component_name=component_name),
|
|
99
|
+
name=f"djhtmx.{component_name}",
|
|
100
|
+
)
|
|
101
|
+
for component_name, component in REGISTRY.items()
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
ws_urlpatterns = [
|
|
106
|
+
re_path("ws", Consumer.as_asgi(), name="djhtmx.ws"), # type: ignore
|
|
107
|
+
]
|
djhtmx/utils.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
from urllib.parse import urlparse
|
|
3
|
+
|
|
4
|
+
import mmh3
|
|
5
|
+
from channels.db import database_sync_to_async as db # type: ignore
|
|
6
|
+
from django.db import models
|
|
7
|
+
from django.http.request import HttpRequest, QueryDict
|
|
8
|
+
from uuid6 import uuid7
|
|
9
|
+
|
|
10
|
+
from . import json
|
|
11
|
+
|
|
12
|
+
if t.TYPE_CHECKING:
|
|
13
|
+
T = t.TypeVar("T")
|
|
14
|
+
P = t.ParamSpec("P")
|
|
15
|
+
|
|
16
|
+
def db(f: t.Callable[P, T]) -> t.Callable[P, t.Awaitable[T]]: ... # noqa: UP047
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_params(obj: HttpRequest | QueryDict | str | None) -> QueryDict:
|
|
20
|
+
if isinstance(obj, HttpRequest):
|
|
21
|
+
is_htmx_request = json.loads(obj.META.get("HTTP_HX_REQUEST", "false"))
|
|
22
|
+
if is_htmx_request:
|
|
23
|
+
return QueryDict(
|
|
24
|
+
urlparse(obj.META["HTTP_HX_CURRENT_URL"]).query,
|
|
25
|
+
mutable=True,
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
qd = QueryDict(None, mutable=True)
|
|
29
|
+
qd.update(obj.GET)
|
|
30
|
+
return qd
|
|
31
|
+
elif isinstance(obj, QueryDict):
|
|
32
|
+
qd = QueryDict(None, mutable=True)
|
|
33
|
+
qd.update(obj) # type: ignore
|
|
34
|
+
return qd
|
|
35
|
+
elif isinstance(obj, str):
|
|
36
|
+
return QueryDict(
|
|
37
|
+
query_string=urlparse(obj).query if obj else None,
|
|
38
|
+
mutable=True,
|
|
39
|
+
)
|
|
40
|
+
else:
|
|
41
|
+
return QueryDict(None, mutable=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_instance_subscriptions(
|
|
45
|
+
obj: models.Model,
|
|
46
|
+
actions: t.Sequence[str] = ("created", "updated", "deleted"),
|
|
47
|
+
):
|
|
48
|
+
"""Get the subscriptions to actions of a single instance of a model.
|
|
49
|
+
|
|
50
|
+
This won't return model-level subscriptions.
|
|
51
|
+
|
|
52
|
+
The `actions` is the set of actions to subscribe to, including any possible relation (e.g
|
|
53
|
+
'users.deleted'). If actions is empty, return only instance-level subscription.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
cls = type(obj)
|
|
57
|
+
app = cls._meta.app_label
|
|
58
|
+
name = cls._meta.model_name
|
|
59
|
+
prefix = f"{app}.{name}.{obj.pk}"
|
|
60
|
+
if not actions:
|
|
61
|
+
return {prefix}
|
|
62
|
+
else:
|
|
63
|
+
return {f"{prefix}.{action}" for action in actions}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_model_subscriptions(
|
|
67
|
+
obj: type[models.Model] | models.Model,
|
|
68
|
+
actions: t.Sequence[str] = ("created", "updated", "deleted"),
|
|
69
|
+
) -> set[str]:
|
|
70
|
+
"""Get the subscriptions to actions of the model.
|
|
71
|
+
|
|
72
|
+
If the `obj` is an instance of the model, return all the subscriptions
|
|
73
|
+
from actions. If `obj` is just the model class, return the top-level
|
|
74
|
+
subscription.
|
|
75
|
+
|
|
76
|
+
The `actions` is the set of actions to subscribe to, including any
|
|
77
|
+
possible relation (e.g 'users.deleted').
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
if isinstance(obj, models.Model):
|
|
81
|
+
cls = type(obj)
|
|
82
|
+
instance = obj
|
|
83
|
+
else:
|
|
84
|
+
cls = obj
|
|
85
|
+
instance = None
|
|
86
|
+
app = cls._meta.app_label
|
|
87
|
+
name = cls._meta.model_name
|
|
88
|
+
result = {(model_prefix := f"{app}.{name}")}
|
|
89
|
+
if instance:
|
|
90
|
+
result.add(prefix := f"{model_prefix}.{instance.pk}")
|
|
91
|
+
result.update(f"{prefix}.{action}" for action in actions)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def generate_id():
|
|
96
|
+
return f"hx-{uuid7().hex}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compact_hash(value: str) -> str:
|
|
100
|
+
"""Return a SHA1 using a base with 64+ symbols"""
|
|
101
|
+
# this returns a signed 32 bit number, we convert it to unsigned with `& 0xffffffff`
|
|
102
|
+
hashed_value = mmh3.hash(value) & 0xFFFFFFFF
|
|
103
|
+
|
|
104
|
+
# Convert the integer to the custom base
|
|
105
|
+
base_len = len(_BASE)
|
|
106
|
+
encoded = []
|
|
107
|
+
while hashed_value > 0:
|
|
108
|
+
hashed_value, rem = divmod(hashed_value, base_len)
|
|
109
|
+
encoded.append(_BASE[rem])
|
|
110
|
+
|
|
111
|
+
return "".join(encoded)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# The order of the base is random so that it doesn't match anything out there.
|
|
115
|
+
# The symbols are chosen to avoid extra encoding in the URL and HTML, and
|
|
116
|
+
# allowed in plain CSS selectors.
|
|
117
|
+
_BASE = "ZmBeUHhTgusXNW_Y1b05KPiFcQJD86joqnIRE7Lfkrdp3AOMCvltSwzVG9yxa42"
|