arthexis 0.1.11__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.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +49 -78
- config/settings_helpers.py +109 -0
- core/admin.py +293 -78
- core/apps.py +21 -0
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +203 -47
- core/reference_utils.py +1 -1
- core/release.py +42 -20
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +75 -1
- core/views.py +178 -29
- core/widgets.py +43 -0
- nodes/admin.py +583 -10
- nodes/apps.py +15 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +287 -49
- nodes/reports.py +411 -0
- nodes/tests.py +990 -42
- nodes/urls.py +1 -0
- nodes/utils.py +32 -0
- nodes/views.py +173 -5
- ocpp/admin.py +424 -17
- ocpp/consumers.py +630 -15
- ocpp/evcs.py +7 -94
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +236 -4
- ocpp/routing.py +4 -2
- ocpp/simulator.py +346 -26
- ocpp/status_display.py +26 -0
- ocpp/store.py +110 -2
- ocpp/tests.py +1425 -33
- ocpp/transactions_io.py +27 -3
- ocpp/views.py +344 -38
- pages/admin.py +138 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +67 -0
- pages/models.py +136 -1
- pages/tests.py +379 -4
- pages/urls.py +1 -0
- pages/views.py +64 -7
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
nodes/admin.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from django.contrib import admin, messages
|
|
2
|
-
from django.urls import path, reverse
|
|
2
|
+
from django.urls import NoReverseMatch, path, reverse
|
|
3
3
|
from django.shortcuts import redirect, render
|
|
4
|
-
from django.
|
|
4
|
+
from django.template.response import TemplateResponse
|
|
5
|
+
from django.utils.html import format_html, format_html_join
|
|
5
6
|
from django import forms
|
|
6
7
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
|
7
8
|
from core.widgets import CopyColorWidget
|
|
@@ -9,15 +10,32 @@ from django.db.models import Count
|
|
|
9
10
|
from django.conf import settings
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from django.http import HttpResponse
|
|
13
|
+
from django.utils import timezone
|
|
12
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
|
|
13
18
|
import base64
|
|
19
|
+
import json
|
|
14
20
|
import pyperclip
|
|
15
21
|
from pyperclip import PyperclipException
|
|
16
22
|
import uuid
|
|
17
23
|
import subprocess
|
|
18
|
-
|
|
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
|
|
29
|
+
from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
|
|
19
30
|
from .actions import NodeAction
|
|
31
|
+
from .reports import (
|
|
32
|
+
collect_celery_log_entries,
|
|
33
|
+
collect_scheduled_tasks,
|
|
34
|
+
iter_report_periods,
|
|
35
|
+
resolve_period,
|
|
36
|
+
)
|
|
20
37
|
|
|
38
|
+
from core.admin import EmailOutboxAdminForm
|
|
21
39
|
from .models import (
|
|
22
40
|
Node,
|
|
23
41
|
EmailOutbox,
|
|
@@ -30,6 +48,7 @@ from .models import (
|
|
|
30
48
|
DNSRecord,
|
|
31
49
|
)
|
|
32
50
|
from . import dns as dns_utils
|
|
51
|
+
from core.models import RFID
|
|
33
52
|
from core.user_data import EntityModelAdmin
|
|
34
53
|
|
|
35
54
|
|
|
@@ -69,6 +88,24 @@ class NodeManagerAdmin(EntityModelAdmin):
|
|
|
69
88
|
"user__username",
|
|
70
89
|
"group__name",
|
|
71
90
|
)
|
|
91
|
+
fieldsets = (
|
|
92
|
+
(_("Owner"), {"fields": ("user", "group")}),
|
|
93
|
+
(
|
|
94
|
+
_("Credentials"),
|
|
95
|
+
{"fields": ("api_key", "api_secret", "customer_id")},
|
|
96
|
+
),
|
|
97
|
+
(
|
|
98
|
+
_("Configuration"),
|
|
99
|
+
{
|
|
100
|
+
"fields": (
|
|
101
|
+
"provider",
|
|
102
|
+
"default_domain",
|
|
103
|
+
"use_sandbox",
|
|
104
|
+
"is_enabled",
|
|
105
|
+
)
|
|
106
|
+
},
|
|
107
|
+
),
|
|
108
|
+
)
|
|
72
109
|
|
|
73
110
|
|
|
74
111
|
@admin.register(DNSRecord)
|
|
@@ -183,7 +220,12 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
183
220
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
184
221
|
change_form_template = "admin/nodes/node/change_form.html"
|
|
185
222
|
form = NodeAdminForm
|
|
186
|
-
actions = [
|
|
223
|
+
actions = [
|
|
224
|
+
"register_visitor",
|
|
225
|
+
"run_task",
|
|
226
|
+
"take_screenshots",
|
|
227
|
+
"fetch_rfids",
|
|
228
|
+
]
|
|
187
229
|
inlines = [NodeFeatureAssignmentInline]
|
|
188
230
|
|
|
189
231
|
def get_urls(self):
|
|
@@ -214,6 +256,8 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
214
256
|
|
|
215
257
|
def register_current(self, request):
|
|
216
258
|
"""Create or update this host and offer browser node registration."""
|
|
259
|
+
if not request.user.is_superuser:
|
|
260
|
+
raise PermissionDenied
|
|
217
261
|
node, created = Node.register_current()
|
|
218
262
|
if created:
|
|
219
263
|
self.message_user(
|
|
@@ -319,6 +363,151 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
319
363
|
count += 1
|
|
320
364
|
self.message_user(request, f"{count} screenshots captured", messages.SUCCESS)
|
|
321
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
|
+
|
|
322
511
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
323
512
|
extra_context = extra_context or {}
|
|
324
513
|
extra_context["node_actions"] = NodeAction.get_actions()
|
|
@@ -358,6 +547,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
358
547
|
|
|
359
548
|
@admin.register(EmailOutbox)
|
|
360
549
|
class EmailOutboxAdmin(EntityModelAdmin):
|
|
550
|
+
form = EmailOutboxAdminForm
|
|
361
551
|
list_display = (
|
|
362
552
|
"owner_label",
|
|
363
553
|
"host",
|
|
@@ -369,15 +559,15 @@ class EmailOutboxAdmin(EntityModelAdmin):
|
|
|
369
559
|
)
|
|
370
560
|
change_form_template = "admin/nodes/emailoutbox/change_form.html"
|
|
371
561
|
fieldsets = (
|
|
372
|
-
("Owner", {"fields": ("user", "group"
|
|
562
|
+
("Owner", {"fields": ("user", "group")}),
|
|
563
|
+
("Credentials", {"fields": ("username", "password")}),
|
|
373
564
|
(
|
|
374
565
|
"Configuration",
|
|
375
566
|
{
|
|
376
567
|
"fields": (
|
|
568
|
+
"node",
|
|
377
569
|
"host",
|
|
378
570
|
"port",
|
|
379
|
-
"username",
|
|
380
|
-
"password",
|
|
381
571
|
"use_tls",
|
|
382
572
|
"use_ssl",
|
|
383
573
|
"from_email",
|
|
@@ -472,7 +662,14 @@ class NodeRoleAdmin(EntityModelAdmin):
|
|
|
472
662
|
@admin.register(NodeFeature)
|
|
473
663
|
class NodeFeatureAdmin(EntityModelAdmin):
|
|
474
664
|
filter_horizontal = ("roles",)
|
|
475
|
-
list_display = (
|
|
665
|
+
list_display = (
|
|
666
|
+
"display",
|
|
667
|
+
"slug",
|
|
668
|
+
"default_roles",
|
|
669
|
+
"is_enabled_display",
|
|
670
|
+
"available_actions",
|
|
671
|
+
)
|
|
672
|
+
actions = ["check_features_for_eligibility", "enable_selected_features"]
|
|
476
673
|
readonly_fields = ("is_enabled",)
|
|
477
674
|
search_fields = ("display", "slug")
|
|
478
675
|
|
|
@@ -485,6 +682,321 @@ class NodeFeatureAdmin(EntityModelAdmin):
|
|
|
485
682
|
roles = [role.name for role in obj.roles.all()]
|
|
486
683
|
return ", ".join(roles) if roles else "—"
|
|
487
684
|
|
|
685
|
+
@admin.display(description="Is Enabled", boolean=True, ordering="is_enabled")
|
|
686
|
+
def is_enabled_display(self, obj):
|
|
687
|
+
return obj.is_enabled
|
|
688
|
+
|
|
689
|
+
@admin.display(description="Actions")
|
|
690
|
+
def available_actions(self, obj):
|
|
691
|
+
if not obj.is_enabled:
|
|
692
|
+
return "—"
|
|
693
|
+
actions = obj.get_default_actions()
|
|
694
|
+
if not actions:
|
|
695
|
+
return "—"
|
|
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))
|
|
709
|
+
|
|
710
|
+
def _manual_enablement_message(self, feature, node):
|
|
711
|
+
if node is None:
|
|
712
|
+
return (
|
|
713
|
+
"Manual enablement is unavailable without a registered local node."
|
|
714
|
+
)
|
|
715
|
+
if feature.slug in Node.MANUAL_FEATURE_SLUGS:
|
|
716
|
+
return "This feature can be enabled manually."
|
|
717
|
+
return "This feature cannot be enabled manually."
|
|
718
|
+
|
|
719
|
+
@admin.action(description="Check features for eligibility")
|
|
720
|
+
def check_features_for_eligibility(self, request, queryset):
|
|
721
|
+
from .feature_checks import feature_checks
|
|
722
|
+
|
|
723
|
+
features = list(queryset)
|
|
724
|
+
total = len(features)
|
|
725
|
+
successes = 0
|
|
726
|
+
node = Node.get_local()
|
|
727
|
+
for feature in features:
|
|
728
|
+
enablement_message = self._manual_enablement_message(feature, node)
|
|
729
|
+
try:
|
|
730
|
+
result = feature_checks.run(feature, node=node)
|
|
731
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
732
|
+
self.message_user(
|
|
733
|
+
request,
|
|
734
|
+
f"{feature.display}: {exc} {enablement_message}",
|
|
735
|
+
level=messages.ERROR,
|
|
736
|
+
)
|
|
737
|
+
continue
|
|
738
|
+
if result is None:
|
|
739
|
+
self.message_user(
|
|
740
|
+
request,
|
|
741
|
+
f"No check is configured for {feature.display}. {enablement_message}",
|
|
742
|
+
level=messages.WARNING,
|
|
743
|
+
)
|
|
744
|
+
continue
|
|
745
|
+
message = result.message or (
|
|
746
|
+
f"{feature.display} check {'passed' if result.success else 'failed'}."
|
|
747
|
+
)
|
|
748
|
+
self.message_user(
|
|
749
|
+
request, f"{message} {enablement_message}", level=result.level
|
|
750
|
+
)
|
|
751
|
+
if result.success:
|
|
752
|
+
successes += 1
|
|
753
|
+
if total:
|
|
754
|
+
self.message_user(
|
|
755
|
+
request,
|
|
756
|
+
f"Completed {successes} of {total} feature check(s) successfully.",
|
|
757
|
+
level=messages.INFO,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
@admin.action(description="Enable selected action")
|
|
761
|
+
def enable_selected_features(self, request, queryset):
|
|
762
|
+
node = Node.get_local()
|
|
763
|
+
if node is None:
|
|
764
|
+
self.message_user(
|
|
765
|
+
request,
|
|
766
|
+
"No local node is registered; unable to enable features manually.",
|
|
767
|
+
level=messages.ERROR,
|
|
768
|
+
)
|
|
769
|
+
return
|
|
770
|
+
|
|
771
|
+
manual_features = [
|
|
772
|
+
feature
|
|
773
|
+
for feature in queryset
|
|
774
|
+
if feature.slug in Node.MANUAL_FEATURE_SLUGS
|
|
775
|
+
]
|
|
776
|
+
non_manual_features = [
|
|
777
|
+
feature
|
|
778
|
+
for feature in queryset
|
|
779
|
+
if feature.slug not in Node.MANUAL_FEATURE_SLUGS
|
|
780
|
+
]
|
|
781
|
+
for feature in non_manual_features:
|
|
782
|
+
self.message_user(
|
|
783
|
+
request,
|
|
784
|
+
f"{feature.display} cannot be enabled manually.",
|
|
785
|
+
level=messages.WARNING,
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
if not manual_features:
|
|
789
|
+
self.message_user(
|
|
790
|
+
request,
|
|
791
|
+
"None of the selected features can be enabled manually.",
|
|
792
|
+
level=messages.WARNING,
|
|
793
|
+
)
|
|
794
|
+
return
|
|
795
|
+
|
|
796
|
+
current_manual = set(
|
|
797
|
+
node.features.filter(slug__in=Node.MANUAL_FEATURE_SLUGS).values_list(
|
|
798
|
+
"slug", flat=True
|
|
799
|
+
)
|
|
800
|
+
)
|
|
801
|
+
desired_manual = current_manual | {feature.slug for feature in manual_features}
|
|
802
|
+
newly_enabled = desired_manual - current_manual
|
|
803
|
+
if not newly_enabled:
|
|
804
|
+
self.message_user(
|
|
805
|
+
request,
|
|
806
|
+
"Selected manual features are already enabled.",
|
|
807
|
+
level=messages.INFO,
|
|
808
|
+
)
|
|
809
|
+
return
|
|
810
|
+
|
|
811
|
+
node.update_manual_features(desired_manual)
|
|
812
|
+
display_map = {feature.slug: feature.display for feature in manual_features}
|
|
813
|
+
newly_enabled_names = [display_map[slug] for slug in sorted(newly_enabled)]
|
|
814
|
+
self.message_user(
|
|
815
|
+
request,
|
|
816
|
+
"Enabled {} feature(s): {}".format(
|
|
817
|
+
len(newly_enabled), ", ".join(newly_enabled_names)
|
|
818
|
+
),
|
|
819
|
+
level=messages.SUCCESS,
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
def get_urls(self):
|
|
823
|
+
urls = super().get_urls()
|
|
824
|
+
custom = [
|
|
825
|
+
path(
|
|
826
|
+
"celery-report/",
|
|
827
|
+
self.admin_site.admin_view(self.celery_report),
|
|
828
|
+
name="nodes_nodefeature_celery_report",
|
|
829
|
+
),
|
|
830
|
+
path(
|
|
831
|
+
"take-screenshot/",
|
|
832
|
+
self.admin_site.admin_view(self.take_screenshot),
|
|
833
|
+
name="nodes_nodefeature_take_screenshot",
|
|
834
|
+
),
|
|
835
|
+
path(
|
|
836
|
+
"take-snapshot/",
|
|
837
|
+
self.admin_site.admin_view(self.take_snapshot),
|
|
838
|
+
name="nodes_nodefeature_take_snapshot",
|
|
839
|
+
),
|
|
840
|
+
path(
|
|
841
|
+
"view-stream/",
|
|
842
|
+
self.admin_site.admin_view(self.view_stream),
|
|
843
|
+
name="nodes_nodefeature_view_stream",
|
|
844
|
+
),
|
|
845
|
+
]
|
|
846
|
+
return custom + urls
|
|
847
|
+
|
|
848
|
+
def celery_report(self, request):
|
|
849
|
+
period = resolve_period(request.GET.get("period"))
|
|
850
|
+
now = timezone.now()
|
|
851
|
+
window_end = now + period.delta
|
|
852
|
+
log_window_start = now - period.delta
|
|
853
|
+
|
|
854
|
+
scheduled_tasks = collect_scheduled_tasks(now, window_end)
|
|
855
|
+
log_collection = collect_celery_log_entries(log_window_start, now)
|
|
856
|
+
|
|
857
|
+
period_options = [
|
|
858
|
+
{
|
|
859
|
+
"key": candidate.key,
|
|
860
|
+
"label": candidate.label,
|
|
861
|
+
"selected": candidate.key == period.key,
|
|
862
|
+
"url": f"?period={candidate.key}",
|
|
863
|
+
}
|
|
864
|
+
for candidate in iter_report_periods()
|
|
865
|
+
]
|
|
866
|
+
|
|
867
|
+
context = {
|
|
868
|
+
**self.admin_site.each_context(request),
|
|
869
|
+
"title": _("Celery Report"),
|
|
870
|
+
"period": period,
|
|
871
|
+
"period_options": period_options,
|
|
872
|
+
"current_time": now,
|
|
873
|
+
"window_end": window_end,
|
|
874
|
+
"log_window_start": log_window_start,
|
|
875
|
+
"scheduled_tasks": scheduled_tasks,
|
|
876
|
+
"log_entries": log_collection.entries,
|
|
877
|
+
"log_sources": log_collection.checked_sources,
|
|
878
|
+
}
|
|
879
|
+
return TemplateResponse(
|
|
880
|
+
request,
|
|
881
|
+
"admin/nodes/nodefeature/celery_report.html",
|
|
882
|
+
context,
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
def _ensure_feature_enabled(self, request, slug: str, action_label: str):
|
|
886
|
+
try:
|
|
887
|
+
feature = NodeFeature.objects.get(slug=slug)
|
|
888
|
+
except NodeFeature.DoesNotExist:
|
|
889
|
+
self.message_user(
|
|
890
|
+
request,
|
|
891
|
+
f"{action_label} is unavailable because the feature is not configured.",
|
|
892
|
+
level=messages.ERROR,
|
|
893
|
+
)
|
|
894
|
+
return None
|
|
895
|
+
if not feature.is_enabled:
|
|
896
|
+
self.message_user(
|
|
897
|
+
request,
|
|
898
|
+
f"{feature.display} feature is not enabled on this node.",
|
|
899
|
+
level=messages.WARNING,
|
|
900
|
+
)
|
|
901
|
+
return None
|
|
902
|
+
return feature
|
|
903
|
+
|
|
904
|
+
def take_screenshot(self, request):
|
|
905
|
+
feature = self._ensure_feature_enabled(
|
|
906
|
+
request, "screenshot-poll", "Take Screenshot"
|
|
907
|
+
)
|
|
908
|
+
if not feature:
|
|
909
|
+
return redirect("..")
|
|
910
|
+
url = request.build_absolute_uri("/")
|
|
911
|
+
try:
|
|
912
|
+
path = capture_screenshot(url)
|
|
913
|
+
except Exception as exc: # pragma: no cover - depends on selenium setup
|
|
914
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
915
|
+
return redirect("..")
|
|
916
|
+
node = Node.get_local()
|
|
917
|
+
sample = save_screenshot(path, node=node, method="DEFAULT_ACTION")
|
|
918
|
+
if not sample:
|
|
919
|
+
self.message_user(
|
|
920
|
+
request, "Duplicate screenshot; not saved", level=messages.INFO
|
|
921
|
+
)
|
|
922
|
+
return redirect("..")
|
|
923
|
+
self.message_user(
|
|
924
|
+
request, f"Screenshot saved to {sample.path}", level=messages.SUCCESS
|
|
925
|
+
)
|
|
926
|
+
try:
|
|
927
|
+
change_url = reverse(
|
|
928
|
+
"admin:nodes_contentsample_change", args=[sample.pk]
|
|
929
|
+
)
|
|
930
|
+
except NoReverseMatch: # pragma: no cover - admin URL always registered
|
|
931
|
+
self.message_user(
|
|
932
|
+
request,
|
|
933
|
+
"Screenshot saved but the admin page could not be resolved.",
|
|
934
|
+
level=messages.WARNING,
|
|
935
|
+
)
|
|
936
|
+
return redirect("..")
|
|
937
|
+
return redirect(change_url)
|
|
938
|
+
|
|
939
|
+
def take_snapshot(self, request):
|
|
940
|
+
feature = self._ensure_feature_enabled(
|
|
941
|
+
request, "rpi-camera", "Take a Snapshot"
|
|
942
|
+
)
|
|
943
|
+
if not feature:
|
|
944
|
+
return redirect("..")
|
|
945
|
+
try:
|
|
946
|
+
path = capture_rpi_snapshot()
|
|
947
|
+
except Exception as exc: # pragma: no cover - depends on camera stack
|
|
948
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
949
|
+
return redirect("..")
|
|
950
|
+
node = Node.get_local()
|
|
951
|
+
sample = save_screenshot(path, node=node, method="RPI_CAMERA")
|
|
952
|
+
if not sample:
|
|
953
|
+
self.message_user(
|
|
954
|
+
request, "Duplicate snapshot; not saved", level=messages.INFO
|
|
955
|
+
)
|
|
956
|
+
return redirect("..")
|
|
957
|
+
self.message_user(
|
|
958
|
+
request, f"Snapshot saved to {sample.path}", level=messages.SUCCESS
|
|
959
|
+
)
|
|
960
|
+
try:
|
|
961
|
+
change_url = reverse(
|
|
962
|
+
"admin:nodes_contentsample_change", args=[sample.pk]
|
|
963
|
+
)
|
|
964
|
+
except NoReverseMatch: # pragma: no cover - admin URL always registered
|
|
965
|
+
self.message_user(
|
|
966
|
+
request,
|
|
967
|
+
"Snapshot saved but the admin page could not be resolved.",
|
|
968
|
+
level=messages.WARNING,
|
|
969
|
+
)
|
|
970
|
+
return redirect("..")
|
|
971
|
+
return redirect(change_url)
|
|
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
|
+
|
|
488
1000
|
|
|
489
1001
|
@admin.register(ContentSample)
|
|
490
1002
|
class ContentSampleAdmin(EntityModelAdmin):
|
|
@@ -566,19 +1078,80 @@ class ContentSampleAdmin(EntityModelAdmin):
|
|
|
566
1078
|
|
|
567
1079
|
@admin.register(NetMessage)
|
|
568
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"
|
|
569
1089
|
list_display = (
|
|
570
1090
|
"subject",
|
|
571
1091
|
"body",
|
|
572
|
-
"
|
|
1092
|
+
"filter_node",
|
|
1093
|
+
"filter_node_role",
|
|
573
1094
|
"node_origin",
|
|
574
1095
|
"created",
|
|
1096
|
+
"target_limit",
|
|
575
1097
|
"complete",
|
|
576
1098
|
)
|
|
577
1099
|
search_fields = ("subject", "body")
|
|
578
|
-
list_filter = ("complete", "
|
|
1100
|
+
list_filter = ("complete", "filter_node_role", "filter_current_relation")
|
|
579
1101
|
ordering = ("-created",)
|
|
580
1102
|
readonly_fields = ("complete",)
|
|
581
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
|
|
582
1155
|
|
|
583
1156
|
def send_messages(self, request, queryset):
|
|
584
1157
|
for msg in queryset:
|