arthexis 0.1.9__py3-none-any.whl → 0.1.10__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.9.dist-info → arthexis-0.1.10.dist-info}/METADATA +63 -20
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/RECORD +39 -36
- config/settings.py +221 -23
- config/urls.py +6 -0
- core/admin.py +401 -35
- core/apps.py +3 -0
- core/auto_upgrade.py +57 -0
- core/backends.py +77 -3
- core/fields.py +93 -0
- core/models.py +212 -7
- core/reference_utils.py +97 -0
- core/sigil_builder.py +16 -3
- core/system.py +157 -143
- core/tasks.py +151 -8
- core/test_system_info.py +37 -1
- core/tests.py +288 -12
- core/user_data.py +103 -8
- core/views.py +257 -15
- nodes/admin.py +12 -4
- nodes/backends.py +109 -17
- nodes/models.py +205 -2
- nodes/tests.py +370 -1
- nodes/views.py +140 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +252 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +49 -7
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/tests.py +384 -8
- ocpp/views.py +101 -76
- pages/context_processors.py +20 -0
- pages/forms.py +131 -0
- pages/tests.py +434 -13
- pages/urls.py +1 -0
- pages/views.py +334 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
nodes/models.py
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
from collections.abc import Iterable
|
|
2
2
|
from django.db import models
|
|
3
3
|
from django.db.models.signals import post_delete
|
|
4
|
-
from django.dispatch import receiver
|
|
4
|
+
from django.dispatch import Signal, receiver
|
|
5
5
|
from core.entity import Entity
|
|
6
6
|
from core.models import Profile
|
|
7
7
|
from core.fields import SigilShortAutoField
|
|
8
8
|
import re
|
|
9
9
|
import json
|
|
10
10
|
import base64
|
|
11
|
+
from django.utils import timezone
|
|
11
12
|
from django.utils.text import slugify
|
|
12
13
|
from django.conf import settings
|
|
13
14
|
from django.contrib.sites.models import Site
|
|
15
|
+
from datetime import timedelta
|
|
14
16
|
import uuid
|
|
15
17
|
import os
|
|
16
18
|
import shutil
|
|
@@ -19,6 +21,7 @@ import stat
|
|
|
19
21
|
import subprocess
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
from utils import revision
|
|
24
|
+
from core.notifications import notify_async
|
|
22
25
|
from django.core.exceptions import ValidationError
|
|
23
26
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
24
27
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
@@ -235,8 +238,112 @@ class Node(Entity):
|
|
|
235
238
|
node.save(update_fields=["role"])
|
|
236
239
|
Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
|
|
237
240
|
node.ensure_keys()
|
|
241
|
+
node.notify_peers_of_update()
|
|
238
242
|
return node, created
|
|
239
243
|
|
|
244
|
+
def notify_peers_of_update(self):
|
|
245
|
+
"""Attempt to update this node's registration with known peers."""
|
|
246
|
+
|
|
247
|
+
from secrets import token_hex
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
import requests
|
|
251
|
+
except Exception: # pragma: no cover - requests should be available
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
|
|
255
|
+
priv_path = security_dir / f"{self.public_endpoint}"
|
|
256
|
+
if not priv_path.exists():
|
|
257
|
+
logger.debug("Private key for %s not found; skipping peer update", self)
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
private_key = serialization.load_pem_private_key(
|
|
261
|
+
priv_path.read_bytes(), password=None
|
|
262
|
+
)
|
|
263
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
264
|
+
logger.warning("Failed to load private key for %s: %s", self, exc)
|
|
265
|
+
return
|
|
266
|
+
token = token_hex(16)
|
|
267
|
+
try:
|
|
268
|
+
signature = private_key.sign(
|
|
269
|
+
token.encode(),
|
|
270
|
+
padding.PKCS1v15(),
|
|
271
|
+
hashes.SHA256(),
|
|
272
|
+
)
|
|
273
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
274
|
+
logger.warning("Failed to sign peer update for %s: %s", self, exc)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
payload = {
|
|
278
|
+
"hostname": self.hostname,
|
|
279
|
+
"address": self.address,
|
|
280
|
+
"port": self.port,
|
|
281
|
+
"mac_address": self.mac_address,
|
|
282
|
+
"public_key": self.public_key,
|
|
283
|
+
"token": token,
|
|
284
|
+
"signature": base64.b64encode(signature).decode(),
|
|
285
|
+
}
|
|
286
|
+
if self.installed_version:
|
|
287
|
+
payload["installed_version"] = self.installed_version
|
|
288
|
+
if self.installed_revision:
|
|
289
|
+
payload["installed_revision"] = self.installed_revision
|
|
290
|
+
|
|
291
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
292
|
+
headers = {"Content-Type": "application/json"}
|
|
293
|
+
|
|
294
|
+
peers = Node.objects.exclude(pk=self.pk)
|
|
295
|
+
for peer in peers:
|
|
296
|
+
host_candidates: list[str] = []
|
|
297
|
+
if peer.address:
|
|
298
|
+
host_candidates.append(peer.address)
|
|
299
|
+
if peer.hostname and peer.hostname not in host_candidates:
|
|
300
|
+
host_candidates.append(peer.hostname)
|
|
301
|
+
port = peer.port or 8000
|
|
302
|
+
urls: list[str] = []
|
|
303
|
+
for host in host_candidates:
|
|
304
|
+
host = host.strip()
|
|
305
|
+
if not host:
|
|
306
|
+
continue
|
|
307
|
+
if ":" in host and not host.startswith("["):
|
|
308
|
+
host = f"[{host}]"
|
|
309
|
+
http_url = (
|
|
310
|
+
f"http://{host}/nodes/register/"
|
|
311
|
+
if port == 80
|
|
312
|
+
else f"http://{host}:{port}/nodes/register/"
|
|
313
|
+
)
|
|
314
|
+
https_url = (
|
|
315
|
+
f"https://{host}/nodes/register/"
|
|
316
|
+
if port in {80, 443}
|
|
317
|
+
else f"https://{host}:{port}/nodes/register/"
|
|
318
|
+
)
|
|
319
|
+
for url in (https_url, http_url):
|
|
320
|
+
if url not in urls:
|
|
321
|
+
urls.append(url)
|
|
322
|
+
if not urls:
|
|
323
|
+
continue
|
|
324
|
+
for url in urls:
|
|
325
|
+
try:
|
|
326
|
+
response = requests.post(
|
|
327
|
+
url, data=payload_json, headers=headers, timeout=2
|
|
328
|
+
)
|
|
329
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
330
|
+
logger.debug("Failed to update %s via %s: %s", peer, url, exc)
|
|
331
|
+
continue
|
|
332
|
+
if response.ok:
|
|
333
|
+
version_display = _format_upgrade_body(
|
|
334
|
+
self.installed_version,
|
|
335
|
+
self.installed_revision,
|
|
336
|
+
)
|
|
337
|
+
version_suffix = f" ({version_display})" if version_display else ""
|
|
338
|
+
logger.info(
|
|
339
|
+
"Announced startup to %s%s",
|
|
340
|
+
peer,
|
|
341
|
+
version_suffix,
|
|
342
|
+
)
|
|
343
|
+
break
|
|
344
|
+
else:
|
|
345
|
+
logger.warning("Unable to notify node %s of startup", peer)
|
|
346
|
+
|
|
240
347
|
def ensure_keys(self):
|
|
241
348
|
security_dir = Path(settings.BASE_DIR) / "security"
|
|
242
349
|
security_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -526,6 +633,52 @@ class Node(Entity):
|
|
|
526
633
|
)
|
|
527
634
|
|
|
528
635
|
|
|
636
|
+
node_information_updated = Signal()
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _format_upgrade_body(version: str, revision: str) -> str:
|
|
640
|
+
version = (version or "").strip()
|
|
641
|
+
revision = (revision or "").strip()
|
|
642
|
+
parts: list[str] = []
|
|
643
|
+
if version:
|
|
644
|
+
normalized = version.lstrip("vV") or version
|
|
645
|
+
parts.append(f"v{normalized}")
|
|
646
|
+
if revision:
|
|
647
|
+
rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
|
|
648
|
+
rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
|
|
649
|
+
parts.append(f"r{rev_short}")
|
|
650
|
+
return " ".join(parts).strip()
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@receiver(node_information_updated)
|
|
654
|
+
def _announce_peer_startup(
|
|
655
|
+
sender,
|
|
656
|
+
*,
|
|
657
|
+
node: "Node",
|
|
658
|
+
previous_version: str = "",
|
|
659
|
+
previous_revision: str = "",
|
|
660
|
+
current_version: str = "",
|
|
661
|
+
current_revision: str = "",
|
|
662
|
+
**_: object,
|
|
663
|
+
) -> None:
|
|
664
|
+
current_version = (current_version or "").strip()
|
|
665
|
+
current_revision = (current_revision or "").strip()
|
|
666
|
+
previous_version = (previous_version or "").strip()
|
|
667
|
+
previous_revision = (previous_revision or "").strip()
|
|
668
|
+
|
|
669
|
+
local = Node.get_local()
|
|
670
|
+
if local and node.pk == local.pk:
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
body = _format_upgrade_body(current_version, current_revision)
|
|
674
|
+
if not body:
|
|
675
|
+
body = "Online"
|
|
676
|
+
|
|
677
|
+
hostname = (node.hostname or "Node").strip() or "Node"
|
|
678
|
+
subject = f"UP {hostname}"
|
|
679
|
+
notify_async(subject, body)
|
|
680
|
+
|
|
681
|
+
|
|
529
682
|
class NodeFeatureAssignment(Entity):
|
|
530
683
|
"""Bridge between :class:`Node` and :class:`NodeFeature`."""
|
|
531
684
|
|
|
@@ -617,6 +770,26 @@ class EmailOutbox(Profile):
|
|
|
617
770
|
verbose_name = "Email Outbox"
|
|
618
771
|
verbose_name_plural = "Email Outboxes"
|
|
619
772
|
|
|
773
|
+
def __str__(self) -> str:
|
|
774
|
+
address = (self.from_email or "").strip()
|
|
775
|
+
if address:
|
|
776
|
+
return address
|
|
777
|
+
|
|
778
|
+
username = (self.username or "").strip()
|
|
779
|
+
host = (self.host or "").strip()
|
|
780
|
+
if username and host:
|
|
781
|
+
return f"{username}@{host}"
|
|
782
|
+
if username:
|
|
783
|
+
return username
|
|
784
|
+
if host:
|
|
785
|
+
return host
|
|
786
|
+
|
|
787
|
+
owner = self.owner_display()
|
|
788
|
+
if owner:
|
|
789
|
+
return owner
|
|
790
|
+
|
|
791
|
+
return super().__str__()
|
|
792
|
+
|
|
620
793
|
def clean(self):
|
|
621
794
|
if self.user_id or self.group_id:
|
|
622
795
|
super().clean()
|
|
@@ -662,6 +835,13 @@ class NetMessage(Entity):
|
|
|
662
835
|
editable=False,
|
|
663
836
|
verbose_name="UUID",
|
|
664
837
|
)
|
|
838
|
+
node_origin = models.ForeignKey(
|
|
839
|
+
"Node",
|
|
840
|
+
on_delete=models.SET_NULL,
|
|
841
|
+
null=True,
|
|
842
|
+
blank=True,
|
|
843
|
+
related_name="originated_net_messages",
|
|
844
|
+
)
|
|
665
845
|
subject = models.CharField(max_length=64, blank=True)
|
|
666
846
|
body = models.CharField(max_length=256, blank=True)
|
|
667
847
|
reach = models.ForeignKey(
|
|
@@ -696,10 +876,12 @@ class NetMessage(Entity):
|
|
|
696
876
|
role = reach
|
|
697
877
|
else:
|
|
698
878
|
role = NodeRole.objects.filter(name=reach).first()
|
|
879
|
+
origin = Node.get_local()
|
|
699
880
|
msg = cls.objects.create(
|
|
700
881
|
subject=subject[:64],
|
|
701
882
|
body=body[:256],
|
|
702
883
|
reach=role or get_terminal_role(),
|
|
884
|
+
node_origin=origin,
|
|
703
885
|
)
|
|
704
886
|
msg.propagate(seen=seen or [])
|
|
705
887
|
return msg
|
|
@@ -709,8 +891,28 @@ class NetMessage(Entity):
|
|
|
709
891
|
import random
|
|
710
892
|
import requests
|
|
711
893
|
|
|
712
|
-
notify(self.subject, self.body)
|
|
894
|
+
displayed = notify(self.subject, self.body)
|
|
713
895
|
local = Node.get_local()
|
|
896
|
+
if displayed:
|
|
897
|
+
cutoff = timezone.now() - timedelta(days=7)
|
|
898
|
+
prune_qs = type(self).objects.filter(created__lt=cutoff)
|
|
899
|
+
if local:
|
|
900
|
+
prune_qs = prune_qs.filter(
|
|
901
|
+
models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
|
|
902
|
+
)
|
|
903
|
+
else:
|
|
904
|
+
prune_qs = prune_qs.filter(node_origin__isnull=True)
|
|
905
|
+
if self.pk:
|
|
906
|
+
prune_qs = prune_qs.exclude(pk=self.pk)
|
|
907
|
+
prune_qs.delete()
|
|
908
|
+
if local and not self.node_origin_id:
|
|
909
|
+
self.node_origin = local
|
|
910
|
+
self.save(update_fields=["node_origin"])
|
|
911
|
+
origin_uuid = None
|
|
912
|
+
if self.node_origin_id:
|
|
913
|
+
origin_uuid = str(self.node_origin.uuid)
|
|
914
|
+
elif local:
|
|
915
|
+
origin_uuid = str(local.uuid)
|
|
714
916
|
private_key = None
|
|
715
917
|
seen = list(seen or [])
|
|
716
918
|
local_id = None
|
|
@@ -785,6 +987,7 @@ class NetMessage(Entity):
|
|
|
785
987
|
"seen": payload_seen,
|
|
786
988
|
"reach": reach_name,
|
|
787
989
|
"sender": local_id,
|
|
990
|
+
"origin": origin_uuid,
|
|
788
991
|
}
|
|
789
992
|
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
790
993
|
headers = {"Content-Type": "application/json"}
|