arthexis 0.1.8__py3-none-any.whl → 0.1.10__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 (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
nodes/models.py CHANGED
@@ -1,27 +1,34 @@
1
+ from collections.abc import Iterable
1
2
  from django.db import models
3
+ from django.db.models.signals import post_delete
4
+ from django.dispatch import Signal, receiver
2
5
  from core.entity import Entity
3
- from core.fields import (
4
- SigilShortAutoField,
5
- SigilLongCheckField,
6
- SigilLongAutoField,
7
- )
6
+ from core.models import Profile
7
+ from core.fields import SigilShortAutoField
8
8
  import re
9
9
  import json
10
10
  import base64
11
+ from django.utils import timezone
11
12
  from django.utils.text import slugify
12
13
  from django.conf import settings
13
14
  from django.contrib.sites.models import Site
15
+ from datetime import timedelta
14
16
  import uuid
15
17
  import os
18
+ import shutil
16
19
  import socket
20
+ import stat
21
+ import subprocess
17
22
  from pathlib import Path
18
23
  from utils import revision
24
+ from core.notifications import notify_async
19
25
  from django.core.exceptions import ValidationError
20
26
  from cryptography.hazmat.primitives.asymmetric import rsa
21
27
  from cryptography.hazmat.primitives import serialization, hashes
22
28
  from cryptography.hazmat.primitives.asymmetric import padding
23
29
  from django.contrib.auth import get_user_model
24
- from django.core.mail import get_connection, send_mail
30
+ from django.core.mail import get_connection
31
+ from core import mailer
25
32
  import logging
26
33
 
27
34
 
@@ -53,6 +60,60 @@ class NodeRole(Entity):
53
60
  return self.name
54
61
 
55
62
 
63
+ class NodeFeatureManager(models.Manager):
64
+ def get_by_natural_key(self, slug: str):
65
+ return self.get(slug=slug)
66
+
67
+
68
+ class NodeFeature(Entity):
69
+ """Feature that may be enabled on nodes and roles."""
70
+
71
+ slug = models.SlugField(max_length=50, unique=True)
72
+ display = models.CharField(max_length=50)
73
+ roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
74
+
75
+ objects = NodeFeatureManager()
76
+
77
+ class Meta:
78
+ ordering = ["display"]
79
+ verbose_name = "Node Feature"
80
+ verbose_name_plural = "Node Features"
81
+
82
+ def natural_key(self): # pragma: no cover - simple representation
83
+ return (self.slug,)
84
+
85
+ def __str__(self) -> str: # pragma: no cover - simple representation
86
+ return self.display
87
+
88
+ @property
89
+ def is_enabled(self) -> bool:
90
+ from django.conf import settings
91
+ from pathlib import Path
92
+
93
+ node = Node.get_local()
94
+ if not node:
95
+ return False
96
+ if node.features.filter(pk=self.pk).exists():
97
+ return True
98
+ if self.slug == "gui-toast":
99
+ from core.notifications import supports_gui_toast
100
+
101
+ return supports_gui_toast()
102
+ if self.slug == "rpi-camera":
103
+ return Node._has_rpi_camera()
104
+ lock_map = {
105
+ "lcd-screen": "lcd_screen.lck",
106
+ "rfid-scanner": "rfid.lck",
107
+ "celery-queue": "celery.lck",
108
+ "nginx-server": "nginx_mode.lck",
109
+ }
110
+ lock = lock_map.get(self.slug)
111
+ if lock:
112
+ base_path = Path(node.base_path or settings.BASE_DIR)
113
+ return (base_path / "locks" / lock).exists()
114
+ return False
115
+
116
+
56
117
  def get_terminal_role():
57
118
  """Return the NodeRole representing a Terminal if it exists."""
58
119
  return NodeRole.objects.filter(name="Terminal").first()
@@ -63,9 +124,7 @@ class Node(Entity):
63
124
 
64
125
  hostname = models.CharField(max_length=100)
65
126
  address = models.GenericIPAddressField()
66
- mac_address = models.CharField(
67
- max_length=17, unique=True, null=True, blank=True
68
- )
127
+ mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
69
128
  port = models.PositiveIntegerField(default=8000)
70
129
  badge_color = models.CharField(max_length=7, default="#28a745")
71
130
  role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
@@ -75,8 +134,6 @@ class Node(Entity):
75
134
  verbose_name="enable public API",
76
135
  )
77
136
  public_endpoint = models.SlugField(blank=True, unique=True)
78
- clipboard_polling = models.BooleanField(default=False)
79
- screenshot_polling = models.BooleanField(default=False)
80
137
  uuid = models.UUIDField(
81
138
  default=uuid.uuid4,
82
139
  unique=True,
@@ -87,7 +144,31 @@ class Node(Entity):
87
144
  base_path = models.CharField(max_length=255, blank=True)
88
145
  installed_version = models.CharField(max_length=20, blank=True)
89
146
  installed_revision = models.CharField(max_length=40, blank=True)
90
- has_lcd_screen = models.BooleanField(default=False)
147
+ features = models.ManyToManyField(
148
+ NodeFeature,
149
+ through="NodeFeatureAssignment",
150
+ related_name="nodes",
151
+ blank=True,
152
+ )
153
+
154
+ FEATURE_LOCK_MAP = {
155
+ "lcd-screen": "lcd_screen.lck",
156
+ "rfid-scanner": "rfid.lck",
157
+ "celery-queue": "celery.lck",
158
+ "nginx-server": "nginx_mode.lck",
159
+ }
160
+ RPI_CAMERA_DEVICE = Path("/dev/video0")
161
+ RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
162
+ AP_ROUTER_SSID = "gelectriic-ap"
163
+ NMCLI_TIMEOUT = 5
164
+ AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
165
+ "gui-toast",
166
+ "rpi-camera",
167
+ "ap-router",
168
+ "ap-public-wifi",
169
+ "postgres-db",
170
+ }
171
+ MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
91
172
 
92
173
  def __str__(self) -> str: # pragma: no cover - simple representation
93
174
  return f"{self.hostname}:{self.port}"
@@ -122,7 +203,6 @@ class Node(Entity):
122
203
  node = cls.objects.filter(mac_address=mac).first()
123
204
  if not node:
124
205
  node = cls.objects.filter(public_endpoint=slug).first()
125
- lcd_lock = Path(settings.BASE_DIR) / "locks" / "lcd_screen.lck"
126
206
  defaults = {
127
207
  "hostname": hostname,
128
208
  "address": address,
@@ -132,14 +212,11 @@ class Node(Entity):
132
212
  "installed_revision": installed_revision,
133
213
  "public_endpoint": slug,
134
214
  "mac_address": mac,
135
- "has_lcd_screen": lcd_lock.exists(),
136
215
  }
137
216
  if node:
138
217
  for field, value in defaults.items():
139
- if field == "has_lcd_screen":
140
- continue
141
218
  setattr(node, field, value)
142
- update_fields = [k for k in defaults.keys() if k != "has_lcd_screen"]
219
+ update_fields = list(defaults.keys())
143
220
  node.save(update_fields=update_fields)
144
221
  created = False
145
222
  else:
@@ -161,17 +238,119 @@ class Node(Entity):
161
238
  node.save(update_fields=["role"])
162
239
  Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
163
240
  node.ensure_keys()
241
+ node.notify_peers_of_update()
164
242
  return node, created
165
243
 
244
+ def notify_peers_of_update(self):
245
+ """Attempt to update this node's registration with known peers."""
246
+
247
+ from secrets import token_hex
248
+
249
+ try:
250
+ import requests
251
+ except Exception: # pragma: no cover - requests should be available
252
+ return
253
+
254
+ security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
255
+ priv_path = security_dir / f"{self.public_endpoint}"
256
+ if not priv_path.exists():
257
+ logger.debug("Private key for %s not found; skipping peer update", self)
258
+ return
259
+ try:
260
+ private_key = serialization.load_pem_private_key(
261
+ priv_path.read_bytes(), password=None
262
+ )
263
+ except Exception as exc: # pragma: no cover - defensive
264
+ logger.warning("Failed to load private key for %s: %s", self, exc)
265
+ return
266
+ token = token_hex(16)
267
+ try:
268
+ signature = private_key.sign(
269
+ token.encode(),
270
+ padding.PKCS1v15(),
271
+ hashes.SHA256(),
272
+ )
273
+ except Exception as exc: # pragma: no cover - defensive
274
+ logger.warning("Failed to sign peer update for %s: %s", self, exc)
275
+ return
276
+
277
+ payload = {
278
+ "hostname": self.hostname,
279
+ "address": self.address,
280
+ "port": self.port,
281
+ "mac_address": self.mac_address,
282
+ "public_key": self.public_key,
283
+ "token": token,
284
+ "signature": base64.b64encode(signature).decode(),
285
+ }
286
+ if self.installed_version:
287
+ payload["installed_version"] = self.installed_version
288
+ if self.installed_revision:
289
+ payload["installed_revision"] = self.installed_revision
290
+
291
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
292
+ headers = {"Content-Type": "application/json"}
293
+
294
+ peers = Node.objects.exclude(pk=self.pk)
295
+ for peer in peers:
296
+ host_candidates: list[str] = []
297
+ if peer.address:
298
+ host_candidates.append(peer.address)
299
+ if peer.hostname and peer.hostname not in host_candidates:
300
+ host_candidates.append(peer.hostname)
301
+ port = peer.port or 8000
302
+ urls: list[str] = []
303
+ for host in host_candidates:
304
+ host = host.strip()
305
+ if not host:
306
+ continue
307
+ if ":" in host and not host.startswith("["):
308
+ host = f"[{host}]"
309
+ http_url = (
310
+ f"http://{host}/nodes/register/"
311
+ if port == 80
312
+ else f"http://{host}:{port}/nodes/register/"
313
+ )
314
+ https_url = (
315
+ f"https://{host}/nodes/register/"
316
+ if port in {80, 443}
317
+ else f"https://{host}:{port}/nodes/register/"
318
+ )
319
+ for url in (https_url, http_url):
320
+ if url not in urls:
321
+ urls.append(url)
322
+ if not urls:
323
+ continue
324
+ for url in urls:
325
+ try:
326
+ response = requests.post(
327
+ url, data=payload_json, headers=headers, timeout=2
328
+ )
329
+ except Exception as exc: # pragma: no cover - best effort
330
+ logger.debug("Failed to update %s via %s: %s", peer, url, exc)
331
+ continue
332
+ if response.ok:
333
+ version_display = _format_upgrade_body(
334
+ self.installed_version,
335
+ self.installed_revision,
336
+ )
337
+ version_suffix = f" ({version_display})" if version_display else ""
338
+ logger.info(
339
+ "Announced startup to %s%s",
340
+ peer,
341
+ version_suffix,
342
+ )
343
+ break
344
+ else:
345
+ logger.warning("Unable to notify node %s of startup", peer)
346
+
166
347
  def ensure_keys(self):
167
348
  security_dir = Path(settings.BASE_DIR) / "security"
168
349
  security_dir.mkdir(parents=True, exist_ok=True)
169
350
  priv_path = security_dir / f"{self.public_endpoint}"
170
351
  pub_path = security_dir / f"{self.public_endpoint}.pub"
171
352
  if not priv_path.exists() or not pub_path.exists():
172
- private_key = rsa.generate_private_key(
173
- public_exponent=65537, key_size=2048
174
- )
353
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
175
354
  private_bytes = private_key.private_bytes(
176
355
  encoding=serialization.Encoding.PEM,
177
356
  format=serialization.PrivateFormat.TraditionalOpenSSL,
@@ -199,22 +378,196 @@ class Node(Entity):
199
378
  self.mac_address = self.mac_address.lower()
200
379
  if not self.public_endpoint:
201
380
  self.public_endpoint = slugify(self.hostname)
202
- previous_clipboard = previous_screenshot = None
203
- if self.pk:
204
- previous = Node.objects.get(pk=self.pk)
205
- previous_clipboard = previous.clipboard_polling
206
- previous_screenshot = previous.screenshot_polling
207
381
  super().save(*args, **kwargs)
208
- if previous_clipboard != self.clipboard_polling:
209
- self._sync_clipboard_task()
210
- if previous_screenshot != self.screenshot_polling:
211
- self._sync_screenshot_task()
382
+ if self.pk:
383
+ self.refresh_features()
384
+
385
+ def has_feature(self, slug: str) -> bool:
386
+ return self.features.filter(slug=slug).exists()
387
+
388
+ @classmethod
389
+ def _has_rpi_camera(cls) -> bool:
390
+ """Return ``True`` when the Raspberry Pi camera stack is available."""
391
+
392
+ device = cls.RPI_CAMERA_DEVICE
393
+ if not device.exists():
394
+ return False
395
+ device_path = str(device)
396
+ try:
397
+ mode = os.stat(device_path).st_mode
398
+ except OSError:
399
+ return False
400
+ if not stat.S_ISCHR(mode):
401
+ return False
402
+ if not os.access(device_path, os.R_OK | os.W_OK):
403
+ return False
404
+ for binary in cls.RPI_CAMERA_BINARIES:
405
+ tool_path = shutil.which(binary)
406
+ if not tool_path:
407
+ return False
408
+ try:
409
+ result = subprocess.run(
410
+ [tool_path, "--help"],
411
+ capture_output=True,
412
+ text=True,
413
+ check=False,
414
+ timeout=5,
415
+ )
416
+ except Exception:
417
+ return False
418
+ if result.returncode != 0:
419
+ return False
420
+ return True
421
+
422
+ @classmethod
423
+ def _hosts_gelectriic_ap(cls) -> bool:
424
+ """Return ``True`` when the node is hosting the gelectriic access point."""
425
+
426
+ nmcli_path = shutil.which("nmcli")
427
+ if not nmcli_path:
428
+ return False
429
+ try:
430
+ result = subprocess.run(
431
+ [
432
+ nmcli_path,
433
+ "-t",
434
+ "-f",
435
+ "NAME,DEVICE,TYPE",
436
+ "connection",
437
+ "show",
438
+ "--active",
439
+ ],
440
+ capture_output=True,
441
+ text=True,
442
+ check=False,
443
+ timeout=cls.NMCLI_TIMEOUT,
444
+ )
445
+ except Exception:
446
+ return False
447
+ if result.returncode != 0:
448
+ return False
449
+ for line in result.stdout.splitlines():
450
+ if not line:
451
+ continue
452
+ parts = line.split(":", 2)
453
+ if not parts:
454
+ continue
455
+ name = parts[0]
456
+ conn_type = ""
457
+ if len(parts) == 3:
458
+ conn_type = parts[2]
459
+ elif len(parts) > 1:
460
+ conn_type = parts[1]
461
+ if name != cls.AP_ROUTER_SSID:
462
+ continue
463
+ conn_type_normalized = conn_type.strip().lower()
464
+ if conn_type_normalized not in {"wifi", "802-11-wireless"}:
465
+ continue
466
+ try:
467
+ mode_result = subprocess.run(
468
+ [
469
+ nmcli_path,
470
+ "-g",
471
+ "802-11-wireless.mode",
472
+ "connection",
473
+ "show",
474
+ name,
475
+ ],
476
+ capture_output=True,
477
+ text=True,
478
+ check=False,
479
+ timeout=cls.NMCLI_TIMEOUT,
480
+ )
481
+ except Exception:
482
+ continue
483
+ if mode_result.returncode != 0:
484
+ continue
485
+ if mode_result.stdout.strip() == "ap":
486
+ return True
487
+ return False
488
+
489
+ @staticmethod
490
+ def _uses_postgres() -> bool:
491
+ """Return ``True`` when the default database uses PostgreSQL."""
212
492
 
213
- def _sync_clipboard_task(self):
493
+ engine = settings.DATABASES.get("default", {}).get("ENGINE", "")
494
+ return "postgresql" in engine.lower()
495
+
496
+ def refresh_features(self):
497
+ if not self.pk:
498
+ return
499
+ if not self.is_local:
500
+ self.sync_feature_tasks()
501
+ return
502
+ detected_slugs = set()
503
+ base_path = Path(self.base_path or settings.BASE_DIR)
504
+ locks_dir = base_path / "locks"
505
+ for slug, filename in self.FEATURE_LOCK_MAP.items():
506
+ if (locks_dir / filename).exists():
507
+ detected_slugs.add(slug)
508
+ if self._has_rpi_camera():
509
+ detected_slugs.add("rpi-camera")
510
+ public_mode_lock = locks_dir / "public_wifi_mode.lck"
511
+ if self._hosts_gelectriic_ap():
512
+ if public_mode_lock.exists():
513
+ detected_slugs.add("ap-public-wifi")
514
+ else:
515
+ detected_slugs.add("ap-router")
516
+ if self._uses_postgres():
517
+ detected_slugs.add("postgres-db")
518
+ try:
519
+ from core.notifications import supports_gui_toast
520
+ except Exception:
521
+ pass
522
+ else:
523
+ try:
524
+ if supports_gui_toast():
525
+ detected_slugs.add("gui-toast")
526
+ except Exception:
527
+ pass
528
+ current_slugs = set(
529
+ self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
530
+ "slug", flat=True
531
+ )
532
+ )
533
+ add_slugs = detected_slugs - current_slugs
534
+ if add_slugs:
535
+ for feature in NodeFeature.objects.filter(slug__in=add_slugs):
536
+ NodeFeatureAssignment.objects.update_or_create(
537
+ node=self, feature=feature
538
+ )
539
+ remove_slugs = current_slugs - detected_slugs
540
+ if remove_slugs:
541
+ NodeFeatureAssignment.objects.filter(
542
+ node=self, feature__slug__in=remove_slugs
543
+ ).delete()
544
+ self.sync_feature_tasks()
545
+
546
+ def update_manual_features(self, slugs: Iterable[str]):
547
+ desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
548
+ remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
549
+ if remove_slugs:
550
+ NodeFeatureAssignment.objects.filter(
551
+ node=self, feature__slug__in=remove_slugs
552
+ ).delete()
553
+ if desired:
554
+ for feature in NodeFeature.objects.filter(slug__in=desired):
555
+ NodeFeatureAssignment.objects.update_or_create(
556
+ node=self, feature=feature
557
+ )
558
+ self.sync_feature_tasks()
559
+
560
+ def sync_feature_tasks(self):
561
+ clipboard_enabled = self.has_feature("clipboard-poll")
562
+ screenshot_enabled = self.has_feature("screenshot-poll")
563
+ self._sync_clipboard_task(clipboard_enabled)
564
+ self._sync_screenshot_task(screenshot_enabled)
565
+
566
+ def _sync_clipboard_task(self, enabled: bool):
214
567
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
215
568
 
216
569
  task_name = f"poll_clipboard_node_{self.pk}"
217
- if self.clipboard_polling:
570
+ if enabled:
218
571
  schedule, _ = IntervalSchedule.objects.get_or_create(
219
572
  every=5, period=IntervalSchedule.SECONDS
220
573
  )
@@ -228,12 +581,12 @@ class Node(Entity):
228
581
  else:
229
582
  PeriodicTask.objects.filter(name=task_name).delete()
230
583
 
231
- def _sync_screenshot_task(self):
584
+ def _sync_screenshot_task(self, enabled: bool):
232
585
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
233
586
  import json
234
587
 
235
588
  task_name = f"capture_screenshot_node_{self.pk}"
236
- if self.screenshot_polling:
589
+ if enabled:
237
590
  schedule, _ = IntervalSchedule.objects.get_or_create(
238
591
  every=1, period=IntervalSchedule.MINUTES
239
592
  )
@@ -254,45 +607,139 @@ class Node(Entity):
254
607
  else:
255
608
  PeriodicTask.objects.filter(name=task_name).delete()
256
609
 
257
-
258
- def send_mail(self, subject: str, message: str, recipient_list: list[str], from_email: str | None = None, **kwargs):
610
+ def send_mail(
611
+ self,
612
+ subject: str,
613
+ message: str,
614
+ recipient_list: list[str],
615
+ from_email: str | None = None,
616
+ **kwargs,
617
+ ):
259
618
  """Send an email using this node's configured outbox if available."""
260
619
  outbox = getattr(self, "email_outbox", None)
261
620
  logger.info(
262
- "Node %s sending email to %s using %s backend",
621
+ "Node %s queueing email to %s using %s backend",
263
622
  self.pk,
264
623
  recipient_list,
265
624
  "outbox" if outbox else "default",
266
625
  )
267
- if outbox:
268
- result = outbox.send_mail(subject, message, recipient_list, from_email, **kwargs)
269
- logger.info("Outbox send_mail result: %s", result)
270
- return result
271
- from_email = from_email or settings.DEFAULT_FROM_EMAIL
272
- result = send_mail(subject, message, from_email, recipient_list, **kwargs)
273
- logger.info("Default send_mail result: %s", result)
274
- return result
626
+ return mailer.send(
627
+ subject,
628
+ message,
629
+ recipient_list,
630
+ from_email,
631
+ outbox=outbox,
632
+ **kwargs,
633
+ )
634
+
635
+
636
+ node_information_updated = Signal()
637
+
638
+
639
+ def _format_upgrade_body(version: str, revision: str) -> str:
640
+ version = (version or "").strip()
641
+ revision = (revision or "").strip()
642
+ parts: list[str] = []
643
+ if version:
644
+ normalized = version.lstrip("vV") or version
645
+ parts.append(f"v{normalized}")
646
+ if revision:
647
+ rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
648
+ rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
649
+ parts.append(f"r{rev_short}")
650
+ return " ".join(parts).strip()
651
+
275
652
 
653
+ @receiver(node_information_updated)
654
+ def _announce_peer_startup(
655
+ sender,
656
+ *,
657
+ node: "Node",
658
+ previous_version: str = "",
659
+ previous_revision: str = "",
660
+ current_version: str = "",
661
+ current_revision: str = "",
662
+ **_: object,
663
+ ) -> None:
664
+ current_version = (current_version or "").strip()
665
+ current_revision = (current_revision or "").strip()
666
+ previous_version = (previous_version or "").strip()
667
+ previous_revision = (previous_revision or "").strip()
276
668
 
277
- class EmailOutbox(Entity):
278
- """SMTP credentials for sending mail from a node."""
669
+ local = Node.get_local()
670
+ if local and node.pk == local.pk:
671
+ return
672
+
673
+ body = _format_upgrade_body(current_version, current_revision)
674
+ if not body:
675
+ body = "Online"
676
+
677
+ hostname = (node.hostname or "Node").strip() or "Node"
678
+ subject = f"UP {hostname}"
679
+ notify_async(subject, body)
680
+
681
+
682
+ class NodeFeatureAssignment(Entity):
683
+ """Bridge between :class:`Node` and :class:`NodeFeature`."""
684
+
685
+ node = models.ForeignKey(
686
+ Node, on_delete=models.CASCADE, related_name="feature_assignments"
687
+ )
688
+ feature = models.ForeignKey(
689
+ NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
690
+ )
691
+ created_at = models.DateTimeField(auto_now_add=True)
692
+
693
+ class Meta:
694
+ unique_together = ("node", "feature")
695
+ verbose_name = "Node Feature Assignment"
696
+ verbose_name_plural = "Node Feature Assignments"
697
+
698
+ def __str__(self) -> str: # pragma: no cover - simple representation
699
+ return f"{self.node} -> {self.feature}"
700
+
701
+ def save(self, *args, **kwargs):
702
+ super().save(*args, **kwargs)
703
+ self.node.sync_feature_tasks()
704
+
705
+
706
+ @receiver(post_delete, sender=NodeFeatureAssignment)
707
+ def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
708
+ node_id = getattr(instance, "node_id", None)
709
+ if not node_id:
710
+ return
711
+ node = Node.objects.filter(pk=node_id).first()
712
+ if node:
713
+ node.sync_feature_tasks()
714
+
715
+
716
+ class EmailOutbox(Profile):
717
+ """SMTP credentials for sending mail."""
718
+
719
+ profile_fields = (
720
+ "host",
721
+ "port",
722
+ "username",
723
+ "password",
724
+ "use_tls",
725
+ "use_ssl",
726
+ "from_email",
727
+ )
279
728
 
280
729
  node = models.OneToOneField(
281
- Node, on_delete=models.CASCADE, related_name="email_outbox"
730
+ Node,
731
+ on_delete=models.CASCADE,
732
+ related_name="email_outbox",
733
+ null=True,
734
+ blank=True,
282
735
  )
283
736
  host = SigilShortAutoField(
284
737
  max_length=100,
285
- help_text=(
286
- "Gmail: smtp.gmail.com. "
287
- "GoDaddy: smtpout.secureserver.net"
288
- ),
738
+ help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
289
739
  )
290
740
  port = models.PositiveIntegerField(
291
741
  default=587,
292
- help_text=(
293
- "Gmail: 587 (TLS). "
294
- "GoDaddy: 587 (TLS) or 465 (SSL)"
295
- ),
742
+ help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
296
743
  )
297
744
  username = SigilShortAutoField(
298
745
  max_length=100,
@@ -323,12 +770,35 @@ class EmailOutbox(Entity):
323
770
  verbose_name = "Email Outbox"
324
771
  verbose_name_plural = "Email Outboxes"
325
772
 
326
- class Meta:
327
- verbose_name = "Email Outbox"
328
- verbose_name_plural = "Email Outboxes"
773
+ def __str__(self) -> str:
774
+ address = (self.from_email or "").strip()
775
+ if address:
776
+ return address
777
+
778
+ username = (self.username or "").strip()
779
+ host = (self.host or "").strip()
780
+ if username and host:
781
+ return f"{username}@{host}"
782
+ if username:
783
+ return username
784
+ if host:
785
+ return host
786
+
787
+ owner = self.owner_display()
788
+ if owner:
789
+ return owner
790
+
791
+ return super().__str__()
792
+
793
+ def clean(self):
794
+ if self.user_id or self.group_id:
795
+ super().clean()
796
+ else:
797
+ super(Profile, self).clean()
329
798
 
330
799
  def get_connection(self):
331
800
  return get_connection(
801
+ "django.core.mail.backends.smtp.EmailBackend",
332
802
  host=self.host,
333
803
  port=self.port,
334
804
  username=self.username or None,
@@ -338,17 +808,23 @@ class EmailOutbox(Entity):
338
808
  )
339
809
 
340
810
  def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
341
- connection = self.get_connection()
342
811
  from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
343
- return send_mail(
812
+ logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
813
+ return mailer.send(
344
814
  subject,
345
815
  message,
346
- from_email,
347
816
  recipient_list,
348
- connection=connection,
817
+ from_email,
818
+ outbox=self,
349
819
  **kwargs,
350
820
  )
351
821
 
822
+ def owner_display(self):
823
+ owner = super().owner_display()
824
+ if owner:
825
+ return owner
826
+ return str(self.node) if self.node_id else ""
827
+
352
828
 
353
829
  class NetMessage(Entity):
354
830
  """Message propagated across nodes."""
@@ -359,6 +835,13 @@ class NetMessage(Entity):
359
835
  editable=False,
360
836
  verbose_name="UUID",
361
837
  )
838
+ node_origin = models.ForeignKey(
839
+ "Node",
840
+ on_delete=models.SET_NULL,
841
+ null=True,
842
+ blank=True,
843
+ related_name="originated_net_messages",
844
+ )
362
845
  subject = models.CharField(max_length=64, blank=True)
363
846
  body = models.CharField(max_length=256, blank=True)
364
847
  reach = models.ForeignKey(
@@ -393,10 +876,12 @@ class NetMessage(Entity):
393
876
  role = reach
394
877
  else:
395
878
  role = NodeRole.objects.filter(name=reach).first()
879
+ origin = Node.get_local()
396
880
  msg = cls.objects.create(
397
881
  subject=subject[:64],
398
882
  body=body[:256],
399
883
  reach=role or get_terminal_role(),
884
+ node_origin=origin,
400
885
  )
401
886
  msg.propagate(seen=seen or [])
402
887
  return msg
@@ -406,8 +891,28 @@ class NetMessage(Entity):
406
891
  import random
407
892
  import requests
408
893
 
409
- notify(self.subject, self.body)
894
+ displayed = notify(self.subject, self.body)
410
895
  local = Node.get_local()
896
+ if displayed:
897
+ cutoff = timezone.now() - timedelta(days=7)
898
+ prune_qs = type(self).objects.filter(created__lt=cutoff)
899
+ if local:
900
+ prune_qs = prune_qs.filter(
901
+ models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
902
+ )
903
+ else:
904
+ prune_qs = prune_qs.filter(node_origin__isnull=True)
905
+ if self.pk:
906
+ prune_qs = prune_qs.exclude(pk=self.pk)
907
+ prune_qs.delete()
908
+ if local and not self.node_origin_id:
909
+ self.node_origin = local
910
+ self.save(update_fields=["node_origin"])
911
+ origin_uuid = None
912
+ if self.node_origin_id:
913
+ origin_uuid = str(self.node_origin.uuid)
914
+ elif local:
915
+ origin_uuid = str(local.uuid)
411
916
  private_key = None
412
917
  seen = list(seen or [])
413
918
  local_id = None
@@ -448,12 +953,15 @@ class NetMessage(Entity):
448
953
 
449
954
  reach_name = self.reach.name if self.reach else "Terminal"
450
955
  role_map = {
451
- "Particle": ["Particle"],
452
- "Terminal": ["Terminal", "Particle"],
453
- "Control": ["Control", "Terminal", "Particle"],
454
- "Satellite": ["Satellite", "Control", "Terminal", "Particle"],
455
- "Constellation": ["Constellation", "Satellite", "Control", "Terminal", "Particle"],
456
- "Virtual": ["Virtual", "Constellation", "Satellite", "Control", "Terminal", "Particle"],
956
+ "Terminal": ["Terminal"],
957
+ "Control": ["Control", "Terminal"],
958
+ "Satellite": ["Satellite", "Control", "Terminal"],
959
+ "Constellation": [
960
+ "Constellation",
961
+ "Satellite",
962
+ "Control",
963
+ "Terminal",
964
+ ],
457
965
  }
458
966
  role_order = role_map.get(reach_name, ["Terminal"])
459
967
  selected: list[Node] = []
@@ -479,6 +987,7 @@ class NetMessage(Entity):
479
987
  "seen": payload_seen,
480
988
  "reach": reach_name,
481
989
  "sender": local_id,
990
+ "origin": origin_uuid,
482
991
  }
483
992
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
484
993
  headers = {"Content-Type": "application/json"}
@@ -527,9 +1036,7 @@ class ContentSample(Entity):
527
1036
  db_index=True,
528
1037
  verbose_name="transaction UUID",
529
1038
  )
530
- node = models.ForeignKey(
531
- Node, on_delete=models.SET_NULL, null=True, blank=True
532
- )
1039
+ node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
533
1040
  user = models.ForeignKey(
534
1041
  settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
535
1042
  )
@@ -555,109 +1062,6 @@ class ContentSample(Entity):
555
1062
  return str(self.name)
556
1063
 
557
1064
 
558
- class NodeTask(Entity):
559
- """Script that can be executed on nodes."""
560
-
561
- recipe = models.TextField()
562
- role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
563
- created = models.DateTimeField(auto_now_add=True)
564
-
565
- class Meta:
566
- ordering = ["-created"]
567
- verbose_name = "Node Task"
568
- verbose_name_plural = "Node Tasks"
569
-
570
- def __str__(self) -> str: # pragma: no cover - simple representation
571
- return self.recipe
572
-
573
- def run(self, node: Node):
574
- """Execute this script on ``node`` and return its output."""
575
- if not node.is_local:
576
- raise NotImplementedError("Remote node execution is not implemented")
577
- import subprocess
578
-
579
- result = subprocess.run(
580
- self.recipe, shell=True, capture_output=True, text=True
581
- )
582
- return result.stdout + result.stderr
583
-
584
-
585
- class Operation(Entity):
586
- """Action that can change node or constellation state."""
587
-
588
- name = models.SlugField(unique=True)
589
- template = SigilLongCheckField(blank=True)
590
- command = SigilLongAutoField(blank=True)
591
- is_django = models.BooleanField(default=False)
592
- next_operations = models.ManyToManyField(
593
- "self",
594
- through="Interrupt",
595
- through_fields=("from_operation", "to_operation"),
596
- symmetrical=False,
597
- related_name="previous_operations",
598
- )
599
-
600
- class Meta:
601
- ordering = ["name"]
602
-
603
- def __str__(self) -> str: # pragma: no cover - simple representation
604
- return self.name
605
-
606
-
607
- class Interrupt(Entity):
608
- """Intermediate transition between operations."""
609
-
610
- name = models.CharField(max_length=100)
611
- preview = SigilLongAutoField(blank=True)
612
- priority = models.PositiveIntegerField(default=0)
613
- from_operation = models.ForeignKey(
614
- Operation,
615
- on_delete=models.CASCADE,
616
- related_name="outgoing_interrupts",
617
- )
618
- to_operation = models.ForeignKey(
619
- Operation,
620
- on_delete=models.CASCADE,
621
- related_name="incoming_interrupts",
622
- )
623
-
624
- class Meta:
625
- ordering = ["-priority"]
626
-
627
- def __str__(self) -> str: # pragma: no cover - simple representation
628
- return self.name
629
-
630
-
631
- class Logbook(Entity):
632
- """Record of executed operations."""
633
-
634
- operation = models.ForeignKey(
635
- Operation, on_delete=models.CASCADE, related_name="logs"
636
- )
637
- user = models.ForeignKey(
638
- settings.AUTH_USER_MODEL,
639
- null=True,
640
- blank=True,
641
- on_delete=models.SET_NULL,
642
- )
643
- input_text = models.TextField(blank=True)
644
- output = models.TextField(blank=True)
645
- error = models.TextField(blank=True)
646
- interrupted = models.BooleanField(default=False)
647
- interrupt = models.ForeignKey(
648
- Interrupt, null=True, blank=True, on_delete=models.SET_NULL
649
- )
650
- created = models.DateTimeField(auto_now_add=True)
651
-
652
- class Meta:
653
- ordering = ["-created"]
654
- verbose_name = "Logbook Entry"
655
- verbose_name_plural = "Logbook"
656
-
657
- def __str__(self) -> str: # pragma: no cover - simple representation
658
- return f"{self.operation} @ {self.created:%Y-%m-%d %H:%M:%S}"
659
-
660
-
661
1065
  UserModel = get_user_model()
662
1066
 
663
1067
 
@@ -667,7 +1071,3 @@ class User(UserModel):
667
1071
  app_label = "nodes"
668
1072
  verbose_name = UserModel._meta.verbose_name
669
1073
  verbose_name_plural = UserModel._meta.verbose_name_plural
670
-
671
-
672
-
673
-