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

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

Potentially problematic release.


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

Files changed (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
nodes/models.py CHANGED
@@ -1,870 +1,2375 @@
1
- from collections.abc import Iterable
2
- from django.db import models
3
- from django.db.models.signals import post_delete
4
- from django.dispatch import receiver
5
- from core.entity import Entity
6
- from core.models import Profile
7
- from core.fields import SigilShortAutoField
8
- import re
9
- import json
10
- import base64
11
- from django.utils.text import slugify
12
- from django.conf import settings
13
- from django.contrib.sites.models import Site
14
- import uuid
15
- import os
16
- import shutil
17
- import socket
18
- import stat
19
- import subprocess
20
- from pathlib import Path
21
- from utils import revision
22
- from django.core.exceptions import ValidationError
23
- from cryptography.hazmat.primitives.asymmetric import rsa
24
- from cryptography.hazmat.primitives import serialization, hashes
25
- from cryptography.hazmat.primitives.asymmetric import padding
26
- from django.contrib.auth import get_user_model
27
- from django.core.mail import get_connection
28
- from core import mailer
29
- import logging
30
-
31
-
32
- logger = logging.getLogger(__name__)
33
-
34
-
35
- class NodeRoleManager(models.Manager):
36
- def get_by_natural_key(self, name: str):
37
- return self.get(name=name)
38
-
39
-
40
- class NodeRole(Entity):
41
- """Assignable role for a :class:`Node`."""
42
-
43
- name = models.CharField(max_length=50, unique=True)
44
- description = models.CharField(max_length=200, blank=True)
45
-
46
- objects = NodeRoleManager()
47
-
48
- class Meta:
49
- ordering = ["name"]
50
- verbose_name = "Node Role"
51
- verbose_name_plural = "Node Roles"
52
-
53
- def natural_key(self): # pragma: no cover - simple representation
54
- return (self.name,)
55
-
56
- def __str__(self) -> str: # pragma: no cover - simple representation
57
- return self.name
58
-
59
-
60
- class NodeFeatureManager(models.Manager):
61
- def get_by_natural_key(self, slug: str):
62
- return self.get(slug=slug)
63
-
64
-
65
- class NodeFeature(Entity):
66
- """Feature that may be enabled on nodes and roles."""
67
-
68
- slug = models.SlugField(max_length=50, unique=True)
69
- display = models.CharField(max_length=50)
70
- roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
71
-
72
- objects = NodeFeatureManager()
73
-
74
- class Meta:
75
- ordering = ["display"]
76
- verbose_name = "Node Feature"
77
- verbose_name_plural = "Node Features"
78
-
79
- def natural_key(self): # pragma: no cover - simple representation
80
- return (self.slug,)
81
-
82
- def __str__(self) -> str: # pragma: no cover - simple representation
83
- return self.display
84
-
85
- @property
86
- def is_enabled(self) -> bool:
87
- from django.conf import settings
88
- from pathlib import Path
89
-
90
- node = Node.get_local()
91
- if not node:
92
- return False
93
- if node.features.filter(pk=self.pk).exists():
94
- return True
95
- if self.slug == "gui-toast":
96
- from core.notifications import supports_gui_toast
97
-
98
- return supports_gui_toast()
99
- if self.slug == "rpi-camera":
100
- return Node._has_rpi_camera()
101
- lock_map = {
102
- "lcd-screen": "lcd_screen.lck",
103
- "rfid-scanner": "rfid.lck",
104
- "celery-queue": "celery.lck",
105
- "nginx-server": "nginx_mode.lck",
106
- }
107
- lock = lock_map.get(self.slug)
108
- if lock:
109
- base_path = Path(node.base_path or settings.BASE_DIR)
110
- return (base_path / "locks" / lock).exists()
111
- return False
112
-
113
-
114
- def get_terminal_role():
115
- """Return the NodeRole representing a Terminal if it exists."""
116
- return NodeRole.objects.filter(name="Terminal").first()
117
-
118
-
119
- class Node(Entity):
120
- """Information about a running node in the network."""
121
-
122
- hostname = models.CharField(max_length=100)
123
- address = models.GenericIPAddressField()
124
- mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
125
- port = models.PositiveIntegerField(default=8000)
126
- badge_color = models.CharField(max_length=7, default="#28a745")
127
- role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
128
- last_seen = models.DateTimeField(auto_now=True)
129
- enable_public_api = models.BooleanField(
130
- default=False,
131
- verbose_name="enable public API",
132
- )
133
- public_endpoint = models.SlugField(blank=True, unique=True)
134
- uuid = models.UUIDField(
135
- default=uuid.uuid4,
136
- unique=True,
137
- editable=False,
138
- verbose_name="UUID",
139
- )
140
- public_key = models.TextField(blank=True)
141
- base_path = models.CharField(max_length=255, blank=True)
142
- installed_version = models.CharField(max_length=20, blank=True)
143
- installed_revision = models.CharField(max_length=40, blank=True)
144
- features = models.ManyToManyField(
145
- NodeFeature,
146
- through="NodeFeatureAssignment",
147
- related_name="nodes",
148
- blank=True,
149
- )
150
-
151
- FEATURE_LOCK_MAP = {
152
- "lcd-screen": "lcd_screen.lck",
153
- "rfid-scanner": "rfid.lck",
154
- "celery-queue": "celery.lck",
155
- "nginx-server": "nginx_mode.lck",
156
- }
157
- RPI_CAMERA_DEVICE = Path("/dev/video0")
158
- RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
159
- AP_ROUTER_SSID = "gelectriic-ap"
160
- NMCLI_TIMEOUT = 5
161
- AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
162
- "gui-toast",
163
- "rpi-camera",
164
- "ap-router",
165
- "ap-public-wifi",
166
- "postgres-db",
167
- }
168
- MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
169
-
170
- def __str__(self) -> str: # pragma: no cover - simple representation
171
- return f"{self.hostname}:{self.port}"
172
-
173
- @staticmethod
174
- def get_current_mac() -> str:
175
- """Return the MAC address of the current host."""
176
- return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
177
-
178
- @classmethod
179
- def get_local(cls):
180
- """Return the node representing the current host if it exists."""
181
- mac = cls.get_current_mac()
182
- return cls.objects.filter(mac_address=mac).first()
183
-
184
- @classmethod
185
- def register_current(cls):
186
- """Create or update the :class:`Node` entry for this host."""
187
- hostname = socket.gethostname()
188
- try:
189
- address = socket.gethostbyname(hostname)
190
- except OSError:
191
- address = "127.0.0.1"
192
- port = int(os.environ.get("PORT", 8000))
193
- base_path = str(settings.BASE_DIR)
194
- ver_path = Path(settings.BASE_DIR) / "VERSION"
195
- installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
196
- rev_value = revision.get_revision()
197
- installed_revision = rev_value if rev_value else ""
198
- mac = cls.get_current_mac()
199
- slug = slugify(hostname)
200
- node = cls.objects.filter(mac_address=mac).first()
201
- if not node:
202
- node = cls.objects.filter(public_endpoint=slug).first()
203
- defaults = {
204
- "hostname": hostname,
205
- "address": address,
206
- "port": port,
207
- "base_path": base_path,
208
- "installed_version": installed_version,
209
- "installed_revision": installed_revision,
210
- "public_endpoint": slug,
211
- "mac_address": mac,
212
- }
213
- if node:
214
- for field, value in defaults.items():
215
- setattr(node, field, value)
216
- update_fields = list(defaults.keys())
217
- node.save(update_fields=update_fields)
218
- created = False
219
- else:
220
- node = cls.objects.create(**defaults)
221
- created = True
222
- # assign role from installation lock file
223
- role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
224
- role_name = (
225
- role_lock.read_text().strip() if role_lock.exists() else "Terminal"
226
- )
227
- role = NodeRole.objects.filter(name=role_name).first()
228
- if role:
229
- node.role = role
230
- node.save(update_fields=["role"])
231
- if created and node.role is None:
232
- terminal = NodeRole.objects.filter(name="Terminal").first()
233
- if terminal:
234
- node.role = terminal
235
- node.save(update_fields=["role"])
236
- Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
237
- node.ensure_keys()
238
- return node, created
239
-
240
- def ensure_keys(self):
241
- security_dir = Path(settings.BASE_DIR) / "security"
242
- security_dir.mkdir(parents=True, exist_ok=True)
243
- priv_path = security_dir / f"{self.public_endpoint}"
244
- pub_path = security_dir / f"{self.public_endpoint}.pub"
245
- if not priv_path.exists() or not pub_path.exists():
246
- private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
247
- private_bytes = private_key.private_bytes(
248
- encoding=serialization.Encoding.PEM,
249
- format=serialization.PrivateFormat.TraditionalOpenSSL,
250
- encryption_algorithm=serialization.NoEncryption(),
251
- )
252
- public_bytes = private_key.public_key().public_bytes(
253
- encoding=serialization.Encoding.PEM,
254
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
255
- )
256
- priv_path.write_bytes(private_bytes)
257
- pub_path.write_bytes(public_bytes)
258
- self.public_key = public_bytes.decode()
259
- self.save(update_fields=["public_key"])
260
- elif not self.public_key:
261
- self.public_key = pub_path.read_text()
262
- self.save(update_fields=["public_key"])
263
-
264
- @property
265
- def is_local(self):
266
- """Determine if this node represents the current host."""
267
- return self.mac_address == self.get_current_mac()
268
-
269
- def save(self, *args, **kwargs):
270
- if self.mac_address:
271
- self.mac_address = self.mac_address.lower()
272
- if not self.public_endpoint:
273
- self.public_endpoint = slugify(self.hostname)
274
- super().save(*args, **kwargs)
275
- if self.pk:
276
- self.refresh_features()
277
-
278
- def has_feature(self, slug: str) -> bool:
279
- return self.features.filter(slug=slug).exists()
280
-
281
- @classmethod
282
- def _has_rpi_camera(cls) -> bool:
283
- """Return ``True`` when the Raspberry Pi camera stack is available."""
284
-
285
- device = cls.RPI_CAMERA_DEVICE
286
- if not device.exists():
287
- return False
288
- device_path = str(device)
289
- try:
290
- mode = os.stat(device_path).st_mode
291
- except OSError:
292
- return False
293
- if not stat.S_ISCHR(mode):
294
- return False
295
- if not os.access(device_path, os.R_OK | os.W_OK):
296
- return False
297
- for binary in cls.RPI_CAMERA_BINARIES:
298
- tool_path = shutil.which(binary)
299
- if not tool_path:
300
- return False
301
- try:
302
- result = subprocess.run(
303
- [tool_path, "--help"],
304
- capture_output=True,
305
- text=True,
306
- check=False,
307
- timeout=5,
308
- )
309
- except Exception:
310
- return False
311
- if result.returncode != 0:
312
- return False
313
- return True
314
-
315
- @classmethod
316
- def _hosts_gelectriic_ap(cls) -> bool:
317
- """Return ``True`` when the node is hosting the gelectriic access point."""
318
-
319
- nmcli_path = shutil.which("nmcli")
320
- if not nmcli_path:
321
- return False
322
- try:
323
- result = subprocess.run(
324
- [
325
- nmcli_path,
326
- "-t",
327
- "-f",
328
- "NAME,DEVICE,TYPE",
329
- "connection",
330
- "show",
331
- "--active",
332
- ],
333
- capture_output=True,
334
- text=True,
335
- check=False,
336
- timeout=cls.NMCLI_TIMEOUT,
337
- )
338
- except Exception:
339
- return False
340
- if result.returncode != 0:
341
- return False
342
- for line in result.stdout.splitlines():
343
- if not line:
344
- continue
345
- parts = line.split(":", 2)
346
- if not parts:
347
- continue
348
- name = parts[0]
349
- conn_type = ""
350
- if len(parts) == 3:
351
- conn_type = parts[2]
352
- elif len(parts) > 1:
353
- conn_type = parts[1]
354
- if name != cls.AP_ROUTER_SSID:
355
- continue
356
- conn_type_normalized = conn_type.strip().lower()
357
- if conn_type_normalized not in {"wifi", "802-11-wireless"}:
358
- continue
359
- try:
360
- mode_result = subprocess.run(
361
- [
362
- nmcli_path,
363
- "-g",
364
- "802-11-wireless.mode",
365
- "connection",
366
- "show",
367
- name,
368
- ],
369
- capture_output=True,
370
- text=True,
371
- check=False,
372
- timeout=cls.NMCLI_TIMEOUT,
373
- )
374
- except Exception:
375
- continue
376
- if mode_result.returncode != 0:
377
- continue
378
- if mode_result.stdout.strip() == "ap":
379
- return True
380
- return False
381
-
382
- @staticmethod
383
- def _uses_postgres() -> bool:
384
- """Return ``True`` when the default database uses PostgreSQL."""
385
-
386
- engine = settings.DATABASES.get("default", {}).get("ENGINE", "")
387
- return "postgresql" in engine.lower()
388
-
389
- def refresh_features(self):
390
- if not self.pk:
391
- return
392
- if not self.is_local:
393
- self.sync_feature_tasks()
394
- return
395
- detected_slugs = set()
396
- base_path = Path(self.base_path or settings.BASE_DIR)
397
- locks_dir = base_path / "locks"
398
- for slug, filename in self.FEATURE_LOCK_MAP.items():
399
- if (locks_dir / filename).exists():
400
- detected_slugs.add(slug)
401
- if self._has_rpi_camera():
402
- detected_slugs.add("rpi-camera")
403
- public_mode_lock = locks_dir / "public_wifi_mode.lck"
404
- if self._hosts_gelectriic_ap():
405
- if public_mode_lock.exists():
406
- detected_slugs.add("ap-public-wifi")
407
- else:
408
- detected_slugs.add("ap-router")
409
- if self._uses_postgres():
410
- detected_slugs.add("postgres-db")
411
- try:
412
- from core.notifications import supports_gui_toast
413
- except Exception:
414
- pass
415
- else:
416
- try:
417
- if supports_gui_toast():
418
- detected_slugs.add("gui-toast")
419
- except Exception:
420
- pass
421
- current_slugs = set(
422
- self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
423
- "slug", flat=True
424
- )
425
- )
426
- add_slugs = detected_slugs - current_slugs
427
- if add_slugs:
428
- for feature in NodeFeature.objects.filter(slug__in=add_slugs):
429
- NodeFeatureAssignment.objects.update_or_create(
430
- node=self, feature=feature
431
- )
432
- remove_slugs = current_slugs - detected_slugs
433
- if remove_slugs:
434
- NodeFeatureAssignment.objects.filter(
435
- node=self, feature__slug__in=remove_slugs
436
- ).delete()
437
- self.sync_feature_tasks()
438
-
439
- def update_manual_features(self, slugs: Iterable[str]):
440
- desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
441
- remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
442
- if remove_slugs:
443
- NodeFeatureAssignment.objects.filter(
444
- node=self, feature__slug__in=remove_slugs
445
- ).delete()
446
- if desired:
447
- for feature in NodeFeature.objects.filter(slug__in=desired):
448
- NodeFeatureAssignment.objects.update_or_create(
449
- node=self, feature=feature
450
- )
451
- self.sync_feature_tasks()
452
-
453
- def sync_feature_tasks(self):
454
- clipboard_enabled = self.has_feature("clipboard-poll")
455
- screenshot_enabled = self.has_feature("screenshot-poll")
456
- self._sync_clipboard_task(clipboard_enabled)
457
- self._sync_screenshot_task(screenshot_enabled)
458
-
459
- def _sync_clipboard_task(self, enabled: bool):
460
- from django_celery_beat.models import IntervalSchedule, PeriodicTask
461
-
462
- task_name = f"poll_clipboard_node_{self.pk}"
463
- if enabled:
464
- schedule, _ = IntervalSchedule.objects.get_or_create(
465
- every=5, period=IntervalSchedule.SECONDS
466
- )
467
- PeriodicTask.objects.update_or_create(
468
- name=task_name,
469
- defaults={
470
- "interval": schedule,
471
- "task": "nodes.tasks.sample_clipboard",
472
- },
473
- )
474
- else:
475
- PeriodicTask.objects.filter(name=task_name).delete()
476
-
477
- def _sync_screenshot_task(self, enabled: bool):
478
- from django_celery_beat.models import IntervalSchedule, PeriodicTask
479
- import json
480
-
481
- task_name = f"capture_screenshot_node_{self.pk}"
482
- if enabled:
483
- schedule, _ = IntervalSchedule.objects.get_or_create(
484
- every=1, period=IntervalSchedule.MINUTES
485
- )
486
- PeriodicTask.objects.update_or_create(
487
- name=task_name,
488
- defaults={
489
- "interval": schedule,
490
- "task": "nodes.tasks.capture_node_screenshot",
491
- "kwargs": json.dumps(
492
- {
493
- "url": f"http://localhost:{self.port}",
494
- "port": self.port,
495
- "method": "AUTO",
496
- }
497
- ),
498
- },
499
- )
500
- else:
501
- PeriodicTask.objects.filter(name=task_name).delete()
502
-
503
- def send_mail(
504
- self,
505
- subject: str,
506
- message: str,
507
- recipient_list: list[str],
508
- from_email: str | None = None,
509
- **kwargs,
510
- ):
511
- """Send an email using this node's configured outbox if available."""
512
- outbox = getattr(self, "email_outbox", None)
513
- logger.info(
514
- "Node %s queueing email to %s using %s backend",
515
- self.pk,
516
- recipient_list,
517
- "outbox" if outbox else "default",
518
- )
519
- return mailer.send(
520
- subject,
521
- message,
522
- recipient_list,
523
- from_email,
524
- outbox=outbox,
525
- **kwargs,
526
- )
527
-
528
-
529
- class NodeFeatureAssignment(Entity):
530
- """Bridge between :class:`Node` and :class:`NodeFeature`."""
531
-
532
- node = models.ForeignKey(
533
- Node, on_delete=models.CASCADE, related_name="feature_assignments"
534
- )
535
- feature = models.ForeignKey(
536
- NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
537
- )
538
- created_at = models.DateTimeField(auto_now_add=True)
539
-
540
- class Meta:
541
- unique_together = ("node", "feature")
542
- verbose_name = "Node Feature Assignment"
543
- verbose_name_plural = "Node Feature Assignments"
544
-
545
- def __str__(self) -> str: # pragma: no cover - simple representation
546
- return f"{self.node} -> {self.feature}"
547
-
548
- def save(self, *args, **kwargs):
549
- super().save(*args, **kwargs)
550
- self.node.sync_feature_tasks()
551
-
552
-
553
- @receiver(post_delete, sender=NodeFeatureAssignment)
554
- def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
555
- node_id = getattr(instance, "node_id", None)
556
- if not node_id:
557
- return
558
- node = Node.objects.filter(pk=node_id).first()
559
- if node:
560
- node.sync_feature_tasks()
561
-
562
-
563
- class EmailOutbox(Profile):
564
- """SMTP credentials for sending mail."""
565
-
566
- profile_fields = (
567
- "host",
568
- "port",
569
- "username",
570
- "password",
571
- "use_tls",
572
- "use_ssl",
573
- "from_email",
574
- )
575
-
576
- node = models.OneToOneField(
577
- Node,
578
- on_delete=models.CASCADE,
579
- related_name="email_outbox",
580
- null=True,
581
- blank=True,
582
- )
583
- host = SigilShortAutoField(
584
- max_length=100,
585
- help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
586
- )
587
- port = models.PositiveIntegerField(
588
- default=587,
589
- help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
590
- )
591
- username = SigilShortAutoField(
592
- max_length=100,
593
- blank=True,
594
- help_text="Full email address for Gmail or GoDaddy",
595
- )
596
- password = SigilShortAutoField(
597
- max_length=100,
598
- blank=True,
599
- help_text="Email account password or app password",
600
- )
601
- use_tls = models.BooleanField(
602
- default=True,
603
- help_text="Check for Gmail or GoDaddy on port 587",
604
- )
605
- use_ssl = models.BooleanField(
606
- default=False,
607
- help_text="Check for GoDaddy on port 465; Gmail does not use SSL",
608
- )
609
- from_email = SigilShortAutoField(
610
- blank=True,
611
- verbose_name="From Email",
612
- max_length=254,
613
- help_text="Default From address; usually the same as username",
614
- )
615
-
616
- class Meta:
617
- verbose_name = "Email Outbox"
618
- verbose_name_plural = "Email Outboxes"
619
-
620
- def clean(self):
621
- if self.user_id or self.group_id:
622
- super().clean()
623
- else:
624
- super(Profile, self).clean()
625
-
626
- def get_connection(self):
627
- return get_connection(
628
- "django.core.mail.backends.smtp.EmailBackend",
629
- host=self.host,
630
- port=self.port,
631
- username=self.username or None,
632
- password=self.password or None,
633
- use_tls=self.use_tls,
634
- use_ssl=self.use_ssl,
635
- )
636
-
637
- def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
638
- from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
639
- logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
640
- return mailer.send(
641
- subject,
642
- message,
643
- recipient_list,
644
- from_email,
645
- outbox=self,
646
- **kwargs,
647
- )
648
-
649
- def owner_display(self):
650
- owner = super().owner_display()
651
- if owner:
652
- return owner
653
- return str(self.node) if self.node_id else ""
654
-
655
-
656
- class NetMessage(Entity):
657
- """Message propagated across nodes."""
658
-
659
- uuid = models.UUIDField(
660
- default=uuid.uuid4,
661
- unique=True,
662
- editable=False,
663
- verbose_name="UUID",
664
- )
665
- subject = models.CharField(max_length=64, blank=True)
666
- body = models.CharField(max_length=256, blank=True)
667
- reach = models.ForeignKey(
668
- NodeRole,
669
- on_delete=models.SET_NULL,
670
- null=True,
671
- blank=True,
672
- default=get_terminal_role,
673
- )
674
- propagated_to = models.ManyToManyField(
675
- Node, blank=True, related_name="received_net_messages"
676
- )
677
- created = models.DateTimeField(auto_now_add=True)
678
- complete = models.BooleanField(default=False, editable=False)
679
-
680
- class Meta:
681
- ordering = ["-created"]
682
- verbose_name = "Net Message"
683
- verbose_name_plural = "Net Messages"
684
-
685
- @classmethod
686
- def broadcast(
687
- cls,
688
- subject: str,
689
- body: str,
690
- reach: NodeRole | str | None = None,
691
- seen: list[str] | None = None,
692
- ):
693
- role = None
694
- if reach:
695
- if isinstance(reach, NodeRole):
696
- role = reach
697
- else:
698
- role = NodeRole.objects.filter(name=reach).first()
699
- msg = cls.objects.create(
700
- subject=subject[:64],
701
- body=body[:256],
702
- reach=role or get_terminal_role(),
703
- )
704
- msg.propagate(seen=seen or [])
705
- return msg
706
-
707
- def propagate(self, seen: list[str] | None = None):
708
- from core.notifications import notify
709
- import random
710
- import requests
711
-
712
- notify(self.subject, self.body)
713
- local = Node.get_local()
714
- private_key = None
715
- seen = list(seen or [])
716
- local_id = None
717
- if local:
718
- local_id = str(local.uuid)
719
- if local_id not in seen:
720
- seen.append(local_id)
721
- priv_path = (
722
- Path(local.base_path or settings.BASE_DIR)
723
- / "security"
724
- / f"{local.public_endpoint}"
725
- )
726
- try:
727
- private_key = serialization.load_pem_private_key(
728
- priv_path.read_bytes(), password=None
729
- )
730
- except Exception:
731
- private_key = None
732
- for node_id in seen:
733
- node = Node.objects.filter(uuid=node_id).first()
734
- if node and (not local or node.pk != local.pk):
735
- self.propagated_to.add(node)
736
-
737
- all_nodes = Node.objects.all()
738
- if local:
739
- all_nodes = all_nodes.exclude(pk=local.pk)
740
- total_known = all_nodes.count()
741
-
742
- remaining = list(
743
- all_nodes.exclude(pk__in=self.propagated_to.values_list("pk", flat=True))
744
- )
745
- if not remaining:
746
- self.complete = True
747
- self.save(update_fields=["complete"])
748
- return
749
-
750
- target_limit = min(3, len(remaining))
751
-
752
- reach_name = self.reach.name if self.reach else "Terminal"
753
- role_map = {
754
- "Terminal": ["Terminal"],
755
- "Control": ["Control", "Terminal"],
756
- "Satellite": ["Satellite", "Control", "Terminal"],
757
- "Constellation": [
758
- "Constellation",
759
- "Satellite",
760
- "Control",
761
- "Terminal",
762
- ],
763
- }
764
- role_order = role_map.get(reach_name, ["Terminal"])
765
- selected: list[Node] = []
766
- for role_name in role_order:
767
- role_nodes = [n for n in remaining if n.role and n.role.name == role_name]
768
- random.shuffle(role_nodes)
769
- for n in role_nodes:
770
- selected.append(n)
771
- remaining.remove(n)
772
- if len(selected) >= target_limit:
773
- break
774
- if len(selected) >= target_limit:
775
- break
776
-
777
- seen_list = seen.copy()
778
- selected_ids = [str(n.uuid) for n in selected]
779
- payload_seen = seen_list + selected_ids
780
- for node in selected:
781
- payload = {
782
- "uuid": str(self.uuid),
783
- "subject": self.subject,
784
- "body": self.body,
785
- "seen": payload_seen,
786
- "reach": reach_name,
787
- "sender": local_id,
788
- }
789
- payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
790
- headers = {"Content-Type": "application/json"}
791
- if private_key:
792
- try:
793
- signature = private_key.sign(
794
- payload_json.encode(),
795
- padding.PKCS1v15(),
796
- hashes.SHA256(),
797
- )
798
- headers["X-Signature"] = base64.b64encode(signature).decode()
799
- except Exception:
800
- pass
801
- try:
802
- requests.post(
803
- f"http://{node.address}:{node.port}/nodes/net-message/",
804
- data=payload_json,
805
- headers=headers,
806
- timeout=1,
807
- )
808
- except Exception:
809
- pass
810
- self.propagated_to.add(node)
811
-
812
- if total_known and self.propagated_to.count() >= total_known:
813
- self.complete = True
814
- self.save(update_fields=["complete"] if self.complete else [])
815
-
816
-
817
- class ContentSample(Entity):
818
- """Collected content such as text snippets or screenshots."""
819
-
820
- TEXT = "TEXT"
821
- IMAGE = "IMAGE"
822
- KIND_CHOICES = [(TEXT, "Text"), (IMAGE, "Image")]
823
-
824
- name = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
825
- kind = models.CharField(max_length=10, choices=KIND_CHOICES)
826
- content = models.TextField(blank=True)
827
- path = models.CharField(max_length=255, blank=True)
828
- method = models.CharField(max_length=10, default="", blank=True)
829
- hash = models.CharField(max_length=64, unique=True, null=True, blank=True)
830
- transaction_uuid = models.UUIDField(
831
- default=uuid.uuid4,
832
- editable=True,
833
- db_index=True,
834
- verbose_name="transaction UUID",
835
- )
836
- node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
837
- user = models.ForeignKey(
838
- settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
839
- )
840
- created_at = models.DateTimeField(auto_now_add=True)
841
-
842
- class Meta:
843
- ordering = ["-created_at"]
844
- verbose_name = "Content Sample"
845
- verbose_name_plural = "Content Samples"
846
-
847
- def save(self, *args, **kwargs):
848
- if self.pk:
849
- original = type(self).all_objects.get(pk=self.pk)
850
- if original.transaction_uuid != self.transaction_uuid:
851
- raise ValidationError(
852
- {"transaction_uuid": "Cannot modify transaction UUID"}
853
- )
854
- if self.node_id is None:
855
- self.node = Node.get_local()
856
- super().save(*args, **kwargs)
857
-
858
- def __str__(self) -> str: # pragma: no cover - simple representation
859
- return str(self.name)
860
-
861
-
862
- UserModel = get_user_model()
863
-
864
-
865
- class User(UserModel):
866
- class Meta:
867
- proxy = True
868
- app_label = "nodes"
869
- verbose_name = UserModel._meta.verbose_name
870
- 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.models import Q
8
+ from django.db.utils import DatabaseError
9
+ from django.db.models.signals import post_delete
10
+ from django.dispatch import Signal, receiver
11
+ from core.entity import Entity
12
+ from core.models import PackageRelease, Profile
13
+ from core.fields import SigilLongAutoField, SigilShortAutoField
14
+ import re
15
+ import json
16
+ import base64
17
+ import ipaddress
18
+ from django.utils import timezone
19
+ from django.utils.text import slugify
20
+ from django.conf import settings
21
+ from datetime import datetime, timedelta, timezone as datetime_timezone
22
+ import uuid
23
+ import os
24
+ import socket
25
+ import stat
26
+ import subprocess
27
+ import shutil
28
+ from pathlib import Path
29
+ from urllib.parse import urlparse, urlunsplit
30
+ from utils import revision
31
+ from core.notifications import notify_async
32
+ from django.core.exceptions import ValidationError
33
+ from cryptography.hazmat.primitives.asymmetric import rsa
34
+ from cryptography.hazmat.primitives import serialization, hashes
35
+ from cryptography.hazmat.primitives.asymmetric import padding
36
+ from django.contrib.auth import get_user_model
37
+ from django.core import serializers
38
+ from django.core.mail import get_connection
39
+ from django.core.serializers.base import DeserializationError
40
+ from core import mailer
41
+ import logging
42
+
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ ROLE_RENAMES: dict[str, str] = {"Constellation": "Watchtower"}
48
+
49
+
50
+ class NodeRoleManager(models.Manager):
51
+ def get_by_natural_key(self, name: str):
52
+ return self.get(name=name)
53
+
54
+
55
+ class NodeRole(Entity):
56
+ """Assignable role for a :class:`Node`."""
57
+
58
+ name = models.CharField(max_length=50, unique=True)
59
+ description = models.CharField(max_length=200, blank=True)
60
+
61
+ objects = NodeRoleManager()
62
+
63
+ class Meta:
64
+ ordering = ["name"]
65
+ verbose_name = "Node Role"
66
+ verbose_name_plural = "Node Roles"
67
+
68
+ def natural_key(self): # pragma: no cover - simple representation
69
+ return (self.name,)
70
+
71
+ def __str__(self) -> str: # pragma: no cover - simple representation
72
+ return self.name
73
+
74
+
75
+ class NodeFeatureManager(models.Manager):
76
+ def get_by_natural_key(self, slug: str):
77
+ return self.get(slug=slug)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class NodeFeatureDefaultAction:
82
+ label: str
83
+ url_name: str
84
+
85
+
86
+ class NodeFeature(Entity):
87
+ """Feature that may be enabled on nodes and roles."""
88
+
89
+ slug = models.SlugField(max_length=50, unique=True)
90
+ display = models.CharField(max_length=50)
91
+ roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
92
+
93
+ objects = NodeFeatureManager()
94
+
95
+ DEFAULT_ACTIONS: dict[str, tuple[NodeFeatureDefaultAction, ...]] = {
96
+ "rfid-scanner": (
97
+ NodeFeatureDefaultAction(
98
+ label="Scan RFIDs", url_name="admin:core_rfid_scan"
99
+ ),
100
+ ),
101
+ "celery-queue": (
102
+ NodeFeatureDefaultAction(
103
+ label="Celery Report",
104
+ url_name="admin:nodes_nodefeature_celery_report",
105
+ ),
106
+ ),
107
+ "audio-capture": (
108
+ NodeFeatureDefaultAction(
109
+ label="View Waveform",
110
+ url_name="admin:nodes_nodefeature_view_waveform",
111
+ ),
112
+ ),
113
+ "screenshot-poll": (
114
+ NodeFeatureDefaultAction(
115
+ label="Take Screenshot",
116
+ url_name="admin:nodes_nodefeature_take_screenshot",
117
+ ),
118
+ ),
119
+ "rpi-camera": (
120
+ NodeFeatureDefaultAction(
121
+ label="Take a Snapshot",
122
+ url_name="admin:nodes_nodefeature_take_snapshot",
123
+ ),
124
+ NodeFeatureDefaultAction(
125
+ label="View stream",
126
+ url_name="admin:nodes_nodefeature_view_stream",
127
+ ),
128
+ ),
129
+ }
130
+
131
+ class Meta:
132
+ ordering = ["display"]
133
+ verbose_name = "Node Feature"
134
+ verbose_name_plural = "Node Features"
135
+
136
+ def natural_key(self): # pragma: no cover - simple representation
137
+ return (self.slug,)
138
+
139
+ def __str__(self) -> str: # pragma: no cover - simple representation
140
+ return self.display
141
+
142
+ @property
143
+ def is_enabled(self) -> bool:
144
+ from django.conf import settings
145
+ from pathlib import Path
146
+
147
+ node = Node.get_local()
148
+ if not node:
149
+ return False
150
+ if node.features.filter(pk=self.pk).exists():
151
+ return True
152
+ if self.slug == "gui-toast":
153
+ from core.notifications import supports_gui_toast
154
+
155
+ return supports_gui_toast()
156
+ if self.slug == "rpi-camera":
157
+ return Node._has_rpi_camera()
158
+ lock_map = {
159
+ "lcd-screen": "lcd_screen.lck",
160
+ "rfid-scanner": "rfid.lck",
161
+ "celery-queue": "celery.lck",
162
+ "nginx-server": "nginx_mode.lck",
163
+ }
164
+ lock = lock_map.get(self.slug)
165
+ if lock:
166
+ base_path = Path(node.base_path or settings.BASE_DIR)
167
+ return (base_path / "locks" / lock).exists()
168
+ return False
169
+
170
+ def get_default_actions(self) -> tuple[NodeFeatureDefaultAction, ...]:
171
+ """Return the configured default actions for this feature."""
172
+
173
+ actions = self.DEFAULT_ACTIONS.get(self.slug, ())
174
+ if isinstance(actions, NodeFeatureDefaultAction): # pragma: no cover - legacy
175
+ return (actions,)
176
+ return actions
177
+
178
+ def get_default_action(self) -> NodeFeatureDefaultAction | None:
179
+ """Return the first configured default action for this feature if any."""
180
+
181
+ actions = self.get_default_actions()
182
+ return actions[0] if actions else None
183
+
184
+
185
+ def get_terminal_role():
186
+ """Return the NodeRole representing a Terminal if it exists."""
187
+ return NodeRole.objects.filter(name="Terminal").first()
188
+
189
+
190
+ class Node(Entity):
191
+ """Information about a running node in the network."""
192
+
193
+ DEFAULT_BADGE_COLOR = "#28a745"
194
+ ROLE_BADGE_COLORS = {
195
+ "Watchtower": "#daa520", # goldenrod
196
+ "Constellation": "#daa520", # legacy alias
197
+ "Control": "#673ab7", # deep purple
198
+ "Interface": "#0dcaf0", # cyan
199
+ }
200
+
201
+ class Relation(models.TextChoices):
202
+ UPSTREAM = "UPSTREAM", "Upstream"
203
+ DOWNSTREAM = "DOWNSTREAM", "Downstream"
204
+ PEER = "PEER", "Peer"
205
+ SELF = "SELF", "Self"
206
+
207
+ hostname = models.CharField(max_length=100)
208
+ network_hostname = models.CharField(max_length=253, blank=True)
209
+ ipv4_address = models.GenericIPAddressField(
210
+ protocol="IPv4", blank=True, null=True
211
+ )
212
+ ipv6_address = models.GenericIPAddressField(
213
+ protocol="IPv6", blank=True, null=True
214
+ )
215
+ address = models.GenericIPAddressField(blank=True, null=True)
216
+ mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
217
+ port = models.PositiveIntegerField(default=8000)
218
+ message_queue_length = models.PositiveSmallIntegerField(
219
+ default=10,
220
+ help_text="Maximum queued NetMessages to retain for this peer.",
221
+ )
222
+ badge_color = models.CharField(max_length=7, default=DEFAULT_BADGE_COLOR)
223
+ role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
224
+ current_relation = models.CharField(
225
+ max_length=10,
226
+ choices=Relation.choices,
227
+ default=Relation.PEER,
228
+ )
229
+ last_seen = models.DateTimeField(auto_now=True)
230
+ enable_public_api = models.BooleanField(
231
+ default=False,
232
+ verbose_name="enable public API",
233
+ )
234
+ public_endpoint = models.SlugField(blank=True, unique=True)
235
+ uuid = models.UUIDField(
236
+ default=uuid.uuid4,
237
+ unique=True,
238
+ editable=False,
239
+ verbose_name="UUID",
240
+ )
241
+ public_key = models.TextField(blank=True)
242
+ base_path = models.CharField(max_length=255, blank=True)
243
+ installed_version = models.CharField(max_length=20, blank=True)
244
+ installed_revision = models.CharField(max_length=40, blank=True)
245
+ features = models.ManyToManyField(
246
+ NodeFeature,
247
+ through="NodeFeatureAssignment",
248
+ related_name="nodes",
249
+ blank=True,
250
+ )
251
+
252
+ FEATURE_LOCK_MAP = {
253
+ "lcd-screen": "lcd_screen.lck",
254
+ "rfid-scanner": "rfid.lck",
255
+ "celery-queue": "celery.lck",
256
+ "nginx-server": "nginx_mode.lck",
257
+ }
258
+ RPI_CAMERA_DEVICE = Path("/dev/video0")
259
+ RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
260
+ AP_ROUTER_SSID = "gelectriic-ap"
261
+ NMCLI_TIMEOUT = 5
262
+ AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
263
+ "gui-toast",
264
+ "rpi-camera",
265
+ "ap-router",
266
+ }
267
+ MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll", "audio-capture"}
268
+
269
+ def __str__(self) -> str: # pragma: no cover - simple representation
270
+ return f"{self.hostname}:{self.port}"
271
+
272
+ @staticmethod
273
+ def _ip_preference(ip_value: str) -> tuple[int, str]:
274
+ """Return a sort key favouring globally routable addresses."""
275
+
276
+ try:
277
+ parsed = ipaddress.ip_address(ip_value)
278
+ except ValueError:
279
+ return (3, ip_value)
280
+
281
+ if parsed.is_global:
282
+ return (0, ip_value)
283
+
284
+ if parsed.is_loopback or parsed.is_link_local:
285
+ return (2, ip_value)
286
+
287
+ if parsed.is_private:
288
+ return (2, ip_value)
289
+
290
+ return (1, ip_value)
291
+
292
+ @classmethod
293
+ def _select_preferred_ip(cls, addresses: Iterable[str]) -> str | None:
294
+ """Return the preferred IP from ``addresses`` when available."""
295
+
296
+ best: tuple[int, str] | None = None
297
+ for candidate in addresses:
298
+ candidate = (candidate or "").strip()
299
+ if not candidate:
300
+ continue
301
+ score = cls._ip_preference(candidate)
302
+ if best is None or score < best:
303
+ best = score
304
+ return best[1] if best else None
305
+
306
+ @classmethod
307
+ def _resolve_ip_addresses(
308
+ cls, *hosts: str, include_ipv4: bool = True, include_ipv6: bool = True
309
+ ) -> tuple[list[str], list[str]]:
310
+ """Resolve ``hosts`` into IPv4 and IPv6 address lists."""
311
+
312
+ ipv4: list[str] = []
313
+ ipv6: list[str] = []
314
+
315
+ for host in hosts:
316
+ host = (host or "").strip()
317
+ if not host:
318
+ continue
319
+ try:
320
+ info = socket.getaddrinfo(
321
+ host,
322
+ None,
323
+ socket.AF_UNSPEC,
324
+ socket.SOCK_STREAM,
325
+ )
326
+ except OSError:
327
+ continue
328
+ for family, _, _, _, sockaddr in info:
329
+ if family == socket.AF_INET and include_ipv4:
330
+ value = sockaddr[0]
331
+ if value not in ipv4:
332
+ ipv4.append(value)
333
+ elif family == socket.AF_INET6 and include_ipv6:
334
+ value = sockaddr[0]
335
+ if value not in ipv6:
336
+ ipv6.append(value)
337
+
338
+ return ipv4, ipv6
339
+
340
+ def get_remote_host_candidates(self) -> list[str]:
341
+ """Return host strings that may reach this node."""
342
+
343
+ values: list[str] = []
344
+ for attr in (
345
+ "network_hostname",
346
+ "hostname",
347
+ "ipv6_address",
348
+ "ipv4_address",
349
+ "address",
350
+ "public_endpoint",
351
+ ):
352
+ value = getattr(self, attr, "") or ""
353
+ value = value.strip()
354
+ if value and value not in values:
355
+ values.append(value)
356
+
357
+ resolved_ipv6: list[str] = []
358
+ resolved_ipv4: list[str] = []
359
+ for host in list(values):
360
+ if host.startswith("http://") or host.startswith("https://"):
361
+ continue
362
+ try:
363
+ ipaddress.ip_address(host)
364
+ except ValueError:
365
+ ipv4, ipv6 = self._resolve_ip_addresses(host)
366
+ for candidate in ipv6:
367
+ if candidate not in values and candidate not in resolved_ipv6:
368
+ resolved_ipv6.append(candidate)
369
+ for candidate in ipv4:
370
+ if candidate not in values and candidate not in resolved_ipv4:
371
+ resolved_ipv4.append(candidate)
372
+ values.extend(resolved_ipv6)
373
+ values.extend(resolved_ipv4)
374
+ return values
375
+
376
+ def get_primary_contact(self) -> str:
377
+ """Return the first reachable host for this node."""
378
+
379
+ for host in self.get_remote_host_candidates():
380
+ if host:
381
+ return host
382
+ return ""
383
+
384
+ def get_best_ip(self) -> str:
385
+ """Return the preferred IP address for this node if known."""
386
+
387
+ candidates: list[str] = []
388
+ for value in (
389
+ getattr(self, "address", "") or "",
390
+ getattr(self, "ipv4_address", "") or "",
391
+ getattr(self, "ipv6_address", "") or "",
392
+ ):
393
+ value = value.strip()
394
+ if not value:
395
+ continue
396
+ try:
397
+ ipaddress.ip_address(value)
398
+ except ValueError:
399
+ continue
400
+ candidates.append(value)
401
+ if not candidates:
402
+ return ""
403
+ selected = self._select_preferred_ip(candidates)
404
+ return selected or ""
405
+
406
+ def iter_remote_urls(self, path: str):
407
+ """Yield potential remote URLs for ``path`` on this node."""
408
+
409
+ host_candidates = self.get_remote_host_candidates()
410
+ default_port = self.port or 8000
411
+ normalized_path = path if path.startswith("/") else f"/{path}"
412
+ seen: set[str] = set()
413
+
414
+ for host in host_candidates:
415
+ host = host.strip()
416
+ if not host:
417
+ continue
418
+ base_path = ""
419
+ formatted_host = host
420
+ port_override: int | None = None
421
+
422
+ if "://" in host:
423
+ parsed = urlparse(host)
424
+ netloc = parsed.netloc or parsed.path
425
+ base_path = (parsed.path or "").rstrip("/")
426
+ combined_path = (
427
+ f"{base_path}{normalized_path}" if base_path else normalized_path
428
+ )
429
+ primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
430
+ if primary not in seen:
431
+ seen.add(primary)
432
+ yield primary
433
+ if parsed.scheme == "https":
434
+ fallback = urlunsplit(("http", netloc, combined_path, "", ""))
435
+ if fallback not in seen:
436
+ seen.add(fallback)
437
+ yield fallback
438
+ elif parsed.scheme == "http":
439
+ alternate = urlunsplit(("https", netloc, combined_path, "", ""))
440
+ if alternate not in seen:
441
+ seen.add(alternate)
442
+ yield alternate
443
+ continue
444
+
445
+ if host.startswith("[") and "]" in host:
446
+ end = host.index("]")
447
+ core_host = host[1:end]
448
+ remainder = host[end + 1 :]
449
+ if remainder.startswith(":"):
450
+ remainder = remainder[1:]
451
+ port_part, sep, path_tail = remainder.partition("/")
452
+ if port_part:
453
+ try:
454
+ port_override = int(port_part)
455
+ except ValueError:
456
+ port_override = None
457
+ if sep:
458
+ base_path = f"/{path_tail}".rstrip("/")
459
+ elif "/" in remainder:
460
+ _, _, path_tail = remainder.partition("/")
461
+ base_path = f"/{path_tail}".rstrip("/")
462
+ formatted_host = f"[{core_host}]"
463
+ else:
464
+ if "/" in host:
465
+ host_only, _, path_tail = host.partition("/")
466
+ formatted_host = host_only or host
467
+ base_path = f"/{path_tail}".rstrip("/")
468
+ try:
469
+ ip_obj = ipaddress.ip_address(formatted_host)
470
+ except ValueError:
471
+ parts = formatted_host.rsplit(":", 1)
472
+ if len(parts) == 2 and parts[1].isdigit():
473
+ formatted_host = parts[0]
474
+ port_override = int(parts[1])
475
+ try:
476
+ ip_obj = ipaddress.ip_address(formatted_host)
477
+ except ValueError:
478
+ ip_obj = None
479
+ else:
480
+ if ip_obj.version == 6 and not formatted_host.startswith("["):
481
+ formatted_host = f"[{formatted_host}]"
482
+
483
+ effective_port = port_override if port_override is not None else default_port
484
+ combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
485
+
486
+ for scheme, scheme_default_port in (("https", 443), ("http", 80)):
487
+ base = f"{scheme}://{formatted_host}"
488
+ if effective_port and (
489
+ port_override is not None or effective_port != scheme_default_port
490
+ ):
491
+ explicit = f"{base}:{effective_port}{combined_path}"
492
+ if explicit not in seen:
493
+ seen.add(explicit)
494
+ yield explicit
495
+ candidate = f"{base}{combined_path}"
496
+ if candidate not in seen:
497
+ seen.add(candidate)
498
+ yield candidate
499
+
500
+ @staticmethod
501
+ def get_current_mac() -> str:
502
+ """Return the MAC address of the current host."""
503
+ return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
504
+
505
+ @classmethod
506
+ def normalize_relation(cls, value):
507
+ """Normalize ``value`` to a valid :class:`Relation`."""
508
+
509
+ if isinstance(value, cls.Relation):
510
+ return value
511
+ if value is None:
512
+ return cls.Relation.PEER
513
+ text = str(value).strip()
514
+ if not text:
515
+ return cls.Relation.PEER
516
+ for relation in cls.Relation:
517
+ if text.lower() == relation.label.lower():
518
+ return relation
519
+ if text.upper() == relation.name:
520
+ return relation
521
+ if text.lower() == relation.value.lower():
522
+ return relation
523
+ return cls.Relation.PEER
524
+
525
+ @classmethod
526
+ def get_local(cls):
527
+ """Return the node representing the current host if it exists."""
528
+ mac = cls.get_current_mac()
529
+ try:
530
+ node = cls.objects.filter(mac_address__iexact=mac).first()
531
+ if node:
532
+ return node
533
+ return (
534
+ cls.objects.filter(current_relation=cls.Relation.SELF)
535
+ .filter(Q(mac_address__isnull=True) | Q(mac_address=""))
536
+ .first()
537
+ )
538
+ except DatabaseError:
539
+ logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
540
+ return None
541
+
542
+ @classmethod
543
+ def register_current(cls):
544
+ """Create or update the :class:`Node` entry for this host."""
545
+ hostname_override = (
546
+ os.environ.get("NODE_HOSTNAME")
547
+ or os.environ.get("HOSTNAME")
548
+ or ""
549
+ )
550
+ hostname_override = hostname_override.strip()
551
+ hostname = hostname_override or socket.gethostname()
552
+
553
+ network_hostname = os.environ.get("NODE_PUBLIC_HOSTNAME", "").strip()
554
+ if not network_hostname:
555
+ fqdn = socket.getfqdn(hostname)
556
+ if fqdn and "." in fqdn:
557
+ network_hostname = fqdn
558
+
559
+ ipv4_override = os.environ.get("NODE_PUBLIC_IPV4", "").strip()
560
+ ipv6_override = os.environ.get("NODE_PUBLIC_IPV6", "").strip()
561
+
562
+ ipv4_candidates: list[str] = []
563
+ ipv6_candidates: list[str] = []
564
+
565
+ for override, version in ((ipv4_override, 4), (ipv6_override, 6)):
566
+ override = override.strip()
567
+ if not override:
568
+ continue
569
+ try:
570
+ parsed = ipaddress.ip_address(override)
571
+ except ValueError:
572
+ continue
573
+ if parsed.version == version:
574
+ if version == 4 and override not in ipv4_candidates:
575
+ ipv4_candidates.append(override)
576
+ elif version == 6 and override not in ipv6_candidates:
577
+ ipv6_candidates.append(override)
578
+
579
+ resolve_hosts: list[str] = []
580
+ for value in (network_hostname, hostname_override, hostname):
581
+ value = (value or "").strip()
582
+ if value and value not in resolve_hosts:
583
+ resolve_hosts.append(value)
584
+
585
+ resolved_ipv4, resolved_ipv6 = cls._resolve_ip_addresses(*resolve_hosts)
586
+ for ip_value in resolved_ipv4:
587
+ if ip_value not in ipv4_candidates:
588
+ ipv4_candidates.append(ip_value)
589
+ for ip_value in resolved_ipv6:
590
+ if ip_value not in ipv6_candidates:
591
+ ipv6_candidates.append(ip_value)
592
+
593
+ try:
594
+ direct_address = socket.gethostbyname(hostname)
595
+ except OSError:
596
+ direct_address = ""
597
+
598
+ if direct_address and direct_address not in ipv4_candidates:
599
+ ipv4_candidates.append(direct_address)
600
+
601
+ ipv4_address = cls._select_preferred_ip(ipv4_candidates)
602
+ ipv6_address = cls._select_preferred_ip(ipv6_candidates)
603
+
604
+ preferred_contact = ipv4_address or ipv6_address or direct_address or "127.0.0.1"
605
+ port = int(os.environ.get("PORT", 8000))
606
+ base_path = str(settings.BASE_DIR)
607
+ ver_path = Path(settings.BASE_DIR) / "VERSION"
608
+ installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
609
+ rev_value = revision.get_revision()
610
+ installed_revision = rev_value if rev_value else ""
611
+ mac = cls.get_current_mac()
612
+ endpoint_override = os.environ.get("NODE_PUBLIC_ENDPOINT", "").strip()
613
+ slug_source = endpoint_override or hostname
614
+ slug = slugify(slug_source)
615
+ if not slug:
616
+ slug = cls._generate_unique_public_endpoint(hostname or mac)
617
+ node = cls.objects.filter(mac_address=mac).first()
618
+ if not node:
619
+ node = cls.objects.filter(public_endpoint=slug).first()
620
+ defaults = {
621
+ "hostname": hostname,
622
+ "network_hostname": network_hostname,
623
+ "ipv4_address": ipv4_address,
624
+ "ipv6_address": ipv6_address,
625
+ "address": preferred_contact,
626
+ "port": port,
627
+ "base_path": base_path,
628
+ "installed_version": installed_version,
629
+ "installed_revision": installed_revision,
630
+ "public_endpoint": slug,
631
+ "mac_address": mac,
632
+ "current_relation": cls.Relation.SELF,
633
+ }
634
+ role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
635
+ role_name = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
636
+ role_name = ROLE_RENAMES.get(role_name, role_name)
637
+ desired_role = NodeRole.objects.filter(name=role_name).first()
638
+
639
+ if node:
640
+ update_fields = []
641
+ for field, value in defaults.items():
642
+ if getattr(node, field) != value:
643
+ setattr(node, field, value)
644
+ update_fields.append(field)
645
+ if desired_role and node.role_id != desired_role.id:
646
+ node.role = desired_role
647
+ update_fields.append("role")
648
+ if update_fields:
649
+ node.save(update_fields=update_fields)
650
+ else:
651
+ node.refresh_features()
652
+ created = False
653
+ else:
654
+ node = cls.objects.create(**defaults)
655
+ created = True
656
+ if desired_role:
657
+ node.role = desired_role
658
+ node.save(update_fields=["role"])
659
+ if created and node.role is None:
660
+ terminal = NodeRole.objects.filter(name="Terminal").first()
661
+ if terminal:
662
+ node.role = terminal
663
+ node.save(update_fields=["role"])
664
+ node.ensure_keys()
665
+ node.notify_peers_of_update()
666
+ return node, created
667
+
668
+ def notify_peers_of_update(self):
669
+ """Attempt to update this node's registration with known peers."""
670
+
671
+ from secrets import token_hex
672
+
673
+ try:
674
+ import requests
675
+ except Exception: # pragma: no cover - requests should be available
676
+ return
677
+
678
+ security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
679
+ priv_path = security_dir / f"{self.public_endpoint}"
680
+ if not priv_path.exists():
681
+ logger.debug("Private key for %s not found; skipping peer update", self)
682
+ return
683
+ try:
684
+ private_key = serialization.load_pem_private_key(
685
+ priv_path.read_bytes(), password=None
686
+ )
687
+ except Exception as exc: # pragma: no cover - defensive
688
+ logger.warning("Failed to load private key for %s: %s", self, exc)
689
+ return
690
+ token = token_hex(16)
691
+ try:
692
+ signature = private_key.sign(
693
+ token.encode(),
694
+ padding.PKCS1v15(),
695
+ hashes.SHA256(),
696
+ )
697
+ except Exception as exc: # pragma: no cover - defensive
698
+ logger.warning("Failed to sign peer update for %s: %s", self, exc)
699
+ return
700
+
701
+ payload = {
702
+ "hostname": self.hostname,
703
+ "network_hostname": self.network_hostname,
704
+ "address": self.address,
705
+ "ipv4_address": self.ipv4_address,
706
+ "ipv6_address": self.ipv6_address,
707
+ "port": self.port,
708
+ "mac_address": self.mac_address,
709
+ "public_key": self.public_key,
710
+ "token": token,
711
+ "signature": base64.b64encode(signature).decode(),
712
+ }
713
+ if self.installed_version:
714
+ payload["installed_version"] = self.installed_version
715
+ if self.installed_revision:
716
+ payload["installed_revision"] = self.installed_revision
717
+
718
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
719
+ headers = {"Content-Type": "application/json"}
720
+
721
+ peers = Node.objects.exclude(pk=self.pk)
722
+ for peer in peers:
723
+ host_candidates = peer.get_remote_host_candidates()
724
+ port = peer.port or 8000
725
+ urls: list[str] = []
726
+ for host in host_candidates:
727
+ host = host.strip()
728
+ if not host:
729
+ continue
730
+ if host.startswith("http://") or host.startswith("https://"):
731
+ normalized = host.rstrip("/")
732
+ if normalized not in urls:
733
+ urls.append(normalized)
734
+ continue
735
+ if ":" in host and not host.startswith("["):
736
+ host = f"[{host}]"
737
+ http_url = (
738
+ f"http://{host}/nodes/register/"
739
+ if port == 80
740
+ else f"http://{host}:{port}/nodes/register/"
741
+ )
742
+ https_url = (
743
+ f"https://{host}/nodes/register/"
744
+ if port in {80, 443}
745
+ else f"https://{host}:{port}/nodes/register/"
746
+ )
747
+ for url in (https_url, http_url):
748
+ if url not in urls:
749
+ urls.append(url)
750
+ if not urls:
751
+ continue
752
+ for url in urls:
753
+ try:
754
+ response = requests.post(
755
+ url, data=payload_json, headers=headers, timeout=2
756
+ )
757
+ except Exception as exc: # pragma: no cover - best effort
758
+ logger.debug("Failed to update %s via %s: %s", peer, url, exc)
759
+ continue
760
+ if response.ok:
761
+ version_display = _format_upgrade_body(
762
+ self.installed_version,
763
+ self.installed_revision,
764
+ )
765
+ version_suffix = f" ({version_display})" if version_display else ""
766
+ logger.info(
767
+ "Announced startup to %s%s",
768
+ peer,
769
+ version_suffix,
770
+ )
771
+ break
772
+ else:
773
+ logger.warning("Unable to notify node %s of startup", peer)
774
+
775
+ def ensure_keys(self):
776
+ security_dir = Path(settings.BASE_DIR) / "security"
777
+ security_dir.mkdir(parents=True, exist_ok=True)
778
+ priv_path = security_dir / f"{self.public_endpoint}"
779
+ pub_path = security_dir / f"{self.public_endpoint}.pub"
780
+ regenerate = not priv_path.exists() or not pub_path.exists()
781
+ if not regenerate:
782
+ key_max_age = getattr(settings, "NODE_KEY_MAX_AGE", timedelta(days=90))
783
+ if key_max_age is not None:
784
+ try:
785
+ priv_mtime = datetime.fromtimestamp(
786
+ priv_path.stat().st_mtime, tz=datetime_timezone.utc
787
+ )
788
+ pub_mtime = datetime.fromtimestamp(
789
+ pub_path.stat().st_mtime, tz=datetime_timezone.utc
790
+ )
791
+ except OSError:
792
+ regenerate = True
793
+ else:
794
+ cutoff = timezone.now() - key_max_age
795
+ if priv_mtime < cutoff or pub_mtime < cutoff:
796
+ regenerate = True
797
+ if regenerate:
798
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
799
+ private_bytes = private_key.private_bytes(
800
+ encoding=serialization.Encoding.PEM,
801
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
802
+ encryption_algorithm=serialization.NoEncryption(),
803
+ )
804
+ public_bytes = private_key.public_key().public_bytes(
805
+ encoding=serialization.Encoding.PEM,
806
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
807
+ )
808
+ priv_path.write_bytes(private_bytes)
809
+ pub_path.write_bytes(public_bytes)
810
+ public_text = public_bytes.decode()
811
+ if self.public_key != public_text:
812
+ self.public_key = public_text
813
+ self.save(update_fields=["public_key"])
814
+ elif not self.public_key:
815
+ self.public_key = pub_path.read_text()
816
+ self.save(update_fields=["public_key"])
817
+
818
+ def get_private_key(self):
819
+ """Return the private key for this node if available."""
820
+
821
+ if not self.public_endpoint:
822
+ return None
823
+ try:
824
+ self.ensure_keys()
825
+ except Exception:
826
+ return None
827
+ priv_path = (
828
+ Path(self.base_path or settings.BASE_DIR)
829
+ / "security"
830
+ / f"{self.public_endpoint}"
831
+ )
832
+ try:
833
+ return serialization.load_pem_private_key(
834
+ priv_path.read_bytes(), password=None
835
+ )
836
+ except Exception:
837
+ return None
838
+
839
+ @property
840
+ def is_local(self):
841
+ """Determine if this node represents the current host."""
842
+ return self.mac_address == self.get_current_mac()
843
+
844
+ @classmethod
845
+ def _generate_unique_public_endpoint(
846
+ cls, value: str | None, *, exclude_pk: int | None = None
847
+ ) -> str:
848
+ """Return a unique public endpoint slug for ``value``."""
849
+
850
+ field = cls._meta.get_field("public_endpoint")
851
+ max_length = getattr(field, "max_length", None) or 50
852
+ base_slug = slugify(value or "") or "node"
853
+ if len(base_slug) > max_length:
854
+ base_slug = base_slug[:max_length]
855
+ slug = base_slug
856
+ queryset = cls.objects.all()
857
+ if exclude_pk is not None:
858
+ queryset = queryset.exclude(pk=exclude_pk)
859
+ counter = 2
860
+ while queryset.filter(public_endpoint=slug).exists():
861
+ suffix = f"-{counter}"
862
+ available = max_length - len(suffix)
863
+ if available <= 0:
864
+ slug = suffix[-max_length:]
865
+ else:
866
+ slug = f"{base_slug[:available]}{suffix}"
867
+ counter += 1
868
+ return slug
869
+
870
+ def save(self, *args, **kwargs):
871
+ update_fields = kwargs.get("update_fields")
872
+
873
+ def include_update_field(field: str):
874
+ nonlocal update_fields
875
+ if update_fields is None:
876
+ return
877
+ fields = set(update_fields)
878
+ if field in fields:
879
+ return
880
+ fields.add(field)
881
+ update_fields = tuple(fields)
882
+ kwargs["update_fields"] = update_fields
883
+
884
+ role_name = None
885
+ role = getattr(self, "role", None)
886
+ if role and getattr(role, "name", None):
887
+ role_name = role.name
888
+ elif self.role_id:
889
+ role_name = (
890
+ NodeRole.objects.filter(pk=self.role_id)
891
+ .values_list("name", flat=True)
892
+ .first()
893
+ )
894
+
895
+ role_color = self.ROLE_BADGE_COLORS.get(role_name)
896
+ if role_color and (
897
+ not self.badge_color or self.badge_color == self.DEFAULT_BADGE_COLOR
898
+ ):
899
+ self.badge_color = role_color
900
+ include_update_field("badge_color")
901
+
902
+ if self.mac_address:
903
+ self.mac_address = self.mac_address.lower()
904
+ endpoint_value = slugify(self.public_endpoint or "")
905
+ if not endpoint_value:
906
+ endpoint_value = self._generate_unique_public_endpoint(
907
+ self.hostname, exclude_pk=self.pk
908
+ )
909
+ else:
910
+ queryset = (
911
+ self.__class__.objects.exclude(pk=self.pk)
912
+ if self.pk
913
+ else self.__class__.objects.all()
914
+ )
915
+ if queryset.filter(public_endpoint=endpoint_value).exists():
916
+ endpoint_value = self._generate_unique_public_endpoint(
917
+ self.hostname or endpoint_value, exclude_pk=self.pk
918
+ )
919
+ if self.public_endpoint != endpoint_value:
920
+ self.public_endpoint = endpoint_value
921
+ include_update_field("public_endpoint")
922
+ super().save(*args, **kwargs)
923
+ if self.pk:
924
+ self.refresh_features()
925
+
926
+ def has_feature(self, slug: str) -> bool:
927
+ return self.features.filter(slug=slug).exists()
928
+
929
+ @classmethod
930
+ def _has_rpi_camera(cls) -> bool:
931
+ """Return ``True`` when the Raspberry Pi camera stack is available."""
932
+
933
+ device = cls.RPI_CAMERA_DEVICE
934
+ if not device.exists():
935
+ return False
936
+ device_path = str(device)
937
+ try:
938
+ mode = os.stat(device_path).st_mode
939
+ except OSError:
940
+ return False
941
+ if not stat.S_ISCHR(mode):
942
+ return False
943
+ if not os.access(device_path, os.R_OK | os.W_OK):
944
+ return False
945
+ for binary in cls.RPI_CAMERA_BINARIES:
946
+ tool_path = shutil.which(binary)
947
+ if not tool_path:
948
+ return False
949
+ try:
950
+ result = subprocess.run(
951
+ [tool_path, "--help"],
952
+ capture_output=True,
953
+ text=True,
954
+ check=False,
955
+ timeout=5,
956
+ )
957
+ except Exception:
958
+ return False
959
+ if result.returncode != 0:
960
+ return False
961
+ return True
962
+
963
+ @classmethod
964
+ def _hosts_gelectriic_ap(cls) -> bool:
965
+ """Return ``True`` when the node is hosting the gelectriic access point."""
966
+
967
+ nmcli_path = shutil.which("nmcli")
968
+ if not nmcli_path:
969
+ return False
970
+ try:
971
+ result = subprocess.run(
972
+ [
973
+ nmcli_path,
974
+ "-t",
975
+ "-f",
976
+ "NAME,DEVICE,TYPE",
977
+ "connection",
978
+ "show",
979
+ "--active",
980
+ ],
981
+ capture_output=True,
982
+ text=True,
983
+ check=False,
984
+ timeout=cls.NMCLI_TIMEOUT,
985
+ )
986
+ except Exception:
987
+ return False
988
+ if result.returncode != 0:
989
+ return False
990
+ for line in result.stdout.splitlines():
991
+ if not line:
992
+ continue
993
+ parts = line.split(":", 2)
994
+ if not parts:
995
+ continue
996
+ name = parts[0]
997
+ conn_type = ""
998
+ if len(parts) == 3:
999
+ conn_type = parts[2]
1000
+ elif len(parts) > 1:
1001
+ conn_type = parts[1]
1002
+ if name != cls.AP_ROUTER_SSID:
1003
+ continue
1004
+ conn_type_normalized = conn_type.strip().lower()
1005
+ if conn_type_normalized not in {"wifi", "802-11-wireless"}:
1006
+ continue
1007
+ try:
1008
+ mode_result = subprocess.run(
1009
+ [
1010
+ nmcli_path,
1011
+ "-g",
1012
+ "802-11-wireless.mode",
1013
+ "connection",
1014
+ "show",
1015
+ name,
1016
+ ],
1017
+ capture_output=True,
1018
+ text=True,
1019
+ check=False,
1020
+ timeout=cls.NMCLI_TIMEOUT,
1021
+ )
1022
+ except Exception:
1023
+ continue
1024
+ if mode_result.returncode != 0:
1025
+ continue
1026
+ if mode_result.stdout.strip() == "ap":
1027
+ return True
1028
+ return False
1029
+
1030
+ def refresh_features(self):
1031
+ if not self.pk:
1032
+ return
1033
+ if not self.is_local:
1034
+ self.sync_feature_tasks()
1035
+ return
1036
+ detected_slugs = set()
1037
+ base_path = Path(self.base_path or settings.BASE_DIR)
1038
+ locks_dir = base_path / "locks"
1039
+ for slug, filename in self.FEATURE_LOCK_MAP.items():
1040
+ if (locks_dir / filename).exists():
1041
+ detected_slugs.add(slug)
1042
+ if self._has_rpi_camera():
1043
+ detected_slugs.add("rpi-camera")
1044
+ if self._hosts_gelectriic_ap():
1045
+ detected_slugs.add("ap-router")
1046
+ try:
1047
+ from core.notifications import supports_gui_toast
1048
+ except Exception:
1049
+ pass
1050
+ else:
1051
+ try:
1052
+ if supports_gui_toast():
1053
+ detected_slugs.add("gui-toast")
1054
+ except Exception:
1055
+ pass
1056
+ current_slugs = set(
1057
+ self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
1058
+ "slug", flat=True
1059
+ )
1060
+ )
1061
+ add_slugs = detected_slugs - current_slugs
1062
+ if add_slugs:
1063
+ for feature in NodeFeature.objects.filter(slug__in=add_slugs):
1064
+ NodeFeatureAssignment.objects.update_or_create(
1065
+ node=self, feature=feature
1066
+ )
1067
+ remove_slugs = current_slugs - detected_slugs
1068
+ if remove_slugs:
1069
+ NodeFeatureAssignment.objects.filter(
1070
+ node=self, feature__slug__in=remove_slugs
1071
+ ).delete()
1072
+ self.sync_feature_tasks()
1073
+
1074
+ def update_manual_features(self, slugs: Iterable[str]):
1075
+ desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
1076
+ remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
1077
+ if remove_slugs:
1078
+ NodeFeatureAssignment.objects.filter(
1079
+ node=self, feature__slug__in=remove_slugs
1080
+ ).delete()
1081
+ if desired:
1082
+ for feature in NodeFeature.objects.filter(slug__in=desired):
1083
+ NodeFeatureAssignment.objects.update_or_create(
1084
+ node=self, feature=feature
1085
+ )
1086
+ self.sync_feature_tasks()
1087
+
1088
+ def sync_feature_tasks(self):
1089
+ clipboard_enabled = self.has_feature("clipboard-poll")
1090
+ screenshot_enabled = self.has_feature("screenshot-poll")
1091
+ celery_enabled = self.is_local and self.has_feature("celery-queue")
1092
+ self._sync_clipboard_task(clipboard_enabled)
1093
+ self._sync_screenshot_task(screenshot_enabled)
1094
+ self._sync_landing_lead_task(celery_enabled)
1095
+ self._sync_ocpp_session_report_task(celery_enabled)
1096
+ self._sync_upstream_poll_task(celery_enabled)
1097
+
1098
+ def _sync_clipboard_task(self, enabled: bool):
1099
+ from django_celery_beat.models import IntervalSchedule, PeriodicTask
1100
+
1101
+ task_name = f"poll_clipboard_node_{self.pk}"
1102
+ if enabled:
1103
+ schedule, _ = IntervalSchedule.objects.get_or_create(
1104
+ every=5, period=IntervalSchedule.SECONDS
1105
+ )
1106
+ PeriodicTask.objects.update_or_create(
1107
+ name=task_name,
1108
+ defaults={
1109
+ "interval": schedule,
1110
+ "task": "nodes.tasks.sample_clipboard",
1111
+ },
1112
+ )
1113
+ else:
1114
+ PeriodicTask.objects.filter(name=task_name).delete()
1115
+
1116
+ def _sync_screenshot_task(self, enabled: bool):
1117
+ from django_celery_beat.models import IntervalSchedule, PeriodicTask
1118
+ import json
1119
+
1120
+ task_name = f"capture_screenshot_node_{self.pk}"
1121
+ if enabled:
1122
+ schedule, _ = IntervalSchedule.objects.get_or_create(
1123
+ every=1, period=IntervalSchedule.MINUTES
1124
+ )
1125
+ PeriodicTask.objects.update_or_create(
1126
+ name=task_name,
1127
+ defaults={
1128
+ "interval": schedule,
1129
+ "task": "nodes.tasks.capture_node_screenshot",
1130
+ "kwargs": json.dumps(
1131
+ {
1132
+ "url": f"http://localhost:{self.port}",
1133
+ "port": self.port,
1134
+ "method": "AUTO",
1135
+ }
1136
+ ),
1137
+ },
1138
+ )
1139
+ else:
1140
+ PeriodicTask.objects.filter(name=task_name).delete()
1141
+
1142
+ def _sync_landing_lead_task(self, enabled: bool):
1143
+ if not self.is_local:
1144
+ return
1145
+
1146
+ from django_celery_beat.models import CrontabSchedule, PeriodicTask
1147
+
1148
+ task_name = "pages_purge_landing_leads"
1149
+ if enabled:
1150
+ schedule, _ = CrontabSchedule.objects.get_or_create(
1151
+ minute="0",
1152
+ hour="3",
1153
+ day_of_week="*",
1154
+ day_of_month="*",
1155
+ month_of_year="*",
1156
+ )
1157
+ PeriodicTask.objects.update_or_create(
1158
+ name=task_name,
1159
+ defaults={
1160
+ "crontab": schedule,
1161
+ "interval": None,
1162
+ "task": "pages.tasks.purge_expired_landing_leads",
1163
+ "enabled": True,
1164
+ },
1165
+ )
1166
+ else:
1167
+ PeriodicTask.objects.filter(name=task_name).delete()
1168
+
1169
+ def _sync_ocpp_session_report_task(self, celery_enabled: bool):
1170
+ from django_celery_beat.models import CrontabSchedule, PeriodicTask
1171
+ from django.db.utils import OperationalError, ProgrammingError
1172
+
1173
+ task_name = "ocpp_send_daily_session_report"
1174
+
1175
+ if not self.is_local:
1176
+ return
1177
+
1178
+ if not celery_enabled or not mailer.can_send_email():
1179
+ PeriodicTask.objects.filter(name=task_name).delete()
1180
+ return
1181
+
1182
+ try:
1183
+ schedule, _ = CrontabSchedule.objects.get_or_create(
1184
+ minute="0",
1185
+ hour="18",
1186
+ day_of_week="*",
1187
+ day_of_month="*",
1188
+ month_of_year="*",
1189
+ )
1190
+ PeriodicTask.objects.update_or_create(
1191
+ name=task_name,
1192
+ defaults={
1193
+ "crontab": schedule,
1194
+ "interval": None,
1195
+ "task": "ocpp.tasks.send_daily_session_report",
1196
+ "enabled": True,
1197
+ },
1198
+ )
1199
+ except (OperationalError, ProgrammingError):
1200
+ logger.debug("Skipping OCPP session report task sync; tables not ready")
1201
+
1202
+ def _sync_upstream_poll_task(self, celery_enabled: bool):
1203
+ if not self.is_local:
1204
+ return
1205
+
1206
+ from django_celery_beat.models import IntervalSchedule, PeriodicTask
1207
+
1208
+ task_name = "nodes_poll_upstream_messages"
1209
+ if celery_enabled:
1210
+ schedule, _ = IntervalSchedule.objects.get_or_create(
1211
+ every=1, period=IntervalSchedule.MINUTES
1212
+ )
1213
+ PeriodicTask.objects.update_or_create(
1214
+ name=task_name,
1215
+ defaults={
1216
+ "interval": schedule,
1217
+ "task": "nodes.tasks.poll_unreachable_upstream",
1218
+ "enabled": True,
1219
+ },
1220
+ )
1221
+ else:
1222
+ PeriodicTask.objects.filter(name=task_name).delete()
1223
+
1224
+ def send_mail(
1225
+ self,
1226
+ subject: str,
1227
+ message: str,
1228
+ recipient_list: list[str],
1229
+ from_email: str | None = None,
1230
+ **kwargs,
1231
+ ):
1232
+ """Send an email using this node's configured outbox if available."""
1233
+ outbox = getattr(self, "email_outbox", None)
1234
+ logger.info(
1235
+ "Node %s queueing email to %s using %s backend",
1236
+ self.pk,
1237
+ recipient_list,
1238
+ "outbox" if outbox else "default",
1239
+ )
1240
+ return mailer.send(
1241
+ subject,
1242
+ message,
1243
+ recipient_list,
1244
+ from_email,
1245
+ outbox=outbox,
1246
+ **kwargs,
1247
+ )
1248
+
1249
+
1250
+ node_information_updated = Signal()
1251
+
1252
+
1253
+ def _format_upgrade_body(version: str, revision: str) -> str:
1254
+ version = (version or "").strip()
1255
+ revision = (revision or "").strip()
1256
+ parts: list[str] = []
1257
+ if version:
1258
+ normalized = version.lstrip("vV") or version
1259
+ base_version = normalized.rstrip("+")
1260
+ display_version = normalized
1261
+ if (
1262
+ base_version
1263
+ and revision
1264
+ and not PackageRelease.matches_revision(base_version, revision)
1265
+ and not normalized.endswith("+")
1266
+ ):
1267
+ display_version = f"{display_version}+"
1268
+ parts.append(f"v{display_version}")
1269
+ if revision:
1270
+ rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
1271
+ rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
1272
+ parts.append(f"r{rev_short}")
1273
+ return " ".join(parts).strip()
1274
+
1275
+
1276
+ @receiver(node_information_updated)
1277
+ def _announce_peer_startup(
1278
+ sender,
1279
+ *,
1280
+ node: "Node",
1281
+ previous_version: str = "",
1282
+ previous_revision: str = "",
1283
+ current_version: str = "",
1284
+ current_revision: str = "",
1285
+ **_: object,
1286
+ ) -> None:
1287
+ current_version = (current_version or "").strip()
1288
+ current_revision = (current_revision or "").strip()
1289
+ previous_version = (previous_version or "").strip()
1290
+ previous_revision = (previous_revision or "").strip()
1291
+
1292
+ local = Node.get_local()
1293
+ if local and node.pk == local.pk:
1294
+ return
1295
+
1296
+ body = _format_upgrade_body(current_version, current_revision)
1297
+ if not body:
1298
+ body = "Online"
1299
+
1300
+ hostname = (node.hostname or "Node").strip() or "Node"
1301
+ subject = f"UP {hostname}"
1302
+ notify_async(subject, body)
1303
+
1304
+
1305
+ class NodeFeatureAssignment(Entity):
1306
+ """Bridge between :class:`Node` and :class:`NodeFeature`."""
1307
+
1308
+ node = models.ForeignKey(
1309
+ Node, on_delete=models.CASCADE, related_name="feature_assignments"
1310
+ )
1311
+ feature = models.ForeignKey(
1312
+ NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
1313
+ )
1314
+ created_at = models.DateTimeField(auto_now_add=True)
1315
+
1316
+ class Meta:
1317
+ unique_together = ("node", "feature")
1318
+ verbose_name = "Node Feature Assignment"
1319
+ verbose_name_plural = "Node Feature Assignments"
1320
+
1321
+ def __str__(self) -> str: # pragma: no cover - simple representation
1322
+ return f"{self.node} -> {self.feature}"
1323
+
1324
+ def save(self, *args, **kwargs):
1325
+ super().save(*args, **kwargs)
1326
+ self.node.sync_feature_tasks()
1327
+
1328
+
1329
+ @receiver(post_delete, sender=NodeFeatureAssignment)
1330
+ def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
1331
+ node_id = getattr(instance, "node_id", None)
1332
+ if not node_id:
1333
+ return
1334
+ node = Node.objects.filter(pk=node_id).first()
1335
+ if node:
1336
+ node.sync_feature_tasks()
1337
+
1338
+
1339
+ class NodeManager(Profile):
1340
+ """Credentials for interacting with external DNS providers."""
1341
+
1342
+ class Provider(models.TextChoices):
1343
+ GODADDY = "godaddy", "GoDaddy"
1344
+
1345
+ profile_fields = (
1346
+ "provider",
1347
+ "api_key",
1348
+ "api_secret",
1349
+ "customer_id",
1350
+ "default_domain",
1351
+ )
1352
+
1353
+ provider = models.CharField(
1354
+ max_length=20,
1355
+ choices=Provider.choices,
1356
+ default=Provider.GODADDY,
1357
+ )
1358
+ api_key = SigilShortAutoField(
1359
+ max_length=255,
1360
+ verbose_name="API key",
1361
+ help_text="API key issued by the DNS provider.",
1362
+ )
1363
+ api_secret = SigilShortAutoField(
1364
+ max_length=255,
1365
+ verbose_name="API secret",
1366
+ help_text="API secret issued by the DNS provider.",
1367
+ )
1368
+ customer_id = SigilShortAutoField(
1369
+ max_length=100,
1370
+ blank=True,
1371
+ verbose_name="Customer ID",
1372
+ help_text="Optional GoDaddy customer identifier for the account.",
1373
+ )
1374
+ default_domain = SigilShortAutoField(
1375
+ max_length=253,
1376
+ blank=True,
1377
+ help_text="Fallback domain when records omit one.",
1378
+ )
1379
+ use_sandbox = models.BooleanField(
1380
+ default=False,
1381
+ help_text="Use the GoDaddy OTE (test) environment.",
1382
+ )
1383
+ is_enabled = models.BooleanField(
1384
+ default=True,
1385
+ help_text="Disable to prevent deployments with this manager.",
1386
+ )
1387
+
1388
+ class Meta:
1389
+ verbose_name = "Node Profile"
1390
+ verbose_name_plural = "Node Profiles"
1391
+
1392
+ def __str__(self) -> str:
1393
+ owner = self.owner_display()
1394
+ provider = self.get_provider_display()
1395
+ if owner:
1396
+ return f"{provider} ({owner})"
1397
+ return provider
1398
+
1399
+ def clean(self):
1400
+ if self.user_id or self.group_id:
1401
+ super().clean()
1402
+ else:
1403
+ super(Profile, self).clean()
1404
+
1405
+ def get_base_url(self) -> str:
1406
+ if self.provider != self.Provider.GODADDY:
1407
+ raise ValueError("Unsupported DNS provider")
1408
+ if self.use_sandbox:
1409
+ return "https://api.ote-godaddy.com"
1410
+ return "https://api.godaddy.com"
1411
+
1412
+ def get_auth_header(self) -> str:
1413
+ key = (self.resolve_sigils("api_key") or "").strip()
1414
+ secret = (self.resolve_sigils("api_secret") or "").strip()
1415
+ if not key or not secret:
1416
+ raise ValueError("API credentials are required for DNS deployment")
1417
+ return f"sso-key {key}:{secret}"
1418
+
1419
+ def get_customer_id(self) -> str:
1420
+ return (self.resolve_sigils("customer_id") or "").strip()
1421
+
1422
+ def get_default_domain(self) -> str:
1423
+ return (self.resolve_sigils("default_domain") or "").strip()
1424
+
1425
+ def publish_dns_records(self, records: Iterable["DNSRecord"]):
1426
+ from . import dns as dns_utils
1427
+
1428
+ return dns_utils.deploy_records(self, records)
1429
+
1430
+
1431
+ class DNSRecord(Entity):
1432
+ """Stored DNS configuration ready for deployment."""
1433
+
1434
+ class Type(models.TextChoices):
1435
+ A = "A", "A"
1436
+ AAAA = "AAAA", "AAAA"
1437
+ CNAME = "CNAME", "CNAME"
1438
+ MX = "MX", "MX"
1439
+ NS = "NS", "NS"
1440
+ SRV = "SRV", "SRV"
1441
+ TXT = "TXT", "TXT"
1442
+
1443
+ class Provider(models.TextChoices):
1444
+ GODADDY = "godaddy", "GoDaddy"
1445
+
1446
+ provider = models.CharField(
1447
+ max_length=20,
1448
+ choices=Provider.choices,
1449
+ default=Provider.GODADDY,
1450
+ )
1451
+ node_manager = models.ForeignKey(
1452
+ "NodeManager",
1453
+ on_delete=models.SET_NULL,
1454
+ null=True,
1455
+ blank=True,
1456
+ related_name="dns_records",
1457
+ )
1458
+ domain = SigilShortAutoField(
1459
+ max_length=253,
1460
+ help_text="Base domain such as example.com.",
1461
+ )
1462
+ name = SigilShortAutoField(
1463
+ max_length=253,
1464
+ help_text="Record host. Use @ for the zone apex.",
1465
+ )
1466
+ record_type = models.CharField(
1467
+ max_length=10,
1468
+ choices=Type.choices,
1469
+ default=Type.A,
1470
+ verbose_name="Type",
1471
+ )
1472
+ data = SigilLongAutoField(
1473
+ help_text="Record value such as an IP address or hostname.",
1474
+ )
1475
+ ttl = models.PositiveIntegerField(
1476
+ default=600,
1477
+ help_text="Time to live in seconds.",
1478
+ )
1479
+ priority = models.PositiveIntegerField(
1480
+ null=True,
1481
+ blank=True,
1482
+ help_text="Priority for MX and SRV records.",
1483
+ )
1484
+ port = models.PositiveIntegerField(
1485
+ null=True,
1486
+ blank=True,
1487
+ help_text="Port for SRV records.",
1488
+ )
1489
+ weight = models.PositiveIntegerField(
1490
+ null=True,
1491
+ blank=True,
1492
+ help_text="Weight for SRV records.",
1493
+ )
1494
+ service = SigilShortAutoField(
1495
+ max_length=50,
1496
+ blank=True,
1497
+ help_text="Service label for SRV records (for example _sip).",
1498
+ )
1499
+ protocol = SigilShortAutoField(
1500
+ max_length=10,
1501
+ blank=True,
1502
+ help_text="Protocol label for SRV records (for example _tcp).",
1503
+ )
1504
+ last_synced_at = models.DateTimeField(null=True, blank=True)
1505
+ last_verified_at = models.DateTimeField(null=True, blank=True)
1506
+ last_error = models.TextField(blank=True)
1507
+
1508
+ class Meta:
1509
+ verbose_name = "DNS Record"
1510
+ verbose_name_plural = "DNS Records"
1511
+
1512
+ def __str__(self) -> str:
1513
+ return f"{self.record_type} {self.fqdn()}"
1514
+
1515
+ def get_domain(self, manager: "NodeManager" | None = None) -> str:
1516
+ domain = (self.resolve_sigils("domain") or "").strip()
1517
+ if domain:
1518
+ return domain.rstrip(".")
1519
+ if manager:
1520
+ fallback = manager.get_default_domain()
1521
+ if fallback:
1522
+ return fallback.rstrip(".")
1523
+ return ""
1524
+
1525
+ def get_name(self) -> str:
1526
+ name = (self.resolve_sigils("name") or "").strip()
1527
+ return name or "@"
1528
+
1529
+ def fqdn(self, manager: "NodeManager" | None = None) -> str:
1530
+ domain = self.get_domain(manager)
1531
+ name = self.get_name()
1532
+ if name in {"@", ""}:
1533
+ return domain
1534
+ if name.endswith("."):
1535
+ return name.rstrip(".")
1536
+ if domain:
1537
+ return f"{name}.{domain}".rstrip(".")
1538
+ return name.rstrip(".")
1539
+
1540
+ def to_godaddy_payload(self) -> dict[str, object]:
1541
+ payload: dict[str, object] = {
1542
+ "data": (self.resolve_sigils("data") or "").strip(),
1543
+ "ttl": self.ttl,
1544
+ }
1545
+ if self.priority is not None:
1546
+ payload["priority"] = self.priority
1547
+ if self.port is not None:
1548
+ payload["port"] = self.port
1549
+ if self.weight is not None:
1550
+ payload["weight"] = self.weight
1551
+ service = (self.resolve_sigils("service") or "").strip()
1552
+ if service:
1553
+ payload["service"] = service
1554
+ protocol = (self.resolve_sigils("protocol") or "").strip()
1555
+ if protocol:
1556
+ payload["protocol"] = protocol
1557
+ return payload
1558
+
1559
+ def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
1560
+ if timestamp is None:
1561
+ timestamp = timezone.now()
1562
+ update_fields = ["last_synced_at", "last_error"]
1563
+ self.last_synced_at = timestamp
1564
+ self.last_error = ""
1565
+ if manager and self.node_manager_id != getattr(manager, "pk", None):
1566
+ self.node_manager = manager
1567
+ update_fields.append("node_manager")
1568
+ self.save(update_fields=update_fields)
1569
+
1570
+ def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
1571
+ update_fields = ["last_error"]
1572
+ self.last_error = message
1573
+ if manager and self.node_manager_id != getattr(manager, "pk", None):
1574
+ self.node_manager = manager
1575
+ update_fields.append("node_manager")
1576
+ self.save(update_fields=update_fields)
1577
+
1578
+
1579
+ class EmailOutbox(Profile):
1580
+ """SMTP credentials for sending mail."""
1581
+
1582
+ profile_fields = (
1583
+ "host",
1584
+ "port",
1585
+ "username",
1586
+ "password",
1587
+ "use_tls",
1588
+ "use_ssl",
1589
+ "from_email",
1590
+ )
1591
+
1592
+ node = models.OneToOneField(
1593
+ Node,
1594
+ on_delete=models.CASCADE,
1595
+ related_name="email_outbox",
1596
+ null=True,
1597
+ blank=True,
1598
+ )
1599
+ host = SigilShortAutoField(
1600
+ max_length=100,
1601
+ help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
1602
+ )
1603
+ port = models.PositiveIntegerField(
1604
+ default=587,
1605
+ help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
1606
+ )
1607
+ username = SigilShortAutoField(
1608
+ max_length=100,
1609
+ blank=True,
1610
+ help_text="Full email address for Gmail or GoDaddy",
1611
+ )
1612
+ password = SigilShortAutoField(
1613
+ max_length=100,
1614
+ blank=True,
1615
+ help_text="Email account password or app password",
1616
+ )
1617
+ use_tls = models.BooleanField(
1618
+ default=True,
1619
+ help_text="Check for Gmail or GoDaddy on port 587",
1620
+ )
1621
+ use_ssl = models.BooleanField(
1622
+ default=False,
1623
+ help_text="Check for GoDaddy on port 465; Gmail does not use SSL",
1624
+ )
1625
+ from_email = SigilShortAutoField(
1626
+ blank=True,
1627
+ verbose_name="From Email",
1628
+ max_length=254,
1629
+ help_text="Default From address; usually the same as username",
1630
+ )
1631
+ is_enabled = models.BooleanField(
1632
+ default=True,
1633
+ help_text="Disable to remove this outbox from automatic selection.",
1634
+ )
1635
+
1636
+ class Meta:
1637
+ verbose_name = "Email Outbox"
1638
+ verbose_name_plural = "Email Outboxes"
1639
+
1640
+ def __str__(self) -> str:
1641
+ address = (self.from_email or "").strip()
1642
+ if address:
1643
+ return address
1644
+
1645
+ username = (self.username or "").strip()
1646
+ host = (self.host or "").strip()
1647
+ if username:
1648
+ local, sep, domain = username.partition("@")
1649
+ if sep and domain:
1650
+ return username
1651
+ if host:
1652
+ sanitized = username.rstrip("@")
1653
+ if sanitized:
1654
+ return f"{sanitized}@{host}"
1655
+ return host
1656
+ return username
1657
+ if host:
1658
+ return host
1659
+
1660
+ owner = self.owner_display()
1661
+ if owner:
1662
+ return owner
1663
+
1664
+ return super().__str__()
1665
+
1666
+ def clean(self):
1667
+ if self.user_id or self.group_id:
1668
+ super().clean()
1669
+ else:
1670
+ super(Profile, self).clean()
1671
+
1672
+ def get_connection(self):
1673
+ return get_connection(
1674
+ "django.core.mail.backends.smtp.EmailBackend",
1675
+ host=self.host,
1676
+ port=self.port,
1677
+ username=self.username or None,
1678
+ password=self.password or None,
1679
+ use_tls=self.use_tls,
1680
+ use_ssl=self.use_ssl,
1681
+ )
1682
+
1683
+ def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
1684
+ from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
1685
+ logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
1686
+ return mailer.send(
1687
+ subject,
1688
+ message,
1689
+ recipient_list,
1690
+ from_email,
1691
+ outbox=self,
1692
+ **kwargs,
1693
+ )
1694
+
1695
+ def owner_display(self):
1696
+ owner = super().owner_display()
1697
+ if owner:
1698
+ return owner
1699
+ return str(self.node) if self.node_id else ""
1700
+
1701
+
1702
+ class NetMessage(Entity):
1703
+ """Message propagated across nodes."""
1704
+
1705
+ uuid = models.UUIDField(
1706
+ default=uuid.uuid4,
1707
+ unique=True,
1708
+ editable=False,
1709
+ verbose_name="UUID",
1710
+ )
1711
+ node_origin = models.ForeignKey(
1712
+ "Node",
1713
+ on_delete=models.SET_NULL,
1714
+ null=True,
1715
+ blank=True,
1716
+ related_name="originated_net_messages",
1717
+ )
1718
+ subject = models.CharField(max_length=64, blank=True)
1719
+ body = models.CharField(max_length=256, blank=True)
1720
+ attachments = models.JSONField(blank=True, null=True)
1721
+ filter_node = models.ForeignKey(
1722
+ "Node",
1723
+ on_delete=models.SET_NULL,
1724
+ null=True,
1725
+ blank=True,
1726
+ related_name="filtered_net_messages",
1727
+ verbose_name="Node",
1728
+ )
1729
+ filter_node_feature = models.ForeignKey(
1730
+ "NodeFeature",
1731
+ on_delete=models.SET_NULL,
1732
+ null=True,
1733
+ blank=True,
1734
+ verbose_name="Node feature",
1735
+ )
1736
+ filter_node_role = models.ForeignKey(
1737
+ NodeRole,
1738
+ on_delete=models.SET_NULL,
1739
+ null=True,
1740
+ blank=True,
1741
+ related_name="filtered_net_messages",
1742
+ verbose_name="Node role",
1743
+ )
1744
+ filter_current_relation = models.CharField(
1745
+ max_length=10,
1746
+ blank=True,
1747
+ choices=Node.Relation.choices,
1748
+ verbose_name="Current relation",
1749
+ )
1750
+ filter_installed_version = models.CharField(
1751
+ max_length=20,
1752
+ blank=True,
1753
+ verbose_name="Installed version",
1754
+ )
1755
+ filter_installed_revision = models.CharField(
1756
+ max_length=40,
1757
+ blank=True,
1758
+ verbose_name="Installed revision",
1759
+ )
1760
+ reach = models.ForeignKey(
1761
+ NodeRole,
1762
+ on_delete=models.SET_NULL,
1763
+ null=True,
1764
+ blank=True,
1765
+ )
1766
+ target_limit = models.PositiveSmallIntegerField(
1767
+ default=6,
1768
+ blank=True,
1769
+ null=True,
1770
+ help_text="Maximum number of peers to contact when propagating.",
1771
+ )
1772
+ propagated_to = models.ManyToManyField(
1773
+ Node, blank=True, related_name="received_net_messages"
1774
+ )
1775
+ created = models.DateTimeField(auto_now_add=True)
1776
+ complete = models.BooleanField(default=False, editable=False)
1777
+
1778
+ class Meta:
1779
+ ordering = ["-created"]
1780
+ verbose_name = "Net Message"
1781
+ verbose_name_plural = "Net Messages"
1782
+
1783
+ @classmethod
1784
+ def broadcast(
1785
+ cls,
1786
+ subject: str,
1787
+ body: str,
1788
+ reach: NodeRole | str | None = None,
1789
+ seen: list[str] | None = None,
1790
+ attachments: list[dict[str, object]] | None = None,
1791
+ ):
1792
+ role = None
1793
+ if reach:
1794
+ if isinstance(reach, NodeRole):
1795
+ role = reach
1796
+ else:
1797
+ role = NodeRole.objects.filter(name=reach).first()
1798
+ else:
1799
+ role = NodeRole.objects.filter(name="Terminal").first()
1800
+ origin = Node.get_local()
1801
+ normalized_attachments = cls.normalize_attachments(attachments)
1802
+ msg = cls.objects.create(
1803
+ subject=subject[:64],
1804
+ body=body[:256],
1805
+ reach=role,
1806
+ node_origin=origin,
1807
+ attachments=normalized_attachments or None,
1808
+ )
1809
+ if normalized_attachments:
1810
+ msg.apply_attachments(normalized_attachments)
1811
+ msg.propagate(seen=seen or [])
1812
+ return msg
1813
+
1814
+ @staticmethod
1815
+ def normalize_attachments(
1816
+ attachments: object,
1817
+ ) -> list[dict[str, object]]:
1818
+ if not attachments or not isinstance(attachments, list):
1819
+ return []
1820
+ normalized: list[dict[str, object]] = []
1821
+ for item in attachments:
1822
+ if not isinstance(item, dict):
1823
+ continue
1824
+ model_label = item.get("model")
1825
+ fields = item.get("fields")
1826
+ if not isinstance(model_label, str) or not isinstance(fields, dict):
1827
+ continue
1828
+ normalized_item: dict[str, object] = {
1829
+ "model": model_label,
1830
+ "fields": deepcopy(fields),
1831
+ }
1832
+ if "pk" in item:
1833
+ normalized_item["pk"] = item["pk"]
1834
+ normalized.append(normalized_item)
1835
+ return normalized
1836
+
1837
+ def apply_attachments(
1838
+ self, attachments: list[dict[str, object]] | None = None
1839
+ ) -> None:
1840
+ payload = attachments if attachments is not None else self.attachments or []
1841
+ if not payload:
1842
+ return
1843
+
1844
+ try:
1845
+ objects = list(
1846
+ serializers.deserialize(
1847
+ "python", deepcopy(payload), ignorenonexistent=True
1848
+ )
1849
+ )
1850
+ except DeserializationError:
1851
+ logger.exception("Failed to deserialize attachments for NetMessage %s", self.pk)
1852
+ return
1853
+ for obj in objects:
1854
+ try:
1855
+ obj.save()
1856
+ except Exception:
1857
+ logger.exception(
1858
+ "Failed to save attachment %s for NetMessage %s",
1859
+ getattr(obj, "object", obj),
1860
+ self.pk,
1861
+ )
1862
+
1863
+ def _build_payload(
1864
+ self,
1865
+ *,
1866
+ sender_id: str | None,
1867
+ origin_uuid: str | None,
1868
+ reach_name: str | None,
1869
+ seen: list[str],
1870
+ ) -> dict[str, object]:
1871
+ payload: dict[str, object] = {
1872
+ "uuid": str(self.uuid),
1873
+ "subject": self.subject,
1874
+ "body": self.body,
1875
+ "seen": list(seen),
1876
+ "reach": reach_name,
1877
+ "sender": sender_id,
1878
+ "origin": origin_uuid,
1879
+ }
1880
+ if self.attachments:
1881
+ payload["attachments"] = self.attachments
1882
+ if self.filter_node:
1883
+ payload["filter_node"] = str(self.filter_node.uuid)
1884
+ if self.filter_node_feature:
1885
+ payload["filter_node_feature"] = self.filter_node_feature.slug
1886
+ if self.filter_node_role:
1887
+ payload["filter_node_role"] = self.filter_node_role.name
1888
+ if self.filter_current_relation:
1889
+ payload["filter_current_relation"] = self.filter_current_relation
1890
+ if self.filter_installed_version:
1891
+ payload["filter_installed_version"] = self.filter_installed_version
1892
+ if self.filter_installed_revision:
1893
+ payload["filter_installed_revision"] = self.filter_installed_revision
1894
+ return payload
1895
+
1896
+ @staticmethod
1897
+ def _serialize_payload(payload: dict[str, object]) -> str:
1898
+ return json.dumps(payload, separators=(",", ":"), sort_keys=True)
1899
+
1900
+ @staticmethod
1901
+ def _sign_payload(payload_json: str, private_key) -> str | None:
1902
+ if not private_key:
1903
+ return None
1904
+ try:
1905
+ signature = private_key.sign(
1906
+ payload_json.encode(),
1907
+ padding.PKCS1v15(),
1908
+ hashes.SHA256(),
1909
+ )
1910
+ except Exception:
1911
+ return None
1912
+ return base64.b64encode(signature).decode()
1913
+
1914
+ def queue_for_node(self, node: "Node", seen: list[str]) -> None:
1915
+ """Queue this message for later delivery to ``node``."""
1916
+
1917
+ if node.current_relation != Node.Relation.DOWNSTREAM:
1918
+ return
1919
+
1920
+ now = timezone.now()
1921
+ expires_at = now + timedelta(hours=1)
1922
+ normalized_seen = [str(value) for value in seen]
1923
+ entry, created = PendingNetMessage.objects.get_or_create(
1924
+ node=node,
1925
+ message=self,
1926
+ defaults={
1927
+ "seen": normalized_seen,
1928
+ "stale_at": expires_at,
1929
+ },
1930
+ )
1931
+ if created:
1932
+ entry.queued_at = now
1933
+ entry.save(update_fields=["queued_at"])
1934
+ else:
1935
+ entry.seen = normalized_seen
1936
+ entry.stale_at = expires_at
1937
+ entry.queued_at = now
1938
+ entry.save(update_fields=["seen", "stale_at", "queued_at"])
1939
+ self._trim_queue(node)
1940
+
1941
+ def clear_queue_for_node(self, node: "Node") -> None:
1942
+ PendingNetMessage.objects.filter(node=node, message=self).delete()
1943
+
1944
+ def _trim_queue(self, node: "Node") -> None:
1945
+ limit = max(int(node.message_queue_length or 0), 0)
1946
+ if limit == 0:
1947
+ PendingNetMessage.objects.filter(node=node).delete()
1948
+ return
1949
+ qs = PendingNetMessage.objects.filter(node=node).order_by("-queued_at")
1950
+ keep_ids = list(qs.values_list("pk", flat=True)[:limit])
1951
+ if keep_ids:
1952
+ PendingNetMessage.objects.filter(node=node).exclude(pk__in=keep_ids).delete()
1953
+ else:
1954
+ qs.delete()
1955
+
1956
+ @classmethod
1957
+ def receive_payload(
1958
+ cls,
1959
+ data: dict[str, object],
1960
+ *,
1961
+ sender: "Node",
1962
+ ) -> "NetMessage":
1963
+ msg_uuid = data.get("uuid")
1964
+ if not msg_uuid:
1965
+ raise ValueError("uuid required")
1966
+ subject = (data.get("subject") or "")[:64]
1967
+ body = (data.get("body") or "")[:256]
1968
+ attachments = cls.normalize_attachments(data.get("attachments"))
1969
+ reach_name = data.get("reach")
1970
+ reach_role = None
1971
+ if reach_name:
1972
+ reach_role = NodeRole.objects.filter(name=reach_name).first()
1973
+ filter_node_uuid = data.get("filter_node")
1974
+ filter_node = None
1975
+ if filter_node_uuid:
1976
+ filter_node = Node.objects.filter(uuid=filter_node_uuid).first()
1977
+ filter_feature_slug = data.get("filter_node_feature")
1978
+ filter_feature = None
1979
+ if filter_feature_slug:
1980
+ filter_feature = NodeFeature.objects.filter(slug=filter_feature_slug).first()
1981
+ filter_role_name = data.get("filter_node_role")
1982
+ filter_role = None
1983
+ if filter_role_name:
1984
+ filter_role = NodeRole.objects.filter(name=filter_role_name).first()
1985
+ filter_relation_value = data.get("filter_current_relation")
1986
+ filter_relation = ""
1987
+ if filter_relation_value:
1988
+ relation = Node.normalize_relation(filter_relation_value)
1989
+ filter_relation = relation.value if relation else ""
1990
+ filter_installed_version = (data.get("filter_installed_version") or "")[:20]
1991
+ filter_installed_revision = (data.get("filter_installed_revision") or "")[:40]
1992
+ seen_values = data.get("seen", [])
1993
+ if not isinstance(seen_values, list):
1994
+ seen_values = list(seen_values) # type: ignore[arg-type]
1995
+ normalized_seen = [str(value) for value in seen_values if value is not None]
1996
+ origin_id = data.get("origin")
1997
+ origin_node = None
1998
+ if origin_id:
1999
+ origin_node = Node.objects.filter(uuid=origin_id).first()
2000
+ if not origin_node:
2001
+ origin_node = sender
2002
+ msg, created = cls.objects.get_or_create(
2003
+ uuid=msg_uuid,
2004
+ defaults={
2005
+ "subject": subject,
2006
+ "body": body,
2007
+ "reach": reach_role,
2008
+ "node_origin": origin_node,
2009
+ "attachments": attachments or None,
2010
+ "filter_node": filter_node,
2011
+ "filter_node_feature": filter_feature,
2012
+ "filter_node_role": filter_role,
2013
+ "filter_current_relation": filter_relation,
2014
+ "filter_installed_version": filter_installed_version,
2015
+ "filter_installed_revision": filter_installed_revision,
2016
+ },
2017
+ )
2018
+ if not created:
2019
+ msg.subject = subject
2020
+ msg.body = body
2021
+ update_fields = ["subject", "body"]
2022
+ if reach_role and msg.reach_id != reach_role.id:
2023
+ msg.reach = reach_role
2024
+ update_fields.append("reach")
2025
+ if msg.node_origin_id is None and origin_node:
2026
+ msg.node_origin = origin_node
2027
+ update_fields.append("node_origin")
2028
+ if attachments and msg.attachments != attachments:
2029
+ msg.attachments = attachments
2030
+ update_fields.append("attachments")
2031
+ field_updates = {
2032
+ "filter_node": filter_node,
2033
+ "filter_node_feature": filter_feature,
2034
+ "filter_node_role": filter_role,
2035
+ "filter_current_relation": filter_relation,
2036
+ "filter_installed_version": filter_installed_version,
2037
+ "filter_installed_revision": filter_installed_revision,
2038
+ }
2039
+ for field, value in field_updates.items():
2040
+ if getattr(msg, field) != value:
2041
+ setattr(msg, field, value)
2042
+ update_fields.append(field)
2043
+ if update_fields:
2044
+ msg.save(update_fields=update_fields)
2045
+ if attachments:
2046
+ msg.apply_attachments(attachments)
2047
+ msg.propagate(seen=normalized_seen)
2048
+ return msg
2049
+
2050
+ def propagate(self, seen: list[str] | None = None):
2051
+ from core.notifications import notify
2052
+ import random
2053
+ import requests
2054
+
2055
+ displayed = notify(self.subject, self.body)
2056
+ local = Node.get_local()
2057
+ if displayed:
2058
+ cutoff = timezone.now() - timedelta(days=7)
2059
+ prune_qs = type(self).objects.filter(created__lt=cutoff)
2060
+ if local:
2061
+ prune_qs = prune_qs.filter(
2062
+ models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
2063
+ )
2064
+ else:
2065
+ prune_qs = prune_qs.filter(node_origin__isnull=True)
2066
+ if self.pk:
2067
+ prune_qs = prune_qs.exclude(pk=self.pk)
2068
+ prune_qs.delete()
2069
+ if local and not self.node_origin_id:
2070
+ self.node_origin = local
2071
+ self.save(update_fields=["node_origin"])
2072
+ origin_uuid = None
2073
+ if self.node_origin_id:
2074
+ origin_uuid = str(self.node_origin.uuid)
2075
+ elif local:
2076
+ origin_uuid = str(local.uuid)
2077
+ private_key = None
2078
+ seen = list(seen or [])
2079
+ local_id = None
2080
+ if local:
2081
+ local_id = str(local.uuid)
2082
+ if local_id not in seen:
2083
+ seen.append(local_id)
2084
+ private_key = local.get_private_key()
2085
+ for node_id in seen:
2086
+ node = Node.objects.filter(uuid=node_id).first()
2087
+ if node and (not local or node.pk != local.pk):
2088
+ self.propagated_to.add(node)
2089
+
2090
+ filtered_nodes = Node.objects.all()
2091
+ if self.filter_node_id:
2092
+ filtered_nodes = filtered_nodes.filter(pk=self.filter_node_id)
2093
+ if self.filter_node_feature_id:
2094
+ filtered_nodes = filtered_nodes.filter(
2095
+ features__pk=self.filter_node_feature_id
2096
+ )
2097
+ if self.filter_node_role_id:
2098
+ filtered_nodes = filtered_nodes.filter(role_id=self.filter_node_role_id)
2099
+ if self.filter_current_relation:
2100
+ filtered_nodes = filtered_nodes.filter(
2101
+ current_relation=self.filter_current_relation
2102
+ )
2103
+ if self.filter_installed_version:
2104
+ filtered_nodes = filtered_nodes.filter(
2105
+ installed_version=self.filter_installed_version
2106
+ )
2107
+ if self.filter_installed_revision:
2108
+ filtered_nodes = filtered_nodes.filter(
2109
+ installed_revision=self.filter_installed_revision
2110
+ )
2111
+
2112
+ filtered_nodes = filtered_nodes.distinct()
2113
+
2114
+ if local:
2115
+ filtered_nodes = filtered_nodes.exclude(pk=local.pk)
2116
+ total_known = filtered_nodes.count()
2117
+
2118
+ remaining = list(
2119
+ filtered_nodes.exclude(
2120
+ pk__in=self.propagated_to.values_list("pk", flat=True)
2121
+ )
2122
+ )
2123
+ if not remaining:
2124
+ self.complete = True
2125
+ self.save(update_fields=["complete"])
2126
+ return
2127
+
2128
+ limit = self.target_limit or 6
2129
+ target_limit = min(limit, len(remaining))
2130
+
2131
+ reach_source = self.filter_node_role or self.reach
2132
+ reach_name = reach_source.name if reach_source else None
2133
+ role_map = {
2134
+ "Interface": ["Interface", "Terminal"],
2135
+ "Terminal": ["Terminal"],
2136
+ "Control": ["Control", "Terminal"],
2137
+ "Satellite": ["Satellite", "Control", "Terminal"],
2138
+ "Watchtower": [
2139
+ "Watchtower",
2140
+ "Satellite",
2141
+ "Control",
2142
+ "Terminal",
2143
+ ],
2144
+ "Constellation": [
2145
+ "Watchtower",
2146
+ "Satellite",
2147
+ "Control",
2148
+ "Terminal",
2149
+ ],
2150
+ }
2151
+ selected: list[Node] = []
2152
+ if self.filter_node_id:
2153
+ target = next((n for n in remaining if n.pk == self.filter_node_id), None)
2154
+ if target:
2155
+ selected = [target]
2156
+ else:
2157
+ self.complete = True
2158
+ self.save(update_fields=["complete"])
2159
+ return
2160
+ else:
2161
+ if self.filter_node_role_id:
2162
+ role_order = [reach_name]
2163
+ else:
2164
+ role_order = role_map.get(reach_name, [None])
2165
+ for role_name in role_order:
2166
+ if role_name is None:
2167
+ role_nodes = remaining[:]
2168
+ else:
2169
+ role_nodes = [
2170
+ n for n in remaining if n.role and n.role.name == role_name
2171
+ ]
2172
+ random.shuffle(role_nodes)
2173
+ for n in role_nodes:
2174
+ selected.append(n)
2175
+ remaining.remove(n)
2176
+ if len(selected) >= target_limit:
2177
+ break
2178
+ if len(selected) >= target_limit:
2179
+ break
2180
+
2181
+ if not selected:
2182
+ self.complete = True
2183
+ self.save(update_fields=["complete"])
2184
+ return
2185
+
2186
+ seen_list = seen.copy()
2187
+ selected_ids = [str(n.uuid) for n in selected]
2188
+ payload_seen = seen_list + selected_ids
2189
+ for node in selected:
2190
+ payload = self._build_payload(
2191
+ sender_id=local_id,
2192
+ origin_uuid=origin_uuid,
2193
+ reach_name=reach_name,
2194
+ seen=payload_seen,
2195
+ )
2196
+ payload_json = self._serialize_payload(payload)
2197
+ headers = {"Content-Type": "application/json"}
2198
+ signature = self._sign_payload(payload_json, private_key)
2199
+ if signature:
2200
+ headers["X-Signature"] = signature
2201
+ success = False
2202
+ for url in node.iter_remote_urls("/nodes/net-message/"):
2203
+ try:
2204
+ response = requests.post(
2205
+ url,
2206
+ data=payload_json,
2207
+ headers=headers,
2208
+ timeout=1,
2209
+ )
2210
+ success = bool(response.ok)
2211
+ except Exception:
2212
+ logger.exception(
2213
+ "Failed to propagate NetMessage %s to node %s via %s",
2214
+ self.pk,
2215
+ node.pk,
2216
+ url,
2217
+ )
2218
+ continue
2219
+ if success:
2220
+ break
2221
+ if success:
2222
+ self.clear_queue_for_node(node)
2223
+ else:
2224
+ self.queue_for_node(node, payload_seen)
2225
+ self.propagated_to.add(node)
2226
+
2227
+ save_fields: list[str] = []
2228
+ if total_known and self.propagated_to.count() >= total_known:
2229
+ self.complete = True
2230
+ save_fields.append("complete")
2231
+
2232
+ if save_fields:
2233
+ self.save(update_fields=save_fields)
2234
+
2235
+
2236
+ class PendingNetMessage(models.Model):
2237
+ """Queued :class:`NetMessage` awaiting delivery to a downstream node."""
2238
+
2239
+ node = models.ForeignKey(
2240
+ Node, on_delete=models.CASCADE, related_name="pending_net_messages"
2241
+ )
2242
+ message = models.ForeignKey(
2243
+ NetMessage,
2244
+ on_delete=models.CASCADE,
2245
+ related_name="pending_deliveries",
2246
+ )
2247
+ seen = models.JSONField(default=list)
2248
+ queued_at = models.DateTimeField(auto_now_add=True)
2249
+ stale_at = models.DateTimeField()
2250
+
2251
+ class Meta:
2252
+ unique_together = ("node", "message")
2253
+ ordering = ("queued_at",)
2254
+
2255
+ def __str__(self) -> str: # pragma: no cover - simple representation
2256
+ return f"{self.message_id} → {self.node_id}"
2257
+
2258
+ @property
2259
+ def is_stale(self) -> bool:
2260
+ return self.stale_at <= timezone.now()
2261
+
2262
+ class ContentSample(Entity):
2263
+ """Collected content such as text snippets or screenshots."""
2264
+
2265
+ TEXT = "TEXT"
2266
+ IMAGE = "IMAGE"
2267
+ KIND_CHOICES = [(TEXT, "Text"), (IMAGE, "Image")]
2268
+
2269
+ name = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
2270
+ kind = models.CharField(max_length=10, choices=KIND_CHOICES)
2271
+ content = models.TextField(blank=True)
2272
+ path = models.CharField(max_length=255, blank=True)
2273
+ method = models.CharField(max_length=10, default="", blank=True)
2274
+ hash = models.CharField(max_length=64, unique=True, null=True, blank=True)
2275
+ transaction_uuid = models.UUIDField(
2276
+ default=uuid.uuid4,
2277
+ editable=True,
2278
+ db_index=True,
2279
+ verbose_name="transaction UUID",
2280
+ )
2281
+ node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
2282
+ user = models.ForeignKey(
2283
+ settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
2284
+ )
2285
+ created_at = models.DateTimeField(auto_now_add=True)
2286
+
2287
+ class Meta:
2288
+ ordering = ["-created_at"]
2289
+ verbose_name = "Content Sample"
2290
+ verbose_name_plural = "Content Samples"
2291
+
2292
+ def save(self, *args, **kwargs):
2293
+ if self.pk:
2294
+ original = type(self).all_objects.get(pk=self.pk)
2295
+ if original.transaction_uuid != self.transaction_uuid:
2296
+ raise ValidationError(
2297
+ {"transaction_uuid": "Cannot modify transaction UUID"}
2298
+ )
2299
+ if self.node_id is None:
2300
+ self.node = Node.get_local()
2301
+ super().save(*args, **kwargs)
2302
+
2303
+ def __str__(self) -> str: # pragma: no cover - simple representation
2304
+ return str(self.name)
2305
+
2306
+
2307
+ class ContentClassifier(Entity):
2308
+ """Configured callable that classifies :class:`ContentSample` objects."""
2309
+
2310
+ slug = models.SlugField(max_length=100, unique=True)
2311
+ label = models.CharField(max_length=150)
2312
+ kind = models.CharField(max_length=10, choices=ContentSample.KIND_CHOICES)
2313
+ entrypoint = models.CharField(max_length=255, help_text="Dotted path to classifier callable")
2314
+ run_by_default = models.BooleanField(default=True)
2315
+ active = models.BooleanField(default=True)
2316
+
2317
+ class Meta:
2318
+ ordering = ["label"]
2319
+ verbose_name = "Content Classifier"
2320
+ verbose_name_plural = "Content Classifiers"
2321
+
2322
+ def __str__(self) -> str: # pragma: no cover - simple representation
2323
+ return self.label
2324
+
2325
+
2326
+ class ContentTag(Entity):
2327
+ """Tag that can be attached to classified content samples."""
2328
+
2329
+ slug = models.SlugField(max_length=100, unique=True)
2330
+ label = models.CharField(max_length=150)
2331
+
2332
+ class Meta:
2333
+ ordering = ["label"]
2334
+ verbose_name = "Content Tag"
2335
+ verbose_name_plural = "Content Tags"
2336
+
2337
+ def __str__(self) -> str: # pragma: no cover - simple representation
2338
+ return self.label
2339
+
2340
+
2341
+ class ContentClassification(Entity):
2342
+ """Link between a sample, classifier, and assigned tag."""
2343
+
2344
+ sample = models.ForeignKey(
2345
+ ContentSample, on_delete=models.CASCADE, related_name="classifications"
2346
+ )
2347
+ classifier = models.ForeignKey(
2348
+ ContentClassifier, on_delete=models.CASCADE, related_name="classifications"
2349
+ )
2350
+ tag = models.ForeignKey(
2351
+ ContentTag, on_delete=models.CASCADE, related_name="classifications"
2352
+ )
2353
+ confidence = models.FloatField(null=True, blank=True)
2354
+ metadata = models.JSONField(blank=True, null=True)
2355
+ created_at = models.DateTimeField(auto_now_add=True)
2356
+
2357
+ class Meta:
2358
+ unique_together = ("sample", "classifier", "tag")
2359
+ ordering = ["-created_at"]
2360
+ verbose_name = "Content Classification"
2361
+ verbose_name_plural = "Content Classifications"
2362
+
2363
+ def __str__(self) -> str: # pragma: no cover - simple representation
2364
+ return f"{self.sample} → {self.tag}"
2365
+
2366
+
2367
+ UserModel = get_user_model()
2368
+
2369
+
2370
+ class User(UserModel):
2371
+ class Meta:
2372
+ proxy = True
2373
+ app_label = "nodes"
2374
+ verbose_name = UserModel._meta.verbose_name
2375
+ verbose_name_plural = UserModel._meta.verbose_name_plural