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/tasks.py CHANGED
@@ -1,17 +1,91 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import subprocess
4
- from datetime import datetime
5
5
  from pathlib import Path
6
+ import urllib.error
7
+ import urllib.request
6
8
 
7
9
  from celery import shared_task
10
+ from django.conf import settings
11
+ from django.contrib.auth import get_user_model
12
+ from core import mailer
13
+ from core import github_issues
14
+ from django.utils import timezone
15
+
16
+ from nodes.models import NetMessage
17
+
18
+
19
+ AUTO_UPGRADE_HEALTH_DELAY_SECONDS = 30
20
+ AUTO_UPGRADE_HEALTH_MAX_ATTEMPTS = 3
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @shared_task
27
+ def heartbeat() -> None:
28
+ """Log a simple heartbeat message."""
29
+ logger.info("Heartbeat task executed")
30
+
31
+
32
+ @shared_task
33
+ def birthday_greetings() -> None:
34
+ """Send birthday greetings to users via Net Message and email."""
35
+ User = get_user_model()
36
+ today = timezone.localdate()
37
+ for user in User.objects.filter(birthday=today):
38
+ NetMessage.broadcast("Happy bday!", user.username)
39
+ if user.email:
40
+ mailer.send(
41
+ "Happy bday!",
42
+ f"Happy bday! {user.username}",
43
+ [user.email],
44
+ settings.DEFAULT_FROM_EMAIL,
45
+ fail_silently=True,
46
+ )
47
+
48
+
49
+ def _auto_upgrade_log_path(base_dir: Path) -> Path:
50
+ """Return the log file used for auto-upgrade events."""
51
+
52
+ log_dir = base_dir / "logs"
53
+ log_dir.mkdir(parents=True, exist_ok=True)
54
+ return log_dir / "auto-upgrade.log"
55
+
56
+
57
+ def _append_auto_upgrade_log(base_dir: Path, message: str) -> None:
58
+ """Append ``message`` to the auto-upgrade log, ignoring errors."""
59
+
60
+ try:
61
+ log_file = _auto_upgrade_log_path(base_dir)
62
+ timestamp = timezone.now().isoformat()
63
+ with log_file.open("a") as fh:
64
+ fh.write(f"{timestamp} {message}\n")
65
+ except Exception: # pragma: no cover - best effort logging only
66
+ logger.warning("Failed to append auto-upgrade log entry: %s", message)
67
+
68
+
69
+ def _resolve_service_url(base_dir: Path) -> str:
70
+ """Return the local URL used to probe the Django suite."""
71
+
72
+ lock_dir = base_dir / "locks"
73
+ mode_file = lock_dir / "nginx_mode.lck"
74
+ mode = "internal"
75
+ if mode_file.exists():
76
+ try:
77
+ mode = mode_file.read_text().strip() or "internal"
78
+ except OSError:
79
+ mode = "internal"
80
+ port = 8000 if mode == "public" else 8888
81
+ return f"http://127.0.0.1:{port}/"
8
82
 
9
83
 
10
84
  @shared_task
11
85
  def check_github_updates() -> None:
12
86
  """Check the GitHub repo for updates and upgrade if needed."""
13
87
  base_dir = Path(__file__).resolve().parent.parent
14
- mode_file = base_dir / "AUTO_UPGRADE"
88
+ mode_file = base_dir / "locks" / "auto_upgrade.lck"
15
89
  mode = "version"
16
90
  if mode_file.exists():
17
91
  mode = mode_file.read_text().strip()
@@ -19,11 +93,11 @@ def check_github_updates() -> None:
19
93
  branch = "main"
20
94
  subprocess.run(["git", "fetch", "origin", branch], cwd=base_dir, check=True)
21
95
 
22
- log_dir = base_dir / "logs"
23
- log_dir.mkdir(parents=True, exist_ok=True)
24
- log_file = log_dir / "auto-upgrade.log"
96
+ log_file = _auto_upgrade_log_path(base_dir)
25
97
  with log_file.open("a") as fh:
26
- fh.write(f"{datetime.utcnow().isoformat()} check_github_updates triggered\n")
98
+ fh.write(
99
+ f"{timezone.now().isoformat()} check_github_updates triggered\n"
100
+ )
27
101
 
28
102
  notify = None
29
103
  startup = None
@@ -36,56 +110,94 @@ def check_github_updates() -> None:
36
110
  except Exception:
37
111
  startup = None
38
112
 
113
+ upgrade_stamp = timezone.now().strftime("@ %Y%m%d %H:%M")
114
+
115
+ upgrade_was_applied = False
116
+
39
117
  if mode == "latest":
40
- local = subprocess.check_output(["git", "rev-parse", branch], cwd=base_dir).decode().strip()
41
- remote = subprocess.check_output([
42
- "git",
43
- "rev-parse",
44
- f"origin/{branch}",
45
- ], cwd=base_dir).decode().strip()
118
+ local = (
119
+ subprocess.check_output(["git", "rev-parse", branch], cwd=base_dir)
120
+ .decode()
121
+ .strip()
122
+ )
123
+ remote = (
124
+ subprocess.check_output(
125
+ [
126
+ "git",
127
+ "rev-parse",
128
+ f"origin/{branch}",
129
+ ],
130
+ cwd=base_dir,
131
+ )
132
+ .decode()
133
+ .strip()
134
+ )
46
135
  if local == remote:
47
136
  if startup:
48
137
  startup()
49
138
  return
50
139
  if notify:
51
- notify("Upgrading...", "")
140
+ notify("Upgrading...", upgrade_stamp)
52
141
  args = ["./upgrade.sh", "--latest", "--no-restart"]
142
+ upgrade_was_applied = True
53
143
  else:
54
144
  local = "0"
55
145
  version_file = base_dir / "VERSION"
56
146
  if version_file.exists():
57
147
  local = version_file.read_text().strip()
58
- remote = subprocess.check_output([
59
- "git",
60
- "show",
61
- f"origin/{branch}:VERSION",
62
- ], cwd=base_dir).decode().strip()
148
+ remote = (
149
+ subprocess.check_output(
150
+ [
151
+ "git",
152
+ "show",
153
+ f"origin/{branch}:VERSION",
154
+ ],
155
+ cwd=base_dir,
156
+ )
157
+ .decode()
158
+ .strip()
159
+ )
63
160
  if local == remote:
64
161
  if startup:
65
162
  startup()
66
163
  return
67
164
  if notify:
68
- notify("Upgrading...", "")
165
+ notify("Upgrading...", upgrade_stamp)
69
166
  args = ["./upgrade.sh", "--no-restart"]
167
+ upgrade_was_applied = True
70
168
 
71
169
  with log_file.open("a") as fh:
72
- fh.write(f"{datetime.utcnow().isoformat()} running: {' '.join(args)}\n")
170
+ fh.write(
171
+ f"{timezone.now().isoformat()} running: {' '.join(args)}\n"
172
+ )
73
173
 
74
174
  subprocess.run(args, cwd=base_dir, check=True)
75
175
 
76
176
  service_file = base_dir / "locks/service.lck"
77
177
  if service_file.exists():
78
178
  service = service_file.read_text().strip()
79
- subprocess.run([
80
- "sudo",
81
- "systemctl",
82
- "kill",
83
- "--signal=TERM",
84
- service,
85
- ])
179
+ subprocess.run(
180
+ [
181
+ "sudo",
182
+ "systemctl",
183
+ "kill",
184
+ "--signal=TERM",
185
+ service,
186
+ ]
187
+ )
86
188
  else:
87
189
  subprocess.run(["pkill", "-f", "manage.py runserver"])
88
190
 
191
+ if upgrade_was_applied:
192
+ _append_auto_upgrade_log(
193
+ base_dir,
194
+ (
195
+ "Scheduled post-upgrade health check in %s seconds"
196
+ % AUTO_UPGRADE_HEALTH_DELAY_SECONDS
197
+ ),
198
+ )
199
+ _schedule_health_check(1)
200
+
89
201
 
90
202
  @shared_task
91
203
  def poll_email_collectors() -> None:
@@ -98,3 +210,133 @@ def poll_email_collectors() -> None:
98
210
  for collector in EmailCollector.objects.all():
99
211
  collector.collect()
100
212
 
213
+
214
+ @shared_task
215
+ def report_runtime_issue(
216
+ title: str,
217
+ body: str,
218
+ labels: list[str] | None = None,
219
+ fingerprint: str | None = None,
220
+ ):
221
+ """Report a runtime issue to GitHub using :mod:`core.github_issues`."""
222
+
223
+ try:
224
+ response = github_issues.create_issue(
225
+ title,
226
+ body,
227
+ labels=labels,
228
+ fingerprint=fingerprint,
229
+ )
230
+ except Exception:
231
+ logger.exception("Failed to report runtime issue '%s'", title)
232
+ raise
233
+
234
+ if response is None:
235
+ logger.info("Skipped GitHub issue creation for fingerprint %s", fingerprint)
236
+ else:
237
+ logger.info("Reported runtime issue '%s' to GitHub", title)
238
+
239
+ return response
240
+
241
+
242
+ def _record_health_check_result(
243
+ base_dir: Path, attempt: int, status: int | None, detail: str
244
+ ) -> None:
245
+ status_display = status if status is not None else "unreachable"
246
+ message = "Health check attempt %s %s (%s)" % (attempt, detail, status_display)
247
+ _append_auto_upgrade_log(base_dir, message)
248
+
249
+
250
+ def _schedule_health_check(next_attempt: int) -> None:
251
+ verify_auto_upgrade_health.apply_async(
252
+ kwargs={"attempt": next_attempt},
253
+ countdown=AUTO_UPGRADE_HEALTH_DELAY_SECONDS,
254
+ )
255
+
256
+
257
+ @shared_task
258
+ def verify_auto_upgrade_health(attempt: int = 1) -> bool | None:
259
+ """Verify the upgraded suite responds successfully.
260
+
261
+ When the check fails three times in a row the upgrade is rolled back by
262
+ invoking ``upgrade.sh --revert``.
263
+ """
264
+
265
+ base_dir = Path(__file__).resolve().parent.parent
266
+ url = _resolve_service_url(base_dir)
267
+ request = urllib.request.Request(
268
+ url,
269
+ headers={"User-Agent": "Arthexis-AutoUpgrade/1.0"},
270
+ )
271
+
272
+ status: int | None = None
273
+ try:
274
+ with urllib.request.urlopen(request, timeout=10) as response:
275
+ status = getattr(response, "status", response.getcode())
276
+ except urllib.error.HTTPError as exc:
277
+ status = exc.code
278
+ logger.warning(
279
+ "Auto-upgrade health check attempt %s returned HTTP %s", attempt, exc.code
280
+ )
281
+ except urllib.error.URLError as exc:
282
+ logger.warning(
283
+ "Auto-upgrade health check attempt %s failed: %s", attempt, exc
284
+ )
285
+ except Exception as exc: # pragma: no cover - unexpected network error
286
+ logger.exception(
287
+ "Unexpected error probing suite during auto-upgrade attempt %s", attempt
288
+ )
289
+ detail = f"failed with {exc}"
290
+ _record_health_check_result(base_dir, attempt, status, detail)
291
+ if attempt >= AUTO_UPGRADE_HEALTH_MAX_ATTEMPTS:
292
+ _append_auto_upgrade_log(
293
+ base_dir,
294
+ "Health check raised unexpected error; reverting upgrade",
295
+ )
296
+ subprocess.run(["./upgrade.sh", "--revert"], cwd=base_dir, check=True)
297
+ else:
298
+ _schedule_health_check(attempt + 1)
299
+ return None
300
+
301
+ if status == 200:
302
+ _record_health_check_result(base_dir, attempt, status, "succeeded")
303
+ logger.info(
304
+ "Auto-upgrade health check succeeded on attempt %s with HTTP %s",
305
+ attempt,
306
+ status,
307
+ )
308
+ return True
309
+
310
+ _record_health_check_result(base_dir, attempt, status, "failed")
311
+
312
+ if attempt >= AUTO_UPGRADE_HEALTH_MAX_ATTEMPTS:
313
+ logger.error(
314
+ "Auto-upgrade health check failed after %s attempts; reverting", attempt
315
+ )
316
+ _append_auto_upgrade_log(
317
+ base_dir,
318
+ "Health check failed three times; reverting upgrade",
319
+ )
320
+ subprocess.run(["./upgrade.sh", "--revert"], cwd=base_dir, check=True)
321
+ return False
322
+
323
+ _schedule_health_check(attempt + 1)
324
+ return None
325
+
326
+
327
+ @shared_task
328
+ def run_client_report_schedule(schedule_id: int) -> None:
329
+ """Execute a :class:`core.models.ClientReportSchedule` run."""
330
+
331
+ from core.models import ClientReportSchedule
332
+
333
+ schedule = ClientReportSchedule.objects.filter(pk=schedule_id).first()
334
+ if not schedule:
335
+ logger.warning("ClientReportSchedule %s no longer exists", schedule_id)
336
+ return
337
+
338
+ try:
339
+ schedule.run()
340
+ except Exception:
341
+ logger.exception("ClientReportSchedule %s failed", schedule_id)
342
+ raise
core/test_system_info.py CHANGED
@@ -1,11 +1,18 @@
1
1
  import os
2
+ from pathlib import Path
3
+ from subprocess import CompletedProcess
4
+ from unittest.mock import patch
5
+
2
6
 
3
7
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
8
 
5
9
  import django
10
+
6
11
  django.setup()
7
12
 
8
- from django.test import SimpleTestCase, override_settings
13
+ from django.conf import settings
14
+ from django.test import SimpleTestCase, TestCase, override_settings
15
+ from nodes.models import Node, NodeFeature, NodeRole
9
16
  from core.system import _gather_info
10
17
 
11
18
 
@@ -19,3 +26,54 @@ class SystemInfoRoleTests(SimpleTestCase):
19
26
  def test_uses_settings_role(self):
20
27
  info = _gather_info()
21
28
  self.assertEqual(info["role"], "Satellite")
29
+
30
+
31
+ class SystemInfoScreenModeTests(SimpleTestCase):
32
+ def test_without_lockfile(self):
33
+ info = _gather_info()
34
+ self.assertEqual(info["screen_mode"], "")
35
+
36
+ def test_with_lockfile(self):
37
+ lock_dir = Path(settings.BASE_DIR) / "locks"
38
+ lock_dir.mkdir(exist_ok=True)
39
+ lock_file = lock_dir / "screen_mode.lck"
40
+ lock_file.write_text("tft")
41
+ try:
42
+ info = _gather_info()
43
+ self.assertEqual(info["screen_mode"], "tft")
44
+ finally:
45
+ lock_file.unlink()
46
+ if not any(lock_dir.iterdir()):
47
+ lock_dir.rmdir()
48
+
49
+
50
+ class SystemInfoRevisionTests(SimpleTestCase):
51
+ @patch("core.system.revision.get_revision", return_value="abcdef1234567890")
52
+ def test_includes_full_revision(self, mock_revision):
53
+ info = _gather_info()
54
+ self.assertEqual(info["revision"], "abcdef1234567890")
55
+ mock_revision.assert_called_once()
56
+
57
+
58
+ class SystemInfoRunserverDetectionTests(SimpleTestCase):
59
+ @patch("core.system.subprocess.run")
60
+ def test_detects_runserver_process_port(self, mock_run):
61
+ mock_run.return_value = CompletedProcess(
62
+ args=["pgrep"],
63
+ returncode=0,
64
+ stdout="123 python manage.py runserver 0.0.0.0:8000 --noreload\n",
65
+ )
66
+
67
+ info = _gather_info()
68
+
69
+ self.assertTrue(info["running"])
70
+ self.assertEqual(info["port"], 8000)
71
+
72
+ @patch("core.system._probe_ports", return_value=(True, 8000))
73
+ @patch("core.system.subprocess.run", side_effect=FileNotFoundError)
74
+ def test_falls_back_to_port_probe_when_pgrep_missing(self, mock_run, mock_probe):
75
+ info = _gather_info()
76
+
77
+ self.assertTrue(info["running"])
78
+ self.assertEqual(info["port"], 8000)
79
+