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/sigil_resolver.py ADDED
@@ -0,0 +1,284 @@
1
+ import logging
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ from functools import lru_cache
6
+ from typing import Optional
7
+
8
+ from django.apps import apps
9
+ from django.conf import settings
10
+ from django.core import serializers
11
+ from django.db import models
12
+
13
+ from .sigil_context import get_context
14
+
15
+ logger = logging.getLogger("core.entity")
16
+
17
+
18
+ def _is_wizard_mode() -> bool:
19
+ """Return ``True`` when the application is running in wizard mode."""
20
+
21
+ flag = getattr(settings, "WIZARD_MODE", False)
22
+ if isinstance(flag, str):
23
+ return flag.lower() in {"1", "true", "yes", "on"}
24
+ return bool(flag)
25
+
26
+
27
+ def _first_instance(model: type[models.Model]) -> Optional[models.Model]:
28
+ qs = model.objects
29
+ ordering = list(getattr(model._meta, "ordering", []))
30
+ if ordering:
31
+ qs = qs.order_by(*ordering)
32
+ else:
33
+ qs = qs.order_by("?")
34
+ return qs.first()
35
+
36
+
37
+ @lru_cache(maxsize=1)
38
+ def _find_gway_command() -> Optional[str]:
39
+ path = shutil.which("gway")
40
+ if path:
41
+ return path
42
+ for candidate in ("~/.local/bin/gway", "/usr/local/bin/gway"):
43
+ expanded = os.path.expanduser(candidate)
44
+ if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
45
+ return expanded
46
+ return None
47
+
48
+
49
+ def _resolve_with_gway(sigil: str) -> Optional[str]:
50
+ command = _find_gway_command()
51
+ if not command:
52
+ return None
53
+ timeout = 60 if _is_wizard_mode() else 1
54
+ try:
55
+ result = subprocess.run(
56
+ [command, "-e", sigil],
57
+ check=False,
58
+ stdout=subprocess.PIPE,
59
+ stderr=subprocess.PIPE,
60
+ text=True,
61
+ timeout=timeout,
62
+ )
63
+ except subprocess.TimeoutExpired:
64
+ logger.warning(
65
+ "gway timed out after %s seconds while resolving sigil %s",
66
+ timeout,
67
+ sigil,
68
+ )
69
+ return None
70
+ except Exception:
71
+ logger.exception("Failed executing gway for sigil %s", sigil)
72
+ return None
73
+ if result.returncode != 0:
74
+ logger.warning(
75
+ "gway exited with status %s while resolving sigil %s",
76
+ result.returncode,
77
+ sigil,
78
+ )
79
+ return None
80
+ return result.stdout.strip()
81
+
82
+
83
+ def _failed_resolution(token: str) -> str:
84
+ sigil = f"[{token}]"
85
+ resolved = _resolve_with_gway(sigil)
86
+ if resolved is not None:
87
+ return resolved
88
+ return sigil
89
+
90
+
91
+ def _resolve_token(token: str, current: Optional[models.Model] = None) -> str:
92
+ original_token = token
93
+ i = 0
94
+ n = len(token)
95
+ root_name = ""
96
+ while i < n and token[i] not in ":=.":
97
+ root_name += token[i]
98
+ i += 1
99
+ if not root_name:
100
+ return _failed_resolution(original_token)
101
+ filter_field = None
102
+ if i < n and token[i] == ":":
103
+ i += 1
104
+ field = ""
105
+ while i < n and token[i] != "=":
106
+ field += token[i]
107
+ i += 1
108
+ if i == n:
109
+ return _failed_resolution(original_token)
110
+ filter_field = field.replace("-", "_")
111
+ instance_id = None
112
+ if i < n and token[i] == "=":
113
+ i += 1
114
+ start = i
115
+ depth = 0
116
+ while i < n:
117
+ ch = token[i]
118
+ if ch == "[":
119
+ depth += 1
120
+ elif ch == "]" and depth:
121
+ depth -= 1
122
+ elif ch == "." and depth == 0:
123
+ break
124
+ i += 1
125
+ instance_id = token[start:i]
126
+ key = None
127
+ if i < n and token[i] == ".":
128
+ i += 1
129
+ start = i
130
+ while i < n and token[i] != "=":
131
+ i += 1
132
+ key = token[start:i]
133
+ param = None
134
+ if i < n and token[i] == "=":
135
+ param = token[i + 1 :]
136
+ normalized_root = root_name.replace("-", "_")
137
+ lookup_root = normalized_root.upper()
138
+ raw_key = key
139
+ normalized_key = None
140
+ key_upper = None
141
+ key_lower = None
142
+ if key:
143
+ normalized_key = key.replace("-", "_")
144
+ key_upper = normalized_key.upper()
145
+ key_lower = normalized_key.lower()
146
+ if param:
147
+ param = resolve_sigils(param, current)
148
+ if instance_id:
149
+ instance_id = resolve_sigils(instance_id, current)
150
+ SigilRoot = apps.get_model("core", "SigilRoot")
151
+ try:
152
+ root = SigilRoot.objects.get(prefix__iexact=lookup_root)
153
+ if root.context_type == SigilRoot.Context.CONFIG:
154
+ if not normalized_key:
155
+ return ""
156
+ if root.prefix.upper() == "ENV":
157
+ candidates = []
158
+ if raw_key:
159
+ candidates.append(raw_key.replace("-", "_"))
160
+ if normalized_key:
161
+ candidates.append(normalized_key)
162
+ if key_upper:
163
+ candidates.append(key_upper)
164
+ if key_lower:
165
+ candidates.append(key_lower)
166
+ seen_candidates: set[str] = set()
167
+ for candidate in candidates:
168
+ if not candidate or candidate in seen_candidates:
169
+ continue
170
+ seen_candidates.add(candidate)
171
+ val = os.environ.get(candidate)
172
+ if val is not None:
173
+ return val
174
+ logger.warning(
175
+ "Missing environment variable for sigil [ENV.%s]",
176
+ key_upper or normalized_key or raw_key or "",
177
+ )
178
+ return _failed_resolution(original_token)
179
+ if root.prefix.upper() == "SYS":
180
+ for candidate in [normalized_key, key_upper, key_lower]:
181
+ if not candidate:
182
+ continue
183
+ sentinel = object()
184
+ value = getattr(settings, candidate, sentinel)
185
+ if value is not sentinel:
186
+ return str(value)
187
+ fallback = _resolve_with_gway(f"[{original_token}]")
188
+ if fallback is not None:
189
+ return fallback
190
+ return ""
191
+ elif root.context_type == SigilRoot.Context.ENTITY:
192
+ model = root.content_type.model_class() if root.content_type else None
193
+ instance = None
194
+ if model:
195
+ if instance_id:
196
+ try:
197
+ if filter_field:
198
+ field_name = filter_field.lower()
199
+ try:
200
+ field_obj = model._meta.get_field(field_name)
201
+ except Exception:
202
+ field_obj = None
203
+ lookup: dict[str, str] = {}
204
+ if field_obj and isinstance(field_obj, models.CharField):
205
+ lookup = {f"{field_name}__iexact": instance_id}
206
+ else:
207
+ lookup = {field_name: instance_id}
208
+ instance = model.objects.filter(**lookup).first()
209
+ else:
210
+ instance = model.objects.filter(pk=instance_id).first()
211
+ except Exception:
212
+ instance = None
213
+ if instance is None and not filter_field:
214
+ for field in model._meta.fields:
215
+ if field.unique and isinstance(field, models.CharField):
216
+ instance = model.objects.filter(
217
+ **{f"{field.name}__iexact": instance_id}
218
+ ).first()
219
+ if instance:
220
+ break
221
+ elif current and isinstance(current, model):
222
+ instance = current
223
+ else:
224
+ ctx = get_context()
225
+ inst_pk = ctx.get(model)
226
+ if inst_pk is not None:
227
+ instance = model.objects.filter(pk=inst_pk).first()
228
+ if instance is None:
229
+ instance = _first_instance(model)
230
+ if instance:
231
+ if normalized_key:
232
+ field = next(
233
+ (
234
+ f
235
+ for f in model._meta.fields
236
+ if f.name.lower() == (key_lower or "")
237
+ ),
238
+ None,
239
+ )
240
+ if field:
241
+ val = getattr(instance, field.attname)
242
+ return "" if val is None else str(val)
243
+ return _failed_resolution(original_token)
244
+ return serializers.serialize("json", [instance])
245
+ return _failed_resolution(original_token)
246
+ except SigilRoot.DoesNotExist:
247
+ logger.warning("Unknown sigil root [%s]", lookup_root)
248
+ except Exception:
249
+ logger.exception(
250
+ "Error resolving sigil [%s.%s]",
251
+ lookup_root,
252
+ key_upper or normalized_key or raw_key,
253
+ )
254
+ return _failed_resolution(original_token)
255
+
256
+
257
+ def resolve_sigils(text: str, current: Optional[models.Model] = None) -> str:
258
+ result = ""
259
+ i = 0
260
+ while i < len(text):
261
+ if text[i] == "[":
262
+ depth = 1
263
+ j = i + 1
264
+ while j < len(text) and depth:
265
+ if text[j] == "[":
266
+ depth += 1
267
+ elif text[j] == "]":
268
+ depth -= 1
269
+ j += 1
270
+ if depth:
271
+ result += text[i]
272
+ i += 1
273
+ continue
274
+ token = text[i + 1 : j - 1]
275
+ result += _resolve_token(token, current)
276
+ i = j
277
+ else:
278
+ result += text[i]
279
+ i += 1
280
+ return result
281
+
282
+
283
+ def resolve_sigil(sigil: str, current: Optional[models.Model] = None) -> str:
284
+ return resolve_sigils(sigil, current)
core/system.py CHANGED
@@ -1,44 +1,182 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from contextlib import closing
3
4
  from pathlib import Path
5
+ import re
4
6
  import socket
5
7
  import subprocess
6
8
  import shutil
7
9
 
8
10
  from django.conf import settings
9
11
  from django.contrib import admin
10
- from django.shortcuts import redirect
11
12
  from django.template.response import TemplateResponse
12
- from django.urls import path, reverse
13
+ from django.urls import path
13
14
  from django.utils.translation import gettext_lazy as _
14
15
 
16
+ from utils import revision
17
+
18
+
19
+ _RUNSERVER_PORT_PATTERN = re.compile(r":(\d{2,5})(?:\D|$)")
20
+ _RUNSERVER_PORT_FLAG_PATTERN = re.compile(r"--port(?:=|\s+)(\d{2,5})", re.IGNORECASE)
21
+
22
+
23
+ def _parse_runserver_port(command_line: str) -> int | None:
24
+ """Extract the HTTP port from a runserver command line."""
25
+
26
+ for pattern in (_RUNSERVER_PORT_PATTERN, _RUNSERVER_PORT_FLAG_PATTERN):
27
+ match = pattern.search(command_line)
28
+ if match:
29
+ try:
30
+ return int(match.group(1))
31
+ except ValueError:
32
+ continue
33
+ return None
34
+
35
+
36
+ def _detect_runserver_process() -> tuple[bool, int | None]:
37
+ """Return whether the dev server is running and the port if available."""
38
+
39
+ try:
40
+ result = subprocess.run(
41
+ ["pgrep", "-af", "manage.py runserver"],
42
+ capture_output=True,
43
+ text=True,
44
+ check=False,
45
+ )
46
+ except FileNotFoundError:
47
+ return False, None
48
+ except Exception:
49
+ return False, None
50
+
51
+ if result.returncode != 0:
52
+ return False, None
53
+
54
+ output = result.stdout.strip()
55
+ if not output:
56
+ return False, None
57
+
58
+ port = None
59
+ for line in output.splitlines():
60
+ port = _parse_runserver_port(line)
61
+ if port is not None:
62
+ break
63
+
64
+ if port is None:
65
+ port = 8000
66
+
67
+ return True, port
68
+
69
+
70
+ def _probe_ports(candidates: list[int]) -> tuple[bool, int | None]:
71
+ """Attempt to connect to localhost on the provided ports."""
72
+
73
+ for port in candidates:
74
+ try:
75
+ with closing(socket.create_connection(("localhost", port), timeout=0.25)):
76
+ return True, port
77
+ except OSError:
78
+ continue
79
+ return False, None
80
+
81
+
82
+ def _port_candidates(default_port: int) -> list[int]:
83
+ """Return a prioritized list of ports to probe for the HTTP service."""
84
+
85
+ candidates = [default_port]
86
+ for port in (8000, 8888):
87
+ if port not in candidates:
88
+ candidates.append(port)
89
+ return candidates
90
+
15
91
 
16
92
  def _gather_info() -> dict:
17
- """Collect basic system information similar to status-check.sh."""
93
+ """Collect basic system information similar to status.sh."""
18
94
  base_dir = Path(settings.BASE_DIR)
19
95
  lock_dir = base_dir / "locks"
20
96
  info: dict[str, object] = {}
21
97
 
22
98
  info["installed"] = (base_dir / ".venv").exists()
99
+ info["revision"] = revision.get_revision()
23
100
 
24
101
  service_file = lock_dir / "service.lck"
25
- info["service"] = (
26
- service_file.read_text().strip() if service_file.exists() else ""
27
- )
102
+ info["service"] = service_file.read_text().strip() if service_file.exists() else ""
28
103
 
29
104
  mode_file = lock_dir / "nginx_mode.lck"
30
105
  mode = mode_file.read_text().strip() if mode_file.exists() else "internal"
31
106
  info["mode"] = mode
32
- info["port"] = 8000 if mode == "public" else 8888
107
+ default_port = 8000 if mode == "public" else 8888
108
+ detected_port: int | None = None
109
+
110
+ screen_file = lock_dir / "screen_mode.lck"
111
+ info["screen_mode"] = (
112
+ screen_file.read_text().strip() if screen_file.exists() else ""
113
+ )
33
114
 
34
115
  # Use settings.NODE_ROLE as the single source of truth for the node role.
35
116
  info["role"] = getattr(settings, "NODE_ROLE", "Terminal")
36
117
 
37
- info["features"] = {
38
- "celery": (lock_dir / "celery.lck").exists(),
39
- "lcd_screen": (lock_dir / "lcd_screen.lck").exists(),
40
- "control": (lock_dir / "control.lck").exists(),
41
- }
118
+ features: list[dict[str, object]] = []
119
+ try:
120
+ from nodes.models import Node, NodeFeature
121
+ except Exception:
122
+ info["features"] = features
123
+ else:
124
+ feature_map: dict[str, dict[str, object]] = {}
125
+
126
+ def _add_feature(feature: NodeFeature, flag: str) -> None:
127
+ slug = getattr(feature, "slug", "") or ""
128
+ if not slug:
129
+ return
130
+ display = (getattr(feature, "display", "") or "").strip()
131
+ normalized = display or slug.replace("-", " ").title()
132
+ entry = feature_map.setdefault(
133
+ slug,
134
+ {
135
+ "slug": slug,
136
+ "display": normalized,
137
+ "expected": False,
138
+ "actual": False,
139
+ },
140
+ )
141
+ if display:
142
+ entry["display"] = display
143
+ entry[flag] = True
144
+
145
+ try:
146
+ expected_features = (
147
+ NodeFeature.objects.filter(roles__name=info["role"]).only("slug", "display").distinct()
148
+ )
149
+ except Exception:
150
+ expected_features = []
151
+ try:
152
+ for feature in expected_features:
153
+ _add_feature(feature, "expected")
154
+ except Exception:
155
+ pass
156
+
157
+ try:
158
+ local_node = Node.get_local()
159
+ except Exception:
160
+ local_node = None
161
+
162
+ actual_features = []
163
+ if local_node:
164
+ try:
165
+ actual_features = list(local_node.features.only("slug", "display"))
166
+ except Exception:
167
+ actual_features = []
168
+
169
+ try:
170
+ for feature in actual_features:
171
+ _add_feature(feature, "actual")
172
+ except Exception:
173
+ pass
174
+
175
+ features = sorted(
176
+ feature_map.values(),
177
+ key=lambda item: str(item.get("display", "")).lower(),
178
+ )
179
+ info["features"] = features
42
180
 
43
181
  running = False
44
182
  service_status = ""
@@ -56,17 +194,20 @@ def _gather_info() -> dict:
56
194
  except Exception:
57
195
  pass
58
196
  else:
59
- try:
60
- subprocess.run(
61
- ["pgrep", "-f", "manage.py runserver"],
62
- check=True,
63
- stdout=subprocess.PIPE,
64
- stderr=subprocess.PIPE,
65
- )
197
+ process_running, process_port = _detect_runserver_process()
198
+ if process_running:
66
199
  running = True
67
- except Exception:
68
- running = False
200
+ detected_port = process_port
201
+
202
+ if not running or detected_port is None:
203
+ probe_running, probe_port = _probe_ports(_port_candidates(default_port))
204
+ if probe_running:
205
+ running = True
206
+ if detected_port is None:
207
+ detected_port = probe_port
208
+
69
209
  info["running"] = running
210
+ info["port"] = detected_port if detected_port is not None else default_port
70
211
  info["service_status"] = service_status
71
212
 
72
213
  try:
@@ -83,14 +224,6 @@ def _gather_info() -> dict:
83
224
 
84
225
  def _system_view(request):
85
226
  info = _gather_info()
86
- if request.method == "POST" and request.user.is_superuser:
87
- action = request.POST.get("action")
88
- stop_script = Path(settings.BASE_DIR) / "stop.sh"
89
- args = [str(stop_script)]
90
- if action == "stop" and info["service"]:
91
- args.append("--all")
92
- subprocess.Popen(args)
93
- return redirect(reverse("admin:index"))
94
227
 
95
228
  context = admin.site.each_context(request)
96
229
  context.update({"title": _("System"), "info": info})