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.
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +49 -78
- config/settings_helpers.py +109 -0
- core/admin.py +293 -78
- core/apps.py +21 -0
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +203 -47
- core/reference_utils.py +1 -1
- core/release.py +42 -20
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +75 -1
- core/views.py +178 -29
- core/widgets.py +43 -0
- nodes/admin.py +583 -10
- nodes/apps.py +15 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +287 -49
- nodes/reports.py +411 -0
- nodes/tests.py +990 -42
- nodes/urls.py +1 -0
- nodes/utils.py +32 -0
- nodes/views.py +173 -5
- ocpp/admin.py +424 -17
- ocpp/consumers.py +630 -15
- ocpp/evcs.py +7 -94
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +236 -4
- ocpp/routing.py +4 -2
- ocpp/simulator.py +346 -26
- ocpp/status_display.py +26 -0
- ocpp/store.py +110 -2
- ocpp/tests.py +1425 -33
- ocpp/transactions_io.py +27 -3
- ocpp/views.py +344 -38
- pages/admin.py +138 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +67 -0
- pages/models.py +136 -1
- pages/tests.py +379 -4
- pages/urls.py +1 -0
- pages/views.py +64 -7
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
nodes/feature_checks.py
ADDED
|
@@ -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=
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1223
|
-
total_known =
|
|
1427
|
+
filtered_nodes = filtered_nodes.exclude(pk=local.pk)
|
|
1428
|
+
total_known = filtered_nodes.count()
|
|
1224
1429
|
|
|
1225
1430
|
remaining = list(
|
|
1226
|
-
|
|
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
|
-
|
|
1440
|
+
limit = self.target_limit or 6
|
|
1441
|
+
target_limit = min(limit, len(remaining))
|
|
1234
1442
|
|
|
1235
|
-
|
|
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
|
-
|
|
1250
|
-
if
|
|
1251
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
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:
|