arthexis 0.1.13__py3-none-any.whl → 0.1.14__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 (107) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +708 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2200
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1128
  104. arthexis-0.1.13.dist-info/RECORD +0 -105
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
core/system.py CHANGED
@@ -1,493 +1,752 @@
1
- from __future__ import annotations
2
-
3
- from contextlib import closing
4
- from dataclasses import dataclass
5
- from datetime import datetime
6
- from pathlib import Path
7
- import json
8
- import re
9
- import socket
10
- import subprocess
11
- import shutil
12
- from typing import Callable, Iterable, Optional
13
-
14
- from django.conf import settings
15
- from django.contrib import admin
16
- from django.template.response import TemplateResponse
17
- from django.urls import path
18
- from django.utils import timezone
19
- from django.utils.formats import date_format
20
- from django.utils.translation import gettext_lazy as _
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 legacy ``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
- normalized_key = key.replace("-", "_").upper()
141
- if normalized_key == "NEXT_VER_CHECK":
142
- return _auto_upgrade_next_check()
143
- namespace, _, remainder = key.partition(".")
144
- if not remainder:
145
- return None
146
- normalized = namespace.replace("-", "_").upper()
147
- handler = _SYSTEM_SIGIL_NAMESPACES.get(normalized)
148
- if not handler:
149
- return None
150
- return handler(remainder)
151
-
152
-
153
- def _database_configurations() -> list[dict[str, str]]:
154
- """Return a normalized list of configured database connections."""
155
-
156
- databases: list[dict[str, str]] = []
157
- for alias, config in settings.DATABASES.items():
158
- engine = config.get("ENGINE", "")
159
- name = config.get("NAME", "")
160
- if engine is None:
161
- engine = ""
162
- if name is None:
163
- name = ""
164
- databases.append({
165
- "alias": alias,
166
- "engine": str(engine),
167
- "name": str(name),
168
- })
169
- databases.sort(key=lambda entry: entry["alias"].lower())
170
- return databases
171
-
172
-
173
- def _build_system_fields(info: dict[str, object]) -> list[SystemField]:
174
- """Convert gathered system information into renderable rows."""
175
-
176
- fields: list[SystemField] = []
177
-
178
- def add_field(label: str, key: str, value: object, *, field_type: str = "text", visible: bool = True) -> None:
179
- if not visible:
180
- return
181
- fields.append(SystemField(label=label, sigil_key=key, value=value, field_type=field_type))
182
-
183
- add_field(_("Suite installed"), "INSTALLED", info.get("installed", False), field_type="boolean")
184
- add_field(_("Revision"), "REVISION", info.get("revision", ""))
185
-
186
- service_value = info.get("service") or _("not installed")
187
- add_field(_("Service"), "SERVICE", service_value)
188
-
189
- nginx_mode = info.get("mode", "")
190
- port = info.get("port", "")
191
- nginx_display = f"{nginx_mode} ({port})" if port else nginx_mode
192
- add_field(_("Nginx mode"), "NGINX_MODE", nginx_display)
193
-
194
- add_field(_("Node role"), "NODE_ROLE", info.get("role", ""))
195
- add_field(
196
- _("Display mode"),
197
- "DISPLAY_MODE",
198
- info.get("screen_mode", ""),
199
- visible=bool(info.get("screen_mode")),
200
- )
201
-
202
- add_field(_("Features"), "FEATURES", info.get("features", []), field_type="features")
203
- add_field(_("Running"), "RUNNING", info.get("running", False), field_type="boolean")
204
- add_field(
205
- _("Service status"),
206
- "SERVICE_STATUS",
207
- info.get("service_status", ""),
208
- visible=bool(info.get("service")),
209
- )
210
-
211
- add_field(_("Hostname"), "HOSTNAME", info.get("hostname", ""))
212
-
213
- ip_addresses: Iterable[str] = info.get("ip_addresses", []) # type: ignore[assignment]
214
- add_field(_("IP addresses"), "IP_ADDRESSES", " ".join(ip_addresses))
215
-
216
- add_field(
217
- _("Databases"),
218
- "DATABASES",
219
- info.get("databases", []),
220
- field_type="databases",
221
- )
222
-
223
- add_field(
224
- _("Next version check"),
225
- "NEXT-VER-CHECK",
226
- info.get("auto_upgrade_next_check", ""),
227
- )
228
-
229
- return fields
230
-
231
-
232
- def _export_field_value(field: SystemField) -> str:
233
- """Serialize a ``SystemField`` value for sigil resolution."""
234
-
235
- if field.field_type in {"features", "databases"}:
236
- return json.dumps(field.value)
237
- if field.field_type == "boolean":
238
- return "True" if field.value else "False"
239
- if field.value is None:
240
- return ""
241
- return str(field.value)
242
-
243
-
244
- def get_system_sigil_values() -> dict[str, str]:
245
- """Expose system information in a format suitable for sigil lookups."""
246
-
247
- info = _gather_info()
248
- values: dict[str, str] = {}
249
- for field in _build_system_fields(info):
250
- exported = _export_field_value(field)
251
- raw_key = (field.sigil_key or "").strip()
252
- if not raw_key:
253
- continue
254
- variants = {
255
- raw_key.upper(),
256
- raw_key.replace("-", "_").upper(),
257
- }
258
- for variant in variants:
259
- values[variant] = exported
260
- return values
261
-
262
-
263
- def _parse_runserver_port(command_line: str) -> int | None:
264
- """Extract the HTTP port from a runserver command line."""
265
-
266
- for pattern in (_RUNSERVER_PORT_PATTERN, _RUNSERVER_PORT_FLAG_PATTERN):
267
- match = pattern.search(command_line)
268
- if match:
269
- try:
270
- return int(match.group(1))
271
- except ValueError:
272
- continue
273
- return None
274
-
275
-
276
- def _detect_runserver_process() -> tuple[bool, int | None]:
277
- """Return whether the dev server is running and the port if available."""
278
-
279
- try:
280
- result = subprocess.run(
281
- ["pgrep", "-af", "manage.py runserver"],
282
- capture_output=True,
283
- text=True,
284
- check=False,
285
- )
286
- except FileNotFoundError:
287
- return False, None
288
- except Exception:
289
- return False, None
290
-
291
- if result.returncode != 0:
292
- return False, None
293
-
294
- output = result.stdout.strip()
295
- if not output:
296
- return False, None
297
-
298
- port = None
299
- for line in output.splitlines():
300
- port = _parse_runserver_port(line)
301
- if port is not None:
302
- break
303
-
304
- if port is None:
305
- port = 8000
306
-
307
- return True, port
308
-
309
-
310
- def _probe_ports(candidates: list[int]) -> tuple[bool, int | None]:
311
- """Attempt to connect to localhost on the provided ports."""
312
-
313
- for port in candidates:
314
- try:
315
- with closing(socket.create_connection(("localhost", port), timeout=0.25)):
316
- return True, port
317
- except OSError:
318
- continue
319
- return False, None
320
-
321
-
322
- def _port_candidates(default_port: int) -> list[int]:
323
- """Return a prioritized list of ports to probe for the HTTP service."""
324
-
325
- candidates = [default_port]
326
- for port in (8000, 8888):
327
- if port not in candidates:
328
- candidates.append(port)
329
- return candidates
330
-
331
-
332
- def _gather_info() -> dict:
333
- """Collect basic system information similar to status.sh."""
334
- base_dir = Path(settings.BASE_DIR)
335
- lock_dir = base_dir / "locks"
336
- info: dict[str, object] = {}
337
-
338
- info["installed"] = (base_dir / ".venv").exists()
339
- info["revision"] = revision.get_revision()
340
-
341
- service_file = lock_dir / "service.lck"
342
- info["service"] = service_file.read_text().strip() if service_file.exists() else ""
343
-
344
- mode_file = lock_dir / "nginx_mode.lck"
345
- mode = mode_file.read_text().strip() if mode_file.exists() else "internal"
346
- info["mode"] = mode
347
- default_port = 8000 if mode == "public" else 8888
348
- detected_port: int | None = None
349
-
350
- screen_file = lock_dir / "screen_mode.lck"
351
- info["screen_mode"] = (
352
- screen_file.read_text().strip() if screen_file.exists() else ""
353
- )
354
-
355
- # Use settings.NODE_ROLE as the single source of truth for the node role.
356
- info["role"] = getattr(settings, "NODE_ROLE", "Terminal")
357
-
358
- features: list[dict[str, object]] = []
359
- try:
360
- from nodes.models import Node, NodeFeature
361
- except Exception:
362
- info["features"] = features
363
- else:
364
- feature_map: dict[str, dict[str, object]] = {}
365
-
366
- def _add_feature(feature: NodeFeature, flag: str) -> None:
367
- slug = getattr(feature, "slug", "") or ""
368
- if not slug:
369
- return
370
- display = (getattr(feature, "display", "") or "").strip()
371
- normalized = display or slug.replace("-", " ").title()
372
- entry = feature_map.setdefault(
373
- slug,
374
- {
375
- "slug": slug,
376
- "display": normalized,
377
- "expected": False,
378
- "actual": False,
379
- },
380
- )
381
- if display:
382
- entry["display"] = display
383
- entry[flag] = True
384
-
385
- try:
386
- expected_features = (
387
- NodeFeature.objects.filter(roles__name=info["role"]).only("slug", "display").distinct()
388
- )
389
- except Exception:
390
- expected_features = []
391
- try:
392
- for feature in expected_features:
393
- _add_feature(feature, "expected")
394
- except Exception:
395
- pass
396
-
397
- try:
398
- local_node = Node.get_local()
399
- except Exception:
400
- local_node = None
401
-
402
- actual_features = []
403
- if local_node:
404
- try:
405
- actual_features = list(local_node.features.only("slug", "display"))
406
- except Exception:
407
- actual_features = []
408
-
409
- try:
410
- for feature in actual_features:
411
- _add_feature(feature, "actual")
412
- except Exception:
413
- pass
414
-
415
- features = sorted(
416
- feature_map.values(),
417
- key=lambda item: str(item.get("display", "")).lower(),
418
- )
419
- info["features"] = features
420
-
421
- running = False
422
- service_status = ""
423
- service = info["service"]
424
- if service and shutil.which("systemctl"):
425
- try:
426
- result = subprocess.run(
427
- ["systemctl", "is-active", str(service)],
428
- capture_output=True,
429
- text=True,
430
- check=False,
431
- )
432
- service_status = result.stdout.strip()
433
- running = service_status == "active"
434
- except Exception:
435
- pass
436
- else:
437
- process_running, process_port = _detect_runserver_process()
438
- if process_running:
439
- running = True
440
- detected_port = process_port
441
-
442
- if not running or detected_port is None:
443
- probe_running, probe_port = _probe_ports(_port_candidates(default_port))
444
- if probe_running:
445
- running = True
446
- if detected_port is None:
447
- detected_port = probe_port
448
-
449
- info["running"] = running
450
- info["port"] = detected_port if detected_port is not None else default_port
451
- info["service_status"] = service_status
452
-
453
- try:
454
- hostname = socket.gethostname()
455
- ip_list = socket.gethostbyname_ex(hostname)[2]
456
- except Exception:
457
- hostname = ""
458
- ip_list = []
459
- info["hostname"] = hostname
460
- info["ip_addresses"] = ip_list
461
-
462
- info["databases"] = _database_configurations()
463
- info["auto_upgrade_next_check"] = _auto_upgrade_next_check()
464
-
465
- return info
466
-
467
-
468
- def _system_view(request):
469
- info = _gather_info()
470
-
471
- context = admin.site.each_context(request)
472
- context.update(
473
- {
474
- "title": _("System"),
475
- "info": info,
476
- "system_fields": _build_system_fields(info),
477
- }
478
- )
479
- return TemplateResponse(request, "admin/system.html", context)
480
-
481
-
482
- def patch_admin_system_view() -> None:
483
- """Add custom admin view for system information."""
484
- original_get_urls = admin.site.get_urls
485
-
486
- def get_urls():
487
- urls = original_get_urls()
488
- custom = [
489
- path("system/", admin.site.admin_view(_system_view), name="system"),
490
- ]
491
- return custom + urls
492
-
493
- admin.site.get_urls = get_urls
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from contextlib import closing
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ import json
9
+ import re
10
+ import socket
11
+ import subprocess
12
+ import shutil
13
+ from typing import Callable, Iterable, Optional
14
+
15
+ from django.conf import settings
16
+ from django.contrib import admin
17
+ from django.template.response import TemplateResponse
18
+ from django.urls import path
19
+ from django.utils import timezone
20
+ from django.utils.formats import date_format
21
+ from django.utils.translation import gettext_lazy as _
22
+
23
+ from core.auto_upgrade import AUTO_UPGRADE_TASK_NAME, AUTO_UPGRADE_TASK_PATH
24
+ from utils import revision
25
+
26
+
27
+ AUTO_UPGRADE_LOCK_NAME = "auto_upgrade.lck"
28
+ AUTO_UPGRADE_SKIP_LOCK_NAME = "auto_upgrade_skip_revisions.lck"
29
+ AUTO_UPGRADE_LOG_NAME = "auto-upgrade.log"
30
+
31
+
32
+ def _auto_upgrade_mode_file(base_dir: Path) -> Path:
33
+ return base_dir / "locks" / AUTO_UPGRADE_LOCK_NAME
34
+
35
+
36
+ def _auto_upgrade_skip_file(base_dir: Path) -> Path:
37
+ return base_dir / "locks" / AUTO_UPGRADE_SKIP_LOCK_NAME
38
+
39
+
40
+ def _auto_upgrade_log_file(base_dir: Path) -> Path:
41
+ return base_dir / "logs" / AUTO_UPGRADE_LOG_NAME
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SystemField:
46
+ """Metadata describing a single entry on the system admin page."""
47
+
48
+ label: str
49
+ sigil_key: str
50
+ value: object
51
+ field_type: str = "text"
52
+
53
+ @property
54
+ def sigil(self) -> str:
55
+ return f"SYS.{self.sigil_key}"
56
+
57
+
58
+ _RUNSERVER_PORT_PATTERN = re.compile(r":(\d{2,5})(?:\D|$)")
59
+ _RUNSERVER_PORT_FLAG_PATTERN = re.compile(r"--port(?:=|\s+)(\d{2,5})", re.IGNORECASE)
60
+
61
+
62
+ def _format_timestamp(dt: datetime | None) -> str:
63
+ """Return ``dt`` formatted using the active ``DATETIME_FORMAT``."""
64
+
65
+ if dt is None:
66
+ return ""
67
+ try:
68
+ localized = timezone.localtime(dt)
69
+ except Exception:
70
+ localized = dt
71
+ return date_format(localized, "DATETIME_FORMAT")
72
+
73
+
74
+ def _auto_upgrade_next_check() -> str:
75
+ """Return the human-readable timestamp for the next auto-upgrade check."""
76
+
77
+ try: # pragma: no cover - optional dependency failures
78
+ from django_celery_beat.models import PeriodicTask
79
+ except Exception:
80
+ return ""
81
+
82
+ try:
83
+ task = (
84
+ PeriodicTask.objects.select_related(
85
+ "interval", "crontab", "solar", "clocked"
86
+ )
87
+ .only("enabled", "last_run_at", "start_time", "name")
88
+ .get(name=AUTO_UPGRADE_TASK_NAME)
89
+ )
90
+ except PeriodicTask.DoesNotExist:
91
+ return ""
92
+ except Exception: # pragma: no cover - database unavailable
93
+ return ""
94
+
95
+ if not task.enabled:
96
+ return str(_("Disabled"))
97
+
98
+ schedule = task.schedule
99
+ if schedule is None:
100
+ return ""
101
+
102
+ now = schedule.maybe_make_aware(schedule.now())
103
+
104
+ start_time = task.start_time
105
+ if start_time is not None:
106
+ try:
107
+ candidate_start = schedule.maybe_make_aware(start_time)
108
+ except Exception:
109
+ candidate_start = (
110
+ timezone.make_aware(start_time)
111
+ if timezone.is_naive(start_time)
112
+ else start_time
113
+ )
114
+ if candidate_start and candidate_start > now:
115
+ return _format_timestamp(candidate_start)
116
+
117
+ last_run_at = task.last_run_at
118
+ if last_run_at is not None:
119
+ try:
120
+ reference = schedule.maybe_make_aware(last_run_at)
121
+ except Exception:
122
+ reference = (
123
+ timezone.make_aware(last_run_at)
124
+ if timezone.is_naive(last_run_at)
125
+ else last_run_at
126
+ )
127
+ else:
128
+ reference = now
129
+
130
+ try:
131
+ remaining = schedule.remaining_estimate(reference)
132
+ except Exception:
133
+ return ""
134
+
135
+ next_run = now + remaining
136
+ return _format_timestamp(next_run)
137
+
138
+
139
+ def _read_auto_upgrade_mode(base_dir: Path) -> dict[str, object]:
140
+ """Return metadata describing the configured auto-upgrade mode."""
141
+
142
+ mode_file = _auto_upgrade_mode_file(base_dir)
143
+ info: dict[str, object] = {
144
+ "mode": "version",
145
+ "enabled": False,
146
+ "lock_exists": mode_file.exists(),
147
+ "read_error": False,
148
+ }
149
+
150
+ if not info["lock_exists"]:
151
+ return info
152
+
153
+ info["enabled"] = True
154
+
155
+ try:
156
+ raw_value = mode_file.read_text(encoding="utf-8").strip()
157
+ except OSError:
158
+ info["read_error"] = True
159
+ return info
160
+
161
+ mode = raw_value or "version"
162
+ info["mode"] = mode
163
+ info["enabled"] = True
164
+ return info
165
+
166
+
167
+ def _load_auto_upgrade_skip_revisions(base_dir: Path) -> list[str]:
168
+ """Return a sorted list of revisions blocked from auto-upgrade."""
169
+
170
+ skip_file = _auto_upgrade_skip_file(base_dir)
171
+ try:
172
+ lines = skip_file.read_text(encoding="utf-8").splitlines()
173
+ except FileNotFoundError:
174
+ return []
175
+ except OSError:
176
+ return []
177
+
178
+ revisions = {line.strip() for line in lines if line.strip()}
179
+ return sorted(revisions)
180
+
181
+
182
+ def _parse_log_timestamp(value: str) -> datetime | None:
183
+ """Return a ``datetime`` parsed from ``value`` if it appears ISO formatted."""
184
+
185
+ if not value:
186
+ return None
187
+
188
+ candidate = value.strip()
189
+ if not candidate:
190
+ return None
191
+
192
+ if candidate[-1] in {"Z", "z"}:
193
+ candidate = f"{candidate[:-1]}+00:00"
194
+
195
+ try:
196
+ return datetime.fromisoformat(candidate)
197
+ except ValueError:
198
+ return None
199
+
200
+
201
+ def _load_auto_upgrade_log_entries(
202
+ base_dir: Path, *, limit: int = 25
203
+ ) -> dict[str, object]:
204
+ """Return the most recent auto-upgrade log entries."""
205
+
206
+ log_file = _auto_upgrade_log_file(base_dir)
207
+ result: dict[str, object] = {
208
+ "path": log_file,
209
+ "entries": [],
210
+ "error": "",
211
+ }
212
+
213
+ try:
214
+ with log_file.open("r", encoding="utf-8") as handle:
215
+ lines = deque((line.rstrip("\n") for line in handle), maxlen=limit)
216
+ except FileNotFoundError:
217
+ return result
218
+ except OSError:
219
+ result["error"] = str(
220
+ _("The auto-upgrade log could not be read."))
221
+ return result
222
+
223
+ entries: list[dict[str, str]] = []
224
+ for raw_line in lines:
225
+ line = raw_line.strip()
226
+ if not line:
227
+ continue
228
+ timestamp_str, _, message = line.partition(" ")
229
+ message = message.strip()
230
+ timestamp = _parse_log_timestamp(timestamp_str)
231
+ if not message:
232
+ message = timestamp_str
233
+ if timestamp is not None:
234
+ timestamp_display = _format_timestamp(timestamp)
235
+ else:
236
+ timestamp_display = timestamp_str
237
+ entries.append({
238
+ "timestamp": timestamp_display,
239
+ "message": message,
240
+ })
241
+
242
+ result["entries"] = entries
243
+ return result
244
+
245
+
246
+ def _get_auto_upgrade_periodic_task():
247
+ """Return the configured auto-upgrade periodic task, if available."""
248
+
249
+ try: # pragma: no cover - optional dependency failures
250
+ from django_celery_beat.models import PeriodicTask
251
+ except Exception:
252
+ return None, False, str(_("django-celery-beat is not installed or configured."))
253
+
254
+ try:
255
+ task = (
256
+ PeriodicTask.objects.select_related(
257
+ "interval", "crontab", "solar", "clocked"
258
+ )
259
+ .only(
260
+ "enabled",
261
+ "last_run_at",
262
+ "start_time",
263
+ "one_off",
264
+ "total_run_count",
265
+ "queue",
266
+ "expires",
267
+ "task",
268
+ "name",
269
+ "description",
270
+ )
271
+ .get(name=AUTO_UPGRADE_TASK_NAME)
272
+ )
273
+ except PeriodicTask.DoesNotExist:
274
+ return None, True, ""
275
+ except Exception:
276
+ return None, False, str(_("Auto-upgrade schedule could not be loaded."))
277
+
278
+ return task, True, ""
279
+
280
+
281
+ def _load_auto_upgrade_schedule() -> dict[str, object]:
282
+ """Return normalized auto-upgrade scheduling metadata."""
283
+
284
+ task, available, error = _get_auto_upgrade_periodic_task()
285
+ info: dict[str, object] = {
286
+ "available": available,
287
+ "configured": bool(task),
288
+ "enabled": getattr(task, "enabled", False) if task else False,
289
+ "one_off": getattr(task, "one_off", False) if task else False,
290
+ "queue": getattr(task, "queue", "") or "",
291
+ "schedule": "",
292
+ "start_time": "",
293
+ "last_run_at": "",
294
+ "next_run": "",
295
+ "total_run_count": 0,
296
+ "description": getattr(task, "description", "") or "",
297
+ "expires": "",
298
+ "task": getattr(task, "task", "") or "",
299
+ "name": getattr(task, "name", AUTO_UPGRADE_TASK_NAME) or AUTO_UPGRADE_TASK_NAME,
300
+ "error": error,
301
+ }
302
+
303
+ if not task:
304
+ return info
305
+
306
+ info["start_time"] = _format_timestamp(getattr(task, "start_time", None))
307
+ info["last_run_at"] = _format_timestamp(getattr(task, "last_run_at", None))
308
+ info["expires"] = _format_timestamp(getattr(task, "expires", None))
309
+ try:
310
+ run_count = int(getattr(task, "total_run_count", 0) or 0)
311
+ except (TypeError, ValueError):
312
+ run_count = 0
313
+ info["total_run_count"] = run_count
314
+
315
+ try:
316
+ schedule_obj = task.schedule
317
+ except Exception: # pragma: no cover - schedule property may raise
318
+ schedule_obj = None
319
+
320
+ if schedule_obj is not None:
321
+ try:
322
+ info["schedule"] = str(schedule_obj)
323
+ except Exception: # pragma: no cover - schedule string conversion failed
324
+ info["schedule"] = ""
325
+
326
+ info["next_run"] = _auto_upgrade_next_check()
327
+ return info
328
+
329
+
330
+ def _build_auto_upgrade_report(*, limit: int = 25) -> dict[str, object]:
331
+ """Assemble the composite auto-upgrade report for the admin view."""
332
+
333
+ base_dir = Path(settings.BASE_DIR)
334
+ mode_info = _read_auto_upgrade_mode(base_dir)
335
+ log_info = _load_auto_upgrade_log_entries(base_dir, limit=limit)
336
+ skip_revisions = _load_auto_upgrade_skip_revisions(base_dir)
337
+ schedule_info = _load_auto_upgrade_schedule()
338
+
339
+ mode_value = str(mode_info.get("mode", "version"))
340
+ is_latest = mode_value.lower() == "latest"
341
+
342
+ settings_info = {
343
+ "enabled": bool(mode_info.get("enabled", False)),
344
+ "mode": mode_value,
345
+ "is_latest": is_latest,
346
+ "lock_exists": bool(mode_info.get("lock_exists", False)),
347
+ "read_error": bool(mode_info.get("read_error", False)),
348
+ "mode_file": str(_auto_upgrade_mode_file(base_dir)),
349
+ "skip_revisions": skip_revisions,
350
+ "task_name": AUTO_UPGRADE_TASK_NAME,
351
+ "task_path": AUTO_UPGRADE_TASK_PATH,
352
+ "log_path": str(log_info.get("path")),
353
+ }
354
+
355
+ return {
356
+ "settings": settings_info,
357
+ "schedule": schedule_info,
358
+ "log_entries": log_info.get("entries", []),
359
+ "log_error": str(log_info.get("error", "")),
360
+ }
361
+
362
+
363
+ def _resolve_auto_upgrade_namespace(key: str) -> str | None:
364
+ """Resolve sigils within the legacy ``AUTO-UPGRADE`` namespace."""
365
+
366
+ normalized = key.replace("-", "_").upper()
367
+ if normalized == "NEXT_CHECK":
368
+ return _auto_upgrade_next_check()
369
+ return None
370
+
371
+
372
+ _SYSTEM_SIGIL_NAMESPACES: dict[str, Callable[[str], Optional[str]]] = {
373
+ "AUTO_UPGRADE": _resolve_auto_upgrade_namespace,
374
+ }
375
+
376
+
377
+ def resolve_system_namespace_value(key: str) -> str | None:
378
+ """Resolve dot-notation sigils mapped to dynamic ``SYS`` namespaces."""
379
+
380
+ if not key:
381
+ return None
382
+ normalized_key = key.replace("-", "_").upper()
383
+ if normalized_key == "NEXT_VER_CHECK":
384
+ return _auto_upgrade_next_check()
385
+ namespace, _, remainder = key.partition(".")
386
+ if not remainder:
387
+ return None
388
+ normalized = namespace.replace("-", "_").upper()
389
+ handler = _SYSTEM_SIGIL_NAMESPACES.get(normalized)
390
+ if not handler:
391
+ return None
392
+ return handler(remainder)
393
+
394
+
395
+ def _database_configurations() -> list[dict[str, str]]:
396
+ """Return a normalized list of configured database connections."""
397
+
398
+ databases: list[dict[str, str]] = []
399
+ for alias, config in settings.DATABASES.items():
400
+ engine = config.get("ENGINE", "")
401
+ name = config.get("NAME", "")
402
+ if engine is None:
403
+ engine = ""
404
+ if name is None:
405
+ name = ""
406
+ databases.append({
407
+ "alias": alias,
408
+ "engine": str(engine),
409
+ "name": str(name),
410
+ })
411
+ databases.sort(key=lambda entry: entry["alias"].lower())
412
+ return databases
413
+
414
+
415
+ def _build_system_fields(info: dict[str, object]) -> list[SystemField]:
416
+ """Convert gathered system information into renderable rows."""
417
+
418
+ fields: list[SystemField] = []
419
+
420
+ def add_field(label: str, key: str, value: object, *, field_type: str = "text", visible: bool = True) -> None:
421
+ if not visible:
422
+ return
423
+ fields.append(SystemField(label=label, sigil_key=key, value=value, field_type=field_type))
424
+
425
+ add_field(_("Suite installed"), "INSTALLED", info.get("installed", False), field_type="boolean")
426
+ add_field(_("Revision"), "REVISION", info.get("revision", ""))
427
+
428
+ service_value = info.get("service") or _("not installed")
429
+ add_field(_("Service"), "SERVICE", service_value)
430
+
431
+ nginx_mode = info.get("mode", "")
432
+ port = info.get("port", "")
433
+ nginx_display = f"{nginx_mode} ({port})" if port else nginx_mode
434
+ add_field(_("Nginx mode"), "NGINX_MODE", nginx_display)
435
+
436
+ add_field(_("Node role"), "NODE_ROLE", info.get("role", ""))
437
+ add_field(
438
+ _("Display mode"),
439
+ "DISPLAY_MODE",
440
+ info.get("screen_mode", ""),
441
+ visible=bool(info.get("screen_mode")),
442
+ )
443
+
444
+ add_field(_("Features"), "FEATURES", info.get("features", []), field_type="features")
445
+ add_field(_("Running"), "RUNNING", info.get("running", False), field_type="boolean")
446
+ add_field(
447
+ _("Service status"),
448
+ "SERVICE_STATUS",
449
+ info.get("service_status", ""),
450
+ visible=bool(info.get("service")),
451
+ )
452
+
453
+ add_field(_("Hostname"), "HOSTNAME", info.get("hostname", ""))
454
+
455
+ ip_addresses: Iterable[str] = info.get("ip_addresses", []) # type: ignore[assignment]
456
+ add_field(_("IP addresses"), "IP_ADDRESSES", " ".join(ip_addresses))
457
+
458
+ add_field(
459
+ _("Databases"),
460
+ "DATABASES",
461
+ info.get("databases", []),
462
+ field_type="databases",
463
+ )
464
+
465
+ add_field(
466
+ _("Next version check"),
467
+ "NEXT-VER-CHECK",
468
+ info.get("auto_upgrade_next_check", ""),
469
+ )
470
+
471
+ return fields
472
+
473
+
474
+ def _export_field_value(field: SystemField) -> str:
475
+ """Serialize a ``SystemField`` value for sigil resolution."""
476
+
477
+ if field.field_type in {"features", "databases"}:
478
+ return json.dumps(field.value)
479
+ if field.field_type == "boolean":
480
+ return "True" if field.value else "False"
481
+ if field.value is None:
482
+ return ""
483
+ return str(field.value)
484
+
485
+
486
+ def get_system_sigil_values() -> dict[str, str]:
487
+ """Expose system information in a format suitable for sigil lookups."""
488
+
489
+ info = _gather_info()
490
+ values: dict[str, str] = {}
491
+ for field in _build_system_fields(info):
492
+ exported = _export_field_value(field)
493
+ raw_key = (field.sigil_key or "").strip()
494
+ if not raw_key:
495
+ continue
496
+ variants = {
497
+ raw_key.upper(),
498
+ raw_key.replace("-", "_").upper(),
499
+ }
500
+ for variant in variants:
501
+ values[variant] = exported
502
+ return values
503
+
504
+
505
+ def _parse_runserver_port(command_line: str) -> int | None:
506
+ """Extract the HTTP port from a runserver command line."""
507
+
508
+ for pattern in (_RUNSERVER_PORT_PATTERN, _RUNSERVER_PORT_FLAG_PATTERN):
509
+ match = pattern.search(command_line)
510
+ if match:
511
+ try:
512
+ return int(match.group(1))
513
+ except ValueError:
514
+ continue
515
+ return None
516
+
517
+
518
+ def _detect_runserver_process() -> tuple[bool, int | None]:
519
+ """Return whether the dev server is running and the port if available."""
520
+
521
+ try:
522
+ result = subprocess.run(
523
+ ["pgrep", "-af", "manage.py runserver"],
524
+ capture_output=True,
525
+ text=True,
526
+ check=False,
527
+ )
528
+ except FileNotFoundError:
529
+ return False, None
530
+ except Exception:
531
+ return False, None
532
+
533
+ if result.returncode != 0:
534
+ return False, None
535
+
536
+ output = result.stdout.strip()
537
+ if not output:
538
+ return False, None
539
+
540
+ port = None
541
+ for line in output.splitlines():
542
+ port = _parse_runserver_port(line)
543
+ if port is not None:
544
+ break
545
+
546
+ if port is None:
547
+ port = 8000
548
+
549
+ return True, port
550
+
551
+
552
+ def _probe_ports(candidates: list[int]) -> tuple[bool, int | None]:
553
+ """Attempt to connect to localhost on the provided ports."""
554
+
555
+ for port in candidates:
556
+ try:
557
+ with closing(socket.create_connection(("localhost", port), timeout=0.25)):
558
+ return True, port
559
+ except OSError:
560
+ continue
561
+ return False, None
562
+
563
+
564
+ def _port_candidates(default_port: int) -> list[int]:
565
+ """Return a prioritized list of ports to probe for the HTTP service."""
566
+
567
+ candidates = [default_port]
568
+ for port in (8000, 8888):
569
+ if port not in candidates:
570
+ candidates.append(port)
571
+ return candidates
572
+
573
+
574
+ def _gather_info() -> dict:
575
+ """Collect basic system information similar to status.sh."""
576
+ base_dir = Path(settings.BASE_DIR)
577
+ lock_dir = base_dir / "locks"
578
+ info: dict[str, object] = {}
579
+
580
+ info["installed"] = (base_dir / ".venv").exists()
581
+ info["revision"] = revision.get_revision()
582
+
583
+ service_file = lock_dir / "service.lck"
584
+ info["service"] = service_file.read_text().strip() if service_file.exists() else ""
585
+
586
+ mode_file = lock_dir / "nginx_mode.lck"
587
+ mode = mode_file.read_text().strip() if mode_file.exists() else "internal"
588
+ info["mode"] = mode
589
+ default_port = 8000 if mode == "public" else 8888
590
+ detected_port: int | None = None
591
+
592
+ screen_file = lock_dir / "screen_mode.lck"
593
+ info["screen_mode"] = (
594
+ screen_file.read_text().strip() if screen_file.exists() else ""
595
+ )
596
+
597
+ # Use settings.NODE_ROLE as the single source of truth for the node role.
598
+ info["role"] = getattr(settings, "NODE_ROLE", "Terminal")
599
+
600
+ features: list[dict[str, object]] = []
601
+ try:
602
+ from nodes.models import Node, NodeFeature
603
+ except Exception:
604
+ info["features"] = features
605
+ else:
606
+ feature_map: dict[str, dict[str, object]] = {}
607
+
608
+ def _add_feature(feature: NodeFeature, flag: str) -> None:
609
+ slug = getattr(feature, "slug", "") or ""
610
+ if not slug:
611
+ return
612
+ display = (getattr(feature, "display", "") or "").strip()
613
+ normalized = display or slug.replace("-", " ").title()
614
+ entry = feature_map.setdefault(
615
+ slug,
616
+ {
617
+ "slug": slug,
618
+ "display": normalized,
619
+ "expected": False,
620
+ "actual": False,
621
+ },
622
+ )
623
+ if display:
624
+ entry["display"] = display
625
+ entry[flag] = True
626
+
627
+ try:
628
+ expected_features = (
629
+ NodeFeature.objects.filter(roles__name=info["role"]).only("slug", "display").distinct()
630
+ )
631
+ except Exception:
632
+ expected_features = []
633
+ try:
634
+ for feature in expected_features:
635
+ _add_feature(feature, "expected")
636
+ except Exception:
637
+ pass
638
+
639
+ try:
640
+ local_node = Node.get_local()
641
+ except Exception:
642
+ local_node = None
643
+
644
+ actual_features = []
645
+ if local_node:
646
+ try:
647
+ actual_features = list(local_node.features.only("slug", "display"))
648
+ except Exception:
649
+ actual_features = []
650
+
651
+ try:
652
+ for feature in actual_features:
653
+ _add_feature(feature, "actual")
654
+ except Exception:
655
+ pass
656
+
657
+ features = sorted(
658
+ feature_map.values(),
659
+ key=lambda item: str(item.get("display", "")).lower(),
660
+ )
661
+ info["features"] = features
662
+
663
+ running = False
664
+ service_status = ""
665
+ service = info["service"]
666
+ if service and shutil.which("systemctl"):
667
+ try:
668
+ result = subprocess.run(
669
+ ["systemctl", "is-active", str(service)],
670
+ capture_output=True,
671
+ text=True,
672
+ check=False,
673
+ )
674
+ service_status = result.stdout.strip()
675
+ running = service_status == "active"
676
+ except Exception:
677
+ pass
678
+ else:
679
+ process_running, process_port = _detect_runserver_process()
680
+ if process_running:
681
+ running = True
682
+ detected_port = process_port
683
+
684
+ if not running or detected_port is None:
685
+ probe_running, probe_port = _probe_ports(_port_candidates(default_port))
686
+ if probe_running:
687
+ running = True
688
+ if detected_port is None:
689
+ detected_port = probe_port
690
+
691
+ info["running"] = running
692
+ info["port"] = detected_port if detected_port is not None else default_port
693
+ info["service_status"] = service_status
694
+
695
+ try:
696
+ hostname = socket.gethostname()
697
+ ip_list = socket.gethostbyname_ex(hostname)[2]
698
+ except Exception:
699
+ hostname = ""
700
+ ip_list = []
701
+ info["hostname"] = hostname
702
+ info["ip_addresses"] = ip_list
703
+
704
+ info["databases"] = _database_configurations()
705
+ info["auto_upgrade_next_check"] = _auto_upgrade_next_check()
706
+
707
+ return info
708
+
709
+
710
+ def _system_view(request):
711
+ info = _gather_info()
712
+
713
+ context = admin.site.each_context(request)
714
+ context.update(
715
+ {
716
+ "title": _("System"),
717
+ "info": info,
718
+ "system_fields": _build_system_fields(info),
719
+ }
720
+ )
721
+ return TemplateResponse(request, "admin/system.html", context)
722
+
723
+
724
+ def _system_upgrade_report_view(request):
725
+ context = admin.site.each_context(request)
726
+ context.update(
727
+ {
728
+ "title": _("Upgrade Report"),
729
+ "auto_upgrade_report": _build_auto_upgrade_report(),
730
+ }
731
+ )
732
+ return TemplateResponse(request, "admin/system_upgrade_report.html", context)
733
+
734
+
735
+ def patch_admin_system_view() -> None:
736
+ """Add custom admin view for system information."""
737
+ original_get_urls = admin.site.get_urls
738
+
739
+ def get_urls():
740
+ urls = original_get_urls()
741
+ custom = [
742
+ path("system/", admin.site.admin_view(_system_view), name="system"),
743
+ path(
744
+ "system/upgrade-report/",
745
+ admin.site.admin_view(_system_upgrade_report_view),
746
+ name="system-upgrade-report",
747
+ ),
748
+ ]
749
+ return custom + urls
750
+
751
+ admin.site.get_urls = get_urls
752
+