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.
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/RECORD +37 -34
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +42 -76
- config/settings_helpers.py +109 -0
- core/admin.py +47 -10
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +182 -59
- core/release.py +38 -20
- core/tests.py +11 -1
- core/views.py +47 -12
- core/widgets.py +43 -0
- nodes/admin.py +277 -14
- nodes/apps.py +15 -0
- nodes/models.py +224 -43
- nodes/tests.py +629 -10
- nodes/urls.py +1 -0
- nodes/views.py +173 -5
- ocpp/admin.py +146 -2
- ocpp/consumers.py +125 -8
- ocpp/evcs.py +7 -94
- ocpp/models.py +2 -0
- ocpp/routing.py +4 -2
- ocpp/simulator.py +29 -8
- ocpp/status_display.py +26 -0
- ocpp/tests.py +625 -16
- ocpp/transactions_io.py +10 -0
- ocpp/views.py +122 -22
- pages/admin.py +3 -0
- pages/forms.py +30 -1
- pages/tests.py +118 -1
- pages/views.py +12 -4
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
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":
|
|
89
|
-
|
|
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":
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
"celery-queue": (
|
|
94
|
+
NodeFeatureDefaultAction(
|
|
95
|
+
label="Celery Report",
|
|
96
|
+
url_name="admin:nodes_nodefeature_celery_report",
|
|
97
|
+
),
|
|
94
98
|
),
|
|
95
|
-
"screenshot-poll":
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
"screenshot-poll": (
|
|
100
|
+
NodeFeatureDefaultAction(
|
|
101
|
+
label="Take Screenshot",
|
|
102
|
+
url_name="admin:nodes_nodefeature_take_screenshot",
|
|
103
|
+
),
|
|
98
104
|
),
|
|
99
|
-
"rpi-camera":
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1280
|
-
total_known =
|
|
1427
|
+
filtered_nodes = filtered_nodes.exclude(pk=local.pk)
|
|
1428
|
+
total_known = filtered_nodes.count()
|
|
1281
1429
|
|
|
1282
1430
|
remaining = list(
|
|
1283
|
-
|
|
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
|
-
|
|
1440
|
+
limit = self.target_limit or 6
|
|
1441
|
+
target_limit = min(limit, len(remaining))
|
|
1291
1442
|
|
|
1292
|
-
|
|
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
|
-
|
|
1307
|
-
if
|
|
1308
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
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:
|