arthexis 0.1.11__py3-none-any.whl → 0.1.13__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 (50) hide show
  1. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
  2. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
  3. config/asgi.py +15 -1
  4. config/celery.py +8 -1
  5. config/settings.py +49 -78
  6. config/settings_helpers.py +109 -0
  7. core/admin.py +293 -78
  8. core/apps.py +21 -0
  9. core/auto_upgrade.py +2 -2
  10. core/form_fields.py +75 -0
  11. core/models.py +203 -47
  12. core/reference_utils.py +1 -1
  13. core/release.py +42 -20
  14. core/system.py +6 -3
  15. core/tasks.py +92 -40
  16. core/tests.py +75 -1
  17. core/views.py +178 -29
  18. core/widgets.py +43 -0
  19. nodes/admin.py +583 -10
  20. nodes/apps.py +15 -0
  21. nodes/feature_checks.py +133 -0
  22. nodes/models.py +287 -49
  23. nodes/reports.py +411 -0
  24. nodes/tests.py +990 -42
  25. nodes/urls.py +1 -0
  26. nodes/utils.py +32 -0
  27. nodes/views.py +173 -5
  28. ocpp/admin.py +424 -17
  29. ocpp/consumers.py +630 -15
  30. ocpp/evcs.py +7 -94
  31. ocpp/evcs_discovery.py +158 -0
  32. ocpp/models.py +236 -4
  33. ocpp/routing.py +4 -2
  34. ocpp/simulator.py +346 -26
  35. ocpp/status_display.py +26 -0
  36. ocpp/store.py +110 -2
  37. ocpp/tests.py +1425 -33
  38. ocpp/transactions_io.py +27 -3
  39. ocpp/views.py +344 -38
  40. pages/admin.py +138 -3
  41. pages/context_processors.py +15 -1
  42. pages/defaults.py +1 -2
  43. pages/forms.py +67 -0
  44. pages/models.py +136 -1
  45. pages/tests.py +379 -4
  46. pages/urls.py +1 -0
  47. pages/views.py +64 -7
  48. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
  49. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
  50. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
nodes/apps.py CHANGED
@@ -31,6 +31,21 @@ def _startup_notification() -> None:
31
31
  rev_short = revision_value[-6:] if revision_value else ""
32
32
 
33
33
  body = version
34
+ if body:
35
+ normalized = body.lstrip("vV") or body
36
+ base_version = normalized.rstrip("+")
37
+ needs_marker = False
38
+ if base_version and revision_value:
39
+ try: # pragma: no cover - defensive guard
40
+ from core.models import PackageRelease
41
+
42
+ needs_marker = not PackageRelease.matches_revision(
43
+ base_version, revision_value
44
+ )
45
+ except Exception:
46
+ logger.debug("Startup release comparison failed", exc_info=True)
47
+ if needs_marker and not normalized.endswith("+"):
48
+ body = f"{body}+"
34
49
  if rev_short:
35
50
  body = f"{body} r{rev_short}" if body else f"r{rev_short}"
36
51
 
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Dict, Iterable, Optional
5
+
6
+ from django.contrib import messages
7
+
8
+ if False: # pragma: no cover - typing imports only
9
+ from .models import Node, NodeFeature
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class FeatureCheckResult:
14
+ """Outcome of a feature validation."""
15
+
16
+ success: bool
17
+ message: str
18
+ level: int = messages.INFO
19
+
20
+
21
+ FeatureCheck = Callable[["NodeFeature", Optional["Node"]], Any]
22
+
23
+
24
+ class FeatureCheckRegistry:
25
+ """Registry for feature validation callbacks."""
26
+
27
+ def __init__(self) -> None:
28
+ self._checks: Dict[str, FeatureCheck] = {}
29
+ self._default_check: Optional[FeatureCheck] = None
30
+
31
+ def register(self, slug: str) -> Callable[[FeatureCheck], FeatureCheck]:
32
+ """Register ``func`` as the validator for ``slug``."""
33
+
34
+ def decorator(func: FeatureCheck) -> FeatureCheck:
35
+ self._checks[slug] = func
36
+ return func
37
+
38
+ return decorator
39
+
40
+ def register_default(self, func: FeatureCheck) -> FeatureCheck:
41
+ """Register ``func`` as the fallback validator."""
42
+
43
+ self._default_check = func
44
+ return func
45
+
46
+ def get(self, slug: str) -> Optional[FeatureCheck]:
47
+ return self._checks.get(slug)
48
+
49
+ def items(self) -> Iterable[tuple[str, FeatureCheck]]:
50
+ return self._checks.items()
51
+
52
+ def run(
53
+ self, feature: "NodeFeature", *, node: Optional["Node"] = None
54
+ ) -> Optional[FeatureCheckResult]:
55
+ check = self._checks.get(feature.slug)
56
+ if check is None:
57
+ check = self._default_check
58
+ if check is None:
59
+ return None
60
+ result = check(feature, node)
61
+ return self._normalize_result(feature, result)
62
+
63
+ def _normalize_result(
64
+ self, feature: "NodeFeature", result: Any
65
+ ) -> FeatureCheckResult:
66
+ if isinstance(result, FeatureCheckResult):
67
+ return result
68
+ if result is None:
69
+ return FeatureCheckResult(
70
+ True,
71
+ f"{feature.display} check completed successfully.",
72
+ messages.SUCCESS,
73
+ )
74
+ if isinstance(result, tuple) and len(result) >= 2:
75
+ success, message, *rest = result
76
+ level = rest[0] if rest else (
77
+ messages.SUCCESS if success else messages.ERROR
78
+ )
79
+ return FeatureCheckResult(bool(success), str(message), int(level))
80
+ if isinstance(result, bool):
81
+ message = (
82
+ f"{feature.display} check {'passed' if result else 'failed'}."
83
+ )
84
+ level = messages.SUCCESS if result else messages.ERROR
85
+ return FeatureCheckResult(result, message, level)
86
+ raise TypeError(
87
+ f"Unsupported feature check result type: {type(result)!r}"
88
+ )
89
+
90
+
91
+ feature_checks = FeatureCheckRegistry()
92
+
93
+
94
+ @feature_checks.register_default
95
+ def _default_feature_check(
96
+ feature: "NodeFeature", node: Optional["Node"]
97
+ ) -> FeatureCheckResult:
98
+ from .models import Node
99
+
100
+ target: Optional["Node"] = node or Node.get_local()
101
+ if target is None:
102
+ return FeatureCheckResult(
103
+ False,
104
+ f"No local node is registered; cannot verify {feature.display}.",
105
+ messages.WARNING,
106
+ )
107
+ try:
108
+ enabled = feature.is_enabled
109
+ except Exception as exc: # pragma: no cover - defensive
110
+ return FeatureCheckResult(
111
+ False,
112
+ f"{feature.display} check failed: {exc}",
113
+ messages.ERROR,
114
+ )
115
+ if enabled:
116
+ return FeatureCheckResult(
117
+ True,
118
+ f"{feature.display} is enabled on {target.hostname}.",
119
+ messages.SUCCESS,
120
+ )
121
+ return FeatureCheckResult(
122
+ False,
123
+ f"{feature.display} is not enabled on {target.hostname}.",
124
+ messages.WARNING,
125
+ )
126
+
127
+
128
+ __all__ = [
129
+ "FeatureCheck",
130
+ "FeatureCheckRegistry",
131
+ "FeatureCheckResult",
132
+ "feature_checks",
133
+ ]
nodes/models.py CHANGED
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Iterable
4
+ from dataclasses import dataclass
4
5
  from django.db import models
5
6
  from django.db.utils import DatabaseError
6
7
  from django.db.models.signals import post_delete
7
8
  from django.dispatch import Signal, receiver
8
9
  from core.entity import Entity
9
- from core.models import Profile
10
+ from core.models import PackageRelease, Profile
10
11
  from core.fields import SigilLongAutoField, SigilShortAutoField
11
12
  import re
12
13
  import json
@@ -68,6 +69,12 @@ class NodeFeatureManager(models.Manager):
68
69
  return self.get(slug=slug)
69
70
 
70
71
 
72
+ @dataclass(frozen=True)
73
+ class NodeFeatureDefaultAction:
74
+ label: str
75
+ url_name: str
76
+
77
+
71
78
  class NodeFeature(Entity):
72
79
  """Feature that may be enabled on nodes and roles."""
73
80
 
@@ -77,6 +84,36 @@ class NodeFeature(Entity):
77
84
 
78
85
  objects = NodeFeatureManager()
79
86
 
87
+ DEFAULT_ACTIONS: dict[str, tuple[NodeFeatureDefaultAction, ...]] = {
88
+ "rfid-scanner": (
89
+ NodeFeatureDefaultAction(
90
+ label="Scan RFIDs", url_name="admin:core_rfid_scan"
91
+ ),
92
+ ),
93
+ "celery-queue": (
94
+ NodeFeatureDefaultAction(
95
+ label="Celery Report",
96
+ url_name="admin:nodes_nodefeature_celery_report",
97
+ ),
98
+ ),
99
+ "screenshot-poll": (
100
+ NodeFeatureDefaultAction(
101
+ label="Take Screenshot",
102
+ url_name="admin:nodes_nodefeature_take_screenshot",
103
+ ),
104
+ ),
105
+ "rpi-camera": (
106
+ NodeFeatureDefaultAction(
107
+ label="Take a Snapshot",
108
+ url_name="admin:nodes_nodefeature_take_snapshot",
109
+ ),
110
+ NodeFeatureDefaultAction(
111
+ label="View stream",
112
+ url_name="admin:nodes_nodefeature_view_stream",
113
+ ),
114
+ ),
115
+ }
116
+
80
117
  class Meta:
81
118
  ordering = ["display"]
82
119
  verbose_name = "Node Feature"
@@ -116,6 +153,20 @@ class NodeFeature(Entity):
116
153
  return (base_path / "locks" / lock).exists()
117
154
  return False
118
155
 
156
+ def get_default_actions(self) -> tuple[NodeFeatureDefaultAction, ...]:
157
+ """Return the configured default actions for this feature."""
158
+
159
+ actions = self.DEFAULT_ACTIONS.get(self.slug, ())
160
+ if isinstance(actions, NodeFeatureDefaultAction): # pragma: no cover - legacy
161
+ return (actions,)
162
+ return actions
163
+
164
+ def get_default_action(self) -> NodeFeatureDefaultAction | None:
165
+ """Return the first configured default action for this feature if any."""
166
+
167
+ actions = self.get_default_actions()
168
+ return actions[0] if actions else None
169
+
119
170
 
120
171
  def get_terminal_role():
121
172
  """Return the NodeRole representing a Terminal if it exists."""
@@ -125,6 +176,12 @@ def get_terminal_role():
125
176
  class Node(Entity):
126
177
  """Information about a running node in the network."""
127
178
 
179
+ DEFAULT_BADGE_COLOR = "#28a745"
180
+ ROLE_BADGE_COLORS = {
181
+ "Constellation": "#daa520", # goldenrod
182
+ "Control": "#673ab7", # deep purple
183
+ }
184
+
128
185
  class Relation(models.TextChoices):
129
186
  UPSTREAM = "UPSTREAM", "Upstream"
130
187
  DOWNSTREAM = "DOWNSTREAM", "Downstream"
@@ -135,7 +192,7 @@ class Node(Entity):
135
192
  address = models.GenericIPAddressField()
136
193
  mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
137
194
  port = models.PositiveIntegerField(default=8000)
138
- badge_color = models.CharField(max_length=7, default="#28a745")
195
+ badge_color = models.CharField(max_length=7, default=DEFAULT_BADGE_COLOR)
139
196
  role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
140
197
  current_relation = models.CharField(
141
198
  max_length=10,
@@ -180,7 +237,6 @@ class Node(Entity):
180
237
  "rpi-camera",
181
238
  "ap-router",
182
239
  "ap-public-wifi",
183
- "postgres-db",
184
240
  }
185
241
  MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
186
242
 
@@ -252,23 +308,27 @@ class Node(Entity):
252
308
  "mac_address": mac,
253
309
  "current_relation": cls.Relation.SELF,
254
310
  }
311
+ role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
312
+ role_name = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
313
+ desired_role = NodeRole.objects.filter(name=role_name).first()
314
+
255
315
  if node:
316
+ update_fields = []
256
317
  for field, value in defaults.items():
257
- setattr(node, field, value)
258
- update_fields = list(defaults.keys())
259
- node.save(update_fields=update_fields)
318
+ if getattr(node, field) != value:
319
+ setattr(node, field, value)
320
+ update_fields.append(field)
321
+ if desired_role and node.role_id != desired_role.id:
322
+ node.role = desired_role
323
+ update_fields.append("role")
324
+ if update_fields:
325
+ node.save(update_fields=update_fields)
260
326
  created = False
261
327
  else:
262
328
  node = cls.objects.create(**defaults)
263
329
  created = True
264
- # assign role from installation lock file
265
- role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
266
- role_name = (
267
- role_lock.read_text().strip() if role_lock.exists() else "Terminal"
268
- )
269
- role = NodeRole.objects.filter(name=role_name).first()
270
- if role:
271
- node.role = role
330
+ if desired_role:
331
+ node.role = desired_role
272
332
  node.save(update_fields=["role"])
273
333
  if created and node.role is None:
274
334
  terminal = NodeRole.objects.filter(name="Terminal").first()
@@ -412,11 +472,84 @@ class Node(Entity):
412
472
  """Determine if this node represents the current host."""
413
473
  return self.mac_address == self.get_current_mac()
414
474
 
475
+ @classmethod
476
+ def _generate_unique_public_endpoint(
477
+ cls, value: str | None, *, exclude_pk: int | None = None
478
+ ) -> str:
479
+ """Return a unique public endpoint slug for ``value``."""
480
+
481
+ field = cls._meta.get_field("public_endpoint")
482
+ max_length = getattr(field, "max_length", None) or 50
483
+ base_slug = slugify(value or "") or "node"
484
+ if len(base_slug) > max_length:
485
+ base_slug = base_slug[:max_length]
486
+ slug = base_slug
487
+ queryset = cls.objects.all()
488
+ if exclude_pk is not None:
489
+ queryset = queryset.exclude(pk=exclude_pk)
490
+ counter = 2
491
+ while queryset.filter(public_endpoint=slug).exists():
492
+ suffix = f"-{counter}"
493
+ available = max_length - len(suffix)
494
+ if available <= 0:
495
+ slug = suffix[-max_length:]
496
+ else:
497
+ slug = f"{base_slug[:available]}{suffix}"
498
+ counter += 1
499
+ return slug
500
+
415
501
  def save(self, *args, **kwargs):
502
+ update_fields = kwargs.get("update_fields")
503
+
504
+ def include_update_field(field: str):
505
+ nonlocal update_fields
506
+ if update_fields is None:
507
+ return
508
+ fields = set(update_fields)
509
+ if field in fields:
510
+ return
511
+ fields.add(field)
512
+ update_fields = tuple(fields)
513
+ kwargs["update_fields"] = update_fields
514
+
515
+ role_name = None
516
+ role = getattr(self, "role", None)
517
+ if role and getattr(role, "name", None):
518
+ role_name = role.name
519
+ elif self.role_id:
520
+ role_name = (
521
+ NodeRole.objects.filter(pk=self.role_id)
522
+ .values_list("name", flat=True)
523
+ .first()
524
+ )
525
+
526
+ role_color = self.ROLE_BADGE_COLORS.get(role_name)
527
+ if role_color and (
528
+ not self.badge_color or self.badge_color == self.DEFAULT_BADGE_COLOR
529
+ ):
530
+ self.badge_color = role_color
531
+ include_update_field("badge_color")
532
+
416
533
  if self.mac_address:
417
534
  self.mac_address = self.mac_address.lower()
418
- if not self.public_endpoint:
419
- self.public_endpoint = slugify(self.hostname)
535
+ endpoint_value = slugify(self.public_endpoint or "")
536
+ if not endpoint_value:
537
+ endpoint_value = self._generate_unique_public_endpoint(
538
+ self.hostname, exclude_pk=self.pk
539
+ )
540
+ else:
541
+ queryset = (
542
+ self.__class__.objects.exclude(pk=self.pk)
543
+ if self.pk
544
+ else self.__class__.objects.all()
545
+ )
546
+ if queryset.filter(public_endpoint=endpoint_value).exists():
547
+ endpoint_value = self._generate_unique_public_endpoint(
548
+ self.hostname or endpoint_value, exclude_pk=self.pk
549
+ )
550
+ if self.public_endpoint != endpoint_value:
551
+ self.public_endpoint = endpoint_value
552
+ include_update_field("public_endpoint")
420
553
  super().save(*args, **kwargs)
421
554
  if self.pk:
422
555
  self.refresh_features()
@@ -525,13 +658,6 @@ class Node(Entity):
525
658
  return True
526
659
  return False
527
660
 
528
- @staticmethod
529
- def _uses_postgres() -> bool:
530
- """Return ``True`` when the default database uses PostgreSQL."""
531
-
532
- engine = settings.DATABASES.get("default", {}).get("ENGINE", "")
533
- return "postgresql" in engine.lower()
534
-
535
661
  def refresh_features(self):
536
662
  if not self.pk:
537
663
  return
@@ -552,8 +678,6 @@ class Node(Entity):
552
678
  detected_slugs.add("ap-public-wifi")
553
679
  else:
554
680
  detected_slugs.add("ap-router")
555
- if self._uses_postgres():
556
- detected_slugs.add("postgres-db")
557
681
  try:
558
682
  from core.notifications import supports_gui_toast
559
683
  except Exception:
@@ -681,7 +805,16 @@ def _format_upgrade_body(version: str, revision: str) -> str:
681
805
  parts: list[str] = []
682
806
  if version:
683
807
  normalized = version.lstrip("vV") or version
684
- parts.append(f"v{normalized}")
808
+ base_version = normalized.rstrip("+")
809
+ display_version = normalized
810
+ if (
811
+ base_version
812
+ and revision
813
+ and not PackageRelease.matches_revision(base_version, revision)
814
+ and not normalized.endswith("+")
815
+ ):
816
+ display_version = f"{display_version}+"
817
+ parts.append(f"v{display_version}")
685
818
  if revision:
686
819
  rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
687
820
  rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
@@ -1057,11 +1190,15 @@ class EmailOutbox(Profile):
1057
1190
 
1058
1191
  username = (self.username or "").strip()
1059
1192
  host = (self.host or "").strip()
1060
- if username and host:
1061
- if "@" in username:
1062
- return username
1063
- return f"{username}@{host}"
1064
1193
  if username:
1194
+ local, sep, domain = username.partition("@")
1195
+ if sep and domain:
1196
+ return username
1197
+ if host:
1198
+ sanitized = username.rstrip("@")
1199
+ if sanitized:
1200
+ return f"{sanitized}@{host}"
1201
+ return host
1065
1202
  return username
1066
1203
  if host:
1067
1204
  return host
@@ -1126,12 +1263,57 @@ class NetMessage(Entity):
1126
1263
  )
1127
1264
  subject = models.CharField(max_length=64, blank=True)
1128
1265
  body = models.CharField(max_length=256, blank=True)
1266
+ filter_node = models.ForeignKey(
1267
+ "Node",
1268
+ on_delete=models.SET_NULL,
1269
+ null=True,
1270
+ blank=True,
1271
+ related_name="filtered_net_messages",
1272
+ verbose_name="Node",
1273
+ )
1274
+ filter_node_feature = models.ForeignKey(
1275
+ "NodeFeature",
1276
+ on_delete=models.SET_NULL,
1277
+ null=True,
1278
+ blank=True,
1279
+ verbose_name="Node feature",
1280
+ )
1281
+ filter_node_role = models.ForeignKey(
1282
+ NodeRole,
1283
+ on_delete=models.SET_NULL,
1284
+ null=True,
1285
+ blank=True,
1286
+ related_name="filtered_net_messages",
1287
+ verbose_name="Node role",
1288
+ )
1289
+ filter_current_relation = models.CharField(
1290
+ max_length=10,
1291
+ blank=True,
1292
+ choices=Node.Relation.choices,
1293
+ verbose_name="Current relation",
1294
+ )
1295
+ filter_installed_version = models.CharField(
1296
+ max_length=20,
1297
+ blank=True,
1298
+ verbose_name="Installed version",
1299
+ )
1300
+ filter_installed_revision = models.CharField(
1301
+ max_length=40,
1302
+ blank=True,
1303
+ verbose_name="Installed revision",
1304
+ )
1129
1305
  reach = models.ForeignKey(
1130
1306
  NodeRole,
1131
1307
  on_delete=models.SET_NULL,
1132
1308
  null=True,
1133
1309
  blank=True,
1134
1310
  )
1311
+ target_limit = models.PositiveSmallIntegerField(
1312
+ default=6,
1313
+ blank=True,
1314
+ null=True,
1315
+ help_text="Maximum number of peers to contact when propagating.",
1316
+ )
1135
1317
  propagated_to = models.ManyToManyField(
1136
1318
  Node, blank=True, related_name="received_net_messages"
1137
1319
  )
@@ -1217,22 +1399,49 @@ class NetMessage(Entity):
1217
1399
  if node and (not local or node.pk != local.pk):
1218
1400
  self.propagated_to.add(node)
1219
1401
 
1220
- all_nodes = Node.objects.all()
1402
+ filtered_nodes = Node.objects.all()
1403
+ if self.filter_node_id:
1404
+ filtered_nodes = filtered_nodes.filter(pk=self.filter_node_id)
1405
+ if self.filter_node_feature_id:
1406
+ filtered_nodes = filtered_nodes.filter(
1407
+ features__pk=self.filter_node_feature_id
1408
+ )
1409
+ if self.filter_node_role_id:
1410
+ filtered_nodes = filtered_nodes.filter(role_id=self.filter_node_role_id)
1411
+ if self.filter_current_relation:
1412
+ filtered_nodes = filtered_nodes.filter(
1413
+ current_relation=self.filter_current_relation
1414
+ )
1415
+ if self.filter_installed_version:
1416
+ filtered_nodes = filtered_nodes.filter(
1417
+ installed_version=self.filter_installed_version
1418
+ )
1419
+ if self.filter_installed_revision:
1420
+ filtered_nodes = filtered_nodes.filter(
1421
+ installed_revision=self.filter_installed_revision
1422
+ )
1423
+
1424
+ filtered_nodes = filtered_nodes.distinct()
1425
+
1221
1426
  if local:
1222
- all_nodes = all_nodes.exclude(pk=local.pk)
1223
- total_known = all_nodes.count()
1427
+ filtered_nodes = filtered_nodes.exclude(pk=local.pk)
1428
+ total_known = filtered_nodes.count()
1224
1429
 
1225
1430
  remaining = list(
1226
- all_nodes.exclude(pk__in=self.propagated_to.values_list("pk", flat=True))
1431
+ filtered_nodes.exclude(
1432
+ pk__in=self.propagated_to.values_list("pk", flat=True)
1433
+ )
1227
1434
  )
1228
1435
  if not remaining:
1229
1436
  self.complete = True
1230
1437
  self.save(update_fields=["complete"])
1231
1438
  return
1232
1439
 
1233
- target_limit = min(3, len(remaining))
1440
+ limit = self.target_limit or 6
1441
+ target_limit = min(limit, len(remaining))
1234
1442
 
1235
- reach_name = self.reach.name if self.reach else None
1443
+ reach_source = self.filter_node_role or self.reach
1444
+ reach_name = reach_source.name if reach_source else None
1236
1445
  role_map = {
1237
1446
  "Terminal": ["Terminal"],
1238
1447
  "Control": ["Control", "Terminal"],
@@ -1244,23 +1453,40 @@ class NetMessage(Entity):
1244
1453
  "Terminal",
1245
1454
  ],
1246
1455
  }
1247
- role_order = role_map.get(reach_name, [None])
1248
1456
  selected: list[Node] = []
1249
- for role_name in role_order:
1250
- if role_name is None:
1251
- role_nodes = remaining[:]
1457
+ if self.filter_node_id:
1458
+ target = next((n for n in remaining if n.pk == self.filter_node_id), None)
1459
+ if target:
1460
+ selected = [target]
1461
+ else:
1462
+ self.complete = True
1463
+ self.save(update_fields=["complete"])
1464
+ return
1465
+ else:
1466
+ if self.filter_node_role_id:
1467
+ role_order = [reach_name]
1252
1468
  else:
1253
- role_nodes = [
1254
- n for n in remaining if n.role and n.role.name == role_name
1255
- ]
1256
- random.shuffle(role_nodes)
1257
- for n in role_nodes:
1258
- selected.append(n)
1259
- remaining.remove(n)
1469
+ role_order = role_map.get(reach_name, [None])
1470
+ for role_name in role_order:
1471
+ if role_name is None:
1472
+ role_nodes = remaining[:]
1473
+ else:
1474
+ role_nodes = [
1475
+ n for n in remaining if n.role and n.role.name == role_name
1476
+ ]
1477
+ random.shuffle(role_nodes)
1478
+ for n in role_nodes:
1479
+ selected.append(n)
1480
+ remaining.remove(n)
1481
+ if len(selected) >= target_limit:
1482
+ break
1260
1483
  if len(selected) >= target_limit:
1261
1484
  break
1262
- if len(selected) >= target_limit:
1263
- break
1485
+
1486
+ if not selected:
1487
+ self.complete = True
1488
+ self.save(update_fields=["complete"])
1489
+ return
1264
1490
 
1265
1491
  seen_list = seen.copy()
1266
1492
  selected_ids = [str(n.uuid) for n in selected]
@@ -1275,6 +1501,18 @@ class NetMessage(Entity):
1275
1501
  "sender": local_id,
1276
1502
  "origin": origin_uuid,
1277
1503
  }
1504
+ if self.filter_node:
1505
+ payload["filter_node"] = str(self.filter_node.uuid)
1506
+ if self.filter_node_feature:
1507
+ payload["filter_node_feature"] = self.filter_node_feature.slug
1508
+ if self.filter_node_role:
1509
+ payload["filter_node_role"] = self.filter_node_role.name
1510
+ if self.filter_current_relation:
1511
+ payload["filter_current_relation"] = self.filter_current_relation
1512
+ if self.filter_installed_version:
1513
+ payload["filter_installed_version"] = self.filter_installed_version
1514
+ if self.filter_installed_revision:
1515
+ payload["filter_installed_revision"] = self.filter_installed_revision
1278
1516
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1279
1517
  headers = {"Content-Type": "application/json"}
1280
1518
  if private_key: