arthexis 0.1.12__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.

nodes/models.py CHANGED
@@ -7,7 +7,7 @@ from django.db.utils import DatabaseError
7
7
  from django.db.models.signals import post_delete
8
8
  from django.dispatch import Signal, receiver
9
9
  from core.entity import Entity
10
- from core.models import Profile
10
+ from core.models import PackageRelease, Profile
11
11
  from core.fields import SigilLongAutoField, SigilShortAutoField
12
12
  import re
13
13
  import json
@@ -84,21 +84,33 @@ class NodeFeature(Entity):
84
84
 
85
85
  objects = NodeFeatureManager()
86
86
 
87
- DEFAULT_ACTIONS = {
88
- "rfid-scanner": NodeFeatureDefaultAction(
89
- label="Scan RFIDs", url_name="admin:core_rfid_scan"
87
+ DEFAULT_ACTIONS: dict[str, tuple[NodeFeatureDefaultAction, ...]] = {
88
+ "rfid-scanner": (
89
+ NodeFeatureDefaultAction(
90
+ label="Scan RFIDs", url_name="admin:core_rfid_scan"
91
+ ),
90
92
  ),
91
- "celery-queue": NodeFeatureDefaultAction(
92
- label="Celery Report",
93
- url_name="admin:nodes_nodefeature_celery_report",
93
+ "celery-queue": (
94
+ NodeFeatureDefaultAction(
95
+ label="Celery Report",
96
+ url_name="admin:nodes_nodefeature_celery_report",
97
+ ),
94
98
  ),
95
- "screenshot-poll": NodeFeatureDefaultAction(
96
- label="Take Screenshot",
97
- url_name="admin:nodes_nodefeature_take_screenshot",
99
+ "screenshot-poll": (
100
+ NodeFeatureDefaultAction(
101
+ label="Take Screenshot",
102
+ url_name="admin:nodes_nodefeature_take_screenshot",
103
+ ),
98
104
  ),
99
- "rpi-camera": NodeFeatureDefaultAction(
100
- label="Take a Snapshot",
101
- url_name="admin:nodes_nodefeature_take_snapshot",
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
+ ),
102
114
  ),
103
115
  }
104
116
 
@@ -141,10 +153,19 @@ class NodeFeature(Entity):
141
153
  return (base_path / "locks" / lock).exists()
142
154
  return False
143
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
+
144
164
  def get_default_action(self) -> NodeFeatureDefaultAction | None:
145
- """Return the configured default action for this feature if any."""
165
+ """Return the first configured default action for this feature if any."""
146
166
 
147
- return self.DEFAULT_ACTIONS.get(self.slug)
167
+ actions = self.get_default_actions()
168
+ return actions[0] if actions else None
148
169
 
149
170
 
150
171
  def get_terminal_role():
@@ -451,7 +472,46 @@ class Node(Entity):
451
472
  """Determine if this node represents the current host."""
452
473
  return self.mac_address == self.get_current_mac()
453
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
+
454
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
+
455
515
  role_name = None
456
516
  role = getattr(self, "role", None)
457
517
  if role and getattr(role, "name", None):
@@ -468,17 +528,28 @@ class Node(Entity):
468
528
  not self.badge_color or self.badge_color == self.DEFAULT_BADGE_COLOR
469
529
  ):
470
530
  self.badge_color = role_color
471
- update_fields = kwargs.get("update_fields")
472
- if update_fields:
473
- fields = set(update_fields)
474
- if "badge_color" not in fields:
475
- fields.add("badge_color")
476
- kwargs["update_fields"] = tuple(fields)
531
+ include_update_field("badge_color")
477
532
 
478
533
  if self.mac_address:
479
534
  self.mac_address = self.mac_address.lower()
480
- if not self.public_endpoint:
481
- 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")
482
553
  super().save(*args, **kwargs)
483
554
  if self.pk:
484
555
  self.refresh_features()
@@ -734,7 +805,16 @@ def _format_upgrade_body(version: str, revision: str) -> str:
734
805
  parts: list[str] = []
735
806
  if version:
736
807
  normalized = version.lstrip("vV") or version
737
- 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}")
738
818
  if revision:
739
819
  rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
740
820
  rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
@@ -1183,12 +1263,57 @@ class NetMessage(Entity):
1183
1263
  )
1184
1264
  subject = models.CharField(max_length=64, blank=True)
1185
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
+ )
1186
1305
  reach = models.ForeignKey(
1187
1306
  NodeRole,
1188
1307
  on_delete=models.SET_NULL,
1189
1308
  null=True,
1190
1309
  blank=True,
1191
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
+ )
1192
1317
  propagated_to = models.ManyToManyField(
1193
1318
  Node, blank=True, related_name="received_net_messages"
1194
1319
  )
@@ -1274,22 +1399,49 @@ class NetMessage(Entity):
1274
1399
  if node and (not local or node.pk != local.pk):
1275
1400
  self.propagated_to.add(node)
1276
1401
 
1277
- 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
+
1278
1426
  if local:
1279
- all_nodes = all_nodes.exclude(pk=local.pk)
1280
- total_known = all_nodes.count()
1427
+ filtered_nodes = filtered_nodes.exclude(pk=local.pk)
1428
+ total_known = filtered_nodes.count()
1281
1429
 
1282
1430
  remaining = list(
1283
- 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
+ )
1284
1434
  )
1285
1435
  if not remaining:
1286
1436
  self.complete = True
1287
1437
  self.save(update_fields=["complete"])
1288
1438
  return
1289
1439
 
1290
- target_limit = min(3, len(remaining))
1440
+ limit = self.target_limit or 6
1441
+ target_limit = min(limit, len(remaining))
1291
1442
 
1292
- 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
1293
1445
  role_map = {
1294
1446
  "Terminal": ["Terminal"],
1295
1447
  "Control": ["Control", "Terminal"],
@@ -1301,23 +1453,40 @@ class NetMessage(Entity):
1301
1453
  "Terminal",
1302
1454
  ],
1303
1455
  }
1304
- role_order = role_map.get(reach_name, [None])
1305
1456
  selected: list[Node] = []
1306
- for role_name in role_order:
1307
- if role_name is None:
1308
- 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]
1309
1461
  else:
1310
- role_nodes = [
1311
- n for n in remaining if n.role and n.role.name == role_name
1312
- ]
1313
- random.shuffle(role_nodes)
1314
- for n in role_nodes:
1315
- selected.append(n)
1316
- remaining.remove(n)
1462
+ self.complete = True
1463
+ self.save(update_fields=["complete"])
1464
+ return
1465
+ else:
1466
+ if self.filter_node_role_id:
1467
+ role_order = [reach_name]
1468
+ else:
1469
+ role_order = role_map.get(reach_name, [None])
1470
+ for role_name in role_order:
1471
+ if role_name is None:
1472
+ role_nodes = remaining[:]
1473
+ else:
1474
+ role_nodes = [
1475
+ n for n in remaining if n.role and n.role.name == role_name
1476
+ ]
1477
+ random.shuffle(role_nodes)
1478
+ for n in role_nodes:
1479
+ selected.append(n)
1480
+ remaining.remove(n)
1481
+ if len(selected) >= target_limit:
1482
+ break
1317
1483
  if len(selected) >= target_limit:
1318
1484
  break
1319
- if len(selected) >= target_limit:
1320
- break
1485
+
1486
+ if not selected:
1487
+ self.complete = True
1488
+ self.save(update_fields=["complete"])
1489
+ return
1321
1490
 
1322
1491
  seen_list = seen.copy()
1323
1492
  selected_ids = [str(n.uuid) for n in selected]
@@ -1332,6 +1501,18 @@ class NetMessage(Entity):
1332
1501
  "sender": local_id,
1333
1502
  "origin": origin_uuid,
1334
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
1335
1516
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1336
1517
  headers = {"Content-Type": "application/json"}
1337
1518
  if private_key: