arthexis 0.1.13__py3-none-any.whl → 0.1.14__py3-none-any.whl

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

Potentially problematic release.


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

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