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.
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {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
|
|
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)
|
core/reference_utils.py
ADDED
|
@@ -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="
|
|
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(
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
*,
|
|
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 =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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")
|