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
ocpp/admin.py CHANGED
@@ -2,25 +2,38 @@ from django.contrib import admin, messages
2
2
  from django import forms
3
3
 
4
4
  import asyncio
5
- from datetime import timedelta
5
+ import base64
6
+ from datetime import datetime, time, timedelta
6
7
  import json
8
+ from typing import Any
7
9
 
8
10
  from django.shortcuts import redirect
9
- from django.utils import timezone
11
+ from django.utils import formats, timezone, translation
12
+ from django.utils.translation import gettext_lazy as _
13
+ from django.utils.dateparse import parse_datetime
14
+ from django.utils.html import format_html
10
15
  from django.urls import path
11
16
  from django.http import HttpResponse, HttpResponseRedirect
12
17
  from django.template.response import TemplateResponse
13
18
 
14
19
  import uuid
15
20
  from asgiref.sync import async_to_sync
21
+ import requests
22
+ from requests import RequestException
23
+ from cryptography.hazmat.primitives import hashes
24
+ from cryptography.hazmat.primitives.asymmetric import padding
25
+ from django.db import transaction
26
+ from django.core.exceptions import ValidationError
16
27
 
17
28
  from .models import (
18
29
  Charger,
30
+ ChargerConfiguration,
19
31
  Simulator,
20
32
  MeterValue,
21
33
  Transaction,
22
34
  Location,
23
35
  DataTransferMessage,
36
+ CPReservation,
24
37
  )
25
38
  from .simulator import ChargePointSimulator
26
39
  from . import store
@@ -29,8 +42,10 @@ from .transactions_io import (
29
42
  import_transactions as import_transactions_data,
30
43
  )
31
44
  from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
45
+ from .views import _charger_state, _live_sessions
32
46
  from core.admin import SaveBeforeChangeAction
33
47
  from core.user_data import EntityModelAdmin
48
+ from nodes.models import Node
34
49
 
35
50
 
36
51
  class LocationAdminForm(forms.ModelForm):
@@ -63,6 +78,43 @@ class TransactionImportForm(forms.Form):
63
78
  file = forms.FileField()
64
79
 
65
80
 
81
+ class CPReservationForm(forms.ModelForm):
82
+ class Meta:
83
+ model = CPReservation
84
+ fields = [
85
+ "location",
86
+ "account",
87
+ "rfid",
88
+ "id_tag",
89
+ "start_time",
90
+ "duration_minutes",
91
+ ]
92
+
93
+ def clean(self):
94
+ cleaned = super().clean()
95
+ instance = self.instance
96
+ for field in self.Meta.fields:
97
+ if field in cleaned:
98
+ setattr(instance, field, cleaned[field])
99
+ try:
100
+ instance.allocate_connector(force=bool(instance.pk))
101
+ except ValidationError as exc:
102
+ if exc.message_dict:
103
+ for field, errors in exc.message_dict.items():
104
+ for error in errors:
105
+ self.add_error(field, error)
106
+ raise forms.ValidationError(
107
+ _("Unable to allocate a connector for the selected time window.")
108
+ )
109
+ raise forms.ValidationError(exc.messages or [str(exc)])
110
+ if not instance.id_tag_value:
111
+ message = _("Select an RFID or provide an idTag for the reservation.")
112
+ self.add_error("id_tag", message)
113
+ self.add_error("rfid", message)
114
+ raise forms.ValidationError(message)
115
+ return cleaned
116
+
117
+
66
118
  class LogViewAdminMixin:
67
119
  """Mixin providing an admin view to display charger or simulator logs."""
68
120
 
@@ -107,11 +159,107 @@ class LogViewAdminMixin:
107
159
  return TemplateResponse(request, self.log_template_name, context)
108
160
 
109
161
 
162
+ @admin.register(ChargerConfiguration)
163
+ class ChargerConfigurationAdmin(admin.ModelAdmin):
164
+ list_display = (
165
+ "charger_identifier",
166
+ "connector_display",
167
+ "origin_display",
168
+ "created_at",
169
+ )
170
+ list_filter = ("connector_id",)
171
+ search_fields = ("charger_identifier",)
172
+ readonly_fields = (
173
+ "charger_identifier",
174
+ "connector_id",
175
+ "origin_display",
176
+ "evcs_snapshot_at",
177
+ "created_at",
178
+ "updated_at",
179
+ "linked_chargers",
180
+ "configuration_keys_display",
181
+ "unknown_keys_display",
182
+ "raw_payload_display",
183
+ )
184
+ fieldsets = (
185
+ (
186
+ None,
187
+ {
188
+ "fields": (
189
+ "charger_identifier",
190
+ "connector_id",
191
+ "origin_display",
192
+ "evcs_snapshot_at",
193
+ "linked_chargers",
194
+ "created_at",
195
+ "updated_at",
196
+ )
197
+ },
198
+ ),
199
+ (
200
+ "Payload",
201
+ {
202
+ "fields": (
203
+ "configuration_keys_display",
204
+ "unknown_keys_display",
205
+ "raw_payload_display",
206
+ )
207
+ },
208
+ ),
209
+ )
210
+
211
+ @admin.display(description="Connector")
212
+ def connector_display(self, obj):
213
+ if obj.connector_id is None:
214
+ return "All"
215
+ return obj.connector_id
216
+
217
+ @admin.display(description="Linked charge points")
218
+ def linked_chargers(self, obj):
219
+ if obj.pk is None:
220
+ return ""
221
+ linked = [charger.identity_slug() for charger in obj.chargers.all()]
222
+ if not linked:
223
+ return "-"
224
+ return ", ".join(sorted(linked))
225
+
226
+ def _render_json(self, data):
227
+ from django.utils.html import format_html
228
+
229
+ if not data:
230
+ return "-"
231
+ formatted = json.dumps(data, indent=2, ensure_ascii=False)
232
+ return format_html("<pre>{}</pre>", formatted)
233
+
234
+ @admin.display(description="configurationKey")
235
+ def configuration_keys_display(self, obj):
236
+ return self._render_json(obj.configuration_keys)
237
+
238
+ @admin.display(description="unknownKey")
239
+ def unknown_keys_display(self, obj):
240
+ return self._render_json(obj.unknown_keys)
241
+
242
+ @admin.display(description="Raw payload")
243
+ def raw_payload_display(self, obj):
244
+ return self._render_json(obj.raw_payload)
245
+
246
+ @admin.display(description="Origin")
247
+ def origin_display(self, obj):
248
+ if obj.evcs_snapshot_at:
249
+ return "EVCS"
250
+ return "Local"
251
+
252
+ def save_model(self, request, obj, form, change):
253
+ obj.evcs_snapshot_at = None
254
+ super().save_model(request, obj, form, change)
255
+
256
+
110
257
  @admin.register(Location)
111
258
  class LocationAdmin(EntityModelAdmin):
112
259
  form = LocationAdminForm
113
- list_display = ("name", "latitude", "longitude")
260
+ list_display = ("name", "zone", "contract_type", "latitude", "longitude")
114
261
  change_form_template = "admin/ocpp/location/change_form.html"
262
+ search_fields = ("name",)
115
263
 
116
264
 
117
265
  @admin.register(DataTransferMessage)
@@ -152,8 +300,139 @@ class DataTransferMessageAdmin(admin.ModelAdmin):
152
300
  )
153
301
 
154
302
 
303
+ @admin.register(CPReservation)
304
+ class CPReservationAdmin(EntityModelAdmin):
305
+ form = CPReservationForm
306
+ list_display = (
307
+ "location",
308
+ "connector_side_display",
309
+ "start_time",
310
+ "end_time_display",
311
+ "account",
312
+ "id_tag_display",
313
+ "evcs_status",
314
+ "evcs_confirmed",
315
+ )
316
+ list_filter = ("location", "evcs_confirmed")
317
+ search_fields = (
318
+ "location__name",
319
+ "connector__charger_id",
320
+ "connector__display_name",
321
+ "account__name",
322
+ "id_tag",
323
+ "rfid__rfid",
324
+ )
325
+ date_hierarchy = "start_time"
326
+ ordering = ("-start_time",)
327
+ autocomplete_fields = ("location", "account", "rfid")
328
+ readonly_fields = (
329
+ "connector_identity",
330
+ "connector_side_display",
331
+ "evcs_status",
332
+ "evcs_error",
333
+ "evcs_confirmed",
334
+ "evcs_confirmed_at",
335
+ "ocpp_message_id",
336
+ "created_on",
337
+ "updated_on",
338
+ )
339
+ fieldsets = (
340
+ (
341
+ None,
342
+ {
343
+ "fields": (
344
+ "location",
345
+ "account",
346
+ "rfid",
347
+ "id_tag",
348
+ "start_time",
349
+ "duration_minutes",
350
+ )
351
+ },
352
+ ),
353
+ (
354
+ _("Assigned connector"),
355
+ {"fields": ("connector_identity", "connector_side_display")},
356
+ ),
357
+ (
358
+ _("EVCS response"),
359
+ {
360
+ "fields": (
361
+ "evcs_confirmed",
362
+ "evcs_status",
363
+ "evcs_confirmed_at",
364
+ "evcs_error",
365
+ "ocpp_message_id",
366
+ )
367
+ },
368
+ ),
369
+ (
370
+ _("Metadata"),
371
+ {"fields": ("created_on", "updated_on")},
372
+ ),
373
+ )
374
+
375
+ def save_model(self, request, obj, form, change):
376
+ trigger_fields = {
377
+ "start_time",
378
+ "duration_minutes",
379
+ "location",
380
+ "id_tag",
381
+ "rfid",
382
+ "account",
383
+ }
384
+ changed_data = set(getattr(form, "changed_data", []))
385
+ should_send = not change or bool(trigger_fields.intersection(changed_data))
386
+ with transaction.atomic():
387
+ super().save_model(request, obj, form, change)
388
+ if should_send:
389
+ try:
390
+ obj.send_reservation_request()
391
+ except ValidationError as exc:
392
+ raise ValidationError(exc.message_dict or exc.messages or str(exc))
393
+ else:
394
+ self.message_user(
395
+ request,
396
+ _("Reservation request sent to %(connector)s.")
397
+ % {"connector": self.connector_identity(obj)},
398
+ messages.SUCCESS,
399
+ )
400
+
401
+ @admin.display(description=_("Connector"), ordering="connector__connector_id")
402
+ def connector_side_display(self, obj):
403
+ return obj.connector_label or "-"
404
+
405
+ @admin.display(description=_("Connector identity"))
406
+ def connector_identity(self, obj):
407
+ if obj.connector_id:
408
+ return obj.connector.identity_slug()
409
+ return "-"
410
+
411
+ @admin.display(description=_("End time"))
412
+ def end_time_display(self, obj):
413
+ try:
414
+ value = timezone.localtime(obj.end_time)
415
+ except Exception:
416
+ value = obj.end_time
417
+ if not value:
418
+ return "-"
419
+ return formats.date_format(value, "DATETIME_FORMAT")
420
+
421
+ @admin.display(description=_("Id tag"))
422
+ def id_tag_display(self, obj):
423
+ value = obj.id_tag_value
424
+ return value or "-"
425
+
426
+
155
427
  @admin.register(Charger)
156
428
  class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
429
+ _REMOTE_DATETIME_FIELDS = {
430
+ "availability_state_updated_at",
431
+ "availability_requested_at",
432
+ "availability_request_status_at",
433
+ "last_online_at",
434
+ }
435
+
157
436
  fieldsets = (
158
437
  (
159
438
  "General",
@@ -162,6 +441,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
162
441
  "charger_id",
163
442
  "display_name",
164
443
  "connector_id",
444
+ "language",
165
445
  "location",
166
446
  "last_path",
167
447
  "last_heartbeat",
@@ -205,7 +485,21 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
205
485
  ),
206
486
  (
207
487
  "Configuration",
208
- {"fields": ("public_display", "require_rfid")},
488
+ {"fields": ("public_display", "require_rfid", "configuration")},
489
+ ),
490
+ (
491
+ "Network",
492
+ {
493
+ "fields": (
494
+ "node_origin",
495
+ "manager_node",
496
+ "forwarded_to",
497
+ "forwarding_watermark",
498
+ "allow_remote",
499
+ "export_transactions",
500
+ "last_online_at",
501
+ )
502
+ },
209
503
  ),
210
504
  (
211
505
  "References",
@@ -234,15 +528,20 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
234
528
  "availability_request_status",
235
529
  "availability_request_status_at",
236
530
  "availability_request_details",
531
+ "configuration",
532
+ "forwarded_to",
533
+ "forwarding_watermark",
534
+ "last_online_at",
237
535
  )
238
536
  list_display = (
239
- "charger_id",
537
+ "display_name_with_fallback",
240
538
  "connector_number",
241
- "location_name",
539
+ "charger_name_display",
540
+ "local_indicator",
242
541
  "require_rfid_display",
243
542
  "public_display",
244
543
  "last_heartbeat",
245
- "session_kw",
544
+ "today_kw",
246
545
  "total_kw_display",
247
546
  "page_link",
248
547
  "log_link",
@@ -253,6 +552,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
253
552
  actions = [
254
553
  "purge_data",
255
554
  "fetch_cp_configuration",
555
+ "toggle_rfid_authentication",
556
+ "recheck_charger_status",
256
557
  "change_availability_operative",
257
558
  "change_availability_inoperative",
258
559
  "set_availability_state_operative",
@@ -262,6 +563,159 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
262
563
  "delete_selected",
263
564
  ]
264
565
 
566
+ def _prepare_remote_credentials(self, request):
567
+ local = Node.get_local()
568
+ if not local or not local.uuid:
569
+ self.message_user(
570
+ request,
571
+ "Local node is not registered; remote actions are unavailable.",
572
+ level=messages.ERROR,
573
+ )
574
+ return None, None
575
+ private_key = local.get_private_key()
576
+ if private_key is None:
577
+ self.message_user(
578
+ request,
579
+ "Local node private key is unavailable; remote actions are disabled.",
580
+ level=messages.ERROR,
581
+ )
582
+ return None, None
583
+ return local, private_key
584
+
585
+ def _call_remote_action(
586
+ self,
587
+ request,
588
+ local_node: Node,
589
+ private_key,
590
+ charger: Charger,
591
+ action: str,
592
+ extra: dict[str, Any] | None = None,
593
+ ) -> tuple[bool, dict[str, Any]]:
594
+ if not charger.node_origin:
595
+ self.message_user(
596
+ request,
597
+ f"{charger}: remote node information is missing.",
598
+ level=messages.ERROR,
599
+ )
600
+ return False, {}
601
+ origin = charger.node_origin
602
+ if not origin.port:
603
+ self.message_user(
604
+ request,
605
+ f"{charger}: remote node port is not configured.",
606
+ level=messages.ERROR,
607
+ )
608
+ return False, {}
609
+
610
+ if not origin.get_remote_host_candidates():
611
+ self.message_user(
612
+ request,
613
+ f"{charger}: remote node connection details are incomplete.",
614
+ level=messages.ERROR,
615
+ )
616
+ return False, {}
617
+
618
+ payload: dict[str, Any] = {
619
+ "requester": str(local_node.uuid),
620
+ "requester_mac": local_node.mac_address,
621
+ "requester_public_key": local_node.public_key,
622
+ "charger_id": charger.charger_id,
623
+ "connector_id": charger.connector_id,
624
+ "action": action,
625
+ }
626
+ if extra:
627
+ payload.update(extra)
628
+
629
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
630
+ headers = {"Content-Type": "application/json"}
631
+ try:
632
+ signature = private_key.sign(
633
+ payload_json.encode(),
634
+ padding.PKCS1v15(),
635
+ hashes.SHA256(),
636
+ )
637
+ headers["X-Signature"] = base64.b64encode(signature).decode()
638
+ except Exception:
639
+ self.message_user(
640
+ request,
641
+ "Unable to sign remote action payload; remote action aborted.",
642
+ level=messages.ERROR,
643
+ )
644
+ return False, {}
645
+
646
+ url = next(
647
+ origin.iter_remote_urls("/nodes/network/chargers/action/"),
648
+ "",
649
+ )
650
+ if not url:
651
+ self.message_user(
652
+ request,
653
+ f"{charger}: no reachable hosts were reported for the remote node.",
654
+ level=messages.ERROR,
655
+ )
656
+ return False, {}
657
+ try:
658
+ response = requests.post(url, data=payload_json, headers=headers, timeout=5)
659
+ except RequestException as exc:
660
+ self.message_user(
661
+ request,
662
+ f"{charger}: failed to contact remote node ({exc}).",
663
+ level=messages.ERROR,
664
+ )
665
+ return False, {}
666
+
667
+ try:
668
+ data = response.json()
669
+ except ValueError:
670
+ self.message_user(
671
+ request,
672
+ f"{charger}: invalid response from remote node.",
673
+ level=messages.ERROR,
674
+ )
675
+ return False, {}
676
+
677
+ if response.status_code != 200 or data.get("status") != "ok":
678
+ detail = data.get("detail") if isinstance(data, dict) else None
679
+ if not detail:
680
+ detail = response.text or "Remote node rejected the request."
681
+ self.message_user(
682
+ request,
683
+ f"{charger}: {detail}",
684
+ level=messages.ERROR,
685
+ )
686
+ return False, {}
687
+
688
+ updates = data.get("updates", {}) if isinstance(data, dict) else {}
689
+ if not isinstance(updates, dict):
690
+ updates = {}
691
+ return True, updates
692
+
693
+ def _apply_remote_updates(self, charger: Charger, updates: dict[str, Any]) -> None:
694
+ if not updates:
695
+ return
696
+
697
+ applied: dict[str, Any] = {}
698
+ for field, value in updates.items():
699
+ if field in self._REMOTE_DATETIME_FIELDS and isinstance(value, str):
700
+ parsed = parse_datetime(value)
701
+ if parsed and timezone.is_naive(parsed):
702
+ parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
703
+ applied[field] = parsed
704
+ else:
705
+ applied[field] = value
706
+
707
+ Charger.objects.filter(pk=charger.pk).update(**applied)
708
+ for field, value in applied.items():
709
+ setattr(charger, field, value)
710
+
711
+ def get_readonly_fields(self, request, obj=None):
712
+ readonly = list(super().get_readonly_fields(request, obj))
713
+ if obj and not obj.is_local:
714
+ for field in ("allow_remote", "export_transactions"):
715
+ if field not in readonly:
716
+ readonly.append(field)
717
+ return tuple(readonly)
718
+
265
719
  def get_view_on_site_url(self, obj=None):
266
720
  return obj.get_absolute_url() if obj else None
267
721
 
@@ -317,16 +771,14 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
317
771
  "charger-status-connector",
318
772
  args=[obj.charger_id, obj.connector_slug],
319
773
  )
320
- label = (obj.last_status or "status").strip() or "status"
321
- status_key = label.lower()
322
- error_code = (obj.last_error_code or "").strip().lower()
323
- if (
324
- self._has_active_session(obj)
325
- and error_code in ERROR_OK_VALUES
326
- and (status_key not in STATUS_BADGE_MAP or status_key == "available")
327
- ):
328
- label = STATUS_BADGE_MAP["charging"][0]
329
- return format_html('<a href="{}" target="_blank">{}</a>', url, label)
774
+ tx_obj = store.get_transaction(obj.charger_id, obj.connector_id)
775
+ state, _ = _charger_state(
776
+ obj,
777
+ tx_obj
778
+ if obj.connector_id is not None
779
+ else (_live_sessions(obj) or None),
780
+ )
781
+ return format_html('<a href="{}" target="_blank">{}</a>', url, state)
330
782
 
331
783
  status_link.short_description = "Status"
332
784
 
@@ -347,6 +799,25 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
347
799
  return True
348
800
  return False
349
801
 
802
+ @admin.display(description="Display Name", ordering="display_name")
803
+ def display_name_with_fallback(self, obj):
804
+ return self._charger_display_name(obj)
805
+
806
+ @admin.display(description="Charger", ordering="display_name")
807
+ def charger_name_display(self, obj):
808
+ return self._charger_display_name(obj)
809
+
810
+ def _charger_display_name(self, obj):
811
+ if obj.display_name:
812
+ return obj.display_name
813
+ if obj.location:
814
+ return obj.location.name
815
+ return obj.charger_id
816
+
817
+ @admin.display(boolean=True, description="Local")
818
+ def local_indicator(self, obj):
819
+ return obj.is_local
820
+
350
821
  def location_name(self, obj):
351
822
  return obj.location.name if obj.location else ""
352
823
 
@@ -359,9 +830,9 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
359
830
 
360
831
  purge_data.short_description = "Purge data"
361
832
 
362
- @admin.action(description="Fetch CP configuration")
363
- def fetch_cp_configuration(self, request, queryset):
364
- fetched = 0
833
+ @admin.action(description="Re-check Charger Status")
834
+ def recheck_charger_status(self, request, queryset):
835
+ requested = 0
365
836
  for charger in queryset:
366
837
  connector_value = charger.connector_id
367
838
  ws = store.get_connection(charger.charger_id, connector_value)
@@ -372,15 +843,19 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
372
843
  level=messages.ERROR,
373
844
  )
374
845
  continue
846
+ payload: dict[str, object] = {"requestedMessage": "StatusNotification"}
847
+ trigger_connector: int | None = None
848
+ if connector_value is not None:
849
+ payload["connectorId"] = connector_value
850
+ trigger_connector = connector_value
375
851
  message_id = uuid.uuid4().hex
376
- payload = {}
377
- msg = json.dumps([2, message_id, "GetConfiguration", payload])
852
+ msg = json.dumps([2, message_id, "TriggerMessage", payload])
378
853
  try:
379
854
  async_to_sync(ws.send)(msg)
380
855
  except Exception as exc: # pragma: no cover - network error
381
856
  self.message_user(
382
857
  request,
383
- f"{charger}: failed to send GetConfiguration ({exc})",
858
+ f"{charger}: failed to send TriggerMessage ({exc})",
384
859
  level=messages.ERROR,
385
860
  )
386
861
  continue
@@ -389,79 +864,254 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
389
864
  store.register_pending_call(
390
865
  message_id,
391
866
  {
392
- "action": "GetConfiguration",
867
+ "action": "TriggerMessage",
393
868
  "charger_id": charger.charger_id,
394
869
  "connector_id": connector_value,
395
870
  "log_key": log_key,
871
+ "trigger_target": "StatusNotification",
872
+ "trigger_connector": trigger_connector,
396
873
  "requested_at": timezone.now(),
397
874
  },
398
875
  )
399
876
  store.schedule_call_timeout(
400
877
  message_id,
401
878
  timeout=5.0,
402
- action="GetConfiguration",
879
+ action="TriggerMessage",
403
880
  log_key=log_key,
404
- message=(
405
- "GetConfiguration timed out: charger did not respond"
406
- " (operation may not be supported)"
407
- ),
881
+ message="TriggerMessage StatusNotification timed out",
408
882
  )
409
- fetched += 1
883
+ requested += 1
884
+ if requested:
885
+ self.message_user(
886
+ request,
887
+ f"Requested status update from {requested} charger(s)",
888
+ )
889
+
890
+ @admin.action(description="Fetch CP configuration")
891
+ def fetch_cp_configuration(self, request, queryset):
892
+ fetched = 0
893
+ local_node = None
894
+ private_key = None
895
+ remote_unavailable = False
896
+ for charger in queryset:
897
+ if charger.is_local:
898
+ connector_value = charger.connector_id
899
+ ws = store.get_connection(charger.charger_id, connector_value)
900
+ if ws is None:
901
+ self.message_user(
902
+ request,
903
+ f"{charger}: no active connection",
904
+ level=messages.ERROR,
905
+ )
906
+ continue
907
+ message_id = uuid.uuid4().hex
908
+ payload = {}
909
+ msg = json.dumps([2, message_id, "GetConfiguration", payload])
910
+ try:
911
+ async_to_sync(ws.send)(msg)
912
+ except Exception as exc: # pragma: no cover - network error
913
+ self.message_user(
914
+ request,
915
+ f"{charger}: failed to send GetConfiguration ({exc})",
916
+ level=messages.ERROR,
917
+ )
918
+ continue
919
+ log_key = store.identity_key(charger.charger_id, connector_value)
920
+ store.add_log(log_key, f"< {msg}", log_type="charger")
921
+ store.register_pending_call(
922
+ message_id,
923
+ {
924
+ "action": "GetConfiguration",
925
+ "charger_id": charger.charger_id,
926
+ "connector_id": connector_value,
927
+ "log_key": log_key,
928
+ "requested_at": timezone.now(),
929
+ },
930
+ )
931
+ store.schedule_call_timeout(
932
+ message_id,
933
+ timeout=5.0,
934
+ action="GetConfiguration",
935
+ log_key=log_key,
936
+ message=(
937
+ "GetConfiguration timed out: charger did not respond"
938
+ " (operation may not be supported)"
939
+ ),
940
+ )
941
+ fetched += 1
942
+ continue
943
+
944
+ if not charger.allow_remote:
945
+ self.message_user(
946
+ request,
947
+ f"{charger}: remote administration is disabled.",
948
+ level=messages.ERROR,
949
+ )
950
+ continue
951
+ if remote_unavailable:
952
+ continue
953
+ if local_node is None:
954
+ local_node, private_key = self._prepare_remote_credentials(request)
955
+ if not local_node or not private_key:
956
+ remote_unavailable = True
957
+ continue
958
+ success, updates = self._call_remote_action(
959
+ request,
960
+ local_node,
961
+ private_key,
962
+ charger,
963
+ "get-configuration",
964
+ )
965
+ if success:
966
+ self._apply_remote_updates(charger, updates)
967
+ fetched += 1
968
+
410
969
  if fetched:
411
970
  self.message_user(
412
971
  request,
413
972
  f"Requested configuration from {fetched} charger(s)",
414
973
  )
415
974
 
416
- def _dispatch_change_availability(self, request, queryset, availability_type: str):
417
- sent = 0
975
+ @admin.action(description="Toggle RFID Authentication")
976
+ def toggle_rfid_authentication(self, request, queryset):
977
+ enabled = 0
978
+ disabled = 0
979
+ local_node = None
980
+ private_key = None
981
+ remote_unavailable = False
418
982
  for charger in queryset:
419
- connector_value = charger.connector_id
420
- ws = store.get_connection(charger.charger_id, connector_value)
421
- if ws is None:
983
+ new_value = not charger.require_rfid
984
+ if charger.is_local:
985
+ Charger.objects.filter(pk=charger.pk).update(require_rfid=new_value)
986
+ charger.require_rfid = new_value
987
+ if new_value:
988
+ enabled += 1
989
+ else:
990
+ disabled += 1
991
+ continue
992
+
993
+ if not charger.allow_remote:
422
994
  self.message_user(
423
995
  request,
424
- f"{charger}: no active connection",
996
+ f"{charger}: remote administration is disabled.",
425
997
  level=messages.ERROR,
426
998
  )
427
999
  continue
428
- connector_id = connector_value if connector_value is not None else 0
429
- message_id = uuid.uuid4().hex
430
- payload = {"connectorId": connector_id, "type": availability_type}
431
- msg = json.dumps([2, message_id, "ChangeAvailability", payload])
432
- try:
433
- async_to_sync(ws.send)(msg)
434
- except Exception as exc: # pragma: no cover - network error
1000
+ if remote_unavailable:
1001
+ continue
1002
+ if local_node is None:
1003
+ local_node, private_key = self._prepare_remote_credentials(request)
1004
+ if not local_node or not private_key:
1005
+ remote_unavailable = True
1006
+ continue
1007
+ success, updates = self._call_remote_action(
1008
+ request,
1009
+ local_node,
1010
+ private_key,
1011
+ charger,
1012
+ "toggle-rfid",
1013
+ {"enable": new_value},
1014
+ )
1015
+ if success:
1016
+ self._apply_remote_updates(charger, updates)
1017
+ if charger.require_rfid:
1018
+ enabled += 1
1019
+ else:
1020
+ disabled += 1
1021
+
1022
+ if enabled or disabled:
1023
+ changes = []
1024
+ if enabled:
1025
+ changes.append(f"enabled for {enabled} charger(s)")
1026
+ if disabled:
1027
+ changes.append(f"disabled for {disabled} charger(s)")
1028
+ summary = "; ".join(changes)
1029
+ self.message_user(
1030
+ request,
1031
+ f"Updated RFID authentication: {summary}",
1032
+ )
1033
+
1034
+ def _dispatch_change_availability(self, request, queryset, availability_type: str):
1035
+ sent = 0
1036
+ local_node = None
1037
+ private_key = None
1038
+ remote_unavailable = False
1039
+ for charger in queryset:
1040
+ if charger.is_local:
1041
+ connector_value = charger.connector_id
1042
+ ws = store.get_connection(charger.charger_id, connector_value)
1043
+ if ws is None:
1044
+ self.message_user(
1045
+ request,
1046
+ f"{charger}: no active connection",
1047
+ level=messages.ERROR,
1048
+ )
1049
+ continue
1050
+ connector_id = connector_value if connector_value is not None else 0
1051
+ message_id = uuid.uuid4().hex
1052
+ payload = {"connectorId": connector_id, "type": availability_type}
1053
+ msg = json.dumps([2, message_id, "ChangeAvailability", payload])
1054
+ try:
1055
+ async_to_sync(ws.send)(msg)
1056
+ except Exception as exc: # pragma: no cover - network error
1057
+ self.message_user(
1058
+ request,
1059
+ f"{charger}: failed to send ChangeAvailability ({exc})",
1060
+ level=messages.ERROR,
1061
+ )
1062
+ continue
1063
+ log_key = store.identity_key(charger.charger_id, connector_value)
1064
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1065
+ timestamp = timezone.now()
1066
+ store.register_pending_call(
1067
+ message_id,
1068
+ {
1069
+ "action": "ChangeAvailability",
1070
+ "charger_id": charger.charger_id,
1071
+ "connector_id": connector_value,
1072
+ "availability_type": availability_type,
1073
+ "requested_at": timestamp,
1074
+ },
1075
+ )
1076
+ updates = {
1077
+ "availability_requested_state": availability_type,
1078
+ "availability_requested_at": timestamp,
1079
+ "availability_request_status": "",
1080
+ "availability_request_status_at": None,
1081
+ "availability_request_details": "",
1082
+ }
1083
+ Charger.objects.filter(pk=charger.pk).update(**updates)
1084
+ for field, value in updates.items():
1085
+ setattr(charger, field, value)
1086
+ sent += 1
1087
+ continue
1088
+
1089
+ if not charger.allow_remote:
435
1090
  self.message_user(
436
1091
  request,
437
- f"{charger}: failed to send ChangeAvailability ({exc})",
1092
+ f"{charger}: remote administration is disabled.",
438
1093
  level=messages.ERROR,
439
1094
  )
440
1095
  continue
441
- log_key = store.identity_key(charger.charger_id, connector_value)
442
- store.add_log(log_key, f"< {msg}", log_type="charger")
443
- timestamp = timezone.now()
444
- store.register_pending_call(
445
- message_id,
446
- {
447
- "action": "ChangeAvailability",
448
- "charger_id": charger.charger_id,
449
- "connector_id": connector_value,
450
- "availability_type": availability_type,
451
- "requested_at": timestamp,
452
- },
1096
+ if remote_unavailable:
1097
+ continue
1098
+ if local_node is None:
1099
+ local_node, private_key = self._prepare_remote_credentials(request)
1100
+ if not local_node or not private_key:
1101
+ remote_unavailable = True
1102
+ continue
1103
+ success, updates = self._call_remote_action(
1104
+ request,
1105
+ local_node,
1106
+ private_key,
1107
+ charger,
1108
+ "change-availability",
1109
+ {"availability_type": availability_type},
453
1110
  )
454
- updates = {
455
- "availability_requested_state": availability_type,
456
- "availability_requested_at": timestamp,
457
- "availability_request_status": "",
458
- "availability_request_status_at": None,
459
- "availability_request_details": "",
460
- }
461
- Charger.objects.filter(pk=charger.pk).update(**updates)
462
- for field, value in updates.items():
463
- setattr(charger, field, value)
464
- sent += 1
1111
+ if success:
1112
+ self._apply_remote_updates(charger, updates)
1113
+ sent += 1
1114
+
465
1115
  if sent:
466
1116
  self.message_user(
467
1117
  request,
@@ -479,17 +1129,49 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
479
1129
  def _set_availability_state(
480
1130
  self, request, queryset, availability_state: str
481
1131
  ) -> None:
482
- timestamp = timezone.now()
483
1132
  updated = 0
1133
+ local_node = None
1134
+ private_key = None
1135
+ remote_unavailable = False
484
1136
  for charger in queryset:
485
- updates = {
486
- "availability_state": availability_state,
487
- "availability_state_updated_at": timestamp,
488
- }
489
- Charger.objects.filter(pk=charger.pk).update(**updates)
490
- for field, value in updates.items():
491
- setattr(charger, field, value)
492
- updated += 1
1137
+ if charger.is_local:
1138
+ timestamp = timezone.now()
1139
+ updates = {
1140
+ "availability_state": availability_state,
1141
+ "availability_state_updated_at": timestamp,
1142
+ }
1143
+ Charger.objects.filter(pk=charger.pk).update(**updates)
1144
+ for field, value in updates.items():
1145
+ setattr(charger, field, value)
1146
+ updated += 1
1147
+ continue
1148
+
1149
+ if not charger.allow_remote:
1150
+ self.message_user(
1151
+ request,
1152
+ f"{charger}: remote administration is disabled.",
1153
+ level=messages.ERROR,
1154
+ )
1155
+ continue
1156
+ if remote_unavailable:
1157
+ continue
1158
+ if local_node is None:
1159
+ local_node, private_key = self._prepare_remote_credentials(request)
1160
+ if not local_node or not private_key:
1161
+ remote_unavailable = True
1162
+ continue
1163
+ success, updates = self._call_remote_action(
1164
+ request,
1165
+ local_node,
1166
+ private_key,
1167
+ charger,
1168
+ "set-availability-state",
1169
+ {"availability_state": availability_state},
1170
+ )
1171
+ if success:
1172
+ self._apply_remote_updates(charger, updates)
1173
+ updated += 1
1174
+
493
1175
  if updated:
494
1176
  self.message_user(
495
1177
  request,
@@ -507,55 +1189,86 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
507
1189
  @admin.action(description="Remote stop active transaction")
508
1190
  def remote_stop_transaction(self, request, queryset):
509
1191
  stopped = 0
1192
+ local_node = None
1193
+ private_key = None
1194
+ remote_unavailable = False
510
1195
  for charger in queryset:
511
- connector_value = charger.connector_id
512
- ws = store.get_connection(charger.charger_id, connector_value)
513
- if ws is None:
514
- self.message_user(
515
- request,
516
- f"{charger}: no active connection",
517
- level=messages.ERROR,
1196
+ if charger.is_local:
1197
+ connector_value = charger.connector_id
1198
+ ws = store.get_connection(charger.charger_id, connector_value)
1199
+ if ws is None:
1200
+ self.message_user(
1201
+ request,
1202
+ f"{charger}: no active connection",
1203
+ level=messages.ERROR,
1204
+ )
1205
+ continue
1206
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
1207
+ if tx_obj is None:
1208
+ self.message_user(
1209
+ request,
1210
+ f"{charger}: no active transaction",
1211
+ level=messages.ERROR,
1212
+ )
1213
+ continue
1214
+ message_id = uuid.uuid4().hex
1215
+ payload = {"transactionId": tx_obj.pk}
1216
+ msg = json.dumps([
1217
+ 2,
1218
+ message_id,
1219
+ "RemoteStopTransaction",
1220
+ payload,
1221
+ ])
1222
+ try:
1223
+ async_to_sync(ws.send)(msg)
1224
+ except Exception as exc: # pragma: no cover - network error
1225
+ self.message_user(
1226
+ request,
1227
+ f"{charger}: failed to send RemoteStopTransaction ({exc})",
1228
+ level=messages.ERROR,
1229
+ )
1230
+ continue
1231
+ log_key = store.identity_key(charger.charger_id, connector_value)
1232
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1233
+ store.register_pending_call(
1234
+ message_id,
1235
+ {
1236
+ "action": "RemoteStopTransaction",
1237
+ "charger_id": charger.charger_id,
1238
+ "connector_id": connector_value,
1239
+ "transaction_id": tx_obj.pk,
1240
+ "log_key": log_key,
1241
+ "requested_at": timezone.now(),
1242
+ },
518
1243
  )
1244
+ stopped += 1
519
1245
  continue
520
- tx_obj = store.get_transaction(charger.charger_id, connector_value)
521
- if tx_obj is None:
1246
+
1247
+ if not charger.allow_remote:
522
1248
  self.message_user(
523
1249
  request,
524
- f"{charger}: no active transaction",
1250
+ f"{charger}: remote administration is disabled.",
525
1251
  level=messages.ERROR,
526
1252
  )
527
1253
  continue
528
- message_id = uuid.uuid4().hex
529
- payload = {"transactionId": tx_obj.pk}
530
- msg = json.dumps([
531
- 2,
532
- message_id,
533
- "RemoteStopTransaction",
534
- payload,
535
- ])
536
- try:
537
- async_to_sync(ws.send)(msg)
538
- except Exception as exc: # pragma: no cover - network error
539
- self.message_user(
540
- request,
541
- f"{charger}: failed to send RemoteStopTransaction ({exc})",
542
- level=messages.ERROR,
543
- )
1254
+ if remote_unavailable:
544
1255
  continue
545
- log_key = store.identity_key(charger.charger_id, connector_value)
546
- store.add_log(log_key, f"< {msg}", log_type="charger")
547
- store.register_pending_call(
548
- message_id,
549
- {
550
- "action": "RemoteStopTransaction",
551
- "charger_id": charger.charger_id,
552
- "connector_id": connector_value,
553
- "transaction_id": tx_obj.pk,
554
- "log_key": log_key,
555
- "requested_at": timezone.now(),
556
- },
1256
+ if local_node is None:
1257
+ local_node, private_key = self._prepare_remote_credentials(request)
1258
+ if not local_node or not private_key:
1259
+ remote_unavailable = True
1260
+ continue
1261
+ success, updates = self._call_remote_action(
1262
+ request,
1263
+ local_node,
1264
+ private_key,
1265
+ charger,
1266
+ "remote-stop",
557
1267
  )
558
- stopped += 1
1268
+ if success:
1269
+ self._apply_remote_updates(charger, updates)
1270
+ stopped += 1
1271
+
559
1272
  if stopped:
560
1273
  self.message_user(
561
1274
  request,
@@ -565,45 +1278,95 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
565
1278
  @admin.action(description="Reset charger (soft)")
566
1279
  def reset_chargers(self, request, queryset):
567
1280
  reset = 0
1281
+ local_node = None
1282
+ private_key = None
1283
+ remote_unavailable = False
568
1284
  for charger in queryset:
569
- connector_value = charger.connector_id
570
- ws = store.get_connection(charger.charger_id, connector_value)
571
- if ws is None:
572
- self.message_user(
573
- request,
574
- f"{charger}: no active connection",
575
- level=messages.ERROR,
1285
+ if charger.is_local:
1286
+ connector_value = charger.connector_id
1287
+ ws = store.get_connection(charger.charger_id, connector_value)
1288
+ if ws is None:
1289
+ self.message_user(
1290
+ request,
1291
+ f"{charger}: no active connection",
1292
+ level=messages.ERROR,
1293
+ )
1294
+ continue
1295
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
1296
+ if tx_obj is not None:
1297
+ self.message_user(
1298
+ request,
1299
+ (
1300
+ f"{charger}: reset skipped because a session is active; "
1301
+ "stop the session first."
1302
+ ),
1303
+ level=messages.WARNING,
1304
+ )
1305
+ continue
1306
+ message_id = uuid.uuid4().hex
1307
+ msg = json.dumps([
1308
+ 2,
1309
+ message_id,
1310
+ "Reset",
1311
+ {"type": "Soft"},
1312
+ ])
1313
+ try:
1314
+ async_to_sync(ws.send)(msg)
1315
+ except Exception as exc: # pragma: no cover - network error
1316
+ self.message_user(
1317
+ request,
1318
+ f"{charger}: failed to send Reset ({exc})",
1319
+ level=messages.ERROR,
1320
+ )
1321
+ continue
1322
+ log_key = store.identity_key(charger.charger_id, connector_value)
1323
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1324
+ store.register_pending_call(
1325
+ message_id,
1326
+ {
1327
+ "action": "Reset",
1328
+ "charger_id": charger.charger_id,
1329
+ "connector_id": connector_value,
1330
+ "log_key": log_key,
1331
+ "requested_at": timezone.now(),
1332
+ },
1333
+ )
1334
+ store.schedule_call_timeout(
1335
+ message_id,
1336
+ timeout=5.0,
1337
+ action="Reset",
1338
+ log_key=log_key,
1339
+ message="Reset timed out: charger did not respond",
576
1340
  )
1341
+ reset += 1
577
1342
  continue
578
- message_id = uuid.uuid4().hex
579
- msg = json.dumps([
580
- 2,
581
- message_id,
582
- "Reset",
583
- {"type": "Soft"},
584
- ])
585
- try:
586
- async_to_sync(ws.send)(msg)
587
- except Exception as exc: # pragma: no cover - network error
1343
+
1344
+ if not charger.allow_remote:
588
1345
  self.message_user(
589
1346
  request,
590
- f"{charger}: failed to send Reset ({exc})",
1347
+ f"{charger}: remote administration is disabled.",
591
1348
  level=messages.ERROR,
592
1349
  )
593
1350
  continue
594
- log_key = store.identity_key(charger.charger_id, connector_value)
595
- store.add_log(log_key, f"< {msg}", log_type="charger")
596
- store.register_pending_call(
597
- message_id,
598
- {
599
- "action": "Reset",
600
- "charger_id": charger.charger_id,
601
- "connector_id": connector_value,
602
- "log_key": log_key,
603
- "requested_at": timezone.now(),
604
- },
1351
+ if remote_unavailable:
1352
+ continue
1353
+ if local_node is None:
1354
+ local_node, private_key = self._prepare_remote_credentials(request)
1355
+ if not local_node or not private_key:
1356
+ remote_unavailable = True
1357
+ continue
1358
+ success, updates = self._call_remote_action(
1359
+ request,
1360
+ local_node,
1361
+ private_key,
1362
+ charger,
1363
+ "reset",
1364
+ {"reset_type": "Soft"},
605
1365
  )
606
- reset += 1
1366
+ if success:
1367
+ self._apply_remote_updates(charger, updates)
1368
+ reset += 1
1369
+
607
1370
  if reset:
608
1371
  self.message_user(
609
1372
  request,
@@ -619,13 +1382,49 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
619
1382
 
620
1383
  total_kw_display.short_description = "Total kW"
621
1384
 
622
- def session_kw(self, obj):
623
- tx = store.get_transaction(obj.charger_id, obj.connector_id)
624
- if tx:
625
- return round(tx.kw, 2)
626
- return 0.0
1385
+ def today_kw(self, obj):
1386
+ start, end = self._today_range()
1387
+ return round(obj.total_kw_for_range(start, end), 2)
1388
+
1389
+ today_kw.short_description = "Today kW"
1390
+
1391
+ def changelist_view(self, request, extra_context=None):
1392
+ response = super().changelist_view(request, extra_context=extra_context)
1393
+ if hasattr(response, "context_data"):
1394
+ cl = response.context_data.get("cl")
1395
+ if cl is not None:
1396
+ response.context_data.update(
1397
+ self._charger_quick_stats_context(cl.queryset)
1398
+ )
1399
+ return response
1400
+
1401
+ def _charger_quick_stats_context(self, queryset):
1402
+ chargers = list(queryset)
1403
+ stats = {"total_kw": 0.0, "today_kw": 0.0}
1404
+ if not chargers:
1405
+ return {"charger_quick_stats": stats}
627
1406
 
628
- session_kw.short_description = "Session kW"
1407
+ parent_ids = {c.charger_id for c in chargers if c.connector_id is None}
1408
+ start, end = self._today_range()
1409
+
1410
+ for charger in chargers:
1411
+ include_totals = True
1412
+ if charger.connector_id is not None and charger.charger_id in parent_ids:
1413
+ include_totals = False
1414
+ if include_totals:
1415
+ stats["total_kw"] += charger.total_kw
1416
+ stats["today_kw"] += charger.total_kw_for_range(start, end)
1417
+
1418
+ stats = {key: round(value, 2) for key, value in stats.items()}
1419
+ return {"charger_quick_stats": stats}
1420
+
1421
+ def _today_range(self):
1422
+ today = timezone.localdate()
1423
+ start = datetime.combine(today, time.min)
1424
+ if timezone.is_naive(start):
1425
+ start = timezone.make_aware(start, timezone.get_current_timezone())
1426
+ end = start + timedelta(days=1)
1427
+ return start, end
629
1428
 
630
1429
 
631
1430
  @admin.register(Simulator)
@@ -637,7 +1436,7 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
637
1436
  "ws_port",
638
1437
  "ws_url",
639
1438
  "interval",
640
- "kw_max",
1439
+ "kw_max_display",
641
1440
  "running",
642
1441
  "log_link",
643
1442
  )
@@ -677,6 +1476,26 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
677
1476
 
678
1477
  log_type = "simulator"
679
1478
 
1479
+ @admin.display(description="kW Max", ordering="kw_max")
1480
+ def kw_max_display(self, obj):
1481
+ """Display ``kw_max`` with a dot decimal separator for Spanish locales."""
1482
+
1483
+ language = translation.get_language() or ""
1484
+ if language.startswith("es"):
1485
+ return formats.number_format(
1486
+ obj.kw_max,
1487
+ decimal_pos=2,
1488
+ use_l10n=False,
1489
+ force_grouping=False,
1490
+ )
1491
+
1492
+ return formats.number_format(
1493
+ obj.kw_max,
1494
+ decimal_pos=2,
1495
+ use_l10n=True,
1496
+ force_grouping=False,
1497
+ )
1498
+
680
1499
  def save_model(self, request, obj, form, change):
681
1500
  previous_door_open = False
682
1501
  if change and obj.pk:
@@ -830,8 +1649,10 @@ class TransactionAdmin(EntityModelAdmin):
830
1649
  change_list_template = "admin/ocpp/transaction/change_list.html"
831
1650
  list_display = (
832
1651
  "charger",
1652
+ "connector_number",
833
1653
  "account",
834
1654
  "rfid",
1655
+ "vid",
835
1656
  "meter_start",
836
1657
  "meter_stop",
837
1658
  "start_time",
@@ -843,6 +1664,12 @@ class TransactionAdmin(EntityModelAdmin):
843
1664
  date_hierarchy = "start_time"
844
1665
  inlines = [MeterValueInline]
845
1666
 
1667
+ def connector_number(self, obj):
1668
+ return obj.connector_id or ""
1669
+
1670
+ connector_number.short_description = "#"
1671
+ connector_number.admin_order_field = "connector_id"
1672
+
846
1673
  def get_urls(self):
847
1674
  urls = super().get_urls()
848
1675
  custom = [