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.

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,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, Location
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
- "mac_address",
235
- "address",
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
- _("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,12 +294,39 @@ class NodeAdmin(EntityModelAdmin):
287
294
  "register_visitor",
288
295
  "run_task",
289
296
  "take_screenshots",
290
- "discover_charge_points",
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: list[str] = []
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 {"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
+ }
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 {"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
+ }
605
717
 
606
- def _iter_remote_urls(self, node, path):
607
- host_candidates: list[str] = []
608
- for attr in ("public_endpoint", "address", "hostname"):
609
- value = getattr(node, attr, "") or ""
610
- cleaned = value.strip()
611
- if cleaned and cleaned not in host_candidates:
612
- host_candidates.append(cleaned)
613
-
614
- default_port = node.port or 8000
615
- normalized_path = path if path.startswith("/") else f"/{path}"
616
- seen: set[str] = set()
617
-
618
- for host in host_candidates:
619
- base_path = ""
620
- formatted_host = host
621
- port_override: int | None = None
622
-
623
- if "://" in host:
624
- parsed = urlparse(host)
625
- netloc = parsed.netloc or parsed.path
626
- base_path = (parsed.path or "").rstrip("/")
627
- combined_path = (
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
- if host.startswith("[") and "]" in host:
647
- end = host.index("]")
648
- core_host = host[1:end]
649
- remainder = host[end + 1 :]
650
- if remainder.startswith(":"):
651
- remainder = remainder[1:]
652
- port_part, sep, path_tail = remainder.partition("/")
653
- if port_part:
654
- try:
655
- port_override = int(port_part)
656
- except ValueError:
657
- port_override = None
658
- if sep:
659
- base_path = f"/{path_tail}".rstrip("/")
660
- elif "/" in remainder:
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
- 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
+ )
774
836
  except Exception:
775
837
  url = source
776
838
  if not url.startswith("http"):
777
- 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
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
- url = f"http://{node.address}:{node.port}/nodes/rfid/export/"
898
- try:
899
- response = requests.post(url, data=payload, headers=headers, timeout=5)
900
- 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:
901
979
  result["status"] = "error"
902
- 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
+ )
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
- url = f"http://{node.address}:{node.port}/nodes/rfid/import/"
943
- try:
944
- response = requests.post(url, data=payload, headers=headers, timeout=5)
945
- 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:
946
1031
  result["status"] = "error"
947
- 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
+ )
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
- @admin.action(description=_("Discover Charge Points"))
1064
- def discover_charge_points(self, request, queryset):
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
- nodes = [node for node in queryset if not local_node.pk or node.pk != local_node.pk]
1071
- if not nodes:
1072
- self.message_user(request, _("No remote nodes selected."), level=messages.WARNING)
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
- payload = json.dumps(
1076
- {"requester": str(local_node.uuid)},
1077
- separators=(",", ":"),
1078
- sort_keys=True,
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
- for node in nodes:
1091
- url = f"http://{node.address}:{node.port}/nodes/network/chargers/"
1092
- try:
1093
- response = requests.post(url, data=payload, headers=headers, timeout=5)
1094
- except RequestException as exc:
1095
- errors.append(f"{node}: {exc}")
1096
- continue
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
- if response.status_code != 200:
1099
- errors.append(f"{node}: {response.status_code} {response.text}")
1100
- continue
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
- try:
1103
- data = response.json()
1104
- except ValueError:
1105
- errors.append(f"{node}: invalid JSON response")
1106
- continue
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
- for entry in data.get("chargers", []):
1109
- applied = self._apply_remote_charger_payload(node, entry)
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
- if created or updated:
1116
- summary = _("Imported %(created)s new and %(updated)s existing charge point(s).") % {
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
- connector_value = payload.get("connector_id")
1131
- if connector_value in ("", None):
1132
- connector_value = None
1133
- elif isinstance(connector_value, str):
1134
- try:
1135
- connector_value = int(connector_value)
1136
- except ValueError:
1137
- connector_value = None
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
- charger, created = Charger.objects.get_or_create(
1140
- charger_id=serial,
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
- location_obj = None
1145
- location_payload = payload.get("location")
1146
- if isinstance(location_payload, Mapping):
1147
- name = location_payload.get("name")
1148
- if name:
1149
- location_obj, _ = Location.objects.get_or_create(name=name)
1150
- simple_fields = [
1151
- "latitude",
1152
- "longitude",
1153
- "zone",
1154
- "contract_type",
1155
- ]
1156
- for field in simple_fields:
1157
- value = location_payload.get(field)
1158
- setattr(location_obj, field, value)
1159
- location_obj.save()
1160
-
1161
- datetime_fields = [
1162
- "firmware_timestamp",
1163
- "last_heartbeat",
1164
- "availability_state_updated_at",
1165
- "availability_requested_at",
1166
- "availability_request_status_at",
1167
- "diagnostics_timestamp",
1168
- "last_status_timestamp",
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
- if location_obj is not None:
1201
- updates["location"] = location_obj
1368
+ try:
1369
+ push_forwarded_charge_points.delay()
1370
+ except Exception:
1371
+ pass
1202
1372
 
1203
- for field in datetime_fields:
1204
- value = payload.get(field)
1205
- updates[field] = parse_datetime(value) if value else None
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
- for field in ("last_meter_values",):
1208
- updates[field] = payload.get(field) or {}
1384
+ cleared = Charger.objects.filter(forwarded_to_id__in=node_ids).update(
1385
+ forwarded_to=None, forwarding_watermark=None
1386
+ )
1209
1387
 
1210
- Charger.objects.filter(pk=charger.pk).update(**updates)
1211
- return "created" if created else "updated"
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 {}