arthexis 0.1.7__py3-none-any.whl → 0.1.9__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 (82) hide show
  1. arthexis-0.1.9.dist-info/METADATA +168 -0
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.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 +134 -16
  10. config/urls.py +71 -3
  11. core/admin.py +1331 -165
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +151 -0
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1136 -259
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +445 -58
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +17 -0
  42. core/workgroup_views.py +94 -0
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +4 -3
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.7.dist-info/METADATA +0 -126
  77. arthexis-0.1.7.dist-info/RECORD +0 -77
  78. arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
  79. config/workgroup_app.py +0 -7
  80. core/checks.py +0 -29
  81. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  82. {arthexis-0.1.7.dist-info → arthexis-0.1.9.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)
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,131 @@
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
+ if request.method == "POST":
92
+ sigils_text = request.POST.get("sigils_text", "")
93
+ upload = request.FILES.get("sigils_file")
94
+ if upload:
95
+ sigils_text = upload.read().decode("utf-8", errors="ignore")
96
+ else:
97
+ single = request.POST.get("sigil", "")
98
+ if single:
99
+ sigils_text = f"[{single}]" if not single.startswith("[") else single
100
+ resolved_text = resolve_sigils_in_text(sigils_text) if sigils_text else ""
101
+
102
+ context = admin.site.each_context(request)
103
+ context.update(
104
+ {
105
+ "title": _("Sigil Builder"),
106
+ "sigil_roots": roots,
107
+ "builtin_roots": builtin_roots,
108
+ "auto_fields": auto_fields,
109
+ "sigils_text": sigils_text,
110
+ "resolved_text": resolved_text,
111
+ }
112
+ )
113
+ return TemplateResponse(request, "admin/sigil_builder.html", context)
114
+
115
+
116
+ def patch_admin_sigil_builder_view() -> None:
117
+ """Add custom admin view for listing SigilRoots."""
118
+ original_get_urls = admin.site.get_urls
119
+
120
+ def get_urls():
121
+ urls = original_get_urls()
122
+ custom = [
123
+ path(
124
+ "sigil-builder/",
125
+ admin.site.admin_view(_sigil_builder_view),
126
+ name="sigil_builder",
127
+ ),
128
+ ]
129
+ return custom + urls
130
+
131
+ 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")