arthexis 0.1.7__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.
- arthexis-0.1.9.dist-info/METADATA +168 -0
- arthexis-0.1.9.dist-info/RECORD +92 -0
- arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +134 -16
- config/urls.py +71 -3
- core/admin.py +1331 -165
- core/admin_history.py +50 -0
- core/admindocs.py +151 -0
- core/apps.py +158 -3
- core/backends.py +46 -4
- core/entity.py +62 -48
- core/fields.py +6 -1
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1136 -259
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/release.py +27 -20
- core/sigil_builder.py +131 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +129 -10
- core/tasks.py +118 -19
- core/test_system_info.py +22 -0
- core/tests.py +445 -58
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +329 -167
- core/views.py +383 -57
- core/widgets.py +51 -0
- core/workgroup_urls.py +17 -0
- core/workgroup_views.py +94 -0
- nodes/actions.py +0 -2
- nodes/admin.py +159 -284
- nodes/apps.py +9 -15
- nodes/backends.py +53 -0
- nodes/lcd.py +24 -10
- nodes/models.py +375 -178
- nodes/tasks.py +1 -5
- nodes/tests.py +524 -129
- nodes/utils.py +13 -2
- nodes/views.py +66 -23
- ocpp/admin.py +150 -61
- ocpp/apps.py +4 -3
- ocpp/consumers.py +432 -69
- ocpp/evcs.py +25 -8
- ocpp/models.py +408 -68
- ocpp/simulator.py +13 -6
- ocpp/store.py +258 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1198 -135
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +654 -101
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +19 -6
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +759 -40
- pages/urls.py +3 -0
- pages/utils.py +0 -1
- pages/views.py +576 -25
- arthexis-0.1.7.dist-info/METADATA +0 -126
- arthexis-0.1.7.dist-info/RECORD +0 -77
- arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
- {arthexis-0.1.7.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
|
|
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"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 / "
|
|
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 =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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()
|