arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
core/system.py CHANGED
@@ -1,23 +1,330 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from contextlib import closing
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
3
6
  from pathlib import Path
4
- import io
7
+ import json
8
+ import re
5
9
  import socket
6
10
  import subprocess
7
11
  import shutil
8
- import argparse
9
- import time
12
+ from typing import Callable, Iterable, Optional
10
13
 
11
- from django import forms
12
14
  from django.conf import settings
13
- from django.contrib import admin, messages
14
- from django.core.management import get_commands, load_command_class
15
- from django.http import Http404
16
- from django.shortcuts import redirect
15
+ from django.contrib import admin
17
16
  from django.template.response import TemplateResponse
18
- from django.urls import path, reverse
17
+ from django.urls import path
18
+ from django.utils import timezone
19
+ from django.utils.formats import date_format
19
20
  from django.utils.translation import gettext_lazy as _
20
21
 
22
+ from core.auto_upgrade import AUTO_UPGRADE_TASK_NAME
23
+ from utils import revision
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SystemField:
28
+ """Metadata describing a single entry on the system admin page."""
29
+
30
+ label: str
31
+ sigil_key: str
32
+ value: object
33
+ field_type: str = "text"
34
+
35
+ @property
36
+ def sigil(self) -> str:
37
+ return f"SYS.{self.sigil_key}"
38
+
39
+
40
+ _RUNSERVER_PORT_PATTERN = re.compile(r":(\d{2,5})(?:\D|$)")
41
+ _RUNSERVER_PORT_FLAG_PATTERN = re.compile(r"--port(?:=|\s+)(\d{2,5})", re.IGNORECASE)
42
+
43
+
44
+ def _format_timestamp(dt: datetime | None) -> str:
45
+ """Return ``dt`` formatted using the active ``DATETIME_FORMAT``."""
46
+
47
+ if dt is None:
48
+ return ""
49
+ try:
50
+ localized = timezone.localtime(dt)
51
+ except Exception:
52
+ localized = dt
53
+ return date_format(localized, "DATETIME_FORMAT")
54
+
55
+
56
+ def _auto_upgrade_next_check() -> str:
57
+ """Return the human-readable timestamp for the next auto-upgrade check."""
58
+
59
+ try: # pragma: no cover - optional dependency failures
60
+ from django_celery_beat.models import PeriodicTask
61
+ except Exception:
62
+ return ""
63
+
64
+ try:
65
+ task = (
66
+ PeriodicTask.objects.select_related(
67
+ "interval", "crontab", "solar", "clocked"
68
+ )
69
+ .only("enabled", "last_run_at", "start_time", "name")
70
+ .get(name=AUTO_UPGRADE_TASK_NAME)
71
+ )
72
+ except PeriodicTask.DoesNotExist:
73
+ return ""
74
+ except Exception: # pragma: no cover - database unavailable
75
+ return ""
76
+
77
+ if not task.enabled:
78
+ return str(_("Disabled"))
79
+
80
+ schedule = task.schedule
81
+ if schedule is None:
82
+ return ""
83
+
84
+ now = schedule.maybe_make_aware(schedule.now())
85
+
86
+ start_time = task.start_time
87
+ if start_time is not None:
88
+ try:
89
+ candidate_start = schedule.maybe_make_aware(start_time)
90
+ except Exception:
91
+ candidate_start = (
92
+ timezone.make_aware(start_time)
93
+ if timezone.is_naive(start_time)
94
+ else start_time
95
+ )
96
+ if candidate_start and candidate_start > now:
97
+ return _format_timestamp(candidate_start)
98
+
99
+ last_run_at = task.last_run_at
100
+ if last_run_at is not None:
101
+ try:
102
+ reference = schedule.maybe_make_aware(last_run_at)
103
+ except Exception:
104
+ reference = (
105
+ timezone.make_aware(last_run_at)
106
+ if timezone.is_naive(last_run_at)
107
+ else last_run_at
108
+ )
109
+ else:
110
+ reference = now
111
+
112
+ try:
113
+ remaining = schedule.remaining_estimate(reference)
114
+ except Exception:
115
+ return ""
116
+
117
+ next_run = now + remaining
118
+ return _format_timestamp(next_run)
119
+
120
+
121
+ def _resolve_auto_upgrade_namespace(key: str) -> str | None:
122
+ """Resolve sigils within the ``AUTO-UPGRADE`` namespace."""
123
+
124
+ normalized = key.replace("-", "_").upper()
125
+ if normalized == "NEXT_CHECK":
126
+ return _auto_upgrade_next_check()
127
+ return None
128
+
129
+
130
+ _SYSTEM_SIGIL_NAMESPACES: dict[str, Callable[[str], Optional[str]]] = {
131
+ "AUTO_UPGRADE": _resolve_auto_upgrade_namespace,
132
+ }
133
+
134
+
135
+ def resolve_system_namespace_value(key: str) -> str | None:
136
+ """Resolve dot-notation sigils mapped to dynamic ``SYS`` namespaces."""
137
+
138
+ if not key:
139
+ return None
140
+ namespace, _, remainder = key.partition(".")
141
+ if not remainder:
142
+ return None
143
+ normalized = namespace.replace("-", "_").upper()
144
+ handler = _SYSTEM_SIGIL_NAMESPACES.get(normalized)
145
+ if not handler:
146
+ return None
147
+ return handler(remainder)
148
+
149
+
150
+ def _database_configurations() -> list[dict[str, str]]:
151
+ """Return a normalized list of configured database connections."""
152
+
153
+ databases: list[dict[str, str]] = []
154
+ for alias, config in settings.DATABASES.items():
155
+ engine = config.get("ENGINE", "")
156
+ name = config.get("NAME", "")
157
+ if engine is None:
158
+ engine = ""
159
+ if name is None:
160
+ name = ""
161
+ databases.append({
162
+ "alias": alias,
163
+ "engine": str(engine),
164
+ "name": str(name),
165
+ })
166
+ databases.sort(key=lambda entry: entry["alias"].lower())
167
+ return databases
168
+
169
+
170
+ def _build_system_fields(info: dict[str, object]) -> list[SystemField]:
171
+ """Convert gathered system information into renderable rows."""
172
+
173
+ fields: list[SystemField] = []
174
+
175
+ def add_field(label: str, key: str, value: object, *, field_type: str = "text", visible: bool = True) -> None:
176
+ if not visible:
177
+ return
178
+ fields.append(SystemField(label=label, sigil_key=key, value=value, field_type=field_type))
179
+
180
+ add_field(_("Suite installed"), "INSTALLED", info.get("installed", False), field_type="boolean")
181
+ add_field(_("Revision"), "REVISION", info.get("revision", ""))
182
+
183
+ service_value = info.get("service") or _("not installed")
184
+ add_field(_("Service"), "SERVICE", service_value)
185
+
186
+ nginx_mode = info.get("mode", "")
187
+ port = info.get("port", "")
188
+ nginx_display = f"{nginx_mode} ({port})" if port else nginx_mode
189
+ add_field(_("Nginx mode"), "NGINX_MODE", nginx_display)
190
+
191
+ add_field(_("Node role"), "NODE_ROLE", info.get("role", ""))
192
+ add_field(
193
+ _("Display mode"),
194
+ "DISPLAY_MODE",
195
+ info.get("screen_mode", ""),
196
+ visible=bool(info.get("screen_mode")),
197
+ )
198
+
199
+ add_field(_("Features"), "FEATURES", info.get("features", []), field_type="features")
200
+ add_field(_("Running"), "RUNNING", info.get("running", False), field_type="boolean")
201
+ add_field(
202
+ _("Service status"),
203
+ "SERVICE_STATUS",
204
+ info.get("service_status", ""),
205
+ visible=bool(info.get("service")),
206
+ )
207
+
208
+ add_field(_("Hostname"), "HOSTNAME", info.get("hostname", ""))
209
+
210
+ ip_addresses: Iterable[str] = info.get("ip_addresses", []) # type: ignore[assignment]
211
+ add_field(_("IP addresses"), "IP_ADDRESSES", " ".join(ip_addresses))
212
+
213
+ add_field(
214
+ _("Databases"),
215
+ "DATABASES",
216
+ info.get("databases", []),
217
+ field_type="databases",
218
+ )
219
+
220
+ add_field(
221
+ _("Next auto-upgrade check"),
222
+ "AUTO-UPGRADE.NEXT-CHECK",
223
+ info.get("auto_upgrade_next_check", ""),
224
+ )
225
+
226
+ return fields
227
+
228
+
229
+ def _export_field_value(field: SystemField) -> str:
230
+ """Serialize a ``SystemField`` value for sigil resolution."""
231
+
232
+ if field.field_type in {"features", "databases"}:
233
+ return json.dumps(field.value)
234
+ if field.field_type == "boolean":
235
+ return "True" if field.value else "False"
236
+ if field.value is None:
237
+ return ""
238
+ return str(field.value)
239
+
240
+
241
+ def get_system_sigil_values() -> dict[str, str]:
242
+ """Expose system information in a format suitable for sigil lookups."""
243
+
244
+ info = _gather_info()
245
+ values: dict[str, str] = {}
246
+ for field in _build_system_fields(info):
247
+ exported = _export_field_value(field)
248
+ raw_key = (field.sigil_key or "").strip()
249
+ if not raw_key:
250
+ continue
251
+ variants = {
252
+ raw_key.upper(),
253
+ raw_key.replace("-", "_").upper(),
254
+ }
255
+ for variant in variants:
256
+ values[variant] = exported
257
+ return values
258
+
259
+
260
+ def _parse_runserver_port(command_line: str) -> int | None:
261
+ """Extract the HTTP port from a runserver command line."""
262
+
263
+ for pattern in (_RUNSERVER_PORT_PATTERN, _RUNSERVER_PORT_FLAG_PATTERN):
264
+ match = pattern.search(command_line)
265
+ if match:
266
+ try:
267
+ return int(match.group(1))
268
+ except ValueError:
269
+ continue
270
+ return None
271
+
272
+
273
+ def _detect_runserver_process() -> tuple[bool, int | None]:
274
+ """Return whether the dev server is running and the port if available."""
275
+
276
+ try:
277
+ result = subprocess.run(
278
+ ["pgrep", "-af", "manage.py runserver"],
279
+ capture_output=True,
280
+ text=True,
281
+ check=False,
282
+ )
283
+ except FileNotFoundError:
284
+ return False, None
285
+ except Exception:
286
+ return False, None
287
+
288
+ if result.returncode != 0:
289
+ return False, None
290
+
291
+ output = result.stdout.strip()
292
+ if not output:
293
+ return False, None
294
+
295
+ port = None
296
+ for line in output.splitlines():
297
+ port = _parse_runserver_port(line)
298
+ if port is not None:
299
+ break
300
+
301
+ if port is None:
302
+ port = 8000
303
+
304
+ return True, port
305
+
306
+
307
+ def _probe_ports(candidates: list[int]) -> tuple[bool, int | None]:
308
+ """Attempt to connect to localhost on the provided ports."""
309
+
310
+ for port in candidates:
311
+ try:
312
+ with closing(socket.create_connection(("localhost", port), timeout=0.25)):
313
+ return True, port
314
+ except OSError:
315
+ continue
316
+ return False, None
317
+
318
+
319
+ def _port_candidates(default_port: int) -> list[int]:
320
+ """Return a prioritized list of ports to probe for the HTTP service."""
321
+
322
+ candidates = [default_port]
323
+ for port in (8000, 8888):
324
+ if port not in candidates:
325
+ candidates.append(port)
326
+ return candidates
327
+
21
328
 
22
329
  def _gather_info() -> dict:
23
330
  """Collect basic system information similar to status.sh."""
@@ -26,6 +333,7 @@ def _gather_info() -> dict:
26
333
  info: dict[str, object] = {}
27
334
 
28
335
  info["installed"] = (base_dir / ".venv").exists()
336
+ info["revision"] = revision.get_revision()
29
337
 
30
338
  service_file = lock_dir / "service.lck"
31
339
  info["service"] = service_file.read_text().strip() if service_file.exists() else ""
@@ -33,7 +341,8 @@ def _gather_info() -> dict:
33
341
  mode_file = lock_dir / "nginx_mode.lck"
34
342
  mode = mode_file.read_text().strip() if mode_file.exists() else "internal"
35
343
  info["mode"] = mode
36
- info["port"] = 8000 if mode == "public" else 8888
344
+ default_port = 8000 if mode == "public" else 8888
345
+ detected_port: int | None = None
37
346
 
38
347
  screen_file = lock_dir / "screen_mode.lck"
39
348
  info["screen_mode"] = (
@@ -43,11 +352,68 @@ def _gather_info() -> dict:
43
352
  # Use settings.NODE_ROLE as the single source of truth for the node role.
44
353
  info["role"] = getattr(settings, "NODE_ROLE", "Terminal")
45
354
 
46
- info["features"] = {
47
- "celery": (lock_dir / "celery.lck").exists(),
48
- "lcd_screen": (lock_dir / "lcd_screen.lck").exists(),
49
- "control": (lock_dir / "control.lck").exists(),
50
- }
355
+ features: list[dict[str, object]] = []
356
+ try:
357
+ from nodes.models import Node, NodeFeature
358
+ except Exception:
359
+ info["features"] = features
360
+ else:
361
+ feature_map: dict[str, dict[str, object]] = {}
362
+
363
+ def _add_feature(feature: NodeFeature, flag: str) -> None:
364
+ slug = getattr(feature, "slug", "") or ""
365
+ if not slug:
366
+ return
367
+ display = (getattr(feature, "display", "") or "").strip()
368
+ normalized = display or slug.replace("-", " ").title()
369
+ entry = feature_map.setdefault(
370
+ slug,
371
+ {
372
+ "slug": slug,
373
+ "display": normalized,
374
+ "expected": False,
375
+ "actual": False,
376
+ },
377
+ )
378
+ if display:
379
+ entry["display"] = display
380
+ entry[flag] = True
381
+
382
+ try:
383
+ expected_features = (
384
+ NodeFeature.objects.filter(roles__name=info["role"]).only("slug", "display").distinct()
385
+ )
386
+ except Exception:
387
+ expected_features = []
388
+ try:
389
+ for feature in expected_features:
390
+ _add_feature(feature, "expected")
391
+ except Exception:
392
+ pass
393
+
394
+ try:
395
+ local_node = Node.get_local()
396
+ except Exception:
397
+ local_node = None
398
+
399
+ actual_features = []
400
+ if local_node:
401
+ try:
402
+ actual_features = list(local_node.features.only("slug", "display"))
403
+ except Exception:
404
+ actual_features = []
405
+
406
+ try:
407
+ for feature in actual_features:
408
+ _add_feature(feature, "actual")
409
+ except Exception:
410
+ pass
411
+
412
+ features = sorted(
413
+ feature_map.values(),
414
+ key=lambda item: str(item.get("display", "")).lower(),
415
+ )
416
+ info["features"] = features
51
417
 
52
418
  running = False
53
419
  service_status = ""
@@ -65,17 +431,20 @@ def _gather_info() -> dict:
65
431
  except Exception:
66
432
  pass
67
433
  else:
68
- try:
69
- subprocess.run(
70
- ["pgrep", "-f", "manage.py runserver"],
71
- check=True,
72
- stdout=subprocess.PIPE,
73
- stderr=subprocess.PIPE,
74
- )
434
+ process_running, process_port = _detect_runserver_process()
435
+ if process_running:
75
436
  running = True
76
- except Exception:
77
- running = False
437
+ detected_port = process_port
438
+
439
+ if not running or detected_port is None:
440
+ probe_running, probe_port = _probe_ports(_port_candidates(default_port))
441
+ if probe_running:
442
+ running = True
443
+ if detected_port is None:
444
+ detected_port = probe_port
445
+
78
446
  info["running"] = running
447
+ info["port"] = detected_port if detected_port is not None else default_port
79
448
  info["service_status"] = service_status
80
449
 
81
450
  try:
@@ -87,128 +456,24 @@ def _gather_info() -> dict:
87
456
  info["hostname"] = hostname
88
457
  info["ip_addresses"] = ip_list
89
458
 
459
+ info["databases"] = _database_configurations()
460
+ info["auto_upgrade_next_check"] = _auto_upgrade_next_check()
461
+
90
462
  return info
91
463
 
92
464
 
93
465
  def _system_view(request):
94
466
  info = _gather_info()
95
- if request.method == "POST" and request.user.is_superuser:
96
- action = request.POST.get("action")
97
- stop_script = Path(settings.BASE_DIR) / "stop.sh"
98
- args = [str(stop_script)]
99
- if action == "stop":
100
- password = request.POST.get("password", "")
101
- if not request.user.check_password(password):
102
- messages.error(request, _("Incorrect password."))
103
- else:
104
- lock_file = Path(settings.BASE_DIR) / "locks" / "charging.lck"
105
- age = None
106
- if lock_file.exists():
107
- age = time.time() - lock_file.stat().st_mtime
108
- if lock_file.exists() and age is not None and age <= 600:
109
- messages.error(request, _("Charging session in progress."))
110
- else:
111
- if info["service"]:
112
- args.append("--all")
113
- subprocess.Popen(args)
114
- return redirect(reverse("admin:index"))
115
- elif action == "restart":
116
- subprocess.Popen(args)
117
- return redirect(reverse("admin:index"))
118
-
119
- excluded = {
120
- "shell",
121
- "dbshell",
122
- "createsuperuser",
123
- "changepassword",
124
- "startapp",
125
- "startproject",
126
- "runserver",
127
- }
128
- commands = sorted(cmd for cmd in get_commands().keys() if cmd not in excluded)
129
-
130
- context = admin.site.each_context(request)
131
- context.update({"title": _("System"), "info": info, "commands": commands})
132
- return TemplateResponse(request, "admin/system.html", context)
133
-
134
-
135
- def _build_form(parser: argparse.ArgumentParser) -> type[forms.Form]:
136
- fields: dict[str, forms.Field] = {}
137
- for action in parser._actions:
138
- if action.help == argparse.SUPPRESS or action.dest == "help":
139
- continue
140
- label = action.option_strings[0] if action.option_strings else action.dest
141
- required = (
142
- action.required
143
- if action.option_strings
144
- else action.nargs not in ["?", "*", argparse.OPTIONAL]
145
- )
146
- fields[action.dest] = forms.CharField(label=label, required=required)
147
- return type("CommandForm", (forms.Form,), fields)
148
-
149
-
150
- def _system_command_view(request, command):
151
- commands = get_commands()
152
- if command not in commands:
153
- raise Http404
154
- app_name = commands[command]
155
- cmd_instance = load_command_class(app_name, command)
156
- parser = cmd_instance.create_parser("manage.py", command)
157
- form_class = _build_form(parser)
158
- form = form_class(request.POST or None)
159
- output = ""
160
-
161
- has_required = any(
162
- (a.option_strings and a.required)
163
- or (not a.option_strings and a.nargs not in ["?", "*", argparse.OPTIONAL])
164
- for a in parser._actions
165
- if a.help != argparse.SUPPRESS and a.dest != "help"
166
- )
167
-
168
- if not has_required and request.method == "GET":
169
- out = io.StringIO()
170
- cmd_instance.stdout = out
171
- cmd_instance.stderr = out
172
- try:
173
- cmd_instance.run_from_argv(["manage.py", command])
174
- except Exception as exc:
175
- out.write(str(exc))
176
- output = out.getvalue()
177
- form = None
178
- elif request.method == "POST" and form.is_valid():
179
- argv = ["manage.py", command]
180
- for action in parser._actions:
181
- if action.help == argparse.SUPPRESS or action.dest == "help":
182
- continue
183
- val = form.cleaned_data.get(action.dest)
184
- if val in (None, ""):
185
- continue
186
- if action.option_strings:
187
- argv.append(action.option_strings[0])
188
- if action.nargs != 0:
189
- argv.append(val)
190
- else:
191
- argv.append(val)
192
- out = io.StringIO()
193
- cmd_instance.stdout = out
194
- cmd_instance.stderr = out
195
- try:
196
- cmd_instance.run_from_argv(argv)
197
- except Exception as exc:
198
- out.write(str(exc))
199
- output = out.getvalue()
200
- form = None
201
467
 
202
468
  context = admin.site.each_context(request)
203
469
  context.update(
204
470
  {
205
- "title": command,
206
- "command_name": command,
207
- "form": form,
208
- "output": output,
471
+ "title": _("System"),
472
+ "info": info,
473
+ "system_fields": _build_system_fields(info),
209
474
  }
210
475
  )
211
- return TemplateResponse(request, "admin/system_command.html", context)
476
+ return TemplateResponse(request, "admin/system.html", context)
212
477
 
213
478
 
214
479
  def patch_admin_system_view() -> None:
@@ -219,11 +484,6 @@ def patch_admin_system_view() -> None:
219
484
  urls = original_get_urls()
220
485
  custom = [
221
486
  path("system/", admin.site.admin_view(_system_view), name="system"),
222
- path(
223
- "system/command/<str:command>/",
224
- admin.site.admin_view(_system_command_view),
225
- name="system_command",
226
- ),
227
487
  ]
228
488
  return custom + urls
229
489