arthexis 0.1.10__py3-none-any.whl → 0.1.12__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.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
- arthexis-0.1.12.dist-info/RECORD +102 -0
- config/context_processors.py +1 -0
- config/settings.py +31 -5
- config/urls.py +5 -4
- core/admin.py +430 -90
- core/apps.py +48 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +303 -31
- core/reference_utils.py +20 -9
- core/release.py +4 -0
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +250 -1
- core/tasks.py +92 -40
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +169 -3
- core/user_data.py +51 -8
- core/views.py +371 -20
- nodes/admin.py +453 -8
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +374 -31
- nodes/reports.py +411 -0
- nodes/tests.py +677 -38
- nodes/utils.py +32 -0
- nodes/views.py +14 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +517 -16
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +237 -4
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +819 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +233 -19
- pages/admin.py +144 -4
- pages/context_processors.py +21 -7
- pages/defaults.py +13 -0
- pages/forms.py +38 -0
- pages/models.py +189 -15
- pages/tests.py +281 -8
- pages/urls.py +4 -0
- pages/views.py +137 -21
- arthexis-0.1.10.dist-info/RECORD +0 -95
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
nodes/admin.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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.template.response import TemplateResponse
|
|
4
5
|
from django.utils.html import format_html
|
|
5
6
|
from django import forms
|
|
6
7
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
|
@@ -9,15 +10,23 @@ 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 _
|
|
13
15
|
import base64
|
|
14
16
|
import pyperclip
|
|
15
17
|
from pyperclip import PyperclipException
|
|
16
18
|
import uuid
|
|
17
19
|
import subprocess
|
|
18
|
-
from .utils import capture_screenshot, save_screenshot
|
|
20
|
+
from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
|
|
19
21
|
from .actions import NodeAction
|
|
22
|
+
from .reports import (
|
|
23
|
+
collect_celery_log_entries,
|
|
24
|
+
collect_scheduled_tasks,
|
|
25
|
+
iter_report_periods,
|
|
26
|
+
resolve_period,
|
|
27
|
+
)
|
|
20
28
|
|
|
29
|
+
from core.admin import EmailOutboxAdminForm
|
|
21
30
|
from .models import (
|
|
22
31
|
Node,
|
|
23
32
|
EmailOutbox,
|
|
@@ -26,7 +35,10 @@ from .models import (
|
|
|
26
35
|
NodeFeatureAssignment,
|
|
27
36
|
ContentSample,
|
|
28
37
|
NetMessage,
|
|
38
|
+
NodeManager,
|
|
39
|
+
DNSRecord,
|
|
29
40
|
)
|
|
41
|
+
from . import dns as dns_utils
|
|
30
42
|
from core.user_data import EntityModelAdmin
|
|
31
43
|
|
|
32
44
|
|
|
@@ -43,6 +55,147 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
|
|
|
43
55
|
autocomplete_fields = ("feature",)
|
|
44
56
|
|
|
45
57
|
|
|
58
|
+
class DeployDNSRecordsForm(forms.Form):
|
|
59
|
+
manager = forms.ModelChoiceField(
|
|
60
|
+
label="Node Manager",
|
|
61
|
+
queryset=NodeManager.objects.none(),
|
|
62
|
+
help_text="Credentials used to authenticate with the DNS provider.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def __init__(self, *args, **kwargs):
|
|
66
|
+
super().__init__(*args, **kwargs)
|
|
67
|
+
self.fields["manager"].queryset = NodeManager.objects.filter(
|
|
68
|
+
provider=NodeManager.Provider.GODADDY, is_enabled=True
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@admin.register(NodeManager)
|
|
73
|
+
class NodeManagerAdmin(EntityModelAdmin):
|
|
74
|
+
list_display = ("__str__", "provider", "is_enabled", "default_domain")
|
|
75
|
+
list_filter = ("provider", "is_enabled")
|
|
76
|
+
search_fields = (
|
|
77
|
+
"default_domain",
|
|
78
|
+
"user__username",
|
|
79
|
+
"group__name",
|
|
80
|
+
)
|
|
81
|
+
fieldsets = (
|
|
82
|
+
(_("Owner"), {"fields": ("user", "group")}),
|
|
83
|
+
(
|
|
84
|
+
_("Credentials"),
|
|
85
|
+
{"fields": ("api_key", "api_secret", "customer_id")},
|
|
86
|
+
),
|
|
87
|
+
(
|
|
88
|
+
_("Configuration"),
|
|
89
|
+
{
|
|
90
|
+
"fields": (
|
|
91
|
+
"provider",
|
|
92
|
+
"default_domain",
|
|
93
|
+
"use_sandbox",
|
|
94
|
+
"is_enabled",
|
|
95
|
+
)
|
|
96
|
+
},
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@admin.register(DNSRecord)
|
|
102
|
+
class DNSRecordAdmin(EntityModelAdmin):
|
|
103
|
+
list_display = (
|
|
104
|
+
"record_type",
|
|
105
|
+
"fqdn",
|
|
106
|
+
"data",
|
|
107
|
+
"ttl",
|
|
108
|
+
"node_manager",
|
|
109
|
+
"last_synced_at",
|
|
110
|
+
"last_verified_at",
|
|
111
|
+
)
|
|
112
|
+
list_filter = ("record_type", "provider", "node_manager")
|
|
113
|
+
search_fields = ("domain", "name", "data")
|
|
114
|
+
autocomplete_fields = ("node_manager",)
|
|
115
|
+
actions = ["deploy_selected_records", "validate_selected_records"]
|
|
116
|
+
|
|
117
|
+
def _default_manager_for_queryset(self, queryset):
|
|
118
|
+
manager_ids = list(
|
|
119
|
+
queryset.exclude(node_manager__isnull=True)
|
|
120
|
+
.values_list("node_manager_id", flat=True)
|
|
121
|
+
.distinct()
|
|
122
|
+
)
|
|
123
|
+
if len(manager_ids) == 1:
|
|
124
|
+
return manager_ids[0]
|
|
125
|
+
available = list(
|
|
126
|
+
NodeManager.objects.filter(
|
|
127
|
+
provider=NodeManager.Provider.GODADDY, is_enabled=True
|
|
128
|
+
).values_list("pk", flat=True)
|
|
129
|
+
)
|
|
130
|
+
if len(available) == 1:
|
|
131
|
+
return available[0]
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
@admin.action(description="Deploy Selected records")
|
|
135
|
+
def deploy_selected_records(self, request, queryset):
|
|
136
|
+
unsupported = queryset.exclude(provider=DNSRecord.Provider.GODADDY)
|
|
137
|
+
for record in unsupported:
|
|
138
|
+
self.message_user(
|
|
139
|
+
request,
|
|
140
|
+
f"{record} uses unsupported provider {record.get_provider_display()}",
|
|
141
|
+
messages.WARNING,
|
|
142
|
+
)
|
|
143
|
+
queryset = queryset.filter(provider=DNSRecord.Provider.GODADDY)
|
|
144
|
+
if not queryset:
|
|
145
|
+
self.message_user(request, "No GoDaddy records selected.", messages.WARNING)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
if "apply" in request.POST:
|
|
149
|
+
form = DeployDNSRecordsForm(request.POST)
|
|
150
|
+
if form.is_valid():
|
|
151
|
+
manager = form.cleaned_data["manager"]
|
|
152
|
+
result = manager.publish_dns_records(list(queryset))
|
|
153
|
+
for record, reason in result.skipped.items():
|
|
154
|
+
self.message_user(request, f"{record}: {reason}", messages.WARNING)
|
|
155
|
+
for record, reason in result.failures.items():
|
|
156
|
+
self.message_user(request, f"{record}: {reason}", messages.ERROR)
|
|
157
|
+
if result.deployed:
|
|
158
|
+
self.message_user(
|
|
159
|
+
request,
|
|
160
|
+
f"Deployed {len(result.deployed)} DNS record(s) via {manager}.",
|
|
161
|
+
messages.SUCCESS,
|
|
162
|
+
)
|
|
163
|
+
return None
|
|
164
|
+
else:
|
|
165
|
+
initial_manager = self._default_manager_for_queryset(queryset)
|
|
166
|
+
form = DeployDNSRecordsForm(initial={"manager": initial_manager})
|
|
167
|
+
|
|
168
|
+
context = {
|
|
169
|
+
**self.admin_site.each_context(request),
|
|
170
|
+
"opts": self.model._meta,
|
|
171
|
+
"form": form,
|
|
172
|
+
"queryset": queryset,
|
|
173
|
+
"title": "Deploy DNS records",
|
|
174
|
+
}
|
|
175
|
+
return render(
|
|
176
|
+
request,
|
|
177
|
+
"admin/nodes/dnsrecord/deploy_records.html",
|
|
178
|
+
context,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@admin.action(description="Validate Selected records")
|
|
182
|
+
def validate_selected_records(self, request, queryset):
|
|
183
|
+
resolver = dns_utils.create_resolver()
|
|
184
|
+
successes = 0
|
|
185
|
+
for record in queryset:
|
|
186
|
+
ok, message = dns_utils.validate_record(record, resolver=resolver)
|
|
187
|
+
if ok:
|
|
188
|
+
successes += 1
|
|
189
|
+
else:
|
|
190
|
+
self.message_user(request, f"{record}: {message}", messages.WARNING)
|
|
191
|
+
if successes:
|
|
192
|
+
self.message_user(
|
|
193
|
+
request,
|
|
194
|
+
f"Validated {successes} DNS record(s).",
|
|
195
|
+
messages.SUCCESS,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
46
199
|
@admin.register(Node)
|
|
47
200
|
class NodeAdmin(EntityModelAdmin):
|
|
48
201
|
list_display = (
|
|
@@ -232,21 +385,31 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
232
385
|
|
|
233
386
|
@admin.register(EmailOutbox)
|
|
234
387
|
class EmailOutboxAdmin(EntityModelAdmin):
|
|
235
|
-
|
|
388
|
+
form = EmailOutboxAdminForm
|
|
389
|
+
list_display = (
|
|
390
|
+
"owner_label",
|
|
391
|
+
"host",
|
|
392
|
+
"port",
|
|
393
|
+
"username",
|
|
394
|
+
"use_tls",
|
|
395
|
+
"use_ssl",
|
|
396
|
+
"is_enabled",
|
|
397
|
+
)
|
|
236
398
|
change_form_template = "admin/nodes/emailoutbox/change_form.html"
|
|
237
399
|
fieldsets = (
|
|
238
|
-
("Owner", {"fields": ("user", "group"
|
|
400
|
+
("Owner", {"fields": ("user", "group")}),
|
|
401
|
+
("Credentials", {"fields": ("username", "password")}),
|
|
239
402
|
(
|
|
240
|
-
|
|
403
|
+
"Configuration",
|
|
241
404
|
{
|
|
242
405
|
"fields": (
|
|
406
|
+
"node",
|
|
243
407
|
"host",
|
|
244
408
|
"port",
|
|
245
|
-
"username",
|
|
246
|
-
"password",
|
|
247
409
|
"use_tls",
|
|
248
410
|
"use_ssl",
|
|
249
411
|
"from_email",
|
|
412
|
+
"is_enabled",
|
|
250
413
|
)
|
|
251
414
|
},
|
|
252
415
|
),
|
|
@@ -337,7 +500,14 @@ class NodeRoleAdmin(EntityModelAdmin):
|
|
|
337
500
|
@admin.register(NodeFeature)
|
|
338
501
|
class NodeFeatureAdmin(EntityModelAdmin):
|
|
339
502
|
filter_horizontal = ("roles",)
|
|
340
|
-
list_display = (
|
|
503
|
+
list_display = (
|
|
504
|
+
"display",
|
|
505
|
+
"slug",
|
|
506
|
+
"default_roles",
|
|
507
|
+
"is_enabled_display",
|
|
508
|
+
"default_action",
|
|
509
|
+
)
|
|
510
|
+
actions = ["check_features_for_eligibility", "enable_selected_features"]
|
|
341
511
|
readonly_fields = ("is_enabled",)
|
|
342
512
|
search_fields = ("display", "slug")
|
|
343
513
|
|
|
@@ -350,6 +520,281 @@ class NodeFeatureAdmin(EntityModelAdmin):
|
|
|
350
520
|
roles = [role.name for role in obj.roles.all()]
|
|
351
521
|
return ", ".join(roles) if roles else "—"
|
|
352
522
|
|
|
523
|
+
@admin.display(description="Is Enabled", boolean=True, ordering="is_enabled")
|
|
524
|
+
def is_enabled_display(self, obj):
|
|
525
|
+
return obj.is_enabled
|
|
526
|
+
|
|
527
|
+
@admin.display(description="Default Action")
|
|
528
|
+
def default_action(self, obj):
|
|
529
|
+
if not obj.is_enabled:
|
|
530
|
+
return "—"
|
|
531
|
+
action = obj.get_default_action()
|
|
532
|
+
if not action:
|
|
533
|
+
return "—"
|
|
534
|
+
try:
|
|
535
|
+
url = reverse(action.url_name)
|
|
536
|
+
except NoReverseMatch:
|
|
537
|
+
return action.label
|
|
538
|
+
return format_html('<a href="{}">{}</a>', url, action.label)
|
|
539
|
+
|
|
540
|
+
def _manual_enablement_message(self, feature, node):
|
|
541
|
+
if node is None:
|
|
542
|
+
return (
|
|
543
|
+
"Manual enablement is unavailable without a registered local node."
|
|
544
|
+
)
|
|
545
|
+
if feature.slug in Node.MANUAL_FEATURE_SLUGS:
|
|
546
|
+
return "This feature can be enabled manually."
|
|
547
|
+
return "This feature cannot be enabled manually."
|
|
548
|
+
|
|
549
|
+
@admin.action(description="Check features for eligibility")
|
|
550
|
+
def check_features_for_eligibility(self, request, queryset):
|
|
551
|
+
from .feature_checks import feature_checks
|
|
552
|
+
|
|
553
|
+
features = list(queryset)
|
|
554
|
+
total = len(features)
|
|
555
|
+
successes = 0
|
|
556
|
+
node = Node.get_local()
|
|
557
|
+
for feature in features:
|
|
558
|
+
enablement_message = self._manual_enablement_message(feature, node)
|
|
559
|
+
try:
|
|
560
|
+
result = feature_checks.run(feature, node=node)
|
|
561
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
562
|
+
self.message_user(
|
|
563
|
+
request,
|
|
564
|
+
f"{feature.display}: {exc} {enablement_message}",
|
|
565
|
+
level=messages.ERROR,
|
|
566
|
+
)
|
|
567
|
+
continue
|
|
568
|
+
if result is None:
|
|
569
|
+
self.message_user(
|
|
570
|
+
request,
|
|
571
|
+
f"No check is configured for {feature.display}. {enablement_message}",
|
|
572
|
+
level=messages.WARNING,
|
|
573
|
+
)
|
|
574
|
+
continue
|
|
575
|
+
message = result.message or (
|
|
576
|
+
f"{feature.display} check {'passed' if result.success else 'failed'}."
|
|
577
|
+
)
|
|
578
|
+
self.message_user(
|
|
579
|
+
request, f"{message} {enablement_message}", level=result.level
|
|
580
|
+
)
|
|
581
|
+
if result.success:
|
|
582
|
+
successes += 1
|
|
583
|
+
if total:
|
|
584
|
+
self.message_user(
|
|
585
|
+
request,
|
|
586
|
+
f"Completed {successes} of {total} feature check(s) successfully.",
|
|
587
|
+
level=messages.INFO,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
@admin.action(description="Enable selected action")
|
|
591
|
+
def enable_selected_features(self, request, queryset):
|
|
592
|
+
node = Node.get_local()
|
|
593
|
+
if node is None:
|
|
594
|
+
self.message_user(
|
|
595
|
+
request,
|
|
596
|
+
"No local node is registered; unable to enable features manually.",
|
|
597
|
+
level=messages.ERROR,
|
|
598
|
+
)
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
manual_features = [
|
|
602
|
+
feature
|
|
603
|
+
for feature in queryset
|
|
604
|
+
if feature.slug in Node.MANUAL_FEATURE_SLUGS
|
|
605
|
+
]
|
|
606
|
+
non_manual_features = [
|
|
607
|
+
feature
|
|
608
|
+
for feature in queryset
|
|
609
|
+
if feature.slug not in Node.MANUAL_FEATURE_SLUGS
|
|
610
|
+
]
|
|
611
|
+
for feature in non_manual_features:
|
|
612
|
+
self.message_user(
|
|
613
|
+
request,
|
|
614
|
+
f"{feature.display} cannot be enabled manually.",
|
|
615
|
+
level=messages.WARNING,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
if not manual_features:
|
|
619
|
+
self.message_user(
|
|
620
|
+
request,
|
|
621
|
+
"None of the selected features can be enabled manually.",
|
|
622
|
+
level=messages.WARNING,
|
|
623
|
+
)
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
current_manual = set(
|
|
627
|
+
node.features.filter(slug__in=Node.MANUAL_FEATURE_SLUGS).values_list(
|
|
628
|
+
"slug", flat=True
|
|
629
|
+
)
|
|
630
|
+
)
|
|
631
|
+
desired_manual = current_manual | {feature.slug for feature in manual_features}
|
|
632
|
+
newly_enabled = desired_manual - current_manual
|
|
633
|
+
if not newly_enabled:
|
|
634
|
+
self.message_user(
|
|
635
|
+
request,
|
|
636
|
+
"Selected manual features are already enabled.",
|
|
637
|
+
level=messages.INFO,
|
|
638
|
+
)
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
node.update_manual_features(desired_manual)
|
|
642
|
+
display_map = {feature.slug: feature.display for feature in manual_features}
|
|
643
|
+
newly_enabled_names = [display_map[slug] for slug in sorted(newly_enabled)]
|
|
644
|
+
self.message_user(
|
|
645
|
+
request,
|
|
646
|
+
"Enabled {} feature(s): {}".format(
|
|
647
|
+
len(newly_enabled), ", ".join(newly_enabled_names)
|
|
648
|
+
),
|
|
649
|
+
level=messages.SUCCESS,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
def get_urls(self):
|
|
653
|
+
urls = super().get_urls()
|
|
654
|
+
custom = [
|
|
655
|
+
path(
|
|
656
|
+
"celery-report/",
|
|
657
|
+
self.admin_site.admin_view(self.celery_report),
|
|
658
|
+
name="nodes_nodefeature_celery_report",
|
|
659
|
+
),
|
|
660
|
+
path(
|
|
661
|
+
"take-screenshot/",
|
|
662
|
+
self.admin_site.admin_view(self.take_screenshot),
|
|
663
|
+
name="nodes_nodefeature_take_screenshot",
|
|
664
|
+
),
|
|
665
|
+
path(
|
|
666
|
+
"take-snapshot/",
|
|
667
|
+
self.admin_site.admin_view(self.take_snapshot),
|
|
668
|
+
name="nodes_nodefeature_take_snapshot",
|
|
669
|
+
),
|
|
670
|
+
]
|
|
671
|
+
return custom + urls
|
|
672
|
+
|
|
673
|
+
def celery_report(self, request):
|
|
674
|
+
period = resolve_period(request.GET.get("period"))
|
|
675
|
+
now = timezone.now()
|
|
676
|
+
window_end = now + period.delta
|
|
677
|
+
log_window_start = now - period.delta
|
|
678
|
+
|
|
679
|
+
scheduled_tasks = collect_scheduled_tasks(now, window_end)
|
|
680
|
+
log_collection = collect_celery_log_entries(log_window_start, now)
|
|
681
|
+
|
|
682
|
+
period_options = [
|
|
683
|
+
{
|
|
684
|
+
"key": candidate.key,
|
|
685
|
+
"label": candidate.label,
|
|
686
|
+
"selected": candidate.key == period.key,
|
|
687
|
+
"url": f"?period={candidate.key}",
|
|
688
|
+
}
|
|
689
|
+
for candidate in iter_report_periods()
|
|
690
|
+
]
|
|
691
|
+
|
|
692
|
+
context = {
|
|
693
|
+
**self.admin_site.each_context(request),
|
|
694
|
+
"title": _("Celery Report"),
|
|
695
|
+
"period": period,
|
|
696
|
+
"period_options": period_options,
|
|
697
|
+
"current_time": now,
|
|
698
|
+
"window_end": window_end,
|
|
699
|
+
"log_window_start": log_window_start,
|
|
700
|
+
"scheduled_tasks": scheduled_tasks,
|
|
701
|
+
"log_entries": log_collection.entries,
|
|
702
|
+
"log_sources": log_collection.checked_sources,
|
|
703
|
+
}
|
|
704
|
+
return TemplateResponse(
|
|
705
|
+
request,
|
|
706
|
+
"admin/nodes/nodefeature/celery_report.html",
|
|
707
|
+
context,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
def _ensure_feature_enabled(self, request, slug: str, action_label: str):
|
|
711
|
+
try:
|
|
712
|
+
feature = NodeFeature.objects.get(slug=slug)
|
|
713
|
+
except NodeFeature.DoesNotExist:
|
|
714
|
+
self.message_user(
|
|
715
|
+
request,
|
|
716
|
+
f"{action_label} is unavailable because the feature is not configured.",
|
|
717
|
+
level=messages.ERROR,
|
|
718
|
+
)
|
|
719
|
+
return None
|
|
720
|
+
if not feature.is_enabled:
|
|
721
|
+
self.message_user(
|
|
722
|
+
request,
|
|
723
|
+
f"{feature.display} feature is not enabled on this node.",
|
|
724
|
+
level=messages.WARNING,
|
|
725
|
+
)
|
|
726
|
+
return None
|
|
727
|
+
return feature
|
|
728
|
+
|
|
729
|
+
def take_screenshot(self, request):
|
|
730
|
+
feature = self._ensure_feature_enabled(
|
|
731
|
+
request, "screenshot-poll", "Take Screenshot"
|
|
732
|
+
)
|
|
733
|
+
if not feature:
|
|
734
|
+
return redirect("..")
|
|
735
|
+
url = request.build_absolute_uri("/")
|
|
736
|
+
try:
|
|
737
|
+
path = capture_screenshot(url)
|
|
738
|
+
except Exception as exc: # pragma: no cover - depends on selenium setup
|
|
739
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
740
|
+
return redirect("..")
|
|
741
|
+
node = Node.get_local()
|
|
742
|
+
sample = save_screenshot(path, node=node, method="DEFAULT_ACTION")
|
|
743
|
+
if not sample:
|
|
744
|
+
self.message_user(
|
|
745
|
+
request, "Duplicate screenshot; not saved", level=messages.INFO
|
|
746
|
+
)
|
|
747
|
+
return redirect("..")
|
|
748
|
+
self.message_user(
|
|
749
|
+
request, f"Screenshot saved to {sample.path}", level=messages.SUCCESS
|
|
750
|
+
)
|
|
751
|
+
try:
|
|
752
|
+
change_url = reverse(
|
|
753
|
+
"admin:nodes_contentsample_change", args=[sample.pk]
|
|
754
|
+
)
|
|
755
|
+
except NoReverseMatch: # pragma: no cover - admin URL always registered
|
|
756
|
+
self.message_user(
|
|
757
|
+
request,
|
|
758
|
+
"Screenshot saved but the admin page could not be resolved.",
|
|
759
|
+
level=messages.WARNING,
|
|
760
|
+
)
|
|
761
|
+
return redirect("..")
|
|
762
|
+
return redirect(change_url)
|
|
763
|
+
|
|
764
|
+
def take_snapshot(self, request):
|
|
765
|
+
feature = self._ensure_feature_enabled(
|
|
766
|
+
request, "rpi-camera", "Take a Snapshot"
|
|
767
|
+
)
|
|
768
|
+
if not feature:
|
|
769
|
+
return redirect("..")
|
|
770
|
+
try:
|
|
771
|
+
path = capture_rpi_snapshot()
|
|
772
|
+
except Exception as exc: # pragma: no cover - depends on camera stack
|
|
773
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
774
|
+
return redirect("..")
|
|
775
|
+
node = Node.get_local()
|
|
776
|
+
sample = save_screenshot(path, node=node, method="RPI_CAMERA")
|
|
777
|
+
if not sample:
|
|
778
|
+
self.message_user(
|
|
779
|
+
request, "Duplicate snapshot; not saved", level=messages.INFO
|
|
780
|
+
)
|
|
781
|
+
return redirect("..")
|
|
782
|
+
self.message_user(
|
|
783
|
+
request, f"Snapshot saved to {sample.path}", level=messages.SUCCESS
|
|
784
|
+
)
|
|
785
|
+
try:
|
|
786
|
+
change_url = reverse(
|
|
787
|
+
"admin:nodes_contentsample_change", args=[sample.pk]
|
|
788
|
+
)
|
|
789
|
+
except NoReverseMatch: # pragma: no cover - admin URL always registered
|
|
790
|
+
self.message_user(
|
|
791
|
+
request,
|
|
792
|
+
"Snapshot saved but the admin page could not be resolved.",
|
|
793
|
+
level=messages.WARNING,
|
|
794
|
+
)
|
|
795
|
+
return redirect("..")
|
|
796
|
+
return redirect(change_url)
|
|
797
|
+
|
|
353
798
|
|
|
354
799
|
@admin.register(ContentSample)
|
|
355
800
|
class ContentSampleAdmin(EntityModelAdmin):
|
nodes/backends.py
CHANGED
|
@@ -38,11 +38,12 @@ class OutboxEmailBackend(BaseEmailBackend):
|
|
|
38
38
|
user_id = self._resolve_identifier(message, "user")
|
|
39
39
|
group_id = self._resolve_identifier(message, "group")
|
|
40
40
|
|
|
41
|
+
enabled_outboxes = EmailOutbox.objects.filter(is_enabled=True)
|
|
41
42
|
match_sets: list[tuple[str, list[EmailOutbox]]] = []
|
|
42
43
|
|
|
43
44
|
if from_email:
|
|
44
45
|
email_matches = list(
|
|
45
|
-
|
|
46
|
+
enabled_outboxes.filter(
|
|
46
47
|
Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
|
|
47
48
|
)
|
|
48
49
|
)
|
|
@@ -50,22 +51,25 @@ class OutboxEmailBackend(BaseEmailBackend):
|
|
|
50
51
|
match_sets.append(("from_email", email_matches))
|
|
51
52
|
|
|
52
53
|
if node_id:
|
|
53
|
-
node_matches = list(
|
|
54
|
+
node_matches = list(enabled_outboxes.filter(node_id=node_id))
|
|
54
55
|
if node_matches:
|
|
55
56
|
match_sets.append(("node", node_matches))
|
|
56
57
|
|
|
57
58
|
if user_id:
|
|
58
|
-
user_matches = list(
|
|
59
|
+
user_matches = list(enabled_outboxes.filter(user_id=user_id))
|
|
59
60
|
if user_matches:
|
|
60
61
|
match_sets.append(("user", user_matches))
|
|
61
62
|
|
|
62
63
|
if group_id:
|
|
63
|
-
group_matches = list(
|
|
64
|
+
group_matches = list(enabled_outboxes.filter(group_id=group_id))
|
|
64
65
|
if group_matches:
|
|
65
66
|
match_sets.append(("group", group_matches))
|
|
66
67
|
|
|
67
68
|
if not match_sets:
|
|
68
|
-
|
|
69
|
+
fallback = self._fallback_outbox(enabled_outboxes)
|
|
70
|
+
if fallback:
|
|
71
|
+
return fallback, []
|
|
72
|
+
return None, []
|
|
69
73
|
|
|
70
74
|
candidates: dict[int, EmailOutbox] = {}
|
|
71
75
|
scores: defaultdict[int, int] = defaultdict(int)
|
|
@@ -76,7 +80,10 @@ class OutboxEmailBackend(BaseEmailBackend):
|
|
|
76
80
|
scores[outbox.pk] += 1
|
|
77
81
|
|
|
78
82
|
if not candidates:
|
|
79
|
-
|
|
83
|
+
fallback = self._fallback_outbox(enabled_outboxes)
|
|
84
|
+
if fallback:
|
|
85
|
+
return fallback, []
|
|
86
|
+
return None, []
|
|
80
87
|
|
|
81
88
|
selected: EmailOutbox | None = None
|
|
82
89
|
fallbacks: list[EmailOutbox] = []
|
|
@@ -93,6 +100,14 @@ class OutboxEmailBackend(BaseEmailBackend):
|
|
|
93
100
|
|
|
94
101
|
return selected, fallbacks
|
|
95
102
|
|
|
103
|
+
def _fallback_outbox(self, queryset):
|
|
104
|
+
ownerless = queryset.filter(
|
|
105
|
+
node__isnull=True, user__isnull=True, group__isnull=True
|
|
106
|
+
).order_by("pk").first()
|
|
107
|
+
if ownerless:
|
|
108
|
+
return ownerless
|
|
109
|
+
return queryset.order_by("pk").first()
|
|
110
|
+
|
|
96
111
|
def send_messages(self, email_messages):
|
|
97
112
|
sent = 0
|
|
98
113
|
for message in email_messages:
|