arthexis 0.1.23__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.

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 urljoin, urlparse, urlsplit, urlunsplit
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,6 +61,9 @@ from .models import (
60
61
  )
61
62
  from . import dns as dns_utils
62
63
  from core.models import RFID
64
+ from ocpp.models import Charger
65
+ from ocpp.network import serialize_charger_for_network
66
+ from ocpp.tasks import push_forwarded_charge_points
63
67
  from core.user_data import EntityModelAdmin
64
68
 
65
69
 
@@ -230,34 +234,37 @@ class DNSRecordAdmin(EntityModelAdmin):
230
234
  class NodeAdmin(EntityModelAdmin):
231
235
  list_display = (
232
236
  "hostname",
233
- "mac_address",
234
- "address",
237
+ "network_hostname",
238
+ "ipv4_address",
239
+ "ipv6_address",
235
240
  "port",
236
241
  "role",
237
242
  "relation",
238
243
  "last_seen",
239
244
  "visit_link",
240
- "proxy_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
- _("Node"),
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,25 +294,43 @@ class NodeAdmin(EntityModelAdmin):
287
294
  "register_visitor",
288
295
  "run_task",
289
296
  "take_screenshots",
297
+ "start_charge_point_forwarding",
298
+ "stop_charge_point_forwarding",
290
299
  "import_rfids_from_selected",
291
300
  "export_rfids_to_selected",
301
+ "send_net_message",
292
302
  ]
293
303
  inlines = [NodeFeatureAssignmentInline]
294
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
+
295
330
  @admin.display(description=_("Relation"), ordering="current_relation")
296
331
  def relation(self, obj):
297
332
  return obj.get_current_relation_display()
298
333
 
299
- @admin.display(description=_("Proxy"))
300
- def proxy_link(self, obj):
301
- if not obj or obj.is_local:
302
- return ""
303
- try:
304
- url = reverse("admin:nodes_node_proxy", args=[obj.pk])
305
- except NoReverseMatch:
306
- return ""
307
- return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
308
-
309
334
  @admin.display(description=_("Visit"))
310
335
  def visit_link(self, obj):
311
336
  if not obj:
@@ -321,12 +346,7 @@ class NodeAdmin(EntityModelAdmin):
321
346
  _("Visit"),
322
347
  )
323
348
 
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)
349
+ host_values = obj.get_remote_host_candidates()
330
350
 
331
351
  remote_url = ""
332
352
  for host in host_values:
@@ -372,11 +392,6 @@ class NodeAdmin(EntityModelAdmin):
372
392
  self.admin_site.admin_view(self.update_selected_progress),
373
393
  name="nodes_node_update_selected_progress",
374
394
  ),
375
- path(
376
- "<int:node_id>/proxy/",
377
- self.admin_site.admin_view(self.proxy_node),
378
- name="nodes_node_proxy",
379
- ),
380
395
  ]
381
396
  return custom + urls
382
397
 
@@ -409,162 +424,6 @@ class NodeAdmin(EntityModelAdmin):
409
424
  )
410
425
  return response
411
426
 
412
- def _load_local_private_key(self, node):
413
- security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
414
- priv_path = security_dir / f"{node.public_endpoint}"
415
- if not priv_path.exists():
416
- return None, _("Local node private key not found.")
417
- try:
418
- return (
419
- serialization.load_pem_private_key(
420
- priv_path.read_bytes(), password=None
421
- ),
422
- "",
423
- )
424
- except Exception as exc: # pragma: no cover - unexpected errors
425
- return None, str(exc)
426
-
427
- def _build_proxy_payload(self, request, local_node):
428
- user = request.user
429
- payload = {
430
- "requester": str(local_node.uuid),
431
- "user": {
432
- "username": user.get_username(),
433
- "email": user.email or "",
434
- "first_name": user.first_name or "",
435
- "last_name": user.last_name or "",
436
- "is_staff": user.is_staff,
437
- "is_superuser": user.is_superuser,
438
- "groups": list(user.groups.values_list("name", flat=True)),
439
- "permissions": sorted(user.get_all_permissions()),
440
- },
441
- "target": reverse("admin:index"),
442
- }
443
- mac_address = str(local_node.mac_address or "").strip()
444
- if mac_address:
445
- payload["requester_mac"] = mac_address
446
- public_key = local_node.public_key
447
- if public_key:
448
- payload["requester_public_key"] = public_key
449
- return payload
450
-
451
- def _start_proxy_session(self, request, node):
452
- if node.is_local:
453
- return {"ok": False, "message": _("Local node cannot be proxied.")}
454
-
455
- local_node = Node.get_local()
456
- if local_node is None:
457
- try:
458
- local_node, _ = Node.register_current()
459
- except Exception as exc: # pragma: no cover - unexpected errors
460
- return {"ok": False, "message": str(exc)}
461
-
462
- private_key, error = self._load_local_private_key(local_node)
463
- if private_key is None:
464
- return {"ok": False, "message": error}
465
-
466
- payload = self._build_proxy_payload(request, local_node)
467
- body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
468
- try:
469
- signature = private_key.sign(
470
- body.encode(),
471
- padding.PKCS1v15(),
472
- hashes.SHA256(),
473
- )
474
- except Exception as exc: # pragma: no cover - unexpected errors
475
- return {"ok": False, "message": str(exc)}
476
-
477
- headers = {
478
- "Content-Type": "application/json",
479
- "X-Signature": base64.b64encode(signature).decode(),
480
- }
481
-
482
- last_error = ""
483
- redirect_codes = {301, 302, 303, 307, 308}
484
-
485
- for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
486
- candidate_url = url
487
- redirects_followed = 0
488
- success = False
489
-
490
- while True:
491
- try:
492
- response = requests.post(
493
- candidate_url,
494
- data=body,
495
- headers=headers,
496
- timeout=5,
497
- allow_redirects=False,
498
- )
499
- except RequestException as exc:
500
- last_error = str(exc)
501
- break
502
-
503
- if response.status_code in redirect_codes:
504
- location = response.headers.get("Location")
505
- if not location:
506
- last_error = f"{response.status_code} redirect missing Location header"
507
- break
508
-
509
- redirects_followed += 1
510
- if redirects_followed > 3:
511
- last_error = "Too many redirects"
512
- break
513
-
514
- candidate_url = urljoin(candidate_url, location)
515
- continue
516
-
517
- if not response.ok:
518
- last_error = f"{response.status_code} {response.text}"
519
- break
520
-
521
- try:
522
- data = response.json()
523
- except ValueError:
524
- last_error = "Invalid JSON response"
525
- break
526
-
527
- login_url = data.get("login_url")
528
- if not login_url:
529
- last_error = "login_url missing"
530
- break
531
-
532
- success = True
533
- break
534
-
535
- if success:
536
- return {
537
- "ok": True,
538
- "login_url": login_url,
539
- "expires": data.get("expires"),
540
- }
541
-
542
- return {
543
- "ok": False,
544
- "message": last_error or "Unable to initiate proxy.",
545
- }
546
-
547
- def proxy_node(self, request, node_id):
548
- node = self.get_queryset(request).filter(pk=node_id).first()
549
- if not node:
550
- raise Http404
551
- if not self.has_view_permission(request):
552
- raise PermissionDenied
553
- result = self._start_proxy_session(request, node)
554
- if not result.get("ok"):
555
- message = result.get("message") or _("Unable to proxy node.")
556
- self.message_user(request, message, messages.ERROR)
557
- return redirect("admin:nodes_node_changelist")
558
-
559
- context = {
560
- **self.admin_site.each_context(request),
561
- "opts": self.model._meta,
562
- "node": node,
563
- "frame_url": result.get("login_url"),
564
- "expires": result.get("expires"),
565
- }
566
- return TemplateResponse(request, "admin/nodes/node/proxy.html", context)
567
-
568
427
  @admin.action(description="Register Visitor")
569
428
  def register_visitor(self, request, queryset=None):
570
429
  return self.register_visitor_view(request)
@@ -587,6 +446,75 @@ class NodeAdmin(EntityModelAdmin):
587
446
  request, "admin/nodes/node/update_selected.html", context
588
447
  )
589
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
+
590
518
  def update_selected_progress(self, request):
591
519
  if request.method != "POST":
592
520
  return JsonResponse({"detail": "POST required"}, status=405)
@@ -631,6 +559,7 @@ class NodeAdmin(EntityModelAdmin):
631
559
  }
632
560
 
633
561
  last_error = ""
562
+ host_candidates = node.get_remote_host_candidates()
634
563
  for url in self._iter_remote_urls(node, "/nodes/info/"):
635
564
  try:
636
565
  response = requests.get(url, timeout=5)
@@ -657,13 +586,19 @@ class NodeAdmin(EntityModelAdmin):
657
586
  "updated_fields": updated,
658
587
  "message": message,
659
588
  }
660
- return {"ok": False, "message": last_error or "Unable to reach remote node."}
589
+ return {
590
+ "ok": False,
591
+ "message": self._build_connectivity_hint(last_error, host_candidates),
592
+ }
661
593
 
662
594
  def _apply_remote_node_info(self, node, payload):
663
595
  changed = []
664
596
  field_map = {
665
597
  "hostname": payload.get("hostname"),
598
+ "network_hostname": payload.get("network_hostname"),
666
599
  "address": payload.get("address"),
600
+ "ipv4_address": payload.get("ipv4_address"),
601
+ "ipv6_address": payload.get("ipv6_address"),
667
602
  "public_key": payload.get("public_key"),
668
603
  }
669
604
  port_value = payload.get("port")
@@ -741,7 +676,10 @@ class NodeAdmin(EntityModelAdmin):
741
676
 
742
677
  payload = {
743
678
  "hostname": local_node.hostname,
679
+ "network_hostname": local_node.network_hostname,
744
680
  "address": local_node.address,
681
+ "ipv4_address": local_node.ipv4_address,
682
+ "ipv6_address": local_node.ipv6_address,
745
683
  "port": local_node.port,
746
684
  "mac_address": local_node.mac_address,
747
685
  "public_key": local_node.public_key,
@@ -757,6 +695,7 @@ class NodeAdmin(EntityModelAdmin):
757
695
  headers = {"Content-Type": "application/json"}
758
696
 
759
697
  last_error = ""
698
+ host_candidates = node.get_remote_host_candidates()
760
699
  for url in self._iter_remote_urls(node, "/nodes/register/"):
761
700
  try:
762
701
  response = requests.post(
@@ -771,102 +710,52 @@ class NodeAdmin(EntityModelAdmin):
771
710
  if response.ok:
772
711
  return {"ok": True, "url": url, "message": "Remote updated."}
773
712
  last_error = f"{response.status_code} {response.text}"
774
- return {"ok": False, "message": last_error or "Unable to reach remote node."}
713
+ return {
714
+ "ok": False,
715
+ "message": self._build_connectivity_hint(last_error, host_candidates),
716
+ }
775
717
 
776
- def _iter_remote_urls(self, node, path):
777
- host_candidates: list[str] = []
778
- for attr in ("public_endpoint", "address", "hostname"):
779
- value = getattr(node, attr, "") or ""
780
- cleaned = value.strip()
781
- if cleaned and cleaned not in host_candidates:
782
- host_candidates.append(cleaned)
783
-
784
- default_port = node.port or 8000
785
- normalized_path = path if path.startswith("/") else f"/{path}"
786
- seen: set[str] = set()
787
-
788
- for host in host_candidates:
789
- base_path = ""
790
- formatted_host = host
791
- port_override: int | None = None
792
-
793
- if "://" in host:
794
- parsed = urlparse(host)
795
- netloc = parsed.netloc or parsed.path
796
- base_path = (parsed.path or "").rstrip("/")
797
- combined_path = (
798
- f"{base_path}{normalized_path}" if base_path else normalized_path
799
- )
800
- primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
801
- if primary not in seen:
802
- seen.add(primary)
803
- yield primary
804
- if parsed.scheme == "https":
805
- fallback = urlunsplit(("http", netloc, combined_path, "", ""))
806
- if fallback not in seen:
807
- seen.add(fallback)
808
- yield fallback
809
- elif parsed.scheme == "http":
810
- alternate = urlunsplit(("https", netloc, combined_path, "", ""))
811
- if alternate not in seen:
812
- seen.add(alternate)
813
- 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}")
814
740
  continue
741
+ return url, response, errors
742
+ return "", None, errors
815
743
 
816
- if host.startswith("[") and "]" in host:
817
- end = host.index("]")
818
- core_host = host[1:end]
819
- remainder = host[end + 1 :]
820
- if remainder.startswith(":"):
821
- remainder = remainder[1:]
822
- port_part, sep, path_tail = remainder.partition("/")
823
- if port_part:
824
- try:
825
- port_override = int(port_part)
826
- except ValueError:
827
- port_override = None
828
- if sep:
829
- base_path = f"/{path_tail}".rstrip("/")
830
- elif "/" in remainder:
831
- _, _, path_tail = remainder.partition("/")
832
- base_path = f"/{path_tail}".rstrip("/")
833
- formatted_host = f"[{core_host}]"
834
- else:
835
- if "/" in host:
836
- host_only, _, path_tail = host.partition("/")
837
- formatted_host = host_only or host
838
- base_path = f"/{path_tail}".rstrip("/")
839
- try:
840
- ip_obj = ipaddress.ip_address(formatted_host)
841
- except ValueError:
842
- parts = formatted_host.rsplit(":", 1)
843
- if len(parts) == 2 and parts[1].isdigit():
844
- formatted_host = parts[0]
845
- port_override = int(parts[1])
846
- try:
847
- ip_obj = ipaddress.ip_address(formatted_host)
848
- except ValueError:
849
- ip_obj = None
850
- else:
851
- if ip_obj.version == 6 and not formatted_host.startswith("["):
852
- formatted_host = f"[{formatted_host}]"
853
-
854
- effective_port = port_override if port_override is not None else default_port
855
- combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
856
-
857
- for scheme, scheme_default_port in (("https", 443), ("http", 80)):
858
- base = f"{scheme}://{formatted_host}"
859
- if effective_port and (
860
- port_override is not None or effective_port != scheme_default_port
861
- ):
862
- explicit = f"{base}:{effective_port}{combined_path}"
863
- if explicit not in seen:
864
- seen.add(explicit)
865
- yield explicit
866
- candidate = f"{base}{combined_path}"
867
- if candidate not in seen:
868
- seen.add(candidate)
869
- 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)
870
759
 
871
760
  def register_visitor_view(self, request):
872
761
  """Exchange registration data with the visiting node."""
@@ -940,11 +829,28 @@ class NodeAdmin(EntityModelAdmin):
940
829
  for node in queryset:
941
830
  for source in sources:
942
831
  try:
943
- url = source.format(node=node, address=node.address, port=node.port)
832
+ contact_host = node.get_primary_contact()
833
+ url = source.format(
834
+ node=node, address=contact_host, port=node.port
835
+ )
944
836
  except Exception:
945
837
  url = source
946
838
  if not url.startswith("http"):
947
- url = f"http://{node.address}:{node.port}{url}"
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
948
854
  try:
949
855
  path = capture_screenshot(url)
950
856
  except Exception as exc: # pragma: no cover - selenium issues
@@ -1064,12 +970,19 @@ class NodeAdmin(EntityModelAdmin):
1064
970
 
1065
971
  def _process_import_from_node(self, node, payload, headers):
1066
972
  result = self._init_rfid_result(node)
1067
- url = f"http://{node.address}:{node.port}/nodes/rfid/export/"
1068
- try:
1069
- response = requests.post(url, data=payload, headers=headers, timeout=5)
1070
- except RequestException as exc:
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:
1071
979
  result["status"] = "error"
1072
- result["errors"].append(str(exc))
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
+ )
1073
986
  return result
1074
987
 
1075
988
  if response.status_code != 200:
@@ -1109,12 +1022,19 @@ class NodeAdmin(EntityModelAdmin):
1109
1022
 
1110
1023
  def _post_export_to_node(self, node, payload, headers):
1111
1024
  result = self._init_rfid_result(node)
1112
- url = f"http://{node.address}:{node.port}/nodes/rfid/import/"
1113
- try:
1114
- response = requests.post(url, data=payload, headers=headers, timeout=5)
1115
- except RequestException as exc:
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:
1116
1031
  result["status"] = "error"
1117
- result["errors"].append(str(exc))
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
+ )
1118
1038
  return result
1119
1039
 
1120
1040
  if response.status_code != 200:
@@ -1230,6 +1150,259 @@ class NodeAdmin(EntityModelAdmin):
1230
1150
 
1231
1151
  return self._render_rfid_sync(request, "export", results)
1232
1152
 
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()
1261
+ local_node, private_key, error = self._load_local_node_credentials()
1262
+ if error:
1263
+ self.message_user(request, error, level=messages.ERROR)
1264
+ return
1265
+
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
+ )
1272
+ return
1273
+
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
+ )
1279
+
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
1288
+
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
+ )
1306
+
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
1319
+
1320
+ charger_pks = [c.pk for c in chargers_to_update]
1321
+ Charger.objects.filter(pk__in=charger_pks).update(forwarded_to=target)
1322
+
1323
+ for charger in chargers_to_update:
1324
+ charger.forwarded_to = target
1325
+
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
+ )
1336
+
1337
+ success = self._send_forwarding_metadata(
1338
+ request, target, chargers_to_update, local_node, private_key
1339
+ )
1340
+
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
+ )
1367
+
1368
+ try:
1369
+ push_forwarded_charge_points.delay()
1370
+ except Exception:
1371
+ pass
1372
+
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
1383
+
1384
+ cleared = Charger.objects.filter(forwarded_to_id__in=node_ids).update(
1385
+ forwarded_to=None, forwarding_watermark=None
1386
+ )
1387
+
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
+ )
1405
+
1233
1406
  def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
1234
1407
  extra_context = extra_context or {}
1235
1408
  if object_id: