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

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

Potentially problematic release.


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

Files changed (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
nodes/admin.py CHANGED
@@ -1,445 +1,2236 @@
1
- from django.contrib import admin, messages
2
- from django.urls import path, reverse
3
- from django.shortcuts import redirect, render
4
- from django.utils.html import format_html
5
- from django import forms
6
- from django.contrib.admin.widgets import FilteredSelectMultiple
7
- from core.widgets import CopyColorWidget
8
- from django.db.models import Count
9
- from django.conf import settings
10
- from pathlib import Path
11
- from django.http import HttpResponse
12
- import base64
13
- import pyperclip
14
- from pyperclip import PyperclipException
15
- import uuid
16
- import subprocess
17
- from .utils import capture_screenshot, save_screenshot
18
- from .actions import NodeAction
19
-
20
- from .models import (
21
- Node,
22
- EmailOutbox,
23
- NodeRole,
24
- NodeFeature,
25
- NodeFeatureAssignment,
26
- ContentSample,
27
- NetMessage,
28
- )
29
- from core.user_data import EntityModelAdmin
30
-
31
-
32
- class NodeAdminForm(forms.ModelForm):
33
- class Meta:
34
- model = Node
35
- fields = "__all__"
36
- widgets = {"badge_color": CopyColorWidget()}
37
-
38
-
39
- class NodeFeatureAssignmentInline(admin.TabularInline):
40
- model = NodeFeatureAssignment
41
- extra = 0
42
- autocomplete_fields = ("feature",)
43
-
44
-
45
- @admin.register(Node)
46
- class NodeAdmin(EntityModelAdmin):
47
- list_display = (
48
- "hostname",
49
- "mac_address",
50
- "address",
51
- "port",
52
- "role",
53
- "last_seen",
54
- )
55
- search_fields = ("hostname", "address", "mac_address")
56
- change_list_template = "admin/nodes/node/change_list.html"
57
- change_form_template = "admin/nodes/node/change_form.html"
58
- form = NodeAdminForm
59
- actions = ["register_visitor", "run_task", "take_screenshots"]
60
- inlines = [NodeFeatureAssignmentInline]
61
-
62
- def get_urls(self):
63
- urls = super().get_urls()
64
- custom = [
65
- path(
66
- "register-current/",
67
- self.admin_site.admin_view(self.register_current),
68
- name="nodes_node_register_current",
69
- ),
70
- path(
71
- "register-visitor/",
72
- self.admin_site.admin_view(self.register_visitor_view),
73
- name="nodes_node_register_visitor",
74
- ),
75
- path(
76
- "<int:node_id>/action/<str:action>/",
77
- self.admin_site.admin_view(self.action_view),
78
- name="nodes_node_action",
79
- ),
80
- path(
81
- "<int:node_id>/public-key/",
82
- self.admin_site.admin_view(self.public_key),
83
- name="nodes_node_public_key",
84
- ),
85
- ]
86
- return custom + urls
87
-
88
- def register_current(self, request):
89
- """Create or update this host and offer browser node registration."""
90
- node, created = Node.register_current()
91
- if created:
92
- self.message_user(
93
- request, f"Current host registered as {node}", messages.SUCCESS
94
- )
95
- token = uuid.uuid4().hex
96
- context = {
97
- "token": token,
98
- "register_url": reverse("register-node"),
99
- }
100
- return render(request, "admin/nodes/node/register_remote.html", context)
101
-
102
- @admin.action(description="Register Visitor Node")
103
- def register_visitor(self, request, queryset=None):
104
- return self.register_visitor_view(request)
105
-
106
- def register_visitor_view(self, request):
107
- """Exchange registration data with the visiting node."""
108
-
109
- node, created = Node.register_current()
110
- if created:
111
- self.message_user(
112
- request, f"Current host registered as {node}", messages.SUCCESS
113
- )
114
-
115
- token = uuid.uuid4().hex
116
- context = {
117
- "token": token,
118
- "info_url": reverse("node-info"),
119
- "register_url": reverse("register-node"),
120
- "visitor_info_url": "http://localhost:8000/nodes/info/",
121
- "visitor_register_url": "http://localhost:8000/nodes/register/",
122
- }
123
- return render(request, "admin/nodes/node/register_visitor.html", context)
124
-
125
- def public_key(self, request, node_id):
126
- node = self.get_object(request, node_id)
127
- if not node:
128
- self.message_user(request, "Unknown node", messages.ERROR)
129
- return redirect("..")
130
- security_dir = Path(settings.BASE_DIR) / "security"
131
- pub_path = security_dir / f"{node.public_endpoint}.pub"
132
- if pub_path.exists():
133
- response = HttpResponse(pub_path.read_bytes(), content_type="text/plain")
134
- response["Content-Disposition"] = f'attachment; filename="{pub_path.name}"'
135
- return response
136
- self.message_user(request, "Public key not found", messages.ERROR)
137
- return redirect("..")
138
-
139
- def run_task(self, request, queryset):
140
- if "apply" in request.POST:
141
- recipe_text = request.POST.get("recipe", "")
142
- results = []
143
- for node in queryset:
144
- try:
145
- if not node.is_local:
146
- raise NotImplementedError(
147
- "Remote node execution is not implemented"
148
- )
149
- command = ["/bin/sh", "-c", recipe_text]
150
- result = subprocess.run(
151
- command,
152
- check=False,
153
- capture_output=True,
154
- text=True,
155
- )
156
- output = result.stdout + result.stderr
157
- except Exception as exc:
158
- output = str(exc)
159
- results.append((node, output))
160
- context = {"recipe": recipe_text, "results": results}
161
- return render(request, "admin/nodes/task_result.html", context)
162
- context = {"nodes": queryset}
163
- return render(request, "admin/nodes/node/run_task.html", context)
164
-
165
- run_task.short_description = "Run task"
166
-
167
- @admin.action(description="Take Screenshots")
168
- def take_screenshots(self, request, queryset):
169
- tx = uuid.uuid4()
170
- sources = getattr(settings, "SCREENSHOT_SOURCES", ["/"])
171
- count = 0
172
- for node in queryset:
173
- for source in sources:
174
- try:
175
- url = source.format(node=node, address=node.address, port=node.port)
176
- except Exception:
177
- url = source
178
- if not url.startswith("http"):
179
- url = f"http://{node.address}:{node.port}{url}"
180
- try:
181
- path = capture_screenshot(url)
182
- except Exception as exc: # pragma: no cover - selenium issues
183
- self.message_user(request, f"{node}: {exc}", messages.ERROR)
184
- continue
185
- sample = save_screenshot(
186
- path, node=node, method="ADMIN", transaction_uuid=tx
187
- )
188
- if sample:
189
- count += 1
190
- self.message_user(request, f"{count} screenshots captured", messages.SUCCESS)
191
-
192
- def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
193
- extra_context = extra_context or {}
194
- extra_context["node_actions"] = NodeAction.get_actions()
195
- if object_id:
196
- extra_context["public_key_url"] = reverse(
197
- "admin:nodes_node_public_key", args=[object_id]
198
- )
199
- return super().changeform_view(
200
- request, object_id, form_url, extra_context=extra_context
201
- )
202
-
203
- def action_view(self, request, node_id, action):
204
- node = self.get_object(request, node_id)
205
- action_cls = NodeAction.registry.get(action)
206
- if not node or not action_cls:
207
- self.message_user(request, "Unknown node action", messages.ERROR)
208
- return redirect("..")
209
- try:
210
- result = action_cls.run(node)
211
- if hasattr(result, "status_code"):
212
- return result
213
- self.message_user(
214
- request,
215
- f"{action_cls.display_name} executed successfully",
216
- messages.SUCCESS,
217
- )
218
- except NotImplementedError:
219
- self.message_user(
220
- request,
221
- "Remote node actions are not yet implemented",
222
- messages.WARNING,
223
- )
224
- except Exception as exc: # pragma: no cover - unexpected errors
225
- self.message_user(request, str(exc), messages.ERROR)
226
- return redirect(reverse("admin:nodes_node_change", args=[node_id]))
227
-
228
-
229
- @admin.register(EmailOutbox)
230
- class EmailOutboxAdmin(EntityModelAdmin):
231
- list_display = ("owner_label", "host", "port", "username", "use_tls", "use_ssl")
232
- change_form_template = "admin/nodes/emailoutbox/change_form.html"
233
- fieldsets = (
234
- ("Owner", {"fields": ("user", "group", "node")}),
235
- (
236
- None,
237
- {
238
- "fields": (
239
- "host",
240
- "port",
241
- "username",
242
- "password",
243
- "use_tls",
244
- "use_ssl",
245
- "from_email",
246
- )
247
- },
248
- ),
249
- )
250
-
251
- @admin.display(description="Owner")
252
- def owner_label(self, obj):
253
- return obj.owner_display()
254
-
255
- def get_urls(self):
256
- urls = super().get_urls()
257
- custom = [
258
- path(
259
- "<path:object_id>/test/",
260
- self.admin_site.admin_view(self.test_outbox),
261
- name="nodes_emailoutbox_test",
262
- )
263
- ]
264
- return custom + urls
265
-
266
- def test_outbox(self, request, object_id):
267
- outbox = self.get_object(request, object_id)
268
- if not outbox:
269
- self.message_user(request, "Unknown outbox", messages.ERROR)
270
- return redirect("..")
271
- recipient = request.user.email or outbox.username
272
- try:
273
- outbox.send_mail(
274
- "Test email",
275
- "This is a test email.",
276
- [recipient],
277
- )
278
- self.message_user(request, "Test email sent", messages.SUCCESS)
279
- except Exception as exc: # pragma: no cover - admin feedback
280
- self.message_user(request, str(exc), messages.ERROR)
281
- return redirect("..")
282
-
283
- def get_model_perms(self, request): # pragma: no cover - hide from index
284
- return {}
285
-
286
- def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
287
- extra_context = extra_context or {}
288
- if object_id:
289
- extra_context["test_url"] = reverse(
290
- "admin:nodes_emailoutbox_test", args=[object_id]
291
- )
292
- return super().changeform_view(request, object_id, form_url, extra_context)
293
-
294
-
295
- class NodeRoleAdminForm(forms.ModelForm):
296
- nodes = forms.ModelMultipleChoiceField(
297
- queryset=Node.objects.all(),
298
- required=False,
299
- widget=FilteredSelectMultiple("Nodes", False),
300
- )
301
-
302
- class Meta:
303
- model = NodeRole
304
- fields = ("name", "description", "nodes")
305
-
306
- def __init__(self, *args, **kwargs):
307
- super().__init__(*args, **kwargs)
308
- if self.instance.pk:
309
- self.fields["nodes"].initial = self.instance.node_set.all()
310
-
311
-
312
- @admin.register(NodeRole)
313
- class NodeRoleAdmin(EntityModelAdmin):
314
- form = NodeRoleAdminForm
315
- list_display = ("name", "description", "registered", "default_features")
316
-
317
- def get_queryset(self, request):
318
- qs = super().get_queryset(request)
319
- return qs.annotate(_registered=Count("node", distinct=True)).prefetch_related(
320
- "features"
321
- )
322
-
323
- @admin.display(description="Registered", ordering="_registered")
324
- def registered(self, obj):
325
- return getattr(obj, "_registered", obj.node_set.count())
326
-
327
- @admin.display(description="Default Features")
328
- def default_features(self, obj):
329
- features = [feature.display for feature in obj.features.all()]
330
- return ", ".join(features) if features else "—"
331
-
332
- def save_model(self, request, obj, form, change):
333
- obj.node_set.set(form.cleaned_data.get("nodes", []))
334
-
335
-
336
- @admin.register(NodeFeature)
337
- class NodeFeatureAdmin(EntityModelAdmin):
338
- filter_horizontal = ("roles",)
339
- list_display = ("display", "slug", "default_roles", "is_enabled")
340
- readonly_fields = ("is_enabled",)
341
- search_fields = ("display", "slug")
342
-
343
- def get_queryset(self, request):
344
- qs = super().get_queryset(request)
345
- return qs.prefetch_related("roles")
346
-
347
- @admin.display(description="Default Roles")
348
- def default_roles(self, obj):
349
- roles = [role.name for role in obj.roles.all()]
350
- return ", ".join(roles) if roles else "—"
351
-
352
-
353
- @admin.register(ContentSample)
354
- class ContentSampleAdmin(EntityModelAdmin):
355
- list_display = ("name", "kind", "node", "user", "created_at")
356
- readonly_fields = ("created_at", "name", "user", "image_preview")
357
-
358
- def get_urls(self):
359
- urls = super().get_urls()
360
- custom = [
361
- path(
362
- "from-clipboard/",
363
- self.admin_site.admin_view(self.add_from_clipboard),
364
- name="nodes_contentsample_from_clipboard",
365
- ),
366
- path(
367
- "capture/",
368
- self.admin_site.admin_view(self.capture_now),
369
- name="nodes_contentsample_capture",
370
- ),
371
- ]
372
- return custom + urls
373
-
374
- def add_from_clipboard(self, request):
375
- try:
376
- content = pyperclip.paste()
377
- except PyperclipException as exc: # pragma: no cover - depends on OS clipboard
378
- self.message_user(request, f"Clipboard error: {exc}", level=messages.ERROR)
379
- return redirect("..")
380
- if not content:
381
- self.message_user(request, "Clipboard is empty.", level=messages.INFO)
382
- return redirect("..")
383
- if ContentSample.objects.filter(
384
- content=content, kind=ContentSample.TEXT
385
- ).exists():
386
- self.message_user(
387
- request, "Duplicate sample not created.", level=messages.INFO
388
- )
389
- return redirect("..")
390
- user = request.user if request.user.is_authenticated else None
391
- ContentSample.objects.create(
392
- content=content, user=user, kind=ContentSample.TEXT
393
- )
394
- self.message_user(
395
- request, "Text sample added from clipboard.", level=messages.SUCCESS
396
- )
397
- return redirect("..")
398
-
399
- def capture_now(self, request):
400
- node = Node.get_local()
401
- url = request.build_absolute_uri("/")
402
- try:
403
- path = capture_screenshot(url)
404
- except Exception as exc: # pragma: no cover - depends on selenium setup
405
- self.message_user(request, str(exc), level=messages.ERROR)
406
- return redirect("..")
407
- sample = save_screenshot(path, node=node, method="ADMIN")
408
- if sample:
409
- self.message_user(request, f"Screenshot saved to {path}", messages.SUCCESS)
410
- else:
411
- self.message_user(request, "Duplicate screenshot; not saved", messages.INFO)
412
- return redirect("..")
413
-
414
- @admin.display(description="Screenshot")
415
- def image_preview(self, obj):
416
- if not obj or obj.kind != ContentSample.IMAGE or not obj.path:
417
- return ""
418
- file_path = Path(obj.path)
419
- if not file_path.is_absolute():
420
- file_path = settings.LOG_DIR / file_path
421
- if not file_path.exists():
422
- return "File not found"
423
- with file_path.open("rb") as f:
424
- encoded = base64.b64encode(f.read()).decode("ascii")
425
- return format_html(
426
- '<img src="data:image/png;base64,{}" style="max-width:100%;" />',
427
- encoded,
428
- )
429
-
430
-
431
- @admin.register(NetMessage)
432
- class NetMessageAdmin(EntityModelAdmin):
433
- list_display = ("subject", "body", "reach", "created", "complete")
434
- search_fields = ("subject", "body")
435
- list_filter = ("complete", "reach")
436
- ordering = ("-created",)
437
- readonly_fields = ("complete",)
438
- actions = ["send_messages"]
439
-
440
- def send_messages(self, request, queryset):
441
- for msg in queryset:
442
- msg.propagate()
443
- self.message_user(request, f"{queryset.count()} messages sent")
444
-
445
- send_messages.short_description = "Send selected messages"
1
+ from collections import OrderedDict
2
+ from collections.abc import Mapping
3
+
4
+ from django import forms
5
+ from django.conf import settings
6
+ from django.contrib import admin, messages
7
+ from django.contrib.admin import helpers
8
+ from django.contrib.admin.widgets import FilteredSelectMultiple
9
+ from django.core.exceptions import PermissionDenied
10
+ from django.db.models import Count
11
+ from django.http import Http404, HttpResponse, JsonResponse
12
+ from django.shortcuts import redirect, render
13
+ from django.template.response import TemplateResponse
14
+ from django.test import signals
15
+ from django.urls import NoReverseMatch, path, reverse
16
+ from django.utils import timezone
17
+ from django.utils.dateparse import parse_datetime
18
+ from django.utils.html import format_html, format_html_join
19
+ from django.utils.translation import gettext_lazy as _, ngettext
20
+ from pathlib import Path
21
+ from types import SimpleNamespace
22
+ from urllib.parse import urlsplit, urlunsplit, quote
23
+ import base64
24
+ import json
25
+ import subprocess
26
+ import uuid
27
+
28
+ import asyncio
29
+ import pyperclip
30
+ import requests
31
+ from cryptography.hazmat.primitives import hashes, serialization
32
+ from cryptography.hazmat.primitives.asymmetric import padding
33
+ from pyperclip import PyperclipException
34
+ from requests import RequestException
35
+ import websockets
36
+
37
+ from .classifiers import run_default_classifiers, suppress_default_classifiers
38
+ from .rfid_sync import apply_rfid_payload, serialize_rfid
39
+ from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
40
+ from .reports import (
41
+ collect_celery_log_entries,
42
+ collect_scheduled_tasks,
43
+ iter_report_periods,
44
+ resolve_period,
45
+ )
46
+
47
+ from core.admin import EmailOutboxAdminForm
48
+ from .models import (
49
+ Node,
50
+ EmailOutbox,
51
+ NodeRole,
52
+ NodeFeature,
53
+ NodeFeatureAssignment,
54
+ ContentSample,
55
+ ContentClassifier,
56
+ ContentClassification,
57
+ ContentTag,
58
+ NetMessage,
59
+ NodeManager,
60
+ DNSRecord,
61
+ )
62
+ from . import dns as dns_utils
63
+ from core.models import RFID
64
+ from ocpp.models import Charger
65
+ from ocpp.network import serialize_charger_for_network
66
+ from ocpp.tasks import push_forwarded_charge_points
67
+ from core.user_data import EntityModelAdmin
68
+
69
+
70
+ class NodeAdminForm(forms.ModelForm):
71
+ class Meta:
72
+ model = Node
73
+ exclude = ("badge_color", "features")
74
+
75
+ def __init__(self, *args, **kwargs):
76
+ super().__init__(*args, **kwargs)
77
+ enable_public = self.fields.get("enable_public_api")
78
+ if enable_public:
79
+ enable_public.label = _("Enable public admin access")
80
+ enable_public.help_text = _(
81
+ "Expose the admin API through this node's public endpoint. "
82
+ "Only enable when trusted peers require administrative access."
83
+ )
84
+
85
+
86
+ class NodeFeatureAssignmentInline(admin.TabularInline):
87
+ model = NodeFeatureAssignment
88
+ extra = 0
89
+ autocomplete_fields = ("feature",)
90
+
91
+
92
+ class DeployDNSRecordsForm(forms.Form):
93
+ manager = forms.ModelChoiceField(
94
+ label="Node Profile",
95
+ queryset=NodeManager.objects.none(),
96
+ help_text="Credentials used to authenticate with the DNS provider.",
97
+ )
98
+
99
+ def __init__(self, *args, **kwargs):
100
+ super().__init__(*args, **kwargs)
101
+ self.fields["manager"].queryset = NodeManager.objects.filter(
102
+ provider=NodeManager.Provider.GODADDY, is_enabled=True
103
+ )
104
+
105
+
106
+ @admin.register(NodeManager)
107
+ class NodeManagerAdmin(EntityModelAdmin):
108
+ list_display = ("__str__", "provider", "is_enabled", "default_domain")
109
+ list_filter = ("provider", "is_enabled")
110
+ search_fields = (
111
+ "default_domain",
112
+ "user__username",
113
+ "group__name",
114
+ )
115
+ fieldsets = (
116
+ (_("Owner"), {"fields": ("user", "group")}),
117
+ (
118
+ _("Credentials"),
119
+ {"fields": ("api_key", "api_secret", "customer_id")},
120
+ ),
121
+ (
122
+ _("Configuration"),
123
+ {
124
+ "fields": (
125
+ "provider",
126
+ "default_domain",
127
+ "use_sandbox",
128
+ "is_enabled",
129
+ )
130
+ },
131
+ ),
132
+ )
133
+
134
+
135
+ @admin.register(DNSRecord)
136
+ class DNSRecordAdmin(EntityModelAdmin):
137
+ list_display = (
138
+ "record_type",
139
+ "fqdn",
140
+ "data",
141
+ "ttl",
142
+ "node_manager",
143
+ "last_synced_at",
144
+ "last_verified_at",
145
+ )
146
+ list_filter = ("record_type", "provider", "node_manager")
147
+ search_fields = ("domain", "name", "data")
148
+ autocomplete_fields = ("node_manager",)
149
+ actions = ["deploy_selected_records", "validate_selected_records"]
150
+
151
+ def _default_manager_for_queryset(self, queryset):
152
+ manager_ids = list(
153
+ queryset.exclude(node_manager__isnull=True)
154
+ .values_list("node_manager_id", flat=True)
155
+ .distinct()
156
+ )
157
+ if len(manager_ids) == 1:
158
+ return manager_ids[0]
159
+ available = list(
160
+ NodeManager.objects.filter(
161
+ provider=NodeManager.Provider.GODADDY, is_enabled=True
162
+ ).values_list("pk", flat=True)
163
+ )
164
+ if len(available) == 1:
165
+ return available[0]
166
+ return None
167
+
168
+ @admin.action(description="Deploy Selected records")
169
+ def deploy_selected_records(self, request, queryset):
170
+ unsupported = queryset.exclude(provider=DNSRecord.Provider.GODADDY)
171
+ for record in unsupported:
172
+ self.message_user(
173
+ request,
174
+ f"{record} uses unsupported provider {record.get_provider_display()}",
175
+ messages.WARNING,
176
+ )
177
+ queryset = queryset.filter(provider=DNSRecord.Provider.GODADDY)
178
+ if not queryset:
179
+ self.message_user(request, "No GoDaddy records selected.", messages.WARNING)
180
+ return None
181
+
182
+ if "apply" in request.POST:
183
+ form = DeployDNSRecordsForm(request.POST)
184
+ if form.is_valid():
185
+ manager = form.cleaned_data["manager"]
186
+ result = manager.publish_dns_records(list(queryset))
187
+ for record, reason in result.skipped.items():
188
+ self.message_user(request, f"{record}: {reason}", messages.WARNING)
189
+ for record, reason in result.failures.items():
190
+ self.message_user(request, f"{record}: {reason}", messages.ERROR)
191
+ if result.deployed:
192
+ self.message_user(
193
+ request,
194
+ f"Deployed {len(result.deployed)} DNS record(s) via {manager}.",
195
+ messages.SUCCESS,
196
+ )
197
+ return None
198
+ else:
199
+ initial_manager = self._default_manager_for_queryset(queryset)
200
+ form = DeployDNSRecordsForm(initial={"manager": initial_manager})
201
+
202
+ context = {
203
+ **self.admin_site.each_context(request),
204
+ "opts": self.model._meta,
205
+ "form": form,
206
+ "queryset": queryset,
207
+ "title": "Deploy DNS records",
208
+ }
209
+ return render(
210
+ request,
211
+ "admin/nodes/dnsrecord/deploy_records.html",
212
+ context,
213
+ )
214
+
215
+ @admin.action(description="Validate Selected records")
216
+ def validate_selected_records(self, request, queryset):
217
+ resolver = dns_utils.create_resolver()
218
+ successes = 0
219
+ for record in queryset:
220
+ ok, message = dns_utils.validate_record(record, resolver=resolver)
221
+ if ok:
222
+ successes += 1
223
+ else:
224
+ self.message_user(request, f"{record}: {message}", messages.WARNING)
225
+ if successes:
226
+ self.message_user(
227
+ request,
228
+ f"Validated {successes} DNS record(s).",
229
+ messages.SUCCESS,
230
+ )
231
+
232
+
233
+ @admin.register(Node)
234
+ class NodeAdmin(EntityModelAdmin):
235
+ list_display = (
236
+ "hostname",
237
+ "primary_ip",
238
+ "port",
239
+ "role",
240
+ "relation",
241
+ "last_seen",
242
+ "visit_link",
243
+ )
244
+ search_fields = ("hostname", "network_hostname", "address", "mac_address")
245
+ change_list_template = "admin/nodes/node/change_list.html"
246
+ change_form_template = "admin/nodes/node/change_form.html"
247
+ form = NodeAdminForm
248
+ fieldsets = (
249
+ (
250
+ _("Network"),
251
+ {
252
+ "fields": (
253
+ "hostname",
254
+ "network_hostname",
255
+ "ipv4_address",
256
+ "ipv6_address",
257
+ "address",
258
+ "mac_address",
259
+ "port",
260
+ "message_queue_length",
261
+ "current_relation",
262
+ )
263
+ },
264
+ ),
265
+ (_("Role"), {"fields": ("role",)}),
266
+ (
267
+ _("Public endpoint"),
268
+ {
269
+ "fields": (
270
+ "public_endpoint",
271
+ "public_key",
272
+ )
273
+ },
274
+ ),
275
+ (
276
+ _("Installation"),
277
+ {
278
+ "fields": (
279
+ "base_path",
280
+ "installed_version",
281
+ "installed_revision",
282
+ )
283
+ },
284
+ ),
285
+ (
286
+ _("Public admin"),
287
+ {"fields": ("enable_public_api",)},
288
+ ),
289
+ )
290
+ actions = [
291
+ "update_selected_nodes",
292
+ "register_visitor",
293
+ "run_task",
294
+ "take_screenshots",
295
+ "start_charge_point_forwarding",
296
+ "stop_charge_point_forwarding",
297
+ "import_rfids_from_selected",
298
+ "export_rfids_to_selected",
299
+ "send_net_message",
300
+ ]
301
+ inlines = [NodeFeatureAssignmentInline]
302
+
303
+ class SendNetMessageForm(forms.Form):
304
+ subject = forms.CharField(
305
+ label=_("Subject"),
306
+ max_length=NetMessage._meta.get_field("subject").max_length,
307
+ required=False,
308
+ )
309
+ body = forms.CharField(
310
+ label=_("Body"),
311
+ max_length=NetMessage._meta.get_field("body").max_length,
312
+ required=False,
313
+ widget=forms.Textarea(attrs={"rows": 4}),
314
+ )
315
+
316
+ def clean(self):
317
+ cleaned = super().clean()
318
+ subject = (cleaned.get("subject") or "").strip()
319
+ body = (cleaned.get("body") or "").strip()
320
+ if not subject and not body:
321
+ raise forms.ValidationError(
322
+ _("Enter a subject or body to send.")
323
+ )
324
+ cleaned["subject"] = subject
325
+ cleaned["body"] = body
326
+ return cleaned
327
+
328
+ @admin.display(description=_("Relation"), ordering="current_relation")
329
+ def relation(self, obj):
330
+ return obj.get_current_relation_display()
331
+
332
+ @admin.display(description=_("IP Address"), ordering="address")
333
+ def primary_ip(self, obj):
334
+ if not obj:
335
+ return ""
336
+ return obj.get_best_ip() or ""
337
+
338
+ @admin.display(description=_("Visit"))
339
+ def visit_link(self, obj):
340
+ if not obj:
341
+ return ""
342
+ if obj.is_local:
343
+ try:
344
+ url = reverse("admin:index")
345
+ except NoReverseMatch:
346
+ return ""
347
+ return format_html(
348
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
349
+ url,
350
+ _("Visit"),
351
+ )
352
+
353
+ host_values = obj.get_remote_host_candidates()
354
+
355
+ remote_url = ""
356
+ for host in host_values:
357
+ temp_node = SimpleNamespace(
358
+ public_endpoint=host,
359
+ address="",
360
+ hostname="",
361
+ port=obj.port,
362
+ )
363
+ remote_url = next(self._iter_remote_urls(temp_node, "/admin/"), "")
364
+ if remote_url:
365
+ break
366
+
367
+ if not remote_url:
368
+ return ""
369
+
370
+ return format_html(
371
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
372
+ remote_url,
373
+ _("Visit"),
374
+ )
375
+
376
+ def get_urls(self):
377
+ urls = super().get_urls()
378
+ custom = [
379
+ path(
380
+ "register-current/",
381
+ self.admin_site.admin_view(self.register_current),
382
+ name="nodes_node_register_current",
383
+ ),
384
+ path(
385
+ "register-visitor/",
386
+ self.admin_site.admin_view(self.register_visitor_view),
387
+ name="nodes_node_register_visitor",
388
+ ),
389
+ path(
390
+ "<int:node_id>/public-key/",
391
+ self.admin_site.admin_view(self.public_key),
392
+ name="nodes_node_public_key",
393
+ ),
394
+ path(
395
+ "update-selected/progress/",
396
+ self.admin_site.admin_view(self.update_selected_progress),
397
+ name="nodes_node_update_selected_progress",
398
+ ),
399
+ ]
400
+ return custom + urls
401
+
402
+ def register_current(self, request):
403
+ """Create or update this host and offer browser node registration."""
404
+ if not request.user.is_superuser:
405
+ raise PermissionDenied
406
+ node, created = Node.register_current()
407
+ if created:
408
+ self.message_user(
409
+ request, f"Current host registered as {node}", messages.SUCCESS
410
+ )
411
+ token = uuid.uuid4().hex
412
+ context = {
413
+ "token": token,
414
+ "register_url": reverse("register-node"),
415
+ }
416
+ response = TemplateResponse(
417
+ request, "admin/nodes/node/register_remote.html", context
418
+ )
419
+ response.render()
420
+ template = response.resolve_template(response.template_name)
421
+ if getattr(template, "name", None) in (None, ""):
422
+ template.name = response.template_name
423
+ signals.template_rendered.send(
424
+ sender=template.__class__,
425
+ template=template,
426
+ context=response.context_data,
427
+ request=request,
428
+ )
429
+ return response
430
+
431
+ @admin.action(description="Register Visitor")
432
+ def register_visitor(self, request, queryset=None):
433
+ return self.register_visitor_view(request)
434
+
435
+ @admin.action(description=_("Update selected nodes"))
436
+ def update_selected_nodes(self, request, queryset):
437
+ node_ids = list(queryset.values_list("pk", flat=True))
438
+ if not node_ids:
439
+ self.message_user(request, _("No nodes selected."), messages.INFO)
440
+ return None
441
+ context = {
442
+ **self.admin_site.each_context(request),
443
+ "opts": self.model._meta,
444
+ "title": _("Update selected nodes"),
445
+ "nodes": list(queryset),
446
+ "node_ids": node_ids,
447
+ "progress_url": reverse("admin:nodes_node_update_selected_progress"),
448
+ }
449
+ return TemplateResponse(
450
+ request, "admin/nodes/node/update_selected.html", context
451
+ )
452
+
453
+ @admin.action(description=_("Send Net Message"))
454
+ def send_net_message(self, request, queryset):
455
+ is_submit = "apply" in request.POST
456
+ form = self.SendNetMessageForm(request.POST if is_submit else None)
457
+ selected_ids = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
458
+ if not selected_ids:
459
+ selected_ids = [str(pk) for pk in queryset.values_list("pk", flat=True)]
460
+ nodes: list[Node] = []
461
+ cleaned_ids: list[int] = []
462
+ for value in selected_ids:
463
+ try:
464
+ cleaned_ids.append(int(value))
465
+ except (TypeError, ValueError):
466
+ continue
467
+ if cleaned_ids:
468
+ base_queryset = self.get_queryset(request).filter(pk__in=cleaned_ids)
469
+ nodes_by_pk = {str(node.pk): node for node in base_queryset}
470
+ nodes = [nodes_by_pk[value] for value in selected_ids if value in nodes_by_pk]
471
+ if not nodes:
472
+ nodes = list(queryset)
473
+ selected_ids = [str(node.pk) for node in nodes]
474
+ if not nodes:
475
+ self.message_user(request, _("No nodes selected."), messages.INFO)
476
+ return None
477
+ if is_submit and form.is_valid():
478
+ subject = form.cleaned_data["subject"]
479
+ body = form.cleaned_data["body"]
480
+ created = 0
481
+ for node in nodes:
482
+ message = NetMessage.objects.create(
483
+ subject=subject,
484
+ body=body,
485
+ filter_node=node,
486
+ )
487
+ message.propagate()
488
+ created += 1
489
+ if created:
490
+ success_message = ngettext(
491
+ "Sent %(count)d net message.",
492
+ "Sent %(count)d net messages.",
493
+ created,
494
+ ) % {"count": created}
495
+ self.message_user(request, success_message, messages.SUCCESS)
496
+ else:
497
+ self.message_user(
498
+ request, _("No net messages were sent."), messages.INFO
499
+ )
500
+ return None
501
+ context = {
502
+ **self.admin_site.each_context(request),
503
+ "opts": self.model._meta,
504
+ "title": _("Send Net Message"),
505
+ "nodes": nodes,
506
+ "selected_ids": selected_ids,
507
+ "action_name": request.POST.get("action", "send_net_message"),
508
+ "select_across": request.POST.get("select_across", "0"),
509
+ "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
510
+ "adminform": helpers.AdminForm(
511
+ form,
512
+ [(None, {"fields": ("subject", "body")})],
513
+ {},
514
+ ),
515
+ "form": form,
516
+ "media": self.media + form.media,
517
+ }
518
+ return TemplateResponse(
519
+ request, "admin/nodes/node/send_net_message.html", context
520
+ )
521
+
522
+ def update_selected_progress(self, request):
523
+ if request.method != "POST":
524
+ return JsonResponse({"detail": "POST required"}, status=405)
525
+ if not self.has_change_permission(request):
526
+ raise PermissionDenied
527
+ try:
528
+ node_id = int(request.POST.get("node_id", ""))
529
+ except (TypeError, ValueError):
530
+ return JsonResponse({"detail": "Invalid node id"}, status=400)
531
+ node = self.get_queryset(request).filter(pk=node_id).first()
532
+ if not node:
533
+ return JsonResponse({"detail": "Node not found"}, status=404)
534
+
535
+ local_result = self._refresh_local_information(node)
536
+ remote_result = self._push_remote_information(node)
537
+
538
+ status = "success"
539
+ if not local_result.get("ok") and not remote_result.get("ok"):
540
+ status = "error"
541
+ elif not local_result.get("ok") or not remote_result.get("ok"):
542
+ status = "partial"
543
+
544
+ return JsonResponse(
545
+ {
546
+ "node": str(node),
547
+ "status": status,
548
+ "local": local_result,
549
+ "remote": remote_result,
550
+ }
551
+ )
552
+
553
+ def _refresh_local_information(self, node):
554
+ if node.is_local:
555
+ try:
556
+ _, created = Node.register_current()
557
+ except Exception as exc: # pragma: no cover - unexpected errors
558
+ return {"ok": False, "message": str(exc)}
559
+ return {
560
+ "ok": True,
561
+ "created": created,
562
+ "message": "Local node registration refreshed.",
563
+ }
564
+
565
+ last_error = ""
566
+ host_candidates = node.get_remote_host_candidates()
567
+ for url in self._iter_remote_urls(node, "/nodes/info/"):
568
+ try:
569
+ response = requests.get(url, timeout=5)
570
+ except RequestException as exc:
571
+ last_error = str(exc)
572
+ continue
573
+ if not response.ok:
574
+ last_error = f"{response.status_code} {response.reason}"
575
+ continue
576
+ try:
577
+ payload = response.json()
578
+ except ValueError:
579
+ last_error = "Invalid JSON response"
580
+ continue
581
+ updated = self._apply_remote_node_info(node, payload)
582
+ message = (
583
+ "Remote information applied."
584
+ if updated
585
+ else "Remote information fetched (no changes)."
586
+ )
587
+ return {
588
+ "ok": True,
589
+ "url": url,
590
+ "updated_fields": updated,
591
+ "message": message,
592
+ }
593
+ return {
594
+ "ok": False,
595
+ "message": self._build_connectivity_hint(last_error, host_candidates),
596
+ }
597
+
598
+ def _apply_remote_node_info(self, node, payload):
599
+ changed = []
600
+ field_map = {
601
+ "hostname": payload.get("hostname"),
602
+ "network_hostname": payload.get("network_hostname"),
603
+ "address": payload.get("address"),
604
+ "ipv4_address": payload.get("ipv4_address"),
605
+ "ipv6_address": payload.get("ipv6_address"),
606
+ "public_key": payload.get("public_key"),
607
+ }
608
+ port_value = payload.get("port")
609
+ if port_value is not None:
610
+ try:
611
+ port_value = int(port_value)
612
+ except (TypeError, ValueError):
613
+ port_value = None
614
+ field_map["port"] = port_value
615
+ mac_address = payload.get("mac_address")
616
+ if mac_address:
617
+ field_map["mac_address"] = str(mac_address).lower()
618
+
619
+ for field, value in field_map.items():
620
+ if value is None:
621
+ continue
622
+ if getattr(node, field) != value:
623
+ setattr(node, field, value)
624
+ changed.append(field)
625
+
626
+ role_value = payload.get("role") or payload.get("role_name")
627
+ if role_value is not None:
628
+ role_name = str(role_value).strip()
629
+ if role_name:
630
+ desired_role = NodeRole.objects.filter(name=role_name).first()
631
+ else:
632
+ desired_role = None
633
+ if desired_role and node.role_id != desired_role.id:
634
+ node.role = desired_role
635
+ changed.append("role")
636
+
637
+ node.last_seen = timezone.now()
638
+ if "last_seen" not in changed:
639
+ changed.append("last_seen")
640
+ node.save(update_fields=changed)
641
+ return changed
642
+
643
+ def _push_remote_information(self, node):
644
+ if node.is_local:
645
+ return {
646
+ "ok": True,
647
+ "message": "Local node does not require remote update.",
648
+ }
649
+
650
+ local_node = Node.get_local()
651
+ if local_node is None:
652
+ try:
653
+ local_node, _ = Node.register_current()
654
+ except Exception as exc: # pragma: no cover - unexpected errors
655
+ return {"ok": False, "message": str(exc)}
656
+
657
+ security_dir = Path(local_node.base_path or settings.BASE_DIR) / "security"
658
+ priv_path = security_dir / f"{local_node.public_endpoint}"
659
+ if not priv_path.exists():
660
+ return {
661
+ "ok": False,
662
+ "message": "Local node private key not found.",
663
+ }
664
+ try:
665
+ private_key = serialization.load_pem_private_key(
666
+ priv_path.read_bytes(), password=None
667
+ )
668
+ except Exception as exc: # pragma: no cover - unexpected errors
669
+ return {"ok": False, "message": f"Failed to load private key: {exc}"}
670
+
671
+ token = uuid.uuid4().hex
672
+ try:
673
+ signature = private_key.sign(
674
+ token.encode(),
675
+ padding.PKCS1v15(),
676
+ hashes.SHA256(),
677
+ )
678
+ except Exception as exc: # pragma: no cover - unexpected errors
679
+ return {"ok": False, "message": f"Failed to sign payload: {exc}"}
680
+
681
+ payload = {
682
+ "hostname": local_node.hostname,
683
+ "network_hostname": local_node.network_hostname,
684
+ "address": local_node.address,
685
+ "ipv4_address": local_node.ipv4_address,
686
+ "ipv6_address": local_node.ipv6_address,
687
+ "port": local_node.port,
688
+ "mac_address": local_node.mac_address,
689
+ "public_key": local_node.public_key,
690
+ "token": token,
691
+ "signature": base64.b64encode(signature).decode(),
692
+ }
693
+ if local_node.installed_version:
694
+ payload["installed_version"] = local_node.installed_version
695
+ if local_node.installed_revision:
696
+ payload["installed_revision"] = local_node.installed_revision
697
+
698
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
699
+ headers = {"Content-Type": "application/json"}
700
+
701
+ last_error = ""
702
+ host_candidates = node.get_remote_host_candidates()
703
+ for url in self._iter_remote_urls(node, "/nodes/register/"):
704
+ try:
705
+ response = requests.post(
706
+ url,
707
+ data=payload_json,
708
+ headers=headers,
709
+ timeout=5,
710
+ )
711
+ except RequestException as exc:
712
+ last_error = str(exc)
713
+ continue
714
+ if response.ok:
715
+ return {"ok": True, "url": url, "message": "Remote updated."}
716
+ last_error = f"{response.status_code} {response.text}"
717
+ return {
718
+ "ok": False,
719
+ "message": self._build_connectivity_hint(last_error, host_candidates),
720
+ }
721
+
722
+ def _build_connectivity_hint(self, last_error: str, hosts: list[str]) -> str:
723
+ base_message = last_error or _("Unable to reach remote node.")
724
+ if hosts:
725
+ host_text = ", ".join(hosts)
726
+ return _("%(message)s Tried hosts: %(hosts)s.") % {
727
+ "message": base_message,
728
+ "hosts": host_text,
729
+ }
730
+ return _("%(message)s No remote hosts were available for contact.") % {
731
+ "message": base_message
732
+ }
733
+
734
+ def _primary_remote_url(self, node, path: str) -> str:
735
+ return next(self._iter_remote_urls(node, path), "")
736
+
737
+ def _request_remote(self, node, path: str, request_callable):
738
+ errors: list[str] = []
739
+ for url in self._iter_remote_urls(node, path):
740
+ try:
741
+ response = request_callable(url)
742
+ except RequestException as exc:
743
+ errors.append(f"{url}: {exc}")
744
+ continue
745
+ return url, response, errors
746
+ return "", None, errors
747
+
748
+ def _iter_remote_urls(self, node, path):
749
+ if hasattr(node, "iter_remote_urls"):
750
+ yield from node.iter_remote_urls(path)
751
+ return
752
+
753
+ temp = Node(
754
+ public_endpoint=getattr(node, "public_endpoint", ""),
755
+ address=getattr(node, "address", ""),
756
+ hostname=getattr(node, "hostname", ""),
757
+ port=getattr(node, "port", None),
758
+ )
759
+ temp.network_hostname = getattr(node, "network_hostname", "")
760
+ temp.ipv4_address = getattr(node, "ipv4_address", "")
761
+ temp.ipv6_address = getattr(node, "ipv6_address", "")
762
+ yield from temp.iter_remote_urls(path)
763
+
764
+ def register_visitor_view(self, request):
765
+ """Exchange registration data with the visiting node."""
766
+
767
+ node, created = Node.register_current()
768
+ if created:
769
+ self.message_user(
770
+ request, f"Current host registered as {node}", messages.SUCCESS
771
+ )
772
+
773
+ token = uuid.uuid4().hex
774
+ context = {
775
+ **self.admin_site.each_context(request),
776
+ "opts": self.model._meta,
777
+ "title": _("Register Visitor"),
778
+ "token": token,
779
+ "info_url": reverse("node-info"),
780
+ "register_url": reverse("register-node"),
781
+ "visitor_info_url": "http://localhost:8000/nodes/info/",
782
+ "visitor_register_url": "http://localhost:8000/nodes/register/",
783
+ }
784
+ return render(request, "admin/nodes/node/register_visitor.html", context)
785
+
786
+ def public_key(self, request, node_id):
787
+ node = self.get_object(request, node_id)
788
+ if not node:
789
+ self.message_user(request, "Unknown node", messages.ERROR)
790
+ return redirect("..")
791
+ security_dir = Path(settings.BASE_DIR) / "security"
792
+ pub_path = security_dir / f"{node.public_endpoint}.pub"
793
+ if pub_path.exists():
794
+ response = HttpResponse(pub_path.read_bytes(), content_type="text/plain")
795
+ response["Content-Disposition"] = f'attachment; filename="{pub_path.name}"'
796
+ return response
797
+ self.message_user(request, "Public key not found", messages.ERROR)
798
+ return redirect("..")
799
+
800
+ def run_task(self, request, queryset):
801
+ if "apply" in request.POST:
802
+ recipe_text = request.POST.get("recipe", "")
803
+ results = []
804
+ for node in queryset:
805
+ try:
806
+ if not node.is_local:
807
+ raise NotImplementedError(
808
+ "Remote node execution is not implemented"
809
+ )
810
+ command = ["/bin/sh", "-c", recipe_text]
811
+ result = subprocess.run(
812
+ command,
813
+ check=False,
814
+ capture_output=True,
815
+ text=True,
816
+ )
817
+ output = result.stdout + result.stderr
818
+ except Exception as exc:
819
+ output = str(exc)
820
+ results.append((node, output))
821
+ context = {"recipe": recipe_text, "results": results}
822
+ return render(request, "admin/nodes/task_result.html", context)
823
+ context = {"nodes": queryset}
824
+ return render(request, "admin/nodes/node/run_task.html", context)
825
+
826
+ run_task.short_description = "Run task"
827
+
828
+ @admin.action(description="Take Screenshots")
829
+ def take_screenshots(self, request, queryset):
830
+ tx = uuid.uuid4()
831
+ sources = getattr(settings, "SCREENSHOT_SOURCES", ["/"])
832
+ count = 0
833
+ for node in queryset:
834
+ for source in sources:
835
+ try:
836
+ contact_host = node.get_primary_contact()
837
+ url = source.format(
838
+ node=node, address=contact_host, port=node.port
839
+ )
840
+ except Exception:
841
+ url = source
842
+ if not url.startswith("http"):
843
+ candidate = next(
844
+ self._iter_remote_urls(node, url),
845
+ "",
846
+ )
847
+ if not candidate:
848
+ self.message_user(
849
+ request,
850
+ _(
851
+ "No reachable host was available for %(node)s while generating %(path)s"
852
+ )
853
+ % {"node": node, "path": url},
854
+ messages.WARNING,
855
+ )
856
+ continue
857
+ url = candidate
858
+ try:
859
+ path = capture_screenshot(url)
860
+ except Exception as exc: # pragma: no cover - selenium issues
861
+ self.message_user(request, f"{node}: {exc}", messages.ERROR)
862
+ continue
863
+ sample = save_screenshot(
864
+ path, node=node, method="ADMIN", transaction_uuid=tx
865
+ )
866
+ if sample:
867
+ count += 1
868
+ self.message_user(request, f"{count} screenshots captured", messages.SUCCESS)
869
+
870
+ def _init_rfid_result(self, node):
871
+ return {
872
+ "node": node,
873
+ "status": "success",
874
+ "created": 0,
875
+ "updated": 0,
876
+ "linked_accounts": 0,
877
+ "missing_accounts": [],
878
+ "errors": [],
879
+ "processed": 0,
880
+ "message": None,
881
+ }
882
+
883
+ def _skip_result(self, node, message):
884
+ result = self._init_rfid_result(node)
885
+ result["status"] = "skipped"
886
+ result["message"] = message
887
+ return result
888
+
889
+ def _load_local_node_credentials(self):
890
+ local_node = Node.get_local()
891
+ if not local_node:
892
+ return None, None, _("Local node is not registered.")
893
+
894
+ endpoint = (local_node.public_endpoint or "").strip()
895
+ if not endpoint:
896
+ return local_node, None, _(
897
+ "Local node public endpoint is not configured."
898
+ )
899
+
900
+ security_dir = Path(local_node.base_path or settings.BASE_DIR) / "security"
901
+ priv_path = security_dir / endpoint
902
+ if not priv_path.exists():
903
+ return local_node, None, _("Local node private key not found.")
904
+
905
+ try:
906
+ private_key = serialization.load_pem_private_key(
907
+ priv_path.read_bytes(), password=None
908
+ )
909
+ except Exception as exc: # pragma: no cover - unexpected key errors
910
+ return local_node, None, _("Failed to load private key: %(error)s") % {
911
+ "error": exc
912
+ }
913
+
914
+ return local_node, private_key, None
915
+
916
+ def _sign_payload(self, private_key, payload: str) -> str:
917
+ return base64.b64encode(
918
+ private_key.sign(
919
+ payload.encode(),
920
+ padding.PKCS1v15(),
921
+ hashes.SHA256(),
922
+ )
923
+ ).decode()
924
+
925
+ def _dedupe(self, values):
926
+ if not values:
927
+ return []
928
+ return list(OrderedDict.fromkeys(values))
929
+
930
+ def _status_from_result(self, result):
931
+ if result["errors"]:
932
+ return "error"
933
+ if result["missing_accounts"]:
934
+ return "partial"
935
+ return result.get("status") or "success"
936
+
937
+ def _summarize_rfid_results(self, results):
938
+ return {
939
+ "total": len(results),
940
+ "processed": sum(1 for item in results if item["status"] != "skipped"),
941
+ "success": sum(1 for item in results if item["status"] == "success"),
942
+ "partial": sum(1 for item in results if item["status"] == "partial"),
943
+ "error": sum(1 for item in results if item["status"] == "error"),
944
+ "created": sum(item["created"] for item in results),
945
+ "updated": sum(item["updated"] for item in results),
946
+ "linked_accounts": sum(item["linked_accounts"] for item in results),
947
+ "missing_accounts": sum(
948
+ len(item["missing_accounts"]) for item in results
949
+ ),
950
+ }
951
+
952
+ def _render_rfid_sync(self, request, operation, results, setup_error=None):
953
+ titles = {
954
+ "import": _("Import RFID results"),
955
+ "fetch": _("Fetch RFID results"),
956
+ "export": _("Export RFID results"),
957
+ }
958
+ summary = self._summarize_rfid_results(results)
959
+ context = {
960
+ **self.admin_site.each_context(request),
961
+ "opts": self.model._meta,
962
+ "title": titles.get(operation, _("RFID results")),
963
+ "operation": operation,
964
+ "results": results,
965
+ "summary": summary,
966
+ "setup_error": setup_error,
967
+ "back_url": reverse("admin:nodes_node_changelist"),
968
+ }
969
+ return TemplateResponse(
970
+ request,
971
+ "admin/nodes/node/rfid_sync_results.html",
972
+ context,
973
+ )
974
+
975
+ def _process_import_from_node(self, node, payload, headers):
976
+ result = self._init_rfid_result(node)
977
+ _, response, attempt_errors = self._request_remote(
978
+ node,
979
+ "/nodes/rfid/export/",
980
+ lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
981
+ )
982
+ if response is None:
983
+ result["status"] = "error"
984
+ if attempt_errors:
985
+ result["errors"].extend(attempt_errors)
986
+ else:
987
+ result["errors"].append(
988
+ _("No remote hosts were available for %(node)s.") % {"node": node}
989
+ )
990
+ return result
991
+
992
+ if response.status_code != 200:
993
+ result["status"] = "error"
994
+ result["errors"].append(f"{response.status_code} {response.text}")
995
+ return result
996
+
997
+ try:
998
+ data = response.json()
999
+ except ValueError:
1000
+ result["status"] = "error"
1001
+ result["errors"].append(_("Invalid JSON response"))
1002
+ return result
1003
+
1004
+ rfids = data.get("rfids", []) or []
1005
+ result["processed"] = len(rfids)
1006
+ for entry in rfids:
1007
+ if not isinstance(entry, Mapping):
1008
+ result["errors"].append(_( "Invalid RFID payload" ))
1009
+ continue
1010
+ outcome = apply_rfid_payload(entry, origin_node=node)
1011
+ if not outcome.ok:
1012
+ result["errors"].append(
1013
+ outcome.error or _("RFID could not be imported")
1014
+ )
1015
+ continue
1016
+ if outcome.created:
1017
+ result["created"] += 1
1018
+ else:
1019
+ result["updated"] += 1
1020
+ result["linked_accounts"] += outcome.accounts_linked
1021
+ result["missing_accounts"].extend(outcome.missing_accounts)
1022
+
1023
+ result["missing_accounts"] = self._dedupe(result["missing_accounts"])
1024
+ result["status"] = self._status_from_result(result)
1025
+ return result
1026
+
1027
+ def _post_export_to_node(self, node, payload, headers):
1028
+ result = self._init_rfid_result(node)
1029
+ _, response, attempt_errors = self._request_remote(
1030
+ node,
1031
+ "/nodes/rfid/import/",
1032
+ lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
1033
+ )
1034
+ if response is None:
1035
+ result["status"] = "error"
1036
+ if attempt_errors:
1037
+ result["errors"].extend(attempt_errors)
1038
+ else:
1039
+ result["errors"].append(
1040
+ _("No remote hosts were available for %(node)s.") % {"node": node}
1041
+ )
1042
+ return result
1043
+
1044
+ if response.status_code != 200:
1045
+ result["status"] = "error"
1046
+ result["errors"].append(f"{response.status_code} {response.text}")
1047
+ return result
1048
+
1049
+ try:
1050
+ data = response.json()
1051
+ except ValueError:
1052
+ result["status"] = "error"
1053
+ result["errors"].append(_("Invalid JSON response"))
1054
+ return result
1055
+
1056
+ result["processed"] = data.get("processed", 0) or 0
1057
+ result["created"] = data.get("created", 0) or 0
1058
+ result["updated"] = data.get("updated", 0) or 0
1059
+ result["linked_accounts"] = data.get("accounts_linked", 0) or 0
1060
+
1061
+ missing = data.get("missing_accounts") or []
1062
+ if isinstance(missing, list):
1063
+ result["missing_accounts"].extend(str(value) for value in missing if value)
1064
+ elif missing:
1065
+ result["missing_accounts"].append(str(missing))
1066
+
1067
+ errors = data.get("errors", 0)
1068
+ if isinstance(errors, int) and errors:
1069
+ result["errors"].append(
1070
+ _("Remote reported %(count)s error(s).") % {"count": errors}
1071
+ )
1072
+ elif isinstance(errors, list):
1073
+ result["errors"].extend(str(err) for err in errors if err)
1074
+
1075
+ result["missing_accounts"] = self._dedupe(result["missing_accounts"])
1076
+ result["status"] = self._status_from_result(result)
1077
+ return result
1078
+
1079
+ def _run_rfid_import(self, request, queryset):
1080
+ nodes = list(queryset)
1081
+ local_node, private_key, error = self._load_local_node_credentials()
1082
+ if error:
1083
+ results = [self._skip_result(node, error) for node in nodes]
1084
+ return self._render_rfid_sync(
1085
+ request, "import", results, setup_error=error
1086
+ )
1087
+
1088
+ if not nodes:
1089
+ return self._render_rfid_sync(
1090
+ request,
1091
+ "import",
1092
+ [],
1093
+ setup_error=_("No nodes selected."),
1094
+ )
1095
+
1096
+ payload = json.dumps(
1097
+ {"requester": str(local_node.uuid)},
1098
+ separators=(",", ":"),
1099
+ sort_keys=True,
1100
+ )
1101
+ signature = self._sign_payload(private_key, payload)
1102
+ headers = {
1103
+ "Content-Type": "application/json",
1104
+ "X-Signature": signature,
1105
+ }
1106
+
1107
+ results = []
1108
+ for node in nodes:
1109
+ if local_node.pk and node.pk == local_node.pk:
1110
+ results.append(self._skip_result(node, _("Skipped local node.")))
1111
+ continue
1112
+ results.append(self._process_import_from_node(node, payload, headers))
1113
+
1114
+ return self._render_rfid_sync(request, "import", results)
1115
+
1116
+ @admin.action(description=_("Import RFIDs from selected"))
1117
+ def import_rfids_from_selected(self, request, queryset):
1118
+ return self._run_rfid_import(request, queryset)
1119
+
1120
+ @admin.action(description=_("Export RFIDs to selected"))
1121
+ def export_rfids_to_selected(self, request, queryset):
1122
+ nodes = list(queryset)
1123
+ local_node, private_key, error = self._load_local_node_credentials()
1124
+ if error:
1125
+ results = [self._skip_result(node, error) for node in nodes]
1126
+ return self._render_rfid_sync(request, "export", results, setup_error=error)
1127
+
1128
+ if not nodes:
1129
+ return self._render_rfid_sync(
1130
+ request,
1131
+ "export",
1132
+ [],
1133
+ setup_error=_("No nodes selected."),
1134
+ )
1135
+
1136
+ rfids = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
1137
+ payload = json.dumps(
1138
+ {"requester": str(local_node.uuid), "rfids": rfids},
1139
+ separators=(",", ":"),
1140
+ sort_keys=True,
1141
+ )
1142
+ signature = self._sign_payload(private_key, payload)
1143
+ headers = {
1144
+ "Content-Type": "application/json",
1145
+ "X-Signature": signature,
1146
+ }
1147
+
1148
+ results = []
1149
+ for node in nodes:
1150
+ if local_node.pk and node.pk == local_node.pk:
1151
+ results.append(self._skip_result(node, _("Skipped local node.")))
1152
+ continue
1153
+ results.append(self._post_export_to_node(node, payload, headers))
1154
+
1155
+ return self._render_rfid_sync(request, "export", results)
1156
+
1157
+ async def _probe_websocket(self, url: str) -> bool:
1158
+ try:
1159
+ async with websockets.connect(url, open_timeout=3, close_timeout=1):
1160
+ return True
1161
+ except Exception:
1162
+ return False
1163
+
1164
+ def _attempt_forwarding_probe(self, node, charger_id: str) -> bool:
1165
+ if not charger_id:
1166
+ return False
1167
+ safe_id = quote(str(charger_id))
1168
+ candidates: list[str] = []
1169
+ for base in node.iter_remote_urls("/"):
1170
+ parsed = urlsplit(base)
1171
+ if parsed.scheme not in {"http", "https"}:
1172
+ continue
1173
+ scheme = "wss" if parsed.scheme == "https" else "ws"
1174
+ base_path = parsed.path.rstrip("/")
1175
+ for prefix in ("", "/ws"):
1176
+ path = f"{base_path}{prefix}/{safe_id}".replace("//", "/")
1177
+ if not path.startswith("/"):
1178
+ path = f"/{path}"
1179
+ candidates.append(urlunsplit((scheme, parsed.netloc, path, "", "")))
1180
+
1181
+ for url in candidates:
1182
+ loop = asyncio.new_event_loop()
1183
+ try:
1184
+ result = loop.run_until_complete(self._probe_websocket(url))
1185
+ except Exception:
1186
+ result = False
1187
+ finally:
1188
+ loop.close()
1189
+ if result:
1190
+ return True
1191
+ return False
1192
+
1193
+ def _send_forwarding_metadata(
1194
+ self,
1195
+ request,
1196
+ node: Node,
1197
+ chargers: list[Charger],
1198
+ local_node: Node,
1199
+ private_key,
1200
+ ) -> bool:
1201
+ if not chargers:
1202
+ return True
1203
+ payload = {
1204
+ "requester": str(local_node.uuid),
1205
+ "requester_mac": local_node.mac_address,
1206
+ "requester_public_key": local_node.public_key,
1207
+ "chargers": [serialize_charger_for_network(charger) for charger in chargers],
1208
+ "transactions": {"chargers": [], "transactions": []},
1209
+ }
1210
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1211
+ signature = self._sign_payload(private_key, payload_json)
1212
+ headers = {"Content-Type": "application/json"}
1213
+ if signature:
1214
+ headers["X-Signature"] = signature
1215
+
1216
+ errors: list[str] = []
1217
+ for url in node.iter_remote_urls("/nodes/network/chargers/forward/"):
1218
+ if not url:
1219
+ continue
1220
+ try:
1221
+ response = requests.post(
1222
+ url, data=payload_json, headers=headers, timeout=5
1223
+ )
1224
+ except RequestException as exc:
1225
+ errors.append(
1226
+ _(
1227
+ "Failed to send forwarding metadata to %(node)s via %(url)s (%(error)s)."
1228
+ )
1229
+ % {"node": node, "url": url, "error": exc}
1230
+ )
1231
+ continue
1232
+
1233
+ try:
1234
+ data = response.json()
1235
+ except ValueError:
1236
+ data = {}
1237
+
1238
+ if response.ok and isinstance(data, Mapping) and data.get("status") == "ok":
1239
+ return True
1240
+
1241
+ detail = ""
1242
+ if isinstance(data, Mapping):
1243
+ detail = data.get("detail") or ""
1244
+ errors.append(
1245
+ _("Forwarding metadata to %(node)s via %(url)s failed: %(status)s %(detail)s")
1246
+ % {
1247
+ "node": node,
1248
+ "url": url,
1249
+ "status": response.status_code,
1250
+ "detail": detail,
1251
+ }
1252
+ )
1253
+
1254
+ if not errors:
1255
+ self.message_user(
1256
+ request,
1257
+ _("No reachable host found for %(node)s.") % {"node": node},
1258
+ level=messages.WARNING,
1259
+ )
1260
+ else:
1261
+ self.message_user(request, errors[-1].strip(), level=messages.WARNING)
1262
+ return False
1263
+
1264
+ @admin.action(description=_("Start Charge Point Forwarding"))
1265
+ def start_charge_point_forwarding(self, request, queryset):
1266
+ if queryset.count() != 1:
1267
+ self.message_user(
1268
+ request,
1269
+ _("Select a single remote node."),
1270
+ level=messages.ERROR,
1271
+ )
1272
+ return
1273
+
1274
+ target = queryset.first()
1275
+ local_node, private_key, error = self._load_local_node_credentials()
1276
+ if error:
1277
+ self.message_user(request, error, level=messages.ERROR)
1278
+ return
1279
+
1280
+ if local_node.pk and target.pk == local_node.pk:
1281
+ self.message_user(
1282
+ request,
1283
+ _("Cannot forward charge points to the local node."),
1284
+ level=messages.ERROR,
1285
+ )
1286
+ return
1287
+
1288
+ eligible = Charger.objects.filter(export_transactions=True)
1289
+ if local_node.pk:
1290
+ eligible = eligible.filter(
1291
+ Q(node_origin=local_node) | Q(node_origin__isnull=True)
1292
+ )
1293
+
1294
+ chargers = list(eligible.select_related("forwarded_to"))
1295
+ if not chargers:
1296
+ self.message_user(
1297
+ request,
1298
+ _("No eligible charge points available for forwarding."),
1299
+ level=messages.WARNING,
1300
+ )
1301
+ return
1302
+
1303
+ conflicts = [
1304
+ charger
1305
+ for charger in chargers
1306
+ if charger.forwarded_to_id
1307
+ and charger.forwarded_to_id not in {None, target.pk}
1308
+ ]
1309
+ if conflicts:
1310
+ self.message_user(
1311
+ request,
1312
+ ngettext(
1313
+ "Skipped %(count)s charge point already forwarded to another node.",
1314
+ "Skipped %(count)s charge points already forwarded to another node.",
1315
+ len(conflicts),
1316
+ )
1317
+ % {"count": len(conflicts)},
1318
+ level=messages.WARNING,
1319
+ )
1320
+
1321
+ chargers_to_update = [
1322
+ charger
1323
+ for charger in chargers
1324
+ if charger.forwarded_to_id in (None, target.pk)
1325
+ ]
1326
+ if not chargers_to_update:
1327
+ self.message_user(
1328
+ request,
1329
+ _("No charge points were updated."),
1330
+ level=messages.WARNING,
1331
+ )
1332
+ return
1333
+
1334
+ charger_pks = [c.pk for c in chargers_to_update]
1335
+ Charger.objects.filter(pk__in=charger_pks).update(forwarded_to=target)
1336
+
1337
+ for charger in chargers_to_update:
1338
+ charger.forwarded_to = target
1339
+
1340
+ sample = next((charger for charger in chargers_to_update if charger.charger_id), None)
1341
+ if sample and not self._attempt_forwarding_probe(target, sample.charger_id):
1342
+ self.message_user(
1343
+ request,
1344
+ _(
1345
+ "Unable to establish a websocket connection to %(node)s for charge point %(charger)s."
1346
+ )
1347
+ % {"node": target, "charger": sample.charger_id},
1348
+ level=messages.WARNING,
1349
+ )
1350
+
1351
+ success = self._send_forwarding_metadata(
1352
+ request, target, chargers_to_update, local_node, private_key
1353
+ )
1354
+
1355
+ if success:
1356
+ now = timezone.now()
1357
+ Charger.objects.filter(pk__in=charger_pks).update(
1358
+ forwarding_watermark=now
1359
+ )
1360
+ self.message_user(
1361
+ request,
1362
+ ngettext(
1363
+ "Forwarding enabled for %(count)s charge point.",
1364
+ "Forwarding enabled for %(count)s charge points.",
1365
+ len(chargers_to_update),
1366
+ )
1367
+ % {"count": len(chargers_to_update)},
1368
+ level=messages.SUCCESS,
1369
+ )
1370
+ else:
1371
+ self.message_user(
1372
+ request,
1373
+ ngettext(
1374
+ "Marked %(count)s charge point for forwarding; awaiting remote acknowledgment.",
1375
+ "Marked %(count)s charge points for forwarding; awaiting remote acknowledgment.",
1376
+ len(chargers_to_update),
1377
+ )
1378
+ % {"count": len(chargers_to_update)},
1379
+ level=messages.INFO,
1380
+ )
1381
+
1382
+ try:
1383
+ push_forwarded_charge_points.delay()
1384
+ except Exception:
1385
+ pass
1386
+
1387
+ @admin.action(description=_("Stop Charge Point Forwarding"))
1388
+ def stop_charge_point_forwarding(self, request, queryset):
1389
+ node_ids = [node.pk for node in queryset if node.pk]
1390
+ if not node_ids:
1391
+ self.message_user(
1392
+ request,
1393
+ _("No remote nodes selected."),
1394
+ level=messages.WARNING,
1395
+ )
1396
+ return
1397
+
1398
+ cleared = Charger.objects.filter(forwarded_to_id__in=node_ids).update(
1399
+ forwarded_to=None, forwarding_watermark=None
1400
+ )
1401
+
1402
+ if cleared:
1403
+ self.message_user(
1404
+ request,
1405
+ ngettext(
1406
+ "Stopped forwarding for %(count)s charge point.",
1407
+ "Stopped forwarding for %(count)s charge points.",
1408
+ cleared,
1409
+ )
1410
+ % {"count": cleared},
1411
+ level=messages.SUCCESS,
1412
+ )
1413
+ else:
1414
+ self.message_user(
1415
+ request,
1416
+ _("No forwarded charge points were updated."),
1417
+ level=messages.WARNING,
1418
+ )
1419
+
1420
+ def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
1421
+ extra_context = extra_context or {}
1422
+ if object_id:
1423
+ extra_context["public_key_url"] = reverse(
1424
+ "admin:nodes_node_public_key", args=[object_id]
1425
+ )
1426
+ return super().changeform_view(
1427
+ request, object_id, form_url, extra_context=extra_context
1428
+ )
1429
+
1430
+
1431
+ @admin.register(EmailOutbox)
1432
+ class EmailOutboxAdmin(EntityModelAdmin):
1433
+ form = EmailOutboxAdminForm
1434
+ list_display = (
1435
+ "owner_label",
1436
+ "host",
1437
+ "port",
1438
+ "username",
1439
+ "use_tls",
1440
+ "use_ssl",
1441
+ "is_enabled",
1442
+ )
1443
+ change_form_template = "admin/nodes/emailoutbox/change_form.html"
1444
+ fieldsets = (
1445
+ ("Owner", {"fields": ("user", "group")}),
1446
+ ("Credentials", {"fields": ("username", "password")}),
1447
+ (
1448
+ "Configuration",
1449
+ {
1450
+ "fields": (
1451
+ "node",
1452
+ "host",
1453
+ "port",
1454
+ "use_tls",
1455
+ "use_ssl",
1456
+ "from_email",
1457
+ "is_enabled",
1458
+ )
1459
+ },
1460
+ ),
1461
+ )
1462
+
1463
+ @admin.display(description="Owner")
1464
+ def owner_label(self, obj):
1465
+ return obj.owner_display()
1466
+
1467
+ def get_urls(self):
1468
+ urls = super().get_urls()
1469
+ custom = [
1470
+ path(
1471
+ "<path:object_id>/test/",
1472
+ self.admin_site.admin_view(self.test_outbox),
1473
+ name="nodes_emailoutbox_test",
1474
+ )
1475
+ ]
1476
+ return custom + urls
1477
+
1478
+ def test_outbox(self, request, object_id):
1479
+ outbox = self.get_object(request, object_id)
1480
+ if not outbox:
1481
+ self.message_user(request, "Unknown outbox", messages.ERROR)
1482
+ return redirect("..")
1483
+ recipient = request.user.email or outbox.username
1484
+ try:
1485
+ outbox.send_mail(
1486
+ "Test email",
1487
+ "This is a test email.",
1488
+ [recipient],
1489
+ )
1490
+ self.message_user(request, "Test email sent", messages.SUCCESS)
1491
+ except Exception as exc: # pragma: no cover - admin feedback
1492
+ self.message_user(request, str(exc), messages.ERROR)
1493
+ return redirect("..")
1494
+
1495
+ def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
1496
+ extra_context = extra_context or {}
1497
+ if object_id:
1498
+ extra_context["test_url"] = reverse(
1499
+ "admin:nodes_emailoutbox_test", args=[object_id]
1500
+ )
1501
+ return super().changeform_view(request, object_id, form_url, extra_context)
1502
+
1503
+
1504
+ class NodeRoleAdminForm(forms.ModelForm):
1505
+ nodes = forms.ModelMultipleChoiceField(
1506
+ queryset=Node.objects.all(),
1507
+ required=False,
1508
+ widget=FilteredSelectMultiple("Nodes", False),
1509
+ )
1510
+
1511
+ class Meta:
1512
+ model = NodeRole
1513
+ fields = ("name", "description", "nodes")
1514
+
1515
+ def __init__(self, *args, **kwargs):
1516
+ super().__init__(*args, **kwargs)
1517
+ if self.instance.pk:
1518
+ self.fields["nodes"].initial = self.instance.node_set.all()
1519
+
1520
+
1521
+ @admin.register(NodeRole)
1522
+ class NodeRoleAdmin(EntityModelAdmin):
1523
+ form = NodeRoleAdminForm
1524
+ list_display = ("name", "description", "registered", "default_features")
1525
+
1526
+ def get_queryset(self, request):
1527
+ qs = super().get_queryset(request)
1528
+ return qs.annotate(_registered=Count("node", distinct=True)).prefetch_related(
1529
+ "features"
1530
+ )
1531
+
1532
+ @admin.display(description="Registered", ordering="_registered")
1533
+ def registered(self, obj):
1534
+ return getattr(obj, "_registered", obj.node_set.count())
1535
+
1536
+ @admin.display(description="Default Features")
1537
+ def default_features(self, obj):
1538
+ features = [feature.display for feature in obj.features.all()]
1539
+ return ", ".join(features) if features else "—"
1540
+
1541
+ def save_model(self, request, obj, form, change):
1542
+ obj.node_set.set(form.cleaned_data.get("nodes", []))
1543
+
1544
+
1545
+ @admin.register(NodeFeature)
1546
+ class NodeFeatureAdmin(EntityModelAdmin):
1547
+ filter_horizontal = ("roles",)
1548
+ list_display = (
1549
+ "display",
1550
+ "slug",
1551
+ "default_roles",
1552
+ "is_enabled_display",
1553
+ "available_actions",
1554
+ )
1555
+ actions = ["check_features_for_eligibility", "enable_selected_features"]
1556
+ readonly_fields = ("is_enabled",)
1557
+ search_fields = ("display", "slug")
1558
+
1559
+ def get_queryset(self, request):
1560
+ qs = super().get_queryset(request)
1561
+ return qs.prefetch_related("roles")
1562
+
1563
+ @admin.display(description="Default Roles")
1564
+ def default_roles(self, obj):
1565
+ roles = [role.name for role in obj.roles.all()]
1566
+ return ", ".join(roles) if roles else "—"
1567
+
1568
+ @admin.display(description="Is Enabled", boolean=True, ordering="is_enabled")
1569
+ def is_enabled_display(self, obj):
1570
+ return obj.is_enabled
1571
+
1572
+ @admin.display(description="Actions")
1573
+ def available_actions(self, obj):
1574
+ if not obj.is_enabled:
1575
+ return "—"
1576
+ actions = obj.get_default_actions()
1577
+ if not actions:
1578
+ return "—"
1579
+
1580
+ links = []
1581
+ for action in actions:
1582
+ try:
1583
+ url = reverse(action.url_name)
1584
+ except NoReverseMatch:
1585
+ links.append(action.label)
1586
+ else:
1587
+ links.append(format_html('<a href="{}">{}</a>', url, action.label))
1588
+
1589
+ if not links:
1590
+ return "—"
1591
+ return format_html_join(" | ", "{}", ((link,) for link in links))
1592
+
1593
+ def _manual_enablement_message(self, feature, node):
1594
+ if node is None:
1595
+ return (
1596
+ "Manual enablement is unavailable without a registered local node."
1597
+ )
1598
+ if feature.slug in Node.MANUAL_FEATURE_SLUGS:
1599
+ return "This feature can be enabled manually."
1600
+ return "This feature cannot be enabled manually."
1601
+
1602
+ @admin.action(description="Check features for eligibility")
1603
+ def check_features_for_eligibility(self, request, queryset):
1604
+ from .feature_checks import feature_checks
1605
+
1606
+ features = list(queryset)
1607
+ total = len(features)
1608
+ successes = 0
1609
+ node = Node.get_local()
1610
+ for feature in features:
1611
+ enablement_message = self._manual_enablement_message(feature, node)
1612
+ try:
1613
+ result = feature_checks.run(feature, node=node)
1614
+ except Exception as exc: # pragma: no cover - defensive
1615
+ self.message_user(
1616
+ request,
1617
+ f"{feature.display}: {exc} {enablement_message}",
1618
+ level=messages.ERROR,
1619
+ )
1620
+ continue
1621
+ if result is None:
1622
+ self.message_user(
1623
+ request,
1624
+ f"No check is configured for {feature.display}. {enablement_message}",
1625
+ level=messages.WARNING,
1626
+ )
1627
+ continue
1628
+ message = result.message or (
1629
+ f"{feature.display} check {'passed' if result.success else 'failed'}."
1630
+ )
1631
+ self.message_user(
1632
+ request, f"{message} {enablement_message}", level=result.level
1633
+ )
1634
+ if result.success:
1635
+ successes += 1
1636
+ if total:
1637
+ self.message_user(
1638
+ request,
1639
+ f"Completed {successes} of {total} feature check(s) successfully.",
1640
+ level=messages.INFO,
1641
+ )
1642
+
1643
+ @admin.action(description="Enable selected action")
1644
+ def enable_selected_features(self, request, queryset):
1645
+ node = Node.get_local()
1646
+ if node is None:
1647
+ self.message_user(
1648
+ request,
1649
+ "No local node is registered; unable to enable features manually.",
1650
+ level=messages.ERROR,
1651
+ )
1652
+ return
1653
+
1654
+ manual_features = [
1655
+ feature
1656
+ for feature in queryset
1657
+ if feature.slug in Node.MANUAL_FEATURE_SLUGS
1658
+ ]
1659
+ non_manual_features = [
1660
+ feature
1661
+ for feature in queryset
1662
+ if feature.slug not in Node.MANUAL_FEATURE_SLUGS
1663
+ ]
1664
+ for feature in non_manual_features:
1665
+ self.message_user(
1666
+ request,
1667
+ f"{feature.display} cannot be enabled manually.",
1668
+ level=messages.WARNING,
1669
+ )
1670
+
1671
+ if not manual_features:
1672
+ self.message_user(
1673
+ request,
1674
+ "None of the selected features can be enabled manually.",
1675
+ level=messages.WARNING,
1676
+ )
1677
+ return
1678
+
1679
+ current_manual = set(
1680
+ node.features.filter(slug__in=Node.MANUAL_FEATURE_SLUGS).values_list(
1681
+ "slug", flat=True
1682
+ )
1683
+ )
1684
+ desired_manual = current_manual | {feature.slug for feature in manual_features}
1685
+ newly_enabled = desired_manual - current_manual
1686
+ if not newly_enabled:
1687
+ self.message_user(
1688
+ request,
1689
+ "Selected manual features are already enabled.",
1690
+ level=messages.INFO,
1691
+ )
1692
+ return
1693
+
1694
+ node.update_manual_features(desired_manual)
1695
+ display_map = {feature.slug: feature.display for feature in manual_features}
1696
+ newly_enabled_names = [display_map[slug] for slug in sorted(newly_enabled)]
1697
+ self.message_user(
1698
+ request,
1699
+ "Enabled {} feature(s): {}".format(
1700
+ len(newly_enabled), ", ".join(newly_enabled_names)
1701
+ ),
1702
+ level=messages.SUCCESS,
1703
+ )
1704
+
1705
+ def get_urls(self):
1706
+ urls = super().get_urls()
1707
+ custom = [
1708
+ path(
1709
+ "celery-report/",
1710
+ self.admin_site.admin_view(self.celery_report),
1711
+ name="nodes_nodefeature_celery_report",
1712
+ ),
1713
+ path(
1714
+ "view-waveform/",
1715
+ self.admin_site.admin_view(self.view_waveform),
1716
+ name="nodes_nodefeature_view_waveform",
1717
+ ),
1718
+ path(
1719
+ "take-screenshot/",
1720
+ self.admin_site.admin_view(self.take_screenshot),
1721
+ name="nodes_nodefeature_take_screenshot",
1722
+ ),
1723
+ path(
1724
+ "take-snapshot/",
1725
+ self.admin_site.admin_view(self.take_snapshot),
1726
+ name="nodes_nodefeature_take_snapshot",
1727
+ ),
1728
+ path(
1729
+ "view-stream/",
1730
+ self.admin_site.admin_view(self.view_stream),
1731
+ name="nodes_nodefeature_view_stream",
1732
+ ),
1733
+ ]
1734
+ return custom + urls
1735
+
1736
+ def celery_report(self, request):
1737
+ period = resolve_period(request.GET.get("period"))
1738
+ now = timezone.now()
1739
+ window_end = now + period.delta
1740
+ log_window_start = now - period.delta
1741
+
1742
+ scheduled_tasks = collect_scheduled_tasks(now, window_end)
1743
+ log_collection = collect_celery_log_entries(log_window_start, now)
1744
+
1745
+ period_options = [
1746
+ {
1747
+ "key": candidate.key,
1748
+ "label": candidate.label,
1749
+ "selected": candidate.key == period.key,
1750
+ "url": f"?period={candidate.key}",
1751
+ }
1752
+ for candidate in iter_report_periods()
1753
+ ]
1754
+
1755
+ context = {
1756
+ **self.admin_site.each_context(request),
1757
+ "title": _("Celery Report"),
1758
+ "period": period,
1759
+ "period_options": period_options,
1760
+ "current_time": now,
1761
+ "window_end": window_end,
1762
+ "log_window_start": log_window_start,
1763
+ "scheduled_tasks": scheduled_tasks,
1764
+ "log_entries": log_collection.entries,
1765
+ "log_sources": log_collection.checked_sources,
1766
+ }
1767
+ return TemplateResponse(
1768
+ request,
1769
+ "admin/nodes/nodefeature/celery_report.html",
1770
+ context,
1771
+ )
1772
+
1773
+ def _ensure_feature_enabled(self, request, slug: str, action_label: str):
1774
+ try:
1775
+ feature = NodeFeature.objects.get(slug=slug)
1776
+ except NodeFeature.DoesNotExist:
1777
+ self.message_user(
1778
+ request,
1779
+ f"{action_label} is unavailable because the feature is not configured.",
1780
+ level=messages.ERROR,
1781
+ )
1782
+ return None
1783
+ if not feature.is_enabled:
1784
+ self.message_user(
1785
+ request,
1786
+ f"{feature.display} feature is not enabled on this node.",
1787
+ level=messages.WARNING,
1788
+ )
1789
+ return None
1790
+ return feature
1791
+
1792
+ def view_waveform(self, request):
1793
+ feature = self._ensure_feature_enabled(
1794
+ request, "audio-capture", "View Waveform"
1795
+ )
1796
+ if not feature:
1797
+ return redirect("..")
1798
+
1799
+ context = {
1800
+ **self.admin_site.each_context(request),
1801
+ "title": _("Audio Capture Waveform"),
1802
+ "feature": feature,
1803
+ }
1804
+ return TemplateResponse(
1805
+ request,
1806
+ "admin/nodes/nodefeature/view_waveform.html",
1807
+ context,
1808
+ )
1809
+
1810
+ def take_screenshot(self, request):
1811
+ feature = self._ensure_feature_enabled(
1812
+ request, "screenshot-poll", "Take Screenshot"
1813
+ )
1814
+ if not feature:
1815
+ return redirect("..")
1816
+ url = request.build_absolute_uri("/")
1817
+ try:
1818
+ path = capture_screenshot(url)
1819
+ except Exception as exc: # pragma: no cover - depends on selenium setup
1820
+ self.message_user(request, str(exc), level=messages.ERROR)
1821
+ return redirect("..")
1822
+ node = Node.get_local()
1823
+ sample = save_screenshot(path, node=node, method="DEFAULT_ACTION")
1824
+ if not sample:
1825
+ self.message_user(
1826
+ request, "Duplicate screenshot; not saved", level=messages.INFO
1827
+ )
1828
+ return redirect("..")
1829
+ self.message_user(
1830
+ request, f"Screenshot saved to {sample.path}", level=messages.SUCCESS
1831
+ )
1832
+ try:
1833
+ change_url = reverse(
1834
+ "admin:nodes_contentsample_change", args=[sample.pk]
1835
+ )
1836
+ except NoReverseMatch: # pragma: no cover - admin URL always registered
1837
+ self.message_user(
1838
+ request,
1839
+ "Screenshot saved but the admin page could not be resolved.",
1840
+ level=messages.WARNING,
1841
+ )
1842
+ return redirect("..")
1843
+ return redirect(change_url)
1844
+
1845
+ def take_snapshot(self, request):
1846
+ feature = self._ensure_feature_enabled(
1847
+ request, "rpi-camera", "Take a Snapshot"
1848
+ )
1849
+ if not feature:
1850
+ return redirect("..")
1851
+ try:
1852
+ path = capture_rpi_snapshot()
1853
+ except Exception as exc: # pragma: no cover - depends on camera stack
1854
+ self.message_user(request, str(exc), level=messages.ERROR)
1855
+ return redirect("..")
1856
+ node = Node.get_local()
1857
+ sample = save_screenshot(path, node=node, method="RPI_CAMERA")
1858
+ if not sample:
1859
+ self.message_user(
1860
+ request, "Duplicate snapshot; not saved", level=messages.INFO
1861
+ )
1862
+ return redirect("..")
1863
+ self.message_user(
1864
+ request, f"Snapshot saved to {sample.path}", level=messages.SUCCESS
1865
+ )
1866
+ try:
1867
+ change_url = reverse(
1868
+ "admin:nodes_contentsample_change", args=[sample.pk]
1869
+ )
1870
+ except NoReverseMatch: # pragma: no cover - admin URL always registered
1871
+ self.message_user(
1872
+ request,
1873
+ "Snapshot saved but the admin page could not be resolved.",
1874
+ level=messages.WARNING,
1875
+ )
1876
+ return redirect("..")
1877
+ return redirect(change_url)
1878
+
1879
+ def view_stream(self, request):
1880
+ feature = self._ensure_feature_enabled(request, "rpi-camera", "View stream")
1881
+ if not feature:
1882
+ return redirect("..")
1883
+
1884
+ configured_stream = getattr(settings, "RPI_CAMERA_STREAM_URL", "").strip()
1885
+ if configured_stream:
1886
+ stream_url = configured_stream
1887
+ else:
1888
+ base_uri = request.build_absolute_uri("/")
1889
+ parsed = urlsplit(base_uri)
1890
+ hostname = parsed.hostname or "127.0.0.1"
1891
+ port = getattr(settings, "RPI_CAMERA_STREAM_PORT", 8554)
1892
+ scheme = getattr(settings, "RPI_CAMERA_STREAM_SCHEME", "http")
1893
+ netloc = f"{hostname}:{port}" if port else hostname
1894
+ stream_url = urlunsplit((scheme, netloc, "/", "", ""))
1895
+ parsed_stream = urlsplit(stream_url)
1896
+ path = (parsed_stream.path or "").lower()
1897
+ query = (parsed_stream.query or "").lower()
1898
+
1899
+ if parsed_stream.scheme in {"rtsp", "rtsps"}:
1900
+ embed_mode = "unsupported"
1901
+ elif any(path.endswith(ext) for ext in (".mjpg", ".mjpeg", ".jpeg", ".jpg", ".png")) or "action=stream" in query:
1902
+ embed_mode = "mjpeg"
1903
+ else:
1904
+ embed_mode = "iframe"
1905
+
1906
+ context = {
1907
+ **self.admin_site.each_context(request),
1908
+ "title": _("Raspberry Pi Camera Stream"),
1909
+ "stream_url": stream_url,
1910
+ "stream_embed": embed_mode,
1911
+ }
1912
+ return TemplateResponse(
1913
+ request,
1914
+ "admin/nodes/nodefeature/view_stream.html",
1915
+ context,
1916
+ )
1917
+
1918
+
1919
+ @admin.register(ContentTag)
1920
+ class ContentTagAdmin(EntityModelAdmin):
1921
+ list_display = ("label", "slug")
1922
+ search_fields = ("label", "slug")
1923
+
1924
+
1925
+ @admin.register(ContentClassifier)
1926
+ class ContentClassifierAdmin(EntityModelAdmin):
1927
+ list_display = ("label", "slug", "kind", "run_by_default", "active")
1928
+ list_filter = ("kind", "run_by_default", "active")
1929
+ search_fields = ("label", "slug", "entrypoint")
1930
+
1931
+
1932
+ class ContentClassificationInline(admin.TabularInline):
1933
+ model = ContentClassification
1934
+ extra = 0
1935
+ autocomplete_fields = ("classifier", "tag")
1936
+
1937
+
1938
+ @admin.register(ContentSample)
1939
+ class ContentSampleAdmin(EntityModelAdmin):
1940
+ list_display = ("name", "kind", "node", "user", "created_at")
1941
+ readonly_fields = ("created_at", "name", "user", "image_preview")
1942
+ inlines = (ContentClassificationInline,)
1943
+ list_filter = ("kind", "classifications__tag")
1944
+
1945
+ def get_urls(self):
1946
+ urls = super().get_urls()
1947
+ custom = [
1948
+ path(
1949
+ "from-clipboard/",
1950
+ self.admin_site.admin_view(self.add_from_clipboard),
1951
+ name="nodes_contentsample_from_clipboard",
1952
+ ),
1953
+ path(
1954
+ "capture/",
1955
+ self.admin_site.admin_view(self.capture_now),
1956
+ name="nodes_contentsample_capture",
1957
+ ),
1958
+ ]
1959
+ return custom + urls
1960
+
1961
+ def add_from_clipboard(self, request):
1962
+ try:
1963
+ content = pyperclip.paste()
1964
+ except PyperclipException as exc: # pragma: no cover - depends on OS clipboard
1965
+ self.message_user(request, f"Clipboard error: {exc}", level=messages.ERROR)
1966
+ return redirect("..")
1967
+ if not content:
1968
+ self.message_user(request, "Clipboard is empty.", level=messages.INFO)
1969
+ return redirect("..")
1970
+ if ContentSample.objects.filter(
1971
+ content=content, kind=ContentSample.TEXT
1972
+ ).exists():
1973
+ self.message_user(
1974
+ request, "Duplicate sample not created.", level=messages.INFO
1975
+ )
1976
+ return redirect("..")
1977
+ user = request.user if request.user.is_authenticated else None
1978
+ with suppress_default_classifiers():
1979
+ sample = ContentSample.objects.create(
1980
+ content=content, user=user, kind=ContentSample.TEXT
1981
+ )
1982
+ run_default_classifiers(sample)
1983
+ self.message_user(
1984
+ request, "Text sample added from clipboard.", level=messages.SUCCESS
1985
+ )
1986
+ return redirect("..")
1987
+
1988
+ def capture_now(self, request):
1989
+ node = Node.get_local()
1990
+ url = request.build_absolute_uri("/")
1991
+ try:
1992
+ path = capture_screenshot(url)
1993
+ except Exception as exc: # pragma: no cover - depends on selenium setup
1994
+ self.message_user(request, str(exc), level=messages.ERROR)
1995
+ return redirect("..")
1996
+ sample = save_screenshot(path, node=node, method="ADMIN")
1997
+ if sample:
1998
+ self.message_user(request, f"Screenshot saved to {path}", messages.SUCCESS)
1999
+ else:
2000
+ self.message_user(request, "Duplicate screenshot; not saved", messages.INFO)
2001
+ return redirect("..")
2002
+
2003
+ @admin.display(description="Screenshot")
2004
+ def image_preview(self, obj):
2005
+ if not obj or obj.kind != ContentSample.IMAGE or not obj.path:
2006
+ return ""
2007
+ file_path = Path(obj.path)
2008
+ if not file_path.is_absolute():
2009
+ file_path = settings.LOG_DIR / file_path
2010
+ if not file_path.exists():
2011
+ return "File not found"
2012
+ with file_path.open("rb") as f:
2013
+ encoded = base64.b64encode(f.read()).decode("ascii")
2014
+ return format_html(
2015
+ '<img src="data:image/png;base64,{}" style="max-width:100%;" />',
2016
+ encoded,
2017
+ )
2018
+
2019
+
2020
+ @admin.register(NetMessage)
2021
+ class NetMessageAdmin(EntityModelAdmin):
2022
+ class QuickSendForm(forms.ModelForm):
2023
+ class Meta:
2024
+ model = NetMessage
2025
+ fields = [
2026
+ "subject",
2027
+ "body",
2028
+ "attachments",
2029
+ "filter_node",
2030
+ "filter_node_feature",
2031
+ "filter_node_role",
2032
+ "filter_current_relation",
2033
+ "filter_installed_version",
2034
+ "filter_installed_revision",
2035
+ "target_limit",
2036
+ ]
2037
+ widgets = {"body": forms.Textarea(attrs={"rows": 4})}
2038
+
2039
+ class NetMessageAdminForm(forms.ModelForm):
2040
+ class Meta:
2041
+ model = NetMessage
2042
+ fields = "__all__"
2043
+ widgets = {"body": forms.Textarea(attrs={"rows": 4})}
2044
+
2045
+ change_list_template = "admin/nodes/netmessage/change_list.html"
2046
+ form = NetMessageAdminForm
2047
+ change_form_template = "admin/nodes/netmessage/change_form.html"
2048
+ list_display = (
2049
+ "subject",
2050
+ "body",
2051
+ "filter_node",
2052
+ "filter_node_role_display",
2053
+ "node_origin",
2054
+ "created",
2055
+ "target_limit_display",
2056
+ "complete",
2057
+ )
2058
+ search_fields = ("subject", "body")
2059
+ list_filter = ("complete", "filter_node_role", "filter_current_relation")
2060
+ ordering = ("-created",)
2061
+ readonly_fields = ("complete",)
2062
+ actions = ["send_messages"]
2063
+ fieldsets = (
2064
+ (None, {"fields": ("subject", "body")}),
2065
+ (
2066
+ "Filters",
2067
+ {
2068
+ "fields": (
2069
+ "filter_node",
2070
+ "filter_node_feature",
2071
+ "filter_node_role",
2072
+ "filter_current_relation",
2073
+ "filter_installed_version",
2074
+ "filter_installed_revision",
2075
+ )
2076
+ },
2077
+ ),
2078
+ ("Attachments", {"fields": ("attachments",)}),
2079
+ (
2080
+ "Propagation",
2081
+ {
2082
+ "fields": (
2083
+ "node_origin",
2084
+ "target_limit",
2085
+ "propagated_to",
2086
+ "complete",
2087
+ )
2088
+ },
2089
+ ),
2090
+ )
2091
+ quick_send_fieldsets = (
2092
+ (None, {"fields": ("subject", "body")}),
2093
+ (
2094
+ _("Filters"),
2095
+ {
2096
+ "fields": (
2097
+ "filter_node",
2098
+ "filter_node_feature",
2099
+ "filter_node_role",
2100
+ "filter_current_relation",
2101
+ "filter_installed_version",
2102
+ "filter_installed_revision",
2103
+ )
2104
+ },
2105
+ ),
2106
+ (
2107
+ _("Propagation"),
2108
+ {
2109
+ "fields": (
2110
+ "target_limit",
2111
+ )
2112
+ },
2113
+ ),
2114
+ )
2115
+
2116
+ def get_actions(self, request):
2117
+ actions = super().get_actions(request)
2118
+ if self.has_add_permission(request):
2119
+ action = getattr(self, "send", None)
2120
+ if action is not None and "send" not in actions:
2121
+ actions["send"] = (
2122
+ action,
2123
+ "send",
2124
+ getattr(action, "short_description", _("Send Net Message")),
2125
+ )
2126
+ return actions
2127
+
2128
+ def send(self, request, queryset=None):
2129
+ return redirect(
2130
+ reverse(
2131
+ f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_send"
2132
+ )
2133
+ )
2134
+
2135
+ send.label = _("Send Net Message")
2136
+ send.short_description = _("Send Net Message")
2137
+
2138
+ def get_urls(self):
2139
+ urls = super().get_urls()
2140
+ opts = self.model._meta
2141
+ custom_urls = [
2142
+ path(
2143
+ "send/",
2144
+ self.admin_site.admin_view(self.send_tool_view),
2145
+ name=f"{opts.app_label}_{opts.model_name}_send",
2146
+ )
2147
+ ]
2148
+ return custom_urls + urls
2149
+
2150
+ def send_tool_view(self, request):
2151
+ if not self.has_add_permission(request):
2152
+ raise PermissionDenied
2153
+
2154
+ form_class = self.QuickSendForm
2155
+ if request.method == "POST":
2156
+ form = form_class(request.POST)
2157
+ if form.is_valid():
2158
+ obj = form.save(commit=False)
2159
+ obj.pk = None
2160
+ previous_skip_flag = getattr(self, "_skip_entity_user_datum", False)
2161
+ self._skip_entity_user_datum = True
2162
+ try:
2163
+ self.save_model(request, obj, form, change=False)
2164
+ self.save_related(request, form, formsets=[], change=False)
2165
+ finally:
2166
+ self._skip_entity_user_datum = previous_skip_flag
2167
+ self.log_addition(
2168
+ request,
2169
+ obj,
2170
+ self.construct_change_message(request, form, None),
2171
+ )
2172
+ obj.propagate()
2173
+ self.message_user(
2174
+ request,
2175
+ _("Net Message sent to the network."),
2176
+ level=messages.SUCCESS,
2177
+ )
2178
+ changelist_url = reverse(
2179
+ f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_changelist"
2180
+ )
2181
+ return redirect(changelist_url)
2182
+ else:
2183
+ form = form_class()
2184
+
2185
+ admin_form = helpers.AdminForm(form, self.quick_send_fieldsets, {})
2186
+ context = {
2187
+ **self.admin_site.each_context(request),
2188
+ "opts": self.model._meta,
2189
+ "title": _("Send Net Message"),
2190
+ "adminform": admin_form,
2191
+ "media": self.media + form.media,
2192
+ }
2193
+ return TemplateResponse(
2194
+ request,
2195
+ "admin/nodes/netmessage/send.html",
2196
+ context,
2197
+ )
2198
+
2199
+ def get_changeform_initial_data(self, request):
2200
+ initial = super().get_changeform_initial_data(request)
2201
+ initial = dict(initial) if initial else {}
2202
+ reply_to = request.GET.get("reply_to")
2203
+ if reply_to:
2204
+ try:
2205
+ message = (
2206
+ NetMessage.objects.select_related("node_origin__role")
2207
+ .get(pk=reply_to)
2208
+ )
2209
+ except (NetMessage.DoesNotExist, ValueError, TypeError):
2210
+ message = None
2211
+ if message:
2212
+ subject = (message.subject or "").strip()
2213
+ if subject:
2214
+ if not subject.lower().startswith("re:"):
2215
+ subject = f"Re: {subject}"
2216
+ else:
2217
+ subject = "Re:"
2218
+ initial.setdefault("subject", subject[:64])
2219
+ if message.node_origin and "filter_node" not in initial:
2220
+ initial["filter_node"] = message.node_origin.pk
2221
+ return initial
2222
+
2223
+ def send_messages(self, request, queryset):
2224
+ for msg in queryset:
2225
+ msg.propagate()
2226
+ self.message_user(request, f"{queryset.count()} messages sent")
2227
+
2228
+ send_messages.short_description = "Send selected messages"
2229
+
2230
+ @admin.display(description="Role", ordering="filter_node_role")
2231
+ def filter_node_role_display(self, obj):
2232
+ return obj.filter_node_role
2233
+
2234
+ @admin.display(description="TL", ordering="target_limit")
2235
+ def target_limit_display(self, obj):
2236
+ return obj.target_limit or ""