arthexis 0.1.13__py3-none-any.whl → 0.1.15__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.
Files changed (108) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
  2. arthexis-0.1.15.dist-info/RECORD +110 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.15.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 +3795 -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 +149 -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 +114 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3637 -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 +840 -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 +952 -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 +2168 -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 +2201 -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 +1764 -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 +3830 -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 +769 -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 +209 -153
  99. pages/models.py +643 -426
  100. pages/tasks.py +74 -0
  101. pages/tests.py +3025 -2200
  102. pages/urls.py +26 -25
  103. pages/utils.py +23 -12
  104. pages/views.py +1176 -1128
  105. arthexis-0.1.13.dist-info/RECORD +0 -105
  106. nodes/actions.py +0 -70
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
  108. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
nodes/models.py CHANGED
@@ -1,1597 +1,1764 @@
1
- from __future__ import annotations
2
-
3
- from collections.abc import Iterable
4
- from dataclasses import dataclass
5
- from django.db import models
6
- from django.db.utils import DatabaseError
7
- from django.db.models.signals import post_delete
8
- from django.dispatch import Signal, receiver
9
- from core.entity import Entity
10
- from core.models import PackageRelease, Profile
11
- from core.fields import SigilLongAutoField, SigilShortAutoField
12
- import re
13
- import json
14
- import base64
15
- from django.utils import timezone
16
- from django.utils.text import slugify
17
- from django.conf import settings
18
- from django.contrib.sites.models import Site
19
- from datetime import timedelta
20
- import uuid
21
- import os
22
- import shutil
23
- import socket
24
- import stat
25
- import subprocess
26
- from pathlib import Path
27
- from utils import revision
28
- from core.notifications import notify_async
29
- from django.core.exceptions import ValidationError
30
- from cryptography.hazmat.primitives.asymmetric import rsa
31
- from cryptography.hazmat.primitives import serialization, hashes
32
- from cryptography.hazmat.primitives.asymmetric import padding
33
- from django.contrib.auth import get_user_model
34
- from django.core.mail import get_connection
35
- from core import mailer
36
- import logging
37
-
38
-
39
- logger = logging.getLogger(__name__)
40
-
41
-
42
- class NodeRoleManager(models.Manager):
43
- def get_by_natural_key(self, name: str):
44
- return self.get(name=name)
45
-
46
-
47
- class NodeRole(Entity):
48
- """Assignable role for a :class:`Node`."""
49
-
50
- name = models.CharField(max_length=50, unique=True)
51
- description = models.CharField(max_length=200, blank=True)
52
-
53
- objects = NodeRoleManager()
54
-
55
- class Meta:
56
- ordering = ["name"]
57
- verbose_name = "Node Role"
58
- verbose_name_plural = "Node Roles"
59
-
60
- def natural_key(self): # pragma: no cover - simple representation
61
- return (self.name,)
62
-
63
- def __str__(self) -> str: # pragma: no cover - simple representation
64
- return self.name
65
-
66
-
67
- class NodeFeatureManager(models.Manager):
68
- def get_by_natural_key(self, slug: str):
69
- return self.get(slug=slug)
70
-
71
-
72
- @dataclass(frozen=True)
73
- class NodeFeatureDefaultAction:
74
- label: str
75
- url_name: str
76
-
77
-
78
- class NodeFeature(Entity):
79
- """Feature that may be enabled on nodes and roles."""
80
-
81
- slug = models.SlugField(max_length=50, unique=True)
82
- display = models.CharField(max_length=50)
83
- roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
84
-
85
- objects = NodeFeatureManager()
86
-
87
- DEFAULT_ACTIONS: dict[str, tuple[NodeFeatureDefaultAction, ...]] = {
88
- "rfid-scanner": (
89
- NodeFeatureDefaultAction(
90
- label="Scan RFIDs", url_name="admin:core_rfid_scan"
91
- ),
92
- ),
93
- "celery-queue": (
94
- NodeFeatureDefaultAction(
95
- label="Celery Report",
96
- url_name="admin:nodes_nodefeature_celery_report",
97
- ),
98
- ),
99
- "screenshot-poll": (
100
- NodeFeatureDefaultAction(
101
- label="Take Screenshot",
102
- url_name="admin:nodes_nodefeature_take_screenshot",
103
- ),
104
- ),
105
- "rpi-camera": (
106
- NodeFeatureDefaultAction(
107
- label="Take a Snapshot",
108
- url_name="admin:nodes_nodefeature_take_snapshot",
109
- ),
110
- NodeFeatureDefaultAction(
111
- label="View stream",
112
- url_name="admin:nodes_nodefeature_view_stream",
113
- ),
114
- ),
115
- }
116
-
117
- class Meta:
118
- ordering = ["display"]
119
- verbose_name = "Node Feature"
120
- verbose_name_plural = "Node Features"
121
-
122
- def natural_key(self): # pragma: no cover - simple representation
123
- return (self.slug,)
124
-
125
- def __str__(self) -> str: # pragma: no cover - simple representation
126
- return self.display
127
-
128
- @property
129
- def is_enabled(self) -> bool:
130
- from django.conf import settings
131
- from pathlib import Path
132
-
133
- node = Node.get_local()
134
- if not node:
135
- return False
136
- if node.features.filter(pk=self.pk).exists():
137
- return True
138
- if self.slug == "gui-toast":
139
- from core.notifications import supports_gui_toast
140
-
141
- return supports_gui_toast()
142
- if self.slug == "rpi-camera":
143
- return Node._has_rpi_camera()
144
- lock_map = {
145
- "lcd-screen": "lcd_screen.lck",
146
- "rfid-scanner": "rfid.lck",
147
- "celery-queue": "celery.lck",
148
- "nginx-server": "nginx_mode.lck",
149
- }
150
- lock = lock_map.get(self.slug)
151
- if lock:
152
- base_path = Path(node.base_path or settings.BASE_DIR)
153
- return (base_path / "locks" / lock).exists()
154
- return False
155
-
156
- def get_default_actions(self) -> tuple[NodeFeatureDefaultAction, ...]:
157
- """Return the configured default actions for this feature."""
158
-
159
- actions = self.DEFAULT_ACTIONS.get(self.slug, ())
160
- if isinstance(actions, NodeFeatureDefaultAction): # pragma: no cover - legacy
161
- return (actions,)
162
- return actions
163
-
164
- def get_default_action(self) -> NodeFeatureDefaultAction | None:
165
- """Return the first configured default action for this feature if any."""
166
-
167
- actions = self.get_default_actions()
168
- return actions[0] if actions else None
169
-
170
-
171
- def get_terminal_role():
172
- """Return the NodeRole representing a Terminal if it exists."""
173
- return NodeRole.objects.filter(name="Terminal").first()
174
-
175
-
176
- class Node(Entity):
177
- """Information about a running node in the network."""
178
-
179
- DEFAULT_BADGE_COLOR = "#28a745"
180
- ROLE_BADGE_COLORS = {
181
- "Constellation": "#daa520", # goldenrod
182
- "Control": "#673ab7", # deep purple
183
- }
184
-
185
- class Relation(models.TextChoices):
186
- UPSTREAM = "UPSTREAM", "Upstream"
187
- DOWNSTREAM = "DOWNSTREAM", "Downstream"
188
- PEER = "PEER", "Peer"
189
- SELF = "SELF", "Self"
190
-
191
- hostname = models.CharField(max_length=100)
192
- address = models.GenericIPAddressField()
193
- mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
194
- port = models.PositiveIntegerField(default=8000)
195
- badge_color = models.CharField(max_length=7, default=DEFAULT_BADGE_COLOR)
196
- role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
197
- current_relation = models.CharField(
198
- max_length=10,
199
- choices=Relation.choices,
200
- default=Relation.PEER,
201
- )
202
- last_seen = models.DateTimeField(auto_now=True)
203
- enable_public_api = models.BooleanField(
204
- default=False,
205
- verbose_name="enable public API",
206
- )
207
- public_endpoint = models.SlugField(blank=True, unique=True)
208
- uuid = models.UUIDField(
209
- default=uuid.uuid4,
210
- unique=True,
211
- editable=False,
212
- verbose_name="UUID",
213
- )
214
- public_key = models.TextField(blank=True)
215
- base_path = models.CharField(max_length=255, blank=True)
216
- installed_version = models.CharField(max_length=20, blank=True)
217
- installed_revision = models.CharField(max_length=40, blank=True)
218
- features = models.ManyToManyField(
219
- NodeFeature,
220
- through="NodeFeatureAssignment",
221
- related_name="nodes",
222
- blank=True,
223
- )
224
-
225
- FEATURE_LOCK_MAP = {
226
- "lcd-screen": "lcd_screen.lck",
227
- "rfid-scanner": "rfid.lck",
228
- "celery-queue": "celery.lck",
229
- "nginx-server": "nginx_mode.lck",
230
- }
231
- RPI_CAMERA_DEVICE = Path("/dev/video0")
232
- RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
233
- AP_ROUTER_SSID = "gelectriic-ap"
234
- NMCLI_TIMEOUT = 5
235
- AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
236
- "gui-toast",
237
- "rpi-camera",
238
- "ap-router",
239
- "ap-public-wifi",
240
- }
241
- MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
242
-
243
- def __str__(self) -> str: # pragma: no cover - simple representation
244
- return f"{self.hostname}:{self.port}"
245
-
246
- @staticmethod
247
- def get_current_mac() -> str:
248
- """Return the MAC address of the current host."""
249
- return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
250
-
251
- @classmethod
252
- def normalize_relation(cls, value):
253
- """Normalize ``value`` to a valid :class:`Relation`."""
254
-
255
- if isinstance(value, cls.Relation):
256
- return value
257
- if value is None:
258
- return cls.Relation.PEER
259
- text = str(value).strip()
260
- if not text:
261
- return cls.Relation.PEER
262
- for relation in cls.Relation:
263
- if text.lower() == relation.label.lower():
264
- return relation
265
- if text.upper() == relation.name:
266
- return relation
267
- if text.lower() == relation.value.lower():
268
- return relation
269
- return cls.Relation.PEER
270
-
271
- @classmethod
272
- def get_local(cls):
273
- """Return the node representing the current host if it exists."""
274
- mac = cls.get_current_mac()
275
- try:
276
- return cls.objects.filter(mac_address=mac).first()
277
- except DatabaseError:
278
- logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
279
- return None
280
-
281
- @classmethod
282
- def register_current(cls):
283
- """Create or update the :class:`Node` entry for this host."""
284
- hostname = socket.gethostname()
285
- try:
286
- address = socket.gethostbyname(hostname)
287
- except OSError:
288
- address = "127.0.0.1"
289
- port = int(os.environ.get("PORT", 8000))
290
- base_path = str(settings.BASE_DIR)
291
- ver_path = Path(settings.BASE_DIR) / "VERSION"
292
- installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
293
- rev_value = revision.get_revision()
294
- installed_revision = rev_value if rev_value else ""
295
- mac = cls.get_current_mac()
296
- slug = slugify(hostname)
297
- node = cls.objects.filter(mac_address=mac).first()
298
- if not node:
299
- node = cls.objects.filter(public_endpoint=slug).first()
300
- defaults = {
301
- "hostname": hostname,
302
- "address": address,
303
- "port": port,
304
- "base_path": base_path,
305
- "installed_version": installed_version,
306
- "installed_revision": installed_revision,
307
- "public_endpoint": slug,
308
- "mac_address": mac,
309
- "current_relation": cls.Relation.SELF,
310
- }
311
- role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
312
- role_name = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
313
- desired_role = NodeRole.objects.filter(name=role_name).first()
314
-
315
- if node:
316
- update_fields = []
317
- for field, value in defaults.items():
318
- if getattr(node, field) != value:
319
- setattr(node, field, value)
320
- update_fields.append(field)
321
- if desired_role and node.role_id != desired_role.id:
322
- node.role = desired_role
323
- update_fields.append("role")
324
- if update_fields:
325
- node.save(update_fields=update_fields)
326
- created = False
327
- else:
328
- node = cls.objects.create(**defaults)
329
- created = True
330
- if desired_role:
331
- node.role = desired_role
332
- node.save(update_fields=["role"])
333
- if created and node.role is None:
334
- terminal = NodeRole.objects.filter(name="Terminal").first()
335
- if terminal:
336
- node.role = terminal
337
- node.save(update_fields=["role"])
338
- Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
339
- node.ensure_keys()
340
- node.notify_peers_of_update()
341
- return node, created
342
-
343
- def notify_peers_of_update(self):
344
- """Attempt to update this node's registration with known peers."""
345
-
346
- from secrets import token_hex
347
-
348
- try:
349
- import requests
350
- except Exception: # pragma: no cover - requests should be available
351
- return
352
-
353
- security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
354
- priv_path = security_dir / f"{self.public_endpoint}"
355
- if not priv_path.exists():
356
- logger.debug("Private key for %s not found; skipping peer update", self)
357
- return
358
- try:
359
- private_key = serialization.load_pem_private_key(
360
- priv_path.read_bytes(), password=None
361
- )
362
- except Exception as exc: # pragma: no cover - defensive
363
- logger.warning("Failed to load private key for %s: %s", self, exc)
364
- return
365
- token = token_hex(16)
366
- try:
367
- signature = private_key.sign(
368
- token.encode(),
369
- padding.PKCS1v15(),
370
- hashes.SHA256(),
371
- )
372
- except Exception as exc: # pragma: no cover - defensive
373
- logger.warning("Failed to sign peer update for %s: %s", self, exc)
374
- return
375
-
376
- payload = {
377
- "hostname": self.hostname,
378
- "address": self.address,
379
- "port": self.port,
380
- "mac_address": self.mac_address,
381
- "public_key": self.public_key,
382
- "token": token,
383
- "signature": base64.b64encode(signature).decode(),
384
- }
385
- if self.installed_version:
386
- payload["installed_version"] = self.installed_version
387
- if self.installed_revision:
388
- payload["installed_revision"] = self.installed_revision
389
-
390
- payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
391
- headers = {"Content-Type": "application/json"}
392
-
393
- peers = Node.objects.exclude(pk=self.pk)
394
- for peer in peers:
395
- host_candidates: list[str] = []
396
- if peer.address:
397
- host_candidates.append(peer.address)
398
- if peer.hostname and peer.hostname not in host_candidates:
399
- host_candidates.append(peer.hostname)
400
- port = peer.port or 8000
401
- urls: list[str] = []
402
- for host in host_candidates:
403
- host = host.strip()
404
- if not host:
405
- continue
406
- if ":" in host and not host.startswith("["):
407
- host = f"[{host}]"
408
- http_url = (
409
- f"http://{host}/nodes/register/"
410
- if port == 80
411
- else f"http://{host}:{port}/nodes/register/"
412
- )
413
- https_url = (
414
- f"https://{host}/nodes/register/"
415
- if port in {80, 443}
416
- else f"https://{host}:{port}/nodes/register/"
417
- )
418
- for url in (https_url, http_url):
419
- if url not in urls:
420
- urls.append(url)
421
- if not urls:
422
- continue
423
- for url in urls:
424
- try:
425
- response = requests.post(
426
- url, data=payload_json, headers=headers, timeout=2
427
- )
428
- except Exception as exc: # pragma: no cover - best effort
429
- logger.debug("Failed to update %s via %s: %s", peer, url, exc)
430
- continue
431
- if response.ok:
432
- version_display = _format_upgrade_body(
433
- self.installed_version,
434
- self.installed_revision,
435
- )
436
- version_suffix = f" ({version_display})" if version_display else ""
437
- logger.info(
438
- "Announced startup to %s%s",
439
- peer,
440
- version_suffix,
441
- )
442
- break
443
- else:
444
- logger.warning("Unable to notify node %s of startup", peer)
445
-
446
- def ensure_keys(self):
447
- security_dir = Path(settings.BASE_DIR) / "security"
448
- security_dir.mkdir(parents=True, exist_ok=True)
449
- priv_path = security_dir / f"{self.public_endpoint}"
450
- pub_path = security_dir / f"{self.public_endpoint}.pub"
451
- if not priv_path.exists() or not pub_path.exists():
452
- private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
453
- private_bytes = private_key.private_bytes(
454
- encoding=serialization.Encoding.PEM,
455
- format=serialization.PrivateFormat.TraditionalOpenSSL,
456
- encryption_algorithm=serialization.NoEncryption(),
457
- )
458
- public_bytes = private_key.public_key().public_bytes(
459
- encoding=serialization.Encoding.PEM,
460
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
461
- )
462
- priv_path.write_bytes(private_bytes)
463
- pub_path.write_bytes(public_bytes)
464
- self.public_key = public_bytes.decode()
465
- self.save(update_fields=["public_key"])
466
- elif not self.public_key:
467
- self.public_key = pub_path.read_text()
468
- self.save(update_fields=["public_key"])
469
-
470
- @property
471
- def is_local(self):
472
- """Determine if this node represents the current host."""
473
- return self.mac_address == self.get_current_mac()
474
-
475
- @classmethod
476
- def _generate_unique_public_endpoint(
477
- cls, value: str | None, *, exclude_pk: int | None = None
478
- ) -> str:
479
- """Return a unique public endpoint slug for ``value``."""
480
-
481
- field = cls._meta.get_field("public_endpoint")
482
- max_length = getattr(field, "max_length", None) or 50
483
- base_slug = slugify(value or "") or "node"
484
- if len(base_slug) > max_length:
485
- base_slug = base_slug[:max_length]
486
- slug = base_slug
487
- queryset = cls.objects.all()
488
- if exclude_pk is not None:
489
- queryset = queryset.exclude(pk=exclude_pk)
490
- counter = 2
491
- while queryset.filter(public_endpoint=slug).exists():
492
- suffix = f"-{counter}"
493
- available = max_length - len(suffix)
494
- if available <= 0:
495
- slug = suffix[-max_length:]
496
- else:
497
- slug = f"{base_slug[:available]}{suffix}"
498
- counter += 1
499
- return slug
500
-
501
- def save(self, *args, **kwargs):
502
- update_fields = kwargs.get("update_fields")
503
-
504
- def include_update_field(field: str):
505
- nonlocal update_fields
506
- if update_fields is None:
507
- return
508
- fields = set(update_fields)
509
- if field in fields:
510
- return
511
- fields.add(field)
512
- update_fields = tuple(fields)
513
- kwargs["update_fields"] = update_fields
514
-
515
- role_name = None
516
- role = getattr(self, "role", None)
517
- if role and getattr(role, "name", None):
518
- role_name = role.name
519
- elif self.role_id:
520
- role_name = (
521
- NodeRole.objects.filter(pk=self.role_id)
522
- .values_list("name", flat=True)
523
- .first()
524
- )
525
-
526
- role_color = self.ROLE_BADGE_COLORS.get(role_name)
527
- if role_color and (
528
- not self.badge_color or self.badge_color == self.DEFAULT_BADGE_COLOR
529
- ):
530
- self.badge_color = role_color
531
- include_update_field("badge_color")
532
-
533
- if self.mac_address:
534
- self.mac_address = self.mac_address.lower()
535
- endpoint_value = slugify(self.public_endpoint or "")
536
- if not endpoint_value:
537
- endpoint_value = self._generate_unique_public_endpoint(
538
- self.hostname, exclude_pk=self.pk
539
- )
540
- else:
541
- queryset = (
542
- self.__class__.objects.exclude(pk=self.pk)
543
- if self.pk
544
- else self.__class__.objects.all()
545
- )
546
- if queryset.filter(public_endpoint=endpoint_value).exists():
547
- endpoint_value = self._generate_unique_public_endpoint(
548
- self.hostname or endpoint_value, exclude_pk=self.pk
549
- )
550
- if self.public_endpoint != endpoint_value:
551
- self.public_endpoint = endpoint_value
552
- include_update_field("public_endpoint")
553
- super().save(*args, **kwargs)
554
- if self.pk:
555
- self.refresh_features()
556
-
557
- def has_feature(self, slug: str) -> bool:
558
- return self.features.filter(slug=slug).exists()
559
-
560
- @classmethod
561
- def _has_rpi_camera(cls) -> bool:
562
- """Return ``True`` when the Raspberry Pi camera stack is available."""
563
-
564
- device = cls.RPI_CAMERA_DEVICE
565
- if not device.exists():
566
- return False
567
- device_path = str(device)
568
- try:
569
- mode = os.stat(device_path).st_mode
570
- except OSError:
571
- return False
572
- if not stat.S_ISCHR(mode):
573
- return False
574
- if not os.access(device_path, os.R_OK | os.W_OK):
575
- return False
576
- for binary in cls.RPI_CAMERA_BINARIES:
577
- tool_path = shutil.which(binary)
578
- if not tool_path:
579
- return False
580
- try:
581
- result = subprocess.run(
582
- [tool_path, "--help"],
583
- capture_output=True,
584
- text=True,
585
- check=False,
586
- timeout=5,
587
- )
588
- except Exception:
589
- return False
590
- if result.returncode != 0:
591
- return False
592
- return True
593
-
594
- @classmethod
595
- def _hosts_gelectriic_ap(cls) -> bool:
596
- """Return ``True`` when the node is hosting the gelectriic access point."""
597
-
598
- nmcli_path = shutil.which("nmcli")
599
- if not nmcli_path:
600
- return False
601
- try:
602
- result = subprocess.run(
603
- [
604
- nmcli_path,
605
- "-t",
606
- "-f",
607
- "NAME,DEVICE,TYPE",
608
- "connection",
609
- "show",
610
- "--active",
611
- ],
612
- capture_output=True,
613
- text=True,
614
- check=False,
615
- timeout=cls.NMCLI_TIMEOUT,
616
- )
617
- except Exception:
618
- return False
619
- if result.returncode != 0:
620
- return False
621
- for line in result.stdout.splitlines():
622
- if not line:
623
- continue
624
- parts = line.split(":", 2)
625
- if not parts:
626
- continue
627
- name = parts[0]
628
- conn_type = ""
629
- if len(parts) == 3:
630
- conn_type = parts[2]
631
- elif len(parts) > 1:
632
- conn_type = parts[1]
633
- if name != cls.AP_ROUTER_SSID:
634
- continue
635
- conn_type_normalized = conn_type.strip().lower()
636
- if conn_type_normalized not in {"wifi", "802-11-wireless"}:
637
- continue
638
- try:
639
- mode_result = subprocess.run(
640
- [
641
- nmcli_path,
642
- "-g",
643
- "802-11-wireless.mode",
644
- "connection",
645
- "show",
646
- name,
647
- ],
648
- capture_output=True,
649
- text=True,
650
- check=False,
651
- timeout=cls.NMCLI_TIMEOUT,
652
- )
653
- except Exception:
654
- continue
655
- if mode_result.returncode != 0:
656
- continue
657
- if mode_result.stdout.strip() == "ap":
658
- return True
659
- return False
660
-
661
- def refresh_features(self):
662
- if not self.pk:
663
- return
664
- if not self.is_local:
665
- self.sync_feature_tasks()
666
- return
667
- detected_slugs = set()
668
- base_path = Path(self.base_path or settings.BASE_DIR)
669
- locks_dir = base_path / "locks"
670
- for slug, filename in self.FEATURE_LOCK_MAP.items():
671
- if (locks_dir / filename).exists():
672
- detected_slugs.add(slug)
673
- if self._has_rpi_camera():
674
- detected_slugs.add("rpi-camera")
675
- public_mode_lock = locks_dir / "public_wifi_mode.lck"
676
- if self._hosts_gelectriic_ap():
677
- if public_mode_lock.exists():
678
- detected_slugs.add("ap-public-wifi")
679
- else:
680
- detected_slugs.add("ap-router")
681
- try:
682
- from core.notifications import supports_gui_toast
683
- except Exception:
684
- pass
685
- else:
686
- try:
687
- if supports_gui_toast():
688
- detected_slugs.add("gui-toast")
689
- except Exception:
690
- pass
691
- current_slugs = set(
692
- self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
693
- "slug", flat=True
694
- )
695
- )
696
- add_slugs = detected_slugs - current_slugs
697
- if add_slugs:
698
- for feature in NodeFeature.objects.filter(slug__in=add_slugs):
699
- NodeFeatureAssignment.objects.update_or_create(
700
- node=self, feature=feature
701
- )
702
- remove_slugs = current_slugs - detected_slugs
703
- if remove_slugs:
704
- NodeFeatureAssignment.objects.filter(
705
- node=self, feature__slug__in=remove_slugs
706
- ).delete()
707
- self.sync_feature_tasks()
708
-
709
- def update_manual_features(self, slugs: Iterable[str]):
710
- desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
711
- remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
712
- if remove_slugs:
713
- NodeFeatureAssignment.objects.filter(
714
- node=self, feature__slug__in=remove_slugs
715
- ).delete()
716
- if desired:
717
- for feature in NodeFeature.objects.filter(slug__in=desired):
718
- NodeFeatureAssignment.objects.update_or_create(
719
- node=self, feature=feature
720
- )
721
- self.sync_feature_tasks()
722
-
723
- def sync_feature_tasks(self):
724
- clipboard_enabled = self.has_feature("clipboard-poll")
725
- screenshot_enabled = self.has_feature("screenshot-poll")
726
- self._sync_clipboard_task(clipboard_enabled)
727
- self._sync_screenshot_task(screenshot_enabled)
728
-
729
- def _sync_clipboard_task(self, enabled: bool):
730
- from django_celery_beat.models import IntervalSchedule, PeriodicTask
731
-
732
- task_name = f"poll_clipboard_node_{self.pk}"
733
- if enabled:
734
- schedule, _ = IntervalSchedule.objects.get_or_create(
735
- every=5, period=IntervalSchedule.SECONDS
736
- )
737
- PeriodicTask.objects.update_or_create(
738
- name=task_name,
739
- defaults={
740
- "interval": schedule,
741
- "task": "nodes.tasks.sample_clipboard",
742
- },
743
- )
744
- else:
745
- PeriodicTask.objects.filter(name=task_name).delete()
746
-
747
- def _sync_screenshot_task(self, enabled: bool):
748
- from django_celery_beat.models import IntervalSchedule, PeriodicTask
749
- import json
750
-
751
- task_name = f"capture_screenshot_node_{self.pk}"
752
- if enabled:
753
- schedule, _ = IntervalSchedule.objects.get_or_create(
754
- every=1, period=IntervalSchedule.MINUTES
755
- )
756
- PeriodicTask.objects.update_or_create(
757
- name=task_name,
758
- defaults={
759
- "interval": schedule,
760
- "task": "nodes.tasks.capture_node_screenshot",
761
- "kwargs": json.dumps(
762
- {
763
- "url": f"http://localhost:{self.port}",
764
- "port": self.port,
765
- "method": "AUTO",
766
- }
767
- ),
768
- },
769
- )
770
- else:
771
- PeriodicTask.objects.filter(name=task_name).delete()
772
-
773
- def send_mail(
774
- self,
775
- subject: str,
776
- message: str,
777
- recipient_list: list[str],
778
- from_email: str | None = None,
779
- **kwargs,
780
- ):
781
- """Send an email using this node's configured outbox if available."""
782
- outbox = getattr(self, "email_outbox", None)
783
- logger.info(
784
- "Node %s queueing email to %s using %s backend",
785
- self.pk,
786
- recipient_list,
787
- "outbox" if outbox else "default",
788
- )
789
- return mailer.send(
790
- subject,
791
- message,
792
- recipient_list,
793
- from_email,
794
- outbox=outbox,
795
- **kwargs,
796
- )
797
-
798
-
799
- node_information_updated = Signal()
800
-
801
-
802
- def _format_upgrade_body(version: str, revision: str) -> str:
803
- version = (version or "").strip()
804
- revision = (revision or "").strip()
805
- parts: list[str] = []
806
- if version:
807
- normalized = version.lstrip("vV") or version
808
- base_version = normalized.rstrip("+")
809
- display_version = normalized
810
- if (
811
- base_version
812
- and revision
813
- and not PackageRelease.matches_revision(base_version, revision)
814
- and not normalized.endswith("+")
815
- ):
816
- display_version = f"{display_version}+"
817
- parts.append(f"v{display_version}")
818
- if revision:
819
- rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
820
- rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
821
- parts.append(f"r{rev_short}")
822
- return " ".join(parts).strip()
823
-
824
-
825
- @receiver(node_information_updated)
826
- def _announce_peer_startup(
827
- sender,
828
- *,
829
- node: "Node",
830
- previous_version: str = "",
831
- previous_revision: str = "",
832
- current_version: str = "",
833
- current_revision: str = "",
834
- **_: object,
835
- ) -> None:
836
- current_version = (current_version or "").strip()
837
- current_revision = (current_revision or "").strip()
838
- previous_version = (previous_version or "").strip()
839
- previous_revision = (previous_revision or "").strip()
840
-
841
- local = Node.get_local()
842
- if local and node.pk == local.pk:
843
- return
844
-
845
- body = _format_upgrade_body(current_version, current_revision)
846
- if not body:
847
- body = "Online"
848
-
849
- hostname = (node.hostname or "Node").strip() or "Node"
850
- subject = f"UP {hostname}"
851
- notify_async(subject, body)
852
-
853
-
854
- class NodeFeatureAssignment(Entity):
855
- """Bridge between :class:`Node` and :class:`NodeFeature`."""
856
-
857
- node = models.ForeignKey(
858
- Node, on_delete=models.CASCADE, related_name="feature_assignments"
859
- )
860
- feature = models.ForeignKey(
861
- NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
862
- )
863
- created_at = models.DateTimeField(auto_now_add=True)
864
-
865
- class Meta:
866
- unique_together = ("node", "feature")
867
- verbose_name = "Node Feature Assignment"
868
- verbose_name_plural = "Node Feature Assignments"
869
-
870
- def __str__(self) -> str: # pragma: no cover - simple representation
871
- return f"{self.node} -> {self.feature}"
872
-
873
- def save(self, *args, **kwargs):
874
- super().save(*args, **kwargs)
875
- self.node.sync_feature_tasks()
876
-
877
-
878
- @receiver(post_delete, sender=NodeFeatureAssignment)
879
- def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
880
- node_id = getattr(instance, "node_id", None)
881
- if not node_id:
882
- return
883
- node = Node.objects.filter(pk=node_id).first()
884
- if node:
885
- node.sync_feature_tasks()
886
-
887
-
888
- class NodeManager(Profile):
889
- """Credentials for interacting with external DNS providers."""
890
-
891
- class Provider(models.TextChoices):
892
- GODADDY = "godaddy", "GoDaddy"
893
-
894
- profile_fields = (
895
- "provider",
896
- "api_key",
897
- "api_secret",
898
- "customer_id",
899
- "default_domain",
900
- )
901
-
902
- provider = models.CharField(
903
- max_length=20,
904
- choices=Provider.choices,
905
- default=Provider.GODADDY,
906
- )
907
- api_key = SigilShortAutoField(
908
- max_length=255,
909
- help_text="API key issued by the DNS provider.",
910
- )
911
- api_secret = SigilShortAutoField(
912
- max_length=255,
913
- help_text="API secret issued by the DNS provider.",
914
- )
915
- customer_id = SigilShortAutoField(
916
- max_length=100,
917
- blank=True,
918
- help_text="Optional GoDaddy customer identifier for the account.",
919
- )
920
- default_domain = SigilShortAutoField(
921
- max_length=253,
922
- blank=True,
923
- help_text="Fallback domain when records omit one.",
924
- )
925
- use_sandbox = models.BooleanField(
926
- default=False,
927
- help_text="Use the GoDaddy OTE (test) environment.",
928
- )
929
- is_enabled = models.BooleanField(
930
- default=True,
931
- help_text="Disable to prevent deployments with this manager.",
932
- )
933
-
934
- class Meta:
935
- verbose_name = "Node Manager"
936
- verbose_name_plural = "Node Managers"
937
-
938
- def __str__(self) -> str:
939
- owner = self.owner_display()
940
- provider = self.get_provider_display()
941
- if owner:
942
- return f"{provider} ({owner})"
943
- return provider
944
-
945
- def clean(self):
946
- if self.user_id or self.group_id:
947
- super().clean()
948
- else:
949
- super(Profile, self).clean()
950
-
951
- def get_base_url(self) -> str:
952
- if self.provider != self.Provider.GODADDY:
953
- raise ValueError("Unsupported DNS provider")
954
- if self.use_sandbox:
955
- return "https://api.ote-godaddy.com"
956
- return "https://api.godaddy.com"
957
-
958
- def get_auth_header(self) -> str:
959
- key = (self.resolve_sigils("api_key") or "").strip()
960
- secret = (self.resolve_sigils("api_secret") or "").strip()
961
- if not key or not secret:
962
- raise ValueError("API credentials are required for DNS deployment")
963
- return f"sso-key {key}:{secret}"
964
-
965
- def get_customer_id(self) -> str:
966
- return (self.resolve_sigils("customer_id") or "").strip()
967
-
968
- def get_default_domain(self) -> str:
969
- return (self.resolve_sigils("default_domain") or "").strip()
970
-
971
- def publish_dns_records(self, records: Iterable["DNSRecord"]):
972
- from . import dns as dns_utils
973
-
974
- return dns_utils.deploy_records(self, records)
975
-
976
-
977
- class DNSRecord(Entity):
978
- """Stored DNS configuration ready for deployment."""
979
-
980
- class Type(models.TextChoices):
981
- A = "A", "A"
982
- AAAA = "AAAA", "AAAA"
983
- CNAME = "CNAME", "CNAME"
984
- MX = "MX", "MX"
985
- NS = "NS", "NS"
986
- SRV = "SRV", "SRV"
987
- TXT = "TXT", "TXT"
988
-
989
- class Provider(models.TextChoices):
990
- GODADDY = "godaddy", "GoDaddy"
991
-
992
- provider = models.CharField(
993
- max_length=20,
994
- choices=Provider.choices,
995
- default=Provider.GODADDY,
996
- )
997
- node_manager = models.ForeignKey(
998
- "NodeManager",
999
- on_delete=models.SET_NULL,
1000
- null=True,
1001
- blank=True,
1002
- related_name="dns_records",
1003
- )
1004
- domain = SigilShortAutoField(
1005
- max_length=253,
1006
- help_text="Base domain such as example.com.",
1007
- )
1008
- name = SigilShortAutoField(
1009
- max_length=253,
1010
- help_text="Record host. Use @ for the zone apex.",
1011
- )
1012
- record_type = models.CharField(
1013
- max_length=10,
1014
- choices=Type.choices,
1015
- default=Type.A,
1016
- verbose_name="Type",
1017
- )
1018
- data = SigilLongAutoField(
1019
- help_text="Record value such as an IP address or hostname.",
1020
- )
1021
- ttl = models.PositiveIntegerField(
1022
- default=600,
1023
- help_text="Time to live in seconds.",
1024
- )
1025
- priority = models.PositiveIntegerField(
1026
- null=True,
1027
- blank=True,
1028
- help_text="Priority for MX and SRV records.",
1029
- )
1030
- port = models.PositiveIntegerField(
1031
- null=True,
1032
- blank=True,
1033
- help_text="Port for SRV records.",
1034
- )
1035
- weight = models.PositiveIntegerField(
1036
- null=True,
1037
- blank=True,
1038
- help_text="Weight for SRV records.",
1039
- )
1040
- service = SigilShortAutoField(
1041
- max_length=50,
1042
- blank=True,
1043
- help_text="Service label for SRV records (for example _sip).",
1044
- )
1045
- protocol = SigilShortAutoField(
1046
- max_length=10,
1047
- blank=True,
1048
- help_text="Protocol label for SRV records (for example _tcp).",
1049
- )
1050
- last_synced_at = models.DateTimeField(null=True, blank=True)
1051
- last_verified_at = models.DateTimeField(null=True, blank=True)
1052
- last_error = models.TextField(blank=True)
1053
-
1054
- class Meta:
1055
- verbose_name = "DNS Record"
1056
- verbose_name_plural = "DNS Records"
1057
-
1058
- def __str__(self) -> str:
1059
- return f"{self.record_type} {self.fqdn()}"
1060
-
1061
- def get_domain(self, manager: "NodeManager" | None = None) -> str:
1062
- domain = (self.resolve_sigils("domain") or "").strip()
1063
- if domain:
1064
- return domain.rstrip(".")
1065
- if manager:
1066
- fallback = manager.get_default_domain()
1067
- if fallback:
1068
- return fallback.rstrip(".")
1069
- return ""
1070
-
1071
- def get_name(self) -> str:
1072
- name = (self.resolve_sigils("name") or "").strip()
1073
- return name or "@"
1074
-
1075
- def fqdn(self, manager: "NodeManager" | None = None) -> str:
1076
- domain = self.get_domain(manager)
1077
- name = self.get_name()
1078
- if name in {"@", ""}:
1079
- return domain
1080
- if name.endswith("."):
1081
- return name.rstrip(".")
1082
- if domain:
1083
- return f"{name}.{domain}".rstrip(".")
1084
- return name.rstrip(".")
1085
-
1086
- def to_godaddy_payload(self) -> dict[str, object]:
1087
- payload: dict[str, object] = {
1088
- "data": (self.resolve_sigils("data") or "").strip(),
1089
- "ttl": self.ttl,
1090
- }
1091
- if self.priority is not None:
1092
- payload["priority"] = self.priority
1093
- if self.port is not None:
1094
- payload["port"] = self.port
1095
- if self.weight is not None:
1096
- payload["weight"] = self.weight
1097
- service = (self.resolve_sigils("service") or "").strip()
1098
- if service:
1099
- payload["service"] = service
1100
- protocol = (self.resolve_sigils("protocol") or "").strip()
1101
- if protocol:
1102
- payload["protocol"] = protocol
1103
- return payload
1104
-
1105
- def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
1106
- if timestamp is None:
1107
- timestamp = timezone.now()
1108
- update_fields = ["last_synced_at", "last_error"]
1109
- self.last_synced_at = timestamp
1110
- self.last_error = ""
1111
- if manager and self.node_manager_id != getattr(manager, "pk", None):
1112
- self.node_manager = manager
1113
- update_fields.append("node_manager")
1114
- self.save(update_fields=update_fields)
1115
-
1116
- def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
1117
- update_fields = ["last_error"]
1118
- self.last_error = message
1119
- if manager and self.node_manager_id != getattr(manager, "pk", None):
1120
- self.node_manager = manager
1121
- update_fields.append("node_manager")
1122
- self.save(update_fields=update_fields)
1123
-
1124
-
1125
- class EmailOutbox(Profile):
1126
- """SMTP credentials for sending mail."""
1127
-
1128
- profile_fields = (
1129
- "host",
1130
- "port",
1131
- "username",
1132
- "password",
1133
- "use_tls",
1134
- "use_ssl",
1135
- "from_email",
1136
- )
1137
-
1138
- node = models.OneToOneField(
1139
- Node,
1140
- on_delete=models.CASCADE,
1141
- related_name="email_outbox",
1142
- null=True,
1143
- blank=True,
1144
- )
1145
- host = SigilShortAutoField(
1146
- max_length=100,
1147
- help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
1148
- )
1149
- port = models.PositiveIntegerField(
1150
- default=587,
1151
- help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
1152
- )
1153
- username = SigilShortAutoField(
1154
- max_length=100,
1155
- blank=True,
1156
- help_text="Full email address for Gmail or GoDaddy",
1157
- )
1158
- password = SigilShortAutoField(
1159
- max_length=100,
1160
- blank=True,
1161
- help_text="Email account password or app password",
1162
- )
1163
- use_tls = models.BooleanField(
1164
- default=True,
1165
- help_text="Check for Gmail or GoDaddy on port 587",
1166
- )
1167
- use_ssl = models.BooleanField(
1168
- default=False,
1169
- help_text="Check for GoDaddy on port 465; Gmail does not use SSL",
1170
- )
1171
- from_email = SigilShortAutoField(
1172
- blank=True,
1173
- verbose_name="From Email",
1174
- max_length=254,
1175
- help_text="Default From address; usually the same as username",
1176
- )
1177
- is_enabled = models.BooleanField(
1178
- default=True,
1179
- help_text="Disable to remove this outbox from automatic selection.",
1180
- )
1181
-
1182
- class Meta:
1183
- verbose_name = "Email Outbox"
1184
- verbose_name_plural = "Email Outboxes"
1185
-
1186
- def __str__(self) -> str:
1187
- address = (self.from_email or "").strip()
1188
- if address:
1189
- return address
1190
-
1191
- username = (self.username or "").strip()
1192
- host = (self.host or "").strip()
1193
- if username:
1194
- local, sep, domain = username.partition("@")
1195
- if sep and domain:
1196
- return username
1197
- if host:
1198
- sanitized = username.rstrip("@")
1199
- if sanitized:
1200
- return f"{sanitized}@{host}"
1201
- return host
1202
- return username
1203
- if host:
1204
- return host
1205
-
1206
- owner = self.owner_display()
1207
- if owner:
1208
- return owner
1209
-
1210
- return super().__str__()
1211
-
1212
- def clean(self):
1213
- if self.user_id or self.group_id:
1214
- super().clean()
1215
- else:
1216
- super(Profile, self).clean()
1217
-
1218
- def get_connection(self):
1219
- return get_connection(
1220
- "django.core.mail.backends.smtp.EmailBackend",
1221
- host=self.host,
1222
- port=self.port,
1223
- username=self.username or None,
1224
- password=self.password or None,
1225
- use_tls=self.use_tls,
1226
- use_ssl=self.use_ssl,
1227
- )
1228
-
1229
- def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
1230
- from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
1231
- logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
1232
- return mailer.send(
1233
- subject,
1234
- message,
1235
- recipient_list,
1236
- from_email,
1237
- outbox=self,
1238
- **kwargs,
1239
- )
1240
-
1241
- def owner_display(self):
1242
- owner = super().owner_display()
1243
- if owner:
1244
- return owner
1245
- return str(self.node) if self.node_id else ""
1246
-
1247
-
1248
- class NetMessage(Entity):
1249
- """Message propagated across nodes."""
1250
-
1251
- uuid = models.UUIDField(
1252
- default=uuid.uuid4,
1253
- unique=True,
1254
- editable=False,
1255
- verbose_name="UUID",
1256
- )
1257
- node_origin = models.ForeignKey(
1258
- "Node",
1259
- on_delete=models.SET_NULL,
1260
- null=True,
1261
- blank=True,
1262
- related_name="originated_net_messages",
1263
- )
1264
- subject = models.CharField(max_length=64, blank=True)
1265
- body = models.CharField(max_length=256, blank=True)
1266
- filter_node = models.ForeignKey(
1267
- "Node",
1268
- on_delete=models.SET_NULL,
1269
- null=True,
1270
- blank=True,
1271
- related_name="filtered_net_messages",
1272
- verbose_name="Node",
1273
- )
1274
- filter_node_feature = models.ForeignKey(
1275
- "NodeFeature",
1276
- on_delete=models.SET_NULL,
1277
- null=True,
1278
- blank=True,
1279
- verbose_name="Node feature",
1280
- )
1281
- filter_node_role = models.ForeignKey(
1282
- NodeRole,
1283
- on_delete=models.SET_NULL,
1284
- null=True,
1285
- blank=True,
1286
- related_name="filtered_net_messages",
1287
- verbose_name="Node role",
1288
- )
1289
- filter_current_relation = models.CharField(
1290
- max_length=10,
1291
- blank=True,
1292
- choices=Node.Relation.choices,
1293
- verbose_name="Current relation",
1294
- )
1295
- filter_installed_version = models.CharField(
1296
- max_length=20,
1297
- blank=True,
1298
- verbose_name="Installed version",
1299
- )
1300
- filter_installed_revision = models.CharField(
1301
- max_length=40,
1302
- blank=True,
1303
- verbose_name="Installed revision",
1304
- )
1305
- reach = models.ForeignKey(
1306
- NodeRole,
1307
- on_delete=models.SET_NULL,
1308
- null=True,
1309
- blank=True,
1310
- )
1311
- target_limit = models.PositiveSmallIntegerField(
1312
- default=6,
1313
- blank=True,
1314
- null=True,
1315
- help_text="Maximum number of peers to contact when propagating.",
1316
- )
1317
- propagated_to = models.ManyToManyField(
1318
- Node, blank=True, related_name="received_net_messages"
1319
- )
1320
- created = models.DateTimeField(auto_now_add=True)
1321
- complete = models.BooleanField(default=False, editable=False)
1322
-
1323
- class Meta:
1324
- ordering = ["-created"]
1325
- verbose_name = "Net Message"
1326
- verbose_name_plural = "Net Messages"
1327
-
1328
- @classmethod
1329
- def broadcast(
1330
- cls,
1331
- subject: str,
1332
- body: str,
1333
- reach: NodeRole | str | None = None,
1334
- seen: list[str] | None = None,
1335
- ):
1336
- role = None
1337
- if reach:
1338
- if isinstance(reach, NodeRole):
1339
- role = reach
1340
- else:
1341
- role = NodeRole.objects.filter(name=reach).first()
1342
- origin = Node.get_local()
1343
- msg = cls.objects.create(
1344
- subject=subject[:64],
1345
- body=body[:256],
1346
- reach=role,
1347
- node_origin=origin,
1348
- )
1349
- msg.propagate(seen=seen or [])
1350
- return msg
1351
-
1352
- def propagate(self, seen: list[str] | None = None):
1353
- from core.notifications import notify
1354
- import random
1355
- import requests
1356
-
1357
- displayed = notify(self.subject, self.body)
1358
- local = Node.get_local()
1359
- if displayed:
1360
- cutoff = timezone.now() - timedelta(days=7)
1361
- prune_qs = type(self).objects.filter(created__lt=cutoff)
1362
- if local:
1363
- prune_qs = prune_qs.filter(
1364
- models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
1365
- )
1366
- else:
1367
- prune_qs = prune_qs.filter(node_origin__isnull=True)
1368
- if self.pk:
1369
- prune_qs = prune_qs.exclude(pk=self.pk)
1370
- prune_qs.delete()
1371
- if local and not self.node_origin_id:
1372
- self.node_origin = local
1373
- self.save(update_fields=["node_origin"])
1374
- origin_uuid = None
1375
- if self.node_origin_id:
1376
- origin_uuid = str(self.node_origin.uuid)
1377
- elif local:
1378
- origin_uuid = str(local.uuid)
1379
- private_key = None
1380
- seen = list(seen or [])
1381
- local_id = None
1382
- if local:
1383
- local_id = str(local.uuid)
1384
- if local_id not in seen:
1385
- seen.append(local_id)
1386
- priv_path = (
1387
- Path(local.base_path or settings.BASE_DIR)
1388
- / "security"
1389
- / f"{local.public_endpoint}"
1390
- )
1391
- try:
1392
- private_key = serialization.load_pem_private_key(
1393
- priv_path.read_bytes(), password=None
1394
- )
1395
- except Exception:
1396
- private_key = None
1397
- for node_id in seen:
1398
- node = Node.objects.filter(uuid=node_id).first()
1399
- if node and (not local or node.pk != local.pk):
1400
- self.propagated_to.add(node)
1401
-
1402
- filtered_nodes = Node.objects.all()
1403
- if self.filter_node_id:
1404
- filtered_nodes = filtered_nodes.filter(pk=self.filter_node_id)
1405
- if self.filter_node_feature_id:
1406
- filtered_nodes = filtered_nodes.filter(
1407
- features__pk=self.filter_node_feature_id
1408
- )
1409
- if self.filter_node_role_id:
1410
- filtered_nodes = filtered_nodes.filter(role_id=self.filter_node_role_id)
1411
- if self.filter_current_relation:
1412
- filtered_nodes = filtered_nodes.filter(
1413
- current_relation=self.filter_current_relation
1414
- )
1415
- if self.filter_installed_version:
1416
- filtered_nodes = filtered_nodes.filter(
1417
- installed_version=self.filter_installed_version
1418
- )
1419
- if self.filter_installed_revision:
1420
- filtered_nodes = filtered_nodes.filter(
1421
- installed_revision=self.filter_installed_revision
1422
- )
1423
-
1424
- filtered_nodes = filtered_nodes.distinct()
1425
-
1426
- if local:
1427
- filtered_nodes = filtered_nodes.exclude(pk=local.pk)
1428
- total_known = filtered_nodes.count()
1429
-
1430
- remaining = list(
1431
- filtered_nodes.exclude(
1432
- pk__in=self.propagated_to.values_list("pk", flat=True)
1433
- )
1434
- )
1435
- if not remaining:
1436
- self.complete = True
1437
- self.save(update_fields=["complete"])
1438
- return
1439
-
1440
- limit = self.target_limit or 6
1441
- target_limit = min(limit, len(remaining))
1442
-
1443
- reach_source = self.filter_node_role or self.reach
1444
- reach_name = reach_source.name if reach_source else None
1445
- role_map = {
1446
- "Terminal": ["Terminal"],
1447
- "Control": ["Control", "Terminal"],
1448
- "Satellite": ["Satellite", "Control", "Terminal"],
1449
- "Constellation": [
1450
- "Constellation",
1451
- "Satellite",
1452
- "Control",
1453
- "Terminal",
1454
- ],
1455
- }
1456
- selected: list[Node] = []
1457
- if self.filter_node_id:
1458
- target = next((n for n in remaining if n.pk == self.filter_node_id), None)
1459
- if target:
1460
- selected = [target]
1461
- else:
1462
- self.complete = True
1463
- self.save(update_fields=["complete"])
1464
- return
1465
- else:
1466
- if self.filter_node_role_id:
1467
- role_order = [reach_name]
1468
- else:
1469
- role_order = role_map.get(reach_name, [None])
1470
- for role_name in role_order:
1471
- if role_name is None:
1472
- role_nodes = remaining[:]
1473
- else:
1474
- role_nodes = [
1475
- n for n in remaining if n.role and n.role.name == role_name
1476
- ]
1477
- random.shuffle(role_nodes)
1478
- for n in role_nodes:
1479
- selected.append(n)
1480
- remaining.remove(n)
1481
- if len(selected) >= target_limit:
1482
- break
1483
- if len(selected) >= target_limit:
1484
- break
1485
-
1486
- if not selected:
1487
- self.complete = True
1488
- self.save(update_fields=["complete"])
1489
- return
1490
-
1491
- seen_list = seen.copy()
1492
- selected_ids = [str(n.uuid) for n in selected]
1493
- payload_seen = seen_list + selected_ids
1494
- for node in selected:
1495
- payload = {
1496
- "uuid": str(self.uuid),
1497
- "subject": self.subject,
1498
- "body": self.body,
1499
- "seen": payload_seen,
1500
- "reach": reach_name,
1501
- "sender": local_id,
1502
- "origin": origin_uuid,
1503
- }
1504
- if self.filter_node:
1505
- payload["filter_node"] = str(self.filter_node.uuid)
1506
- if self.filter_node_feature:
1507
- payload["filter_node_feature"] = self.filter_node_feature.slug
1508
- if self.filter_node_role:
1509
- payload["filter_node_role"] = self.filter_node_role.name
1510
- if self.filter_current_relation:
1511
- payload["filter_current_relation"] = self.filter_current_relation
1512
- if self.filter_installed_version:
1513
- payload["filter_installed_version"] = self.filter_installed_version
1514
- if self.filter_installed_revision:
1515
- payload["filter_installed_revision"] = self.filter_installed_revision
1516
- payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1517
- headers = {"Content-Type": "application/json"}
1518
- if private_key:
1519
- try:
1520
- signature = private_key.sign(
1521
- payload_json.encode(),
1522
- padding.PKCS1v15(),
1523
- hashes.SHA256(),
1524
- )
1525
- headers["X-Signature"] = base64.b64encode(signature).decode()
1526
- except Exception:
1527
- pass
1528
- try:
1529
- requests.post(
1530
- f"http://{node.address}:{node.port}/nodes/net-message/",
1531
- data=payload_json,
1532
- headers=headers,
1533
- timeout=1,
1534
- )
1535
- except Exception:
1536
- pass
1537
- self.propagated_to.add(node)
1538
-
1539
- if total_known and self.propagated_to.count() >= total_known:
1540
- self.complete = True
1541
- self.save(update_fields=["complete"] if self.complete else [])
1542
-
1543
-
1544
- class ContentSample(Entity):
1545
- """Collected content such as text snippets or screenshots."""
1546
-
1547
- TEXT = "TEXT"
1548
- IMAGE = "IMAGE"
1549
- KIND_CHOICES = [(TEXT, "Text"), (IMAGE, "Image")]
1550
-
1551
- name = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
1552
- kind = models.CharField(max_length=10, choices=KIND_CHOICES)
1553
- content = models.TextField(blank=True)
1554
- path = models.CharField(max_length=255, blank=True)
1555
- method = models.CharField(max_length=10, default="", blank=True)
1556
- hash = models.CharField(max_length=64, unique=True, null=True, blank=True)
1557
- transaction_uuid = models.UUIDField(
1558
- default=uuid.uuid4,
1559
- editable=True,
1560
- db_index=True,
1561
- verbose_name="transaction UUID",
1562
- )
1563
- node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
1564
- user = models.ForeignKey(
1565
- settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
1566
- )
1567
- created_at = models.DateTimeField(auto_now_add=True)
1568
-
1569
- class Meta:
1570
- ordering = ["-created_at"]
1571
- verbose_name = "Content Sample"
1572
- verbose_name_plural = "Content Samples"
1573
-
1574
- def save(self, *args, **kwargs):
1575
- if self.pk:
1576
- original = type(self).all_objects.get(pk=self.pk)
1577
- if original.transaction_uuid != self.transaction_uuid:
1578
- raise ValidationError(
1579
- {"transaction_uuid": "Cannot modify transaction UUID"}
1580
- )
1581
- if self.node_id is None:
1582
- self.node = Node.get_local()
1583
- super().save(*args, **kwargs)
1584
-
1585
- def __str__(self) -> str: # pragma: no cover - simple representation
1586
- return str(self.name)
1587
-
1588
-
1589
- UserModel = get_user_model()
1590
-
1591
-
1592
- class User(UserModel):
1593
- class Meta:
1594
- proxy = True
1595
- app_label = "nodes"
1596
- verbose_name = UserModel._meta.verbose_name
1597
- verbose_name_plural = UserModel._meta.verbose_name_plural
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass
6
+ from django.db import models
7
+ from django.db.utils import DatabaseError
8
+ from django.db.models.signals import post_delete
9
+ from django.dispatch import Signal, receiver
10
+ from core.entity import Entity
11
+ from core.models import PackageRelease, Profile
12
+ from core.fields import SigilLongAutoField, SigilShortAutoField
13
+ import re
14
+ import json
15
+ import base64
16
+ from django.utils import timezone
17
+ from django.utils.text import slugify
18
+ from django.conf import settings
19
+ from datetime import timedelta
20
+ import uuid
21
+ import os
22
+ import shutil
23
+ import socket
24
+ import stat
25
+ import subprocess
26
+ from pathlib import Path
27
+ from utils import revision
28
+ from core.notifications import notify_async
29
+ from django.core.exceptions import ValidationError
30
+ from cryptography.hazmat.primitives.asymmetric import rsa
31
+ from cryptography.hazmat.primitives import serialization, hashes
32
+ from cryptography.hazmat.primitives.asymmetric import padding
33
+ from django.contrib.auth import get_user_model
34
+ from django.core import serializers
35
+ from django.core.mail import get_connection
36
+ from django.core.serializers.base import DeserializationError
37
+ from core import mailer
38
+ import logging
39
+
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class NodeRoleManager(models.Manager):
45
+ def get_by_natural_key(self, name: str):
46
+ return self.get(name=name)
47
+
48
+
49
+ class NodeRole(Entity):
50
+ """Assignable role for a :class:`Node`."""
51
+
52
+ name = models.CharField(max_length=50, unique=True)
53
+ description = models.CharField(max_length=200, blank=True)
54
+
55
+ objects = NodeRoleManager()
56
+
57
+ class Meta:
58
+ ordering = ["name"]
59
+ verbose_name = "Node Role"
60
+ verbose_name_plural = "Node Roles"
61
+
62
+ def natural_key(self): # pragma: no cover - simple representation
63
+ return (self.name,)
64
+
65
+ def __str__(self) -> str: # pragma: no cover - simple representation
66
+ return self.name
67
+
68
+
69
+ class NodeFeatureManager(models.Manager):
70
+ def get_by_natural_key(self, slug: str):
71
+ return self.get(slug=slug)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class NodeFeatureDefaultAction:
76
+ label: str
77
+ url_name: str
78
+
79
+
80
+ class NodeFeature(Entity):
81
+ """Feature that may be enabled on nodes and roles."""
82
+
83
+ slug = models.SlugField(max_length=50, unique=True)
84
+ display = models.CharField(max_length=50)
85
+ roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
86
+
87
+ objects = NodeFeatureManager()
88
+
89
+ DEFAULT_ACTIONS: dict[str, tuple[NodeFeatureDefaultAction, ...]] = {
90
+ "rfid-scanner": (
91
+ NodeFeatureDefaultAction(
92
+ label="Scan RFIDs", url_name="admin:core_rfid_scan"
93
+ ),
94
+ ),
95
+ "celery-queue": (
96
+ NodeFeatureDefaultAction(
97
+ label="Celery Report",
98
+ url_name="admin:nodes_nodefeature_celery_report",
99
+ ),
100
+ ),
101
+ "screenshot-poll": (
102
+ NodeFeatureDefaultAction(
103
+ label="Take Screenshot",
104
+ url_name="admin:nodes_nodefeature_take_screenshot",
105
+ ),
106
+ ),
107
+ "rpi-camera": (
108
+ NodeFeatureDefaultAction(
109
+ label="Take a Snapshot",
110
+ url_name="admin:nodes_nodefeature_take_snapshot",
111
+ ),
112
+ NodeFeatureDefaultAction(
113
+ label="View stream",
114
+ url_name="admin:nodes_nodefeature_view_stream",
115
+ ),
116
+ ),
117
+ }
118
+
119
+ class Meta:
120
+ ordering = ["display"]
121
+ verbose_name = "Node Feature"
122
+ verbose_name_plural = "Node Features"
123
+
124
+ def natural_key(self): # pragma: no cover - simple representation
125
+ return (self.slug,)
126
+
127
+ def __str__(self) -> str: # pragma: no cover - simple representation
128
+ return self.display
129
+
130
+ @property
131
+ def is_enabled(self) -> bool:
132
+ from django.conf import settings
133
+ from pathlib import Path
134
+
135
+ node = Node.get_local()
136
+ if not node:
137
+ return False
138
+ if node.features.filter(pk=self.pk).exists():
139
+ return True
140
+ if self.slug == "gway-runner":
141
+ return Node._has_gway_runner()
142
+ if self.slug == "gui-toast":
143
+ from core.notifications import supports_gui_toast
144
+
145
+ return supports_gui_toast()
146
+ if self.slug == "rpi-camera":
147
+ return Node._has_rpi_camera()
148
+ lock_map = {
149
+ "lcd-screen": "lcd_screen.lck",
150
+ "rfid-scanner": "rfid.lck",
151
+ "celery-queue": "celery.lck",
152
+ "nginx-server": "nginx_mode.lck",
153
+ }
154
+ lock = lock_map.get(self.slug)
155
+ if lock:
156
+ base_path = Path(node.base_path or settings.BASE_DIR)
157
+ return (base_path / "locks" / lock).exists()
158
+ return False
159
+
160
+ def get_default_actions(self) -> tuple[NodeFeatureDefaultAction, ...]:
161
+ """Return the configured default actions for this feature."""
162
+
163
+ actions = self.DEFAULT_ACTIONS.get(self.slug, ())
164
+ if isinstance(actions, NodeFeatureDefaultAction): # pragma: no cover - legacy
165
+ return (actions,)
166
+ return actions
167
+
168
+ def get_default_action(self) -> NodeFeatureDefaultAction | None:
169
+ """Return the first configured default action for this feature if any."""
170
+
171
+ actions = self.get_default_actions()
172
+ return actions[0] if actions else None
173
+
174
+
175
+ def get_terminal_role():
176
+ """Return the NodeRole representing a Terminal if it exists."""
177
+ return NodeRole.objects.filter(name="Terminal").first()
178
+
179
+
180
+ class Node(Entity):
181
+ """Information about a running node in the network."""
182
+
183
+ DEFAULT_BADGE_COLOR = "#28a745"
184
+ ROLE_BADGE_COLORS = {
185
+ "Constellation": "#daa520", # goldenrod
186
+ "Control": "#673ab7", # deep purple
187
+ }
188
+
189
+ class Relation(models.TextChoices):
190
+ UPSTREAM = "UPSTREAM", "Upstream"
191
+ DOWNSTREAM = "DOWNSTREAM", "Downstream"
192
+ PEER = "PEER", "Peer"
193
+ SELF = "SELF", "Self"
194
+
195
+ hostname = models.CharField(max_length=100)
196
+ address = models.GenericIPAddressField()
197
+ mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
198
+ port = models.PositiveIntegerField(default=8000)
199
+ badge_color = models.CharField(max_length=7, default=DEFAULT_BADGE_COLOR)
200
+ role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
201
+ current_relation = models.CharField(
202
+ max_length=10,
203
+ choices=Relation.choices,
204
+ default=Relation.PEER,
205
+ )
206
+ last_seen = models.DateTimeField(auto_now=True)
207
+ enable_public_api = models.BooleanField(
208
+ default=False,
209
+ verbose_name="enable public API",
210
+ )
211
+ public_endpoint = models.SlugField(blank=True, unique=True)
212
+ uuid = models.UUIDField(
213
+ default=uuid.uuid4,
214
+ unique=True,
215
+ editable=False,
216
+ verbose_name="UUID",
217
+ )
218
+ public_key = models.TextField(blank=True)
219
+ base_path = models.CharField(max_length=255, blank=True)
220
+ installed_version = models.CharField(max_length=20, blank=True)
221
+ installed_revision = models.CharField(max_length=40, blank=True)
222
+ features = models.ManyToManyField(
223
+ NodeFeature,
224
+ through="NodeFeatureAssignment",
225
+ related_name="nodes",
226
+ blank=True,
227
+ )
228
+
229
+ FEATURE_LOCK_MAP = {
230
+ "lcd-screen": "lcd_screen.lck",
231
+ "rfid-scanner": "rfid.lck",
232
+ "celery-queue": "celery.lck",
233
+ "nginx-server": "nginx_mode.lck",
234
+ }
235
+ RPI_CAMERA_DEVICE = Path("/dev/video0")
236
+ RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
237
+ AP_ROUTER_SSID = "gelectriic-ap"
238
+ NMCLI_TIMEOUT = 5
239
+ GWAY_RUNNER_COMMAND = "gway"
240
+ GWAY_RUNNER_CANDIDATES = ("~/.local/bin/gway", "/usr/local/bin/gway")
241
+ AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
242
+ "gui-toast",
243
+ "rpi-camera",
244
+ "ap-router",
245
+ "gway-runner",
246
+ }
247
+ MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
248
+
249
+ def __str__(self) -> str: # pragma: no cover - simple representation
250
+ return f"{self.hostname}:{self.port}"
251
+
252
+ @staticmethod
253
+ def get_current_mac() -> str:
254
+ """Return the MAC address of the current host."""
255
+ return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
256
+
257
+ @classmethod
258
+ def normalize_relation(cls, value):
259
+ """Normalize ``value`` to a valid :class:`Relation`."""
260
+
261
+ if isinstance(value, cls.Relation):
262
+ return value
263
+ if value is None:
264
+ return cls.Relation.PEER
265
+ text = str(value).strip()
266
+ if not text:
267
+ return cls.Relation.PEER
268
+ for relation in cls.Relation:
269
+ if text.lower() == relation.label.lower():
270
+ return relation
271
+ if text.upper() == relation.name:
272
+ return relation
273
+ if text.lower() == relation.value.lower():
274
+ return relation
275
+ return cls.Relation.PEER
276
+
277
+ @classmethod
278
+ def get_local(cls):
279
+ """Return the node representing the current host if it exists."""
280
+ mac = cls.get_current_mac()
281
+ try:
282
+ return cls.objects.filter(mac_address=mac).first()
283
+ except DatabaseError:
284
+ logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
285
+ return None
286
+
287
+ @classmethod
288
+ def register_current(cls):
289
+ """Create or update the :class:`Node` entry for this host."""
290
+ hostname = socket.gethostname()
291
+ try:
292
+ address = socket.gethostbyname(hostname)
293
+ except OSError:
294
+ address = "127.0.0.1"
295
+ port = int(os.environ.get("PORT", 8000))
296
+ base_path = str(settings.BASE_DIR)
297
+ ver_path = Path(settings.BASE_DIR) / "VERSION"
298
+ installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
299
+ rev_value = revision.get_revision()
300
+ installed_revision = rev_value if rev_value else ""
301
+ mac = cls.get_current_mac()
302
+ slug = slugify(hostname)
303
+ node = cls.objects.filter(mac_address=mac).first()
304
+ if not node:
305
+ node = cls.objects.filter(public_endpoint=slug).first()
306
+ defaults = {
307
+ "hostname": hostname,
308
+ "address": address,
309
+ "port": port,
310
+ "base_path": base_path,
311
+ "installed_version": installed_version,
312
+ "installed_revision": installed_revision,
313
+ "public_endpoint": slug,
314
+ "mac_address": mac,
315
+ "current_relation": cls.Relation.SELF,
316
+ }
317
+ role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
318
+ role_name = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
319
+ desired_role = NodeRole.objects.filter(name=role_name).first()
320
+
321
+ if node:
322
+ update_fields = []
323
+ for field, value in defaults.items():
324
+ if getattr(node, field) != value:
325
+ setattr(node, field, value)
326
+ update_fields.append(field)
327
+ if desired_role and node.role_id != desired_role.id:
328
+ node.role = desired_role
329
+ update_fields.append("role")
330
+ if update_fields:
331
+ node.save(update_fields=update_fields)
332
+ else:
333
+ node.refresh_features()
334
+ created = False
335
+ else:
336
+ node = cls.objects.create(**defaults)
337
+ created = True
338
+ if desired_role:
339
+ node.role = desired_role
340
+ node.save(update_fields=["role"])
341
+ if created and node.role is None:
342
+ terminal = NodeRole.objects.filter(name="Terminal").first()
343
+ if terminal:
344
+ node.role = terminal
345
+ node.save(update_fields=["role"])
346
+ node.ensure_keys()
347
+ node.notify_peers_of_update()
348
+ return node, created
349
+
350
+ def notify_peers_of_update(self):
351
+ """Attempt to update this node's registration with known peers."""
352
+
353
+ from secrets import token_hex
354
+
355
+ try:
356
+ import requests
357
+ except Exception: # pragma: no cover - requests should be available
358
+ return
359
+
360
+ security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
361
+ priv_path = security_dir / f"{self.public_endpoint}"
362
+ if not priv_path.exists():
363
+ logger.debug("Private key for %s not found; skipping peer update", self)
364
+ return
365
+ try:
366
+ private_key = serialization.load_pem_private_key(
367
+ priv_path.read_bytes(), password=None
368
+ )
369
+ except Exception as exc: # pragma: no cover - defensive
370
+ logger.warning("Failed to load private key for %s: %s", self, exc)
371
+ return
372
+ token = token_hex(16)
373
+ try:
374
+ signature = private_key.sign(
375
+ token.encode(),
376
+ padding.PKCS1v15(),
377
+ hashes.SHA256(),
378
+ )
379
+ except Exception as exc: # pragma: no cover - defensive
380
+ logger.warning("Failed to sign peer update for %s: %s", self, exc)
381
+ return
382
+
383
+ payload = {
384
+ "hostname": self.hostname,
385
+ "address": self.address,
386
+ "port": self.port,
387
+ "mac_address": self.mac_address,
388
+ "public_key": self.public_key,
389
+ "token": token,
390
+ "signature": base64.b64encode(signature).decode(),
391
+ }
392
+ if self.installed_version:
393
+ payload["installed_version"] = self.installed_version
394
+ if self.installed_revision:
395
+ payload["installed_revision"] = self.installed_revision
396
+
397
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
398
+ headers = {"Content-Type": "application/json"}
399
+
400
+ peers = Node.objects.exclude(pk=self.pk)
401
+ for peer in peers:
402
+ host_candidates: list[str] = []
403
+ if peer.address:
404
+ host_candidates.append(peer.address)
405
+ if peer.hostname and peer.hostname not in host_candidates:
406
+ host_candidates.append(peer.hostname)
407
+ port = peer.port or 8000
408
+ urls: list[str] = []
409
+ for host in host_candidates:
410
+ host = host.strip()
411
+ if not host:
412
+ continue
413
+ if ":" in host and not host.startswith("["):
414
+ host = f"[{host}]"
415
+ http_url = (
416
+ f"http://{host}/nodes/register/"
417
+ if port == 80
418
+ else f"http://{host}:{port}/nodes/register/"
419
+ )
420
+ https_url = (
421
+ f"https://{host}/nodes/register/"
422
+ if port in {80, 443}
423
+ else f"https://{host}:{port}/nodes/register/"
424
+ )
425
+ for url in (https_url, http_url):
426
+ if url not in urls:
427
+ urls.append(url)
428
+ if not urls:
429
+ continue
430
+ for url in urls:
431
+ try:
432
+ response = requests.post(
433
+ url, data=payload_json, headers=headers, timeout=2
434
+ )
435
+ except Exception as exc: # pragma: no cover - best effort
436
+ logger.debug("Failed to update %s via %s: %s", peer, url, exc)
437
+ continue
438
+ if response.ok:
439
+ version_display = _format_upgrade_body(
440
+ self.installed_version,
441
+ self.installed_revision,
442
+ )
443
+ version_suffix = f" ({version_display})" if version_display else ""
444
+ logger.info(
445
+ "Announced startup to %s%s",
446
+ peer,
447
+ version_suffix,
448
+ )
449
+ break
450
+ else:
451
+ logger.warning("Unable to notify node %s of startup", peer)
452
+
453
+ def ensure_keys(self):
454
+ security_dir = Path(settings.BASE_DIR) / "security"
455
+ security_dir.mkdir(parents=True, exist_ok=True)
456
+ priv_path = security_dir / f"{self.public_endpoint}"
457
+ pub_path = security_dir / f"{self.public_endpoint}.pub"
458
+ if not priv_path.exists() or not pub_path.exists():
459
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
460
+ private_bytes = private_key.private_bytes(
461
+ encoding=serialization.Encoding.PEM,
462
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
463
+ encryption_algorithm=serialization.NoEncryption(),
464
+ )
465
+ public_bytes = private_key.public_key().public_bytes(
466
+ encoding=serialization.Encoding.PEM,
467
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
468
+ )
469
+ priv_path.write_bytes(private_bytes)
470
+ pub_path.write_bytes(public_bytes)
471
+ self.public_key = public_bytes.decode()
472
+ self.save(update_fields=["public_key"])
473
+ elif not self.public_key:
474
+ self.public_key = pub_path.read_text()
475
+ self.save(update_fields=["public_key"])
476
+
477
+ @property
478
+ def is_local(self):
479
+ """Determine if this node represents the current host."""
480
+ return self.mac_address == self.get_current_mac()
481
+
482
+ @classmethod
483
+ def _generate_unique_public_endpoint(
484
+ cls, value: str | None, *, exclude_pk: int | None = None
485
+ ) -> str:
486
+ """Return a unique public endpoint slug for ``value``."""
487
+
488
+ field = cls._meta.get_field("public_endpoint")
489
+ max_length = getattr(field, "max_length", None) or 50
490
+ base_slug = slugify(value or "") or "node"
491
+ if len(base_slug) > max_length:
492
+ base_slug = base_slug[:max_length]
493
+ slug = base_slug
494
+ queryset = cls.objects.all()
495
+ if exclude_pk is not None:
496
+ queryset = queryset.exclude(pk=exclude_pk)
497
+ counter = 2
498
+ while queryset.filter(public_endpoint=slug).exists():
499
+ suffix = f"-{counter}"
500
+ available = max_length - len(suffix)
501
+ if available <= 0:
502
+ slug = suffix[-max_length:]
503
+ else:
504
+ slug = f"{base_slug[:available]}{suffix}"
505
+ counter += 1
506
+ return slug
507
+
508
+ def save(self, *args, **kwargs):
509
+ update_fields = kwargs.get("update_fields")
510
+
511
+ def include_update_field(field: str):
512
+ nonlocal update_fields
513
+ if update_fields is None:
514
+ return
515
+ fields = set(update_fields)
516
+ if field in fields:
517
+ return
518
+ fields.add(field)
519
+ update_fields = tuple(fields)
520
+ kwargs["update_fields"] = update_fields
521
+
522
+ role_name = None
523
+ role = getattr(self, "role", None)
524
+ if role and getattr(role, "name", None):
525
+ role_name = role.name
526
+ elif self.role_id:
527
+ role_name = (
528
+ NodeRole.objects.filter(pk=self.role_id)
529
+ .values_list("name", flat=True)
530
+ .first()
531
+ )
532
+
533
+ role_color = self.ROLE_BADGE_COLORS.get(role_name)
534
+ if role_color and (
535
+ not self.badge_color or self.badge_color == self.DEFAULT_BADGE_COLOR
536
+ ):
537
+ self.badge_color = role_color
538
+ include_update_field("badge_color")
539
+
540
+ if self.mac_address:
541
+ self.mac_address = self.mac_address.lower()
542
+ endpoint_value = slugify(self.public_endpoint or "")
543
+ if not endpoint_value:
544
+ endpoint_value = self._generate_unique_public_endpoint(
545
+ self.hostname, exclude_pk=self.pk
546
+ )
547
+ else:
548
+ queryset = (
549
+ self.__class__.objects.exclude(pk=self.pk)
550
+ if self.pk
551
+ else self.__class__.objects.all()
552
+ )
553
+ if queryset.filter(public_endpoint=endpoint_value).exists():
554
+ endpoint_value = self._generate_unique_public_endpoint(
555
+ self.hostname or endpoint_value, exclude_pk=self.pk
556
+ )
557
+ if self.public_endpoint != endpoint_value:
558
+ self.public_endpoint = endpoint_value
559
+ include_update_field("public_endpoint")
560
+ super().save(*args, **kwargs)
561
+ if self.pk:
562
+ self.refresh_features()
563
+
564
+ def has_feature(self, slug: str) -> bool:
565
+ return self.features.filter(slug=slug).exists()
566
+
567
+ @classmethod
568
+ def _has_rpi_camera(cls) -> bool:
569
+ """Return ``True`` when the Raspberry Pi camera stack is available."""
570
+
571
+ device = cls.RPI_CAMERA_DEVICE
572
+ if not device.exists():
573
+ return False
574
+ device_path = str(device)
575
+ try:
576
+ mode = os.stat(device_path).st_mode
577
+ except OSError:
578
+ return False
579
+ if not stat.S_ISCHR(mode):
580
+ return False
581
+ if not os.access(device_path, os.R_OK | os.W_OK):
582
+ return False
583
+ for binary in cls.RPI_CAMERA_BINARIES:
584
+ tool_path = shutil.which(binary)
585
+ if not tool_path:
586
+ return False
587
+ try:
588
+ result = subprocess.run(
589
+ [tool_path, "--help"],
590
+ capture_output=True,
591
+ text=True,
592
+ check=False,
593
+ timeout=5,
594
+ )
595
+ except Exception:
596
+ return False
597
+ if result.returncode != 0:
598
+ return False
599
+ return True
600
+
601
+ @classmethod
602
+ def _hosts_gelectriic_ap(cls) -> bool:
603
+ """Return ``True`` when the node is hosting the gelectriic access point."""
604
+
605
+ nmcli_path = shutil.which("nmcli")
606
+ if not nmcli_path:
607
+ return False
608
+ try:
609
+ result = subprocess.run(
610
+ [
611
+ nmcli_path,
612
+ "-t",
613
+ "-f",
614
+ "NAME,DEVICE,TYPE",
615
+ "connection",
616
+ "show",
617
+ "--active",
618
+ ],
619
+ capture_output=True,
620
+ text=True,
621
+ check=False,
622
+ timeout=cls.NMCLI_TIMEOUT,
623
+ )
624
+ except Exception:
625
+ return False
626
+ if result.returncode != 0:
627
+ return False
628
+ for line in result.stdout.splitlines():
629
+ if not line:
630
+ continue
631
+ parts = line.split(":", 2)
632
+ if not parts:
633
+ continue
634
+ name = parts[0]
635
+ conn_type = ""
636
+ if len(parts) == 3:
637
+ conn_type = parts[2]
638
+ elif len(parts) > 1:
639
+ conn_type = parts[1]
640
+ if name != cls.AP_ROUTER_SSID:
641
+ continue
642
+ conn_type_normalized = conn_type.strip().lower()
643
+ if conn_type_normalized not in {"wifi", "802-11-wireless"}:
644
+ continue
645
+ try:
646
+ mode_result = subprocess.run(
647
+ [
648
+ nmcli_path,
649
+ "-g",
650
+ "802-11-wireless.mode",
651
+ "connection",
652
+ "show",
653
+ name,
654
+ ],
655
+ capture_output=True,
656
+ text=True,
657
+ check=False,
658
+ timeout=cls.NMCLI_TIMEOUT,
659
+ )
660
+ except Exception:
661
+ continue
662
+ if mode_result.returncode != 0:
663
+ continue
664
+ if mode_result.stdout.strip() == "ap":
665
+ return True
666
+ return False
667
+
668
+ @classmethod
669
+ def _find_gway_runner_command(cls) -> str | None:
670
+ command = shutil.which(cls.GWAY_RUNNER_COMMAND)
671
+ if command:
672
+ return command
673
+ for candidate in cls.GWAY_RUNNER_CANDIDATES:
674
+ expanded = os.path.expanduser(candidate)
675
+ if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
676
+ return expanded
677
+ return None
678
+
679
+ @classmethod
680
+ def _has_gway_runner(cls) -> bool:
681
+ return cls._find_gway_runner_command() is not None
682
+
683
+ def refresh_features(self):
684
+ if not self.pk:
685
+ return
686
+ if not self.is_local:
687
+ self.sync_feature_tasks()
688
+ return
689
+ detected_slugs = set()
690
+ base_path = Path(self.base_path or settings.BASE_DIR)
691
+ locks_dir = base_path / "locks"
692
+ for slug, filename in self.FEATURE_LOCK_MAP.items():
693
+ if (locks_dir / filename).exists():
694
+ detected_slugs.add(slug)
695
+ if self._has_rpi_camera():
696
+ detected_slugs.add("rpi-camera")
697
+ if self._has_gway_runner():
698
+ detected_slugs.add("gway-runner")
699
+ if self._hosts_gelectriic_ap():
700
+ detected_slugs.add("ap-router")
701
+ try:
702
+ from core.notifications import supports_gui_toast
703
+ except Exception:
704
+ pass
705
+ else:
706
+ try:
707
+ if supports_gui_toast():
708
+ detected_slugs.add("gui-toast")
709
+ except Exception:
710
+ pass
711
+ current_slugs = set(
712
+ self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
713
+ "slug", flat=True
714
+ )
715
+ )
716
+ add_slugs = detected_slugs - current_slugs
717
+ if add_slugs:
718
+ for feature in NodeFeature.objects.filter(slug__in=add_slugs):
719
+ NodeFeatureAssignment.objects.update_or_create(
720
+ node=self, feature=feature
721
+ )
722
+ remove_slugs = current_slugs - detected_slugs
723
+ if remove_slugs:
724
+ NodeFeatureAssignment.objects.filter(
725
+ node=self, feature__slug__in=remove_slugs
726
+ ).delete()
727
+ self.sync_feature_tasks()
728
+
729
+ def update_manual_features(self, slugs: Iterable[str]):
730
+ desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
731
+ remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
732
+ if remove_slugs:
733
+ NodeFeatureAssignment.objects.filter(
734
+ node=self, feature__slug__in=remove_slugs
735
+ ).delete()
736
+ if desired:
737
+ for feature in NodeFeature.objects.filter(slug__in=desired):
738
+ NodeFeatureAssignment.objects.update_or_create(
739
+ node=self, feature=feature
740
+ )
741
+ self.sync_feature_tasks()
742
+
743
+ def sync_feature_tasks(self):
744
+ clipboard_enabled = self.has_feature("clipboard-poll")
745
+ screenshot_enabled = self.has_feature("screenshot-poll")
746
+ celery_enabled = self.is_local and self.has_feature("celery-queue")
747
+ self._sync_clipboard_task(clipboard_enabled)
748
+ self._sync_screenshot_task(screenshot_enabled)
749
+ self._sync_landing_lead_task(celery_enabled)
750
+
751
+ def _sync_clipboard_task(self, enabled: bool):
752
+ from django_celery_beat.models import IntervalSchedule, PeriodicTask
753
+
754
+ task_name = f"poll_clipboard_node_{self.pk}"
755
+ if enabled:
756
+ schedule, _ = IntervalSchedule.objects.get_or_create(
757
+ every=5, period=IntervalSchedule.SECONDS
758
+ )
759
+ PeriodicTask.objects.update_or_create(
760
+ name=task_name,
761
+ defaults={
762
+ "interval": schedule,
763
+ "task": "nodes.tasks.sample_clipboard",
764
+ },
765
+ )
766
+ else:
767
+ PeriodicTask.objects.filter(name=task_name).delete()
768
+
769
+ def _sync_screenshot_task(self, enabled: bool):
770
+ from django_celery_beat.models import IntervalSchedule, PeriodicTask
771
+ import json
772
+
773
+ task_name = f"capture_screenshot_node_{self.pk}"
774
+ if enabled:
775
+ schedule, _ = IntervalSchedule.objects.get_or_create(
776
+ every=1, period=IntervalSchedule.MINUTES
777
+ )
778
+ PeriodicTask.objects.update_or_create(
779
+ name=task_name,
780
+ defaults={
781
+ "interval": schedule,
782
+ "task": "nodes.tasks.capture_node_screenshot",
783
+ "kwargs": json.dumps(
784
+ {
785
+ "url": f"http://localhost:{self.port}",
786
+ "port": self.port,
787
+ "method": "AUTO",
788
+ }
789
+ ),
790
+ },
791
+ )
792
+ else:
793
+ PeriodicTask.objects.filter(name=task_name).delete()
794
+
795
+ def _sync_landing_lead_task(self, enabled: bool):
796
+ if not self.is_local:
797
+ return
798
+
799
+ from django_celery_beat.models import CrontabSchedule, PeriodicTask
800
+
801
+ task_name = "pages_purge_landing_leads"
802
+ if enabled:
803
+ schedule, _ = CrontabSchedule.objects.get_or_create(
804
+ minute="0",
805
+ hour="3",
806
+ day_of_week="*",
807
+ day_of_month="*",
808
+ month_of_year="*",
809
+ )
810
+ PeriodicTask.objects.update_or_create(
811
+ name=task_name,
812
+ defaults={
813
+ "crontab": schedule,
814
+ "interval": None,
815
+ "task": "pages.tasks.purge_expired_landing_leads",
816
+ "enabled": True,
817
+ },
818
+ )
819
+ else:
820
+ PeriodicTask.objects.filter(name=task_name).delete()
821
+
822
+ def send_mail(
823
+ self,
824
+ subject: str,
825
+ message: str,
826
+ recipient_list: list[str],
827
+ from_email: str | None = None,
828
+ **kwargs,
829
+ ):
830
+ """Send an email using this node's configured outbox if available."""
831
+ outbox = getattr(self, "email_outbox", None)
832
+ logger.info(
833
+ "Node %s queueing email to %s using %s backend",
834
+ self.pk,
835
+ recipient_list,
836
+ "outbox" if outbox else "default",
837
+ )
838
+ return mailer.send(
839
+ subject,
840
+ message,
841
+ recipient_list,
842
+ from_email,
843
+ outbox=outbox,
844
+ **kwargs,
845
+ )
846
+
847
+
848
+ node_information_updated = Signal()
849
+
850
+
851
+ def _format_upgrade_body(version: str, revision: str) -> str:
852
+ version = (version or "").strip()
853
+ revision = (revision or "").strip()
854
+ parts: list[str] = []
855
+ if version:
856
+ normalized = version.lstrip("vV") or version
857
+ base_version = normalized.rstrip("+")
858
+ display_version = normalized
859
+ if (
860
+ base_version
861
+ and revision
862
+ and not PackageRelease.matches_revision(base_version, revision)
863
+ and not normalized.endswith("+")
864
+ ):
865
+ display_version = f"{display_version}+"
866
+ parts.append(f"v{display_version}")
867
+ if revision:
868
+ rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
869
+ rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
870
+ parts.append(f"r{rev_short}")
871
+ return " ".join(parts).strip()
872
+
873
+
874
+ @receiver(node_information_updated)
875
+ def _announce_peer_startup(
876
+ sender,
877
+ *,
878
+ node: "Node",
879
+ previous_version: str = "",
880
+ previous_revision: str = "",
881
+ current_version: str = "",
882
+ current_revision: str = "",
883
+ **_: object,
884
+ ) -> None:
885
+ current_version = (current_version or "").strip()
886
+ current_revision = (current_revision or "").strip()
887
+ previous_version = (previous_version or "").strip()
888
+ previous_revision = (previous_revision or "").strip()
889
+
890
+ local = Node.get_local()
891
+ if local and node.pk == local.pk:
892
+ return
893
+
894
+ body = _format_upgrade_body(current_version, current_revision)
895
+ if not body:
896
+ body = "Online"
897
+
898
+ hostname = (node.hostname or "Node").strip() or "Node"
899
+ subject = f"UP {hostname}"
900
+ notify_async(subject, body)
901
+
902
+
903
+ class NodeFeatureAssignment(Entity):
904
+ """Bridge between :class:`Node` and :class:`NodeFeature`."""
905
+
906
+ node = models.ForeignKey(
907
+ Node, on_delete=models.CASCADE, related_name="feature_assignments"
908
+ )
909
+ feature = models.ForeignKey(
910
+ NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
911
+ )
912
+ created_at = models.DateTimeField(auto_now_add=True)
913
+
914
+ class Meta:
915
+ unique_together = ("node", "feature")
916
+ verbose_name = "Node Feature Assignment"
917
+ verbose_name_plural = "Node Feature Assignments"
918
+
919
+ def __str__(self) -> str: # pragma: no cover - simple representation
920
+ return f"{self.node} -> {self.feature}"
921
+
922
+ def save(self, *args, **kwargs):
923
+ super().save(*args, **kwargs)
924
+ self.node.sync_feature_tasks()
925
+
926
+
927
+ @receiver(post_delete, sender=NodeFeatureAssignment)
928
+ def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
929
+ node_id = getattr(instance, "node_id", None)
930
+ if not node_id:
931
+ return
932
+ node = Node.objects.filter(pk=node_id).first()
933
+ if node:
934
+ node.sync_feature_tasks()
935
+
936
+
937
+ class NodeManager(Profile):
938
+ """Credentials for interacting with external DNS providers."""
939
+
940
+ class Provider(models.TextChoices):
941
+ GODADDY = "godaddy", "GoDaddy"
942
+
943
+ profile_fields = (
944
+ "provider",
945
+ "api_key",
946
+ "api_secret",
947
+ "customer_id",
948
+ "default_domain",
949
+ )
950
+
951
+ provider = models.CharField(
952
+ max_length=20,
953
+ choices=Provider.choices,
954
+ default=Provider.GODADDY,
955
+ )
956
+ api_key = SigilShortAutoField(
957
+ max_length=255,
958
+ help_text="API key issued by the DNS provider.",
959
+ )
960
+ api_secret = SigilShortAutoField(
961
+ max_length=255,
962
+ help_text="API secret issued by the DNS provider.",
963
+ )
964
+ customer_id = SigilShortAutoField(
965
+ max_length=100,
966
+ blank=True,
967
+ help_text="Optional GoDaddy customer identifier for the account.",
968
+ )
969
+ default_domain = SigilShortAutoField(
970
+ max_length=253,
971
+ blank=True,
972
+ help_text="Fallback domain when records omit one.",
973
+ )
974
+ use_sandbox = models.BooleanField(
975
+ default=False,
976
+ help_text="Use the GoDaddy OTE (test) environment.",
977
+ )
978
+ is_enabled = models.BooleanField(
979
+ default=True,
980
+ help_text="Disable to prevent deployments with this manager.",
981
+ )
982
+
983
+ class Meta:
984
+ verbose_name = "Node Manager"
985
+ verbose_name_plural = "Node Managers"
986
+
987
+ def __str__(self) -> str:
988
+ owner = self.owner_display()
989
+ provider = self.get_provider_display()
990
+ if owner:
991
+ return f"{provider} ({owner})"
992
+ return provider
993
+
994
+ def clean(self):
995
+ if self.user_id or self.group_id:
996
+ super().clean()
997
+ else:
998
+ super(Profile, self).clean()
999
+
1000
+ def get_base_url(self) -> str:
1001
+ if self.provider != self.Provider.GODADDY:
1002
+ raise ValueError("Unsupported DNS provider")
1003
+ if self.use_sandbox:
1004
+ return "https://api.ote-godaddy.com"
1005
+ return "https://api.godaddy.com"
1006
+
1007
+ def get_auth_header(self) -> str:
1008
+ key = (self.resolve_sigils("api_key") or "").strip()
1009
+ secret = (self.resolve_sigils("api_secret") or "").strip()
1010
+ if not key or not secret:
1011
+ raise ValueError("API credentials are required for DNS deployment")
1012
+ return f"sso-key {key}:{secret}"
1013
+
1014
+ def get_customer_id(self) -> str:
1015
+ return (self.resolve_sigils("customer_id") or "").strip()
1016
+
1017
+ def get_default_domain(self) -> str:
1018
+ return (self.resolve_sigils("default_domain") or "").strip()
1019
+
1020
+ def publish_dns_records(self, records: Iterable["DNSRecord"]):
1021
+ from . import dns as dns_utils
1022
+
1023
+ return dns_utils.deploy_records(self, records)
1024
+
1025
+
1026
+ class DNSRecord(Entity):
1027
+ """Stored DNS configuration ready for deployment."""
1028
+
1029
+ class Type(models.TextChoices):
1030
+ A = "A", "A"
1031
+ AAAA = "AAAA", "AAAA"
1032
+ CNAME = "CNAME", "CNAME"
1033
+ MX = "MX", "MX"
1034
+ NS = "NS", "NS"
1035
+ SRV = "SRV", "SRV"
1036
+ TXT = "TXT", "TXT"
1037
+
1038
+ class Provider(models.TextChoices):
1039
+ GODADDY = "godaddy", "GoDaddy"
1040
+
1041
+ provider = models.CharField(
1042
+ max_length=20,
1043
+ choices=Provider.choices,
1044
+ default=Provider.GODADDY,
1045
+ )
1046
+ node_manager = models.ForeignKey(
1047
+ "NodeManager",
1048
+ on_delete=models.SET_NULL,
1049
+ null=True,
1050
+ blank=True,
1051
+ related_name="dns_records",
1052
+ )
1053
+ domain = SigilShortAutoField(
1054
+ max_length=253,
1055
+ help_text="Base domain such as example.com.",
1056
+ )
1057
+ name = SigilShortAutoField(
1058
+ max_length=253,
1059
+ help_text="Record host. Use @ for the zone apex.",
1060
+ )
1061
+ record_type = models.CharField(
1062
+ max_length=10,
1063
+ choices=Type.choices,
1064
+ default=Type.A,
1065
+ verbose_name="Type",
1066
+ )
1067
+ data = SigilLongAutoField(
1068
+ help_text="Record value such as an IP address or hostname.",
1069
+ )
1070
+ ttl = models.PositiveIntegerField(
1071
+ default=600,
1072
+ help_text="Time to live in seconds.",
1073
+ )
1074
+ priority = models.PositiveIntegerField(
1075
+ null=True,
1076
+ blank=True,
1077
+ help_text="Priority for MX and SRV records.",
1078
+ )
1079
+ port = models.PositiveIntegerField(
1080
+ null=True,
1081
+ blank=True,
1082
+ help_text="Port for SRV records.",
1083
+ )
1084
+ weight = models.PositiveIntegerField(
1085
+ null=True,
1086
+ blank=True,
1087
+ help_text="Weight for SRV records.",
1088
+ )
1089
+ service = SigilShortAutoField(
1090
+ max_length=50,
1091
+ blank=True,
1092
+ help_text="Service label for SRV records (for example _sip).",
1093
+ )
1094
+ protocol = SigilShortAutoField(
1095
+ max_length=10,
1096
+ blank=True,
1097
+ help_text="Protocol label for SRV records (for example _tcp).",
1098
+ )
1099
+ last_synced_at = models.DateTimeField(null=True, blank=True)
1100
+ last_verified_at = models.DateTimeField(null=True, blank=True)
1101
+ last_error = models.TextField(blank=True)
1102
+
1103
+ class Meta:
1104
+ verbose_name = "DNS Record"
1105
+ verbose_name_plural = "DNS Records"
1106
+
1107
+ def __str__(self) -> str:
1108
+ return f"{self.record_type} {self.fqdn()}"
1109
+
1110
+ def get_domain(self, manager: "NodeManager" | None = None) -> str:
1111
+ domain = (self.resolve_sigils("domain") or "").strip()
1112
+ if domain:
1113
+ return domain.rstrip(".")
1114
+ if manager:
1115
+ fallback = manager.get_default_domain()
1116
+ if fallback:
1117
+ return fallback.rstrip(".")
1118
+ return ""
1119
+
1120
+ def get_name(self) -> str:
1121
+ name = (self.resolve_sigils("name") or "").strip()
1122
+ return name or "@"
1123
+
1124
+ def fqdn(self, manager: "NodeManager" | None = None) -> str:
1125
+ domain = self.get_domain(manager)
1126
+ name = self.get_name()
1127
+ if name in {"@", ""}:
1128
+ return domain
1129
+ if name.endswith("."):
1130
+ return name.rstrip(".")
1131
+ if domain:
1132
+ return f"{name}.{domain}".rstrip(".")
1133
+ return name.rstrip(".")
1134
+
1135
+ def to_godaddy_payload(self) -> dict[str, object]:
1136
+ payload: dict[str, object] = {
1137
+ "data": (self.resolve_sigils("data") or "").strip(),
1138
+ "ttl": self.ttl,
1139
+ }
1140
+ if self.priority is not None:
1141
+ payload["priority"] = self.priority
1142
+ if self.port is not None:
1143
+ payload["port"] = self.port
1144
+ if self.weight is not None:
1145
+ payload["weight"] = self.weight
1146
+ service = (self.resolve_sigils("service") or "").strip()
1147
+ if service:
1148
+ payload["service"] = service
1149
+ protocol = (self.resolve_sigils("protocol") or "").strip()
1150
+ if protocol:
1151
+ payload["protocol"] = protocol
1152
+ return payload
1153
+
1154
+ def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
1155
+ if timestamp is None:
1156
+ timestamp = timezone.now()
1157
+ update_fields = ["last_synced_at", "last_error"]
1158
+ self.last_synced_at = timestamp
1159
+ self.last_error = ""
1160
+ if manager and self.node_manager_id != getattr(manager, "pk", None):
1161
+ self.node_manager = manager
1162
+ update_fields.append("node_manager")
1163
+ self.save(update_fields=update_fields)
1164
+
1165
+ def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
1166
+ update_fields = ["last_error"]
1167
+ self.last_error = message
1168
+ if manager and self.node_manager_id != getattr(manager, "pk", None):
1169
+ self.node_manager = manager
1170
+ update_fields.append("node_manager")
1171
+ self.save(update_fields=update_fields)
1172
+
1173
+
1174
+ class EmailOutbox(Profile):
1175
+ """SMTP credentials for sending mail."""
1176
+
1177
+ profile_fields = (
1178
+ "host",
1179
+ "port",
1180
+ "username",
1181
+ "password",
1182
+ "use_tls",
1183
+ "use_ssl",
1184
+ "from_email",
1185
+ )
1186
+
1187
+ node = models.OneToOneField(
1188
+ Node,
1189
+ on_delete=models.CASCADE,
1190
+ related_name="email_outbox",
1191
+ null=True,
1192
+ blank=True,
1193
+ )
1194
+ host = SigilShortAutoField(
1195
+ max_length=100,
1196
+ help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
1197
+ )
1198
+ port = models.PositiveIntegerField(
1199
+ default=587,
1200
+ help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
1201
+ )
1202
+ username = SigilShortAutoField(
1203
+ max_length=100,
1204
+ blank=True,
1205
+ help_text="Full email address for Gmail or GoDaddy",
1206
+ )
1207
+ password = SigilShortAutoField(
1208
+ max_length=100,
1209
+ blank=True,
1210
+ help_text="Email account password or app password",
1211
+ )
1212
+ use_tls = models.BooleanField(
1213
+ default=True,
1214
+ help_text="Check for Gmail or GoDaddy on port 587",
1215
+ )
1216
+ use_ssl = models.BooleanField(
1217
+ default=False,
1218
+ help_text="Check for GoDaddy on port 465; Gmail does not use SSL",
1219
+ )
1220
+ from_email = SigilShortAutoField(
1221
+ blank=True,
1222
+ verbose_name="From Email",
1223
+ max_length=254,
1224
+ help_text="Default From address; usually the same as username",
1225
+ )
1226
+ is_enabled = models.BooleanField(
1227
+ default=True,
1228
+ help_text="Disable to remove this outbox from automatic selection.",
1229
+ )
1230
+
1231
+ class Meta:
1232
+ verbose_name = "Email Outbox"
1233
+ verbose_name_plural = "Email Outboxes"
1234
+
1235
+ def __str__(self) -> str:
1236
+ address = (self.from_email or "").strip()
1237
+ if address:
1238
+ return address
1239
+
1240
+ username = (self.username or "").strip()
1241
+ host = (self.host or "").strip()
1242
+ if username:
1243
+ local, sep, domain = username.partition("@")
1244
+ if sep and domain:
1245
+ return username
1246
+ if host:
1247
+ sanitized = username.rstrip("@")
1248
+ if sanitized:
1249
+ return f"{sanitized}@{host}"
1250
+ return host
1251
+ return username
1252
+ if host:
1253
+ return host
1254
+
1255
+ owner = self.owner_display()
1256
+ if owner:
1257
+ return owner
1258
+
1259
+ return super().__str__()
1260
+
1261
+ def clean(self):
1262
+ if self.user_id or self.group_id:
1263
+ super().clean()
1264
+ else:
1265
+ super(Profile, self).clean()
1266
+
1267
+ def get_connection(self):
1268
+ return get_connection(
1269
+ "django.core.mail.backends.smtp.EmailBackend",
1270
+ host=self.host,
1271
+ port=self.port,
1272
+ username=self.username or None,
1273
+ password=self.password or None,
1274
+ use_tls=self.use_tls,
1275
+ use_ssl=self.use_ssl,
1276
+ )
1277
+
1278
+ def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
1279
+ from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
1280
+ logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
1281
+ return mailer.send(
1282
+ subject,
1283
+ message,
1284
+ recipient_list,
1285
+ from_email,
1286
+ outbox=self,
1287
+ **kwargs,
1288
+ )
1289
+
1290
+ def owner_display(self):
1291
+ owner = super().owner_display()
1292
+ if owner:
1293
+ return owner
1294
+ return str(self.node) if self.node_id else ""
1295
+
1296
+
1297
+ class NetMessage(Entity):
1298
+ """Message propagated across nodes."""
1299
+
1300
+ uuid = models.UUIDField(
1301
+ default=uuid.uuid4,
1302
+ unique=True,
1303
+ editable=False,
1304
+ verbose_name="UUID",
1305
+ )
1306
+ node_origin = models.ForeignKey(
1307
+ "Node",
1308
+ on_delete=models.SET_NULL,
1309
+ null=True,
1310
+ blank=True,
1311
+ related_name="originated_net_messages",
1312
+ )
1313
+ subject = models.CharField(max_length=64, blank=True)
1314
+ body = models.CharField(max_length=256, blank=True)
1315
+ attachments = models.JSONField(blank=True, null=True)
1316
+ filter_node = models.ForeignKey(
1317
+ "Node",
1318
+ on_delete=models.SET_NULL,
1319
+ null=True,
1320
+ blank=True,
1321
+ related_name="filtered_net_messages",
1322
+ verbose_name="Node",
1323
+ )
1324
+ filter_node_feature = models.ForeignKey(
1325
+ "NodeFeature",
1326
+ on_delete=models.SET_NULL,
1327
+ null=True,
1328
+ blank=True,
1329
+ verbose_name="Node feature",
1330
+ )
1331
+ filter_node_role = models.ForeignKey(
1332
+ NodeRole,
1333
+ on_delete=models.SET_NULL,
1334
+ null=True,
1335
+ blank=True,
1336
+ related_name="filtered_net_messages",
1337
+ verbose_name="Node role",
1338
+ )
1339
+ filter_current_relation = models.CharField(
1340
+ max_length=10,
1341
+ blank=True,
1342
+ choices=Node.Relation.choices,
1343
+ verbose_name="Current relation",
1344
+ )
1345
+ filter_installed_version = models.CharField(
1346
+ max_length=20,
1347
+ blank=True,
1348
+ verbose_name="Installed version",
1349
+ )
1350
+ filter_installed_revision = models.CharField(
1351
+ max_length=40,
1352
+ blank=True,
1353
+ verbose_name="Installed revision",
1354
+ )
1355
+ reach = models.ForeignKey(
1356
+ NodeRole,
1357
+ on_delete=models.SET_NULL,
1358
+ null=True,
1359
+ blank=True,
1360
+ )
1361
+ target_limit = models.PositiveSmallIntegerField(
1362
+ default=6,
1363
+ blank=True,
1364
+ null=True,
1365
+ help_text="Maximum number of peers to contact when propagating.",
1366
+ )
1367
+ propagated_to = models.ManyToManyField(
1368
+ Node, blank=True, related_name="received_net_messages"
1369
+ )
1370
+ created = models.DateTimeField(auto_now_add=True)
1371
+ complete = models.BooleanField(default=False, editable=False)
1372
+
1373
+ class Meta:
1374
+ ordering = ["-created"]
1375
+ verbose_name = "Net Message"
1376
+ verbose_name_plural = "Net Messages"
1377
+
1378
+ @classmethod
1379
+ def broadcast(
1380
+ cls,
1381
+ subject: str,
1382
+ body: str,
1383
+ reach: NodeRole | str | None = None,
1384
+ seen: list[str] | None = None,
1385
+ attachments: list[dict[str, object]] | None = None,
1386
+ ):
1387
+ role = None
1388
+ if reach:
1389
+ if isinstance(reach, NodeRole):
1390
+ role = reach
1391
+ else:
1392
+ role = NodeRole.objects.filter(name=reach).first()
1393
+ else:
1394
+ role = NodeRole.objects.filter(name="Terminal").first()
1395
+ origin = Node.get_local()
1396
+ normalized_attachments = cls.normalize_attachments(attachments)
1397
+ msg = cls.objects.create(
1398
+ subject=subject[:64],
1399
+ body=body[:256],
1400
+ reach=role,
1401
+ node_origin=origin,
1402
+ attachments=normalized_attachments or None,
1403
+ )
1404
+ if normalized_attachments:
1405
+ msg.apply_attachments(normalized_attachments)
1406
+ msg.propagate(seen=seen or [])
1407
+ return msg
1408
+
1409
+ @staticmethod
1410
+ def normalize_attachments(
1411
+ attachments: object,
1412
+ ) -> list[dict[str, object]]:
1413
+ if not attachments or not isinstance(attachments, list):
1414
+ return []
1415
+ normalized: list[dict[str, object]] = []
1416
+ for item in attachments:
1417
+ if not isinstance(item, dict):
1418
+ continue
1419
+ model_label = item.get("model")
1420
+ fields = item.get("fields")
1421
+ if not isinstance(model_label, str) or not isinstance(fields, dict):
1422
+ continue
1423
+ normalized_item: dict[str, object] = {
1424
+ "model": model_label,
1425
+ "fields": deepcopy(fields),
1426
+ }
1427
+ if "pk" in item:
1428
+ normalized_item["pk"] = item["pk"]
1429
+ normalized.append(normalized_item)
1430
+ return normalized
1431
+
1432
+ def apply_attachments(
1433
+ self, attachments: list[dict[str, object]] | None = None
1434
+ ) -> None:
1435
+ payload = attachments if attachments is not None else self.attachments or []
1436
+ if not payload:
1437
+ return
1438
+ try:
1439
+ objects = list(
1440
+ serializers.deserialize(
1441
+ "python", deepcopy(payload), ignorenonexistent=True
1442
+ )
1443
+ )
1444
+ except DeserializationError:
1445
+ logger.exception("Failed to deserialize attachments for NetMessage %s", self.pk)
1446
+ return
1447
+ for obj in objects:
1448
+ try:
1449
+ obj.save()
1450
+ except Exception:
1451
+ logger.exception(
1452
+ "Failed to save attachment %s for NetMessage %s",
1453
+ getattr(obj, "object", obj),
1454
+ self.pk,
1455
+ )
1456
+
1457
+ def propagate(self, seen: list[str] | None = None):
1458
+ from core.notifications import notify
1459
+ import random
1460
+ import requests
1461
+
1462
+ displayed = notify(self.subject, self.body)
1463
+ local = Node.get_local()
1464
+ if displayed:
1465
+ cutoff = timezone.now() - timedelta(days=7)
1466
+ prune_qs = type(self).objects.filter(created__lt=cutoff)
1467
+ if local:
1468
+ prune_qs = prune_qs.filter(
1469
+ models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
1470
+ )
1471
+ else:
1472
+ prune_qs = prune_qs.filter(node_origin__isnull=True)
1473
+ if self.pk:
1474
+ prune_qs = prune_qs.exclude(pk=self.pk)
1475
+ prune_qs.delete()
1476
+ if local and not self.node_origin_id:
1477
+ self.node_origin = local
1478
+ self.save(update_fields=["node_origin"])
1479
+ origin_uuid = None
1480
+ if self.node_origin_id:
1481
+ origin_uuid = str(self.node_origin.uuid)
1482
+ elif local:
1483
+ origin_uuid = str(local.uuid)
1484
+ private_key = None
1485
+ seen = list(seen or [])
1486
+ local_id = None
1487
+ if local:
1488
+ local_id = str(local.uuid)
1489
+ if local_id not in seen:
1490
+ seen.append(local_id)
1491
+ priv_path = (
1492
+ Path(local.base_path or settings.BASE_DIR)
1493
+ / "security"
1494
+ / f"{local.public_endpoint}"
1495
+ )
1496
+ try:
1497
+ private_key = serialization.load_pem_private_key(
1498
+ priv_path.read_bytes(), password=None
1499
+ )
1500
+ except Exception:
1501
+ private_key = None
1502
+ for node_id in seen:
1503
+ node = Node.objects.filter(uuid=node_id).first()
1504
+ if node and (not local or node.pk != local.pk):
1505
+ self.propagated_to.add(node)
1506
+
1507
+ filtered_nodes = Node.objects.all()
1508
+ if self.filter_node_id:
1509
+ filtered_nodes = filtered_nodes.filter(pk=self.filter_node_id)
1510
+ if self.filter_node_feature_id:
1511
+ filtered_nodes = filtered_nodes.filter(
1512
+ features__pk=self.filter_node_feature_id
1513
+ )
1514
+ if self.filter_node_role_id:
1515
+ filtered_nodes = filtered_nodes.filter(role_id=self.filter_node_role_id)
1516
+ if self.filter_current_relation:
1517
+ filtered_nodes = filtered_nodes.filter(
1518
+ current_relation=self.filter_current_relation
1519
+ )
1520
+ if self.filter_installed_version:
1521
+ filtered_nodes = filtered_nodes.filter(
1522
+ installed_version=self.filter_installed_version
1523
+ )
1524
+ if self.filter_installed_revision:
1525
+ filtered_nodes = filtered_nodes.filter(
1526
+ installed_revision=self.filter_installed_revision
1527
+ )
1528
+
1529
+ filtered_nodes = filtered_nodes.distinct()
1530
+
1531
+ if local:
1532
+ filtered_nodes = filtered_nodes.exclude(pk=local.pk)
1533
+ total_known = filtered_nodes.count()
1534
+
1535
+ remaining = list(
1536
+ filtered_nodes.exclude(
1537
+ pk__in=self.propagated_to.values_list("pk", flat=True)
1538
+ )
1539
+ )
1540
+ if not remaining:
1541
+ self.complete = True
1542
+ self.save(update_fields=["complete"])
1543
+ return
1544
+
1545
+ limit = self.target_limit or 6
1546
+ target_limit = min(limit, len(remaining))
1547
+
1548
+ reach_source = self.filter_node_role or self.reach
1549
+ reach_name = reach_source.name if reach_source else None
1550
+ role_map = {
1551
+ "Terminal": ["Terminal"],
1552
+ "Control": ["Control", "Terminal"],
1553
+ "Satellite": ["Satellite", "Control", "Terminal"],
1554
+ "Constellation": [
1555
+ "Constellation",
1556
+ "Satellite",
1557
+ "Control",
1558
+ "Terminal",
1559
+ ],
1560
+ }
1561
+ selected: list[Node] = []
1562
+ if self.filter_node_id:
1563
+ target = next((n for n in remaining if n.pk == self.filter_node_id), None)
1564
+ if target:
1565
+ selected = [target]
1566
+ else:
1567
+ self.complete = True
1568
+ self.save(update_fields=["complete"])
1569
+ return
1570
+ else:
1571
+ if self.filter_node_role_id:
1572
+ role_order = [reach_name]
1573
+ else:
1574
+ role_order = role_map.get(reach_name, [None])
1575
+ for role_name in role_order:
1576
+ if role_name is None:
1577
+ role_nodes = remaining[:]
1578
+ else:
1579
+ role_nodes = [
1580
+ n for n in remaining if n.role and n.role.name == role_name
1581
+ ]
1582
+ random.shuffle(role_nodes)
1583
+ for n in role_nodes:
1584
+ selected.append(n)
1585
+ remaining.remove(n)
1586
+ if len(selected) >= target_limit:
1587
+ break
1588
+ if len(selected) >= target_limit:
1589
+ break
1590
+
1591
+ if not selected:
1592
+ self.complete = True
1593
+ self.save(update_fields=["complete"])
1594
+ return
1595
+
1596
+ seen_list = seen.copy()
1597
+ selected_ids = [str(n.uuid) for n in selected]
1598
+ payload_seen = seen_list + selected_ids
1599
+ for node in selected:
1600
+ payload = {
1601
+ "uuid": str(self.uuid),
1602
+ "subject": self.subject,
1603
+ "body": self.body,
1604
+ "seen": payload_seen,
1605
+ "reach": reach_name,
1606
+ "sender": local_id,
1607
+ "origin": origin_uuid,
1608
+ }
1609
+ if self.attachments:
1610
+ payload["attachments"] = self.attachments
1611
+ if self.filter_node:
1612
+ payload["filter_node"] = str(self.filter_node.uuid)
1613
+ if self.filter_node_feature:
1614
+ payload["filter_node_feature"] = self.filter_node_feature.slug
1615
+ if self.filter_node_role:
1616
+ payload["filter_node_role"] = self.filter_node_role.name
1617
+ if self.filter_current_relation:
1618
+ payload["filter_current_relation"] = self.filter_current_relation
1619
+ if self.filter_installed_version:
1620
+ payload["filter_installed_version"] = self.filter_installed_version
1621
+ if self.filter_installed_revision:
1622
+ payload["filter_installed_revision"] = self.filter_installed_revision
1623
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1624
+ headers = {"Content-Type": "application/json"}
1625
+ if private_key:
1626
+ try:
1627
+ signature = private_key.sign(
1628
+ payload_json.encode(),
1629
+ padding.PKCS1v15(),
1630
+ hashes.SHA256(),
1631
+ )
1632
+ headers["X-Signature"] = base64.b64encode(signature).decode()
1633
+ except Exception:
1634
+ pass
1635
+ try:
1636
+ requests.post(
1637
+ f"http://{node.address}:{node.port}/nodes/net-message/",
1638
+ data=payload_json,
1639
+ headers=headers,
1640
+ timeout=1,
1641
+ )
1642
+ except Exception:
1643
+ pass
1644
+ self.propagated_to.add(node)
1645
+
1646
+ if total_known and self.propagated_to.count() >= total_known:
1647
+ self.complete = True
1648
+ self.save(update_fields=["complete"] if self.complete else [])
1649
+
1650
+
1651
+ class ContentSample(Entity):
1652
+ """Collected content such as text snippets or screenshots."""
1653
+
1654
+ TEXT = "TEXT"
1655
+ IMAGE = "IMAGE"
1656
+ KIND_CHOICES = [(TEXT, "Text"), (IMAGE, "Image")]
1657
+
1658
+ name = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
1659
+ kind = models.CharField(max_length=10, choices=KIND_CHOICES)
1660
+ content = models.TextField(blank=True)
1661
+ path = models.CharField(max_length=255, blank=True)
1662
+ method = models.CharField(max_length=10, default="", blank=True)
1663
+ hash = models.CharField(max_length=64, unique=True, null=True, blank=True)
1664
+ transaction_uuid = models.UUIDField(
1665
+ default=uuid.uuid4,
1666
+ editable=True,
1667
+ db_index=True,
1668
+ verbose_name="transaction UUID",
1669
+ )
1670
+ node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
1671
+ user = models.ForeignKey(
1672
+ settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
1673
+ )
1674
+ created_at = models.DateTimeField(auto_now_add=True)
1675
+
1676
+ class Meta:
1677
+ ordering = ["-created_at"]
1678
+ verbose_name = "Content Sample"
1679
+ verbose_name_plural = "Content Samples"
1680
+
1681
+ def save(self, *args, **kwargs):
1682
+ if self.pk:
1683
+ original = type(self).all_objects.get(pk=self.pk)
1684
+ if original.transaction_uuid != self.transaction_uuid:
1685
+ raise ValidationError(
1686
+ {"transaction_uuid": "Cannot modify transaction UUID"}
1687
+ )
1688
+ if self.node_id is None:
1689
+ self.node = Node.get_local()
1690
+ super().save(*args, **kwargs)
1691
+
1692
+ def __str__(self) -> str: # pragma: no cover - simple representation
1693
+ return str(self.name)
1694
+
1695
+
1696
+ class ContentClassifier(Entity):
1697
+ """Configured callable that classifies :class:`ContentSample` objects."""
1698
+
1699
+ slug = models.SlugField(max_length=100, unique=True)
1700
+ label = models.CharField(max_length=150)
1701
+ kind = models.CharField(max_length=10, choices=ContentSample.KIND_CHOICES)
1702
+ entrypoint = models.CharField(max_length=255, help_text="Dotted path to classifier callable")
1703
+ run_by_default = models.BooleanField(default=True)
1704
+ active = models.BooleanField(default=True)
1705
+
1706
+ class Meta:
1707
+ ordering = ["label"]
1708
+ verbose_name = "Content Classifier"
1709
+ verbose_name_plural = "Content Classifiers"
1710
+
1711
+ def __str__(self) -> str: # pragma: no cover - simple representation
1712
+ return self.label
1713
+
1714
+
1715
+ class ContentTag(Entity):
1716
+ """Tag that can be attached to classified content samples."""
1717
+
1718
+ slug = models.SlugField(max_length=100, unique=True)
1719
+ label = models.CharField(max_length=150)
1720
+
1721
+ class Meta:
1722
+ ordering = ["label"]
1723
+ verbose_name = "Content Tag"
1724
+ verbose_name_plural = "Content Tags"
1725
+
1726
+ def __str__(self) -> str: # pragma: no cover - simple representation
1727
+ return self.label
1728
+
1729
+
1730
+ class ContentClassification(Entity):
1731
+ """Link between a sample, classifier, and assigned tag."""
1732
+
1733
+ sample = models.ForeignKey(
1734
+ ContentSample, on_delete=models.CASCADE, related_name="classifications"
1735
+ )
1736
+ classifier = models.ForeignKey(
1737
+ ContentClassifier, on_delete=models.CASCADE, related_name="classifications"
1738
+ )
1739
+ tag = models.ForeignKey(
1740
+ ContentTag, on_delete=models.CASCADE, related_name="classifications"
1741
+ )
1742
+ confidence = models.FloatField(null=True, blank=True)
1743
+ metadata = models.JSONField(blank=True, null=True)
1744
+ created_at = models.DateTimeField(auto_now_add=True)
1745
+
1746
+ class Meta:
1747
+ unique_together = ("sample", "classifier", "tag")
1748
+ ordering = ["-created_at"]
1749
+ verbose_name = "Content Classification"
1750
+ verbose_name_plural = "Content Classifications"
1751
+
1752
+ def __str__(self) -> str: # pragma: no cover - simple representation
1753
+ return f"{self.sample} → {self.tag}"
1754
+
1755
+
1756
+ UserModel = get_user_model()
1757
+
1758
+
1759
+ class User(UserModel):
1760
+ class Meta:
1761
+ proxy = True
1762
+ app_label = "nodes"
1763
+ verbose_name = UserModel._meta.verbose_name
1764
+ verbose_name_plural = UserModel._meta.verbose_name_plural