arthexis 0.1.16__py3-none-any.whl → 0.1.26__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.

Files changed (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
nodes/admin.py CHANGED
@@ -8,27 +8,31 @@ from django.contrib.admin import helpers
8
8
  from django.contrib.admin.widgets import FilteredSelectMultiple
9
9
  from django.core.exceptions import PermissionDenied
10
10
  from django.db.models import Count
11
- from django.http import HttpResponse, JsonResponse
11
+ from django.http import Http404, HttpResponse, JsonResponse
12
12
  from django.shortcuts import redirect, render
13
13
  from django.template.response import TemplateResponse
14
+ from django.test import signals
14
15
  from django.urls import NoReverseMatch, path, reverse
15
16
  from django.utils import timezone
16
17
  from django.utils.dateparse import parse_datetime
17
18
  from django.utils.html import format_html, format_html_join
18
- from django.utils.translation import gettext_lazy as _
19
+ from django.utils.translation import gettext_lazy as _, ngettext
19
20
  from pathlib import Path
20
- from urllib.parse import urlsplit, urlunsplit
21
+ from types import SimpleNamespace
22
+ from urllib.parse import urlsplit, urlunsplit, quote
21
23
  import base64
22
24
  import json
23
25
  import subprocess
24
26
  import uuid
25
27
 
28
+ import asyncio
26
29
  import pyperclip
27
30
  import requests
28
31
  from cryptography.hazmat.primitives import hashes, serialization
29
32
  from cryptography.hazmat.primitives.asymmetric import padding
30
33
  from pyperclip import PyperclipException
31
34
  from requests import RequestException
35
+ import websockets
32
36
 
33
37
  from .classifiers import run_default_classifiers, suppress_default_classifiers
34
38
  from .rfid_sync import apply_rfid_payload, serialize_rfid
@@ -57,6 +61,9 @@ from .models import (
57
61
  )
58
62
  from . import dns as dns_utils
59
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
60
67
  from core.user_data import EntityModelAdmin
61
68
 
62
69
 
@@ -84,7 +91,7 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
84
91
 
85
92
  class DeployDNSRecordsForm(forms.Form):
86
93
  manager = forms.ModelChoiceField(
87
- label="Node Manager",
94
+ label="Node Profile",
88
95
  queryset=NodeManager.objects.none(),
89
96
  help_text="Credentials used to authenticate with the DNS provider.",
90
97
  )
@@ -227,31 +234,35 @@ class DNSRecordAdmin(EntityModelAdmin):
227
234
  class NodeAdmin(EntityModelAdmin):
228
235
  list_display = (
229
236
  "hostname",
230
- "mac_address",
231
- "address",
237
+ "primary_ip",
232
238
  "port",
233
239
  "role",
234
240
  "relation",
235
241
  "last_seen",
242
+ "visit_link",
236
243
  )
237
- search_fields = ("hostname", "address", "mac_address")
244
+ search_fields = ("hostname", "network_hostname", "address", "mac_address")
238
245
  change_list_template = "admin/nodes/node/change_list.html"
239
246
  change_form_template = "admin/nodes/node/change_form.html"
240
247
  form = NodeAdminForm
241
248
  fieldsets = (
242
249
  (
243
- _("Node"),
250
+ _("Network"),
244
251
  {
245
252
  "fields": (
246
253
  "hostname",
254
+ "network_hostname",
255
+ "ipv4_address",
256
+ "ipv6_address",
247
257
  "address",
248
258
  "mac_address",
249
259
  "port",
250
- "role",
260
+ "message_queue_length",
251
261
  "current_relation",
252
262
  )
253
263
  },
254
264
  ),
265
+ (_("Role"), {"fields": ("role",)}),
255
266
  (
256
267
  _("Public endpoint"),
257
268
  {
@@ -281,15 +292,87 @@ class NodeAdmin(EntityModelAdmin):
281
292
  "register_visitor",
282
293
  "run_task",
283
294
  "take_screenshots",
295
+ "start_charge_point_forwarding",
296
+ "stop_charge_point_forwarding",
284
297
  "import_rfids_from_selected",
285
298
  "export_rfids_to_selected",
299
+ "send_net_message",
286
300
  ]
287
301
  inlines = [NodeFeatureAssignmentInline]
288
302
 
303
+ class SendNetMessageForm(forms.Form):
304
+ subject = forms.CharField(
305
+ label=_("Subject"),
306
+ max_length=NetMessage._meta.get_field("subject").max_length,
307
+ required=False,
308
+ )
309
+ body = forms.CharField(
310
+ label=_("Body"),
311
+ max_length=NetMessage._meta.get_field("body").max_length,
312
+ required=False,
313
+ widget=forms.Textarea(attrs={"rows": 4}),
314
+ )
315
+
316
+ def clean(self):
317
+ cleaned = super().clean()
318
+ subject = (cleaned.get("subject") or "").strip()
319
+ body = (cleaned.get("body") or "").strip()
320
+ if not subject and not body:
321
+ raise forms.ValidationError(
322
+ _("Enter a subject or body to send.")
323
+ )
324
+ cleaned["subject"] = subject
325
+ cleaned["body"] = body
326
+ return cleaned
327
+
289
328
  @admin.display(description=_("Relation"), ordering="current_relation")
290
329
  def relation(self, obj):
291
330
  return obj.get_current_relation_display()
292
331
 
332
+ @admin.display(description=_("IP Address"), ordering="address")
333
+ def primary_ip(self, obj):
334
+ if not obj:
335
+ return ""
336
+ return obj.get_best_ip() or ""
337
+
338
+ @admin.display(description=_("Visit"))
339
+ def visit_link(self, obj):
340
+ if not obj:
341
+ return ""
342
+ if obj.is_local:
343
+ try:
344
+ url = reverse("admin:index")
345
+ except NoReverseMatch:
346
+ return ""
347
+ return format_html(
348
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
349
+ url,
350
+ _("Visit"),
351
+ )
352
+
353
+ host_values = obj.get_remote_host_candidates()
354
+
355
+ remote_url = ""
356
+ for host in host_values:
357
+ temp_node = SimpleNamespace(
358
+ public_endpoint=host,
359
+ address="",
360
+ hostname="",
361
+ port=obj.port,
362
+ )
363
+ remote_url = next(self._iter_remote_urls(temp_node, "/admin/"), "")
364
+ if remote_url:
365
+ break
366
+
367
+ if not remote_url:
368
+ return ""
369
+
370
+ return format_html(
371
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
372
+ remote_url,
373
+ _("Visit"),
374
+ )
375
+
293
376
  def get_urls(self):
294
377
  urls = super().get_urls()
295
378
  custom = [
@@ -330,7 +413,20 @@ class NodeAdmin(EntityModelAdmin):
330
413
  "token": token,
331
414
  "register_url": reverse("register-node"),
332
415
  }
333
- return render(request, "admin/nodes/node/register_remote.html", context)
416
+ response = TemplateResponse(
417
+ request, "admin/nodes/node/register_remote.html", context
418
+ )
419
+ response.render()
420
+ template = response.resolve_template(response.template_name)
421
+ if getattr(template, "name", None) in (None, ""):
422
+ template.name = response.template_name
423
+ signals.template_rendered.send(
424
+ sender=template.__class__,
425
+ template=template,
426
+ context=response.context_data,
427
+ request=request,
428
+ )
429
+ return response
334
430
 
335
431
  @admin.action(description="Register Visitor")
336
432
  def register_visitor(self, request, queryset=None):
@@ -354,6 +450,75 @@ class NodeAdmin(EntityModelAdmin):
354
450
  request, "admin/nodes/node/update_selected.html", context
355
451
  )
356
452
 
453
+ @admin.action(description=_("Send Net Message"))
454
+ def send_net_message(self, request, queryset):
455
+ is_submit = "apply" in request.POST
456
+ form = self.SendNetMessageForm(request.POST if is_submit else None)
457
+ selected_ids = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
458
+ if not selected_ids:
459
+ selected_ids = [str(pk) for pk in queryset.values_list("pk", flat=True)]
460
+ nodes: list[Node] = []
461
+ cleaned_ids: list[int] = []
462
+ for value in selected_ids:
463
+ try:
464
+ cleaned_ids.append(int(value))
465
+ except (TypeError, ValueError):
466
+ continue
467
+ if cleaned_ids:
468
+ base_queryset = self.get_queryset(request).filter(pk__in=cleaned_ids)
469
+ nodes_by_pk = {str(node.pk): node for node in base_queryset}
470
+ nodes = [nodes_by_pk[value] for value in selected_ids if value in nodes_by_pk]
471
+ if not nodes:
472
+ nodes = list(queryset)
473
+ selected_ids = [str(node.pk) for node in nodes]
474
+ if not nodes:
475
+ self.message_user(request, _("No nodes selected."), messages.INFO)
476
+ return None
477
+ if is_submit and form.is_valid():
478
+ subject = form.cleaned_data["subject"]
479
+ body = form.cleaned_data["body"]
480
+ created = 0
481
+ for node in nodes:
482
+ message = NetMessage.objects.create(
483
+ subject=subject,
484
+ body=body,
485
+ filter_node=node,
486
+ )
487
+ message.propagate()
488
+ created += 1
489
+ if created:
490
+ success_message = ngettext(
491
+ "Sent %(count)d net message.",
492
+ "Sent %(count)d net messages.",
493
+ created,
494
+ ) % {"count": created}
495
+ self.message_user(request, success_message, messages.SUCCESS)
496
+ else:
497
+ self.message_user(
498
+ request, _("No net messages were sent."), messages.INFO
499
+ )
500
+ return None
501
+ context = {
502
+ **self.admin_site.each_context(request),
503
+ "opts": self.model._meta,
504
+ "title": _("Send Net Message"),
505
+ "nodes": nodes,
506
+ "selected_ids": selected_ids,
507
+ "action_name": request.POST.get("action", "send_net_message"),
508
+ "select_across": request.POST.get("select_across", "0"),
509
+ "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
510
+ "adminform": helpers.AdminForm(
511
+ form,
512
+ [(None, {"fields": ("subject", "body")})],
513
+ {},
514
+ ),
515
+ "form": form,
516
+ "media": self.media + form.media,
517
+ }
518
+ return TemplateResponse(
519
+ request, "admin/nodes/node/send_net_message.html", context
520
+ )
521
+
357
522
  def update_selected_progress(self, request):
358
523
  if request.method != "POST":
359
524
  return JsonResponse({"detail": "POST required"}, status=405)
@@ -398,6 +563,7 @@ class NodeAdmin(EntityModelAdmin):
398
563
  }
399
564
 
400
565
  last_error = ""
566
+ host_candidates = node.get_remote_host_candidates()
401
567
  for url in self._iter_remote_urls(node, "/nodes/info/"):
402
568
  try:
403
569
  response = requests.get(url, timeout=5)
@@ -424,13 +590,19 @@ class NodeAdmin(EntityModelAdmin):
424
590
  "updated_fields": updated,
425
591
  "message": message,
426
592
  }
427
- return {"ok": False, "message": last_error or "Unable to reach remote node."}
593
+ return {
594
+ "ok": False,
595
+ "message": self._build_connectivity_hint(last_error, host_candidates),
596
+ }
428
597
 
429
598
  def _apply_remote_node_info(self, node, payload):
430
599
  changed = []
431
600
  field_map = {
432
601
  "hostname": payload.get("hostname"),
602
+ "network_hostname": payload.get("network_hostname"),
433
603
  "address": payload.get("address"),
604
+ "ipv4_address": payload.get("ipv4_address"),
605
+ "ipv6_address": payload.get("ipv6_address"),
434
606
  "public_key": payload.get("public_key"),
435
607
  }
436
608
  port_value = payload.get("port")
@@ -451,6 +623,17 @@ class NodeAdmin(EntityModelAdmin):
451
623
  setattr(node, field, value)
452
624
  changed.append(field)
453
625
 
626
+ role_value = payload.get("role") or payload.get("role_name")
627
+ if role_value is not None:
628
+ role_name = str(role_value).strip()
629
+ if role_name:
630
+ desired_role = NodeRole.objects.filter(name=role_name).first()
631
+ else:
632
+ desired_role = None
633
+ if desired_role and node.role_id != desired_role.id:
634
+ node.role = desired_role
635
+ changed.append("role")
636
+
454
637
  node.last_seen = timezone.now()
455
638
  if "last_seen" not in changed:
456
639
  changed.append("last_seen")
@@ -497,7 +680,10 @@ class NodeAdmin(EntityModelAdmin):
497
680
 
498
681
  payload = {
499
682
  "hostname": local_node.hostname,
683
+ "network_hostname": local_node.network_hostname,
500
684
  "address": local_node.address,
685
+ "ipv4_address": local_node.ipv4_address,
686
+ "ipv6_address": local_node.ipv6_address,
501
687
  "port": local_node.port,
502
688
  "mac_address": local_node.mac_address,
503
689
  "public_key": local_node.public_key,
@@ -513,6 +699,7 @@ class NodeAdmin(EntityModelAdmin):
513
699
  headers = {"Content-Type": "application/json"}
514
700
 
515
701
  last_error = ""
702
+ host_candidates = node.get_remote_host_candidates()
516
703
  for url in self._iter_remote_urls(node, "/nodes/register/"):
517
704
  try:
518
705
  response = requests.post(
@@ -527,46 +714,52 @@ class NodeAdmin(EntityModelAdmin):
527
714
  if response.ok:
528
715
  return {"ok": True, "url": url, "message": "Remote updated."}
529
716
  last_error = f"{response.status_code} {response.text}"
530
- return {"ok": False, "message": last_error or "Unable to reach remote node."}
717
+ return {
718
+ "ok": False,
719
+ "message": self._build_connectivity_hint(last_error, host_candidates),
720
+ }
721
+
722
+ def _build_connectivity_hint(self, last_error: str, hosts: list[str]) -> str:
723
+ base_message = last_error or _("Unable to reach remote node.")
724
+ if hosts:
725
+ host_text = ", ".join(hosts)
726
+ return _("%(message)s Tried hosts: %(hosts)s.") % {
727
+ "message": base_message,
728
+ "hosts": host_text,
729
+ }
730
+ return _("%(message)s No remote hosts were available for contact.") % {
731
+ "message": base_message
732
+ }
733
+
734
+ def _primary_remote_url(self, node, path: str) -> str:
735
+ return next(self._iter_remote_urls(node, path), "")
736
+
737
+ def _request_remote(self, node, path: str, request_callable):
738
+ errors: list[str] = []
739
+ for url in self._iter_remote_urls(node, path):
740
+ try:
741
+ response = request_callable(url)
742
+ except RequestException as exc:
743
+ errors.append(f"{url}: {exc}")
744
+ continue
745
+ return url, response, errors
746
+ return "", None, errors
531
747
 
532
748
  def _iter_remote_urls(self, node, path):
533
- host_candidates = []
534
- for attr in ("public_endpoint", "address", "hostname"):
535
- value = getattr(node, attr, "") or ""
536
- value = value.strip()
537
- if value and value not in host_candidates:
538
- host_candidates.append(value)
539
-
540
- port = node.port or 8000
541
- normalized_path = path if path.startswith("/") else f"/{path}"
542
- seen = set()
543
-
544
- for host in host_candidates:
545
- formatted_host = host
546
- if ":" in host and not host.startswith("["):
547
- formatted_host = f"[{host}]"
548
-
549
- candidates = []
550
- if port == 80:
551
- candidates = [
552
- f"http://{formatted_host}{normalized_path}",
553
- f"https://{formatted_host}{normalized_path}",
554
- ]
555
- elif port == 443:
556
- candidates = [
557
- f"https://{formatted_host}{normalized_path}",
558
- f"http://{formatted_host}:{port}{normalized_path}",
559
- ]
560
- else:
561
- candidates = [
562
- f"http://{formatted_host}:{port}{normalized_path}",
563
- f"https://{formatted_host}:{port}{normalized_path}",
564
- ]
749
+ if hasattr(node, "iter_remote_urls"):
750
+ yield from node.iter_remote_urls(path)
751
+ return
565
752
 
566
- for candidate in candidates:
567
- if candidate not in seen:
568
- seen.add(candidate)
569
- yield candidate
753
+ temp = Node(
754
+ public_endpoint=getattr(node, "public_endpoint", ""),
755
+ address=getattr(node, "address", ""),
756
+ hostname=getattr(node, "hostname", ""),
757
+ port=getattr(node, "port", None),
758
+ )
759
+ temp.network_hostname = getattr(node, "network_hostname", "")
760
+ temp.ipv4_address = getattr(node, "ipv4_address", "")
761
+ temp.ipv6_address = getattr(node, "ipv6_address", "")
762
+ yield from temp.iter_remote_urls(path)
570
763
 
571
764
  def register_visitor_view(self, request):
572
765
  """Exchange registration data with the visiting node."""
@@ -640,11 +833,28 @@ class NodeAdmin(EntityModelAdmin):
640
833
  for node in queryset:
641
834
  for source in sources:
642
835
  try:
643
- url = source.format(node=node, address=node.address, port=node.port)
836
+ contact_host = node.get_primary_contact()
837
+ url = source.format(
838
+ node=node, address=contact_host, port=node.port
839
+ )
644
840
  except Exception:
645
841
  url = source
646
842
  if not url.startswith("http"):
647
- url = f"http://{node.address}:{node.port}{url}"
843
+ candidate = next(
844
+ self._iter_remote_urls(node, url),
845
+ "",
846
+ )
847
+ if not candidate:
848
+ self.message_user(
849
+ request,
850
+ _(
851
+ "No reachable host was available for %(node)s while generating %(path)s"
852
+ )
853
+ % {"node": node, "path": url},
854
+ messages.WARNING,
855
+ )
856
+ continue
857
+ url = candidate
648
858
  try:
649
859
  path = capture_screenshot(url)
650
860
  except Exception as exc: # pragma: no cover - selenium issues
@@ -742,6 +952,7 @@ class NodeAdmin(EntityModelAdmin):
742
952
  def _render_rfid_sync(self, request, operation, results, setup_error=None):
743
953
  titles = {
744
954
  "import": _("Import RFID results"),
955
+ "fetch": _("Fetch RFID results"),
745
956
  "export": _("Export RFID results"),
746
957
  }
747
958
  summary = self._summarize_rfid_results(results)
@@ -763,12 +974,19 @@ class NodeAdmin(EntityModelAdmin):
763
974
 
764
975
  def _process_import_from_node(self, node, payload, headers):
765
976
  result = self._init_rfid_result(node)
766
- url = f"http://{node.address}:{node.port}/nodes/rfid/export/"
767
- try:
768
- response = requests.post(url, data=payload, headers=headers, timeout=5)
769
- except RequestException as exc:
977
+ _, response, attempt_errors = self._request_remote(
978
+ node,
979
+ "/nodes/rfid/export/",
980
+ lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
981
+ )
982
+ if response is None:
770
983
  result["status"] = "error"
771
- result["errors"].append(str(exc))
984
+ if attempt_errors:
985
+ result["errors"].extend(attempt_errors)
986
+ else:
987
+ result["errors"].append(
988
+ _("No remote hosts were available for %(node)s.") % {"node": node}
989
+ )
772
990
  return result
773
991
 
774
992
  if response.status_code != 200:
@@ -808,12 +1026,19 @@ class NodeAdmin(EntityModelAdmin):
808
1026
 
809
1027
  def _post_export_to_node(self, node, payload, headers):
810
1028
  result = self._init_rfid_result(node)
811
- url = f"http://{node.address}:{node.port}/nodes/rfid/import/"
812
- try:
813
- response = requests.post(url, data=payload, headers=headers, timeout=5)
814
- except RequestException as exc:
1029
+ _, response, attempt_errors = self._request_remote(
1030
+ node,
1031
+ "/nodes/rfid/import/",
1032
+ lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
1033
+ )
1034
+ if response is None:
815
1035
  result["status"] = "error"
816
- result["errors"].append(str(exc))
1036
+ if attempt_errors:
1037
+ result["errors"].extend(attempt_errors)
1038
+ else:
1039
+ result["errors"].append(
1040
+ _("No remote hosts were available for %(node)s.") % {"node": node}
1041
+ )
817
1042
  return result
818
1043
 
819
1044
  if response.status_code != 200:
@@ -851,13 +1076,14 @@ class NodeAdmin(EntityModelAdmin):
851
1076
  result["status"] = self._status_from_result(result)
852
1077
  return result
853
1078
 
854
- @admin.action(description=_("Import RFIDs from selected"))
855
- def import_rfids_from_selected(self, request, queryset):
1079
+ def _run_rfid_import(self, request, queryset):
856
1080
  nodes = list(queryset)
857
1081
  local_node, private_key, error = self._load_local_node_credentials()
858
1082
  if error:
859
1083
  results = [self._skip_result(node, error) for node in nodes]
860
- return self._render_rfid_sync(request, "import", results, setup_error=error)
1084
+ return self._render_rfid_sync(
1085
+ request, "import", results, setup_error=error
1086
+ )
861
1087
 
862
1088
  if not nodes:
863
1089
  return self._render_rfid_sync(
@@ -887,6 +1113,10 @@ class NodeAdmin(EntityModelAdmin):
887
1113
 
888
1114
  return self._render_rfid_sync(request, "import", results)
889
1115
 
1116
+ @admin.action(description=_("Import RFIDs from selected"))
1117
+ def import_rfids_from_selected(self, request, queryset):
1118
+ return self._run_rfid_import(request, queryset)
1119
+
890
1120
  @admin.action(description=_("Export RFIDs to selected"))
891
1121
  def export_rfids_to_selected(self, request, queryset):
892
1122
  nodes = list(queryset)
@@ -924,6 +1154,269 @@ class NodeAdmin(EntityModelAdmin):
924
1154
 
925
1155
  return self._render_rfid_sync(request, "export", results)
926
1156
 
1157
+ async def _probe_websocket(self, url: str) -> bool:
1158
+ try:
1159
+ async with websockets.connect(url, open_timeout=3, close_timeout=1):
1160
+ return True
1161
+ except Exception:
1162
+ return False
1163
+
1164
+ def _attempt_forwarding_probe(self, node, charger_id: str) -> bool:
1165
+ if not charger_id:
1166
+ return False
1167
+ safe_id = quote(str(charger_id))
1168
+ candidates: list[str] = []
1169
+ for base in node.iter_remote_urls("/"):
1170
+ parsed = urlsplit(base)
1171
+ if parsed.scheme not in {"http", "https"}:
1172
+ continue
1173
+ scheme = "wss" if parsed.scheme == "https" else "ws"
1174
+ base_path = parsed.path.rstrip("/")
1175
+ for prefix in ("", "/ws"):
1176
+ path = f"{base_path}{prefix}/{safe_id}".replace("//", "/")
1177
+ if not path.startswith("/"):
1178
+ path = f"/{path}"
1179
+ candidates.append(urlunsplit((scheme, parsed.netloc, path, "", "")))
1180
+
1181
+ for url in candidates:
1182
+ loop = asyncio.new_event_loop()
1183
+ try:
1184
+ result = loop.run_until_complete(self._probe_websocket(url))
1185
+ except Exception:
1186
+ result = False
1187
+ finally:
1188
+ loop.close()
1189
+ if result:
1190
+ return True
1191
+ return False
1192
+
1193
+ def _send_forwarding_metadata(
1194
+ self,
1195
+ request,
1196
+ node: Node,
1197
+ chargers: list[Charger],
1198
+ local_node: Node,
1199
+ private_key,
1200
+ ) -> bool:
1201
+ if not chargers:
1202
+ return True
1203
+ payload = {
1204
+ "requester": str(local_node.uuid),
1205
+ "requester_mac": local_node.mac_address,
1206
+ "requester_public_key": local_node.public_key,
1207
+ "chargers": [serialize_charger_for_network(charger) for charger in chargers],
1208
+ "transactions": {"chargers": [], "transactions": []},
1209
+ }
1210
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1211
+ signature = self._sign_payload(private_key, payload_json)
1212
+ headers = {"Content-Type": "application/json"}
1213
+ if signature:
1214
+ headers["X-Signature"] = signature
1215
+
1216
+ errors: list[str] = []
1217
+ for url in node.iter_remote_urls("/nodes/network/chargers/forward/"):
1218
+ if not url:
1219
+ continue
1220
+ try:
1221
+ response = requests.post(
1222
+ url, data=payload_json, headers=headers, timeout=5
1223
+ )
1224
+ except RequestException as exc:
1225
+ errors.append(
1226
+ _(
1227
+ "Failed to send forwarding metadata to %(node)s via %(url)s (%(error)s)."
1228
+ )
1229
+ % {"node": node, "url": url, "error": exc}
1230
+ )
1231
+ continue
1232
+
1233
+ try:
1234
+ data = response.json()
1235
+ except ValueError:
1236
+ data = {}
1237
+
1238
+ if response.ok and isinstance(data, Mapping) and data.get("status") == "ok":
1239
+ return True
1240
+
1241
+ detail = ""
1242
+ if isinstance(data, Mapping):
1243
+ detail = data.get("detail") or ""
1244
+ errors.append(
1245
+ _("Forwarding metadata to %(node)s via %(url)s failed: %(status)s %(detail)s")
1246
+ % {
1247
+ "node": node,
1248
+ "url": url,
1249
+ "status": response.status_code,
1250
+ "detail": detail,
1251
+ }
1252
+ )
1253
+
1254
+ if not errors:
1255
+ self.message_user(
1256
+ request,
1257
+ _("No reachable host found for %(node)s.") % {"node": node},
1258
+ level=messages.WARNING,
1259
+ )
1260
+ else:
1261
+ self.message_user(request, errors[-1].strip(), level=messages.WARNING)
1262
+ return False
1263
+
1264
+ @admin.action(description=_("Start Charge Point Forwarding"))
1265
+ def start_charge_point_forwarding(self, request, queryset):
1266
+ if queryset.count() != 1:
1267
+ self.message_user(
1268
+ request,
1269
+ _("Select a single remote node."),
1270
+ level=messages.ERROR,
1271
+ )
1272
+ return
1273
+
1274
+ target = queryset.first()
1275
+ local_node, private_key, error = self._load_local_node_credentials()
1276
+ if error:
1277
+ self.message_user(request, error, level=messages.ERROR)
1278
+ return
1279
+
1280
+ if local_node.pk and target.pk == local_node.pk:
1281
+ self.message_user(
1282
+ request,
1283
+ _("Cannot forward charge points to the local node."),
1284
+ level=messages.ERROR,
1285
+ )
1286
+ return
1287
+
1288
+ eligible = Charger.objects.filter(export_transactions=True)
1289
+ if local_node.pk:
1290
+ eligible = eligible.filter(
1291
+ Q(node_origin=local_node) | Q(node_origin__isnull=True)
1292
+ )
1293
+
1294
+ chargers = list(eligible.select_related("forwarded_to"))
1295
+ if not chargers:
1296
+ self.message_user(
1297
+ request,
1298
+ _("No eligible charge points available for forwarding."),
1299
+ level=messages.WARNING,
1300
+ )
1301
+ return
1302
+
1303
+ conflicts = [
1304
+ charger
1305
+ for charger in chargers
1306
+ if charger.forwarded_to_id
1307
+ and charger.forwarded_to_id not in {None, target.pk}
1308
+ ]
1309
+ if conflicts:
1310
+ self.message_user(
1311
+ request,
1312
+ ngettext(
1313
+ "Skipped %(count)s charge point already forwarded to another node.",
1314
+ "Skipped %(count)s charge points already forwarded to another node.",
1315
+ len(conflicts),
1316
+ )
1317
+ % {"count": len(conflicts)},
1318
+ level=messages.WARNING,
1319
+ )
1320
+
1321
+ chargers_to_update = [
1322
+ charger
1323
+ for charger in chargers
1324
+ if charger.forwarded_to_id in (None, target.pk)
1325
+ ]
1326
+ if not chargers_to_update:
1327
+ self.message_user(
1328
+ request,
1329
+ _("No charge points were updated."),
1330
+ level=messages.WARNING,
1331
+ )
1332
+ return
1333
+
1334
+ charger_pks = [c.pk for c in chargers_to_update]
1335
+ Charger.objects.filter(pk__in=charger_pks).update(forwarded_to=target)
1336
+
1337
+ for charger in chargers_to_update:
1338
+ charger.forwarded_to = target
1339
+
1340
+ sample = next((charger for charger in chargers_to_update if charger.charger_id), None)
1341
+ if sample and not self._attempt_forwarding_probe(target, sample.charger_id):
1342
+ self.message_user(
1343
+ request,
1344
+ _(
1345
+ "Unable to establish a websocket connection to %(node)s for charge point %(charger)s."
1346
+ )
1347
+ % {"node": target, "charger": sample.charger_id},
1348
+ level=messages.WARNING,
1349
+ )
1350
+
1351
+ success = self._send_forwarding_metadata(
1352
+ request, target, chargers_to_update, local_node, private_key
1353
+ )
1354
+
1355
+ if success:
1356
+ now = timezone.now()
1357
+ Charger.objects.filter(pk__in=charger_pks).update(
1358
+ forwarding_watermark=now
1359
+ )
1360
+ self.message_user(
1361
+ request,
1362
+ ngettext(
1363
+ "Forwarding enabled for %(count)s charge point.",
1364
+ "Forwarding enabled for %(count)s charge points.",
1365
+ len(chargers_to_update),
1366
+ )
1367
+ % {"count": len(chargers_to_update)},
1368
+ level=messages.SUCCESS,
1369
+ )
1370
+ else:
1371
+ self.message_user(
1372
+ request,
1373
+ ngettext(
1374
+ "Marked %(count)s charge point for forwarding; awaiting remote acknowledgment.",
1375
+ "Marked %(count)s charge points for forwarding; awaiting remote acknowledgment.",
1376
+ len(chargers_to_update),
1377
+ )
1378
+ % {"count": len(chargers_to_update)},
1379
+ level=messages.INFO,
1380
+ )
1381
+
1382
+ try:
1383
+ push_forwarded_charge_points.delay()
1384
+ except Exception:
1385
+ pass
1386
+
1387
+ @admin.action(description=_("Stop Charge Point Forwarding"))
1388
+ def stop_charge_point_forwarding(self, request, queryset):
1389
+ node_ids = [node.pk for node in queryset if node.pk]
1390
+ if not node_ids:
1391
+ self.message_user(
1392
+ request,
1393
+ _("No remote nodes selected."),
1394
+ level=messages.WARNING,
1395
+ )
1396
+ return
1397
+
1398
+ cleared = Charger.objects.filter(forwarded_to_id__in=node_ids).update(
1399
+ forwarded_to=None, forwarding_watermark=None
1400
+ )
1401
+
1402
+ if cleared:
1403
+ self.message_user(
1404
+ request,
1405
+ ngettext(
1406
+ "Stopped forwarding for %(count)s charge point.",
1407
+ "Stopped forwarding for %(count)s charge points.",
1408
+ cleared,
1409
+ )
1410
+ % {"count": cleared},
1411
+ level=messages.SUCCESS,
1412
+ )
1413
+ else:
1414
+ self.message_user(
1415
+ request,
1416
+ _("No forwarded charge points were updated."),
1417
+ level=messages.WARNING,
1418
+ )
1419
+
927
1420
  def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
928
1421
  extra_context = extra_context or {}
929
1422
  if object_id:
@@ -1565,7 +2058,7 @@ class NetMessageAdmin(EntityModelAdmin):
1565
2058
  search_fields = ("subject", "body")
1566
2059
  list_filter = ("complete", "filter_node_role", "filter_current_relation")
1567
2060
  ordering = ("-created",)
1568
- readonly_fields = ("complete", "confirmed_peers")
2061
+ readonly_fields = ("complete",)
1569
2062
  actions = ["send_messages"]
1570
2063
  fieldsets = (
1571
2064
  (None, {"fields": ("subject", "body")}),
@@ -1590,7 +2083,6 @@ class NetMessageAdmin(EntityModelAdmin):
1590
2083
  "node_origin",
1591
2084
  "target_limit",
1592
2085
  "propagated_to",
1593
- "confirmed_peers",
1594
2086
  "complete",
1595
2087
  )
1596
2088
  },