arthexis 0.1.24__py3-none-any.whl → 0.1.25__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.24.dist-info → arthexis-0.1.25.dist-info}/METADATA +35 -14
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/RECORD +30 -29
- config/settings.py +6 -3
- config/urls.py +2 -0
- core/admin.py +1 -186
- core/backends.py +3 -1
- core/models.py +74 -8
- core/system.py +67 -2
- core/views.py +0 -3
- nodes/admin.py +444 -251
- nodes/models.py +299 -23
- nodes/tasks.py +13 -16
- nodes/tests.py +211 -1
- nodes/urls.py +5 -0
- nodes/utils.py +9 -2
- nodes/views.py +128 -80
- ocpp/admin.py +190 -2
- ocpp/consumers.py +98 -0
- ocpp/models.py +271 -0
- ocpp/network.py +398 -0
- ocpp/tasks.py +108 -267
- ocpp/tests.py +179 -0
- ocpp/views.py +2 -0
- pages/middleware.py +3 -2
- pages/tests.py +40 -0
- pages/utils.py +70 -0
- pages/views.py +4 -2
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/WHEEL +0 -0
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/top_level.txt +0 -0
nodes/admin.py
CHANGED
|
@@ -16,22 +16,23 @@ from django.urls import NoReverseMatch, path, reverse
|
|
|
16
16
|
from django.utils import timezone
|
|
17
17
|
from django.utils.dateparse import parse_datetime
|
|
18
18
|
from django.utils.html import format_html, format_html_join
|
|
19
|
-
from django.utils.translation import gettext_lazy as _
|
|
19
|
+
from django.utils.translation import gettext_lazy as _, ngettext
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from types import SimpleNamespace
|
|
22
|
-
from urllib.parse import
|
|
22
|
+
from urllib.parse import urlsplit, urlunsplit, quote
|
|
23
23
|
import base64
|
|
24
|
-
import ipaddress
|
|
25
24
|
import json
|
|
26
25
|
import subprocess
|
|
27
26
|
import uuid
|
|
28
27
|
|
|
28
|
+
import asyncio
|
|
29
29
|
import pyperclip
|
|
30
30
|
import requests
|
|
31
31
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
32
32
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
33
33
|
from pyperclip import PyperclipException
|
|
34
34
|
from requests import RequestException
|
|
35
|
+
import websockets
|
|
35
36
|
|
|
36
37
|
from .classifiers import run_default_classifiers, suppress_default_classifiers
|
|
37
38
|
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
@@ -60,7 +61,9 @@ from .models import (
|
|
|
60
61
|
)
|
|
61
62
|
from . import dns as dns_utils
|
|
62
63
|
from core.models import RFID
|
|
63
|
-
from ocpp.models import Charger
|
|
64
|
+
from ocpp.models import Charger
|
|
65
|
+
from ocpp.network import serialize_charger_for_network
|
|
66
|
+
from ocpp.tasks import push_forwarded_charge_points
|
|
64
67
|
from core.user_data import EntityModelAdmin
|
|
65
68
|
|
|
66
69
|
|
|
@@ -231,33 +234,37 @@ class DNSRecordAdmin(EntityModelAdmin):
|
|
|
231
234
|
class NodeAdmin(EntityModelAdmin):
|
|
232
235
|
list_display = (
|
|
233
236
|
"hostname",
|
|
234
|
-
"
|
|
235
|
-
"
|
|
237
|
+
"network_hostname",
|
|
238
|
+
"ipv4_address",
|
|
239
|
+
"ipv6_address",
|
|
236
240
|
"port",
|
|
237
241
|
"role",
|
|
238
242
|
"relation",
|
|
239
243
|
"last_seen",
|
|
240
244
|
"visit_link",
|
|
241
245
|
)
|
|
242
|
-
search_fields = ("hostname", "address", "mac_address")
|
|
246
|
+
search_fields = ("hostname", "network_hostname", "address", "mac_address")
|
|
243
247
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
244
248
|
change_form_template = "admin/nodes/node/change_form.html"
|
|
245
249
|
form = NodeAdminForm
|
|
246
250
|
fieldsets = (
|
|
247
251
|
(
|
|
248
|
-
_("
|
|
252
|
+
_("Network"),
|
|
249
253
|
{
|
|
250
254
|
"fields": (
|
|
251
255
|
"hostname",
|
|
256
|
+
"network_hostname",
|
|
257
|
+
"ipv4_address",
|
|
258
|
+
"ipv6_address",
|
|
252
259
|
"address",
|
|
253
260
|
"mac_address",
|
|
254
261
|
"port",
|
|
255
262
|
"message_queue_length",
|
|
256
|
-
"role",
|
|
257
263
|
"current_relation",
|
|
258
264
|
)
|
|
259
265
|
},
|
|
260
266
|
),
|
|
267
|
+
(_("Role"), {"fields": ("role",)}),
|
|
261
268
|
(
|
|
262
269
|
_("Public endpoint"),
|
|
263
270
|
{
|
|
@@ -287,12 +294,39 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
287
294
|
"register_visitor",
|
|
288
295
|
"run_task",
|
|
289
296
|
"take_screenshots",
|
|
290
|
-
"
|
|
297
|
+
"start_charge_point_forwarding",
|
|
298
|
+
"stop_charge_point_forwarding",
|
|
291
299
|
"import_rfids_from_selected",
|
|
292
300
|
"export_rfids_to_selected",
|
|
301
|
+
"send_net_message",
|
|
293
302
|
]
|
|
294
303
|
inlines = [NodeFeatureAssignmentInline]
|
|
295
304
|
|
|
305
|
+
class SendNetMessageForm(forms.Form):
|
|
306
|
+
subject = forms.CharField(
|
|
307
|
+
label=_("Subject"),
|
|
308
|
+
max_length=NetMessage._meta.get_field("subject").max_length,
|
|
309
|
+
required=False,
|
|
310
|
+
)
|
|
311
|
+
body = forms.CharField(
|
|
312
|
+
label=_("Body"),
|
|
313
|
+
max_length=NetMessage._meta.get_field("body").max_length,
|
|
314
|
+
required=False,
|
|
315
|
+
widget=forms.Textarea(attrs={"rows": 4}),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def clean(self):
|
|
319
|
+
cleaned = super().clean()
|
|
320
|
+
subject = (cleaned.get("subject") or "").strip()
|
|
321
|
+
body = (cleaned.get("body") or "").strip()
|
|
322
|
+
if not subject and not body:
|
|
323
|
+
raise forms.ValidationError(
|
|
324
|
+
_("Enter a subject or body to send.")
|
|
325
|
+
)
|
|
326
|
+
cleaned["subject"] = subject
|
|
327
|
+
cleaned["body"] = body
|
|
328
|
+
return cleaned
|
|
329
|
+
|
|
296
330
|
@admin.display(description=_("Relation"), ordering="current_relation")
|
|
297
331
|
def relation(self, obj):
|
|
298
332
|
return obj.get_current_relation_display()
|
|
@@ -312,12 +346,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
312
346
|
_("Visit"),
|
|
313
347
|
)
|
|
314
348
|
|
|
315
|
-
host_values
|
|
316
|
-
for attr in ("hostname", "address", "public_endpoint"):
|
|
317
|
-
value = getattr(obj, attr, "") or ""
|
|
318
|
-
cleaned = value.strip()
|
|
319
|
-
if cleaned and cleaned not in host_values:
|
|
320
|
-
host_values.append(cleaned)
|
|
349
|
+
host_values = obj.get_remote_host_candidates()
|
|
321
350
|
|
|
322
351
|
remote_url = ""
|
|
323
352
|
for host in host_values:
|
|
@@ -417,6 +446,75 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
417
446
|
request, "admin/nodes/node/update_selected.html", context
|
|
418
447
|
)
|
|
419
448
|
|
|
449
|
+
@admin.action(description=_("Send Net Message"))
|
|
450
|
+
def send_net_message(self, request, queryset):
|
|
451
|
+
is_submit = "apply" in request.POST
|
|
452
|
+
form = self.SendNetMessageForm(request.POST if is_submit else None)
|
|
453
|
+
selected_ids = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
|
|
454
|
+
if not selected_ids:
|
|
455
|
+
selected_ids = [str(pk) for pk in queryset.values_list("pk", flat=True)]
|
|
456
|
+
nodes: list[Node] = []
|
|
457
|
+
cleaned_ids: list[int] = []
|
|
458
|
+
for value in selected_ids:
|
|
459
|
+
try:
|
|
460
|
+
cleaned_ids.append(int(value))
|
|
461
|
+
except (TypeError, ValueError):
|
|
462
|
+
continue
|
|
463
|
+
if cleaned_ids:
|
|
464
|
+
base_queryset = self.get_queryset(request).filter(pk__in=cleaned_ids)
|
|
465
|
+
nodes_by_pk = {str(node.pk): node for node in base_queryset}
|
|
466
|
+
nodes = [nodes_by_pk[value] for value in selected_ids if value in nodes_by_pk]
|
|
467
|
+
if not nodes:
|
|
468
|
+
nodes = list(queryset)
|
|
469
|
+
selected_ids = [str(node.pk) for node in nodes]
|
|
470
|
+
if not nodes:
|
|
471
|
+
self.message_user(request, _("No nodes selected."), messages.INFO)
|
|
472
|
+
return None
|
|
473
|
+
if is_submit and form.is_valid():
|
|
474
|
+
subject = form.cleaned_data["subject"]
|
|
475
|
+
body = form.cleaned_data["body"]
|
|
476
|
+
created = 0
|
|
477
|
+
for node in nodes:
|
|
478
|
+
message = NetMessage.objects.create(
|
|
479
|
+
subject=subject,
|
|
480
|
+
body=body,
|
|
481
|
+
filter_node=node,
|
|
482
|
+
)
|
|
483
|
+
message.propagate()
|
|
484
|
+
created += 1
|
|
485
|
+
if created:
|
|
486
|
+
success_message = ngettext(
|
|
487
|
+
"Sent %(count)d net message.",
|
|
488
|
+
"Sent %(count)d net messages.",
|
|
489
|
+
created,
|
|
490
|
+
) % {"count": created}
|
|
491
|
+
self.message_user(request, success_message, messages.SUCCESS)
|
|
492
|
+
else:
|
|
493
|
+
self.message_user(
|
|
494
|
+
request, _("No net messages were sent."), messages.INFO
|
|
495
|
+
)
|
|
496
|
+
return None
|
|
497
|
+
context = {
|
|
498
|
+
**self.admin_site.each_context(request),
|
|
499
|
+
"opts": self.model._meta,
|
|
500
|
+
"title": _("Send Net Message"),
|
|
501
|
+
"nodes": nodes,
|
|
502
|
+
"selected_ids": selected_ids,
|
|
503
|
+
"action_name": request.POST.get("action", "send_net_message"),
|
|
504
|
+
"select_across": request.POST.get("select_across", "0"),
|
|
505
|
+
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
|
|
506
|
+
"adminform": helpers.AdminForm(
|
|
507
|
+
form,
|
|
508
|
+
[(None, {"fields": ("subject", "body")})],
|
|
509
|
+
{},
|
|
510
|
+
),
|
|
511
|
+
"form": form,
|
|
512
|
+
"media": self.media + form.media,
|
|
513
|
+
}
|
|
514
|
+
return TemplateResponse(
|
|
515
|
+
request, "admin/nodes/node/send_net_message.html", context
|
|
516
|
+
)
|
|
517
|
+
|
|
420
518
|
def update_selected_progress(self, request):
|
|
421
519
|
if request.method != "POST":
|
|
422
520
|
return JsonResponse({"detail": "POST required"}, status=405)
|
|
@@ -461,6 +559,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
461
559
|
}
|
|
462
560
|
|
|
463
561
|
last_error = ""
|
|
562
|
+
host_candidates = node.get_remote_host_candidates()
|
|
464
563
|
for url in self._iter_remote_urls(node, "/nodes/info/"):
|
|
465
564
|
try:
|
|
466
565
|
response = requests.get(url, timeout=5)
|
|
@@ -487,13 +586,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
487
586
|
"updated_fields": updated,
|
|
488
587
|
"message": message,
|
|
489
588
|
}
|
|
490
|
-
return {
|
|
589
|
+
return {
|
|
590
|
+
"ok": False,
|
|
591
|
+
"message": self._build_connectivity_hint(last_error, host_candidates),
|
|
592
|
+
}
|
|
491
593
|
|
|
492
594
|
def _apply_remote_node_info(self, node, payload):
|
|
493
595
|
changed = []
|
|
494
596
|
field_map = {
|
|
495
597
|
"hostname": payload.get("hostname"),
|
|
598
|
+
"network_hostname": payload.get("network_hostname"),
|
|
496
599
|
"address": payload.get("address"),
|
|
600
|
+
"ipv4_address": payload.get("ipv4_address"),
|
|
601
|
+
"ipv6_address": payload.get("ipv6_address"),
|
|
497
602
|
"public_key": payload.get("public_key"),
|
|
498
603
|
}
|
|
499
604
|
port_value = payload.get("port")
|
|
@@ -571,7 +676,10 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
571
676
|
|
|
572
677
|
payload = {
|
|
573
678
|
"hostname": local_node.hostname,
|
|
679
|
+
"network_hostname": local_node.network_hostname,
|
|
574
680
|
"address": local_node.address,
|
|
681
|
+
"ipv4_address": local_node.ipv4_address,
|
|
682
|
+
"ipv6_address": local_node.ipv6_address,
|
|
575
683
|
"port": local_node.port,
|
|
576
684
|
"mac_address": local_node.mac_address,
|
|
577
685
|
"public_key": local_node.public_key,
|
|
@@ -587,6 +695,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
587
695
|
headers = {"Content-Type": "application/json"}
|
|
588
696
|
|
|
589
697
|
last_error = ""
|
|
698
|
+
host_candidates = node.get_remote_host_candidates()
|
|
590
699
|
for url in self._iter_remote_urls(node, "/nodes/register/"):
|
|
591
700
|
try:
|
|
592
701
|
response = requests.post(
|
|
@@ -601,102 +710,52 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
601
710
|
if response.ok:
|
|
602
711
|
return {"ok": True, "url": url, "message": "Remote updated."}
|
|
603
712
|
last_error = f"{response.status_code} {response.text}"
|
|
604
|
-
return {
|
|
713
|
+
return {
|
|
714
|
+
"ok": False,
|
|
715
|
+
"message": self._build_connectivity_hint(last_error, host_candidates),
|
|
716
|
+
}
|
|
605
717
|
|
|
606
|
-
def
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
629
|
-
)
|
|
630
|
-
primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
|
|
631
|
-
if primary not in seen:
|
|
632
|
-
seen.add(primary)
|
|
633
|
-
yield primary
|
|
634
|
-
if parsed.scheme == "https":
|
|
635
|
-
fallback = urlunsplit(("http", netloc, combined_path, "", ""))
|
|
636
|
-
if fallback not in seen:
|
|
637
|
-
seen.add(fallback)
|
|
638
|
-
yield fallback
|
|
639
|
-
elif parsed.scheme == "http":
|
|
640
|
-
alternate = urlunsplit(("https", netloc, combined_path, "", ""))
|
|
641
|
-
if alternate not in seen:
|
|
642
|
-
seen.add(alternate)
|
|
643
|
-
yield alternate
|
|
718
|
+
def _build_connectivity_hint(self, last_error: str, hosts: list[str]) -> str:
|
|
719
|
+
base_message = last_error or _("Unable to reach remote node.")
|
|
720
|
+
if hosts:
|
|
721
|
+
host_text = ", ".join(hosts)
|
|
722
|
+
return _("%(message)s Tried hosts: %(hosts)s.") % {
|
|
723
|
+
"message": base_message,
|
|
724
|
+
"hosts": host_text,
|
|
725
|
+
}
|
|
726
|
+
return _("%(message)s No remote hosts were available for contact.") % {
|
|
727
|
+
"message": base_message
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
def _primary_remote_url(self, node, path: str) -> str:
|
|
731
|
+
return next(self._iter_remote_urls(node, path), "")
|
|
732
|
+
|
|
733
|
+
def _request_remote(self, node, path: str, request_callable):
|
|
734
|
+
errors: list[str] = []
|
|
735
|
+
for url in self._iter_remote_urls(node, path):
|
|
736
|
+
try:
|
|
737
|
+
response = request_callable(url)
|
|
738
|
+
except RequestException as exc:
|
|
739
|
+
errors.append(f"{url}: {exc}")
|
|
644
740
|
continue
|
|
741
|
+
return url, response, errors
|
|
742
|
+
return "", None, errors
|
|
645
743
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
_, _, path_tail = remainder.partition("/")
|
|
662
|
-
base_path = f"/{path_tail}".rstrip("/")
|
|
663
|
-
formatted_host = f"[{core_host}]"
|
|
664
|
-
else:
|
|
665
|
-
if "/" in host:
|
|
666
|
-
host_only, _, path_tail = host.partition("/")
|
|
667
|
-
formatted_host = host_only or host
|
|
668
|
-
base_path = f"/{path_tail}".rstrip("/")
|
|
669
|
-
try:
|
|
670
|
-
ip_obj = ipaddress.ip_address(formatted_host)
|
|
671
|
-
except ValueError:
|
|
672
|
-
parts = formatted_host.rsplit(":", 1)
|
|
673
|
-
if len(parts) == 2 and parts[1].isdigit():
|
|
674
|
-
formatted_host = parts[0]
|
|
675
|
-
port_override = int(parts[1])
|
|
676
|
-
try:
|
|
677
|
-
ip_obj = ipaddress.ip_address(formatted_host)
|
|
678
|
-
except ValueError:
|
|
679
|
-
ip_obj = None
|
|
680
|
-
else:
|
|
681
|
-
if ip_obj.version == 6 and not formatted_host.startswith("["):
|
|
682
|
-
formatted_host = f"[{formatted_host}]"
|
|
683
|
-
|
|
684
|
-
effective_port = port_override if port_override is not None else default_port
|
|
685
|
-
combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
686
|
-
|
|
687
|
-
for scheme, scheme_default_port in (("https", 443), ("http", 80)):
|
|
688
|
-
base = f"{scheme}://{formatted_host}"
|
|
689
|
-
if effective_port and (
|
|
690
|
-
port_override is not None or effective_port != scheme_default_port
|
|
691
|
-
):
|
|
692
|
-
explicit = f"{base}:{effective_port}{combined_path}"
|
|
693
|
-
if explicit not in seen:
|
|
694
|
-
seen.add(explicit)
|
|
695
|
-
yield explicit
|
|
696
|
-
candidate = f"{base}{combined_path}"
|
|
697
|
-
if candidate not in seen:
|
|
698
|
-
seen.add(candidate)
|
|
699
|
-
yield candidate
|
|
744
|
+
def _iter_remote_urls(self, node, path):
|
|
745
|
+
if hasattr(node, "iter_remote_urls"):
|
|
746
|
+
yield from node.iter_remote_urls(path)
|
|
747
|
+
return
|
|
748
|
+
|
|
749
|
+
temp = Node(
|
|
750
|
+
public_endpoint=getattr(node, "public_endpoint", ""),
|
|
751
|
+
address=getattr(node, "address", ""),
|
|
752
|
+
hostname=getattr(node, "hostname", ""),
|
|
753
|
+
port=getattr(node, "port", None),
|
|
754
|
+
)
|
|
755
|
+
temp.network_hostname = getattr(node, "network_hostname", "")
|
|
756
|
+
temp.ipv4_address = getattr(node, "ipv4_address", "")
|
|
757
|
+
temp.ipv6_address = getattr(node, "ipv6_address", "")
|
|
758
|
+
yield from temp.iter_remote_urls(path)
|
|
700
759
|
|
|
701
760
|
def register_visitor_view(self, request):
|
|
702
761
|
"""Exchange registration data with the visiting node."""
|
|
@@ -770,11 +829,28 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
770
829
|
for node in queryset:
|
|
771
830
|
for source in sources:
|
|
772
831
|
try:
|
|
773
|
-
|
|
832
|
+
contact_host = node.get_primary_contact()
|
|
833
|
+
url = source.format(
|
|
834
|
+
node=node, address=contact_host, port=node.port
|
|
835
|
+
)
|
|
774
836
|
except Exception:
|
|
775
837
|
url = source
|
|
776
838
|
if not url.startswith("http"):
|
|
777
|
-
|
|
839
|
+
candidate = next(
|
|
840
|
+
self._iter_remote_urls(node, url),
|
|
841
|
+
"",
|
|
842
|
+
)
|
|
843
|
+
if not candidate:
|
|
844
|
+
self.message_user(
|
|
845
|
+
request,
|
|
846
|
+
_(
|
|
847
|
+
"No reachable host was available for %(node)s while generating %(path)s"
|
|
848
|
+
)
|
|
849
|
+
% {"node": node, "path": url},
|
|
850
|
+
messages.WARNING,
|
|
851
|
+
)
|
|
852
|
+
continue
|
|
853
|
+
url = candidate
|
|
778
854
|
try:
|
|
779
855
|
path = capture_screenshot(url)
|
|
780
856
|
except Exception as exc: # pragma: no cover - selenium issues
|
|
@@ -894,12 +970,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
894
970
|
|
|
895
971
|
def _process_import_from_node(self, node, payload, headers):
|
|
896
972
|
result = self._init_rfid_result(node)
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
973
|
+
_, response, attempt_errors = self._request_remote(
|
|
974
|
+
node,
|
|
975
|
+
"/nodes/rfid/export/",
|
|
976
|
+
lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
|
|
977
|
+
)
|
|
978
|
+
if response is None:
|
|
901
979
|
result["status"] = "error"
|
|
902
|
-
|
|
980
|
+
if attempt_errors:
|
|
981
|
+
result["errors"].extend(attempt_errors)
|
|
982
|
+
else:
|
|
983
|
+
result["errors"].append(
|
|
984
|
+
_("No remote hosts were available for %(node)s.") % {"node": node}
|
|
985
|
+
)
|
|
903
986
|
return result
|
|
904
987
|
|
|
905
988
|
if response.status_code != 200:
|
|
@@ -939,12 +1022,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
939
1022
|
|
|
940
1023
|
def _post_export_to_node(self, node, payload, headers):
|
|
941
1024
|
result = self._init_rfid_result(node)
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1025
|
+
_, response, attempt_errors = self._request_remote(
|
|
1026
|
+
node,
|
|
1027
|
+
"/nodes/rfid/import/",
|
|
1028
|
+
lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
|
|
1029
|
+
)
|
|
1030
|
+
if response is None:
|
|
946
1031
|
result["status"] = "error"
|
|
947
|
-
|
|
1032
|
+
if attempt_errors:
|
|
1033
|
+
result["errors"].extend(attempt_errors)
|
|
1034
|
+
else:
|
|
1035
|
+
result["errors"].append(
|
|
1036
|
+
_("No remote hosts were available for %(node)s.") % {"node": node}
|
|
1037
|
+
)
|
|
948
1038
|
return result
|
|
949
1039
|
|
|
950
1040
|
if response.status_code != 200:
|
|
@@ -1060,155 +1150,258 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
1060
1150
|
|
|
1061
1151
|
return self._render_rfid_sync(request, "export", results)
|
|
1062
1152
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1153
|
+
async def _probe_websocket(self, url: str) -> bool:
|
|
1154
|
+
try:
|
|
1155
|
+
async with websockets.connect(url, open_timeout=3, close_timeout=1):
|
|
1156
|
+
return True
|
|
1157
|
+
except Exception:
|
|
1158
|
+
return False
|
|
1159
|
+
|
|
1160
|
+
def _attempt_forwarding_probe(self, node, charger_id: str) -> bool:
|
|
1161
|
+
if not charger_id:
|
|
1162
|
+
return False
|
|
1163
|
+
safe_id = quote(str(charger_id))
|
|
1164
|
+
candidates: list[str] = []
|
|
1165
|
+
for base in node.iter_remote_urls("/"):
|
|
1166
|
+
parsed = urlsplit(base)
|
|
1167
|
+
if parsed.scheme not in {"http", "https"}:
|
|
1168
|
+
continue
|
|
1169
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
1170
|
+
base_path = parsed.path.rstrip("/")
|
|
1171
|
+
for prefix in ("", "/ws"):
|
|
1172
|
+
path = f"{base_path}{prefix}/{safe_id}".replace("//", "/")
|
|
1173
|
+
if not path.startswith("/"):
|
|
1174
|
+
path = f"/{path}"
|
|
1175
|
+
candidates.append(urlunsplit((scheme, parsed.netloc, path, "", "")))
|
|
1176
|
+
|
|
1177
|
+
for url in candidates:
|
|
1178
|
+
loop = asyncio.new_event_loop()
|
|
1179
|
+
try:
|
|
1180
|
+
result = loop.run_until_complete(self._probe_websocket(url))
|
|
1181
|
+
except Exception:
|
|
1182
|
+
result = False
|
|
1183
|
+
finally:
|
|
1184
|
+
loop.close()
|
|
1185
|
+
if result:
|
|
1186
|
+
return True
|
|
1187
|
+
return False
|
|
1188
|
+
|
|
1189
|
+
def _send_forwarding_metadata(
|
|
1190
|
+
self,
|
|
1191
|
+
request,
|
|
1192
|
+
node: Node,
|
|
1193
|
+
chargers: list[Charger],
|
|
1194
|
+
local_node: Node,
|
|
1195
|
+
private_key,
|
|
1196
|
+
) -> bool:
|
|
1197
|
+
if not chargers:
|
|
1198
|
+
return True
|
|
1199
|
+
payload = {
|
|
1200
|
+
"requester": str(local_node.uuid),
|
|
1201
|
+
"requester_mac": local_node.mac_address,
|
|
1202
|
+
"requester_public_key": local_node.public_key,
|
|
1203
|
+
"chargers": [serialize_charger_for_network(charger) for charger in chargers],
|
|
1204
|
+
"transactions": {"chargers": [], "transactions": []},
|
|
1205
|
+
}
|
|
1206
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
1207
|
+
signature = self._sign_payload(private_key, payload_json)
|
|
1208
|
+
headers = {"Content-Type": "application/json"}
|
|
1209
|
+
if signature:
|
|
1210
|
+
headers["X-Signature"] = signature
|
|
1211
|
+
|
|
1212
|
+
url = next(node.iter_remote_urls("/nodes/network/chargers/forward/"), "")
|
|
1213
|
+
if not url:
|
|
1214
|
+
self.message_user(
|
|
1215
|
+
request,
|
|
1216
|
+
_("No reachable host found for %(node)s.") % {"node": node},
|
|
1217
|
+
level=messages.WARNING,
|
|
1218
|
+
)
|
|
1219
|
+
return False
|
|
1220
|
+
try:
|
|
1221
|
+
response = requests.post(url, data=payload_json, headers=headers, timeout=5)
|
|
1222
|
+
except RequestException as exc:
|
|
1223
|
+
self.message_user(
|
|
1224
|
+
request,
|
|
1225
|
+
_("Failed to send forwarding metadata to %(node)s (%(error)s).")
|
|
1226
|
+
% {"node": node, "error": exc},
|
|
1227
|
+
level=messages.WARNING,
|
|
1228
|
+
)
|
|
1229
|
+
return False
|
|
1230
|
+
|
|
1231
|
+
try:
|
|
1232
|
+
data = response.json()
|
|
1233
|
+
except ValueError:
|
|
1234
|
+
data = {}
|
|
1235
|
+
|
|
1236
|
+
if not response.ok or not isinstance(data, dict) or data.get("status") != "ok":
|
|
1237
|
+
detail = ""
|
|
1238
|
+
if isinstance(data, dict):
|
|
1239
|
+
detail = data.get("detail") or ""
|
|
1240
|
+
message = _("Forwarding metadata to %(node)s failed: %(status)s %(detail)s") % {
|
|
1241
|
+
"node": node,
|
|
1242
|
+
"status": response.status_code,
|
|
1243
|
+
"detail": detail,
|
|
1244
|
+
}
|
|
1245
|
+
self.message_user(request, message.strip(), level=messages.WARNING)
|
|
1246
|
+
return False
|
|
1247
|
+
|
|
1248
|
+
return True
|
|
1249
|
+
|
|
1250
|
+
@admin.action(description=_("Start Charge Point Forwarding"))
|
|
1251
|
+
def start_charge_point_forwarding(self, request, queryset):
|
|
1252
|
+
if queryset.count() != 1:
|
|
1253
|
+
self.message_user(
|
|
1254
|
+
request,
|
|
1255
|
+
_("Select a single remote node."),
|
|
1256
|
+
level=messages.ERROR,
|
|
1257
|
+
)
|
|
1258
|
+
return
|
|
1259
|
+
|
|
1260
|
+
target = queryset.first()
|
|
1065
1261
|
local_node, private_key, error = self._load_local_node_credentials()
|
|
1066
1262
|
if error:
|
|
1067
1263
|
self.message_user(request, error, level=messages.ERROR)
|
|
1068
1264
|
return
|
|
1069
1265
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1266
|
+
if local_node.pk and target.pk == local_node.pk:
|
|
1267
|
+
self.message_user(
|
|
1268
|
+
request,
|
|
1269
|
+
_("Cannot forward charge points to the local node."),
|
|
1270
|
+
level=messages.ERROR,
|
|
1271
|
+
)
|
|
1073
1272
|
return
|
|
1074
1273
|
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
signature = self._sign_payload(private_key, payload)
|
|
1081
|
-
headers = {
|
|
1082
|
-
"Content-Type": "application/json",
|
|
1083
|
-
"X-Signature": signature,
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
created = 0
|
|
1087
|
-
updated = 0
|
|
1088
|
-
errors: list[str] = []
|
|
1274
|
+
eligible = Charger.objects.filter(export_transactions=True)
|
|
1275
|
+
if local_node.pk:
|
|
1276
|
+
eligible = eligible.filter(
|
|
1277
|
+
Q(node_origin=local_node) | Q(node_origin__isnull=True)
|
|
1278
|
+
)
|
|
1089
1279
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1280
|
+
chargers = list(eligible.select_related("forwarded_to"))
|
|
1281
|
+
if not chargers:
|
|
1282
|
+
self.message_user(
|
|
1283
|
+
request,
|
|
1284
|
+
_("No eligible charge points available for forwarding."),
|
|
1285
|
+
level=messages.WARNING,
|
|
1286
|
+
)
|
|
1287
|
+
return
|
|
1097
1288
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1289
|
+
conflicts = [
|
|
1290
|
+
charger
|
|
1291
|
+
for charger in chargers
|
|
1292
|
+
if charger.forwarded_to_id
|
|
1293
|
+
and charger.forwarded_to_id not in {None, target.pk}
|
|
1294
|
+
]
|
|
1295
|
+
if conflicts:
|
|
1296
|
+
self.message_user(
|
|
1297
|
+
request,
|
|
1298
|
+
ngettext(
|
|
1299
|
+
"Skipped %(count)s charge point already forwarded to another node.",
|
|
1300
|
+
"Skipped %(count)s charge points already forwarded to another node.",
|
|
1301
|
+
len(conflicts),
|
|
1302
|
+
)
|
|
1303
|
+
% {"count": len(conflicts)},
|
|
1304
|
+
level=messages.WARNING,
|
|
1305
|
+
)
|
|
1101
1306
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1307
|
+
chargers_to_update = [
|
|
1308
|
+
charger
|
|
1309
|
+
for charger in chargers
|
|
1310
|
+
if charger.forwarded_to_id in (None, target.pk)
|
|
1311
|
+
]
|
|
1312
|
+
if not chargers_to_update:
|
|
1313
|
+
self.message_user(
|
|
1314
|
+
request,
|
|
1315
|
+
_("No charge points were updated."),
|
|
1316
|
+
level=messages.WARNING,
|
|
1317
|
+
)
|
|
1318
|
+
return
|
|
1107
1319
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
if applied == "created":
|
|
1111
|
-
created += 1
|
|
1112
|
-
elif applied == "updated":
|
|
1113
|
-
updated += 1
|
|
1320
|
+
charger_pks = [c.pk for c in chargers_to_update]
|
|
1321
|
+
Charger.objects.filter(pk__in=charger_pks).update(forwarded_to=target)
|
|
1114
1322
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
"created": created,
|
|
1118
|
-
"updated": updated,
|
|
1119
|
-
}
|
|
1120
|
-
self.message_user(request, summary, level=messages.SUCCESS)
|
|
1121
|
-
if errors:
|
|
1122
|
-
for error in errors:
|
|
1123
|
-
self.message_user(request, error, level=messages.ERROR)
|
|
1124
|
-
|
|
1125
|
-
def _apply_remote_charger_payload(self, node, payload: Mapping) -> str | None:
|
|
1126
|
-
serial = Charger.normalize_serial(payload.get("charger_id"))
|
|
1127
|
-
if not serial or Charger.is_placeholder_serial(serial):
|
|
1128
|
-
return None
|
|
1323
|
+
for charger in chargers_to_update:
|
|
1324
|
+
charger.forwarded_to = target
|
|
1129
1325
|
|
|
1130
|
-
|
|
1131
|
-
if
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1326
|
+
sample = next((charger for charger in chargers_to_update if charger.charger_id), None)
|
|
1327
|
+
if sample and not self._attempt_forwarding_probe(target, sample.charger_id):
|
|
1328
|
+
self.message_user(
|
|
1329
|
+
request,
|
|
1330
|
+
_(
|
|
1331
|
+
"Unable to establish a websocket connection to %(node)s for charge point %(charger)s."
|
|
1332
|
+
)
|
|
1333
|
+
% {"node": target, "charger": sample.charger_id},
|
|
1334
|
+
level=messages.WARNING,
|
|
1335
|
+
)
|
|
1138
1336
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
connector_id=connector_value,
|
|
1337
|
+
success = self._send_forwarding_metadata(
|
|
1338
|
+
request, target, chargers_to_update, local_node, private_key
|
|
1142
1339
|
)
|
|
1143
1340
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
"
|
|
1153
|
-
"
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
updates: dict[str, object] = {
|
|
1172
|
-
"node_origin": node,
|
|
1173
|
-
"allow_remote": bool(payload.get("allow_remote", False)),
|
|
1174
|
-
"export_transactions": bool(payload.get("export_transactions", False)),
|
|
1175
|
-
"last_online_at": timezone.now(),
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
simple_fields = [
|
|
1179
|
-
"display_name",
|
|
1180
|
-
"language",
|
|
1181
|
-
"public_display",
|
|
1182
|
-
"require_rfid",
|
|
1183
|
-
"firmware_status",
|
|
1184
|
-
"firmware_status_info",
|
|
1185
|
-
"last_status",
|
|
1186
|
-
"last_error_code",
|
|
1187
|
-
"last_status_vendor_info",
|
|
1188
|
-
"availability_state",
|
|
1189
|
-
"availability_requested_state",
|
|
1190
|
-
"availability_request_status",
|
|
1191
|
-
"availability_request_details",
|
|
1192
|
-
"temperature",
|
|
1193
|
-
"temperature_unit",
|
|
1194
|
-
"diagnostics_status",
|
|
1195
|
-
"diagnostics_location",
|
|
1196
|
-
]
|
|
1197
|
-
for field in simple_fields:
|
|
1198
|
-
updates[field] = payload.get(field)
|
|
1341
|
+
if success:
|
|
1342
|
+
now = timezone.now()
|
|
1343
|
+
Charger.objects.filter(pk__in=charger_pks).update(
|
|
1344
|
+
forwarding_watermark=now
|
|
1345
|
+
)
|
|
1346
|
+
self.message_user(
|
|
1347
|
+
request,
|
|
1348
|
+
ngettext(
|
|
1349
|
+
"Forwarding enabled for %(count)s charge point.",
|
|
1350
|
+
"Forwarding enabled for %(count)s charge points.",
|
|
1351
|
+
len(chargers_to_update),
|
|
1352
|
+
)
|
|
1353
|
+
% {"count": len(chargers_to_update)},
|
|
1354
|
+
level=messages.SUCCESS,
|
|
1355
|
+
)
|
|
1356
|
+
else:
|
|
1357
|
+
self.message_user(
|
|
1358
|
+
request,
|
|
1359
|
+
ngettext(
|
|
1360
|
+
"Marked %(count)s charge point for forwarding; awaiting remote acknowledgment.",
|
|
1361
|
+
"Marked %(count)s charge points for forwarding; awaiting remote acknowledgment.",
|
|
1362
|
+
len(chargers_to_update),
|
|
1363
|
+
)
|
|
1364
|
+
% {"count": len(chargers_to_update)},
|
|
1365
|
+
level=messages.INFO,
|
|
1366
|
+
)
|
|
1199
1367
|
|
|
1200
|
-
|
|
1201
|
-
|
|
1368
|
+
try:
|
|
1369
|
+
push_forwarded_charge_points.delay()
|
|
1370
|
+
except Exception:
|
|
1371
|
+
pass
|
|
1202
1372
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1373
|
+
@admin.action(description=_("Stop Charge Point Forwarding"))
|
|
1374
|
+
def stop_charge_point_forwarding(self, request, queryset):
|
|
1375
|
+
node_ids = [node.pk for node in queryset if node.pk]
|
|
1376
|
+
if not node_ids:
|
|
1377
|
+
self.message_user(
|
|
1378
|
+
request,
|
|
1379
|
+
_("No remote nodes selected."),
|
|
1380
|
+
level=messages.WARNING,
|
|
1381
|
+
)
|
|
1382
|
+
return
|
|
1206
1383
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1384
|
+
cleared = Charger.objects.filter(forwarded_to_id__in=node_ids).update(
|
|
1385
|
+
forwarded_to=None, forwarding_watermark=None
|
|
1386
|
+
)
|
|
1209
1387
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1388
|
+
if cleared:
|
|
1389
|
+
self.message_user(
|
|
1390
|
+
request,
|
|
1391
|
+
ngettext(
|
|
1392
|
+
"Stopped forwarding for %(count)s charge point.",
|
|
1393
|
+
"Stopped forwarding for %(count)s charge points.",
|
|
1394
|
+
cleared,
|
|
1395
|
+
)
|
|
1396
|
+
% {"count": cleared},
|
|
1397
|
+
level=messages.SUCCESS,
|
|
1398
|
+
)
|
|
1399
|
+
else:
|
|
1400
|
+
self.message_user(
|
|
1401
|
+
request,
|
|
1402
|
+
_("No forwarded charge points were updated."),
|
|
1403
|
+
level=messages.WARNING,
|
|
1404
|
+
)
|
|
1212
1405
|
|
|
1213
1406
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
1214
1407
|
extra_context = extra_context or {}
|