arthexis 0.1.16__py3-none-any.whl → 0.1.28__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 (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
nodes/admin.py CHANGED
@@ -8,27 +8,33 @@ 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
24
+ import binascii
22
25
  import json
23
26
  import subprocess
24
27
  import uuid
25
28
 
29
+ import asyncio
26
30
  import pyperclip
27
31
  import requests
28
32
  from cryptography.hazmat.primitives import hashes, serialization
29
33
  from cryptography.hazmat.primitives.asymmetric import padding
30
34
  from pyperclip import PyperclipException
31
35
  from requests import RequestException
36
+ import websockets
37
+ from asgiref.sync import async_to_sync
32
38
 
33
39
  from .classifiers import run_default_classifiers, suppress_default_classifiers
34
40
  from .rfid_sync import apply_rfid_payload, serialize_rfid
@@ -57,6 +63,15 @@ from .models import (
57
63
  )
58
64
  from . import dns as dns_utils
59
65
  from core.models import RFID
66
+ from ocpp import store
67
+ from ocpp.models import (
68
+ Charger,
69
+ CPFirmware,
70
+ CPFirmwareDeployment,
71
+ DataTransferMessage,
72
+ )
73
+ from ocpp.network import serialize_charger_for_network
74
+ from ocpp.tasks import push_forwarded_charge_points
60
75
  from core.user_data import EntityModelAdmin
61
76
 
62
77
 
@@ -84,7 +99,7 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
84
99
 
85
100
  class DeployDNSRecordsForm(forms.Form):
86
101
  manager = forms.ModelChoiceField(
87
- label="Node Manager",
102
+ label="Node Profile",
88
103
  queryset=NodeManager.objects.none(),
89
104
  help_text="Credentials used to authenticate with the DNS provider.",
90
105
  )
@@ -227,31 +242,35 @@ class DNSRecordAdmin(EntityModelAdmin):
227
242
  class NodeAdmin(EntityModelAdmin):
228
243
  list_display = (
229
244
  "hostname",
230
- "mac_address",
231
- "address",
245
+ "primary_ip",
232
246
  "port",
233
247
  "role",
234
248
  "relation",
235
249
  "last_seen",
250
+ "visit_link",
236
251
  )
237
- search_fields = ("hostname", "address", "mac_address")
252
+ search_fields = ("hostname", "network_hostname", "address", "mac_address")
238
253
  change_list_template = "admin/nodes/node/change_list.html"
239
254
  change_form_template = "admin/nodes/node/change_form.html"
240
255
  form = NodeAdminForm
241
256
  fieldsets = (
242
257
  (
243
- _("Node"),
258
+ _("Network"),
244
259
  {
245
260
  "fields": (
246
261
  "hostname",
262
+ "network_hostname",
263
+ "ipv4_address",
264
+ "ipv6_address",
247
265
  "address",
248
266
  "mac_address",
249
267
  "port",
250
- "role",
268
+ "message_queue_length",
251
269
  "current_relation",
252
270
  )
253
271
  },
254
272
  ),
273
+ (_("Role"), {"fields": ("role",)}),
255
274
  (
256
275
  _("Public endpoint"),
257
276
  {
@@ -281,15 +300,108 @@ class NodeAdmin(EntityModelAdmin):
281
300
  "register_visitor",
282
301
  "run_task",
283
302
  "take_screenshots",
303
+ "download_evcs_firmware",
304
+ "start_charge_point_forwarding",
305
+ "stop_charge_point_forwarding",
284
306
  "import_rfids_from_selected",
285
307
  "export_rfids_to_selected",
308
+ "send_net_message",
286
309
  ]
287
310
  inlines = [NodeFeatureAssignmentInline]
288
311
 
312
+ class SendNetMessageForm(forms.Form):
313
+ subject = forms.CharField(
314
+ label=_("Subject"),
315
+ max_length=NetMessage._meta.get_field("subject").max_length,
316
+ required=False,
317
+ )
318
+ body = forms.CharField(
319
+ label=_("Body"),
320
+ max_length=NetMessage._meta.get_field("body").max_length,
321
+ required=False,
322
+ widget=forms.Textarea(attrs={"rows": 4}),
323
+ )
324
+
325
+ def clean(self):
326
+ cleaned = super().clean()
327
+ subject = (cleaned.get("subject") or "").strip()
328
+ body = (cleaned.get("body") or "").strip()
329
+ if not subject and not body:
330
+ raise forms.ValidationError(
331
+ _("Enter a subject or body to send.")
332
+ )
333
+ cleaned["subject"] = subject
334
+ cleaned["body"] = body
335
+ return cleaned
336
+
337
+ class DownloadFirmwareForm(forms.Form):
338
+ def __init__(self, node: Node, *args, **kwargs):
339
+ super().__init__(*args, **kwargs)
340
+ base_queryset = Charger.objects.filter(
341
+ node_origin=node, connector_id__isnull=True
342
+ ).order_by("display_name", "charger_id")
343
+ self.fields["charger"].queryset = base_queryset
344
+
345
+ charger = forms.ModelChoiceField(
346
+ label=_("Charge point"),
347
+ queryset=Charger.objects.none(),
348
+ help_text=_("Select the EVCS to request firmware from."),
349
+ )
350
+ vendor_id = forms.CharField(
351
+ label=_("Vendor ID"),
352
+ max_length=255,
353
+ initial="org.openchargealliance.firmware",
354
+ help_text=_("Vendor identifier included in the DataTransfer request."),
355
+ )
356
+
289
357
  @admin.display(description=_("Relation"), ordering="current_relation")
290
358
  def relation(self, obj):
291
359
  return obj.get_current_relation_display()
292
360
 
361
+ @admin.display(description=_("IP Address"), ordering="address")
362
+ def primary_ip(self, obj):
363
+ if not obj:
364
+ return ""
365
+ return obj.get_best_ip() or ""
366
+
367
+ @admin.display(description=_("Visit"))
368
+ def visit_link(self, obj):
369
+ if not obj:
370
+ return ""
371
+ if obj.is_local:
372
+ try:
373
+ url = reverse("admin:index")
374
+ except NoReverseMatch:
375
+ return ""
376
+ return format_html(
377
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
378
+ url,
379
+ _("Visit"),
380
+ )
381
+
382
+ host_values = obj.get_remote_host_candidates()
383
+
384
+ remote_url = ""
385
+ for host in host_values:
386
+ temp_node = SimpleNamespace(
387
+ public_endpoint=host,
388
+ address="",
389
+ hostname="",
390
+ port=obj.port,
391
+ )
392
+ remote_url = next(self._iter_remote_urls(temp_node, "/admin/"), "")
393
+ if remote_url:
394
+ break
395
+
396
+ if not remote_url:
397
+ return ""
398
+
399
+ return format_html(
400
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
401
+ remote_url,
402
+ _("Visit"),
403
+ )
404
+
293
405
  def get_urls(self):
294
406
  urls = super().get_urls()
295
407
  custom = [
@@ -330,7 +442,20 @@ class NodeAdmin(EntityModelAdmin):
330
442
  "token": token,
331
443
  "register_url": reverse("register-node"),
332
444
  }
333
- return render(request, "admin/nodes/node/register_remote.html", context)
445
+ response = TemplateResponse(
446
+ request, "admin/nodes/node/register_remote.html", context
447
+ )
448
+ response.render()
449
+ template = response.resolve_template(response.template_name)
450
+ if getattr(template, "name", None) in (None, ""):
451
+ template.name = response.template_name
452
+ signals.template_rendered.send(
453
+ sender=template.__class__,
454
+ template=template,
455
+ context=response.context_data,
456
+ request=request,
457
+ )
458
+ return response
334
459
 
335
460
  @admin.action(description="Register Visitor")
336
461
  def register_visitor(self, request, queryset=None):
@@ -354,6 +479,391 @@ class NodeAdmin(EntityModelAdmin):
354
479
  request, "admin/nodes/node/update_selected.html", context
355
480
  )
356
481
 
482
+ @admin.action(description=_("Send Net Message"))
483
+ def send_net_message(self, request, queryset):
484
+ is_submit = "apply" in request.POST
485
+ form = self.SendNetMessageForm(request.POST if is_submit else None)
486
+ selected_ids = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
487
+ if not selected_ids:
488
+ selected_ids = [str(pk) for pk in queryset.values_list("pk", flat=True)]
489
+ nodes: list[Node] = []
490
+ cleaned_ids: list[int] = []
491
+ for value in selected_ids:
492
+ try:
493
+ cleaned_ids.append(int(value))
494
+ except (TypeError, ValueError):
495
+ continue
496
+ if cleaned_ids:
497
+ base_queryset = self.get_queryset(request).filter(pk__in=cleaned_ids)
498
+ nodes_by_pk = {str(node.pk): node for node in base_queryset}
499
+ nodes = [nodes_by_pk[value] for value in selected_ids if value in nodes_by_pk]
500
+ if not nodes:
501
+ nodes = list(queryset)
502
+ selected_ids = [str(node.pk) for node in nodes]
503
+ if not nodes:
504
+ self.message_user(request, _("No nodes selected."), messages.INFO)
505
+ return None
506
+ if is_submit and form.is_valid():
507
+ subject = form.cleaned_data["subject"]
508
+ body = form.cleaned_data["body"]
509
+ created = 0
510
+ for node in nodes:
511
+ message = NetMessage.objects.create(
512
+ subject=subject,
513
+ body=body,
514
+ filter_node=node,
515
+ )
516
+ message.propagate()
517
+ created += 1
518
+ if created:
519
+ success_message = ngettext(
520
+ "Sent %(count)d net message.",
521
+ "Sent %(count)d net messages.",
522
+ created,
523
+ ) % {"count": created}
524
+ self.message_user(request, success_message, messages.SUCCESS)
525
+ else:
526
+ self.message_user(
527
+ request, _("No net messages were sent."), messages.INFO
528
+ )
529
+ return None
530
+ context = {
531
+ **self.admin_site.each_context(request),
532
+ "opts": self.model._meta,
533
+ "title": _("Send Net Message"),
534
+ "nodes": nodes,
535
+ "selected_ids": selected_ids,
536
+ "action_name": request.POST.get("action", "send_net_message"),
537
+ "select_across": request.POST.get("select_across", "0"),
538
+ "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
539
+ "adminform": helpers.AdminForm(
540
+ form,
541
+ [(None, {"fields": ("subject", "body")})],
542
+ {},
543
+ ),
544
+ "form": form,
545
+ "media": self.media + form.media,
546
+ }
547
+ return TemplateResponse(
548
+ request, "admin/nodes/node/send_net_message.html", context
549
+ )
550
+
551
+ def _coerce_metadata_value(self, value):
552
+ if isinstance(value, (str, int, float, bool)) or value is None:
553
+ return value
554
+ if isinstance(value, (bytes, bytearray)):
555
+ return base64.b64encode(bytes(value)).decode("ascii")
556
+ if isinstance(value, Mapping):
557
+ return {k: self._coerce_metadata_value(v) for k, v in value.items()}
558
+ if isinstance(value, (list, tuple, set)):
559
+ return [self._coerce_metadata_value(v) for v in value]
560
+ return str(value)
561
+
562
+ def _decode_payload_bytes(self, value, encoding_hint: str = ""):
563
+ if isinstance(value, (bytes, bytearray)):
564
+ return bytes(value), encoding_hint or "binary"
565
+ if not isinstance(value, str):
566
+ return None, encoding_hint
567
+ text = value.strip()
568
+ if not text:
569
+ return b"", encoding_hint or "binary"
570
+ try:
571
+ decoded = base64.b64decode(text, validate=True)
572
+ return decoded, "base64"
573
+ except (binascii.Error, ValueError):
574
+ return None, encoding_hint
575
+
576
+ def _extract_firmware_payload(self, data):
577
+ content_type = "application/octet-stream"
578
+ encoding = ""
579
+ filename = ""
580
+ json_payload = None
581
+ binary_payload = None
582
+ metadata: dict[str, object] = {}
583
+
584
+ if isinstance(data, Mapping):
585
+ metadata = {
586
+ key: self._coerce_metadata_value(value)
587
+ for key, value in data.items()
588
+ if key not in {"payload", "data", "json"}
589
+ }
590
+ filename = str(data.get("filename") or data.get("name") or "").strip()
591
+ if data.get("contentType"):
592
+ content_type_candidate = str(data.get("contentType")).strip()
593
+ if content_type_candidate:
594
+ content_type = content_type_candidate
595
+ encoding = str(data.get("encoding") or "").strip()
596
+ raw_payload = data.get("payload")
597
+ if raw_payload is None:
598
+ raw_payload = data.get("data")
599
+ if raw_payload is not None:
600
+ binary_payload, encoding = self._decode_payload_bytes(
601
+ raw_payload, encoding
602
+ )
603
+ json_candidate = data.get("json")
604
+ if json_candidate is not None:
605
+ if isinstance(json_candidate, str):
606
+ try:
607
+ json_payload = json.loads(json_candidate)
608
+ except json.JSONDecodeError:
609
+ metadata["json_raw"] = json_candidate
610
+ else:
611
+ json_payload = json_candidate
612
+ if json_payload is None and binary_payload is None:
613
+ remaining = {
614
+ key: value
615
+ for key, value in data.items()
616
+ if key
617
+ not in {
618
+ "payload",
619
+ "data",
620
+ "encoding",
621
+ "contentType",
622
+ "filename",
623
+ "json",
624
+ }
625
+ }
626
+ if remaining:
627
+ json_payload = remaining
628
+ elif isinstance(data, (bytes, bytearray)):
629
+ binary_payload = bytes(data)
630
+ encoding = encoding or "binary"
631
+ elif isinstance(data, str):
632
+ metadata = {"raw": data}
633
+ binary_payload, encoding = self._decode_payload_bytes(data, encoding)
634
+ if binary_payload is None:
635
+ try:
636
+ json_payload = json.loads(data)
637
+ except json.JSONDecodeError:
638
+ binary_payload = data.encode("utf-8")
639
+ encoding = encoding or "utf-8"
640
+ elif data is not None:
641
+ metadata = {"raw": self._coerce_metadata_value(data)}
642
+ json_payload = metadata.get("raw")
643
+
644
+ return {
645
+ "binary": binary_payload,
646
+ "json": json_payload,
647
+ "encoding": encoding,
648
+ "content_type": content_type,
649
+ "filename": filename,
650
+ "metadata": metadata,
651
+ }
652
+
653
+ def _format_pending_failure(self, action: str, result: Mapping) -> str:
654
+ label_map = {
655
+ "DataTransfer": _("Data transfer"),
656
+ "UpdateFirmware": _("Update firmware"),
657
+ }
658
+ action_label = label_map.get(action, action)
659
+ error_code = str(result.get("error_code") or "").strip()
660
+ error_description = str(result.get("error_description") or "").strip()
661
+ details = result.get("error_details")
662
+ parts: list[str] = []
663
+ if error_code:
664
+ parts.append(_("code=%(code)s") % {"code": error_code})
665
+ if error_description:
666
+ parts.append(
667
+ _("description=%(description)s")
668
+ % {"description": error_description}
669
+ )
670
+ if details:
671
+ try:
672
+ details_text = json.dumps(
673
+ details, sort_keys=True, ensure_ascii=False
674
+ )
675
+ except TypeError:
676
+ details_text = str(details)
677
+ if details_text:
678
+ parts.append(_("details=%(details)s") % {"details": details_text})
679
+ if parts:
680
+ return _("%(action)s failed: %(details)s") % {
681
+ "action": action_label,
682
+ "details": ", ".join(parts),
683
+ }
684
+ return _("%(action)s failed.") % {"action": action_label}
685
+
686
+ def _process_firmware_download(self, request, node: Node, cleaned_data) -> bool:
687
+ charger: Charger = cleaned_data["charger"]
688
+ vendor_id = cleaned_data.get("vendor_id", "")
689
+ connection = store.get_connection(charger.charger_id, charger.connector_id)
690
+ if connection is None:
691
+ self.message_user(
692
+ request,
693
+ _("%(charger)s is not currently connected to the platform.")
694
+ % {"charger": charger},
695
+ level=messages.ERROR,
696
+ )
697
+ return False
698
+
699
+ message_id = uuid.uuid4().hex
700
+ payload = {
701
+ "vendorId": vendor_id,
702
+ "messageId": "DownloadFirmware",
703
+ }
704
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
705
+ message_record = DataTransferMessage.objects.create(
706
+ charger=charger,
707
+ connector_id=charger.connector_id,
708
+ direction=DataTransferMessage.DIRECTION_CSMS_TO_CP,
709
+ ocpp_message_id=message_id,
710
+ vendor_id=vendor_id,
711
+ message_id="DownloadFirmware",
712
+ payload=payload,
713
+ status="Pending",
714
+ )
715
+
716
+ frame = json.dumps([2, message_id, "DataTransfer", payload])
717
+ async_to_sync(connection.send)(frame)
718
+ store.add_log(
719
+ log_key,
720
+ _("Requested firmware download via DataTransfer."),
721
+ log_type="charger",
722
+ )
723
+ store.register_pending_call(
724
+ message_id,
725
+ {
726
+ "action": "DataTransfer",
727
+ "charger_id": charger.charger_id,
728
+ "connector_id": charger.connector_id,
729
+ "log_key": log_key,
730
+ "message_pk": message_record.pk,
731
+ },
732
+ )
733
+ store.schedule_call_timeout(
734
+ message_id, action="DataTransfer", log_key=log_key
735
+ )
736
+
737
+ result = store.wait_for_pending_call(message_id, timeout=15.0)
738
+ if result is None:
739
+ self.message_user(
740
+ request,
741
+ _("The charge point did not respond to the firmware request."),
742
+ level=messages.ERROR,
743
+ )
744
+ return False
745
+ if not result.get("success", True):
746
+ detail = self._format_pending_failure("DataTransfer", result)
747
+ self.message_user(request, detail, level=messages.ERROR)
748
+ return False
749
+
750
+ payload_data = result.get("payload") or {}
751
+ status_value = str(payload_data.get("status") or "").strip()
752
+ if status_value.lower() != "accepted":
753
+ self.message_user(
754
+ request,
755
+ _(
756
+ "Firmware request for %(charger)s was %(status)s."
757
+ )
758
+ % {"charger": charger, "status": status_value or "Rejected"},
759
+ level=messages.ERROR,
760
+ )
761
+ return False
762
+
763
+ data_section = payload_data.get("data")
764
+ extracted = self._extract_firmware_payload(data_section)
765
+ binary_payload = extracted["binary"]
766
+ json_payload = extracted["json"]
767
+ if binary_payload is None and json_payload is None:
768
+ self.message_user(
769
+ request,
770
+ _("The charge point did not include a firmware payload."),
771
+ level=messages.ERROR,
772
+ )
773
+ return False
774
+
775
+ now = timezone.now()
776
+ filename = extracted["filename"] or ""
777
+ if not filename:
778
+ suffix = ".bin" if binary_payload is not None else ".json"
779
+ filename = f"{charger.charger_id}_{now:%Y%m%d%H%M%S}{suffix}"
780
+
781
+ metadata = {
782
+ "vendor_id": vendor_id,
783
+ "response": self._coerce_metadata_value(payload_data),
784
+ }
785
+ metadata.update(extracted["metadata"])
786
+
787
+ firmware = CPFirmware(
788
+ name=f"{charger.charger_id} firmware {now:%Y-%m-%d %H:%M:%S}",
789
+ source=CPFirmware.Source.DOWNLOAD,
790
+ source_node=node,
791
+ source_charger=charger,
792
+ filename=filename,
793
+ payload_binary=binary_payload,
794
+ payload_json=json_payload,
795
+ payload_encoding=extracted["encoding"],
796
+ content_type=extracted["content_type"],
797
+ metadata=metadata,
798
+ download_vendor_id=vendor_id,
799
+ download_message_id=message_id,
800
+ downloaded_at=now,
801
+ is_user_data=True,
802
+ )
803
+ firmware.save()
804
+
805
+ self.message_user(
806
+ request,
807
+ _("Stored firmware from %(charger)s as %(firmware)s.")
808
+ % {"charger": charger, "firmware": firmware},
809
+ level=messages.SUCCESS,
810
+ )
811
+ return True
812
+
813
+ @admin.action(description=_("Download EVCS firmware"))
814
+ def download_evcs_firmware(self, request, queryset):
815
+ nodes = list(queryset)
816
+ if len(nodes) != 1:
817
+ self.message_user(
818
+ request,
819
+ _("Select a single node to request firmware."),
820
+ level=messages.ERROR,
821
+ )
822
+ return None
823
+ node = nodes[0]
824
+
825
+ if "apply" in request.POST:
826
+ form = self.DownloadFirmwareForm(node, request.POST)
827
+ if form.is_valid():
828
+ if self._process_firmware_download(request, node, form.cleaned_data):
829
+ return None
830
+ else:
831
+ form = self.DownloadFirmwareForm(node)
832
+
833
+ context = {
834
+ **self.admin_site.each_context(request),
835
+ "opts": self.model._meta,
836
+ "title": _("Download EVCS firmware"),
837
+ "node": node,
838
+ "nodes": [node],
839
+ "selected_ids": [str(node.pk)],
840
+ "action_name": request.POST.get(
841
+ "action", "download_evcs_firmware"
842
+ ),
843
+ "select_across": request.POST.get("select_across", "0"),
844
+ "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
845
+ "adminform": helpers.AdminForm(
846
+ form,
847
+ [
848
+ (
849
+ None,
850
+ {
851
+ "fields": (
852
+ "charger",
853
+ "vendor_id",
854
+ )
855
+ },
856
+ )
857
+ ],
858
+ {},
859
+ ),
860
+ "form": form,
861
+ "media": self.media + form.media,
862
+ }
863
+ return TemplateResponse(
864
+ request, "admin/nodes/node/download_firmware.html", context
865
+ )
866
+
357
867
  def update_selected_progress(self, request):
358
868
  if request.method != "POST":
359
869
  return JsonResponse({"detail": "POST required"}, status=405)
@@ -398,6 +908,7 @@ class NodeAdmin(EntityModelAdmin):
398
908
  }
399
909
 
400
910
  last_error = ""
911
+ host_candidates = node.get_remote_host_candidates()
401
912
  for url in self._iter_remote_urls(node, "/nodes/info/"):
402
913
  try:
403
914
  response = requests.get(url, timeout=5)
@@ -424,13 +935,19 @@ class NodeAdmin(EntityModelAdmin):
424
935
  "updated_fields": updated,
425
936
  "message": message,
426
937
  }
427
- return {"ok": False, "message": last_error or "Unable to reach remote node."}
938
+ return {
939
+ "ok": False,
940
+ "message": self._build_connectivity_hint(last_error, host_candidates),
941
+ }
428
942
 
429
943
  def _apply_remote_node_info(self, node, payload):
430
944
  changed = []
431
945
  field_map = {
432
946
  "hostname": payload.get("hostname"),
947
+ "network_hostname": payload.get("network_hostname"),
433
948
  "address": payload.get("address"),
949
+ "ipv4_address": payload.get("ipv4_address"),
950
+ "ipv6_address": payload.get("ipv6_address"),
434
951
  "public_key": payload.get("public_key"),
435
952
  }
436
953
  port_value = payload.get("port")
@@ -451,6 +968,17 @@ class NodeAdmin(EntityModelAdmin):
451
968
  setattr(node, field, value)
452
969
  changed.append(field)
453
970
 
971
+ role_value = payload.get("role") or payload.get("role_name")
972
+ if role_value is not None:
973
+ role_name = str(role_value).strip()
974
+ if role_name:
975
+ desired_role = NodeRole.objects.filter(name=role_name).first()
976
+ else:
977
+ desired_role = None
978
+ if desired_role and node.role_id != desired_role.id:
979
+ node.role = desired_role
980
+ changed.append("role")
981
+
454
982
  node.last_seen = timezone.now()
455
983
  if "last_seen" not in changed:
456
984
  changed.append("last_seen")
@@ -497,7 +1025,10 @@ class NodeAdmin(EntityModelAdmin):
497
1025
 
498
1026
  payload = {
499
1027
  "hostname": local_node.hostname,
1028
+ "network_hostname": local_node.network_hostname,
500
1029
  "address": local_node.address,
1030
+ "ipv4_address": local_node.ipv4_address,
1031
+ "ipv6_address": local_node.ipv6_address,
501
1032
  "port": local_node.port,
502
1033
  "mac_address": local_node.mac_address,
503
1034
  "public_key": local_node.public_key,
@@ -513,6 +1044,7 @@ class NodeAdmin(EntityModelAdmin):
513
1044
  headers = {"Content-Type": "application/json"}
514
1045
 
515
1046
  last_error = ""
1047
+ host_candidates = node.get_remote_host_candidates()
516
1048
  for url in self._iter_remote_urls(node, "/nodes/register/"):
517
1049
  try:
518
1050
  response = requests.post(
@@ -527,46 +1059,52 @@ class NodeAdmin(EntityModelAdmin):
527
1059
  if response.ok:
528
1060
  return {"ok": True, "url": url, "message": "Remote updated."}
529
1061
  last_error = f"{response.status_code} {response.text}"
530
- return {"ok": False, "message": last_error or "Unable to reach remote node."}
1062
+ return {
1063
+ "ok": False,
1064
+ "message": self._build_connectivity_hint(last_error, host_candidates),
1065
+ }
1066
+
1067
+ def _build_connectivity_hint(self, last_error: str, hosts: list[str]) -> str:
1068
+ base_message = last_error or _("Unable to reach remote node.")
1069
+ if hosts:
1070
+ host_text = ", ".join(hosts)
1071
+ return _("%(message)s Tried hosts: %(hosts)s.") % {
1072
+ "message": base_message,
1073
+ "hosts": host_text,
1074
+ }
1075
+ return _("%(message)s No remote hosts were available for contact.") % {
1076
+ "message": base_message
1077
+ }
1078
+
1079
+ def _primary_remote_url(self, node, path: str) -> str:
1080
+ return next(self._iter_remote_urls(node, path), "")
1081
+
1082
+ def _request_remote(self, node, path: str, request_callable):
1083
+ errors: list[str] = []
1084
+ for url in self._iter_remote_urls(node, path):
1085
+ try:
1086
+ response = request_callable(url)
1087
+ except RequestException as exc:
1088
+ errors.append(f"{url}: {exc}")
1089
+ continue
1090
+ return url, response, errors
1091
+ return "", None, errors
531
1092
 
532
1093
  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
- ]
1094
+ if hasattr(node, "iter_remote_urls"):
1095
+ yield from node.iter_remote_urls(path)
1096
+ return
565
1097
 
566
- for candidate in candidates:
567
- if candidate not in seen:
568
- seen.add(candidate)
569
- yield candidate
1098
+ temp = Node(
1099
+ public_endpoint=getattr(node, "public_endpoint", ""),
1100
+ address=getattr(node, "address", ""),
1101
+ hostname=getattr(node, "hostname", ""),
1102
+ port=getattr(node, "port", None),
1103
+ )
1104
+ temp.network_hostname = getattr(node, "network_hostname", "")
1105
+ temp.ipv4_address = getattr(node, "ipv4_address", "")
1106
+ temp.ipv6_address = getattr(node, "ipv6_address", "")
1107
+ yield from temp.iter_remote_urls(path)
570
1108
 
571
1109
  def register_visitor_view(self, request):
572
1110
  """Exchange registration data with the visiting node."""
@@ -585,8 +1123,8 @@ class NodeAdmin(EntityModelAdmin):
585
1123
  "token": token,
586
1124
  "info_url": reverse("node-info"),
587
1125
  "register_url": reverse("register-node"),
588
- "visitor_info_url": "http://localhost:8000/nodes/info/",
589
- "visitor_register_url": "http://localhost:8000/nodes/register/",
1126
+ "visitor_info_url": "http://localhost:8888/nodes/info/",
1127
+ "visitor_register_url": "http://localhost:8888/nodes/register/",
590
1128
  }
591
1129
  return render(request, "admin/nodes/node/register_visitor.html", context)
592
1130
 
@@ -640,11 +1178,28 @@ class NodeAdmin(EntityModelAdmin):
640
1178
  for node in queryset:
641
1179
  for source in sources:
642
1180
  try:
643
- url = source.format(node=node, address=node.address, port=node.port)
1181
+ contact_host = node.get_primary_contact()
1182
+ url = source.format(
1183
+ node=node, address=contact_host, port=node.port
1184
+ )
644
1185
  except Exception:
645
1186
  url = source
646
1187
  if not url.startswith("http"):
647
- url = f"http://{node.address}:{node.port}{url}"
1188
+ candidate = next(
1189
+ self._iter_remote_urls(node, url),
1190
+ "",
1191
+ )
1192
+ if not candidate:
1193
+ self.message_user(
1194
+ request,
1195
+ _(
1196
+ "No reachable host was available for %(node)s while generating %(path)s"
1197
+ )
1198
+ % {"node": node, "path": url},
1199
+ messages.WARNING,
1200
+ )
1201
+ continue
1202
+ url = candidate
648
1203
  try:
649
1204
  path = capture_screenshot(url)
650
1205
  except Exception as exc: # pragma: no cover - selenium issues
@@ -742,6 +1297,7 @@ class NodeAdmin(EntityModelAdmin):
742
1297
  def _render_rfid_sync(self, request, operation, results, setup_error=None):
743
1298
  titles = {
744
1299
  "import": _("Import RFID results"),
1300
+ "fetch": _("Fetch RFID results"),
745
1301
  "export": _("Export RFID results"),
746
1302
  }
747
1303
  summary = self._summarize_rfid_results(results)
@@ -763,12 +1319,19 @@ class NodeAdmin(EntityModelAdmin):
763
1319
 
764
1320
  def _process_import_from_node(self, node, payload, headers):
765
1321
  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:
1322
+ _, response, attempt_errors = self._request_remote(
1323
+ node,
1324
+ "/nodes/rfid/export/",
1325
+ lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
1326
+ )
1327
+ if response is None:
770
1328
  result["status"] = "error"
771
- result["errors"].append(str(exc))
1329
+ if attempt_errors:
1330
+ result["errors"].extend(attempt_errors)
1331
+ else:
1332
+ result["errors"].append(
1333
+ _("No remote hosts were available for %(node)s.") % {"node": node}
1334
+ )
772
1335
  return result
773
1336
 
774
1337
  if response.status_code != 200:
@@ -808,12 +1371,19 @@ class NodeAdmin(EntityModelAdmin):
808
1371
 
809
1372
  def _post_export_to_node(self, node, payload, headers):
810
1373
  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:
1374
+ _, response, attempt_errors = self._request_remote(
1375
+ node,
1376
+ "/nodes/rfid/import/",
1377
+ lambda url: requests.post(url, data=payload, headers=headers, timeout=5),
1378
+ )
1379
+ if response is None:
815
1380
  result["status"] = "error"
816
- result["errors"].append(str(exc))
1381
+ if attempt_errors:
1382
+ result["errors"].extend(attempt_errors)
1383
+ else:
1384
+ result["errors"].append(
1385
+ _("No remote hosts were available for %(node)s.") % {"node": node}
1386
+ )
817
1387
  return result
818
1388
 
819
1389
  if response.status_code != 200:
@@ -851,13 +1421,14 @@ class NodeAdmin(EntityModelAdmin):
851
1421
  result["status"] = self._status_from_result(result)
852
1422
  return result
853
1423
 
854
- @admin.action(description=_("Import RFIDs from selected"))
855
- def import_rfids_from_selected(self, request, queryset):
1424
+ def _run_rfid_import(self, request, queryset):
856
1425
  nodes = list(queryset)
857
1426
  local_node, private_key, error = self._load_local_node_credentials()
858
1427
  if error:
859
1428
  results = [self._skip_result(node, error) for node in nodes]
860
- return self._render_rfid_sync(request, "import", results, setup_error=error)
1429
+ return self._render_rfid_sync(
1430
+ request, "import", results, setup_error=error
1431
+ )
861
1432
 
862
1433
  if not nodes:
863
1434
  return self._render_rfid_sync(
@@ -887,6 +1458,10 @@ class NodeAdmin(EntityModelAdmin):
887
1458
 
888
1459
  return self._render_rfid_sync(request, "import", results)
889
1460
 
1461
+ @admin.action(description=_("Import RFIDs from selected"))
1462
+ def import_rfids_from_selected(self, request, queryset):
1463
+ return self._run_rfid_import(request, queryset)
1464
+
890
1465
  @admin.action(description=_("Export RFIDs to selected"))
891
1466
  def export_rfids_to_selected(self, request, queryset):
892
1467
  nodes = list(queryset)
@@ -924,6 +1499,269 @@ class NodeAdmin(EntityModelAdmin):
924
1499
 
925
1500
  return self._render_rfid_sync(request, "export", results)
926
1501
 
1502
+ async def _probe_websocket(self, url: str) -> bool:
1503
+ try:
1504
+ async with websockets.connect(url, open_timeout=3, close_timeout=1):
1505
+ return True
1506
+ except Exception:
1507
+ return False
1508
+
1509
+ def _attempt_forwarding_probe(self, node, charger_id: str) -> bool:
1510
+ if not charger_id:
1511
+ return False
1512
+ safe_id = quote(str(charger_id))
1513
+ candidates: list[str] = []
1514
+ for base in node.iter_remote_urls("/"):
1515
+ parsed = urlsplit(base)
1516
+ if parsed.scheme not in {"http", "https"}:
1517
+ continue
1518
+ scheme = "wss" if parsed.scheme == "https" else "ws"
1519
+ base_path = parsed.path.rstrip("/")
1520
+ for prefix in ("", "/ws"):
1521
+ path = f"{base_path}{prefix}/{safe_id}".replace("//", "/")
1522
+ if not path.startswith("/"):
1523
+ path = f"/{path}"
1524
+ candidates.append(urlunsplit((scheme, parsed.netloc, path, "", "")))
1525
+
1526
+ for url in candidates:
1527
+ loop = asyncio.new_event_loop()
1528
+ try:
1529
+ result = loop.run_until_complete(self._probe_websocket(url))
1530
+ except Exception:
1531
+ result = False
1532
+ finally:
1533
+ loop.close()
1534
+ if result:
1535
+ return True
1536
+ return False
1537
+
1538
+ def _send_forwarding_metadata(
1539
+ self,
1540
+ request,
1541
+ node: Node,
1542
+ chargers: list[Charger],
1543
+ local_node: Node,
1544
+ private_key,
1545
+ ) -> bool:
1546
+ if not chargers:
1547
+ return True
1548
+ payload = {
1549
+ "requester": str(local_node.uuid),
1550
+ "requester_mac": local_node.mac_address,
1551
+ "requester_public_key": local_node.public_key,
1552
+ "chargers": [serialize_charger_for_network(charger) for charger in chargers],
1553
+ "transactions": {"chargers": [], "transactions": []},
1554
+ }
1555
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1556
+ signature = self._sign_payload(private_key, payload_json)
1557
+ headers = {"Content-Type": "application/json"}
1558
+ if signature:
1559
+ headers["X-Signature"] = signature
1560
+
1561
+ errors: list[str] = []
1562
+ for url in node.iter_remote_urls("/nodes/network/chargers/forward/"):
1563
+ if not url:
1564
+ continue
1565
+ try:
1566
+ response = requests.post(
1567
+ url, data=payload_json, headers=headers, timeout=5
1568
+ )
1569
+ except RequestException as exc:
1570
+ errors.append(
1571
+ _(
1572
+ "Failed to send forwarding metadata to %(node)s via %(url)s (%(error)s)."
1573
+ )
1574
+ % {"node": node, "url": url, "error": exc}
1575
+ )
1576
+ continue
1577
+
1578
+ try:
1579
+ data = response.json()
1580
+ except ValueError:
1581
+ data = {}
1582
+
1583
+ if response.ok and isinstance(data, Mapping) and data.get("status") == "ok":
1584
+ return True
1585
+
1586
+ detail = ""
1587
+ if isinstance(data, Mapping):
1588
+ detail = data.get("detail") or ""
1589
+ errors.append(
1590
+ _("Forwarding metadata to %(node)s via %(url)s failed: %(status)s %(detail)s")
1591
+ % {
1592
+ "node": node,
1593
+ "url": url,
1594
+ "status": response.status_code,
1595
+ "detail": detail,
1596
+ }
1597
+ )
1598
+
1599
+ if not errors:
1600
+ self.message_user(
1601
+ request,
1602
+ _("No reachable host found for %(node)s.") % {"node": node},
1603
+ level=messages.WARNING,
1604
+ )
1605
+ else:
1606
+ self.message_user(request, errors[-1].strip(), level=messages.WARNING)
1607
+ return False
1608
+
1609
+ @admin.action(description=_("Start Charge Point Forwarding"))
1610
+ def start_charge_point_forwarding(self, request, queryset):
1611
+ if queryset.count() != 1:
1612
+ self.message_user(
1613
+ request,
1614
+ _("Select a single remote node."),
1615
+ level=messages.ERROR,
1616
+ )
1617
+ return
1618
+
1619
+ target = queryset.first()
1620
+ local_node, private_key, error = self._load_local_node_credentials()
1621
+ if error:
1622
+ self.message_user(request, error, level=messages.ERROR)
1623
+ return
1624
+
1625
+ if local_node.pk and target.pk == local_node.pk:
1626
+ self.message_user(
1627
+ request,
1628
+ _("Cannot forward charge points to the local node."),
1629
+ level=messages.ERROR,
1630
+ )
1631
+ return
1632
+
1633
+ eligible = Charger.objects.filter(export_transactions=True)
1634
+ if local_node.pk:
1635
+ eligible = eligible.filter(
1636
+ Q(node_origin=local_node) | Q(node_origin__isnull=True)
1637
+ )
1638
+
1639
+ chargers = list(eligible.select_related("forwarded_to"))
1640
+ if not chargers:
1641
+ self.message_user(
1642
+ request,
1643
+ _("No eligible charge points available for forwarding."),
1644
+ level=messages.WARNING,
1645
+ )
1646
+ return
1647
+
1648
+ conflicts = [
1649
+ charger
1650
+ for charger in chargers
1651
+ if charger.forwarded_to_id
1652
+ and charger.forwarded_to_id not in {None, target.pk}
1653
+ ]
1654
+ if conflicts:
1655
+ self.message_user(
1656
+ request,
1657
+ ngettext(
1658
+ "Skipped %(count)s charge point already forwarded to another node.",
1659
+ "Skipped %(count)s charge points already forwarded to another node.",
1660
+ len(conflicts),
1661
+ )
1662
+ % {"count": len(conflicts)},
1663
+ level=messages.WARNING,
1664
+ )
1665
+
1666
+ chargers_to_update = [
1667
+ charger
1668
+ for charger in chargers
1669
+ if charger.forwarded_to_id in (None, target.pk)
1670
+ ]
1671
+ if not chargers_to_update:
1672
+ self.message_user(
1673
+ request,
1674
+ _("No charge points were updated."),
1675
+ level=messages.WARNING,
1676
+ )
1677
+ return
1678
+
1679
+ charger_pks = [c.pk for c in chargers_to_update]
1680
+ Charger.objects.filter(pk__in=charger_pks).update(forwarded_to=target)
1681
+
1682
+ for charger in chargers_to_update:
1683
+ charger.forwarded_to = target
1684
+
1685
+ sample = next((charger for charger in chargers_to_update if charger.charger_id), None)
1686
+ if sample and not self._attempt_forwarding_probe(target, sample.charger_id):
1687
+ self.message_user(
1688
+ request,
1689
+ _(
1690
+ "Unable to establish a websocket connection to %(node)s for charge point %(charger)s."
1691
+ )
1692
+ % {"node": target, "charger": sample.charger_id},
1693
+ level=messages.WARNING,
1694
+ )
1695
+
1696
+ success = self._send_forwarding_metadata(
1697
+ request, target, chargers_to_update, local_node, private_key
1698
+ )
1699
+
1700
+ if success:
1701
+ now = timezone.now()
1702
+ Charger.objects.filter(pk__in=charger_pks).update(
1703
+ forwarding_watermark=now
1704
+ )
1705
+ self.message_user(
1706
+ request,
1707
+ ngettext(
1708
+ "Forwarding enabled for %(count)s charge point.",
1709
+ "Forwarding enabled for %(count)s charge points.",
1710
+ len(chargers_to_update),
1711
+ )
1712
+ % {"count": len(chargers_to_update)},
1713
+ level=messages.SUCCESS,
1714
+ )
1715
+ else:
1716
+ self.message_user(
1717
+ request,
1718
+ ngettext(
1719
+ "Marked %(count)s charge point for forwarding; awaiting remote acknowledgment.",
1720
+ "Marked %(count)s charge points for forwarding; awaiting remote acknowledgment.",
1721
+ len(chargers_to_update),
1722
+ )
1723
+ % {"count": len(chargers_to_update)},
1724
+ level=messages.INFO,
1725
+ )
1726
+
1727
+ try:
1728
+ push_forwarded_charge_points.delay()
1729
+ except Exception:
1730
+ pass
1731
+
1732
+ @admin.action(description=_("Stop Charge Point Forwarding"))
1733
+ def stop_charge_point_forwarding(self, request, queryset):
1734
+ node_ids = [node.pk for node in queryset if node.pk]
1735
+ if not node_ids:
1736
+ self.message_user(
1737
+ request,
1738
+ _("No remote nodes selected."),
1739
+ level=messages.WARNING,
1740
+ )
1741
+ return
1742
+
1743
+ cleared = Charger.objects.filter(forwarded_to_id__in=node_ids).update(
1744
+ forwarded_to=None, forwarding_watermark=None
1745
+ )
1746
+
1747
+ if cleared:
1748
+ self.message_user(
1749
+ request,
1750
+ ngettext(
1751
+ "Stopped forwarding for %(count)s charge point.",
1752
+ "Stopped forwarding for %(count)s charge points.",
1753
+ cleared,
1754
+ )
1755
+ % {"count": cleared},
1756
+ level=messages.SUCCESS,
1757
+ )
1758
+ else:
1759
+ self.message_user(
1760
+ request,
1761
+ _("No forwarded charge points were updated."),
1762
+ level=messages.WARNING,
1763
+ )
1764
+
927
1765
  def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
928
1766
  extra_context = extra_context or {}
929
1767
  if object_id:
@@ -1565,7 +2403,7 @@ class NetMessageAdmin(EntityModelAdmin):
1565
2403
  search_fields = ("subject", "body")
1566
2404
  list_filter = ("complete", "filter_node_role", "filter_current_relation")
1567
2405
  ordering = ("-created",)
1568
- readonly_fields = ("complete", "confirmed_peers")
2406
+ readonly_fields = ("complete",)
1569
2407
  actions = ["send_messages"]
1570
2408
  fieldsets = (
1571
2409
  (None, {"fields": ("subject", "body")}),
@@ -1590,7 +2428,6 @@ class NetMessageAdmin(EntityModelAdmin):
1590
2428
  "node_origin",
1591
2429
  "target_limit",
1592
2430
  "propagated_to",
1593
- "confirmed_peers",
1594
2431
  "complete",
1595
2432
  )
1596
2433
  },