arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
nodes/models.py CHANGED
@@ -1,16 +1,21 @@
1
+ from __future__ import annotations
2
+
1
3
  from collections.abc import Iterable
2
4
  from django.db import models
5
+ from django.db.utils import DatabaseError
3
6
  from django.db.models.signals import post_delete
4
- from django.dispatch import receiver
7
+ from django.dispatch import Signal, receiver
5
8
  from core.entity import Entity
6
9
  from core.models import Profile
7
- from core.fields import SigilShortAutoField
10
+ from core.fields import SigilLongAutoField, SigilShortAutoField
8
11
  import re
9
12
  import json
10
13
  import base64
14
+ from django.utils import timezone
11
15
  from django.utils.text import slugify
12
16
  from django.conf import settings
13
17
  from django.contrib.sites.models import Site
18
+ from datetime import timedelta
14
19
  import uuid
15
20
  import os
16
21
  import shutil
@@ -19,6 +24,7 @@ import stat
19
24
  import subprocess
20
25
  from pathlib import Path
21
26
  from utils import revision
27
+ from core.notifications import notify_async
22
28
  from django.core.exceptions import ValidationError
23
29
  from cryptography.hazmat.primitives.asymmetric import rsa
24
30
  from cryptography.hazmat.primitives import serialization, hashes
@@ -119,12 +125,23 @@ def get_terminal_role():
119
125
  class Node(Entity):
120
126
  """Information about a running node in the network."""
121
127
 
128
+ class Relation(models.TextChoices):
129
+ UPSTREAM = "UPSTREAM", "Upstream"
130
+ DOWNSTREAM = "DOWNSTREAM", "Downstream"
131
+ PEER = "PEER", "Peer"
132
+ SELF = "SELF", "Self"
133
+
122
134
  hostname = models.CharField(max_length=100)
123
135
  address = models.GenericIPAddressField()
124
136
  mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
125
137
  port = models.PositiveIntegerField(default=8000)
126
138
  badge_color = models.CharField(max_length=7, default="#28a745")
127
139
  role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
140
+ current_relation = models.CharField(
141
+ max_length=10,
142
+ choices=Relation.choices,
143
+ default=Relation.PEER,
144
+ )
128
145
  last_seen = models.DateTimeField(auto_now=True)
129
146
  enable_public_api = models.BooleanField(
130
147
  default=False,
@@ -175,11 +192,35 @@ class Node(Entity):
175
192
  """Return the MAC address of the current host."""
176
193
  return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
177
194
 
195
+ @classmethod
196
+ def normalize_relation(cls, value):
197
+ """Normalize ``value`` to a valid :class:`Relation`."""
198
+
199
+ if isinstance(value, cls.Relation):
200
+ return value
201
+ if value is None:
202
+ return cls.Relation.PEER
203
+ text = str(value).strip()
204
+ if not text:
205
+ return cls.Relation.PEER
206
+ for relation in cls.Relation:
207
+ if text.lower() == relation.label.lower():
208
+ return relation
209
+ if text.upper() == relation.name:
210
+ return relation
211
+ if text.lower() == relation.value.lower():
212
+ return relation
213
+ return cls.Relation.PEER
214
+
178
215
  @classmethod
179
216
  def get_local(cls):
180
217
  """Return the node representing the current host if it exists."""
181
218
  mac = cls.get_current_mac()
182
- return cls.objects.filter(mac_address=mac).first()
219
+ try:
220
+ return cls.objects.filter(mac_address=mac).first()
221
+ except DatabaseError:
222
+ logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
223
+ return None
183
224
 
184
225
  @classmethod
185
226
  def register_current(cls):
@@ -209,6 +250,7 @@ class Node(Entity):
209
250
  "installed_revision": installed_revision,
210
251
  "public_endpoint": slug,
211
252
  "mac_address": mac,
253
+ "current_relation": cls.Relation.SELF,
212
254
  }
213
255
  if node:
214
256
  for field, value in defaults.items():
@@ -235,8 +277,112 @@ class Node(Entity):
235
277
  node.save(update_fields=["role"])
236
278
  Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
237
279
  node.ensure_keys()
280
+ node.notify_peers_of_update()
238
281
  return node, created
239
282
 
283
+ def notify_peers_of_update(self):
284
+ """Attempt to update this node's registration with known peers."""
285
+
286
+ from secrets import token_hex
287
+
288
+ try:
289
+ import requests
290
+ except Exception: # pragma: no cover - requests should be available
291
+ return
292
+
293
+ security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
294
+ priv_path = security_dir / f"{self.public_endpoint}"
295
+ if not priv_path.exists():
296
+ logger.debug("Private key for %s not found; skipping peer update", self)
297
+ return
298
+ try:
299
+ private_key = serialization.load_pem_private_key(
300
+ priv_path.read_bytes(), password=None
301
+ )
302
+ except Exception as exc: # pragma: no cover - defensive
303
+ logger.warning("Failed to load private key for %s: %s", self, exc)
304
+ return
305
+ token = token_hex(16)
306
+ try:
307
+ signature = private_key.sign(
308
+ token.encode(),
309
+ padding.PKCS1v15(),
310
+ hashes.SHA256(),
311
+ )
312
+ except Exception as exc: # pragma: no cover - defensive
313
+ logger.warning("Failed to sign peer update for %s: %s", self, exc)
314
+ return
315
+
316
+ payload = {
317
+ "hostname": self.hostname,
318
+ "address": self.address,
319
+ "port": self.port,
320
+ "mac_address": self.mac_address,
321
+ "public_key": self.public_key,
322
+ "token": token,
323
+ "signature": base64.b64encode(signature).decode(),
324
+ }
325
+ if self.installed_version:
326
+ payload["installed_version"] = self.installed_version
327
+ if self.installed_revision:
328
+ payload["installed_revision"] = self.installed_revision
329
+
330
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
331
+ headers = {"Content-Type": "application/json"}
332
+
333
+ peers = Node.objects.exclude(pk=self.pk)
334
+ for peer in peers:
335
+ host_candidates: list[str] = []
336
+ if peer.address:
337
+ host_candidates.append(peer.address)
338
+ if peer.hostname and peer.hostname not in host_candidates:
339
+ host_candidates.append(peer.hostname)
340
+ port = peer.port or 8000
341
+ urls: list[str] = []
342
+ for host in host_candidates:
343
+ host = host.strip()
344
+ if not host:
345
+ continue
346
+ if ":" in host and not host.startswith("["):
347
+ host = f"[{host}]"
348
+ http_url = (
349
+ f"http://{host}/nodes/register/"
350
+ if port == 80
351
+ else f"http://{host}:{port}/nodes/register/"
352
+ )
353
+ https_url = (
354
+ f"https://{host}/nodes/register/"
355
+ if port in {80, 443}
356
+ else f"https://{host}:{port}/nodes/register/"
357
+ )
358
+ for url in (https_url, http_url):
359
+ if url not in urls:
360
+ urls.append(url)
361
+ if not urls:
362
+ continue
363
+ for url in urls:
364
+ try:
365
+ response = requests.post(
366
+ url, data=payload_json, headers=headers, timeout=2
367
+ )
368
+ except Exception as exc: # pragma: no cover - best effort
369
+ logger.debug("Failed to update %s via %s: %s", peer, url, exc)
370
+ continue
371
+ if response.ok:
372
+ version_display = _format_upgrade_body(
373
+ self.installed_version,
374
+ self.installed_revision,
375
+ )
376
+ version_suffix = f" ({version_display})" if version_display else ""
377
+ logger.info(
378
+ "Announced startup to %s%s",
379
+ peer,
380
+ version_suffix,
381
+ )
382
+ break
383
+ else:
384
+ logger.warning("Unable to notify node %s of startup", peer)
385
+
240
386
  def ensure_keys(self):
241
387
  security_dir = Path(settings.BASE_DIR) / "security"
242
388
  security_dir.mkdir(parents=True, exist_ok=True)
@@ -526,6 +672,52 @@ class Node(Entity):
526
672
  )
527
673
 
528
674
 
675
+ node_information_updated = Signal()
676
+
677
+
678
+ def _format_upgrade_body(version: str, revision: str) -> str:
679
+ version = (version or "").strip()
680
+ revision = (revision or "").strip()
681
+ parts: list[str] = []
682
+ if version:
683
+ normalized = version.lstrip("vV") or version
684
+ parts.append(f"v{normalized}")
685
+ if revision:
686
+ rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
687
+ rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
688
+ parts.append(f"r{rev_short}")
689
+ return " ".join(parts).strip()
690
+
691
+
692
+ @receiver(node_information_updated)
693
+ def _announce_peer_startup(
694
+ sender,
695
+ *,
696
+ node: "Node",
697
+ previous_version: str = "",
698
+ previous_revision: str = "",
699
+ current_version: str = "",
700
+ current_revision: str = "",
701
+ **_: object,
702
+ ) -> None:
703
+ current_version = (current_version or "").strip()
704
+ current_revision = (current_revision or "").strip()
705
+ previous_version = (previous_version or "").strip()
706
+ previous_revision = (previous_revision or "").strip()
707
+
708
+ local = Node.get_local()
709
+ if local and node.pk == local.pk:
710
+ return
711
+
712
+ body = _format_upgrade_body(current_version, current_revision)
713
+ if not body:
714
+ body = "Online"
715
+
716
+ hostname = (node.hostname or "Node").strip() or "Node"
717
+ subject = f"UP {hostname}"
718
+ notify_async(subject, body)
719
+
720
+
529
721
  class NodeFeatureAssignment(Entity):
530
722
  """Bridge between :class:`Node` and :class:`NodeFeature`."""
531
723
 
@@ -560,6 +752,243 @@ def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
560
752
  node.sync_feature_tasks()
561
753
 
562
754
 
755
+ class NodeManager(Profile):
756
+ """Credentials for interacting with external DNS providers."""
757
+
758
+ class Provider(models.TextChoices):
759
+ GODADDY = "godaddy", "GoDaddy"
760
+
761
+ profile_fields = (
762
+ "provider",
763
+ "api_key",
764
+ "api_secret",
765
+ "customer_id",
766
+ "default_domain",
767
+ )
768
+
769
+ provider = models.CharField(
770
+ max_length=20,
771
+ choices=Provider.choices,
772
+ default=Provider.GODADDY,
773
+ )
774
+ api_key = SigilShortAutoField(
775
+ max_length=255,
776
+ help_text="API key issued by the DNS provider.",
777
+ )
778
+ api_secret = SigilShortAutoField(
779
+ max_length=255,
780
+ help_text="API secret issued by the DNS provider.",
781
+ )
782
+ customer_id = SigilShortAutoField(
783
+ max_length=100,
784
+ blank=True,
785
+ help_text="Optional GoDaddy customer identifier for the account.",
786
+ )
787
+ default_domain = SigilShortAutoField(
788
+ max_length=253,
789
+ blank=True,
790
+ help_text="Fallback domain when records omit one.",
791
+ )
792
+ use_sandbox = models.BooleanField(
793
+ default=False,
794
+ help_text="Use the GoDaddy OTE (test) environment.",
795
+ )
796
+ is_enabled = models.BooleanField(
797
+ default=True,
798
+ help_text="Disable to prevent deployments with this manager.",
799
+ )
800
+
801
+ class Meta:
802
+ verbose_name = "Node Manager"
803
+ verbose_name_plural = "Node Managers"
804
+
805
+ def __str__(self) -> str:
806
+ owner = self.owner_display()
807
+ provider = self.get_provider_display()
808
+ if owner:
809
+ return f"{provider} ({owner})"
810
+ return provider
811
+
812
+ def clean(self):
813
+ if self.user_id or self.group_id:
814
+ super().clean()
815
+ else:
816
+ super(Profile, self).clean()
817
+
818
+ def get_base_url(self) -> str:
819
+ if self.provider != self.Provider.GODADDY:
820
+ raise ValueError("Unsupported DNS provider")
821
+ if self.use_sandbox:
822
+ return "https://api.ote-godaddy.com"
823
+ return "https://api.godaddy.com"
824
+
825
+ def get_auth_header(self) -> str:
826
+ key = (self.resolve_sigils("api_key") or "").strip()
827
+ secret = (self.resolve_sigils("api_secret") or "").strip()
828
+ if not key or not secret:
829
+ raise ValueError("API credentials are required for DNS deployment")
830
+ return f"sso-key {key}:{secret}"
831
+
832
+ def get_customer_id(self) -> str:
833
+ return (self.resolve_sigils("customer_id") or "").strip()
834
+
835
+ def get_default_domain(self) -> str:
836
+ return (self.resolve_sigils("default_domain") or "").strip()
837
+
838
+ def publish_dns_records(self, records: Iterable["DNSRecord"]):
839
+ from . import dns as dns_utils
840
+
841
+ return dns_utils.deploy_records(self, records)
842
+
843
+
844
+ class DNSRecord(Entity):
845
+ """Stored DNS configuration ready for deployment."""
846
+
847
+ class Type(models.TextChoices):
848
+ A = "A", "A"
849
+ AAAA = "AAAA", "AAAA"
850
+ CNAME = "CNAME", "CNAME"
851
+ MX = "MX", "MX"
852
+ NS = "NS", "NS"
853
+ SRV = "SRV", "SRV"
854
+ TXT = "TXT", "TXT"
855
+
856
+ class Provider(models.TextChoices):
857
+ GODADDY = "godaddy", "GoDaddy"
858
+
859
+ provider = models.CharField(
860
+ max_length=20,
861
+ choices=Provider.choices,
862
+ default=Provider.GODADDY,
863
+ )
864
+ node_manager = models.ForeignKey(
865
+ "NodeManager",
866
+ on_delete=models.SET_NULL,
867
+ null=True,
868
+ blank=True,
869
+ related_name="dns_records",
870
+ )
871
+ domain = SigilShortAutoField(
872
+ max_length=253,
873
+ help_text="Base domain such as example.com.",
874
+ )
875
+ name = SigilShortAutoField(
876
+ max_length=253,
877
+ help_text="Record host. Use @ for the zone apex.",
878
+ )
879
+ record_type = models.CharField(
880
+ max_length=10,
881
+ choices=Type.choices,
882
+ default=Type.A,
883
+ verbose_name="Type",
884
+ )
885
+ data = SigilLongAutoField(
886
+ help_text="Record value such as an IP address or hostname.",
887
+ )
888
+ ttl = models.PositiveIntegerField(
889
+ default=600,
890
+ help_text="Time to live in seconds.",
891
+ )
892
+ priority = models.PositiveIntegerField(
893
+ null=True,
894
+ blank=True,
895
+ help_text="Priority for MX and SRV records.",
896
+ )
897
+ port = models.PositiveIntegerField(
898
+ null=True,
899
+ blank=True,
900
+ help_text="Port for SRV records.",
901
+ )
902
+ weight = models.PositiveIntegerField(
903
+ null=True,
904
+ blank=True,
905
+ help_text="Weight for SRV records.",
906
+ )
907
+ service = SigilShortAutoField(
908
+ max_length=50,
909
+ blank=True,
910
+ help_text="Service label for SRV records (for example _sip).",
911
+ )
912
+ protocol = SigilShortAutoField(
913
+ max_length=10,
914
+ blank=True,
915
+ help_text="Protocol label for SRV records (for example _tcp).",
916
+ )
917
+ last_synced_at = models.DateTimeField(null=True, blank=True)
918
+ last_verified_at = models.DateTimeField(null=True, blank=True)
919
+ last_error = models.TextField(blank=True)
920
+
921
+ class Meta:
922
+ verbose_name = "DNS Record"
923
+ verbose_name_plural = "DNS Records"
924
+
925
+ def __str__(self) -> str:
926
+ return f"{self.record_type} {self.fqdn()}"
927
+
928
+ def get_domain(self, manager: "NodeManager" | None = None) -> str:
929
+ domain = (self.resolve_sigils("domain") or "").strip()
930
+ if domain:
931
+ return domain.rstrip(".")
932
+ if manager:
933
+ fallback = manager.get_default_domain()
934
+ if fallback:
935
+ return fallback.rstrip(".")
936
+ return ""
937
+
938
+ def get_name(self) -> str:
939
+ name = (self.resolve_sigils("name") or "").strip()
940
+ return name or "@"
941
+
942
+ def fqdn(self, manager: "NodeManager" | None = None) -> str:
943
+ domain = self.get_domain(manager)
944
+ name = self.get_name()
945
+ if name in {"@", ""}:
946
+ return domain
947
+ if name.endswith("."):
948
+ return name.rstrip(".")
949
+ if domain:
950
+ return f"{name}.{domain}".rstrip(".")
951
+ return name.rstrip(".")
952
+
953
+ def to_godaddy_payload(self) -> dict[str, object]:
954
+ payload: dict[str, object] = {
955
+ "data": (self.resolve_sigils("data") or "").strip(),
956
+ "ttl": self.ttl,
957
+ }
958
+ if self.priority is not None:
959
+ payload["priority"] = self.priority
960
+ if self.port is not None:
961
+ payload["port"] = self.port
962
+ if self.weight is not None:
963
+ payload["weight"] = self.weight
964
+ service = (self.resolve_sigils("service") or "").strip()
965
+ if service:
966
+ payload["service"] = service
967
+ protocol = (self.resolve_sigils("protocol") or "").strip()
968
+ if protocol:
969
+ payload["protocol"] = protocol
970
+ return payload
971
+
972
+ def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
973
+ if timestamp is None:
974
+ timestamp = timezone.now()
975
+ update_fields = ["last_synced_at", "last_error"]
976
+ self.last_synced_at = timestamp
977
+ self.last_error = ""
978
+ if manager and self.node_manager_id != getattr(manager, "pk", None):
979
+ self.node_manager = manager
980
+ update_fields.append("node_manager")
981
+ self.save(update_fields=update_fields)
982
+
983
+ def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
984
+ update_fields = ["last_error"]
985
+ self.last_error = message
986
+ if manager and self.node_manager_id != getattr(manager, "pk", None):
987
+ self.node_manager = manager
988
+ update_fields.append("node_manager")
989
+ self.save(update_fields=update_fields)
990
+
991
+
563
992
  class EmailOutbox(Profile):
564
993
  """SMTP credentials for sending mail."""
565
994
 
@@ -612,11 +1041,37 @@ class EmailOutbox(Profile):
612
1041
  max_length=254,
613
1042
  help_text="Default From address; usually the same as username",
614
1043
  )
1044
+ is_enabled = models.BooleanField(
1045
+ default=True,
1046
+ help_text="Disable to remove this outbox from automatic selection.",
1047
+ )
615
1048
 
616
1049
  class Meta:
617
1050
  verbose_name = "Email Outbox"
618
1051
  verbose_name_plural = "Email Outboxes"
619
1052
 
1053
+ def __str__(self) -> str:
1054
+ address = (self.from_email or "").strip()
1055
+ if address:
1056
+ return address
1057
+
1058
+ username = (self.username or "").strip()
1059
+ host = (self.host or "").strip()
1060
+ if username and host:
1061
+ if "@" in username:
1062
+ return username
1063
+ return f"{username}@{host}"
1064
+ if username:
1065
+ return username
1066
+ if host:
1067
+ return host
1068
+
1069
+ owner = self.owner_display()
1070
+ if owner:
1071
+ return owner
1072
+
1073
+ return super().__str__()
1074
+
620
1075
  def clean(self):
621
1076
  if self.user_id or self.group_id:
622
1077
  super().clean()
@@ -662,6 +1117,13 @@ class NetMessage(Entity):
662
1117
  editable=False,
663
1118
  verbose_name="UUID",
664
1119
  )
1120
+ node_origin = models.ForeignKey(
1121
+ "Node",
1122
+ on_delete=models.SET_NULL,
1123
+ null=True,
1124
+ blank=True,
1125
+ related_name="originated_net_messages",
1126
+ )
665
1127
  subject = models.CharField(max_length=64, blank=True)
666
1128
  body = models.CharField(max_length=256, blank=True)
667
1129
  reach = models.ForeignKey(
@@ -669,7 +1131,6 @@ class NetMessage(Entity):
669
1131
  on_delete=models.SET_NULL,
670
1132
  null=True,
671
1133
  blank=True,
672
- default=get_terminal_role,
673
1134
  )
674
1135
  propagated_to = models.ManyToManyField(
675
1136
  Node, blank=True, related_name="received_net_messages"
@@ -696,10 +1157,12 @@ class NetMessage(Entity):
696
1157
  role = reach
697
1158
  else:
698
1159
  role = NodeRole.objects.filter(name=reach).first()
1160
+ origin = Node.get_local()
699
1161
  msg = cls.objects.create(
700
1162
  subject=subject[:64],
701
1163
  body=body[:256],
702
- reach=role or get_terminal_role(),
1164
+ reach=role,
1165
+ node_origin=origin,
703
1166
  )
704
1167
  msg.propagate(seen=seen or [])
705
1168
  return msg
@@ -709,8 +1172,28 @@ class NetMessage(Entity):
709
1172
  import random
710
1173
  import requests
711
1174
 
712
- notify(self.subject, self.body)
1175
+ displayed = notify(self.subject, self.body)
713
1176
  local = Node.get_local()
1177
+ if displayed:
1178
+ cutoff = timezone.now() - timedelta(days=7)
1179
+ prune_qs = type(self).objects.filter(created__lt=cutoff)
1180
+ if local:
1181
+ prune_qs = prune_qs.filter(
1182
+ models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
1183
+ )
1184
+ else:
1185
+ prune_qs = prune_qs.filter(node_origin__isnull=True)
1186
+ if self.pk:
1187
+ prune_qs = prune_qs.exclude(pk=self.pk)
1188
+ prune_qs.delete()
1189
+ if local and not self.node_origin_id:
1190
+ self.node_origin = local
1191
+ self.save(update_fields=["node_origin"])
1192
+ origin_uuid = None
1193
+ if self.node_origin_id:
1194
+ origin_uuid = str(self.node_origin.uuid)
1195
+ elif local:
1196
+ origin_uuid = str(local.uuid)
714
1197
  private_key = None
715
1198
  seen = list(seen or [])
716
1199
  local_id = None
@@ -749,7 +1232,7 @@ class NetMessage(Entity):
749
1232
 
750
1233
  target_limit = min(3, len(remaining))
751
1234
 
752
- reach_name = self.reach.name if self.reach else "Terminal"
1235
+ reach_name = self.reach.name if self.reach else None
753
1236
  role_map = {
754
1237
  "Terminal": ["Terminal"],
755
1238
  "Control": ["Control", "Terminal"],
@@ -761,10 +1244,15 @@ class NetMessage(Entity):
761
1244
  "Terminal",
762
1245
  ],
763
1246
  }
764
- role_order = role_map.get(reach_name, ["Terminal"])
1247
+ role_order = role_map.get(reach_name, [None])
765
1248
  selected: list[Node] = []
766
1249
  for role_name in role_order:
767
- role_nodes = [n for n in remaining if n.role and n.role.name == role_name]
1250
+ if role_name is None:
1251
+ role_nodes = remaining[:]
1252
+ else:
1253
+ role_nodes = [
1254
+ n for n in remaining if n.role and n.role.name == role_name
1255
+ ]
768
1256
  random.shuffle(role_nodes)
769
1257
  for n in role_nodes:
770
1258
  selected.append(n)
@@ -785,6 +1273,7 @@ class NetMessage(Entity):
785
1273
  "seen": payload_seen,
786
1274
  "reach": reach_name,
787
1275
  "sender": local_id,
1276
+ "origin": origin_uuid,
788
1277
  }
789
1278
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
790
1279
  headers = {"Content-Type": "application/json"}