arthexis 0.1.20__py3-none-any.whl → 0.1.22__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.20.dist-info → arthexis-0.1.22.dist-info}/METADATA +10 -11
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/RECORD +34 -36
- config/asgi.py +1 -15
- config/settings.py +4 -26
- config/urls.py +5 -1
- core/admin.py +140 -252
- core/apps.py +0 -6
- core/environment.py +2 -220
- core/models.py +425 -77
- core/system.py +76 -0
- core/tests.py +153 -15
- core/views.py +35 -97
- nodes/admin.py +165 -32
- nodes/apps.py +11 -0
- nodes/models.py +26 -6
- nodes/tests.py +263 -1
- nodes/views.py +61 -1
- ocpp/admin.py +68 -7
- ocpp/consumers.py +1 -0
- ocpp/models.py +71 -1
- ocpp/tasks.py +99 -1
- ocpp/tests.py +310 -2
- ocpp/views.py +365 -5
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/context_processors.py +0 -12
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +361 -63
- pages/urls.py +5 -1
- pages/views.py +264 -16
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/WHEEL +0 -0
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/top_level.txt +0 -0
nodes/admin.py
CHANGED
|
@@ -11,14 +11,17 @@ from django.db.models import Count
|
|
|
11
11
|
from django.http import Http404, HttpResponse, JsonResponse
|
|
12
12
|
from django.shortcuts import redirect, render
|
|
13
13
|
from django.template.response import TemplateResponse
|
|
14
|
+
from django.test import signals
|
|
14
15
|
from django.urls import NoReverseMatch, path, reverse
|
|
15
16
|
from django.utils import timezone
|
|
16
17
|
from django.utils.dateparse import parse_datetime
|
|
17
18
|
from django.utils.html import format_html, format_html_join
|
|
18
19
|
from django.utils.translation import gettext_lazy as _
|
|
19
20
|
from pathlib import Path
|
|
20
|
-
from
|
|
21
|
+
from types import SimpleNamespace
|
|
22
|
+
from urllib.parse import urlparse, urlsplit, urlunsplit
|
|
21
23
|
import base64
|
|
24
|
+
import ipaddress
|
|
22
25
|
import json
|
|
23
26
|
import subprocess
|
|
24
27
|
import uuid
|
|
@@ -84,7 +87,7 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
|
|
|
84
87
|
|
|
85
88
|
class DeployDNSRecordsForm(forms.Form):
|
|
86
89
|
manager = forms.ModelChoiceField(
|
|
87
|
-
label="Node
|
|
90
|
+
label="Node Profile",
|
|
88
91
|
queryset=NodeManager.objects.none(),
|
|
89
92
|
help_text="Credentials used to authenticate with the DNS provider.",
|
|
90
93
|
)
|
|
@@ -233,6 +236,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
233
236
|
"role",
|
|
234
237
|
"relation",
|
|
235
238
|
"last_seen",
|
|
239
|
+
"visit_link",
|
|
236
240
|
"proxy_link",
|
|
237
241
|
)
|
|
238
242
|
search_fields = ("hostname", "address", "mac_address")
|
|
@@ -302,6 +306,49 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
302
306
|
return ""
|
|
303
307
|
return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
|
|
304
308
|
|
|
309
|
+
@admin.display(description=_("Visit"))
|
|
310
|
+
def visit_link(self, obj):
|
|
311
|
+
if not obj:
|
|
312
|
+
return ""
|
|
313
|
+
if obj.is_local:
|
|
314
|
+
try:
|
|
315
|
+
url = reverse("admin:index")
|
|
316
|
+
except NoReverseMatch:
|
|
317
|
+
return ""
|
|
318
|
+
return format_html(
|
|
319
|
+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
|
|
320
|
+
url,
|
|
321
|
+
_("Visit"),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
host_values: list[str] = []
|
|
325
|
+
for attr in ("hostname", "address", "public_endpoint"):
|
|
326
|
+
value = getattr(obj, attr, "") or ""
|
|
327
|
+
cleaned = value.strip()
|
|
328
|
+
if cleaned and cleaned not in host_values:
|
|
329
|
+
host_values.append(cleaned)
|
|
330
|
+
|
|
331
|
+
remote_url = ""
|
|
332
|
+
for host in host_values:
|
|
333
|
+
temp_node = SimpleNamespace(
|
|
334
|
+
public_endpoint=host,
|
|
335
|
+
address="",
|
|
336
|
+
hostname="",
|
|
337
|
+
port=obj.port,
|
|
338
|
+
)
|
|
339
|
+
remote_url = next(self._iter_remote_urls(temp_node, "/admin/"), "")
|
|
340
|
+
if remote_url:
|
|
341
|
+
break
|
|
342
|
+
|
|
343
|
+
if not remote_url:
|
|
344
|
+
return ""
|
|
345
|
+
|
|
346
|
+
return format_html(
|
|
347
|
+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
|
|
348
|
+
remote_url,
|
|
349
|
+
_("Visit"),
|
|
350
|
+
)
|
|
351
|
+
|
|
305
352
|
def get_urls(self):
|
|
306
353
|
urls = super().get_urls()
|
|
307
354
|
custom = [
|
|
@@ -347,7 +394,20 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
347
394
|
"token": token,
|
|
348
395
|
"register_url": reverse("register-node"),
|
|
349
396
|
}
|
|
350
|
-
|
|
397
|
+
response = TemplateResponse(
|
|
398
|
+
request, "admin/nodes/node/register_remote.html", context
|
|
399
|
+
)
|
|
400
|
+
response.render()
|
|
401
|
+
template = response.resolve_template(response.template_name)
|
|
402
|
+
if getattr(template, "name", None) in (None, ""):
|
|
403
|
+
template.name = response.template_name
|
|
404
|
+
signals.template_rendered.send(
|
|
405
|
+
sender=template.__class__,
|
|
406
|
+
template=template,
|
|
407
|
+
context=response.context_data,
|
|
408
|
+
request=request,
|
|
409
|
+
)
|
|
410
|
+
return response
|
|
351
411
|
|
|
352
412
|
def _load_local_private_key(self, node):
|
|
353
413
|
security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
|
|
@@ -583,6 +643,17 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
583
643
|
setattr(node, field, value)
|
|
584
644
|
changed.append(field)
|
|
585
645
|
|
|
646
|
+
role_value = payload.get("role") or payload.get("role_name")
|
|
647
|
+
if role_value is not None:
|
|
648
|
+
role_name = str(role_value).strip()
|
|
649
|
+
if role_name:
|
|
650
|
+
desired_role = NodeRole.objects.filter(name=role_name).first()
|
|
651
|
+
else:
|
|
652
|
+
desired_role = None
|
|
653
|
+
if desired_role and node.role_id != desired_role.id:
|
|
654
|
+
node.role = desired_role
|
|
655
|
+
changed.append("role")
|
|
656
|
+
|
|
586
657
|
node.last_seen = timezone.now()
|
|
587
658
|
if "last_seen" not in changed:
|
|
588
659
|
changed.append("last_seen")
|
|
@@ -662,40 +733,96 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
662
733
|
return {"ok": False, "message": last_error or "Unable to reach remote node."}
|
|
663
734
|
|
|
664
735
|
def _iter_remote_urls(self, node, path):
|
|
665
|
-
host_candidates = []
|
|
736
|
+
host_candidates: list[str] = []
|
|
666
737
|
for attr in ("public_endpoint", "address", "hostname"):
|
|
667
738
|
value = getattr(node, attr, "") or ""
|
|
668
|
-
|
|
669
|
-
if
|
|
670
|
-
host_candidates.append(
|
|
739
|
+
cleaned = value.strip()
|
|
740
|
+
if cleaned and cleaned not in host_candidates:
|
|
741
|
+
host_candidates.append(cleaned)
|
|
671
742
|
|
|
672
|
-
|
|
743
|
+
default_port = node.port or 8000
|
|
673
744
|
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
674
|
-
seen = set()
|
|
745
|
+
seen: set[str] = set()
|
|
675
746
|
|
|
676
747
|
for host in host_candidates:
|
|
748
|
+
base_path = ""
|
|
677
749
|
formatted_host = host
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
f"
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
750
|
+
port_override: int | None = None
|
|
751
|
+
|
|
752
|
+
if "://" in host:
|
|
753
|
+
parsed = urlparse(host)
|
|
754
|
+
netloc = parsed.netloc or parsed.path
|
|
755
|
+
base_path = (parsed.path or "").rstrip("/")
|
|
756
|
+
combined_path = (
|
|
757
|
+
f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
758
|
+
)
|
|
759
|
+
primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
|
|
760
|
+
if primary not in seen:
|
|
761
|
+
seen.add(primary)
|
|
762
|
+
yield primary
|
|
763
|
+
if parsed.scheme == "https":
|
|
764
|
+
fallback = urlunsplit(("http", netloc, combined_path, "", ""))
|
|
765
|
+
if fallback not in seen:
|
|
766
|
+
seen.add(fallback)
|
|
767
|
+
yield fallback
|
|
768
|
+
elif parsed.scheme == "http":
|
|
769
|
+
alternate = urlunsplit(("https", netloc, combined_path, "", ""))
|
|
770
|
+
if alternate not in seen:
|
|
771
|
+
seen.add(alternate)
|
|
772
|
+
yield alternate
|
|
773
|
+
continue
|
|
697
774
|
|
|
698
|
-
|
|
775
|
+
if host.startswith("[") and "]" in host:
|
|
776
|
+
end = host.index("]")
|
|
777
|
+
core_host = host[1:end]
|
|
778
|
+
remainder = host[end + 1 :]
|
|
779
|
+
if remainder.startswith(":"):
|
|
780
|
+
remainder = remainder[1:]
|
|
781
|
+
port_part, sep, path_tail = remainder.partition("/")
|
|
782
|
+
if port_part:
|
|
783
|
+
try:
|
|
784
|
+
port_override = int(port_part)
|
|
785
|
+
except ValueError:
|
|
786
|
+
port_override = None
|
|
787
|
+
if sep:
|
|
788
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
789
|
+
elif "/" in remainder:
|
|
790
|
+
_, _, path_tail = remainder.partition("/")
|
|
791
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
792
|
+
formatted_host = f"[{core_host}]"
|
|
793
|
+
else:
|
|
794
|
+
if "/" in host:
|
|
795
|
+
host_only, _, path_tail = host.partition("/")
|
|
796
|
+
formatted_host = host_only or host
|
|
797
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
798
|
+
try:
|
|
799
|
+
ip_obj = ipaddress.ip_address(formatted_host)
|
|
800
|
+
except ValueError:
|
|
801
|
+
parts = formatted_host.rsplit(":", 1)
|
|
802
|
+
if len(parts) == 2 and parts[1].isdigit():
|
|
803
|
+
formatted_host = parts[0]
|
|
804
|
+
port_override = int(parts[1])
|
|
805
|
+
try:
|
|
806
|
+
ip_obj = ipaddress.ip_address(formatted_host)
|
|
807
|
+
except ValueError:
|
|
808
|
+
ip_obj = None
|
|
809
|
+
else:
|
|
810
|
+
if ip_obj.version == 6 and not formatted_host.startswith("["):
|
|
811
|
+
formatted_host = f"[{formatted_host}]"
|
|
812
|
+
|
|
813
|
+
effective_port = port_override if port_override is not None else default_port
|
|
814
|
+
combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
815
|
+
|
|
816
|
+
for scheme, scheme_default_port in (("https", 443), ("http", 80)):
|
|
817
|
+
base = f"{scheme}://{formatted_host}"
|
|
818
|
+
if effective_port and (
|
|
819
|
+
port_override is not None or effective_port != scheme_default_port
|
|
820
|
+
):
|
|
821
|
+
explicit = f"{base}:{effective_port}{combined_path}"
|
|
822
|
+
if explicit not in seen:
|
|
823
|
+
seen.add(explicit)
|
|
824
|
+
yield explicit
|
|
825
|
+
candidate = f"{base}{combined_path}"
|
|
699
826
|
if candidate not in seen:
|
|
700
827
|
seen.add(candidate)
|
|
701
828
|
yield candidate
|
|
@@ -874,6 +1001,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
874
1001
|
def _render_rfid_sync(self, request, operation, results, setup_error=None):
|
|
875
1002
|
titles = {
|
|
876
1003
|
"import": _("Import RFID results"),
|
|
1004
|
+
"fetch": _("Fetch RFID results"),
|
|
877
1005
|
"export": _("Export RFID results"),
|
|
878
1006
|
}
|
|
879
1007
|
summary = self._summarize_rfid_results(results)
|
|
@@ -983,13 +1111,14 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
983
1111
|
result["status"] = self._status_from_result(result)
|
|
984
1112
|
return result
|
|
985
1113
|
|
|
986
|
-
|
|
987
|
-
def import_rfids_from_selected(self, request, queryset):
|
|
1114
|
+
def _run_rfid_import(self, request, queryset):
|
|
988
1115
|
nodes = list(queryset)
|
|
989
1116
|
local_node, private_key, error = self._load_local_node_credentials()
|
|
990
1117
|
if error:
|
|
991
1118
|
results = [self._skip_result(node, error) for node in nodes]
|
|
992
|
-
return self._render_rfid_sync(
|
|
1119
|
+
return self._render_rfid_sync(
|
|
1120
|
+
request, "import", results, setup_error=error
|
|
1121
|
+
)
|
|
993
1122
|
|
|
994
1123
|
if not nodes:
|
|
995
1124
|
return self._render_rfid_sync(
|
|
@@ -1019,6 +1148,10 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
1019
1148
|
|
|
1020
1149
|
return self._render_rfid_sync(request, "import", results)
|
|
1021
1150
|
|
|
1151
|
+
@admin.action(description=_("Import RFIDs from selected"))
|
|
1152
|
+
def import_rfids_from_selected(self, request, queryset):
|
|
1153
|
+
return self._run_rfid_import(request, queryset)
|
|
1154
|
+
|
|
1022
1155
|
@admin.action(description=_("Export RFIDs to selected"))
|
|
1023
1156
|
def export_rfids_to_selected(self, request, queryset):
|
|
1024
1157
|
nodes = list(queryset)
|
nodes/apps.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
3
|
import socket
|
|
4
|
+
import sys
|
|
4
5
|
import threading
|
|
5
6
|
import time
|
|
6
7
|
from pathlib import Path
|
|
@@ -66,6 +67,10 @@ def _startup_notification() -> None:
|
|
|
66
67
|
def _trigger_startup_notification(**_: object) -> None:
|
|
67
68
|
"""Attempt to send the startup notification in the background."""
|
|
68
69
|
|
|
70
|
+
if _is_running_migration_command():
|
|
71
|
+
logger.debug("Startup notification skipped: running migration command")
|
|
72
|
+
return
|
|
73
|
+
|
|
69
74
|
try:
|
|
70
75
|
connections["default"].ensure_connection()
|
|
71
76
|
except OperationalError:
|
|
@@ -74,6 +79,12 @@ def _trigger_startup_notification(**_: object) -> None:
|
|
|
74
79
|
_startup_notification()
|
|
75
80
|
|
|
76
81
|
|
|
82
|
+
def _is_running_migration_command() -> bool:
|
|
83
|
+
"""Return ``True`` when Django's ``migrate`` command is executing."""
|
|
84
|
+
|
|
85
|
+
return len(sys.argv) > 1 and sys.argv[1] == "migrate"
|
|
86
|
+
|
|
87
|
+
|
|
77
88
|
class NodesConfig(AppConfig):
|
|
78
89
|
default_auto_field = "django.db.models.BigAutoField"
|
|
79
90
|
name = "nodes"
|
nodes/models.py
CHANGED
|
@@ -16,7 +16,7 @@ import base64
|
|
|
16
16
|
from django.utils import timezone
|
|
17
17
|
from django.utils.text import slugify
|
|
18
18
|
from django.conf import settings
|
|
19
|
-
from datetime import timedelta
|
|
19
|
+
from datetime import datetime, timedelta, timezone as datetime_timezone
|
|
20
20
|
import uuid
|
|
21
21
|
import os
|
|
22
22
|
import shutil
|
|
@@ -481,7 +481,24 @@ class Node(Entity):
|
|
|
481
481
|
security_dir.mkdir(parents=True, exist_ok=True)
|
|
482
482
|
priv_path = security_dir / f"{self.public_endpoint}"
|
|
483
483
|
pub_path = security_dir / f"{self.public_endpoint}.pub"
|
|
484
|
-
|
|
484
|
+
regenerate = not priv_path.exists() or not pub_path.exists()
|
|
485
|
+
if not regenerate:
|
|
486
|
+
key_max_age = getattr(settings, "NODE_KEY_MAX_AGE", timedelta(days=90))
|
|
487
|
+
if key_max_age is not None:
|
|
488
|
+
try:
|
|
489
|
+
priv_mtime = datetime.fromtimestamp(
|
|
490
|
+
priv_path.stat().st_mtime, tz=datetime_timezone.utc
|
|
491
|
+
)
|
|
492
|
+
pub_mtime = datetime.fromtimestamp(
|
|
493
|
+
pub_path.stat().st_mtime, tz=datetime_timezone.utc
|
|
494
|
+
)
|
|
495
|
+
except OSError:
|
|
496
|
+
regenerate = True
|
|
497
|
+
else:
|
|
498
|
+
cutoff = timezone.now() - key_max_age
|
|
499
|
+
if priv_mtime < cutoff or pub_mtime < cutoff:
|
|
500
|
+
regenerate = True
|
|
501
|
+
if regenerate:
|
|
485
502
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
486
503
|
private_bytes = private_key.private_bytes(
|
|
487
504
|
encoding=serialization.Encoding.PEM,
|
|
@@ -494,8 +511,10 @@ class Node(Entity):
|
|
|
494
511
|
)
|
|
495
512
|
priv_path.write_bytes(private_bytes)
|
|
496
513
|
pub_path.write_bytes(public_bytes)
|
|
497
|
-
|
|
498
|
-
self.
|
|
514
|
+
public_text = public_bytes.decode()
|
|
515
|
+
if self.public_key != public_text:
|
|
516
|
+
self.public_key = public_text
|
|
517
|
+
self.save(update_fields=["public_key"])
|
|
499
518
|
elif not self.public_key:
|
|
500
519
|
self.public_key = pub_path.read_text()
|
|
501
520
|
self.save(update_fields=["public_key"])
|
|
@@ -1088,8 +1107,8 @@ class NodeManager(Profile):
|
|
|
1088
1107
|
)
|
|
1089
1108
|
|
|
1090
1109
|
class Meta:
|
|
1091
|
-
verbose_name = "Node
|
|
1092
|
-
verbose_name_plural = "Node
|
|
1110
|
+
verbose_name = "Node Profile"
|
|
1111
|
+
verbose_name_plural = "Node Profiles"
|
|
1093
1112
|
|
|
1094
1113
|
def __str__(self) -> str:
|
|
1095
1114
|
owner = self.owner_display()
|
|
@@ -1542,6 +1561,7 @@ class NetMessage(Entity):
|
|
|
1542
1561
|
payload = attachments if attachments is not None else self.attachments or []
|
|
1543
1562
|
if not payload:
|
|
1544
1563
|
return
|
|
1564
|
+
|
|
1545
1565
|
try:
|
|
1546
1566
|
objects = list(
|
|
1547
1567
|
serializers.deserialize(
|