arthexis 0.1.13__py3-none-any.whl → 0.1.14__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 (107) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -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 +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +708 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2200
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1128
  104. arthexis-0.1.13.dist-info/RECORD +0 -105
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
nodes/admin.py CHANGED
@@ -1,1161 +1,1720 @@
1
- from django.contrib import admin, messages
2
- from django.urls import NoReverseMatch, path, reverse
3
- from django.shortcuts import redirect, render
4
- from django.template.response import TemplateResponse
5
- from django.utils.html import format_html, format_html_join
6
- from django import forms
7
- from django.contrib.admin.widgets import FilteredSelectMultiple
8
- from core.widgets import CopyColorWidget
9
- from django.db.models import Count
10
- from django.conf import settings
11
- from pathlib import Path
12
- from django.http import HttpResponse
13
- from django.utils import timezone
14
- from django.utils.translation import gettext_lazy as _
15
- from urllib.parse import urlsplit, urlunsplit
16
- from django.core.exceptions import PermissionDenied
17
- from django.utils.dateparse import parse_datetime
18
- import base64
19
- import json
20
- import pyperclip
21
- from pyperclip import PyperclipException
22
- import uuid
23
- import subprocess
24
-
25
- import requests
26
- from requests import RequestException
27
- from cryptography.hazmat.primitives import hashes, serialization
28
- from cryptography.hazmat.primitives.asymmetric import padding
29
- from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
30
- from .actions import NodeAction
31
- from .reports import (
32
- collect_celery_log_entries,
33
- collect_scheduled_tasks,
34
- iter_report_periods,
35
- resolve_period,
36
- )
37
-
38
- from core.admin import EmailOutboxAdminForm
39
- from .models import (
40
- Node,
41
- EmailOutbox,
42
- NodeRole,
43
- NodeFeature,
44
- NodeFeatureAssignment,
45
- ContentSample,
46
- NetMessage,
47
- NodeManager,
48
- DNSRecord,
49
- )
50
- from . import dns as dns_utils
51
- from core.models import RFID
52
- from core.user_data import EntityModelAdmin
53
-
54
-
55
- class NodeAdminForm(forms.ModelForm):
56
- class Meta:
57
- model = Node
58
- fields = "__all__"
59
- widgets = {"badge_color": CopyColorWidget()}
60
-
61
-
62
- class NodeFeatureAssignmentInline(admin.TabularInline):
63
- model = NodeFeatureAssignment
64
- extra = 0
65
- autocomplete_fields = ("feature",)
66
-
67
-
68
- class DeployDNSRecordsForm(forms.Form):
69
- manager = forms.ModelChoiceField(
70
- label="Node Manager",
71
- queryset=NodeManager.objects.none(),
72
- help_text="Credentials used to authenticate with the DNS provider.",
73
- )
74
-
75
- def __init__(self, *args, **kwargs):
76
- super().__init__(*args, **kwargs)
77
- self.fields["manager"].queryset = NodeManager.objects.filter(
78
- provider=NodeManager.Provider.GODADDY, is_enabled=True
79
- )
80
-
81
-
82
- @admin.register(NodeManager)
83
- class NodeManagerAdmin(EntityModelAdmin):
84
- list_display = ("__str__", "provider", "is_enabled", "default_domain")
85
- list_filter = ("provider", "is_enabled")
86
- search_fields = (
87
- "default_domain",
88
- "user__username",
89
- "group__name",
90
- )
91
- fieldsets = (
92
- (_("Owner"), {"fields": ("user", "group")}),
93
- (
94
- _("Credentials"),
95
- {"fields": ("api_key", "api_secret", "customer_id")},
96
- ),
97
- (
98
- _("Configuration"),
99
- {
100
- "fields": (
101
- "provider",
102
- "default_domain",
103
- "use_sandbox",
104
- "is_enabled",
105
- )
106
- },
107
- ),
108
- )
109
-
110
-
111
- @admin.register(DNSRecord)
112
- class DNSRecordAdmin(EntityModelAdmin):
113
- list_display = (
114
- "record_type",
115
- "fqdn",
116
- "data",
117
- "ttl",
118
- "node_manager",
119
- "last_synced_at",
120
- "last_verified_at",
121
- )
122
- list_filter = ("record_type", "provider", "node_manager")
123
- search_fields = ("domain", "name", "data")
124
- autocomplete_fields = ("node_manager",)
125
- actions = ["deploy_selected_records", "validate_selected_records"]
126
-
127
- def _default_manager_for_queryset(self, queryset):
128
- manager_ids = list(
129
- queryset.exclude(node_manager__isnull=True)
130
- .values_list("node_manager_id", flat=True)
131
- .distinct()
132
- )
133
- if len(manager_ids) == 1:
134
- return manager_ids[0]
135
- available = list(
136
- NodeManager.objects.filter(
137
- provider=NodeManager.Provider.GODADDY, is_enabled=True
138
- ).values_list("pk", flat=True)
139
- )
140
- if len(available) == 1:
141
- return available[0]
142
- return None
143
-
144
- @admin.action(description="Deploy Selected records")
145
- def deploy_selected_records(self, request, queryset):
146
- unsupported = queryset.exclude(provider=DNSRecord.Provider.GODADDY)
147
- for record in unsupported:
148
- self.message_user(
149
- request,
150
- f"{record} uses unsupported provider {record.get_provider_display()}",
151
- messages.WARNING,
152
- )
153
- queryset = queryset.filter(provider=DNSRecord.Provider.GODADDY)
154
- if not queryset:
155
- self.message_user(request, "No GoDaddy records selected.", messages.WARNING)
156
- return None
157
-
158
- if "apply" in request.POST:
159
- form = DeployDNSRecordsForm(request.POST)
160
- if form.is_valid():
161
- manager = form.cleaned_data["manager"]
162
- result = manager.publish_dns_records(list(queryset))
163
- for record, reason in result.skipped.items():
164
- self.message_user(request, f"{record}: {reason}", messages.WARNING)
165
- for record, reason in result.failures.items():
166
- self.message_user(request, f"{record}: {reason}", messages.ERROR)
167
- if result.deployed:
168
- self.message_user(
169
- request,
170
- f"Deployed {len(result.deployed)} DNS record(s) via {manager}.",
171
- messages.SUCCESS,
172
- )
173
- return None
174
- else:
175
- initial_manager = self._default_manager_for_queryset(queryset)
176
- form = DeployDNSRecordsForm(initial={"manager": initial_manager})
177
-
178
- context = {
179
- **self.admin_site.each_context(request),
180
- "opts": self.model._meta,
181
- "form": form,
182
- "queryset": queryset,
183
- "title": "Deploy DNS records",
184
- }
185
- return render(
186
- request,
187
- "admin/nodes/dnsrecord/deploy_records.html",
188
- context,
189
- )
190
-
191
- @admin.action(description="Validate Selected records")
192
- def validate_selected_records(self, request, queryset):
193
- resolver = dns_utils.create_resolver()
194
- successes = 0
195
- for record in queryset:
196
- ok, message = dns_utils.validate_record(record, resolver=resolver)
197
- if ok:
198
- successes += 1
199
- else:
200
- self.message_user(request, f"{record}: {message}", messages.WARNING)
201
- if successes:
202
- self.message_user(
203
- request,
204
- f"Validated {successes} DNS record(s).",
205
- messages.SUCCESS,
206
- )
207
-
208
-
209
- @admin.register(Node)
210
- class NodeAdmin(EntityModelAdmin):
211
- list_display = (
212
- "hostname",
213
- "mac_address",
214
- "address",
215
- "port",
216
- "role",
217
- "last_seen",
218
- )
219
- search_fields = ("hostname", "address", "mac_address")
220
- change_list_template = "admin/nodes/node/change_list.html"
221
- change_form_template = "admin/nodes/node/change_form.html"
222
- form = NodeAdminForm
223
- actions = [
224
- "register_visitor",
225
- "run_task",
226
- "take_screenshots",
227
- "fetch_rfids",
228
- ]
229
- inlines = [NodeFeatureAssignmentInline]
230
-
231
- def get_urls(self):
232
- urls = super().get_urls()
233
- custom = [
234
- path(
235
- "register-current/",
236
- self.admin_site.admin_view(self.register_current),
237
- name="nodes_node_register_current",
238
- ),
239
- path(
240
- "register-visitor/",
241
- self.admin_site.admin_view(self.register_visitor_view),
242
- name="nodes_node_register_visitor",
243
- ),
244
- path(
245
- "<int:node_id>/action/<str:action>/",
246
- self.admin_site.admin_view(self.action_view),
247
- name="nodes_node_action",
248
- ),
249
- path(
250
- "<int:node_id>/public-key/",
251
- self.admin_site.admin_view(self.public_key),
252
- name="nodes_node_public_key",
253
- ),
254
- ]
255
- return custom + urls
256
-
257
- def register_current(self, request):
258
- """Create or update this host and offer browser node registration."""
259
- if not request.user.is_superuser:
260
- raise PermissionDenied
261
- node, created = Node.register_current()
262
- if created:
263
- self.message_user(
264
- request, f"Current host registered as {node}", messages.SUCCESS
265
- )
266
- token = uuid.uuid4().hex
267
- context = {
268
- "token": token,
269
- "register_url": reverse("register-node"),
270
- }
271
- return render(request, "admin/nodes/node/register_remote.html", context)
272
-
273
- @admin.action(description="Register Visitor Node")
274
- def register_visitor(self, request, queryset=None):
275
- return self.register_visitor_view(request)
276
-
277
- def register_visitor_view(self, request):
278
- """Exchange registration data with the visiting node."""
279
-
280
- node, created = Node.register_current()
281
- if created:
282
- self.message_user(
283
- request, f"Current host registered as {node}", messages.SUCCESS
284
- )
285
-
286
- token = uuid.uuid4().hex
287
- context = {
288
- **self.admin_site.each_context(request),
289
- "opts": self.model._meta,
290
- "title": _("Register Visitor Node"),
291
- "token": token,
292
- "info_url": reverse("node-info"),
293
- "register_url": reverse("register-node"),
294
- "visitor_info_url": "http://localhost:8000/nodes/info/",
295
- "visitor_register_url": "http://localhost:8000/nodes/register/",
296
- }
297
- return render(request, "admin/nodes/node/register_visitor.html", context)
298
-
299
- def public_key(self, request, node_id):
300
- node = self.get_object(request, node_id)
301
- if not node:
302
- self.message_user(request, "Unknown node", messages.ERROR)
303
- return redirect("..")
304
- security_dir = Path(settings.BASE_DIR) / "security"
305
- pub_path = security_dir / f"{node.public_endpoint}.pub"
306
- if pub_path.exists():
307
- response = HttpResponse(pub_path.read_bytes(), content_type="text/plain")
308
- response["Content-Disposition"] = f'attachment; filename="{pub_path.name}"'
309
- return response
310
- self.message_user(request, "Public key not found", messages.ERROR)
311
- return redirect("..")
312
-
313
- def run_task(self, request, queryset):
314
- if "apply" in request.POST:
315
- recipe_text = request.POST.get("recipe", "")
316
- results = []
317
- for node in queryset:
318
- try:
319
- if not node.is_local:
320
- raise NotImplementedError(
321
- "Remote node execution is not implemented"
322
- )
323
- command = ["/bin/sh", "-c", recipe_text]
324
- result = subprocess.run(
325
- command,
326
- check=False,
327
- capture_output=True,
328
- text=True,
329
- )
330
- output = result.stdout + result.stderr
331
- except Exception as exc:
332
- output = str(exc)
333
- results.append((node, output))
334
- context = {"recipe": recipe_text, "results": results}
335
- return render(request, "admin/nodes/task_result.html", context)
336
- context = {"nodes": queryset}
337
- return render(request, "admin/nodes/node/run_task.html", context)
338
-
339
- run_task.short_description = "Run task"
340
-
341
- @admin.action(description="Take Screenshots")
342
- def take_screenshots(self, request, queryset):
343
- tx = uuid.uuid4()
344
- sources = getattr(settings, "SCREENSHOT_SOURCES", ["/"])
345
- count = 0
346
- for node in queryset:
347
- for source in sources:
348
- try:
349
- url = source.format(node=node, address=node.address, port=node.port)
350
- except Exception:
351
- url = source
352
- if not url.startswith("http"):
353
- url = f"http://{node.address}:{node.port}{url}"
354
- try:
355
- path = capture_screenshot(url)
356
- except Exception as exc: # pragma: no cover - selenium issues
357
- self.message_user(request, f"{node}: {exc}", messages.ERROR)
358
- continue
359
- sample = save_screenshot(
360
- path, node=node, method="ADMIN", transaction_uuid=tx
361
- )
362
- if sample:
363
- count += 1
364
- self.message_user(request, f"{count} screenshots captured", messages.SUCCESS)
365
-
366
- @admin.action(description="Fetch RFIDs from selected")
367
- def fetch_rfids(self, request, queryset):
368
- local_node = Node.get_local()
369
- if not local_node:
370
- self.message_user(
371
- request,
372
- "Local node is not registered.",
373
- messages.ERROR,
374
- )
375
- return None
376
-
377
- security_dir = Path(local_node.base_path or settings.BASE_DIR) / "security"
378
- priv_path = security_dir / f"{local_node.public_endpoint}"
379
- if not priv_path.exists():
380
- self.message_user(
381
- request,
382
- "Local node private key not found.",
383
- messages.ERROR,
384
- )
385
- return None
386
-
387
- try:
388
- private_key = serialization.load_pem_private_key(
389
- priv_path.read_bytes(), password=None
390
- )
391
- except Exception as exc: # pragma: no cover - unexpected key errors
392
- self.message_user(
393
- request,
394
- f"Failed to load private key: {exc}",
395
- messages.ERROR,
396
- )
397
- return None
398
-
399
- payload = json.dumps(
400
- {"requester": str(local_node.uuid)},
401
- separators=(",", ":"),
402
- sort_keys=True,
403
- )
404
- signature = base64.b64encode(
405
- private_key.sign(
406
- payload.encode(),
407
- padding.PKCS1v15(),
408
- hashes.SHA256(),
409
- )
410
- ).decode()
411
- headers = {
412
- "Content-Type": "application/json",
413
- "X-Signature": signature,
414
- }
415
-
416
- processed = 0
417
- total_created = 0
418
- total_updated = 0
419
- errors = 0
420
-
421
- for node in queryset:
422
- if local_node.pk and node.pk == local_node.pk:
423
- continue
424
- url = f"http://{node.address}:{node.port}/nodes/rfid/export/"
425
- try:
426
- response = requests.post(
427
- url,
428
- data=payload,
429
- headers=headers,
430
- timeout=5,
431
- )
432
- except RequestException as exc:
433
- self.message_user(request, f"{node}: {exc}", messages.ERROR)
434
- errors += 1
435
- continue
436
-
437
- if response.status_code != 200:
438
- self.message_user(
439
- request,
440
- f"{node}: {response.status_code} {response.text}",
441
- messages.ERROR,
442
- )
443
- errors += 1
444
- continue
445
-
446
- try:
447
- data = response.json()
448
- except ValueError:
449
- self.message_user(
450
- request,
451
- f"{node}: invalid JSON response",
452
- messages.ERROR,
453
- )
454
- errors += 1
455
- continue
456
-
457
- created = 0
458
- updated = 0
459
- rfids = data.get("rfids", []) or []
460
- for entry in rfids:
461
- rfid_value = entry.get("rfid")
462
- if not rfid_value:
463
- continue
464
- defaults = {
465
- "custom_label": entry.get("custom_label", ""),
466
- "key_a": entry.get(
467
- "key_a", RFID._meta.get_field("key_a").default
468
- ),
469
- "key_b": entry.get(
470
- "key_b", RFID._meta.get_field("key_b").default
471
- ),
472
- "data": entry.get("data", []),
473
- "key_a_verified": bool(entry.get("key_a_verified", False)),
474
- "key_b_verified": bool(entry.get("key_b_verified", False)),
475
- "allowed": bool(entry.get("allowed", True)),
476
- "color": entry.get("color", RFID.BLACK),
477
- "kind": entry.get("kind", RFID.CLASSIC),
478
- "released": bool(entry.get("released", False)),
479
- "origin_node": node,
480
- }
481
- if "last_seen_on" in entry:
482
- last_seen_raw = entry.get("last_seen_on")
483
- if last_seen_raw:
484
- defaults["last_seen_on"] = parse_datetime(last_seen_raw)
485
- else:
486
- defaults["last_seen_on"] = None
487
-
488
- obj, created_flag = RFID.objects.update_or_create(
489
- rfid=rfid_value,
490
- defaults=defaults,
491
- )
492
- if created_flag:
493
- created += 1
494
- else:
495
- updated += 1
496
-
497
- processed += 1
498
- total_created += created
499
- total_updated += updated
500
-
501
- if processed:
502
- message = (
503
- f"Fetched RFIDs from {processed} node(s); "
504
- f"{total_created} created, {total_updated} updated."
505
- )
506
- level = messages.SUCCESS if not errors else messages.WARNING
507
- self.message_user(request, message, level)
508
- elif not errors:
509
- self.message_user(request, "No remote nodes selected.", messages.INFO)
510
-
511
- def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
512
- extra_context = extra_context or {}
513
- extra_context["node_actions"] = NodeAction.get_actions()
514
- if object_id:
515
- extra_context["public_key_url"] = reverse(
516
- "admin:nodes_node_public_key", args=[object_id]
517
- )
518
- return super().changeform_view(
519
- request, object_id, form_url, extra_context=extra_context
520
- )
521
-
522
- def action_view(self, request, node_id, action):
523
- node = self.get_object(request, node_id)
524
- action_cls = NodeAction.registry.get(action)
525
- if not node or not action_cls:
526
- self.message_user(request, "Unknown node action", messages.ERROR)
527
- return redirect("..")
528
- try:
529
- result = action_cls.run(node)
530
- if hasattr(result, "status_code"):
531
- return result
532
- self.message_user(
533
- request,
534
- f"{action_cls.display_name} executed successfully",
535
- messages.SUCCESS,
536
- )
537
- except NotImplementedError:
538
- self.message_user(
539
- request,
540
- "Remote node actions are not yet implemented",
541
- messages.WARNING,
542
- )
543
- except Exception as exc: # pragma: no cover - unexpected errors
544
- self.message_user(request, str(exc), messages.ERROR)
545
- return redirect(reverse("admin:nodes_node_change", args=[node_id]))
546
-
547
-
548
- @admin.register(EmailOutbox)
549
- class EmailOutboxAdmin(EntityModelAdmin):
550
- form = EmailOutboxAdminForm
551
- list_display = (
552
- "owner_label",
553
- "host",
554
- "port",
555
- "username",
556
- "use_tls",
557
- "use_ssl",
558
- "is_enabled",
559
- )
560
- change_form_template = "admin/nodes/emailoutbox/change_form.html"
561
- fieldsets = (
562
- ("Owner", {"fields": ("user", "group")}),
563
- ("Credentials", {"fields": ("username", "password")}),
564
- (
565
- "Configuration",
566
- {
567
- "fields": (
568
- "node",
569
- "host",
570
- "port",
571
- "use_tls",
572
- "use_ssl",
573
- "from_email",
574
- "is_enabled",
575
- )
576
- },
577
- ),
578
- )
579
-
580
- @admin.display(description="Owner")
581
- def owner_label(self, obj):
582
- return obj.owner_display()
583
-
584
- def get_urls(self):
585
- urls = super().get_urls()
586
- custom = [
587
- path(
588
- "<path:object_id>/test/",
589
- self.admin_site.admin_view(self.test_outbox),
590
- name="nodes_emailoutbox_test",
591
- )
592
- ]
593
- return custom + urls
594
-
595
- def test_outbox(self, request, object_id):
596
- outbox = self.get_object(request, object_id)
597
- if not outbox:
598
- self.message_user(request, "Unknown outbox", messages.ERROR)
599
- return redirect("..")
600
- recipient = request.user.email or outbox.username
601
- try:
602
- outbox.send_mail(
603
- "Test email",
604
- "This is a test email.",
605
- [recipient],
606
- )
607
- self.message_user(request, "Test email sent", messages.SUCCESS)
608
- except Exception as exc: # pragma: no cover - admin feedback
609
- self.message_user(request, str(exc), messages.ERROR)
610
- return redirect("..")
611
-
612
- def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
613
- extra_context = extra_context or {}
614
- if object_id:
615
- extra_context["test_url"] = reverse(
616
- "admin:nodes_emailoutbox_test", args=[object_id]
617
- )
618
- return super().changeform_view(request, object_id, form_url, extra_context)
619
-
620
-
621
- class NodeRoleAdminForm(forms.ModelForm):
622
- nodes = forms.ModelMultipleChoiceField(
623
- queryset=Node.objects.all(),
624
- required=False,
625
- widget=FilteredSelectMultiple("Nodes", False),
626
- )
627
-
628
- class Meta:
629
- model = NodeRole
630
- fields = ("name", "description", "nodes")
631
-
632
- def __init__(self, *args, **kwargs):
633
- super().__init__(*args, **kwargs)
634
- if self.instance.pk:
635
- self.fields["nodes"].initial = self.instance.node_set.all()
636
-
637
-
638
- @admin.register(NodeRole)
639
- class NodeRoleAdmin(EntityModelAdmin):
640
- form = NodeRoleAdminForm
641
- list_display = ("name", "description", "registered", "default_features")
642
-
643
- def get_queryset(self, request):
644
- qs = super().get_queryset(request)
645
- return qs.annotate(_registered=Count("node", distinct=True)).prefetch_related(
646
- "features"
647
- )
648
-
649
- @admin.display(description="Registered", ordering="_registered")
650
- def registered(self, obj):
651
- return getattr(obj, "_registered", obj.node_set.count())
652
-
653
- @admin.display(description="Default Features")
654
- def default_features(self, obj):
655
- features = [feature.display for feature in obj.features.all()]
656
- return ", ".join(features) if features else "—"
657
-
658
- def save_model(self, request, obj, form, change):
659
- obj.node_set.set(form.cleaned_data.get("nodes", []))
660
-
661
-
662
- @admin.register(NodeFeature)
663
- class NodeFeatureAdmin(EntityModelAdmin):
664
- filter_horizontal = ("roles",)
665
- list_display = (
666
- "display",
667
- "slug",
668
- "default_roles",
669
- "is_enabled_display",
670
- "available_actions",
671
- )
672
- actions = ["check_features_for_eligibility", "enable_selected_features"]
673
- readonly_fields = ("is_enabled",)
674
- search_fields = ("display", "slug")
675
-
676
- def get_queryset(self, request):
677
- qs = super().get_queryset(request)
678
- return qs.prefetch_related("roles")
679
-
680
- @admin.display(description="Default Roles")
681
- def default_roles(self, obj):
682
- roles = [role.name for role in obj.roles.all()]
683
- return ", ".join(roles) if roles else "—"
684
-
685
- @admin.display(description="Is Enabled", boolean=True, ordering="is_enabled")
686
- def is_enabled_display(self, obj):
687
- return obj.is_enabled
688
-
689
- @admin.display(description="Actions")
690
- def available_actions(self, obj):
691
- if not obj.is_enabled:
692
- return "—"
693
- actions = obj.get_default_actions()
694
- if not actions:
695
- return "—"
696
-
697
- links = []
698
- for action in actions:
699
- try:
700
- url = reverse(action.url_name)
701
- except NoReverseMatch:
702
- links.append(action.label)
703
- else:
704
- links.append(format_html('<a href="{}">{}</a>', url, action.label))
705
-
706
- if not links:
707
- return "—"
708
- return format_html_join(" | ", "{}", ((link,) for link in links))
709
-
710
- def _manual_enablement_message(self, feature, node):
711
- if node is None:
712
- return (
713
- "Manual enablement is unavailable without a registered local node."
714
- )
715
- if feature.slug in Node.MANUAL_FEATURE_SLUGS:
716
- return "This feature can be enabled manually."
717
- return "This feature cannot be enabled manually."
718
-
719
- @admin.action(description="Check features for eligibility")
720
- def check_features_for_eligibility(self, request, queryset):
721
- from .feature_checks import feature_checks
722
-
723
- features = list(queryset)
724
- total = len(features)
725
- successes = 0
726
- node = Node.get_local()
727
- for feature in features:
728
- enablement_message = self._manual_enablement_message(feature, node)
729
- try:
730
- result = feature_checks.run(feature, node=node)
731
- except Exception as exc: # pragma: no cover - defensive
732
- self.message_user(
733
- request,
734
- f"{feature.display}: {exc} {enablement_message}",
735
- level=messages.ERROR,
736
- )
737
- continue
738
- if result is None:
739
- self.message_user(
740
- request,
741
- f"No check is configured for {feature.display}. {enablement_message}",
742
- level=messages.WARNING,
743
- )
744
- continue
745
- message = result.message or (
746
- f"{feature.display} check {'passed' if result.success else 'failed'}."
747
- )
748
- self.message_user(
749
- request, f"{message} {enablement_message}", level=result.level
750
- )
751
- if result.success:
752
- successes += 1
753
- if total:
754
- self.message_user(
755
- request,
756
- f"Completed {successes} of {total} feature check(s) successfully.",
757
- level=messages.INFO,
758
- )
759
-
760
- @admin.action(description="Enable selected action")
761
- def enable_selected_features(self, request, queryset):
762
- node = Node.get_local()
763
- if node is None:
764
- self.message_user(
765
- request,
766
- "No local node is registered; unable to enable features manually.",
767
- level=messages.ERROR,
768
- )
769
- return
770
-
771
- manual_features = [
772
- feature
773
- for feature in queryset
774
- if feature.slug in Node.MANUAL_FEATURE_SLUGS
775
- ]
776
- non_manual_features = [
777
- feature
778
- for feature in queryset
779
- if feature.slug not in Node.MANUAL_FEATURE_SLUGS
780
- ]
781
- for feature in non_manual_features:
782
- self.message_user(
783
- request,
784
- f"{feature.display} cannot be enabled manually.",
785
- level=messages.WARNING,
786
- )
787
-
788
- if not manual_features:
789
- self.message_user(
790
- request,
791
- "None of the selected features can be enabled manually.",
792
- level=messages.WARNING,
793
- )
794
- return
795
-
796
- current_manual = set(
797
- node.features.filter(slug__in=Node.MANUAL_FEATURE_SLUGS).values_list(
798
- "slug", flat=True
799
- )
800
- )
801
- desired_manual = current_manual | {feature.slug for feature in manual_features}
802
- newly_enabled = desired_manual - current_manual
803
- if not newly_enabled:
804
- self.message_user(
805
- request,
806
- "Selected manual features are already enabled.",
807
- level=messages.INFO,
808
- )
809
- return
810
-
811
- node.update_manual_features(desired_manual)
812
- display_map = {feature.slug: feature.display for feature in manual_features}
813
- newly_enabled_names = [display_map[slug] for slug in sorted(newly_enabled)]
814
- self.message_user(
815
- request,
816
- "Enabled {} feature(s): {}".format(
817
- len(newly_enabled), ", ".join(newly_enabled_names)
818
- ),
819
- level=messages.SUCCESS,
820
- )
821
-
822
- def get_urls(self):
823
- urls = super().get_urls()
824
- custom = [
825
- path(
826
- "celery-report/",
827
- self.admin_site.admin_view(self.celery_report),
828
- name="nodes_nodefeature_celery_report",
829
- ),
830
- path(
831
- "take-screenshot/",
832
- self.admin_site.admin_view(self.take_screenshot),
833
- name="nodes_nodefeature_take_screenshot",
834
- ),
835
- path(
836
- "take-snapshot/",
837
- self.admin_site.admin_view(self.take_snapshot),
838
- name="nodes_nodefeature_take_snapshot",
839
- ),
840
- path(
841
- "view-stream/",
842
- self.admin_site.admin_view(self.view_stream),
843
- name="nodes_nodefeature_view_stream",
844
- ),
845
- ]
846
- return custom + urls
847
-
848
- def celery_report(self, request):
849
- period = resolve_period(request.GET.get("period"))
850
- now = timezone.now()
851
- window_end = now + period.delta
852
- log_window_start = now - period.delta
853
-
854
- scheduled_tasks = collect_scheduled_tasks(now, window_end)
855
- log_collection = collect_celery_log_entries(log_window_start, now)
856
-
857
- period_options = [
858
- {
859
- "key": candidate.key,
860
- "label": candidate.label,
861
- "selected": candidate.key == period.key,
862
- "url": f"?period={candidate.key}",
863
- }
864
- for candidate in iter_report_periods()
865
- ]
866
-
867
- context = {
868
- **self.admin_site.each_context(request),
869
- "title": _("Celery Report"),
870
- "period": period,
871
- "period_options": period_options,
872
- "current_time": now,
873
- "window_end": window_end,
874
- "log_window_start": log_window_start,
875
- "scheduled_tasks": scheduled_tasks,
876
- "log_entries": log_collection.entries,
877
- "log_sources": log_collection.checked_sources,
878
- }
879
- return TemplateResponse(
880
- request,
881
- "admin/nodes/nodefeature/celery_report.html",
882
- context,
883
- )
884
-
885
- def _ensure_feature_enabled(self, request, slug: str, action_label: str):
886
- try:
887
- feature = NodeFeature.objects.get(slug=slug)
888
- except NodeFeature.DoesNotExist:
889
- self.message_user(
890
- request,
891
- f"{action_label} is unavailable because the feature is not configured.",
892
- level=messages.ERROR,
893
- )
894
- return None
895
- if not feature.is_enabled:
896
- self.message_user(
897
- request,
898
- f"{feature.display} feature is not enabled on this node.",
899
- level=messages.WARNING,
900
- )
901
- return None
902
- return feature
903
-
904
- def take_screenshot(self, request):
905
- feature = self._ensure_feature_enabled(
906
- request, "screenshot-poll", "Take Screenshot"
907
- )
908
- if not feature:
909
- return redirect("..")
910
- url = request.build_absolute_uri("/")
911
- try:
912
- path = capture_screenshot(url)
913
- except Exception as exc: # pragma: no cover - depends on selenium setup
914
- self.message_user(request, str(exc), level=messages.ERROR)
915
- return redirect("..")
916
- node = Node.get_local()
917
- sample = save_screenshot(path, node=node, method="DEFAULT_ACTION")
918
- if not sample:
919
- self.message_user(
920
- request, "Duplicate screenshot; not saved", level=messages.INFO
921
- )
922
- return redirect("..")
923
- self.message_user(
924
- request, f"Screenshot saved to {sample.path}", level=messages.SUCCESS
925
- )
926
- try:
927
- change_url = reverse(
928
- "admin:nodes_contentsample_change", args=[sample.pk]
929
- )
930
- except NoReverseMatch: # pragma: no cover - admin URL always registered
931
- self.message_user(
932
- request,
933
- "Screenshot saved but the admin page could not be resolved.",
934
- level=messages.WARNING,
935
- )
936
- return redirect("..")
937
- return redirect(change_url)
938
-
939
- def take_snapshot(self, request):
940
- feature = self._ensure_feature_enabled(
941
- request, "rpi-camera", "Take a Snapshot"
942
- )
943
- if not feature:
944
- return redirect("..")
945
- try:
946
- path = capture_rpi_snapshot()
947
- except Exception as exc: # pragma: no cover - depends on camera stack
948
- self.message_user(request, str(exc), level=messages.ERROR)
949
- return redirect("..")
950
- node = Node.get_local()
951
- sample = save_screenshot(path, node=node, method="RPI_CAMERA")
952
- if not sample:
953
- self.message_user(
954
- request, "Duplicate snapshot; not saved", level=messages.INFO
955
- )
956
- return redirect("..")
957
- self.message_user(
958
- request, f"Snapshot saved to {sample.path}", level=messages.SUCCESS
959
- )
960
- try:
961
- change_url = reverse(
962
- "admin:nodes_contentsample_change", args=[sample.pk]
963
- )
964
- except NoReverseMatch: # pragma: no cover - admin URL always registered
965
- self.message_user(
966
- request,
967
- "Snapshot saved but the admin page could not be resolved.",
968
- level=messages.WARNING,
969
- )
970
- return redirect("..")
971
- return redirect(change_url)
972
-
973
- def view_stream(self, request):
974
- feature = self._ensure_feature_enabled(request, "rpi-camera", "View stream")
975
- if not feature:
976
- return redirect("..")
977
-
978
- configured_stream = getattr(settings, "RPI_CAMERA_STREAM_URL", "").strip()
979
- if configured_stream:
980
- stream_url = configured_stream
981
- else:
982
- base_uri = request.build_absolute_uri("/")
983
- parsed = urlsplit(base_uri)
984
- hostname = parsed.hostname or "127.0.0.1"
985
- port = getattr(settings, "RPI_CAMERA_STREAM_PORT", 8554)
986
- scheme = getattr(settings, "RPI_CAMERA_STREAM_SCHEME", "http")
987
- netloc = f"{hostname}:{port}" if port else hostname
988
- stream_url = urlunsplit((scheme, netloc, "/", "", ""))
989
- context = {
990
- **self.admin_site.each_context(request),
991
- "title": _("Raspberry Pi Camera Stream"),
992
- "stream_url": stream_url,
993
- }
994
- return TemplateResponse(
995
- request,
996
- "admin/nodes/nodefeature/view_stream.html",
997
- context,
998
- )
999
-
1000
-
1001
- @admin.register(ContentSample)
1002
- class ContentSampleAdmin(EntityModelAdmin):
1003
- list_display = ("name", "kind", "node", "user", "created_at")
1004
- readonly_fields = ("created_at", "name", "user", "image_preview")
1005
-
1006
- def get_urls(self):
1007
- urls = super().get_urls()
1008
- custom = [
1009
- path(
1010
- "from-clipboard/",
1011
- self.admin_site.admin_view(self.add_from_clipboard),
1012
- name="nodes_contentsample_from_clipboard",
1013
- ),
1014
- path(
1015
- "capture/",
1016
- self.admin_site.admin_view(self.capture_now),
1017
- name="nodes_contentsample_capture",
1018
- ),
1019
- ]
1020
- return custom + urls
1021
-
1022
- def add_from_clipboard(self, request):
1023
- try:
1024
- content = pyperclip.paste()
1025
- except PyperclipException as exc: # pragma: no cover - depends on OS clipboard
1026
- self.message_user(request, f"Clipboard error: {exc}", level=messages.ERROR)
1027
- return redirect("..")
1028
- if not content:
1029
- self.message_user(request, "Clipboard is empty.", level=messages.INFO)
1030
- return redirect("..")
1031
- if ContentSample.objects.filter(
1032
- content=content, kind=ContentSample.TEXT
1033
- ).exists():
1034
- self.message_user(
1035
- request, "Duplicate sample not created.", level=messages.INFO
1036
- )
1037
- return redirect("..")
1038
- user = request.user if request.user.is_authenticated else None
1039
- ContentSample.objects.create(
1040
- content=content, user=user, kind=ContentSample.TEXT
1041
- )
1042
- self.message_user(
1043
- request, "Text sample added from clipboard.", level=messages.SUCCESS
1044
- )
1045
- return redirect("..")
1046
-
1047
- def capture_now(self, request):
1048
- node = Node.get_local()
1049
- url = request.build_absolute_uri("/")
1050
- try:
1051
- path = capture_screenshot(url)
1052
- except Exception as exc: # pragma: no cover - depends on selenium setup
1053
- self.message_user(request, str(exc), level=messages.ERROR)
1054
- return redirect("..")
1055
- sample = save_screenshot(path, node=node, method="ADMIN")
1056
- if sample:
1057
- self.message_user(request, f"Screenshot saved to {path}", messages.SUCCESS)
1058
- else:
1059
- self.message_user(request, "Duplicate screenshot; not saved", messages.INFO)
1060
- return redirect("..")
1061
-
1062
- @admin.display(description="Screenshot")
1063
- def image_preview(self, obj):
1064
- if not obj or obj.kind != ContentSample.IMAGE or not obj.path:
1065
- return ""
1066
- file_path = Path(obj.path)
1067
- if not file_path.is_absolute():
1068
- file_path = settings.LOG_DIR / file_path
1069
- if not file_path.exists():
1070
- return "File not found"
1071
- with file_path.open("rb") as f:
1072
- encoded = base64.b64encode(f.read()).decode("ascii")
1073
- return format_html(
1074
- '<img src="data:image/png;base64,{}" style="max-width:100%;" />',
1075
- encoded,
1076
- )
1077
-
1078
-
1079
- @admin.register(NetMessage)
1080
- class NetMessageAdmin(EntityModelAdmin):
1081
- class NetMessageAdminForm(forms.ModelForm):
1082
- class Meta:
1083
- model = NetMessage
1084
- fields = "__all__"
1085
- widgets = {"body": forms.Textarea(attrs={"rows": 4})}
1086
-
1087
- form = NetMessageAdminForm
1088
- change_form_template = "admin/nodes/netmessage/change_form.html"
1089
- list_display = (
1090
- "subject",
1091
- "body",
1092
- "filter_node",
1093
- "filter_node_role",
1094
- "node_origin",
1095
- "created",
1096
- "target_limit",
1097
- "complete",
1098
- )
1099
- search_fields = ("subject", "body")
1100
- list_filter = ("complete", "filter_node_role", "filter_current_relation")
1101
- ordering = ("-created",)
1102
- readonly_fields = ("complete",)
1103
- actions = ["send_messages"]
1104
- fieldsets = (
1105
- (None, {"fields": ("subject", "body")}),
1106
- (
1107
- "Filters",
1108
- {
1109
- "fields": (
1110
- "filter_node",
1111
- "filter_node_feature",
1112
- "filter_node_role",
1113
- "filter_current_relation",
1114
- "filter_installed_version",
1115
- "filter_installed_revision",
1116
- )
1117
- },
1118
- ),
1119
- (
1120
- "Propagation",
1121
- {
1122
- "fields": (
1123
- "node_origin",
1124
- "target_limit",
1125
- "propagated_to",
1126
- "complete",
1127
- )
1128
- },
1129
- ),
1130
- )
1131
-
1132
- def get_changeform_initial_data(self, request):
1133
- initial = super().get_changeform_initial_data(request)
1134
- initial = dict(initial) if initial else {}
1135
- reply_to = request.GET.get("reply_to")
1136
- if reply_to:
1137
- try:
1138
- message = (
1139
- NetMessage.objects.select_related("node_origin__role")
1140
- .get(pk=reply_to)
1141
- )
1142
- except (NetMessage.DoesNotExist, ValueError, TypeError):
1143
- message = None
1144
- if message:
1145
- subject = (message.subject or "").strip()
1146
- if subject:
1147
- if not subject.lower().startswith("re:"):
1148
- subject = f"Re: {subject}"
1149
- else:
1150
- subject = "Re:"
1151
- initial.setdefault("subject", subject[:64])
1152
- if message.node_origin and "filter_node" not in initial:
1153
- initial["filter_node"] = message.node_origin.pk
1154
- return initial
1155
-
1156
- def send_messages(self, request, queryset):
1157
- for msg in queryset:
1158
- msg.propagate()
1159
- self.message_user(request, f"{queryset.count()} messages sent")
1160
-
1161
- 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 HttpResponse, JsonResponse
12
+ from django.shortcuts import redirect, render
13
+ from django.template.response import TemplateResponse
14
+ from django.urls import NoReverseMatch, path, reverse
15
+ from django.utils import timezone
16
+ from django.utils.dateparse import parse_datetime
17
+ from django.utils.html import format_html, format_html_join
18
+ from django.utils.translation import gettext_lazy as _
19
+ from pathlib import Path
20
+ from urllib.parse import urlsplit, urlunsplit
21
+ import base64
22
+ import json
23
+ import subprocess
24
+ import uuid
25
+
26
+ import pyperclip
27
+ import requests
28
+ from cryptography.hazmat.primitives import hashes, serialization
29
+ from cryptography.hazmat.primitives.asymmetric import padding
30
+ from pyperclip import PyperclipException
31
+ from requests import RequestException
32
+
33
+ from .classifiers import run_default_classifiers, suppress_default_classifiers
34
+ from .rfid_sync import apply_rfid_payload, serialize_rfid
35
+ from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
36
+ from .reports import (
37
+ collect_celery_log_entries,
38
+ collect_scheduled_tasks,
39
+ iter_report_periods,
40
+ resolve_period,
41
+ )
42
+
43
+ from core.admin import EmailOutboxAdminForm
44
+ from .models import (
45
+ Node,
46
+ EmailOutbox,
47
+ NodeRole,
48
+ NodeFeature,
49
+ NodeFeatureAssignment,
50
+ ContentSample,
51
+ ContentClassifier,
52
+ ContentClassification,
53
+ ContentTag,
54
+ NetMessage,
55
+ NodeManager,
56
+ DNSRecord,
57
+ )
58
+ from . import dns as dns_utils
59
+ from core.models import RFID
60
+ from core.user_data import EntityModelAdmin
61
+
62
+
63
+ class NodeAdminForm(forms.ModelForm):
64
+ class Meta:
65
+ model = Node
66
+ exclude = ("badge_color", "features")
67
+
68
+ def __init__(self, *args, **kwargs):
69
+ super().__init__(*args, **kwargs)
70
+ enable_public = self.fields.get("enable_public_api")
71
+ if enable_public:
72
+ enable_public.label = _("Enable public admin access")
73
+ enable_public.help_text = _(
74
+ "Expose the admin API through this node's public endpoint. "
75
+ "Only enable when trusted peers require administrative access."
76
+ )
77
+
78
+
79
+ class NodeFeatureAssignmentInline(admin.TabularInline):
80
+ model = NodeFeatureAssignment
81
+ extra = 0
82
+ autocomplete_fields = ("feature",)
83
+
84
+
85
+ class DeployDNSRecordsForm(forms.Form):
86
+ manager = forms.ModelChoiceField(
87
+ label="Node Manager",
88
+ queryset=NodeManager.objects.none(),
89
+ help_text="Credentials used to authenticate with the DNS provider.",
90
+ )
91
+
92
+ def __init__(self, *args, **kwargs):
93
+ super().__init__(*args, **kwargs)
94
+ self.fields["manager"].queryset = NodeManager.objects.filter(
95
+ provider=NodeManager.Provider.GODADDY, is_enabled=True
96
+ )
97
+
98
+
99
+ @admin.register(NodeManager)
100
+ class NodeManagerAdmin(EntityModelAdmin):
101
+ list_display = ("__str__", "provider", "is_enabled", "default_domain")
102
+ list_filter = ("provider", "is_enabled")
103
+ search_fields = (
104
+ "default_domain",
105
+ "user__username",
106
+ "group__name",
107
+ )
108
+ fieldsets = (
109
+ (_("Owner"), {"fields": ("user", "group")}),
110
+ (
111
+ _("Credentials"),
112
+ {"fields": ("api_key", "api_secret", "customer_id")},
113
+ ),
114
+ (
115
+ _("Configuration"),
116
+ {
117
+ "fields": (
118
+ "provider",
119
+ "default_domain",
120
+ "use_sandbox",
121
+ "is_enabled",
122
+ )
123
+ },
124
+ ),
125
+ )
126
+
127
+
128
+ @admin.register(DNSRecord)
129
+ class DNSRecordAdmin(EntityModelAdmin):
130
+ list_display = (
131
+ "record_type",
132
+ "fqdn",
133
+ "data",
134
+ "ttl",
135
+ "node_manager",
136
+ "last_synced_at",
137
+ "last_verified_at",
138
+ )
139
+ list_filter = ("record_type", "provider", "node_manager")
140
+ search_fields = ("domain", "name", "data")
141
+ autocomplete_fields = ("node_manager",)
142
+ actions = ["deploy_selected_records", "validate_selected_records"]
143
+
144
+ def _default_manager_for_queryset(self, queryset):
145
+ manager_ids = list(
146
+ queryset.exclude(node_manager__isnull=True)
147
+ .values_list("node_manager_id", flat=True)
148
+ .distinct()
149
+ )
150
+ if len(manager_ids) == 1:
151
+ return manager_ids[0]
152
+ available = list(
153
+ NodeManager.objects.filter(
154
+ provider=NodeManager.Provider.GODADDY, is_enabled=True
155
+ ).values_list("pk", flat=True)
156
+ )
157
+ if len(available) == 1:
158
+ return available[0]
159
+ return None
160
+
161
+ @admin.action(description="Deploy Selected records")
162
+ def deploy_selected_records(self, request, queryset):
163
+ unsupported = queryset.exclude(provider=DNSRecord.Provider.GODADDY)
164
+ for record in unsupported:
165
+ self.message_user(
166
+ request,
167
+ f"{record} uses unsupported provider {record.get_provider_display()}",
168
+ messages.WARNING,
169
+ )
170
+ queryset = queryset.filter(provider=DNSRecord.Provider.GODADDY)
171
+ if not queryset:
172
+ self.message_user(request, "No GoDaddy records selected.", messages.WARNING)
173
+ return None
174
+
175
+ if "apply" in request.POST:
176
+ form = DeployDNSRecordsForm(request.POST)
177
+ if form.is_valid():
178
+ manager = form.cleaned_data["manager"]
179
+ result = manager.publish_dns_records(list(queryset))
180
+ for record, reason in result.skipped.items():
181
+ self.message_user(request, f"{record}: {reason}", messages.WARNING)
182
+ for record, reason in result.failures.items():
183
+ self.message_user(request, f"{record}: {reason}", messages.ERROR)
184
+ if result.deployed:
185
+ self.message_user(
186
+ request,
187
+ f"Deployed {len(result.deployed)} DNS record(s) via {manager}.",
188
+ messages.SUCCESS,
189
+ )
190
+ return None
191
+ else:
192
+ initial_manager = self._default_manager_for_queryset(queryset)
193
+ form = DeployDNSRecordsForm(initial={"manager": initial_manager})
194
+
195
+ context = {
196
+ **self.admin_site.each_context(request),
197
+ "opts": self.model._meta,
198
+ "form": form,
199
+ "queryset": queryset,
200
+ "title": "Deploy DNS records",
201
+ }
202
+ return render(
203
+ request,
204
+ "admin/nodes/dnsrecord/deploy_records.html",
205
+ context,
206
+ )
207
+
208
+ @admin.action(description="Validate Selected records")
209
+ def validate_selected_records(self, request, queryset):
210
+ resolver = dns_utils.create_resolver()
211
+ successes = 0
212
+ for record in queryset:
213
+ ok, message = dns_utils.validate_record(record, resolver=resolver)
214
+ if ok:
215
+ successes += 1
216
+ else:
217
+ self.message_user(request, f"{record}: {message}", messages.WARNING)
218
+ if successes:
219
+ self.message_user(
220
+ request,
221
+ f"Validated {successes} DNS record(s).",
222
+ messages.SUCCESS,
223
+ )
224
+
225
+
226
+ @admin.register(Node)
227
+ class NodeAdmin(EntityModelAdmin):
228
+ list_display = (
229
+ "hostname",
230
+ "mac_address",
231
+ "address",
232
+ "port",
233
+ "role",
234
+ "relation",
235
+ "last_seen",
236
+ )
237
+ search_fields = ("hostname", "address", "mac_address")
238
+ change_list_template = "admin/nodes/node/change_list.html"
239
+ change_form_template = "admin/nodes/node/change_form.html"
240
+ form = NodeAdminForm
241
+ fieldsets = (
242
+ (
243
+ _("Node"),
244
+ {
245
+ "fields": (
246
+ "hostname",
247
+ "address",
248
+ "mac_address",
249
+ "port",
250
+ "role",
251
+ "current_relation",
252
+ )
253
+ },
254
+ ),
255
+ (
256
+ _("Public endpoint"),
257
+ {
258
+ "fields": (
259
+ "public_endpoint",
260
+ "public_key",
261
+ )
262
+ },
263
+ ),
264
+ (
265
+ _("Installation"),
266
+ {
267
+ "fields": (
268
+ "base_path",
269
+ "installed_version",
270
+ "installed_revision",
271
+ )
272
+ },
273
+ ),
274
+ (
275
+ _("Public admin"),
276
+ {"fields": ("enable_public_api",)},
277
+ ),
278
+ )
279
+ actions = [
280
+ "update_selected_nodes",
281
+ "register_visitor",
282
+ "run_task",
283
+ "take_screenshots",
284
+ "import_rfids_from_selected",
285
+ "export_rfids_to_selected",
286
+ ]
287
+ inlines = [NodeFeatureAssignmentInline]
288
+
289
+ @admin.display(description=_("Relation"), ordering="current_relation")
290
+ def relation(self, obj):
291
+ return obj.get_current_relation_display()
292
+
293
+ def get_urls(self):
294
+ urls = super().get_urls()
295
+ custom = [
296
+ path(
297
+ "register-current/",
298
+ self.admin_site.admin_view(self.register_current),
299
+ name="nodes_node_register_current",
300
+ ),
301
+ path(
302
+ "register-visitor/",
303
+ self.admin_site.admin_view(self.register_visitor_view),
304
+ name="nodes_node_register_visitor",
305
+ ),
306
+ path(
307
+ "<int:node_id>/public-key/",
308
+ self.admin_site.admin_view(self.public_key),
309
+ name="nodes_node_public_key",
310
+ ),
311
+ path(
312
+ "update-selected/progress/",
313
+ self.admin_site.admin_view(self.update_selected_progress),
314
+ name="nodes_node_update_selected_progress",
315
+ ),
316
+ ]
317
+ return custom + urls
318
+
319
+ def register_current(self, request):
320
+ """Create or update this host and offer browser node registration."""
321
+ if not request.user.is_superuser:
322
+ raise PermissionDenied
323
+ node, created = Node.register_current()
324
+ if created:
325
+ self.message_user(
326
+ request, f"Current host registered as {node}", messages.SUCCESS
327
+ )
328
+ token = uuid.uuid4().hex
329
+ context = {
330
+ "token": token,
331
+ "register_url": reverse("register-node"),
332
+ }
333
+ return render(request, "admin/nodes/node/register_remote.html", context)
334
+
335
+ @admin.action(description="Register Visitor")
336
+ def register_visitor(self, request, queryset=None):
337
+ return self.register_visitor_view(request)
338
+
339
+ @admin.action(description=_("Update selected nodes"))
340
+ def update_selected_nodes(self, request, queryset):
341
+ node_ids = list(queryset.values_list("pk", flat=True))
342
+ if not node_ids:
343
+ self.message_user(request, _("No nodes selected."), messages.INFO)
344
+ return None
345
+ context = {
346
+ **self.admin_site.each_context(request),
347
+ "opts": self.model._meta,
348
+ "title": _("Update selected nodes"),
349
+ "nodes": list(queryset),
350
+ "node_ids": node_ids,
351
+ "progress_url": reverse("admin:nodes_node_update_selected_progress"),
352
+ }
353
+ return TemplateResponse(
354
+ request, "admin/nodes/node/update_selected.html", context
355
+ )
356
+
357
+ def update_selected_progress(self, request):
358
+ if request.method != "POST":
359
+ return JsonResponse({"detail": "POST required"}, status=405)
360
+ if not self.has_change_permission(request):
361
+ raise PermissionDenied
362
+ try:
363
+ node_id = int(request.POST.get("node_id", ""))
364
+ except (TypeError, ValueError):
365
+ return JsonResponse({"detail": "Invalid node id"}, status=400)
366
+ node = self.get_queryset(request).filter(pk=node_id).first()
367
+ if not node:
368
+ return JsonResponse({"detail": "Node not found"}, status=404)
369
+
370
+ local_result = self._refresh_local_information(node)
371
+ remote_result = self._push_remote_information(node)
372
+
373
+ status = "success"
374
+ if not local_result.get("ok") and not remote_result.get("ok"):
375
+ status = "error"
376
+ elif not local_result.get("ok") or not remote_result.get("ok"):
377
+ status = "partial"
378
+
379
+ return JsonResponse(
380
+ {
381
+ "node": str(node),
382
+ "status": status,
383
+ "local": local_result,
384
+ "remote": remote_result,
385
+ }
386
+ )
387
+
388
+ def _refresh_local_information(self, node):
389
+ if node.is_local:
390
+ try:
391
+ _, created = Node.register_current()
392
+ except Exception as exc: # pragma: no cover - unexpected errors
393
+ return {"ok": False, "message": str(exc)}
394
+ return {
395
+ "ok": True,
396
+ "created": created,
397
+ "message": "Local node registration refreshed.",
398
+ }
399
+
400
+ last_error = ""
401
+ for url in self._iter_remote_urls(node, "/nodes/info/"):
402
+ try:
403
+ response = requests.get(url, timeout=5)
404
+ except RequestException as exc:
405
+ last_error = str(exc)
406
+ continue
407
+ if not response.ok:
408
+ last_error = f"{response.status_code} {response.reason}"
409
+ continue
410
+ try:
411
+ payload = response.json()
412
+ except ValueError:
413
+ last_error = "Invalid JSON response"
414
+ continue
415
+ updated = self._apply_remote_node_info(node, payload)
416
+ message = (
417
+ "Remote information applied."
418
+ if updated
419
+ else "Remote information fetched (no changes)."
420
+ )
421
+ return {
422
+ "ok": True,
423
+ "url": url,
424
+ "updated_fields": updated,
425
+ "message": message,
426
+ }
427
+ return {"ok": False, "message": last_error or "Unable to reach remote node."}
428
+
429
+ def _apply_remote_node_info(self, node, payload):
430
+ changed = []
431
+ field_map = {
432
+ "hostname": payload.get("hostname"),
433
+ "address": payload.get("address"),
434
+ "public_key": payload.get("public_key"),
435
+ }
436
+ port_value = payload.get("port")
437
+ if port_value is not None:
438
+ try:
439
+ port_value = int(port_value)
440
+ except (TypeError, ValueError):
441
+ port_value = None
442
+ field_map["port"] = port_value
443
+ mac_address = payload.get("mac_address")
444
+ if mac_address:
445
+ field_map["mac_address"] = str(mac_address).lower()
446
+
447
+ for field, value in field_map.items():
448
+ if value is None:
449
+ continue
450
+ if getattr(node, field) != value:
451
+ setattr(node, field, value)
452
+ changed.append(field)
453
+
454
+ node.last_seen = timezone.now()
455
+ if "last_seen" not in changed:
456
+ changed.append("last_seen")
457
+ node.save(update_fields=changed)
458
+ return changed
459
+
460
+ def _push_remote_information(self, node):
461
+ if node.is_local:
462
+ return {
463
+ "ok": True,
464
+ "message": "Local node does not require remote update.",
465
+ }
466
+
467
+ local_node = Node.get_local()
468
+ if local_node is None:
469
+ try:
470
+ local_node, _ = Node.register_current()
471
+ except Exception as exc: # pragma: no cover - unexpected errors
472
+ return {"ok": False, "message": str(exc)}
473
+
474
+ security_dir = Path(local_node.base_path or settings.BASE_DIR) / "security"
475
+ priv_path = security_dir / f"{local_node.public_endpoint}"
476
+ if not priv_path.exists():
477
+ return {
478
+ "ok": False,
479
+ "message": "Local node private key not found.",
480
+ }
481
+ try:
482
+ private_key = serialization.load_pem_private_key(
483
+ priv_path.read_bytes(), password=None
484
+ )
485
+ except Exception as exc: # pragma: no cover - unexpected errors
486
+ return {"ok": False, "message": f"Failed to load private key: {exc}"}
487
+
488
+ token = uuid.uuid4().hex
489
+ try:
490
+ signature = private_key.sign(
491
+ token.encode(),
492
+ padding.PKCS1v15(),
493
+ hashes.SHA256(),
494
+ )
495
+ except Exception as exc: # pragma: no cover - unexpected errors
496
+ return {"ok": False, "message": f"Failed to sign payload: {exc}"}
497
+
498
+ payload = {
499
+ "hostname": local_node.hostname,
500
+ "address": local_node.address,
501
+ "port": local_node.port,
502
+ "mac_address": local_node.mac_address,
503
+ "public_key": local_node.public_key,
504
+ "token": token,
505
+ "signature": base64.b64encode(signature).decode(),
506
+ }
507
+ if local_node.installed_version:
508
+ payload["installed_version"] = local_node.installed_version
509
+ if local_node.installed_revision:
510
+ payload["installed_revision"] = local_node.installed_revision
511
+
512
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
513
+ headers = {"Content-Type": "application/json"}
514
+
515
+ last_error = ""
516
+ for url in self._iter_remote_urls(node, "/nodes/register/"):
517
+ try:
518
+ response = requests.post(
519
+ url,
520
+ data=payload_json,
521
+ headers=headers,
522
+ timeout=5,
523
+ )
524
+ except RequestException as exc:
525
+ last_error = str(exc)
526
+ continue
527
+ if response.ok:
528
+ return {"ok": True, "url": url, "message": "Remote updated."}
529
+ last_error = f"{response.status_code} {response.text}"
530
+ return {"ok": False, "message": last_error or "Unable to reach remote node."}
531
+
532
+ def _iter_remote_urls(self, node, path):
533
+ host_candidates = []
534
+ for attr in ("public_endpoint", "address", "hostname"):
535
+ value = getattr(node, attr, "") or ""
536
+ value = value.strip()
537
+ if value and value not in host_candidates:
538
+ host_candidates.append(value)
539
+
540
+ port = node.port or 8000
541
+ normalized_path = path if path.startswith("/") else f"/{path}"
542
+ seen = set()
543
+
544
+ for host in host_candidates:
545
+ formatted_host = host
546
+ if ":" in host and not host.startswith("["):
547
+ formatted_host = f"[{host}]"
548
+
549
+ candidates = []
550
+ if port == 80:
551
+ candidates = [
552
+ f"http://{formatted_host}{normalized_path}",
553
+ f"https://{formatted_host}{normalized_path}",
554
+ ]
555
+ elif port == 443:
556
+ candidates = [
557
+ f"https://{formatted_host}{normalized_path}",
558
+ f"http://{formatted_host}:{port}{normalized_path}",
559
+ ]
560
+ else:
561
+ candidates = [
562
+ f"http://{formatted_host}:{port}{normalized_path}",
563
+ f"https://{formatted_host}:{port}{normalized_path}",
564
+ ]
565
+
566
+ for candidate in candidates:
567
+ if candidate not in seen:
568
+ seen.add(candidate)
569
+ yield candidate
570
+
571
+ def register_visitor_view(self, request):
572
+ """Exchange registration data with the visiting node."""
573
+
574
+ node, created = Node.register_current()
575
+ if created:
576
+ self.message_user(
577
+ request, f"Current host registered as {node}", messages.SUCCESS
578
+ )
579
+
580
+ token = uuid.uuid4().hex
581
+ context = {
582
+ **self.admin_site.each_context(request),
583
+ "opts": self.model._meta,
584
+ "title": _("Register Visitor"),
585
+ "token": token,
586
+ "info_url": reverse("node-info"),
587
+ "register_url": reverse("register-node"),
588
+ "visitor_info_url": "http://localhost:8000/nodes/info/",
589
+ "visitor_register_url": "http://localhost:8000/nodes/register/",
590
+ }
591
+ return render(request, "admin/nodes/node/register_visitor.html", context)
592
+
593
+ def public_key(self, request, node_id):
594
+ node = self.get_object(request, node_id)
595
+ if not node:
596
+ self.message_user(request, "Unknown node", messages.ERROR)
597
+ return redirect("..")
598
+ security_dir = Path(settings.BASE_DIR) / "security"
599
+ pub_path = security_dir / f"{node.public_endpoint}.pub"
600
+ if pub_path.exists():
601
+ response = HttpResponse(pub_path.read_bytes(), content_type="text/plain")
602
+ response["Content-Disposition"] = f'attachment; filename="{pub_path.name}"'
603
+ return response
604
+ self.message_user(request, "Public key not found", messages.ERROR)
605
+ return redirect("..")
606
+
607
+ def run_task(self, request, queryset):
608
+ if "apply" in request.POST:
609
+ recipe_text = request.POST.get("recipe", "")
610
+ results = []
611
+ for node in queryset:
612
+ try:
613
+ if not node.is_local:
614
+ raise NotImplementedError(
615
+ "Remote node execution is not implemented"
616
+ )
617
+ command = ["/bin/sh", "-c", recipe_text]
618
+ result = subprocess.run(
619
+ command,
620
+ check=False,
621
+ capture_output=True,
622
+ text=True,
623
+ )
624
+ output = result.stdout + result.stderr
625
+ except Exception as exc:
626
+ output = str(exc)
627
+ results.append((node, output))
628
+ context = {"recipe": recipe_text, "results": results}
629
+ return render(request, "admin/nodes/task_result.html", context)
630
+ context = {"nodes": queryset}
631
+ return render(request, "admin/nodes/node/run_task.html", context)
632
+
633
+ run_task.short_description = "Run task"
634
+
635
+ @admin.action(description="Take Screenshots")
636
+ def take_screenshots(self, request, queryset):
637
+ tx = uuid.uuid4()
638
+ sources = getattr(settings, "SCREENSHOT_SOURCES", ["/"])
639
+ count = 0
640
+ for node in queryset:
641
+ for source in sources:
642
+ try:
643
+ url = source.format(node=node, address=node.address, port=node.port)
644
+ except Exception:
645
+ url = source
646
+ if not url.startswith("http"):
647
+ url = f"http://{node.address}:{node.port}{url}"
648
+ try:
649
+ path = capture_screenshot(url)
650
+ except Exception as exc: # pragma: no cover - selenium issues
651
+ self.message_user(request, f"{node}: {exc}", messages.ERROR)
652
+ continue
653
+ sample = save_screenshot(
654
+ path, node=node, method="ADMIN", transaction_uuid=tx
655
+ )
656
+ if sample:
657
+ count += 1
658
+ self.message_user(request, f"{count} screenshots captured", messages.SUCCESS)
659
+
660
+ def _init_rfid_result(self, node):
661
+ return {
662
+ "node": node,
663
+ "status": "success",
664
+ "created": 0,
665
+ "updated": 0,
666
+ "linked_accounts": 0,
667
+ "missing_accounts": [],
668
+ "errors": [],
669
+ "processed": 0,
670
+ "message": None,
671
+ }
672
+
673
+ def _skip_result(self, node, message):
674
+ result = self._init_rfid_result(node)
675
+ result["status"] = "skipped"
676
+ result["message"] = message
677
+ return result
678
+
679
+ def _load_local_node_credentials(self):
680
+ local_node = Node.get_local()
681
+ if not local_node:
682
+ return None, None, _("Local node is not registered.")
683
+
684
+ endpoint = (local_node.public_endpoint or "").strip()
685
+ if not endpoint:
686
+ return local_node, None, _(
687
+ "Local node public endpoint is not configured."
688
+ )
689
+
690
+ security_dir = Path(local_node.base_path or settings.BASE_DIR) / "security"
691
+ priv_path = security_dir / endpoint
692
+ if not priv_path.exists():
693
+ return local_node, None, _("Local node private key not found.")
694
+
695
+ try:
696
+ private_key = serialization.load_pem_private_key(
697
+ priv_path.read_bytes(), password=None
698
+ )
699
+ except Exception as exc: # pragma: no cover - unexpected key errors
700
+ return local_node, None, _("Failed to load private key: %(error)s") % {
701
+ "error": exc
702
+ }
703
+
704
+ return local_node, private_key, None
705
+
706
+ def _sign_payload(self, private_key, payload: str) -> str:
707
+ return base64.b64encode(
708
+ private_key.sign(
709
+ payload.encode(),
710
+ padding.PKCS1v15(),
711
+ hashes.SHA256(),
712
+ )
713
+ ).decode()
714
+
715
+ def _dedupe(self, values):
716
+ if not values:
717
+ return []
718
+ return list(OrderedDict.fromkeys(values))
719
+
720
+ def _status_from_result(self, result):
721
+ if result["errors"]:
722
+ return "error"
723
+ if result["missing_accounts"]:
724
+ return "partial"
725
+ return result.get("status") or "success"
726
+
727
+ def _summarize_rfid_results(self, results):
728
+ return {
729
+ "total": len(results),
730
+ "processed": sum(1 for item in results if item["status"] != "skipped"),
731
+ "success": sum(1 for item in results if item["status"] == "success"),
732
+ "partial": sum(1 for item in results if item["status"] == "partial"),
733
+ "error": sum(1 for item in results if item["status"] == "error"),
734
+ "created": sum(item["created"] for item in results),
735
+ "updated": sum(item["updated"] for item in results),
736
+ "linked_accounts": sum(item["linked_accounts"] for item in results),
737
+ "missing_accounts": sum(
738
+ len(item["missing_accounts"]) for item in results
739
+ ),
740
+ }
741
+
742
+ def _render_rfid_sync(self, request, operation, results, setup_error=None):
743
+ titles = {
744
+ "import": _("Import RFID results"),
745
+ "export": _("Export RFID results"),
746
+ }
747
+ summary = self._summarize_rfid_results(results)
748
+ context = {
749
+ **self.admin_site.each_context(request),
750
+ "opts": self.model._meta,
751
+ "title": titles.get(operation, _("RFID results")),
752
+ "operation": operation,
753
+ "results": results,
754
+ "summary": summary,
755
+ "setup_error": setup_error,
756
+ "back_url": reverse("admin:nodes_node_changelist"),
757
+ }
758
+ return TemplateResponse(
759
+ request,
760
+ "admin/nodes/node/rfid_sync_results.html",
761
+ context,
762
+ )
763
+
764
+ def _process_import_from_node(self, node, payload, headers):
765
+ result = self._init_rfid_result(node)
766
+ url = f"http://{node.address}:{node.port}/nodes/rfid/export/"
767
+ try:
768
+ response = requests.post(url, data=payload, headers=headers, timeout=5)
769
+ except RequestException as exc:
770
+ result["status"] = "error"
771
+ result["errors"].append(str(exc))
772
+ return result
773
+
774
+ if response.status_code != 200:
775
+ result["status"] = "error"
776
+ result["errors"].append(f"{response.status_code} {response.text}")
777
+ return result
778
+
779
+ try:
780
+ data = response.json()
781
+ except ValueError:
782
+ result["status"] = "error"
783
+ result["errors"].append(_("Invalid JSON response"))
784
+ return result
785
+
786
+ rfids = data.get("rfids", []) or []
787
+ result["processed"] = len(rfids)
788
+ for entry in rfids:
789
+ if not isinstance(entry, Mapping):
790
+ result["errors"].append(_( "Invalid RFID payload" ))
791
+ continue
792
+ outcome = apply_rfid_payload(entry, origin_node=node)
793
+ if not outcome.ok:
794
+ result["errors"].append(
795
+ outcome.error or _("RFID could not be imported")
796
+ )
797
+ continue
798
+ if outcome.created:
799
+ result["created"] += 1
800
+ else:
801
+ result["updated"] += 1
802
+ result["linked_accounts"] += outcome.accounts_linked
803
+ result["missing_accounts"].extend(outcome.missing_accounts)
804
+
805
+ result["missing_accounts"] = self._dedupe(result["missing_accounts"])
806
+ result["status"] = self._status_from_result(result)
807
+ return result
808
+
809
+ def _post_export_to_node(self, node, payload, headers):
810
+ result = self._init_rfid_result(node)
811
+ url = f"http://{node.address}:{node.port}/nodes/rfid/import/"
812
+ try:
813
+ response = requests.post(url, data=payload, headers=headers, timeout=5)
814
+ except RequestException as exc:
815
+ result["status"] = "error"
816
+ result["errors"].append(str(exc))
817
+ return result
818
+
819
+ if response.status_code != 200:
820
+ result["status"] = "error"
821
+ result["errors"].append(f"{response.status_code} {response.text}")
822
+ return result
823
+
824
+ try:
825
+ data = response.json()
826
+ except ValueError:
827
+ result["status"] = "error"
828
+ result["errors"].append(_("Invalid JSON response"))
829
+ return result
830
+
831
+ result["processed"] = data.get("processed", 0) or 0
832
+ result["created"] = data.get("created", 0) or 0
833
+ result["updated"] = data.get("updated", 0) or 0
834
+ result["linked_accounts"] = data.get("accounts_linked", 0) or 0
835
+
836
+ missing = data.get("missing_accounts") or []
837
+ if isinstance(missing, list):
838
+ result["missing_accounts"].extend(str(value) for value in missing if value)
839
+ elif missing:
840
+ result["missing_accounts"].append(str(missing))
841
+
842
+ errors = data.get("errors", 0)
843
+ if isinstance(errors, int) and errors:
844
+ result["errors"].append(
845
+ _("Remote reported %(count)s error(s).") % {"count": errors}
846
+ )
847
+ elif isinstance(errors, list):
848
+ result["errors"].extend(str(err) for err in errors if err)
849
+
850
+ result["missing_accounts"] = self._dedupe(result["missing_accounts"])
851
+ result["status"] = self._status_from_result(result)
852
+ return result
853
+
854
+ @admin.action(description=_("Import RFIDs from selected"))
855
+ def import_rfids_from_selected(self, request, queryset):
856
+ nodes = list(queryset)
857
+ local_node, private_key, error = self._load_local_node_credentials()
858
+ if error:
859
+ results = [self._skip_result(node, error) for node in nodes]
860
+ return self._render_rfid_sync(request, "import", results, setup_error=error)
861
+
862
+ if not nodes:
863
+ return self._render_rfid_sync(
864
+ request,
865
+ "import",
866
+ [],
867
+ setup_error=_("No nodes selected."),
868
+ )
869
+
870
+ payload = json.dumps(
871
+ {"requester": str(local_node.uuid)},
872
+ separators=(",", ":"),
873
+ sort_keys=True,
874
+ )
875
+ signature = self._sign_payload(private_key, payload)
876
+ headers = {
877
+ "Content-Type": "application/json",
878
+ "X-Signature": signature,
879
+ }
880
+
881
+ results = []
882
+ for node in nodes:
883
+ if local_node.pk and node.pk == local_node.pk:
884
+ results.append(self._skip_result(node, _("Skipped local node.")))
885
+ continue
886
+ results.append(self._process_import_from_node(node, payload, headers))
887
+
888
+ return self._render_rfid_sync(request, "import", results)
889
+
890
+ @admin.action(description=_("Export RFIDs to selected"))
891
+ def export_rfids_to_selected(self, request, queryset):
892
+ nodes = list(queryset)
893
+ local_node, private_key, error = self._load_local_node_credentials()
894
+ if error:
895
+ results = [self._skip_result(node, error) for node in nodes]
896
+ return self._render_rfid_sync(request, "export", results, setup_error=error)
897
+
898
+ if not nodes:
899
+ return self._render_rfid_sync(
900
+ request,
901
+ "export",
902
+ [],
903
+ setup_error=_("No nodes selected."),
904
+ )
905
+
906
+ rfids = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
907
+ payload = json.dumps(
908
+ {"requester": str(local_node.uuid), "rfids": rfids},
909
+ separators=(",", ":"),
910
+ sort_keys=True,
911
+ )
912
+ signature = self._sign_payload(private_key, payload)
913
+ headers = {
914
+ "Content-Type": "application/json",
915
+ "X-Signature": signature,
916
+ }
917
+
918
+ results = []
919
+ for node in nodes:
920
+ if local_node.pk and node.pk == local_node.pk:
921
+ results.append(self._skip_result(node, _("Skipped local node.")))
922
+ continue
923
+ results.append(self._post_export_to_node(node, payload, headers))
924
+
925
+ return self._render_rfid_sync(request, "export", results)
926
+
927
+ def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
928
+ extra_context = extra_context or {}
929
+ if object_id:
930
+ extra_context["public_key_url"] = reverse(
931
+ "admin:nodes_node_public_key", args=[object_id]
932
+ )
933
+ return super().changeform_view(
934
+ request, object_id, form_url, extra_context=extra_context
935
+ )
936
+
937
+
938
+ @admin.register(EmailOutbox)
939
+ class EmailOutboxAdmin(EntityModelAdmin):
940
+ form = EmailOutboxAdminForm
941
+ list_display = (
942
+ "owner_label",
943
+ "host",
944
+ "port",
945
+ "username",
946
+ "use_tls",
947
+ "use_ssl",
948
+ "is_enabled",
949
+ )
950
+ change_form_template = "admin/nodes/emailoutbox/change_form.html"
951
+ fieldsets = (
952
+ ("Owner", {"fields": ("user", "group")}),
953
+ ("Credentials", {"fields": ("username", "password")}),
954
+ (
955
+ "Configuration",
956
+ {
957
+ "fields": (
958
+ "node",
959
+ "host",
960
+ "port",
961
+ "use_tls",
962
+ "use_ssl",
963
+ "from_email",
964
+ "is_enabled",
965
+ )
966
+ },
967
+ ),
968
+ )
969
+
970
+ @admin.display(description="Owner")
971
+ def owner_label(self, obj):
972
+ return obj.owner_display()
973
+
974
+ def get_urls(self):
975
+ urls = super().get_urls()
976
+ custom = [
977
+ path(
978
+ "<path:object_id>/test/",
979
+ self.admin_site.admin_view(self.test_outbox),
980
+ name="nodes_emailoutbox_test",
981
+ )
982
+ ]
983
+ return custom + urls
984
+
985
+ def test_outbox(self, request, object_id):
986
+ outbox = self.get_object(request, object_id)
987
+ if not outbox:
988
+ self.message_user(request, "Unknown outbox", messages.ERROR)
989
+ return redirect("..")
990
+ recipient = request.user.email or outbox.username
991
+ try:
992
+ outbox.send_mail(
993
+ "Test email",
994
+ "This is a test email.",
995
+ [recipient],
996
+ )
997
+ self.message_user(request, "Test email sent", messages.SUCCESS)
998
+ except Exception as exc: # pragma: no cover - admin feedback
999
+ self.message_user(request, str(exc), messages.ERROR)
1000
+ return redirect("..")
1001
+
1002
+ def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
1003
+ extra_context = extra_context or {}
1004
+ if object_id:
1005
+ extra_context["test_url"] = reverse(
1006
+ "admin:nodes_emailoutbox_test", args=[object_id]
1007
+ )
1008
+ return super().changeform_view(request, object_id, form_url, extra_context)
1009
+
1010
+
1011
+ class NodeRoleAdminForm(forms.ModelForm):
1012
+ nodes = forms.ModelMultipleChoiceField(
1013
+ queryset=Node.objects.all(),
1014
+ required=False,
1015
+ widget=FilteredSelectMultiple("Nodes", False),
1016
+ )
1017
+
1018
+ class Meta:
1019
+ model = NodeRole
1020
+ fields = ("name", "description", "nodes")
1021
+
1022
+ def __init__(self, *args, **kwargs):
1023
+ super().__init__(*args, **kwargs)
1024
+ if self.instance.pk:
1025
+ self.fields["nodes"].initial = self.instance.node_set.all()
1026
+
1027
+
1028
+ @admin.register(NodeRole)
1029
+ class NodeRoleAdmin(EntityModelAdmin):
1030
+ form = NodeRoleAdminForm
1031
+ list_display = ("name", "description", "registered", "default_features")
1032
+
1033
+ def get_queryset(self, request):
1034
+ qs = super().get_queryset(request)
1035
+ return qs.annotate(_registered=Count("node", distinct=True)).prefetch_related(
1036
+ "features"
1037
+ )
1038
+
1039
+ @admin.display(description="Registered", ordering="_registered")
1040
+ def registered(self, obj):
1041
+ return getattr(obj, "_registered", obj.node_set.count())
1042
+
1043
+ @admin.display(description="Default Features")
1044
+ def default_features(self, obj):
1045
+ features = [feature.display for feature in obj.features.all()]
1046
+ return ", ".join(features) if features else "—"
1047
+
1048
+ def save_model(self, request, obj, form, change):
1049
+ obj.node_set.set(form.cleaned_data.get("nodes", []))
1050
+
1051
+
1052
+ @admin.register(NodeFeature)
1053
+ class NodeFeatureAdmin(EntityModelAdmin):
1054
+ filter_horizontal = ("roles",)
1055
+ list_display = (
1056
+ "display",
1057
+ "slug",
1058
+ "default_roles",
1059
+ "is_enabled_display",
1060
+ "available_actions",
1061
+ )
1062
+ actions = ["check_features_for_eligibility", "enable_selected_features"]
1063
+ readonly_fields = ("is_enabled",)
1064
+ search_fields = ("display", "slug")
1065
+
1066
+ def get_queryset(self, request):
1067
+ qs = super().get_queryset(request)
1068
+ return qs.prefetch_related("roles")
1069
+
1070
+ @admin.display(description="Default Roles")
1071
+ def default_roles(self, obj):
1072
+ roles = [role.name for role in obj.roles.all()]
1073
+ return ", ".join(roles) if roles else "—"
1074
+
1075
+ @admin.display(description="Is Enabled", boolean=True, ordering="is_enabled")
1076
+ def is_enabled_display(self, obj):
1077
+ return obj.is_enabled
1078
+
1079
+ @admin.display(description="Actions")
1080
+ def available_actions(self, obj):
1081
+ if not obj.is_enabled:
1082
+ return "—"
1083
+ actions = obj.get_default_actions()
1084
+ if not actions:
1085
+ return ""
1086
+
1087
+ links = []
1088
+ for action in actions:
1089
+ try:
1090
+ url = reverse(action.url_name)
1091
+ except NoReverseMatch:
1092
+ links.append(action.label)
1093
+ else:
1094
+ links.append(format_html('<a href="{}">{}</a>', url, action.label))
1095
+
1096
+ if not links:
1097
+ return ""
1098
+ return format_html_join(" | ", "{}", ((link,) for link in links))
1099
+
1100
+ def _manual_enablement_message(self, feature, node):
1101
+ if node is None:
1102
+ return (
1103
+ "Manual enablement is unavailable without a registered local node."
1104
+ )
1105
+ if feature.slug in Node.MANUAL_FEATURE_SLUGS:
1106
+ return "This feature can be enabled manually."
1107
+ return "This feature cannot be enabled manually."
1108
+
1109
+ @admin.action(description="Check features for eligibility")
1110
+ def check_features_for_eligibility(self, request, queryset):
1111
+ from .feature_checks import feature_checks
1112
+
1113
+ features = list(queryset)
1114
+ total = len(features)
1115
+ successes = 0
1116
+ node = Node.get_local()
1117
+ for feature in features:
1118
+ enablement_message = self._manual_enablement_message(feature, node)
1119
+ try:
1120
+ result = feature_checks.run(feature, node=node)
1121
+ except Exception as exc: # pragma: no cover - defensive
1122
+ self.message_user(
1123
+ request,
1124
+ f"{feature.display}: {exc} {enablement_message}",
1125
+ level=messages.ERROR,
1126
+ )
1127
+ continue
1128
+ if result is None:
1129
+ self.message_user(
1130
+ request,
1131
+ f"No check is configured for {feature.display}. {enablement_message}",
1132
+ level=messages.WARNING,
1133
+ )
1134
+ continue
1135
+ message = result.message or (
1136
+ f"{feature.display} check {'passed' if result.success else 'failed'}."
1137
+ )
1138
+ self.message_user(
1139
+ request, f"{message} {enablement_message}", level=result.level
1140
+ )
1141
+ if result.success:
1142
+ successes += 1
1143
+ if total:
1144
+ self.message_user(
1145
+ request,
1146
+ f"Completed {successes} of {total} feature check(s) successfully.",
1147
+ level=messages.INFO,
1148
+ )
1149
+
1150
+ @admin.action(description="Enable selected action")
1151
+ def enable_selected_features(self, request, queryset):
1152
+ node = Node.get_local()
1153
+ if node is None:
1154
+ self.message_user(
1155
+ request,
1156
+ "No local node is registered; unable to enable features manually.",
1157
+ level=messages.ERROR,
1158
+ )
1159
+ return
1160
+
1161
+ manual_features = [
1162
+ feature
1163
+ for feature in queryset
1164
+ if feature.slug in Node.MANUAL_FEATURE_SLUGS
1165
+ ]
1166
+ non_manual_features = [
1167
+ feature
1168
+ for feature in queryset
1169
+ if feature.slug not in Node.MANUAL_FEATURE_SLUGS
1170
+ ]
1171
+ for feature in non_manual_features:
1172
+ self.message_user(
1173
+ request,
1174
+ f"{feature.display} cannot be enabled manually.",
1175
+ level=messages.WARNING,
1176
+ )
1177
+
1178
+ if not manual_features:
1179
+ self.message_user(
1180
+ request,
1181
+ "None of the selected features can be enabled manually.",
1182
+ level=messages.WARNING,
1183
+ )
1184
+ return
1185
+
1186
+ current_manual = set(
1187
+ node.features.filter(slug__in=Node.MANUAL_FEATURE_SLUGS).values_list(
1188
+ "slug", flat=True
1189
+ )
1190
+ )
1191
+ desired_manual = current_manual | {feature.slug for feature in manual_features}
1192
+ newly_enabled = desired_manual - current_manual
1193
+ if not newly_enabled:
1194
+ self.message_user(
1195
+ request,
1196
+ "Selected manual features are already enabled.",
1197
+ level=messages.INFO,
1198
+ )
1199
+ return
1200
+
1201
+ node.update_manual_features(desired_manual)
1202
+ display_map = {feature.slug: feature.display for feature in manual_features}
1203
+ newly_enabled_names = [display_map[slug] for slug in sorted(newly_enabled)]
1204
+ self.message_user(
1205
+ request,
1206
+ "Enabled {} feature(s): {}".format(
1207
+ len(newly_enabled), ", ".join(newly_enabled_names)
1208
+ ),
1209
+ level=messages.SUCCESS,
1210
+ )
1211
+
1212
+ def get_urls(self):
1213
+ urls = super().get_urls()
1214
+ custom = [
1215
+ path(
1216
+ "celery-report/",
1217
+ self.admin_site.admin_view(self.celery_report),
1218
+ name="nodes_nodefeature_celery_report",
1219
+ ),
1220
+ path(
1221
+ "take-screenshot/",
1222
+ self.admin_site.admin_view(self.take_screenshot),
1223
+ name="nodes_nodefeature_take_screenshot",
1224
+ ),
1225
+ path(
1226
+ "take-snapshot/",
1227
+ self.admin_site.admin_view(self.take_snapshot),
1228
+ name="nodes_nodefeature_take_snapshot",
1229
+ ),
1230
+ path(
1231
+ "view-stream/",
1232
+ self.admin_site.admin_view(self.view_stream),
1233
+ name="nodes_nodefeature_view_stream",
1234
+ ),
1235
+ ]
1236
+ return custom + urls
1237
+
1238
+ def celery_report(self, request):
1239
+ period = resolve_period(request.GET.get("period"))
1240
+ now = timezone.now()
1241
+ window_end = now + period.delta
1242
+ log_window_start = now - period.delta
1243
+
1244
+ scheduled_tasks = collect_scheduled_tasks(now, window_end)
1245
+ log_collection = collect_celery_log_entries(log_window_start, now)
1246
+
1247
+ period_options = [
1248
+ {
1249
+ "key": candidate.key,
1250
+ "label": candidate.label,
1251
+ "selected": candidate.key == period.key,
1252
+ "url": f"?period={candidate.key}",
1253
+ }
1254
+ for candidate in iter_report_periods()
1255
+ ]
1256
+
1257
+ context = {
1258
+ **self.admin_site.each_context(request),
1259
+ "title": _("Celery Report"),
1260
+ "period": period,
1261
+ "period_options": period_options,
1262
+ "current_time": now,
1263
+ "window_end": window_end,
1264
+ "log_window_start": log_window_start,
1265
+ "scheduled_tasks": scheduled_tasks,
1266
+ "log_entries": log_collection.entries,
1267
+ "log_sources": log_collection.checked_sources,
1268
+ }
1269
+ return TemplateResponse(
1270
+ request,
1271
+ "admin/nodes/nodefeature/celery_report.html",
1272
+ context,
1273
+ )
1274
+
1275
+ def _ensure_feature_enabled(self, request, slug: str, action_label: str):
1276
+ try:
1277
+ feature = NodeFeature.objects.get(slug=slug)
1278
+ except NodeFeature.DoesNotExist:
1279
+ self.message_user(
1280
+ request,
1281
+ f"{action_label} is unavailable because the feature is not configured.",
1282
+ level=messages.ERROR,
1283
+ )
1284
+ return None
1285
+ if not feature.is_enabled:
1286
+ self.message_user(
1287
+ request,
1288
+ f"{feature.display} feature is not enabled on this node.",
1289
+ level=messages.WARNING,
1290
+ )
1291
+ return None
1292
+ return feature
1293
+
1294
+ def take_screenshot(self, request):
1295
+ feature = self._ensure_feature_enabled(
1296
+ request, "screenshot-poll", "Take Screenshot"
1297
+ )
1298
+ if not feature:
1299
+ return redirect("..")
1300
+ url = request.build_absolute_uri("/")
1301
+ try:
1302
+ path = capture_screenshot(url)
1303
+ except Exception as exc: # pragma: no cover - depends on selenium setup
1304
+ self.message_user(request, str(exc), level=messages.ERROR)
1305
+ return redirect("..")
1306
+ node = Node.get_local()
1307
+ sample = save_screenshot(path, node=node, method="DEFAULT_ACTION")
1308
+ if not sample:
1309
+ self.message_user(
1310
+ request, "Duplicate screenshot; not saved", level=messages.INFO
1311
+ )
1312
+ return redirect("..")
1313
+ self.message_user(
1314
+ request, f"Screenshot saved to {sample.path}", level=messages.SUCCESS
1315
+ )
1316
+ try:
1317
+ change_url = reverse(
1318
+ "admin:nodes_contentsample_change", args=[sample.pk]
1319
+ )
1320
+ except NoReverseMatch: # pragma: no cover - admin URL always registered
1321
+ self.message_user(
1322
+ request,
1323
+ "Screenshot saved but the admin page could not be resolved.",
1324
+ level=messages.WARNING,
1325
+ )
1326
+ return redirect("..")
1327
+ return redirect(change_url)
1328
+
1329
+ def take_snapshot(self, request):
1330
+ feature = self._ensure_feature_enabled(
1331
+ request, "rpi-camera", "Take a Snapshot"
1332
+ )
1333
+ if not feature:
1334
+ return redirect("..")
1335
+ try:
1336
+ path = capture_rpi_snapshot()
1337
+ except Exception as exc: # pragma: no cover - depends on camera stack
1338
+ self.message_user(request, str(exc), level=messages.ERROR)
1339
+ return redirect("..")
1340
+ node = Node.get_local()
1341
+ sample = save_screenshot(path, node=node, method="RPI_CAMERA")
1342
+ if not sample:
1343
+ self.message_user(
1344
+ request, "Duplicate snapshot; not saved", level=messages.INFO
1345
+ )
1346
+ return redirect("..")
1347
+ self.message_user(
1348
+ request, f"Snapshot saved to {sample.path}", level=messages.SUCCESS
1349
+ )
1350
+ try:
1351
+ change_url = reverse(
1352
+ "admin:nodes_contentsample_change", args=[sample.pk]
1353
+ )
1354
+ except NoReverseMatch: # pragma: no cover - admin URL always registered
1355
+ self.message_user(
1356
+ request,
1357
+ "Snapshot saved but the admin page could not be resolved.",
1358
+ level=messages.WARNING,
1359
+ )
1360
+ return redirect("..")
1361
+ return redirect(change_url)
1362
+
1363
+ def view_stream(self, request):
1364
+ feature = self._ensure_feature_enabled(request, "rpi-camera", "View stream")
1365
+ if not feature:
1366
+ return redirect("..")
1367
+
1368
+ configured_stream = getattr(settings, "RPI_CAMERA_STREAM_URL", "").strip()
1369
+ if configured_stream:
1370
+ stream_url = configured_stream
1371
+ else:
1372
+ base_uri = request.build_absolute_uri("/")
1373
+ parsed = urlsplit(base_uri)
1374
+ hostname = parsed.hostname or "127.0.0.1"
1375
+ port = getattr(settings, "RPI_CAMERA_STREAM_PORT", 8554)
1376
+ scheme = getattr(settings, "RPI_CAMERA_STREAM_SCHEME", "http")
1377
+ netloc = f"{hostname}:{port}" if port else hostname
1378
+ stream_url = urlunsplit((scheme, netloc, "/", "", ""))
1379
+ parsed_stream = urlsplit(stream_url)
1380
+ path = (parsed_stream.path or "").lower()
1381
+ query = (parsed_stream.query or "").lower()
1382
+
1383
+ if parsed_stream.scheme in {"rtsp", "rtsps"}:
1384
+ embed_mode = "unsupported"
1385
+ elif any(path.endswith(ext) for ext in (".mjpg", ".mjpeg", ".jpeg", ".jpg", ".png")) or "action=stream" in query:
1386
+ embed_mode = "mjpeg"
1387
+ else:
1388
+ embed_mode = "iframe"
1389
+
1390
+ context = {
1391
+ **self.admin_site.each_context(request),
1392
+ "title": _("Raspberry Pi Camera Stream"),
1393
+ "stream_url": stream_url,
1394
+ "stream_embed": embed_mode,
1395
+ }
1396
+ return TemplateResponse(
1397
+ request,
1398
+ "admin/nodes/nodefeature/view_stream.html",
1399
+ context,
1400
+ )
1401
+
1402
+
1403
+ @admin.register(ContentTag)
1404
+ class ContentTagAdmin(EntityModelAdmin):
1405
+ list_display = ("label", "slug")
1406
+ search_fields = ("label", "slug")
1407
+
1408
+
1409
+ @admin.register(ContentClassifier)
1410
+ class ContentClassifierAdmin(EntityModelAdmin):
1411
+ list_display = ("label", "slug", "kind", "run_by_default", "active")
1412
+ list_filter = ("kind", "run_by_default", "active")
1413
+ search_fields = ("label", "slug", "entrypoint")
1414
+
1415
+
1416
+ class ContentClassificationInline(admin.TabularInline):
1417
+ model = ContentClassification
1418
+ extra = 0
1419
+ autocomplete_fields = ("classifier", "tag")
1420
+
1421
+
1422
+ @admin.register(ContentSample)
1423
+ class ContentSampleAdmin(EntityModelAdmin):
1424
+ list_display = ("name", "kind", "node", "user", "created_at")
1425
+ readonly_fields = ("created_at", "name", "user", "image_preview")
1426
+ inlines = (ContentClassificationInline,)
1427
+ list_filter = ("kind", "classifications__tag")
1428
+
1429
+ def get_urls(self):
1430
+ urls = super().get_urls()
1431
+ custom = [
1432
+ path(
1433
+ "from-clipboard/",
1434
+ self.admin_site.admin_view(self.add_from_clipboard),
1435
+ name="nodes_contentsample_from_clipboard",
1436
+ ),
1437
+ path(
1438
+ "capture/",
1439
+ self.admin_site.admin_view(self.capture_now),
1440
+ name="nodes_contentsample_capture",
1441
+ ),
1442
+ ]
1443
+ return custom + urls
1444
+
1445
+ def add_from_clipboard(self, request):
1446
+ try:
1447
+ content = pyperclip.paste()
1448
+ except PyperclipException as exc: # pragma: no cover - depends on OS clipboard
1449
+ self.message_user(request, f"Clipboard error: {exc}", level=messages.ERROR)
1450
+ return redirect("..")
1451
+ if not content:
1452
+ self.message_user(request, "Clipboard is empty.", level=messages.INFO)
1453
+ return redirect("..")
1454
+ if ContentSample.objects.filter(
1455
+ content=content, kind=ContentSample.TEXT
1456
+ ).exists():
1457
+ self.message_user(
1458
+ request, "Duplicate sample not created.", level=messages.INFO
1459
+ )
1460
+ return redirect("..")
1461
+ user = request.user if request.user.is_authenticated else None
1462
+ with suppress_default_classifiers():
1463
+ sample = ContentSample.objects.create(
1464
+ content=content, user=user, kind=ContentSample.TEXT
1465
+ )
1466
+ run_default_classifiers(sample)
1467
+ self.message_user(
1468
+ request, "Text sample added from clipboard.", level=messages.SUCCESS
1469
+ )
1470
+ return redirect("..")
1471
+
1472
+ def capture_now(self, request):
1473
+ node = Node.get_local()
1474
+ url = request.build_absolute_uri("/")
1475
+ try:
1476
+ path = capture_screenshot(url)
1477
+ except Exception as exc: # pragma: no cover - depends on selenium setup
1478
+ self.message_user(request, str(exc), level=messages.ERROR)
1479
+ return redirect("..")
1480
+ sample = save_screenshot(path, node=node, method="ADMIN")
1481
+ if sample:
1482
+ self.message_user(request, f"Screenshot saved to {path}", messages.SUCCESS)
1483
+ else:
1484
+ self.message_user(request, "Duplicate screenshot; not saved", messages.INFO)
1485
+ return redirect("..")
1486
+
1487
+ @admin.display(description="Screenshot")
1488
+ def image_preview(self, obj):
1489
+ if not obj or obj.kind != ContentSample.IMAGE or not obj.path:
1490
+ return ""
1491
+ file_path = Path(obj.path)
1492
+ if not file_path.is_absolute():
1493
+ file_path = settings.LOG_DIR / file_path
1494
+ if not file_path.exists():
1495
+ return "File not found"
1496
+ with file_path.open("rb") as f:
1497
+ encoded = base64.b64encode(f.read()).decode("ascii")
1498
+ return format_html(
1499
+ '<img src="data:image/png;base64,{}" style="max-width:100%;" />',
1500
+ encoded,
1501
+ )
1502
+
1503
+
1504
+ @admin.register(NetMessage)
1505
+ class NetMessageAdmin(EntityModelAdmin):
1506
+ class QuickSendForm(forms.ModelForm):
1507
+ class Meta:
1508
+ model = NetMessage
1509
+ fields = [
1510
+ "subject",
1511
+ "body",
1512
+ "attachments",
1513
+ "filter_node",
1514
+ "filter_node_feature",
1515
+ "filter_node_role",
1516
+ "filter_current_relation",
1517
+ "filter_installed_version",
1518
+ "filter_installed_revision",
1519
+ "target_limit",
1520
+ ]
1521
+ widgets = {"body": forms.Textarea(attrs={"rows": 4})}
1522
+
1523
+ class NetMessageAdminForm(forms.ModelForm):
1524
+ class Meta:
1525
+ model = NetMessage
1526
+ fields = "__all__"
1527
+ widgets = {"body": forms.Textarea(attrs={"rows": 4})}
1528
+
1529
+ change_list_template = "admin/nodes/netmessage/change_list.html"
1530
+ form = NetMessageAdminForm
1531
+ change_form_template = "admin/nodes/netmessage/change_form.html"
1532
+ list_display = (
1533
+ "subject",
1534
+ "body",
1535
+ "filter_node",
1536
+ "filter_node_role_display",
1537
+ "node_origin",
1538
+ "created",
1539
+ "target_limit_display",
1540
+ "complete",
1541
+ )
1542
+ search_fields = ("subject", "body")
1543
+ list_filter = ("complete", "filter_node_role", "filter_current_relation")
1544
+ ordering = ("-created",)
1545
+ readonly_fields = ("complete",)
1546
+ actions = ["send_messages"]
1547
+ fieldsets = (
1548
+ (None, {"fields": ("subject", "body")}),
1549
+ (
1550
+ "Filters",
1551
+ {
1552
+ "fields": (
1553
+ "filter_node",
1554
+ "filter_node_feature",
1555
+ "filter_node_role",
1556
+ "filter_current_relation",
1557
+ "filter_installed_version",
1558
+ "filter_installed_revision",
1559
+ )
1560
+ },
1561
+ ),
1562
+ ("Attachments", {"fields": ("attachments",)}),
1563
+ (
1564
+ "Propagation",
1565
+ {
1566
+ "fields": (
1567
+ "node_origin",
1568
+ "target_limit",
1569
+ "propagated_to",
1570
+ "complete",
1571
+ )
1572
+ },
1573
+ ),
1574
+ )
1575
+ quick_send_fieldsets = (
1576
+ (None, {"fields": ("subject", "body")}),
1577
+ (
1578
+ _("Filters"),
1579
+ {
1580
+ "fields": (
1581
+ "filter_node",
1582
+ "filter_node_feature",
1583
+ "filter_node_role",
1584
+ "filter_current_relation",
1585
+ "filter_installed_version",
1586
+ "filter_installed_revision",
1587
+ )
1588
+ },
1589
+ ),
1590
+ (
1591
+ _("Propagation"),
1592
+ {
1593
+ "fields": (
1594
+ "target_limit",
1595
+ )
1596
+ },
1597
+ ),
1598
+ )
1599
+
1600
+ def get_actions(self, request):
1601
+ actions = super().get_actions(request)
1602
+ if self.has_add_permission(request):
1603
+ action = getattr(self, "send", None)
1604
+ if action is not None and "send" not in actions:
1605
+ actions["send"] = (
1606
+ action,
1607
+ "send",
1608
+ getattr(action, "short_description", _("Send Net Message")),
1609
+ )
1610
+ return actions
1611
+
1612
+ def send(self, request, queryset=None):
1613
+ return redirect(
1614
+ reverse(
1615
+ f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_send"
1616
+ )
1617
+ )
1618
+
1619
+ send.label = _("Send Net Message")
1620
+ send.short_description = _("Send Net Message")
1621
+
1622
+ def get_urls(self):
1623
+ urls = super().get_urls()
1624
+ opts = self.model._meta
1625
+ custom_urls = [
1626
+ path(
1627
+ "send/",
1628
+ self.admin_site.admin_view(self.send_tool_view),
1629
+ name=f"{opts.app_label}_{opts.model_name}_send",
1630
+ )
1631
+ ]
1632
+ return custom_urls + urls
1633
+
1634
+ def send_tool_view(self, request):
1635
+ if not self.has_add_permission(request):
1636
+ raise PermissionDenied
1637
+
1638
+ form_class = self.QuickSendForm
1639
+ if request.method == "POST":
1640
+ form = form_class(request.POST)
1641
+ if form.is_valid():
1642
+ obj = form.save(commit=False)
1643
+ obj.pk = None
1644
+ previous_skip_flag = getattr(self, "_skip_entity_user_datum", False)
1645
+ self._skip_entity_user_datum = True
1646
+ try:
1647
+ self.save_model(request, obj, form, change=False)
1648
+ self.save_related(request, form, formsets=[], change=False)
1649
+ finally:
1650
+ self._skip_entity_user_datum = previous_skip_flag
1651
+ self.log_addition(
1652
+ request,
1653
+ obj,
1654
+ self.construct_change_message(request, form, None),
1655
+ )
1656
+ obj.propagate()
1657
+ self.message_user(
1658
+ request,
1659
+ _("Net Message sent to the network."),
1660
+ level=messages.SUCCESS,
1661
+ )
1662
+ changelist_url = reverse(
1663
+ f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_changelist"
1664
+ )
1665
+ return redirect(changelist_url)
1666
+ else:
1667
+ form = form_class()
1668
+
1669
+ admin_form = helpers.AdminForm(form, self.quick_send_fieldsets, {})
1670
+ context = {
1671
+ **self.admin_site.each_context(request),
1672
+ "opts": self.model._meta,
1673
+ "title": _("Send Net Message"),
1674
+ "adminform": admin_form,
1675
+ "media": self.media + form.media,
1676
+ }
1677
+ return TemplateResponse(
1678
+ request,
1679
+ "admin/nodes/netmessage/send.html",
1680
+ context,
1681
+ )
1682
+
1683
+ def get_changeform_initial_data(self, request):
1684
+ initial = super().get_changeform_initial_data(request)
1685
+ initial = dict(initial) if initial else {}
1686
+ reply_to = request.GET.get("reply_to")
1687
+ if reply_to:
1688
+ try:
1689
+ message = (
1690
+ NetMessage.objects.select_related("node_origin__role")
1691
+ .get(pk=reply_to)
1692
+ )
1693
+ except (NetMessage.DoesNotExist, ValueError, TypeError):
1694
+ message = None
1695
+ if message:
1696
+ subject = (message.subject or "").strip()
1697
+ if subject:
1698
+ if not subject.lower().startswith("re:"):
1699
+ subject = f"Re: {subject}"
1700
+ else:
1701
+ subject = "Re:"
1702
+ initial.setdefault("subject", subject[:64])
1703
+ if message.node_origin and "filter_node" not in initial:
1704
+ initial["filter_node"] = message.node_origin.pk
1705
+ return initial
1706
+
1707
+ def send_messages(self, request, queryset):
1708
+ for msg in queryset:
1709
+ msg.propagate()
1710
+ self.message_user(request, f"{queryset.count()} messages sent")
1711
+
1712
+ send_messages.short_description = "Send selected messages"
1713
+
1714
+ @admin.display(description="Role", ordering="filter_node_role")
1715
+ def filter_node_role_display(self, obj):
1716
+ return obj.filter_node_role
1717
+
1718
+ @admin.display(description="TL", ordering="target_limit")
1719
+ def target_limit_display(self, obj):
1720
+ return obj.target_limit or ""