arthexis 0.1.12__py3-none-any.whl → 0.1.13__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.12.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/RECORD +37 -34
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +42 -76
- config/settings_helpers.py +109 -0
- core/admin.py +47 -10
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +182 -59
- core/release.py +38 -20
- core/tests.py +11 -1
- core/views.py +47 -12
- core/widgets.py +43 -0
- nodes/admin.py +277 -14
- nodes/apps.py +15 -0
- nodes/models.py +224 -43
- nodes/tests.py +629 -10
- nodes/urls.py +1 -0
- nodes/views.py +173 -5
- ocpp/admin.py +146 -2
- ocpp/consumers.py +125 -8
- ocpp/evcs.py +7 -94
- ocpp/models.py +2 -0
- ocpp/routing.py +4 -2
- ocpp/simulator.py +29 -8
- ocpp/status_display.py +26 -0
- ocpp/tests.py +625 -16
- ocpp/transactions_io.py +10 -0
- ocpp/views.py +122 -22
- pages/admin.py +3 -0
- pages/forms.py +30 -1
- pages/tests.py +118 -1
- pages/views.py +12 -4
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
core/views.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import logging
|
|
2
3
|
import shutil
|
|
3
4
|
from datetime import timedelta
|
|
4
5
|
|
|
@@ -8,7 +9,7 @@ from django.contrib.admin.views.decorators import staff_member_required
|
|
|
8
9
|
from django.contrib.auth import authenticate, login
|
|
9
10
|
from django.contrib import messages
|
|
10
11
|
from django.contrib.sites.models import Site
|
|
11
|
-
from django.http import Http404, JsonResponse
|
|
12
|
+
from django.http import Http404, JsonResponse, HttpResponse
|
|
12
13
|
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
|
|
13
14
|
from django.utils import timezone
|
|
14
15
|
from django.utils.text import slugify
|
|
@@ -22,9 +23,14 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
22
23
|
import errno
|
|
23
24
|
import subprocess
|
|
24
25
|
|
|
26
|
+
from django.template.loader import get_template
|
|
27
|
+
from django.test import signals
|
|
28
|
+
|
|
25
29
|
from utils import revision
|
|
26
30
|
from utils.api import api_login_required
|
|
27
31
|
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
28
34
|
from .models import Product, EnergyAccount, PackageRelease, Todo
|
|
29
35
|
from .models import RFID
|
|
30
36
|
|
|
@@ -44,6 +50,13 @@ def odoo_products(request):
|
|
|
44
50
|
{"fields": ["name"], "limit": 50},
|
|
45
51
|
)
|
|
46
52
|
except Exception:
|
|
53
|
+
logger.exception(
|
|
54
|
+
"Failed to fetch Odoo products via API for user %s (profile_id=%s, host=%s, database=%s)",
|
|
55
|
+
getattr(request.user, "pk", None),
|
|
56
|
+
getattr(profile, "pk", None),
|
|
57
|
+
getattr(profile, "host", None),
|
|
58
|
+
getattr(profile, "database", None),
|
|
59
|
+
)
|
|
47
60
|
return JsonResponse({"detail": "Unable to fetch products"}, status=502)
|
|
48
61
|
items = [{"id": p.get("id"), "name": p.get("name", "")} for p in products]
|
|
49
62
|
return JsonResponse(items, safe=False)
|
|
@@ -77,6 +90,10 @@ def _append_log(path: Path, message: str) -> None:
|
|
|
77
90
|
fh.write(message + "\n")
|
|
78
91
|
|
|
79
92
|
|
|
93
|
+
def _release_log_name(package_name: str, version: str) -> str:
|
|
94
|
+
return f"pr.{package_name}.v{version}.log"
|
|
95
|
+
|
|
96
|
+
|
|
80
97
|
def _clean_repo() -> None:
|
|
81
98
|
"""Return the git repository to a clean state."""
|
|
82
99
|
subprocess.run(["git", "reset", "--hard"], check=False)
|
|
@@ -599,9 +616,14 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
|
|
|
599
616
|
except Exception:
|
|
600
617
|
_clean_repo()
|
|
601
618
|
raise
|
|
602
|
-
|
|
603
|
-
new_log = log_path.with_name(
|
|
604
|
-
log_path
|
|
619
|
+
target_name = _release_log_name(release.package.name, release.version)
|
|
620
|
+
new_log = log_path.with_name(target_name)
|
|
621
|
+
if log_path != new_log:
|
|
622
|
+
if new_log.exists():
|
|
623
|
+
new_log.unlink()
|
|
624
|
+
log_path.rename(new_log)
|
|
625
|
+
else:
|
|
626
|
+
new_log = log_path
|
|
605
627
|
ctx["log"] = new_log.name
|
|
606
628
|
_append_log(new_log, "Build complete")
|
|
607
629
|
|
|
@@ -880,9 +902,8 @@ def release_progress(request, pk: int, action: str):
|
|
|
880
902
|
if restart_path.exists():
|
|
881
903
|
restart_path.unlink()
|
|
882
904
|
log_dir = Path("logs")
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
):
|
|
905
|
+
pattern = f"pr.{release.package.name}.v{previous_version}*.log"
|
|
906
|
+
for log_file in log_dir.glob(pattern):
|
|
886
907
|
log_file.unlink()
|
|
887
908
|
if not release.is_current:
|
|
888
909
|
raise Http404("Release is not current")
|
|
@@ -904,7 +925,8 @@ def release_progress(request, pk: int, action: str):
|
|
|
904
925
|
if lock_path.exists():
|
|
905
926
|
lock_path.unlink()
|
|
906
927
|
log_dir = Path("logs")
|
|
907
|
-
|
|
928
|
+
pattern = f"pr.{release.package.name}.v{release.version}*.log"
|
|
929
|
+
for f in log_dir.glob(pattern):
|
|
908
930
|
f.unlink()
|
|
909
931
|
return redirect(request.path)
|
|
910
932
|
ctx = request.session.get(session_key)
|
|
@@ -979,8 +1001,7 @@ def release_progress(request, pk: int, action: str):
|
|
|
979
1001
|
else:
|
|
980
1002
|
ctx.pop("todos", None)
|
|
981
1003
|
|
|
982
|
-
|
|
983
|
-
log_name = f"{identifier}.log"
|
|
1004
|
+
log_name = _release_log_name(release.package.name, release.version)
|
|
984
1005
|
if ctx.get("log") != log_name:
|
|
985
1006
|
ctx = {
|
|
986
1007
|
"step": 0,
|
|
@@ -1186,7 +1207,18 @@ def release_progress(request, pk: int, action: str):
|
|
|
1186
1207
|
else:
|
|
1187
1208
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1188
1209
|
lock_path.write_text(json.dumps(ctx), encoding="utf-8")
|
|
1189
|
-
|
|
1210
|
+
template = get_template("core/release_progress.html")
|
|
1211
|
+
content = template.render(context, request)
|
|
1212
|
+
signals.template_rendered.send(
|
|
1213
|
+
sender=template.__class__,
|
|
1214
|
+
template=template,
|
|
1215
|
+
context=context,
|
|
1216
|
+
using=getattr(getattr(template, "engine", None), "name", None),
|
|
1217
|
+
)
|
|
1218
|
+
response = HttpResponse(content)
|
|
1219
|
+
response.context = context
|
|
1220
|
+
response.templates = [template]
|
|
1221
|
+
return response
|
|
1190
1222
|
|
|
1191
1223
|
|
|
1192
1224
|
def _dedupe_preserve_order(values):
|
|
@@ -1371,8 +1403,11 @@ def todo_focus(request, pk: int):
|
|
|
1371
1403
|
@staff_member_required
|
|
1372
1404
|
@require_POST
|
|
1373
1405
|
def todo_done(request, pk: int):
|
|
1374
|
-
todo = get_object_or_404(Todo, pk=pk, is_deleted=False, done_on__isnull=True)
|
|
1375
1406
|
redirect_to = _get_return_url(request)
|
|
1407
|
+
try:
|
|
1408
|
+
todo = Todo.objects.get(pk=pk, is_deleted=False, done_on__isnull=True)
|
|
1409
|
+
except Todo.DoesNotExist:
|
|
1410
|
+
return redirect(redirect_to)
|
|
1376
1411
|
result = todo.check_on_done_condition()
|
|
1377
1412
|
if not result.passed:
|
|
1378
1413
|
messages.error(request, _format_condition_failure(todo, result))
|
core/widgets.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from django import forms
|
|
2
|
+
from django.forms.widgets import ClearableFileInput
|
|
2
3
|
import json
|
|
3
4
|
|
|
4
5
|
|
|
@@ -49,3 +50,45 @@ class OdooProductWidget(forms.Select):
|
|
|
49
50
|
return json.loads(raw)
|
|
50
51
|
except Exception:
|
|
51
52
|
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AdminBase64FileWidget(ClearableFileInput):
|
|
56
|
+
"""Clearable file input that exposes base64 data for downloads."""
|
|
57
|
+
|
|
58
|
+
template_name = "widgets/admin_base64_file.html"
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
download_name: str | None = None,
|
|
64
|
+
content_type: str = "application/octet-stream",
|
|
65
|
+
**kwargs,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.download_name = download_name
|
|
68
|
+
self.content_type = content_type
|
|
69
|
+
super().__init__(**kwargs)
|
|
70
|
+
|
|
71
|
+
def is_initial(self, value):
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
return bool(value)
|
|
74
|
+
return super().is_initial(value)
|
|
75
|
+
|
|
76
|
+
def format_value(self, value):
|
|
77
|
+
if isinstance(value, str):
|
|
78
|
+
return value
|
|
79
|
+
return super().format_value(value)
|
|
80
|
+
|
|
81
|
+
def get_context(self, name, value, attrs):
|
|
82
|
+
if isinstance(value, str):
|
|
83
|
+
base64_value = value.strip()
|
|
84
|
+
rendered_value = None
|
|
85
|
+
else:
|
|
86
|
+
base64_value = None
|
|
87
|
+
rendered_value = value
|
|
88
|
+
context = super().get_context(name, rendered_value, attrs)
|
|
89
|
+
widget_context = context["widget"]
|
|
90
|
+
widget_context["is_initial"] = bool(base64_value)
|
|
91
|
+
widget_context["base64_value"] = base64_value
|
|
92
|
+
widget_context["download_name"] = self.download_name or f"{name}.bin"
|
|
93
|
+
widget_context["content_type"] = self.content_type
|
|
94
|
+
return context
|
nodes/admin.py
CHANGED
|
@@ -2,7 +2,7 @@ from django.contrib import admin, messages
|
|
|
2
2
|
from django.urls import NoReverseMatch, path, reverse
|
|
3
3
|
from django.shortcuts import redirect, render
|
|
4
4
|
from django.template.response import TemplateResponse
|
|
5
|
-
from django.utils.html import format_html
|
|
5
|
+
from django.utils.html import format_html, format_html_join
|
|
6
6
|
from django import forms
|
|
7
7
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
|
8
8
|
from core.widgets import CopyColorWidget
|
|
@@ -12,11 +12,20 @@ from pathlib import Path
|
|
|
12
12
|
from django.http import HttpResponse
|
|
13
13
|
from django.utils import timezone
|
|
14
14
|
from django.utils.translation import gettext_lazy as _
|
|
15
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
16
|
+
from django.core.exceptions import PermissionDenied
|
|
17
|
+
from django.utils.dateparse import parse_datetime
|
|
15
18
|
import base64
|
|
19
|
+
import json
|
|
16
20
|
import pyperclip
|
|
17
21
|
from pyperclip import PyperclipException
|
|
18
22
|
import uuid
|
|
19
23
|
import subprocess
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
from requests import RequestException
|
|
27
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
28
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
20
29
|
from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
|
|
21
30
|
from .actions import NodeAction
|
|
22
31
|
from .reports import (
|
|
@@ -39,6 +48,7 @@ from .models import (
|
|
|
39
48
|
DNSRecord,
|
|
40
49
|
)
|
|
41
50
|
from . import dns as dns_utils
|
|
51
|
+
from core.models import RFID
|
|
42
52
|
from core.user_data import EntityModelAdmin
|
|
43
53
|
|
|
44
54
|
|
|
@@ -210,7 +220,12 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
210
220
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
211
221
|
change_form_template = "admin/nodes/node/change_form.html"
|
|
212
222
|
form = NodeAdminForm
|
|
213
|
-
actions = [
|
|
223
|
+
actions = [
|
|
224
|
+
"register_visitor",
|
|
225
|
+
"run_task",
|
|
226
|
+
"take_screenshots",
|
|
227
|
+
"fetch_rfids",
|
|
228
|
+
]
|
|
214
229
|
inlines = [NodeFeatureAssignmentInline]
|
|
215
230
|
|
|
216
231
|
def get_urls(self):
|
|
@@ -241,6 +256,8 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
241
256
|
|
|
242
257
|
def register_current(self, request):
|
|
243
258
|
"""Create or update this host and offer browser node registration."""
|
|
259
|
+
if not request.user.is_superuser:
|
|
260
|
+
raise PermissionDenied
|
|
244
261
|
node, created = Node.register_current()
|
|
245
262
|
if created:
|
|
246
263
|
self.message_user(
|
|
@@ -346,6 +363,151 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
346
363
|
count += 1
|
|
347
364
|
self.message_user(request, f"{count} screenshots captured", messages.SUCCESS)
|
|
348
365
|
|
|
366
|
+
@admin.action(description="Fetch RFIDs from selected")
|
|
367
|
+
def fetch_rfids(self, request, queryset):
|
|
368
|
+
local_node = Node.get_local()
|
|
369
|
+
if not local_node:
|
|
370
|
+
self.message_user(
|
|
371
|
+
request,
|
|
372
|
+
"Local node is not registered.",
|
|
373
|
+
messages.ERROR,
|
|
374
|
+
)
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
security_dir = Path(local_node.base_path or settings.BASE_DIR) / "security"
|
|
378
|
+
priv_path = security_dir / f"{local_node.public_endpoint}"
|
|
379
|
+
if not priv_path.exists():
|
|
380
|
+
self.message_user(
|
|
381
|
+
request,
|
|
382
|
+
"Local node private key not found.",
|
|
383
|
+
messages.ERROR,
|
|
384
|
+
)
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
private_key = serialization.load_pem_private_key(
|
|
389
|
+
priv_path.read_bytes(), password=None
|
|
390
|
+
)
|
|
391
|
+
except Exception as exc: # pragma: no cover - unexpected key errors
|
|
392
|
+
self.message_user(
|
|
393
|
+
request,
|
|
394
|
+
f"Failed to load private key: {exc}",
|
|
395
|
+
messages.ERROR,
|
|
396
|
+
)
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
payload = json.dumps(
|
|
400
|
+
{"requester": str(local_node.uuid)},
|
|
401
|
+
separators=(",", ":"),
|
|
402
|
+
sort_keys=True,
|
|
403
|
+
)
|
|
404
|
+
signature = base64.b64encode(
|
|
405
|
+
private_key.sign(
|
|
406
|
+
payload.encode(),
|
|
407
|
+
padding.PKCS1v15(),
|
|
408
|
+
hashes.SHA256(),
|
|
409
|
+
)
|
|
410
|
+
).decode()
|
|
411
|
+
headers = {
|
|
412
|
+
"Content-Type": "application/json",
|
|
413
|
+
"X-Signature": signature,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
processed = 0
|
|
417
|
+
total_created = 0
|
|
418
|
+
total_updated = 0
|
|
419
|
+
errors = 0
|
|
420
|
+
|
|
421
|
+
for node in queryset:
|
|
422
|
+
if local_node.pk and node.pk == local_node.pk:
|
|
423
|
+
continue
|
|
424
|
+
url = f"http://{node.address}:{node.port}/nodes/rfid/export/"
|
|
425
|
+
try:
|
|
426
|
+
response = requests.post(
|
|
427
|
+
url,
|
|
428
|
+
data=payload,
|
|
429
|
+
headers=headers,
|
|
430
|
+
timeout=5,
|
|
431
|
+
)
|
|
432
|
+
except RequestException as exc:
|
|
433
|
+
self.message_user(request, f"{node}: {exc}", messages.ERROR)
|
|
434
|
+
errors += 1
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
if response.status_code != 200:
|
|
438
|
+
self.message_user(
|
|
439
|
+
request,
|
|
440
|
+
f"{node}: {response.status_code} {response.text}",
|
|
441
|
+
messages.ERROR,
|
|
442
|
+
)
|
|
443
|
+
errors += 1
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
data = response.json()
|
|
448
|
+
except ValueError:
|
|
449
|
+
self.message_user(
|
|
450
|
+
request,
|
|
451
|
+
f"{node}: invalid JSON response",
|
|
452
|
+
messages.ERROR,
|
|
453
|
+
)
|
|
454
|
+
errors += 1
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
created = 0
|
|
458
|
+
updated = 0
|
|
459
|
+
rfids = data.get("rfids", []) or []
|
|
460
|
+
for entry in rfids:
|
|
461
|
+
rfid_value = entry.get("rfid")
|
|
462
|
+
if not rfid_value:
|
|
463
|
+
continue
|
|
464
|
+
defaults = {
|
|
465
|
+
"custom_label": entry.get("custom_label", ""),
|
|
466
|
+
"key_a": entry.get(
|
|
467
|
+
"key_a", RFID._meta.get_field("key_a").default
|
|
468
|
+
),
|
|
469
|
+
"key_b": entry.get(
|
|
470
|
+
"key_b", RFID._meta.get_field("key_b").default
|
|
471
|
+
),
|
|
472
|
+
"data": entry.get("data", []),
|
|
473
|
+
"key_a_verified": bool(entry.get("key_a_verified", False)),
|
|
474
|
+
"key_b_verified": bool(entry.get("key_b_verified", False)),
|
|
475
|
+
"allowed": bool(entry.get("allowed", True)),
|
|
476
|
+
"color": entry.get("color", RFID.BLACK),
|
|
477
|
+
"kind": entry.get("kind", RFID.CLASSIC),
|
|
478
|
+
"released": bool(entry.get("released", False)),
|
|
479
|
+
"origin_node": node,
|
|
480
|
+
}
|
|
481
|
+
if "last_seen_on" in entry:
|
|
482
|
+
last_seen_raw = entry.get("last_seen_on")
|
|
483
|
+
if last_seen_raw:
|
|
484
|
+
defaults["last_seen_on"] = parse_datetime(last_seen_raw)
|
|
485
|
+
else:
|
|
486
|
+
defaults["last_seen_on"] = None
|
|
487
|
+
|
|
488
|
+
obj, created_flag = RFID.objects.update_or_create(
|
|
489
|
+
rfid=rfid_value,
|
|
490
|
+
defaults=defaults,
|
|
491
|
+
)
|
|
492
|
+
if created_flag:
|
|
493
|
+
created += 1
|
|
494
|
+
else:
|
|
495
|
+
updated += 1
|
|
496
|
+
|
|
497
|
+
processed += 1
|
|
498
|
+
total_created += created
|
|
499
|
+
total_updated += updated
|
|
500
|
+
|
|
501
|
+
if processed:
|
|
502
|
+
message = (
|
|
503
|
+
f"Fetched RFIDs from {processed} node(s); "
|
|
504
|
+
f"{total_created} created, {total_updated} updated."
|
|
505
|
+
)
|
|
506
|
+
level = messages.SUCCESS if not errors else messages.WARNING
|
|
507
|
+
self.message_user(request, message, level)
|
|
508
|
+
elif not errors:
|
|
509
|
+
self.message_user(request, "No remote nodes selected.", messages.INFO)
|
|
510
|
+
|
|
349
511
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
350
512
|
extra_context = extra_context or {}
|
|
351
513
|
extra_context["node_actions"] = NodeAction.get_actions()
|
|
@@ -505,7 +667,7 @@ class NodeFeatureAdmin(EntityModelAdmin):
|
|
|
505
667
|
"slug",
|
|
506
668
|
"default_roles",
|
|
507
669
|
"is_enabled_display",
|
|
508
|
-
"
|
|
670
|
+
"available_actions",
|
|
509
671
|
)
|
|
510
672
|
actions = ["check_features_for_eligibility", "enable_selected_features"]
|
|
511
673
|
readonly_fields = ("is_enabled",)
|
|
@@ -524,18 +686,26 @@ class NodeFeatureAdmin(EntityModelAdmin):
|
|
|
524
686
|
def is_enabled_display(self, obj):
|
|
525
687
|
return obj.is_enabled
|
|
526
688
|
|
|
527
|
-
@admin.display(description="
|
|
528
|
-
def
|
|
689
|
+
@admin.display(description="Actions")
|
|
690
|
+
def available_actions(self, obj):
|
|
529
691
|
if not obj.is_enabled:
|
|
530
692
|
return "—"
|
|
531
|
-
|
|
532
|
-
if not
|
|
693
|
+
actions = obj.get_default_actions()
|
|
694
|
+
if not actions:
|
|
533
695
|
return "—"
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
696
|
+
|
|
697
|
+
links = []
|
|
698
|
+
for action in actions:
|
|
699
|
+
try:
|
|
700
|
+
url = reverse(action.url_name)
|
|
701
|
+
except NoReverseMatch:
|
|
702
|
+
links.append(action.label)
|
|
703
|
+
else:
|
|
704
|
+
links.append(format_html('<a href="{}">{}</a>', url, action.label))
|
|
705
|
+
|
|
706
|
+
if not links:
|
|
707
|
+
return "—"
|
|
708
|
+
return format_html_join(" | ", "{}", ((link,) for link in links))
|
|
539
709
|
|
|
540
710
|
def _manual_enablement_message(self, feature, node):
|
|
541
711
|
if node is None:
|
|
@@ -667,6 +837,11 @@ class NodeFeatureAdmin(EntityModelAdmin):
|
|
|
667
837
|
self.admin_site.admin_view(self.take_snapshot),
|
|
668
838
|
name="nodes_nodefeature_take_snapshot",
|
|
669
839
|
),
|
|
840
|
+
path(
|
|
841
|
+
"view-stream/",
|
|
842
|
+
self.admin_site.admin_view(self.view_stream),
|
|
843
|
+
name="nodes_nodefeature_view_stream",
|
|
844
|
+
),
|
|
670
845
|
]
|
|
671
846
|
return custom + urls
|
|
672
847
|
|
|
@@ -795,6 +970,33 @@ class NodeFeatureAdmin(EntityModelAdmin):
|
|
|
795
970
|
return redirect("..")
|
|
796
971
|
return redirect(change_url)
|
|
797
972
|
|
|
973
|
+
def view_stream(self, request):
|
|
974
|
+
feature = self._ensure_feature_enabled(request, "rpi-camera", "View stream")
|
|
975
|
+
if not feature:
|
|
976
|
+
return redirect("..")
|
|
977
|
+
|
|
978
|
+
configured_stream = getattr(settings, "RPI_CAMERA_STREAM_URL", "").strip()
|
|
979
|
+
if configured_stream:
|
|
980
|
+
stream_url = configured_stream
|
|
981
|
+
else:
|
|
982
|
+
base_uri = request.build_absolute_uri("/")
|
|
983
|
+
parsed = urlsplit(base_uri)
|
|
984
|
+
hostname = parsed.hostname or "127.0.0.1"
|
|
985
|
+
port = getattr(settings, "RPI_CAMERA_STREAM_PORT", 8554)
|
|
986
|
+
scheme = getattr(settings, "RPI_CAMERA_STREAM_SCHEME", "http")
|
|
987
|
+
netloc = f"{hostname}:{port}" if port else hostname
|
|
988
|
+
stream_url = urlunsplit((scheme, netloc, "/", "", ""))
|
|
989
|
+
context = {
|
|
990
|
+
**self.admin_site.each_context(request),
|
|
991
|
+
"title": _("Raspberry Pi Camera Stream"),
|
|
992
|
+
"stream_url": stream_url,
|
|
993
|
+
}
|
|
994
|
+
return TemplateResponse(
|
|
995
|
+
request,
|
|
996
|
+
"admin/nodes/nodefeature/view_stream.html",
|
|
997
|
+
context,
|
|
998
|
+
)
|
|
999
|
+
|
|
798
1000
|
|
|
799
1001
|
@admin.register(ContentSample)
|
|
800
1002
|
class ContentSampleAdmin(EntityModelAdmin):
|
|
@@ -876,19 +1078,80 @@ class ContentSampleAdmin(EntityModelAdmin):
|
|
|
876
1078
|
|
|
877
1079
|
@admin.register(NetMessage)
|
|
878
1080
|
class NetMessageAdmin(EntityModelAdmin):
|
|
1081
|
+
class NetMessageAdminForm(forms.ModelForm):
|
|
1082
|
+
class Meta:
|
|
1083
|
+
model = NetMessage
|
|
1084
|
+
fields = "__all__"
|
|
1085
|
+
widgets = {"body": forms.Textarea(attrs={"rows": 4})}
|
|
1086
|
+
|
|
1087
|
+
form = NetMessageAdminForm
|
|
1088
|
+
change_form_template = "admin/nodes/netmessage/change_form.html"
|
|
879
1089
|
list_display = (
|
|
880
1090
|
"subject",
|
|
881
1091
|
"body",
|
|
882
|
-
"
|
|
1092
|
+
"filter_node",
|
|
1093
|
+
"filter_node_role",
|
|
883
1094
|
"node_origin",
|
|
884
1095
|
"created",
|
|
1096
|
+
"target_limit",
|
|
885
1097
|
"complete",
|
|
886
1098
|
)
|
|
887
1099
|
search_fields = ("subject", "body")
|
|
888
|
-
list_filter = ("complete", "
|
|
1100
|
+
list_filter = ("complete", "filter_node_role", "filter_current_relation")
|
|
889
1101
|
ordering = ("-created",)
|
|
890
1102
|
readonly_fields = ("complete",)
|
|
891
1103
|
actions = ["send_messages"]
|
|
1104
|
+
fieldsets = (
|
|
1105
|
+
(None, {"fields": ("subject", "body")}),
|
|
1106
|
+
(
|
|
1107
|
+
"Filters",
|
|
1108
|
+
{
|
|
1109
|
+
"fields": (
|
|
1110
|
+
"filter_node",
|
|
1111
|
+
"filter_node_feature",
|
|
1112
|
+
"filter_node_role",
|
|
1113
|
+
"filter_current_relation",
|
|
1114
|
+
"filter_installed_version",
|
|
1115
|
+
"filter_installed_revision",
|
|
1116
|
+
)
|
|
1117
|
+
},
|
|
1118
|
+
),
|
|
1119
|
+
(
|
|
1120
|
+
"Propagation",
|
|
1121
|
+
{
|
|
1122
|
+
"fields": (
|
|
1123
|
+
"node_origin",
|
|
1124
|
+
"target_limit",
|
|
1125
|
+
"propagated_to",
|
|
1126
|
+
"complete",
|
|
1127
|
+
)
|
|
1128
|
+
},
|
|
1129
|
+
),
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
def get_changeform_initial_data(self, request):
|
|
1133
|
+
initial = super().get_changeform_initial_data(request)
|
|
1134
|
+
initial = dict(initial) if initial else {}
|
|
1135
|
+
reply_to = request.GET.get("reply_to")
|
|
1136
|
+
if reply_to:
|
|
1137
|
+
try:
|
|
1138
|
+
message = (
|
|
1139
|
+
NetMessage.objects.select_related("node_origin__role")
|
|
1140
|
+
.get(pk=reply_to)
|
|
1141
|
+
)
|
|
1142
|
+
except (NetMessage.DoesNotExist, ValueError, TypeError):
|
|
1143
|
+
message = None
|
|
1144
|
+
if message:
|
|
1145
|
+
subject = (message.subject or "").strip()
|
|
1146
|
+
if subject:
|
|
1147
|
+
if not subject.lower().startswith("re:"):
|
|
1148
|
+
subject = f"Re: {subject}"
|
|
1149
|
+
else:
|
|
1150
|
+
subject = "Re:"
|
|
1151
|
+
initial.setdefault("subject", subject[:64])
|
|
1152
|
+
if message.node_origin and "filter_node" not in initial:
|
|
1153
|
+
initial["filter_node"] = message.node_origin.pk
|
|
1154
|
+
return initial
|
|
892
1155
|
|
|
893
1156
|
def send_messages(self, request, queryset):
|
|
894
1157
|
for msg in queryset:
|
nodes/apps.py
CHANGED
|
@@ -31,6 +31,21 @@ def _startup_notification() -> None:
|
|
|
31
31
|
rev_short = revision_value[-6:] if revision_value else ""
|
|
32
32
|
|
|
33
33
|
body = version
|
|
34
|
+
if body:
|
|
35
|
+
normalized = body.lstrip("vV") or body
|
|
36
|
+
base_version = normalized.rstrip("+")
|
|
37
|
+
needs_marker = False
|
|
38
|
+
if base_version and revision_value:
|
|
39
|
+
try: # pragma: no cover - defensive guard
|
|
40
|
+
from core.models import PackageRelease
|
|
41
|
+
|
|
42
|
+
needs_marker = not PackageRelease.matches_revision(
|
|
43
|
+
base_version, revision_value
|
|
44
|
+
)
|
|
45
|
+
except Exception:
|
|
46
|
+
logger.debug("Startup release comparison failed", exc_info=True)
|
|
47
|
+
if needs_marker and not normalized.endswith("+"):
|
|
48
|
+
body = f"{body}+"
|
|
34
49
|
if rev_short:
|
|
35
50
|
body = f"{body} r{rev_short}" if body else f"r{rev_short}"
|
|
36
51
|
|