arthexis 0.1.8__py3-none-any.whl → 0.1.10__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.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/notifications.py CHANGED
@@ -5,6 +5,7 @@ updates the LCD. If writing to the lock file fails, a Windows
5
5
  notification or log entry is used as a fallback. Each line is truncated
6
6
  to 64 characters; scrolling is handled by the LCD service.
7
7
  """
8
+
8
9
  from __future__ import annotations
9
10
 
10
11
  import logging
@@ -20,6 +21,15 @@ except Exception: # pragma: no cover - plyer may not be installed
20
21
  logger = logging.getLogger(__name__)
21
22
 
22
23
 
24
+ def supports_gui_toast() -> bool:
25
+ """Return ``True`` when a GUI toast notification is available."""
26
+
27
+ if not sys.platform.startswith("win"):
28
+ return False
29
+ notify = getattr(plyer_notification, "notify", None)
30
+ return callable(notify)
31
+
32
+
23
33
  class NotificationManager:
24
34
  """Write notifications to a lock file or fall back to GUI/log output."""
25
35
 
@@ -68,7 +78,7 @@ class NotificationManager:
68
78
 
69
79
  # GUI/log fallback ------------------------------------------------
70
80
  def _gui_display(self, subject: str, body: str) -> None:
71
- if sys.platform.startswith("win") and plyer_notification:
81
+ if supports_gui_toast():
72
82
  try: # pragma: no cover - depends on platform
73
83
  plyer_notification.notify(
74
84
  title="Arthexis", message=f"{subject}\n{body}", timeout=6
core/public_wifi.py ADDED
@@ -0,0 +1,227 @@
1
+ """Utilities for managing public Wi-Fi access control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Iterable
11
+
12
+ from django.conf import settings
13
+ from django.utils import timezone
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _MAC_RE = re.compile(r"(?P<mac>([0-9a-f]{2}:){5}[0-9a-f]{2})", re.IGNORECASE)
18
+
19
+
20
+ def _lock_dir() -> Path:
21
+ return Path(settings.BASE_DIR) / "locks"
22
+
23
+
24
+ def _mode_lock() -> Path:
25
+ return _lock_dir() / "public_wifi_mode.lck"
26
+
27
+
28
+ def _allowlist_path() -> Path:
29
+ return _lock_dir() / "public_wifi_allow.list"
30
+
31
+
32
+ def _normalize_mac(mac: str) -> str:
33
+ return mac.strip().lower()
34
+
35
+
36
+ def resolve_mac_address(ip_address: str | None) -> str | None:
37
+ """Attempt to resolve the MAC address for ``ip_address``.
38
+
39
+ The lookup prefers ``ip neigh`` and falls back to ``arp`` when available.
40
+ Returns ``None`` when the MAC cannot be determined.
41
+ """
42
+
43
+ if not ip_address:
44
+ return None
45
+
46
+ commands: Iterable[list[str]] = []
47
+ ip_cmd = shutil.which("ip")
48
+ if ip_cmd:
49
+ commands = [[ip_cmd, "neigh", "show", ip_address]]
50
+ arp_cmd = shutil.which("arp")
51
+ if arp_cmd:
52
+ commands = list(commands) + [[arp_cmd, "-n", ip_address]]
53
+ for command in commands:
54
+ try:
55
+ result = subprocess.run(
56
+ command,
57
+ capture_output=True,
58
+ text=True,
59
+ check=False,
60
+ timeout=1,
61
+ )
62
+ except Exception: # pragma: no cover - defensive
63
+ continue
64
+ if result.returncode != 0:
65
+ continue
66
+ match = _MAC_RE.search(result.stdout)
67
+ if match:
68
+ mac = match.group("mac")
69
+ return _normalize_mac(mac)
70
+ return None
71
+
72
+
73
+ def _iptables_available() -> bool:
74
+ return shutil.which("iptables") is not None
75
+
76
+
77
+ def _run_iptables(args: list[str]) -> None:
78
+ try:
79
+ subprocess.run(["iptables", *args], check=False, timeout=2)
80
+ except Exception: # pragma: no cover - defensive
81
+ logger.exception("iptables command failed: %s", " ".join(args))
82
+
83
+
84
+ def _load_allowlist() -> set[str]:
85
+ path = _allowlist_path()
86
+ if not path.exists():
87
+ return set()
88
+ try:
89
+ content = path.read_text().splitlines()
90
+ except OSError: # pragma: no cover - defensive
91
+ logger.exception("Unable to read public Wi-Fi allow list")
92
+ return set()
93
+ return {line.strip().lower() for line in content if line.strip()}
94
+
95
+
96
+ def _save_allowlist(macs: Iterable[str]) -> None:
97
+ path = _allowlist_path()
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ lines = sorted({m.lower() for m in macs if m})
100
+ try:
101
+ path.write_text("\n".join(lines) + ("\n" if lines else ""))
102
+ except OSError: # pragma: no cover - defensive
103
+ logger.exception("Unable to write public Wi-Fi allow list")
104
+
105
+
106
+ def allow_mac(mac: str) -> None:
107
+ mac = _normalize_mac(mac)
108
+ if not mac:
109
+ return
110
+ allowlist = _load_allowlist()
111
+ if mac not in allowlist:
112
+ allowlist.add(mac)
113
+ _save_allowlist(allowlist)
114
+ if _iptables_available():
115
+ check_args = [
116
+ "-C",
117
+ "FORWARD",
118
+ "-i",
119
+ "wlan0",
120
+ "-m",
121
+ "mac",
122
+ "--mac-source",
123
+ mac,
124
+ "-j",
125
+ "ACCEPT",
126
+ ]
127
+ try:
128
+ result = subprocess.run(
129
+ ["iptables", *check_args],
130
+ capture_output=True,
131
+ text=True,
132
+ check=False,
133
+ timeout=2,
134
+ )
135
+ exists = result.returncode == 0
136
+ except Exception: # pragma: no cover - defensive
137
+ logger.exception("iptables check failed for %s", mac)
138
+ exists = True
139
+ if not exists:
140
+ _run_iptables(
141
+ [
142
+ "-I",
143
+ "FORWARD",
144
+ "1",
145
+ "-i",
146
+ "wlan0",
147
+ "-m",
148
+ "mac",
149
+ "--mac-source",
150
+ mac,
151
+ "-j",
152
+ "ACCEPT",
153
+ ]
154
+ )
155
+
156
+
157
+ def revoke_mac(mac: str) -> None:
158
+ mac = _normalize_mac(mac)
159
+ if not mac:
160
+ return
161
+ allowlist = _load_allowlist()
162
+ if mac in allowlist:
163
+ allowlist.remove(mac)
164
+ _save_allowlist(allowlist)
165
+ if _iptables_available():
166
+ while True:
167
+ try:
168
+ result = subprocess.run(
169
+ [
170
+ "iptables",
171
+ "-D",
172
+ "FORWARD",
173
+ "-i",
174
+ "wlan0",
175
+ "-m",
176
+ "mac",
177
+ "--mac-source",
178
+ mac,
179
+ "-j",
180
+ "ACCEPT",
181
+ ],
182
+ capture_output=True,
183
+ text=True,
184
+ check=False,
185
+ timeout=2,
186
+ )
187
+ except Exception: # pragma: no cover - defensive
188
+ logger.exception("iptables delete failed for %s", mac)
189
+ break
190
+ if result.returncode != 0:
191
+ break
192
+
193
+
194
+ def public_mode_lock_exists() -> bool:
195
+ return _mode_lock().exists()
196
+
197
+
198
+ def grant_public_access(user, mac: str):
199
+ from core.models import PublicWifiAccess
200
+
201
+ mac = _normalize_mac(mac)
202
+ if not mac:
203
+ return None
204
+ access, created = PublicWifiAccess.objects.get_or_create(
205
+ user=user,
206
+ mac_address=mac,
207
+ defaults={"revoked_on": None},
208
+ )
209
+ if access.revoked_on is not None:
210
+ access.revoked_on = None
211
+ access.save(update_fields=["revoked_on", "updated_on"])
212
+ allow_mac(mac)
213
+ return access
214
+
215
+
216
+ def revoke_public_access(access) -> None:
217
+ if access.revoked_on is None:
218
+ access.revoked_on = timezone.now()
219
+ access.save(update_fields=["revoked_on", "updated_on"])
220
+ revoke_mac(access.mac_address)
221
+
222
+
223
+ def revoke_public_access_for_user(user) -> None:
224
+ from core.models import PublicWifiAccess
225
+
226
+ for access in PublicWifiAccess.objects.filter(user=user, revoked_on__isnull=True):
227
+ revoke_public_access(access)
@@ -0,0 +1,97 @@
1
+ """Utility helpers for working with Reference objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable, TYPE_CHECKING
6
+
7
+ from django.contrib.sites.models import Site
8
+
9
+ if TYPE_CHECKING: # pragma: no cover - imported only for type checking
10
+ from django.http import HttpRequest
11
+ from nodes.models import Node
12
+ from .models import Reference
13
+
14
+
15
+ def filter_visible_references(
16
+ refs: Iterable["Reference"],
17
+ *,
18
+ request: "HttpRequest | None" = None,
19
+ site: Site | None = None,
20
+ node: "Node | None" = None,
21
+ respect_footer_visibility: bool = True,
22
+ ) -> list["Reference"]:
23
+ """Return references visible for the current context."""
24
+
25
+ if site is None and request is not None:
26
+ try:
27
+ host = request.get_host().split(":")[0]
28
+ except Exception:
29
+ host = ""
30
+ if host:
31
+ site = Site.objects.filter(domain__iexact=host).first()
32
+
33
+ site_id = site.pk if site else None
34
+
35
+ if node is None:
36
+ try:
37
+ from nodes.models import Node # imported lazily to avoid circular import
38
+
39
+ node = Node.get_local()
40
+ except Exception:
41
+ node = None
42
+
43
+ node_role_id = getattr(node, "role_id", None)
44
+ node_feature_ids: set[int] = set()
45
+ if node is not None:
46
+ features_manager = getattr(node, "features", None)
47
+ if features_manager is not None:
48
+ try:
49
+ node_feature_ids = set(
50
+ features_manager.values_list("pk", flat=True)
51
+ )
52
+ except Exception:
53
+ node_feature_ids = set()
54
+
55
+ visible_refs: list["Reference"] = []
56
+ for ref in refs:
57
+ required_roles = {role.pk for role in ref.roles.all()}
58
+ required_features = {feature.pk for feature in ref.features.all()}
59
+ required_sites = {current_site.pk for current_site in ref.sites.all()}
60
+
61
+ if required_roles or required_features or required_sites:
62
+ allowed = False
63
+ if required_roles and node_role_id and node_role_id in required_roles:
64
+ allowed = True
65
+ elif (
66
+ required_features
67
+ and node_feature_ids
68
+ and node_feature_ids.intersection(required_features)
69
+ ):
70
+ allowed = True
71
+ elif required_sites and site_id and site_id in required_sites:
72
+ allowed = True
73
+
74
+ if not allowed:
75
+ continue
76
+
77
+ if respect_footer_visibility:
78
+ if ref.footer_visibility == ref.FOOTER_PUBLIC:
79
+ visible_refs.append(ref)
80
+ elif (
81
+ ref.footer_visibility == ref.FOOTER_PRIVATE
82
+ and request
83
+ and request.user.is_authenticated
84
+ ):
85
+ visible_refs.append(ref)
86
+ elif (
87
+ ref.footer_visibility == ref.FOOTER_STAFF
88
+ and request
89
+ and request.user.is_authenticated
90
+ and request.user.is_staff
91
+ ):
92
+ visible_refs.append(ref)
93
+ else:
94
+ visible_refs.append(ref)
95
+
96
+ return visible_refs
97
+
core/release.py CHANGED
@@ -52,7 +52,7 @@ DEFAULT_PACKAGE = Package(
52
52
  author="Rafael J. Guillén-Osorio",
53
53
  email="tecnologia@gelectriic.com",
54
54
  python_requires=">=3.10",
55
- license="MIT",
55
+ license="GPL-3.0-only",
56
56
  )
57
57
 
58
58
 
@@ -79,7 +79,9 @@ def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
79
79
 
80
80
 
81
81
  def _git_clean() -> bool:
82
- proc = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True)
82
+ proc = subprocess.run(
83
+ ["git", "status", "--porcelain"], capture_output=True, text=True
84
+ )
83
85
  return not proc.stdout.strip()
84
86
 
85
87
 
@@ -89,7 +91,6 @@ def _git_has_staged_changes() -> bool:
89
91
  return proc.returncode != 0
90
92
 
91
93
 
92
-
93
94
  def _manager_credentials() -> Optional[Credentials]:
94
95
  """Return credentials from the Package's release manager if available."""
95
96
  try: # pragma: no cover - optional dependency
@@ -162,13 +163,12 @@ def _write_pyproject(package: Package, version: str, requirements: list[str]) ->
162
163
  if toml is not None and hasattr(toml, "dumps"):
163
164
  return toml.dumps(data)
164
165
  import json
166
+
165
167
  return json.dumps(data)
166
168
 
167
169
  Path("pyproject.toml").write_text(_dump_toml(content), encoding="utf-8")
168
170
 
169
171
 
170
-
171
-
172
172
  @requires_network
173
173
  def build(
174
174
  *,
@@ -254,19 +254,19 @@ def build(
254
254
  except Exception:
255
255
  requests = None # type: ignore
256
256
  if requests is not None:
257
- resp = requests.get(
258
- f"https://pypi.org/pypi/{package.name}/json"
259
- )
257
+ resp = requests.get(f"https://pypi.org/pypi/{package.name}/json")
260
258
  if resp.ok:
261
259
  releases = resp.json().get("releases", {})
262
260
  if version in releases:
263
- raise ReleaseError(
264
- f"Version {version} already on PyPI"
265
- )
266
- creds = creds or _manager_credentials() or Credentials(
267
- token=os.environ.get("PYPI_API_TOKEN"),
268
- username=os.environ.get("PYPI_USERNAME"),
269
- password=os.environ.get("PYPI_PASSWORD"),
261
+ raise ReleaseError(f"Version {version} already on PyPI")
262
+ creds = (
263
+ creds
264
+ or _manager_credentials()
265
+ or Credentials(
266
+ token=os.environ.get("PYPI_API_TOKEN"),
267
+ username=os.environ.get("PYPI_USERNAME"),
268
+ password=os.environ.get("PYPI_PASSWORD"),
269
+ )
270
270
  )
271
271
  files = sorted(str(p) for p in Path("dist").glob("*"))
272
272
  if not files:
@@ -307,7 +307,10 @@ def promote(
307
307
 
308
308
 
309
309
  def publish(
310
- *, package: Package = DEFAULT_PACKAGE, version: str, creds: Optional[Credentials] = None
310
+ *,
311
+ package: Package = DEFAULT_PACKAGE,
312
+ version: str,
313
+ creds: Optional[Credentials] = None,
311
314
  ) -> None:
312
315
  """Upload the existing distribution to PyPI."""
313
316
  if network_available():
@@ -321,10 +324,14 @@ def publish(
321
324
  raise ReleaseError(f"Version {version} already on PyPI")
322
325
  if not Path("dist").exists():
323
326
  raise ReleaseError("dist directory not found")
324
- creds = creds or _manager_credentials() or Credentials(
325
- token=os.environ.get("PYPI_API_TOKEN"),
326
- username=os.environ.get("PYPI_USERNAME"),
327
- password=os.environ.get("PYPI_PASSWORD"),
327
+ creds = (
328
+ creds
329
+ or _manager_credentials()
330
+ or Credentials(
331
+ token=os.environ.get("PYPI_API_TOKEN"),
332
+ username=os.environ.get("PYPI_USERNAME"),
333
+ password=os.environ.get("PYPI_PASSWORD"),
334
+ )
328
335
  )
329
336
  files = sorted(str(p) for p in Path("dist").glob("*"))
330
337
  if not files:
core/sigil_builder.py ADDED
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ from django.apps import apps
5
+ from django.contrib import admin
6
+ from django.template.response import TemplateResponse
7
+ from django.urls import path, reverse
8
+ from django.utils.translation import gettext_lazy as _
9
+
10
+ from .fields import SigilAutoFieldMixin
11
+ from .sigil_resolver import (
12
+ resolve_sigils as resolve_sigils_in_text,
13
+ resolve_sigil as _resolve_sigil,
14
+ )
15
+
16
+
17
+ def generate_model_sigils(**kwargs) -> None:
18
+ """Ensure built-in configuration SigilRoot entries exist."""
19
+ SigilRoot = apps.get_model("core", "SigilRoot")
20
+ for prefix in ["ENV", "SYS"]:
21
+ # Ensure built-in configuration roots exist without violating the
22
+ # unique ``prefix`` constraint, even if older databases already have
23
+ # entries with a different ``context_type``.
24
+ root = SigilRoot.objects.filter(prefix__iexact=prefix).first()
25
+ if root:
26
+ root.prefix = prefix
27
+ root.context_type = SigilRoot.Context.CONFIG
28
+ root.save(update_fields=["prefix", "context_type"])
29
+ else:
30
+ SigilRoot.objects.create(
31
+ prefix=prefix,
32
+ context_type=SigilRoot.Context.CONFIG,
33
+ )
34
+
35
+
36
+ def _sigil_builder_view(request):
37
+ SigilRoot = apps.get_model("core", "SigilRoot")
38
+ grouped: dict[str, dict[str, object]] = {}
39
+ builtin_roots = [
40
+ {
41
+ "prefix": "ENV",
42
+ "url": reverse("admin:environment"),
43
+ "label": _("Environment"),
44
+ },
45
+ {
46
+ "prefix": "SYS",
47
+ "url": reverse("admin:system"),
48
+ "label": _("System"),
49
+ },
50
+ ]
51
+ for root in SigilRoot.objects.filter(
52
+ context_type=SigilRoot.Context.ENTITY
53
+ ).select_related("content_type"):
54
+ if not root.content_type:
55
+ continue
56
+ model = root.content_type.model_class()
57
+ model_name = model._meta.object_name
58
+ entry = grouped.setdefault(
59
+ model_name,
60
+ {
61
+ "model": model_name,
62
+ "fields": [f.name.upper() for f in model._meta.fields],
63
+ "prefixes": [],
64
+ },
65
+ )
66
+ entry["prefixes"].append(root.prefix.upper())
67
+ roots = sorted(grouped.values(), key=lambda r: r["model"])
68
+ for entry in roots:
69
+ entry["prefixes"].sort()
70
+
71
+ auto_fields = []
72
+ seen = set()
73
+ for model in apps.get_models():
74
+ model_name = model._meta.object_name
75
+ if model_name in seen:
76
+ continue
77
+ seen.add(model_name)
78
+ prefixes = grouped.get(model_name, {}).get("prefixes", [])
79
+ for field in model._meta.fields:
80
+ if isinstance(field, SigilAutoFieldMixin):
81
+ auto_fields.append(
82
+ {
83
+ "model": model_name,
84
+ "roots": prefixes,
85
+ "field": field.name.upper(),
86
+ }
87
+ )
88
+
89
+ sigils_text = ""
90
+ resolved_text = ""
91
+ show_sigils_input = True
92
+ show_result = False
93
+ if request.method == "POST":
94
+ sigils_text = request.POST.get("sigils_text", "")
95
+ source_text = sigils_text
96
+ upload = request.FILES.get("sigils_file")
97
+ if upload:
98
+ source_text = upload.read().decode("utf-8", errors="ignore")
99
+ show_sigils_input = False
100
+ else:
101
+ single = request.POST.get("sigil", "")
102
+ if single:
103
+ source_text = (
104
+ f"[{single}]" if not single.startswith("[") else single
105
+ )
106
+ sigils_text = source_text
107
+ if source_text:
108
+ resolved_text = resolve_sigils_in_text(source_text)
109
+ show_result = True
110
+ if upload:
111
+ sigils_text = ""
112
+
113
+ context = admin.site.each_context(request)
114
+ context.update(
115
+ {
116
+ "title": _("Sigil Builder"),
117
+ "sigil_roots": roots,
118
+ "builtin_roots": builtin_roots,
119
+ "auto_fields": auto_fields,
120
+ "sigils_text": sigils_text,
121
+ "resolved_text": resolved_text,
122
+ "show_sigils_input": show_sigils_input,
123
+ "show_result": show_result,
124
+ }
125
+ )
126
+ return TemplateResponse(request, "admin/sigil_builder.html", context)
127
+
128
+
129
+ def patch_admin_sigil_builder_view() -> None:
130
+ """Add custom admin view for listing SigilRoots."""
131
+ original_get_urls = admin.site.get_urls
132
+
133
+ def get_urls():
134
+ urls = original_get_urls()
135
+ custom = [
136
+ path(
137
+ "sigil-builder/",
138
+ admin.site.admin_view(_sigil_builder_view),
139
+ name="sigil_builder",
140
+ ),
141
+ ]
142
+ return custom + urls
143
+
144
+ admin.site.get_urls = get_urls
core/sigil_context.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from threading import local
4
+ from typing import Dict, Type
5
+ from django.db import models
6
+
7
+ _thread = local()
8
+
9
+
10
+ def set_context(context: Dict[Type[models.Model], str]) -> None:
11
+ _thread.context = context
12
+
13
+
14
+ def get_context() -> Dict[Type[models.Model], str]:
15
+ return getattr(_thread, "context", {})
16
+
17
+
18
+ def clear_context() -> None:
19
+ if hasattr(_thread, "context"):
20
+ delattr(_thread, "context")