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

ocpp/admin.py CHANGED
@@ -1,18 +1,22 @@
1
1
  from django.contrib import admin, messages
2
+ from django.contrib.admin import helpers
2
3
  from django import forms
3
4
 
4
5
  import asyncio
5
6
  import base64
6
7
  from datetime import datetime, time, timedelta
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse, unquote
10
+ import contextlib
7
11
  import json
8
12
  from typing import Any
9
-
10
13
  from django.shortcuts import redirect
11
14
  from django.utils import formats, timezone, translation
12
- from django.utils.translation import gettext_lazy as _
15
+ from django.utils.translation import gettext_lazy as _, ngettext
13
16
  from django.utils.dateparse import parse_datetime
14
- from django.utils.html import format_html
15
- from django.urls import path
17
+ from django.utils.html import format_html, format_html_join
18
+ from django.utils.text import slugify
19
+ from django.urls import path, reverse
16
20
  from django.http import HttpResponse, HttpResponseRedirect
17
21
  from django.template.response import TemplateResponse
18
22
 
@@ -24,16 +28,20 @@ from cryptography.hazmat.primitives import hashes
24
28
  from cryptography.hazmat.primitives.asymmetric import padding
25
29
  from django.db import transaction
26
30
  from django.core.exceptions import ValidationError
31
+ from django.conf import settings
27
32
 
28
33
  from .models import (
29
34
  Charger,
30
35
  ChargerConfiguration,
36
+ ConfigurationKey,
31
37
  Simulator,
32
38
  MeterValue,
33
39
  Transaction,
34
40
  Location,
35
41
  DataTransferMessage,
36
42
  CPReservation,
43
+ CPFirmware,
44
+ CPFirmwareDeployment,
37
45
  )
38
46
  from .simulator import ChargePointSimulator
39
47
  from . import store
@@ -44,6 +52,7 @@ from .transactions_io import (
44
52
  from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
45
53
  from .views import _charger_state, _live_sessions
46
54
  from core.admin import SaveBeforeChangeAction
55
+ from core.models import RFID as CoreRFID
47
56
  from core.user_data import EntityModelAdmin
48
57
  from nodes.models import Node
49
58
 
@@ -115,6 +124,64 @@ class CPReservationForm(forms.ModelForm):
115
124
  return cleaned
116
125
 
117
126
 
127
+ class UploadFirmwareForm(forms.Form):
128
+ def __init__(self, *args, **kwargs):
129
+ super().__init__(*args, **kwargs)
130
+ default_date = timezone.now() + timedelta(minutes=1)
131
+ if timezone.is_naive(default_date):
132
+ default_date = timezone.make_aware(
133
+ default_date, timezone.get_current_timezone()
134
+ )
135
+ self.fields["retrieve_date"].initial = timezone.localtime(default_date)
136
+ self.fields["chargers"].queryset = (
137
+ Charger.objects.filter(connector_id__isnull=True)
138
+ .order_by("display_name", "charger_id")
139
+ )
140
+
141
+ chargers = forms.ModelMultipleChoiceField(
142
+ label=_("Charge points"),
143
+ queryset=Charger.objects.none(),
144
+ help_text=_("Select the EVCS units to update."),
145
+ )
146
+ retrieve_date = forms.DateTimeField(
147
+ label=_("Retrieve date"),
148
+ required=False,
149
+ help_text=_("When the EVCS should start downloading the firmware."),
150
+ )
151
+ retries = forms.IntegerField(
152
+ label=_("Retries"),
153
+ required=False,
154
+ min_value=0,
155
+ initial=1,
156
+ help_text=_("Number of download attempts before giving up."),
157
+ )
158
+ retry_interval = forms.IntegerField(
159
+ label=_("Retry interval (seconds)"),
160
+ required=False,
161
+ min_value=0,
162
+ initial=600,
163
+ help_text=_("Seconds between retry attempts."),
164
+ )
165
+
166
+ def clean_retrieve_date(self):
167
+ value = self.cleaned_data.get("retrieve_date")
168
+ if value is None:
169
+ return None
170
+ if timezone.is_naive(value):
171
+ value = timezone.make_aware(value, timezone.get_current_timezone())
172
+ return value
173
+
174
+ def clean(self):
175
+ cleaned = super().clean()
176
+ chargers = cleaned.get("chargers")
177
+ if not chargers:
178
+ self.add_error(
179
+ "chargers",
180
+ _("Select at least one charge point to receive the firmware."),
181
+ )
182
+ return cleaned
183
+
184
+
118
185
  class LogViewAdminMixin:
119
186
  """Mixin providing an admin view to display charger or simulator logs."""
120
187
 
@@ -159,6 +226,44 @@ class LogViewAdminMixin:
159
226
  return TemplateResponse(request, self.log_template_name, context)
160
227
 
161
228
 
229
+ class ConfigurationKeyInline(admin.TabularInline):
230
+ model = ConfigurationKey
231
+ extra = 0
232
+ can_delete = False
233
+ ordering = ("position", "id")
234
+ readonly_fields = (
235
+ "position",
236
+ "key",
237
+ "readonly",
238
+ "value_display",
239
+ "extra_display",
240
+ )
241
+ fields = readonly_fields
242
+ show_change_link = False
243
+
244
+ def has_add_permission(self, request, obj=None): # pragma: no cover - admin hook
245
+ return False
246
+
247
+ @admin.display(description=_("Value"))
248
+ def value_display(self, obj):
249
+ if not obj.has_value:
250
+ return "-"
251
+ value = obj.value
252
+ if isinstance(value, (dict, list)):
253
+ formatted = json.dumps(value, indent=2, ensure_ascii=False)
254
+ return format_html("<pre>{}</pre>", formatted)
255
+ if value in (None, ""):
256
+ return "-"
257
+ return str(value)
258
+
259
+ @admin.display(description=_("Extra data"))
260
+ def extra_display(self, obj):
261
+ if not obj.extra_data:
262
+ return "-"
263
+ formatted = json.dumps(obj.extra_data, indent=2, ensure_ascii=False)
264
+ return format_html("<pre>{}</pre>", formatted)
265
+
266
+
162
267
  @admin.register(ChargerConfiguration)
163
268
  class ChargerConfigurationAdmin(admin.ModelAdmin):
164
269
  list_display = (
@@ -177,10 +282,10 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
177
282
  "created_at",
178
283
  "updated_at",
179
284
  "linked_chargers",
180
- "configuration_keys_display",
181
285
  "unknown_keys_display",
182
286
  "raw_payload_display",
183
287
  )
288
+ inlines = (ConfigurationKeyInline,)
184
289
  fieldsets = (
185
290
  (
186
291
  None,
@@ -200,7 +305,6 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
200
305
  "Payload",
201
306
  {
202
307
  "fields": (
203
- "configuration_keys_display",
204
308
  "unknown_keys_display",
205
309
  "raw_payload_display",
206
310
  )
@@ -231,10 +335,6 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
231
335
  formatted = json.dumps(data, indent=2, ensure_ascii=False)
232
336
  return format_html("<pre>{}</pre>", formatted)
233
337
 
234
- @admin.display(description="configurationKey")
235
- def configuration_keys_display(self, obj):
236
- return self._render_json(obj.configuration_keys)
237
-
238
338
  @admin.display(description="unknownKey")
239
339
  def unknown_keys_display(self, obj):
240
340
  return self._render_json(obj.unknown_keys)
@@ -254,6 +354,15 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
254
354
  super().save_model(request, obj, form, change)
255
355
 
256
356
 
357
+ @admin.register(ConfigurationKey)
358
+ class ConfigurationKeyAdmin(admin.ModelAdmin):
359
+ list_display = ("configuration", "key", "position", "readonly")
360
+ ordering = ("configuration", "position", "id")
361
+
362
+ def get_model_perms(self, request): # pragma: no cover - admin hook
363
+ return {}
364
+
365
+
257
366
  @admin.register(Location)
258
367
  class LocationAdmin(EntityModelAdmin):
259
368
  form = LocationAdminForm
@@ -300,9 +409,388 @@ class DataTransferMessageAdmin(admin.ModelAdmin):
300
409
  )
301
410
 
302
411
 
412
+ class CPFirmwareDeploymentInline(admin.TabularInline):
413
+ model = CPFirmwareDeployment
414
+ extra = 0
415
+ can_delete = False
416
+ ordering = ("-requested_at",)
417
+ readonly_fields = (
418
+ "charger",
419
+ "node",
420
+ "status",
421
+ "status_info",
422
+ "status_timestamp",
423
+ "retrieve_date",
424
+ "retry_count",
425
+ "retry_interval",
426
+ "download_token",
427
+ "download_token_expires_at",
428
+ "downloaded_at",
429
+ "requested_at",
430
+ "completed_at",
431
+ "ocpp_message_id",
432
+ )
433
+ show_change_link = True
434
+
435
+
436
+ def _format_failure_message(result: dict, *, action_label: str) -> str:
437
+ error_code = str(result.get("error_code") or "").strip()
438
+ error_description = str(result.get("error_description") or "").strip()
439
+ details = result.get("error_details")
440
+ parts: list[str] = []
441
+ if error_code:
442
+ parts.append(_("code=%(code)s") % {"code": error_code})
443
+ if error_description:
444
+ parts.append(
445
+ _("description=%(description)s") % {"description": error_description}
446
+ )
447
+ if details:
448
+ try:
449
+ details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
450
+ except TypeError:
451
+ details_text = str(details)
452
+ if details_text:
453
+ parts.append(_("details=%(details)s") % {"details": details_text})
454
+ if parts:
455
+ return _("%(action)s failed: %(details)s") % {
456
+ "action": action_label,
457
+ "details": ", ".join(parts),
458
+ }
459
+ return _("%(action)s failed.") % {"action": action_label}
460
+
461
+
462
+ @admin.register(CPFirmware)
463
+ class CPFirmwareAdmin(EntityModelAdmin):
464
+ list_display = (
465
+ "name",
466
+ "filename",
467
+ "content_type",
468
+ "payload_size",
469
+ "downloaded_at",
470
+ "source_node",
471
+ "source_charger",
472
+ )
473
+ list_filter = ("source", "content_type")
474
+ search_fields = (
475
+ "name",
476
+ "filename",
477
+ "source_charger__charger_id",
478
+ "source_charger__display_name",
479
+ )
480
+ readonly_fields = (
481
+ "source",
482
+ "source_node",
483
+ "source_charger",
484
+ "payload_size",
485
+ "checksum",
486
+ "download_vendor_id",
487
+ "download_message_id",
488
+ "downloaded_at",
489
+ "created_at",
490
+ "updated_at",
491
+ "metadata",
492
+ )
493
+ fieldsets = (
494
+ (
495
+ None,
496
+ {
497
+ "fields": (
498
+ "name",
499
+ "description",
500
+ "filename",
501
+ "content_type",
502
+ "payload_encoding",
503
+ "payload_size",
504
+ "checksum",
505
+ )
506
+ },
507
+ ),
508
+ (
509
+ _("Source"),
510
+ {
511
+ "fields": (
512
+ "source",
513
+ "source_node",
514
+ "source_charger",
515
+ "download_vendor_id",
516
+ "download_message_id",
517
+ "downloaded_at",
518
+ )
519
+ },
520
+ ),
521
+ (
522
+ _("Metadata"),
523
+ {"fields": ("metadata", "created_at", "updated_at")},
524
+ ),
525
+ )
526
+ actions = ["upload_evcs_firmware"]
527
+ inlines = [CPFirmwareDeploymentInline]
528
+
529
+ def _format_pending_failure(self, result: dict) -> str:
530
+ return _format_failure_message(result, action_label=_("Update firmware"))
531
+
532
+ def _dispatch_firmware_update(
533
+ self,
534
+ request,
535
+ firmware: CPFirmware,
536
+ charger: Charger,
537
+ retrieve_date: datetime | None,
538
+ retries: int | None,
539
+ retry_interval: int | None,
540
+ ) -> bool:
541
+ connection = store.get_connection(charger.charger_id, charger.connector_id)
542
+ if connection is None:
543
+ self.message_user(
544
+ request,
545
+ _("%(charger)s is not currently connected to the platform.")
546
+ % {"charger": charger},
547
+ level=messages.ERROR,
548
+ )
549
+ return False
550
+
551
+ if not firmware.has_binary and not firmware.has_json:
552
+ self.message_user(
553
+ request,
554
+ _("%(firmware)s does not contain any payload to upload.")
555
+ % {"firmware": firmware},
556
+ level=messages.ERROR,
557
+ )
558
+ return False
559
+
560
+ start_time = retrieve_date or (timezone.now() + timedelta(seconds=30))
561
+ if timezone.is_naive(start_time):
562
+ start_time = timezone.make_aware(
563
+ start_time, timezone.get_current_timezone()
564
+ )
565
+
566
+ message_id = uuid.uuid4().hex
567
+ deployment = CPFirmwareDeployment.objects.create(
568
+ firmware=firmware,
569
+ charger=charger,
570
+ node=charger.node_origin,
571
+ ocpp_message_id=message_id,
572
+ status="Pending",
573
+ status_info=_("Awaiting charge point response."),
574
+ status_timestamp=timezone.now(),
575
+ retrieve_date=start_time,
576
+ retry_count=int(retries or 0),
577
+ retry_interval=int(retry_interval or 0),
578
+ request_payload={},
579
+ is_user_data=True,
580
+ )
581
+ token = deployment.issue_download_token(lifetime=timedelta(hours=4))
582
+ download_url = request.build_absolute_uri(
583
+ reverse("cp-firmware-download", args=[deployment.pk, token])
584
+ )
585
+ payload = {
586
+ "location": download_url,
587
+ "retrieveDate": start_time.isoformat(),
588
+ }
589
+ if retries is not None:
590
+ payload["retries"] = int(retries)
591
+ if retry_interval:
592
+ payload["retryInterval"] = int(retry_interval)
593
+ if firmware.checksum:
594
+ payload["checksum"] = firmware.checksum
595
+ deployment.request_payload = payload
596
+ deployment.save(update_fields=["request_payload", "updated_at"])
597
+
598
+ frame = json.dumps([2, message_id, "UpdateFirmware", payload])
599
+ async_to_sync(connection.send)(frame)
600
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
601
+ store.add_log(
602
+ log_key,
603
+ _("Dispatched UpdateFirmware request."),
604
+ log_type="charger",
605
+ )
606
+ store.register_pending_call(
607
+ message_id,
608
+ {
609
+ "action": "UpdateFirmware",
610
+ "charger_id": charger.charger_id,
611
+ "connector_id": charger.connector_id,
612
+ "deployment_pk": deployment.pk,
613
+ "log_key": log_key,
614
+ },
615
+ )
616
+ store.schedule_call_timeout(
617
+ message_id, action="UpdateFirmware", log_key=log_key
618
+ )
619
+
620
+ result = store.wait_for_pending_call(message_id, timeout=15.0)
621
+ if result is None:
622
+ deployment.mark_status("Timeout", _("No response received."))
623
+ deployment.completed_at = timezone.now()
624
+ deployment.save(update_fields=["completed_at", "updated_at"])
625
+ self.message_user(
626
+ request,
627
+ _(
628
+ "The charge point did not respond to the UpdateFirmware request."
629
+ ),
630
+ level=messages.ERROR,
631
+ )
632
+ return False
633
+ if not result.get("success", True):
634
+ detail = self._format_pending_failure(result)
635
+ deployment.mark_status("Error", detail, response=result.get("payload"))
636
+ deployment.completed_at = timezone.now()
637
+ deployment.save(update_fields=["completed_at", "updated_at"])
638
+ self.message_user(request, detail, level=messages.ERROR)
639
+ return False
640
+
641
+ payload_data = result.get("payload") or {}
642
+ status_value = str(payload_data.get("status") or "").strip() or "Accepted"
643
+ timestamp = timezone.now()
644
+ deployment.mark_status(status_value, "", timestamp, response=payload_data)
645
+ if status_value.lower() != "accepted":
646
+ self.message_user(
647
+ request,
648
+ _(
649
+ "UpdateFirmware for %(charger)s was %(status)s."
650
+ )
651
+ % {"charger": charger, "status": status_value},
652
+ level=messages.ERROR,
653
+ )
654
+ return False
655
+
656
+ self.message_user(
657
+ request,
658
+ _("Queued firmware installation for %(charger)s.")
659
+ % {"charger": charger},
660
+ level=messages.SUCCESS,
661
+ )
662
+ return True
663
+
664
+ @admin.action(description=_("Upload EVCS firmware"))
665
+ def upload_evcs_firmware(self, request, queryset):
666
+ selected_ids = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
667
+ if selected_ids:
668
+ firmware_qs = CPFirmware.objects.filter(pk__in=selected_ids)
669
+ firmware_map = {str(obj.pk): obj for obj in firmware_qs}
670
+ firmware_list = [
671
+ firmware_map[value]
672
+ for value in selected_ids
673
+ if value in firmware_map
674
+ ]
675
+ else:
676
+ firmware_list = list(queryset)
677
+ selected_ids = [str(obj.pk) for obj in firmware_list]
678
+
679
+ if not firmware_list:
680
+ self.message_user(
681
+ request,
682
+ _("Select at least one firmware record to upload."),
683
+ level=messages.ERROR,
684
+ )
685
+ return None
686
+
687
+ form = UploadFirmwareForm(request.POST or None)
688
+ if request.method == "POST" and form.is_valid():
689
+ chargers = list(form.cleaned_data["chargers"])
690
+ retrieve_date = form.cleaned_data.get("retrieve_date")
691
+ retries = form.cleaned_data.get("retries")
692
+ retry_interval = form.cleaned_data.get("retry_interval")
693
+ success_count = 0
694
+ for firmware in firmware_list:
695
+ for charger in chargers:
696
+ if self._dispatch_firmware_update(
697
+ request,
698
+ firmware,
699
+ charger,
700
+ retrieve_date,
701
+ retries,
702
+ retry_interval,
703
+ ):
704
+ success_count += 1
705
+ if success_count:
706
+ self.message_user(
707
+ request,
708
+ ngettext(
709
+ "Queued %(count)d firmware upload.",
710
+ "Queued %(count)d firmware uploads.",
711
+ success_count,
712
+ )
713
+ % {"count": success_count},
714
+ level=messages.SUCCESS,
715
+ )
716
+ return None
717
+
718
+ context = {
719
+ **self.admin_site.each_context(request),
720
+ "opts": self.model._meta,
721
+ "title": _("Upload EVCS firmware"),
722
+ "firmware_list": firmware_list,
723
+ "selected_ids": selected_ids,
724
+ "action_name": request.POST.get("action", "upload_evcs_firmware"),
725
+ "select_across": request.POST.get("select_across", "0"),
726
+ "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
727
+ "adminform": helpers.AdminForm(
728
+ form,
729
+ [
730
+ (
731
+ None,
732
+ {
733
+ "fields": (
734
+ "chargers",
735
+ "retrieve_date",
736
+ "retries",
737
+ "retry_interval",
738
+ )
739
+ },
740
+ )
741
+ ],
742
+ {},
743
+ ),
744
+ "form": form,
745
+ "media": self.media + form.media,
746
+ }
747
+ return TemplateResponse(
748
+ request, "admin/ocpp/cpfirmware/upload_evcs.html", context
749
+ )
750
+
751
+
752
+ @admin.register(CPFirmwareDeployment)
753
+ class CPFirmwareDeploymentAdmin(EntityModelAdmin):
754
+ list_display = (
755
+ "firmware",
756
+ "charger",
757
+ "status",
758
+ "status_timestamp",
759
+ "requested_at",
760
+ "completed_at",
761
+ )
762
+ list_filter = ("status",)
763
+ search_fields = (
764
+ "firmware__name",
765
+ "charger__charger_id",
766
+ "ocpp_message_id",
767
+ )
768
+ readonly_fields = (
769
+ "firmware",
770
+ "charger",
771
+ "node",
772
+ "ocpp_message_id",
773
+ "status",
774
+ "status_info",
775
+ "status_timestamp",
776
+ "requested_at",
777
+ "completed_at",
778
+ "retrieve_date",
779
+ "retry_count",
780
+ "retry_interval",
781
+ "download_token",
782
+ "download_token_expires_at",
783
+ "downloaded_at",
784
+ "request_payload",
785
+ "response_payload",
786
+ "created_at",
787
+ "updated_at",
788
+ )
789
+
303
790
  @admin.register(CPReservation)
304
791
  class CPReservationAdmin(EntityModelAdmin):
305
792
  form = CPReservationForm
793
+ actions = ("cancel_reservations",)
306
794
  list_display = (
307
795
  "location",
308
796
  "connector_side_display",
@@ -423,6 +911,49 @@ class CPReservationAdmin(EntityModelAdmin):
423
911
  value = obj.id_tag_value
424
912
  return value or "-"
425
913
 
914
+ @admin.action(description=_("Cancel selected Reservations"))
915
+ def cancel_reservations(self, request, queryset):
916
+ cancelled = 0
917
+ for reservation in queryset:
918
+ try:
919
+ reservation.send_cancel_request()
920
+ except ValidationError as exc:
921
+ messages_list: list[str] = []
922
+ if getattr(exc, "message_dict", None):
923
+ for errors in exc.message_dict.values():
924
+ messages_list.extend(str(error) for error in errors)
925
+ elif getattr(exc, "messages", None):
926
+ messages_list.extend(str(error) for error in exc.messages)
927
+ else:
928
+ messages_list.append(str(exc))
929
+ for message in messages_list:
930
+ self.message_user(
931
+ request,
932
+ _("%(reservation)s: %(message)s")
933
+ % {"reservation": reservation, "message": message},
934
+ level=messages.ERROR,
935
+ )
936
+ except Exception as exc: # pragma: no cover - defensive
937
+ self.message_user(
938
+ request,
939
+ _("%(reservation)s: unable to cancel reservation (%(error)s)")
940
+ % {"reservation": reservation, "error": exc},
941
+ level=messages.ERROR,
942
+ )
943
+ else:
944
+ cancelled += 1
945
+ if cancelled:
946
+ self.message_user(
947
+ request,
948
+ ngettext(
949
+ "Sent %(count)d cancellation request.",
950
+ "Sent %(count)d cancellation requests.",
951
+ cancelled,
952
+ )
953
+ % {"count": cancelled},
954
+ level=messages.SUCCESS,
955
+ )
956
+
426
957
 
427
958
  @admin.register(Charger)
428
959
  class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
@@ -487,6 +1018,15 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
487
1018
  "Configuration",
488
1019
  {"fields": ("public_display", "require_rfid", "configuration")},
489
1020
  ),
1021
+ (
1022
+ "Local authorization",
1023
+ {
1024
+ "fields": (
1025
+ "local_auth_list_version",
1026
+ "local_auth_list_updated_at",
1027
+ )
1028
+ },
1029
+ ),
490
1030
  (
491
1031
  "Network",
492
1032
  {
@@ -529,6 +1069,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
529
1069
  "availability_request_status_at",
530
1070
  "availability_request_details",
531
1071
  "configuration",
1072
+ "local_auth_list_version",
1073
+ "local_auth_list_updated_at",
532
1074
  "forwarded_to",
533
1075
  "forwarding_watermark",
534
1076
  "last_online_at",
@@ -553,16 +1095,145 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
553
1095
  "purge_data",
554
1096
  "fetch_cp_configuration",
555
1097
  "toggle_rfid_authentication",
1098
+ "send_rfid_list_to_evcs",
1099
+ "update_rfids_from_evcs",
556
1100
  "recheck_charger_status",
1101
+ "get_diagnostics",
557
1102
  "change_availability_operative",
558
1103
  "change_availability_inoperative",
559
1104
  "set_availability_state_operative",
560
1105
  "set_availability_state_inoperative",
1106
+ "clear_authorization_cache",
561
1107
  "remote_stop_transaction",
562
1108
  "reset_chargers",
563
1109
  "delete_selected",
564
1110
  ]
565
1111
 
1112
+ class DiagnosticsDownloadError(Exception):
1113
+ """Raised when diagnostics downloads fail."""
1114
+
1115
+ def _diagnostics_directory_for(self, user) -> tuple[Path, Path]:
1116
+ username = getattr(user, "get_username", None)
1117
+ if callable(username):
1118
+ username = username()
1119
+ else:
1120
+ username = getattr(user, "username", "")
1121
+ if not username:
1122
+ username = str(getattr(user, "pk", "user"))
1123
+ username_component = Path(str(username)).name or "user"
1124
+ base_dir = Path(settings.BASE_DIR)
1125
+ user_dir = base_dir / "work" / username_component
1126
+ diagnostics_dir = user_dir / "diagnostics"
1127
+ diagnostics_dir.mkdir(parents=True, exist_ok=True)
1128
+ return diagnostics_dir, user_dir
1129
+
1130
+ def _content_disposition_filename(self, header_value: str) -> str:
1131
+ for part in header_value.split(";"):
1132
+ candidate = part.strip()
1133
+ lower = candidate.lower()
1134
+ if lower.startswith("filename*="):
1135
+ value = candidate.split("=", 1)[1].strip()
1136
+ if value.lower().startswith("utf-8''"):
1137
+ value = value[7:]
1138
+ return Path(unquote(value.strip('"'))).name
1139
+ if lower.startswith("filename="):
1140
+ value = candidate.split("=", 1)[1].strip().strip('"')
1141
+ return Path(value).name
1142
+ return ""
1143
+
1144
+ def _diagnostics_filename(self, charger: Charger, location: str, response) -> str:
1145
+ parsed = urlparse(location)
1146
+ candidate = Path(parsed.path or "").name
1147
+ header_name = ""
1148
+ content_disposition = response.headers.get("Content-Disposition") if hasattr(response, "headers") else None
1149
+ if content_disposition:
1150
+ header_name = self._content_disposition_filename(content_disposition)
1151
+ if header_name:
1152
+ candidate = header_name
1153
+ if not candidate:
1154
+ candidate = "diagnostics.log"
1155
+ path_candidate = Path(candidate)
1156
+ suffix = "".join(path_candidate.suffixes)
1157
+ if suffix:
1158
+ base_name = candidate[: -len(suffix)]
1159
+ else:
1160
+ base_name = candidate
1161
+ suffix = ".log"
1162
+ base_name = base_name.rstrip(".")
1163
+ if not base_name:
1164
+ base_name = "diagnostics"
1165
+ charger_slug = slugify(charger.charger_id or charger.display_name or str(charger.pk or "charger"))
1166
+ if not charger_slug:
1167
+ charger_slug = "charger"
1168
+ diagnostics_slug = slugify(base_name) or "diagnostics"
1169
+ timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
1170
+ return f"{charger_slug}-{diagnostics_slug}-{timestamp}{suffix}"
1171
+
1172
+ def _unique_diagnostics_path(self, directory: Path, filename: str) -> Path:
1173
+ base_path = Path(filename)
1174
+ suffix = "".join(base_path.suffixes)
1175
+ if suffix:
1176
+ base_name = filename[: -len(suffix)]
1177
+ else:
1178
+ base_name = filename
1179
+ suffix = ""
1180
+ base_name = base_name.rstrip(".") or "diagnostics"
1181
+ candidate = directory / f"{base_name}{suffix}"
1182
+ counter = 1
1183
+ while candidate.exists():
1184
+ candidate = directory / f"{base_name}-{counter}{suffix}"
1185
+ counter += 1
1186
+ return candidate
1187
+
1188
+ def _download_diagnostics(
1189
+ self,
1190
+ request,
1191
+ charger: Charger,
1192
+ location: str,
1193
+ diagnostics_dir: Path,
1194
+ user_dir: Path,
1195
+ ) -> tuple[Path, str]:
1196
+ parsed = urlparse(location)
1197
+ scheme = (parsed.scheme or "").lower()
1198
+ if scheme not in {"http", "https"}:
1199
+ raise self.DiagnosticsDownloadError(
1200
+ _("Diagnostics location must use HTTP or HTTPS.")
1201
+ )
1202
+ try:
1203
+ response = requests.get(location, stream=True, timeout=15)
1204
+ except RequestException as exc:
1205
+ raise self.DiagnosticsDownloadError(
1206
+ _("Failed to download diagnostics: %s") % exc
1207
+ ) from exc
1208
+ try:
1209
+ if response.status_code != 200:
1210
+ raise self.DiagnosticsDownloadError(
1211
+ _("Diagnostics download returned status %s.")
1212
+ % response.status_code
1213
+ )
1214
+ filename = self._diagnostics_filename(charger, location, response)
1215
+ destination = self._unique_diagnostics_path(diagnostics_dir, filename)
1216
+ try:
1217
+ with destination.open("wb") as handle:
1218
+ for chunk in response.iter_content(chunk_size=65536):
1219
+ if not chunk:
1220
+ continue
1221
+ handle.write(chunk)
1222
+ except OSError as exc:
1223
+ raise self.DiagnosticsDownloadError(
1224
+ _("Unable to write diagnostics file: %s") % exc
1225
+ ) from exc
1226
+ finally:
1227
+ with contextlib.suppress(Exception):
1228
+ response.close()
1229
+ relative_asset = destination.relative_to(user_dir).as_posix()
1230
+ asset_url = reverse(
1231
+ "pages:readme-asset",
1232
+ kwargs={"source": "work", "asset": relative_asset},
1233
+ )
1234
+ absolute_url = request.build_absolute_uri(asset_url)
1235
+ return destination, absolute_url
1236
+
566
1237
  def _prepare_remote_credentials(self, request):
567
1238
  local = Node.get_local()
568
1239
  if not local or not local.uuid:
@@ -708,6 +1379,55 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
708
1379
  for field, value in applied.items():
709
1380
  setattr(charger, field, value)
710
1381
 
1382
+ @admin.action(description="Get diagnostics")
1383
+ def get_diagnostics(self, request, queryset):
1384
+ diagnostics_dir, user_dir = self._diagnostics_directory_for(request.user)
1385
+ successes: list[tuple[Charger, str, Path]] = []
1386
+ for charger in queryset:
1387
+ location = (charger.diagnostics_location or "").strip()
1388
+ if not location:
1389
+ self.message_user(
1390
+ request,
1391
+ _("%(charger)s: no diagnostics location reported.")
1392
+ % {"charger": charger},
1393
+ level=messages.WARNING,
1394
+ )
1395
+ continue
1396
+ try:
1397
+ destination, asset_url = self._download_diagnostics(
1398
+ request,
1399
+ charger,
1400
+ location,
1401
+ diagnostics_dir,
1402
+ user_dir,
1403
+ )
1404
+ except self.DiagnosticsDownloadError as exc:
1405
+ self.message_user(
1406
+ request,
1407
+ _("%(charger)s: %(error)s")
1408
+ % {"charger": charger, "error": exc},
1409
+ level=messages.ERROR,
1410
+ )
1411
+ continue
1412
+ successes.append((charger, asset_url, destination))
1413
+
1414
+ if successes:
1415
+ summary = ngettext(
1416
+ "Retrieved diagnostics for %(count)d charger.",
1417
+ "Retrieved diagnostics for %(count)d chargers.",
1418
+ len(successes),
1419
+ ) % {"count": len(successes)}
1420
+ details = format_html_join(
1421
+ "",
1422
+ "<li>{}: <a href=\"{}\" target=\"_blank\">{}</a> (<code>{}</code>)</li>",
1423
+ (
1424
+ (charger, url, destination.name, destination)
1425
+ for charger, url, destination in successes
1426
+ ),
1427
+ )
1428
+ message = format_html("{}<ul>{}</ul>", summary, details)
1429
+ self.message_user(request, message, level=messages.SUCCESS)
1430
+
711
1431
  def get_readonly_fields(self, request, obj=None):
712
1432
  readonly = list(super().get_readonly_fields(request, obj))
713
1433
  if obj and not obj.is_local:
@@ -814,6 +1534,21 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
814
1534
  return obj.location.name
815
1535
  return obj.charger_id
816
1536
 
1537
+ def _build_local_authorization_list(self) -> list[dict[str, object]]:
1538
+ """Return the payload for SendLocalList with released and allowed RFIDs."""
1539
+
1540
+ entries: list[dict[str, object]] = []
1541
+ queryset = (
1542
+ CoreRFID.objects.filter(released=True, allowed=True)
1543
+ .order_by("rfid")
1544
+ .only("rfid")
1545
+ )
1546
+ for tag in queryset.iterator():
1547
+ entry: dict[str, object] = {"idTag": tag.rfid}
1548
+ entry["idTagInfo"] = {"status": "Accepted"}
1549
+ entries.append(entry)
1550
+ return entries
1551
+
817
1552
  @admin.display(boolean=True, description="Local")
818
1553
  def local_indicator(self, obj):
819
1554
  return obj.is_local
@@ -1031,6 +1766,183 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
1031
1766
  f"Updated RFID authentication: {summary}",
1032
1767
  )
1033
1768
 
1769
+ @admin.action(description="Send RFID list to EVCS")
1770
+ def send_rfid_list_to_evcs(self, request, queryset):
1771
+ authorization_list = self._build_local_authorization_list()
1772
+ update_type = "Full"
1773
+ sent = 0
1774
+ local_node = None
1775
+ private_key = None
1776
+ remote_unavailable = False
1777
+ for charger in queryset:
1778
+ list_version = (charger.local_auth_list_version or 0) + 1
1779
+ if charger.is_local:
1780
+ connector_value = charger.connector_id
1781
+ ws = store.get_connection(charger.charger_id, connector_value)
1782
+ if ws is None:
1783
+ self.message_user(
1784
+ request,
1785
+ f"{charger}: no active connection",
1786
+ level=messages.ERROR,
1787
+ )
1788
+ continue
1789
+ message_id = uuid.uuid4().hex
1790
+ payload = {
1791
+ "listVersion": list_version,
1792
+ "updateType": update_type,
1793
+ "localAuthorizationList": authorization_list,
1794
+ }
1795
+ msg = json.dumps([2, message_id, "SendLocalList", payload])
1796
+ try:
1797
+ async_to_sync(ws.send)(msg)
1798
+ except Exception as exc: # pragma: no cover - network error
1799
+ self.message_user(
1800
+ request,
1801
+ f"{charger}: failed to send SendLocalList ({exc})",
1802
+ level=messages.ERROR,
1803
+ )
1804
+ continue
1805
+ log_key = store.identity_key(charger.charger_id, connector_value)
1806
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1807
+ store.register_pending_call(
1808
+ message_id,
1809
+ {
1810
+ "action": "SendLocalList",
1811
+ "charger_id": charger.charger_id,
1812
+ "connector_id": connector_value,
1813
+ "log_key": log_key,
1814
+ "list_version": list_version,
1815
+ "list_size": len(authorization_list),
1816
+ "requested_at": timezone.now(),
1817
+ },
1818
+ )
1819
+ store.schedule_call_timeout(
1820
+ message_id,
1821
+ action="SendLocalList",
1822
+ log_key=log_key,
1823
+ message="SendLocalList request timed out",
1824
+ )
1825
+ sent += 1
1826
+ continue
1827
+
1828
+ if not charger.allow_remote:
1829
+ self.message_user(
1830
+ request,
1831
+ f"{charger}: remote administration is disabled.",
1832
+ level=messages.ERROR,
1833
+ )
1834
+ continue
1835
+ if remote_unavailable:
1836
+ continue
1837
+ if local_node is None:
1838
+ local_node, private_key = self._prepare_remote_credentials(request)
1839
+ if not local_node or not private_key:
1840
+ remote_unavailable = True
1841
+ continue
1842
+ extra = {
1843
+ "local_authorization_list": [entry.copy() for entry in authorization_list],
1844
+ "list_version": list_version,
1845
+ "update_type": update_type,
1846
+ }
1847
+ success, updates = self._call_remote_action(
1848
+ request,
1849
+ local_node,
1850
+ private_key,
1851
+ charger,
1852
+ "send-local-rfid-list",
1853
+ extra,
1854
+ )
1855
+ if success:
1856
+ self._apply_remote_updates(charger, updates)
1857
+ sent += 1
1858
+
1859
+ if sent:
1860
+ self.message_user(
1861
+ request,
1862
+ f"Sent SendLocalList to {sent} charger(s)",
1863
+ )
1864
+
1865
+ @admin.action(description="Update RFIDs from EVCS")
1866
+ def update_rfids_from_evcs(self, request, queryset):
1867
+ requested = 0
1868
+ local_node = None
1869
+ private_key = None
1870
+ remote_unavailable = False
1871
+ for charger in queryset:
1872
+ if charger.is_local:
1873
+ connector_value = charger.connector_id
1874
+ ws = store.get_connection(charger.charger_id, connector_value)
1875
+ if ws is None:
1876
+ self.message_user(
1877
+ request,
1878
+ f"{charger}: no active connection",
1879
+ level=messages.ERROR,
1880
+ )
1881
+ continue
1882
+ message_id = uuid.uuid4().hex
1883
+ payload: dict[str, object] = {}
1884
+ msg = json.dumps([2, message_id, "GetLocalListVersion", payload])
1885
+ try:
1886
+ async_to_sync(ws.send)(msg)
1887
+ except Exception as exc: # pragma: no cover - network error
1888
+ self.message_user(
1889
+ request,
1890
+ f"{charger}: failed to send GetLocalListVersion ({exc})",
1891
+ level=messages.ERROR,
1892
+ )
1893
+ continue
1894
+ log_key = store.identity_key(charger.charger_id, connector_value)
1895
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1896
+ store.register_pending_call(
1897
+ message_id,
1898
+ {
1899
+ "action": "GetLocalListVersion",
1900
+ "charger_id": charger.charger_id,
1901
+ "connector_id": connector_value,
1902
+ "log_key": log_key,
1903
+ "requested_at": timezone.now(),
1904
+ },
1905
+ )
1906
+ store.schedule_call_timeout(
1907
+ message_id,
1908
+ action="GetLocalListVersion",
1909
+ log_key=log_key,
1910
+ message="GetLocalListVersion request timed out",
1911
+ )
1912
+ requested += 1
1913
+ continue
1914
+
1915
+ if not charger.allow_remote:
1916
+ self.message_user(
1917
+ request,
1918
+ f"{charger}: remote administration is disabled.",
1919
+ level=messages.ERROR,
1920
+ )
1921
+ continue
1922
+ if remote_unavailable:
1923
+ continue
1924
+ if local_node is None:
1925
+ local_node, private_key = self._prepare_remote_credentials(request)
1926
+ if not local_node or not private_key:
1927
+ remote_unavailable = True
1928
+ continue
1929
+ success, updates = self._call_remote_action(
1930
+ request,
1931
+ local_node,
1932
+ private_key,
1933
+ charger,
1934
+ "get-local-list-version",
1935
+ )
1936
+ if success:
1937
+ self._apply_remote_updates(charger, updates)
1938
+ requested += 1
1939
+
1940
+ if requested:
1941
+ self.message_user(
1942
+ request,
1943
+ f"Requested GetLocalListVersion from {requested} charger(s)",
1944
+ )
1945
+
1034
1946
  def _dispatch_change_availability(self, request, queryset, availability_type: str):
1035
1947
  sent = 0
1036
1948
  local_node = None
@@ -1186,6 +2098,85 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
1186
2098
  def set_availability_state_inoperative(self, request, queryset):
1187
2099
  self._set_availability_state(request, queryset, "Inoperative")
1188
2100
 
2101
+ @admin.action(description="Clear charger authorization cache")
2102
+ def clear_authorization_cache(self, request, queryset):
2103
+ cleared = 0
2104
+ local_node = None
2105
+ private_key = None
2106
+ remote_unavailable = False
2107
+ for charger in queryset:
2108
+ if charger.is_local:
2109
+ connector_value = charger.connector_id
2110
+ ws = store.get_connection(charger.charger_id, connector_value)
2111
+ if ws is None:
2112
+ self.message_user(
2113
+ request,
2114
+ f"{charger}: no active connection",
2115
+ level=messages.ERROR,
2116
+ )
2117
+ continue
2118
+ message_id = uuid.uuid4().hex
2119
+ msg = json.dumps([2, message_id, "ClearCache", {}])
2120
+ try:
2121
+ async_to_sync(ws.send)(msg)
2122
+ except Exception as exc: # pragma: no cover - network error
2123
+ self.message_user(
2124
+ request,
2125
+ f"{charger}: failed to send ClearCache ({exc})",
2126
+ level=messages.ERROR,
2127
+ )
2128
+ continue
2129
+ log_key = store.identity_key(charger.charger_id, connector_value)
2130
+ store.add_log(log_key, f"< {msg}", log_type="charger")
2131
+ requested_at = timezone.now()
2132
+ store.register_pending_call(
2133
+ message_id,
2134
+ {
2135
+ "action": "ClearCache",
2136
+ "charger_id": charger.charger_id,
2137
+ "connector_id": connector_value,
2138
+ "log_key": log_key,
2139
+ "requested_at": requested_at,
2140
+ },
2141
+ )
2142
+ store.schedule_call_timeout(
2143
+ message_id,
2144
+ action="ClearCache",
2145
+ log_key=log_key,
2146
+ )
2147
+ cleared += 1
2148
+ continue
2149
+
2150
+ if not charger.allow_remote:
2151
+ self.message_user(
2152
+ request,
2153
+ f"{charger}: remote administration is disabled.",
2154
+ level=messages.ERROR,
2155
+ )
2156
+ continue
2157
+ if remote_unavailable:
2158
+ continue
2159
+ if local_node is None:
2160
+ local_node, private_key = self._prepare_remote_credentials(request)
2161
+ if not local_node or not private_key:
2162
+ remote_unavailable = True
2163
+ continue
2164
+ success, _updates = self._call_remote_action(
2165
+ request,
2166
+ local_node,
2167
+ private_key,
2168
+ charger,
2169
+ "clear-cache",
2170
+ )
2171
+ if success:
2172
+ cleared += 1
2173
+
2174
+ if cleared:
2175
+ self.message_user(
2176
+ request,
2177
+ f"Sent ClearCache to {cleared} charger(s)",
2178
+ )
2179
+
1189
2180
  @admin.action(description="Remote stop active transaction")
1190
2181
  def remote_stop_transaction(self, request, queryset):
1191
2182
  stopped = 0