arthexis 0.1.8__py3-none-any.whl → 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (81) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +133 -16
  10. config/urls.py +65 -6
  11. core/admin.py +1226 -191
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1071 -264
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +358 -63
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +7 -3
  42. core/workgroup_views.py +43 -6
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +1 -1
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.8.dist-info/RECORD +0 -80
  77. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  78. config/workgroup_app.py +0 -7
  79. core/checks.py +0 -29
  80. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  81. {arthexis-0.1.8.dist-info → arthexis-0.1.9.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,12 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
+ import io
4
5
  import socket
5
6
  import subprocess
6
7
  import shutil
8
+ import argparse
9
+ import time
7
10
 
11
+ from django import forms
8
12
  from django.conf import settings
9
- from django.contrib import admin
13
+ from django.contrib import admin, messages
14
+ from django.core.management import get_commands, load_command_class
15
+ from django.http import Http404
10
16
  from django.shortcuts import redirect
11
17
  from django.template.response import TemplateResponse
12
18
  from django.urls import path, reverse
@@ -14,7 +20,7 @@ from django.utils.translation import gettext_lazy as _
14
20
 
15
21
 
16
22
  def _gather_info() -> dict:
17
- """Collect basic system information similar to status-check.sh."""
23
+ """Collect basic system information similar to status.sh."""
18
24
  base_dir = Path(settings.BASE_DIR)
19
25
  lock_dir = base_dir / "locks"
20
26
  info: dict[str, object] = {}
@@ -22,15 +28,18 @@ def _gather_info() -> dict:
22
28
  info["installed"] = (base_dir / ".venv").exists()
23
29
 
24
30
  service_file = lock_dir / "service.lck"
25
- info["service"] = (
26
- service_file.read_text().strip() if service_file.exists() else ""
27
- )
31
+ info["service"] = service_file.read_text().strip() if service_file.exists() else ""
28
32
 
29
33
  mode_file = lock_dir / "nginx_mode.lck"
30
34
  mode = mode_file.read_text().strip() if mode_file.exists() else "internal"
31
35
  info["mode"] = mode
32
36
  info["port"] = 8000 if mode == "public" else 8888
33
37
 
38
+ screen_file = lock_dir / "screen_mode.lck"
39
+ info["screen_mode"] = (
40
+ screen_file.read_text().strip() if screen_file.exists() else ""
41
+ )
42
+
34
43
  # Use settings.NODE_ROLE as the single source of truth for the node role.
35
44
  info["role"] = getattr(settings, "NODE_ROLE", "Terminal")
36
45
 
@@ -87,16 +96,121 @@ def _system_view(request):
87
96
  action = request.POST.get("action")
88
97
  stop_script = Path(settings.BASE_DIR) / "stop.sh"
89
98
  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"))
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)
94
129
 
95
130
  context = admin.site.each_context(request)
96
- context.update({"title": _("System"), "info": info})
131
+ context.update({"title": _("System"), "info": info, "commands": commands})
97
132
  return TemplateResponse(request, "admin/system.html", context)
98
133
 
99
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
+
202
+ context = admin.site.each_context(request)
203
+ context.update(
204
+ {
205
+ "title": command,
206
+ "command_name": command,
207
+ "form": form,
208
+ "output": output,
209
+ }
210
+ )
211
+ return TemplateResponse(request, "admin/system_command.html", context)
212
+
213
+
100
214
  def patch_admin_system_view() -> None:
101
215
  """Add custom admin view for system information."""
102
216
  original_get_urls = admin.site.get_urls
@@ -105,6 +219,11 @@ def patch_admin_system_view() -> None:
105
219
  urls = original_get_urls()
106
220
  custom = [
107
221
  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
+ ),
108
227
  ]
109
228
  return custom + urls
110
229
 
core/tasks.py CHANGED
@@ -1,17 +1,51 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import subprocess
4
5
  from datetime import datetime
5
6
  from pathlib import Path
6
7
 
7
8
  from celery import shared_task
9
+ from django.conf import settings
10
+ from django.contrib.auth import get_user_model
11
+ from core import mailer
12
+ from core import github_issues
13
+ from django.utils import timezone
14
+
15
+ from nodes.models import NetMessage
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @shared_task
22
+ def heartbeat() -> None:
23
+ """Log a simple heartbeat message."""
24
+ logger.info("Heartbeat task executed")
25
+
26
+
27
+ @shared_task
28
+ def birthday_greetings() -> None:
29
+ """Send birthday greetings to users via Net Message and email."""
30
+ User = get_user_model()
31
+ today = timezone.localdate()
32
+ for user in User.objects.filter(birthday=today):
33
+ NetMessage.broadcast("Happy bday!", user.username)
34
+ if user.email:
35
+ mailer.send(
36
+ "Happy bday!",
37
+ f"Happy bday! {user.username}",
38
+ [user.email],
39
+ settings.DEFAULT_FROM_EMAIL,
40
+ fail_silently=True,
41
+ )
8
42
 
9
43
 
10
44
  @shared_task
11
45
  def check_github_updates() -> None:
12
46
  """Check the GitHub repo for updates and upgrade if needed."""
13
47
  base_dir = Path(__file__).resolve().parent.parent
14
- mode_file = base_dir / "AUTO_UPGRADE"
48
+ mode_file = base_dir / "locks" / "auto_upgrade.lck"
15
49
  mode = "version"
16
50
  if mode_file.exists():
17
51
  mode = mode_file.read_text().strip()
@@ -37,12 +71,23 @@ def check_github_updates() -> None:
37
71
  startup = None
38
72
 
39
73
  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()
74
+ local = (
75
+ subprocess.check_output(["git", "rev-parse", branch], cwd=base_dir)
76
+ .decode()
77
+ .strip()
78
+ )
79
+ remote = (
80
+ subprocess.check_output(
81
+ [
82
+ "git",
83
+ "rev-parse",
84
+ f"origin/{branch}",
85
+ ],
86
+ cwd=base_dir,
87
+ )
88
+ .decode()
89
+ .strip()
90
+ )
46
91
  if local == remote:
47
92
  if startup:
48
93
  startup()
@@ -55,11 +100,18 @@ def check_github_updates() -> None:
55
100
  version_file = base_dir / "VERSION"
56
101
  if version_file.exists():
57
102
  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()
103
+ remote = (
104
+ subprocess.check_output(
105
+ [
106
+ "git",
107
+ "show",
108
+ f"origin/{branch}:VERSION",
109
+ ],
110
+ cwd=base_dir,
111
+ )
112
+ .decode()
113
+ .strip()
114
+ )
63
115
  if local == remote:
64
116
  if startup:
65
117
  startup()
@@ -76,13 +128,15 @@ def check_github_updates() -> None:
76
128
  service_file = base_dir / "locks/service.lck"
77
129
  if service_file.exists():
78
130
  service = service_file.read_text().strip()
79
- subprocess.run([
80
- "sudo",
81
- "systemctl",
82
- "kill",
83
- "--signal=TERM",
84
- service,
85
- ])
131
+ subprocess.run(
132
+ [
133
+ "sudo",
134
+ "systemctl",
135
+ "kill",
136
+ "--signal=TERM",
137
+ service,
138
+ ]
139
+ )
86
140
  else:
87
141
  subprocess.run(["pkill", "-f", "manage.py runserver"])
88
142
 
@@ -98,3 +152,48 @@ def poll_email_collectors() -> None:
98
152
  for collector in EmailCollector.objects.all():
99
153
  collector.collect()
100
154
 
155
+
156
+ @shared_task
157
+ def report_runtime_issue(
158
+ title: str,
159
+ body: str,
160
+ labels: list[str] | None = None,
161
+ fingerprint: str | None = None,
162
+ ):
163
+ """Report a runtime issue to GitHub using :mod:`core.github_issues`."""
164
+
165
+ try:
166
+ response = github_issues.create_issue(
167
+ title,
168
+ body,
169
+ labels=labels,
170
+ fingerprint=fingerprint,
171
+ )
172
+ except Exception:
173
+ logger.exception("Failed to report runtime issue '%s'", title)
174
+ raise
175
+
176
+ if response is None:
177
+ logger.info("Skipped GitHub issue creation for fingerprint %s", fingerprint)
178
+ else:
179
+ logger.info("Reported runtime issue '%s' to GitHub", title)
180
+
181
+ return response
182
+
183
+
184
+ @shared_task
185
+ def run_client_report_schedule(schedule_id: int) -> None:
186
+ """Execute a :class:`core.models.ClientReportSchedule` run."""
187
+
188
+ from core.models import ClientReportSchedule
189
+
190
+ schedule = ClientReportSchedule.objects.filter(pk=schedule_id).first()
191
+ if not schedule:
192
+ logger.warning("ClientReportSchedule %s no longer exists", schedule_id)
193
+ return
194
+
195
+ try:
196
+ schedule.run()
197
+ except Exception:
198
+ logger.exception("ClientReportSchedule %s failed", schedule_id)
199
+ raise
core/test_system_info.py CHANGED
@@ -1,10 +1,13 @@
1
1
  import os
2
+ from pathlib import Path
2
3
 
3
4
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
5
 
5
6
  import django
7
+
6
8
  django.setup()
7
9
 
10
+ from django.conf import settings
8
11
  from django.test import SimpleTestCase, override_settings
9
12
  from core.system import _gather_info
10
13
 
@@ -19,3 +22,22 @@ class SystemInfoRoleTests(SimpleTestCase):
19
22
  def test_uses_settings_role(self):
20
23
  info = _gather_info()
21
24
  self.assertEqual(info["role"], "Satellite")
25
+
26
+
27
+ class SystemInfoScreenModeTests(SimpleTestCase):
28
+ def test_without_lockfile(self):
29
+ info = _gather_info()
30
+ self.assertEqual(info["screen_mode"], "")
31
+
32
+ def test_with_lockfile(self):
33
+ lock_dir = Path(settings.BASE_DIR) / "locks"
34
+ lock_dir.mkdir(exist_ok=True)
35
+ lock_file = lock_dir / "screen_mode.lck"
36
+ lock_file.write_text("tft")
37
+ try:
38
+ info = _gather_info()
39
+ self.assertEqual(info["screen_mode"], "tft")
40
+ finally:
41
+ lock_file.unlink()
42
+ if not any(lock_dir.iterdir()):
43
+ lock_dir.rmdir()