arthexis 0.1.18__py3-none-any.whl → 0.1.20__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.
ocpp/tests.py CHANGED
@@ -61,13 +61,14 @@ from config.asgi import application
61
61
  from .models import (
62
62
  Transaction,
63
63
  Charger,
64
+ ChargerConfiguration,
64
65
  Simulator,
65
66
  MeterReading,
66
67
  Location,
67
68
  DataTransferMessage,
68
69
  )
69
70
  from .consumers import CSMSConsumer
70
- from .views import dispatch_action
71
+ from .views import dispatch_action, _transaction_rfid_details
71
72
  from .status_display import STATUS_BADGE_MAP
72
73
  from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
73
74
  from . import store
@@ -714,6 +715,13 @@ class CSMSConsumerTests(TransactionTestCase):
714
715
  connected, _ = await communicator.connect()
715
716
  self.assertTrue(connected)
716
717
 
718
+ await database_sync_to_async(Charger.objects.get_or_create)(
719
+ charger_id="CFGRES", connector_id=1
720
+ )
721
+ await database_sync_to_async(Charger.objects.get_or_create)(
722
+ charger_id="CFGRES", connector_id=2
723
+ )
724
+
717
725
  message_id = "cfg-result"
718
726
  payload = {
719
727
  "configurationKey": [
@@ -744,6 +752,31 @@ class CSMSConsumerTests(TransactionTestCase):
744
752
  )
745
753
  self.assertNotIn(message_id, store.pending_calls)
746
754
 
755
+ configuration = await database_sync_to_async(
756
+ lambda: ChargerConfiguration.objects.order_by("-created_at").first()
757
+ )()
758
+ self.assertIsNotNone(configuration)
759
+ self.assertEqual(configuration.charger_identifier, "CFGRES")
760
+ self.assertEqual(
761
+ configuration.configuration_keys,
762
+ [
763
+ {
764
+ "key": "AllowOfflineTxForUnknownId",
765
+ "value": "false",
766
+ "readonly": True,
767
+ }
768
+ ],
769
+ )
770
+ self.assertEqual(configuration.unknown_keys, [])
771
+ config_ids = await database_sync_to_async(
772
+ lambda: set(
773
+ Charger.objects.filter(charger_id="CFGRES").values_list(
774
+ "configuration_id", flat=True
775
+ )
776
+ )
777
+ )()
778
+ self.assertEqual(config_ids, {configuration.pk})
779
+
747
780
  await communicator.disconnect()
748
781
  store.clear_log(log_key, log_type="charger")
749
782
  store.clear_log(pending_key, log_type="charger")
@@ -1109,7 +1142,7 @@ class CSMSConsumerTests(TransactionTestCase):
1109
1142
 
1110
1143
  await communicator.disconnect()
1111
1144
 
1112
- async def test_vin_recorded(self):
1145
+ async def test_vid_populated_from_vin(self):
1113
1146
  await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
1114
1147
  communicator = WebsocketCommunicator(application, "/VINREC/")
1115
1148
  connected, _ = await communicator.connect()
@@ -1124,7 +1157,29 @@ class CSMSConsumerTests(TransactionTestCase):
1124
1157
  tx = await database_sync_to_async(Transaction.objects.get)(
1125
1158
  pk=tx_id, charger__charger_id="VINREC"
1126
1159
  )
1127
- self.assertEqual(tx.vin, "WP0ZZZ11111111111")
1160
+ self.assertEqual(tx.vid, "WP0ZZZ11111111111")
1161
+ self.assertEqual(tx.vehicle_identifier, "WP0ZZZ11111111111")
1162
+ self.assertEqual(tx.vehicle_identifier_source, "vid")
1163
+
1164
+ await communicator.disconnect()
1165
+
1166
+ async def test_vid_recorded(self):
1167
+ await database_sync_to_async(Charger.objects.create)(charger_id="VIDREC")
1168
+ communicator = WebsocketCommunicator(application, "/VIDREC/")
1169
+ connected, _ = await communicator.connect()
1170
+ self.assertTrue(connected)
1171
+
1172
+ await communicator.send_json_to(
1173
+ [2, "1", "StartTransaction", {"meterStart": 1, "vid": "VID123456"}]
1174
+ )
1175
+ response = await communicator.receive_json_from()
1176
+ tx_id = response[2]["transactionId"]
1177
+
1178
+ tx = await database_sync_to_async(Transaction.objects.get)(
1179
+ pk=tx_id, charger__charger_id="VIDREC"
1180
+ )
1181
+ self.assertEqual(tx.vid, "VID123456")
1182
+ self.assertEqual(tx.rfid, "")
1128
1183
 
1129
1184
  await communicator.disconnect()
1130
1185
 
@@ -1852,6 +1907,16 @@ class ChargerLandingTests(TestCase):
1852
1907
  status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
1853
1908
  self.assertContains(response, status_url)
1854
1909
 
1910
+ def test_charger_page_respects_language_configuration(self):
1911
+ charger = Charger.objects.create(charger_id="PAGE-DE", language="de")
1912
+
1913
+ response = self.client.get(reverse("charger-page", args=["PAGE-DE"]))
1914
+
1915
+ self.assertEqual(response.status_code, 200)
1916
+ self.assertEqual(response.context["LANGUAGE_CODE"], "de")
1917
+ self.assertContains(response, 'lang="de"')
1918
+ self.assertContains(response, 'data-preferred-language="de"')
1919
+
1855
1920
  def test_status_page_renders(self):
1856
1921
  charger = Charger.objects.create(charger_id="PAGE2")
1857
1922
  resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
@@ -2078,7 +2143,10 @@ class ChargerLandingTests(TestCase):
2078
2143
  log_id = store.identity_key("LOG1", None)
2079
2144
  store.add_log(log_id, "hello", log_type="charger")
2080
2145
  entry = store.get_logs(log_id, log_type="charger")[0]
2081
- self.assertRegex(entry, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} hello$")
2146
+ self.assertRegex(
2147
+ entry,
2148
+ r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} hello$",
2149
+ )
2082
2150
  resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
2083
2151
  self.assertEqual(resp.status_code, 200)
2084
2152
  self.assertContains(resp, "hello")
@@ -2113,12 +2181,12 @@ class SimulatorLandingTests(TestCase):
2113
2181
  @skip("Navigation links unavailable in test environment")
2114
2182
  def test_simulator_app_link_in_nav(self):
2115
2183
  resp = self.client.get(reverse("pages:index"))
2116
- self.assertContains(resp, "/ocpp/")
2117
- self.assertNotContains(resp, "/ocpp/simulator/")
2184
+ self.assertContains(resp, "/ocpp/cpms/dashboard/")
2185
+ self.assertNotContains(resp, "/ocpp/evcs/simulator/")
2118
2186
  self.client.force_login(self.user)
2119
2187
  resp = self.client.get(reverse("pages:index"))
2120
- self.assertContains(resp, "/ocpp/")
2121
- self.assertContains(resp, "/ocpp/simulator/")
2188
+ self.assertContains(resp, "/ocpp/cpms/dashboard/")
2189
+ self.assertContains(resp, "/ocpp/evcs/simulator/")
2122
2190
 
2123
2191
  def test_cp_simulator_redirects_to_login(self):
2124
2192
  response = self.client.get(reverse("cp-simulator"))
@@ -3199,8 +3267,7 @@ class SimulatorAdminTests(TransactionTestCase):
3199
3267
  self.assertIsNotNone(aggregate.last_heartbeat)
3200
3268
  if previous_heartbeat:
3201
3269
  self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
3202
- if connector.last_heartbeat:
3203
- self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
3270
+ self.assertEqual(connector.last_heartbeat, aggregate.last_heartbeat)
3204
3271
 
3205
3272
  await communicator.disconnect()
3206
3273
 
@@ -3996,6 +4063,43 @@ class TransactionKwTests(TestCase):
3996
4063
  self.assertEqual(tx.kw, 0.0)
3997
4064
 
3998
4065
 
4066
+ class TransactionIdentifierTests(TestCase):
4067
+ def test_vehicle_identifier_prefers_vid(self):
4068
+ charger = Charger.objects.create(charger_id="VIDPREF")
4069
+ tx = Transaction.objects.create(
4070
+ charger=charger,
4071
+ start_time=timezone.now(),
4072
+ vid="VID-123",
4073
+ vin="VIN-456",
4074
+ )
4075
+ self.assertEqual(tx.vehicle_identifier, "VID-123")
4076
+ self.assertEqual(tx.vehicle_identifier_source, "vid")
4077
+
4078
+ def test_vehicle_identifier_falls_back_to_vin(self):
4079
+ charger = Charger.objects.create(charger_id="VINONLY")
4080
+ tx = Transaction.objects.create(
4081
+ charger=charger,
4082
+ start_time=timezone.now(),
4083
+ vin="WP0ZZZ00000000001",
4084
+ )
4085
+ self.assertEqual(tx.vehicle_identifier, "WP0ZZZ00000000001")
4086
+ self.assertEqual(tx.vehicle_identifier_source, "vin")
4087
+
4088
+ def test_transaction_rfid_details_handles_vin(self):
4089
+ charger = Charger.objects.create(charger_id="VINDET")
4090
+ tx = Transaction.objects.create(
4091
+ charger=charger,
4092
+ start_time=timezone.now(),
4093
+ vin="WAUZZZ00000000002",
4094
+ )
4095
+ details = _transaction_rfid_details(tx, cache={})
4096
+ self.assertIsNotNone(details)
4097
+ assert details is not None # for type checkers
4098
+ self.assertEqual(details["value"], "WAUZZZ00000000002")
4099
+ self.assertEqual(details["display_label"], "VIN")
4100
+ self.assertEqual(details["type"], "vin")
4101
+
4102
+
3999
4103
  class DispatchActionViewTests(TestCase):
4000
4104
  def setUp(self):
4001
4105
  self.client = Client()
ocpp/transactions_io.py CHANGED
@@ -46,6 +46,7 @@ def export_transactions(
46
46
  "charger": tx.charger.charger_id if tx.charger else None,
47
47
  "account": tx.account_id,
48
48
  "rfid": tx.rfid,
49
+ "vid": tx.vehicle_identifier,
49
50
  "vin": tx.vin,
50
51
  "meter_start": tx.meter_start,
51
52
  "meter_stop": tx.meter_stop,
@@ -144,11 +145,18 @@ def import_transactions(data: dict) -> int:
144
145
  except ValidationError:
145
146
  continue
146
147
  charger_map[serial] = charger
148
+ vid_value = tx.get("vid")
149
+ vin_value = tx.get("vin")
150
+ vid_text = str(vid_value).strip() if vid_value is not None else ""
151
+ vin_text = str(vin_value).strip() if vin_value is not None else ""
152
+ if not vid_text and vin_text:
153
+ vid_text = vin_text
147
154
  transaction = Transaction.objects.create(
148
155
  charger=charger,
149
156
  account_id=tx.get("account"),
150
157
  rfid=tx.get("rfid", ""),
151
- vin=tx.get("vin", ""),
158
+ vid=vid_text,
159
+ vin=vin_text,
152
160
  meter_start=tx.get("meter_start"),
153
161
  meter_stop=tx.get("meter_stop"),
154
162
  voltage_start=tx.get("voltage_start"),
ocpp/urls.py CHANGED
@@ -3,8 +3,8 @@ from django.urls import include, path
3
3
  from . import views
4
4
 
5
5
  urlpatterns = [
6
- path("", views.dashboard, name="ocpp-dashboard"),
7
- path("simulator/", views.cp_simulator, name="cp-simulator"),
6
+ path("cpms/dashboard/", views.dashboard, name="ocpp-dashboard"),
7
+ path("evcs/simulator/", views.cp_simulator, name="cp-simulator"),
8
8
  path("chargers/", views.charger_list, name="charger-list"),
9
9
  path("chargers/<str:cid>/", views.charger_detail, name="charger-detail"),
10
10
  path(
@@ -46,5 +46,5 @@ urlpatterns = [
46
46
  views.charger_status,
47
47
  name="charger-status-connector",
48
48
  ),
49
- path("rfid/", include("ocpp.rfid.urls")),
49
+ path("rfid/validator/", include("ocpp.rfid.urls")),
50
50
  ]
ocpp/views.py CHANGED
@@ -11,6 +11,7 @@ from django.core.paginator import Paginator
11
11
  from django.contrib.auth.decorators import login_required
12
12
  from django.contrib.auth.views import redirect_to_login
13
13
  from django.utils.translation import gettext_lazy as _, gettext, ngettext
14
+ from django.utils.text import slugify
14
15
  from django.urls import NoReverseMatch, reverse
15
16
  from django.conf import settings
16
17
  from django.utils import translation, timezone
@@ -223,24 +224,75 @@ def _transaction_rfid_details(
223
224
  if not tx_obj:
224
225
  return None
225
226
  rfid_value = getattr(tx_obj, "rfid", None)
226
- if not rfid_value:
227
- return None
228
- normalized = str(rfid_value).strip()
229
- if not normalized:
227
+ normalized = str(rfid_value or "").strip().upper()
228
+ cache_key = normalized
229
+ if normalized:
230
+ if cache is not None and cache_key in cache:
231
+ return cache[cache_key]
232
+ tag = (
233
+ RFID.matching_queryset(normalized)
234
+ .only("pk", "label_id", "custom_label")
235
+ .first()
236
+ )
237
+ rfid_url = None
238
+ label_value = None
239
+ canonical_value = normalized
240
+ if tag:
241
+ try:
242
+ rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
243
+ except NoReverseMatch: # pragma: no cover - admin may be disabled
244
+ rfid_url = None
245
+ custom_label = (tag.custom_label or "").strip()
246
+ if custom_label:
247
+ label_value = custom_label
248
+ elif tag.label_id is not None:
249
+ label_value = str(tag.label_id)
250
+ canonical_value = tag.rfid or canonical_value
251
+ display_value = label_value or canonical_value
252
+ details = {
253
+ "value": display_value,
254
+ "url": rfid_url,
255
+ "uid": canonical_value,
256
+ "type": "rfid",
257
+ "display_label": gettext("RFID"),
258
+ }
259
+ if label_value:
260
+ details["label"] = label_value
261
+ if cache is not None:
262
+ cache[cache_key] = details
263
+ return details
264
+
265
+ identifier_value = getattr(tx_obj, "vehicle_identifier", None)
266
+ normalized_identifier = str(identifier_value or "").strip()
267
+ if not normalized_identifier:
268
+ vid_value = getattr(tx_obj, "vid", None)
269
+ vin_value = getattr(tx_obj, "vin", None)
270
+ normalized_identifier = str(vid_value or vin_value or "").strip()
271
+ if not normalized_identifier:
230
272
  return None
231
- normalized = normalized.upper()
232
- if cache is not None and normalized in cache:
233
- return cache[normalized]
234
- tag = RFID.objects.filter(rfid=normalized).only("pk").first()
235
- rfid_url = None
236
- if tag:
237
- try:
238
- rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
239
- except NoReverseMatch: # pragma: no cover - admin may be disabled
240
- rfid_url = None
241
- details = {"value": normalized, "url": rfid_url}
273
+ source = getattr(tx_obj, "vehicle_identifier_source", "") or "vid"
274
+ if source not in {"vid", "vin"}:
275
+ vid_raw = getattr(tx_obj, "vid", None)
276
+ vin_raw = getattr(tx_obj, "vin", None)
277
+ if str(vid_raw or "").strip():
278
+ source = "vid"
279
+ elif str(vin_raw or "").strip():
280
+ source = "vin"
281
+ else:
282
+ source = "vid"
283
+ cache_key = f"{source}:{normalized_identifier}"
284
+ if cache is not None and cache_key in cache:
285
+ return cache[cache_key]
286
+ label = gettext("VID") if source == "vid" else gettext("VIN")
287
+ details = {
288
+ "value": normalized_identifier,
289
+ "url": None,
290
+ "uid": None,
291
+ "type": source,
292
+ "display_label": label,
293
+ }
242
294
  if cache is not None:
243
- cache[normalized] = details
295
+ cache[cache_key] = details
244
296
  return details
245
297
 
246
298
 
@@ -309,9 +361,14 @@ def _landing_page_translations() -> dict[str, dict[str, str]]:
309
361
  """Return static translations used by the charger public landing page."""
310
362
 
311
363
  catalog: dict[str, dict[str, str]] = {}
312
- for code in ("en", "es"):
313
- with translation.override(code):
314
- catalog[code] = {
364
+ seen_codes: set[str] = set()
365
+ for code, _name in settings.LANGUAGES:
366
+ normalized = str(code).strip()
367
+ if not normalized or normalized in seen_codes:
368
+ continue
369
+ seen_codes.add(normalized)
370
+ with translation.override(normalized):
371
+ catalog[normalized] = {
315
372
  "serial_number_label": gettext("Serial Number"),
316
373
  "connector_label": gettext("Connector"),
317
374
  "advanced_view_label": gettext("Advanced View"),
@@ -503,8 +560,12 @@ def charger_list(request):
503
560
  "meterStart": tx_obj.meter_start,
504
561
  "startTime": tx_obj.start_time.isoformat(),
505
562
  }
506
- if tx_obj.vin:
507
- tx_data["vin"] = tx_obj.vin
563
+ identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
564
+ if identifier:
565
+ tx_data["vid"] = identifier
566
+ legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
567
+ if legacy_vin:
568
+ tx_data["vin"] = legacy_vin
508
569
  if tx_obj.meter_stop is not None:
509
570
  tx_data["meterStop"] = tx_obj.meter_stop
510
571
  if tx_obj.stop_time is not None:
@@ -519,8 +580,12 @@ def charger_list(request):
519
580
  "meterStart": session_tx.meter_start,
520
581
  "startTime": session_tx.start_time.isoformat(),
521
582
  }
522
- if session_tx.vin:
523
- active_payload["vin"] = session_tx.vin
583
+ identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
584
+ if identifier:
585
+ active_payload["vid"] = identifier
586
+ legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
587
+ if legacy_vin:
588
+ active_payload["vin"] = legacy_vin
524
589
  if session_tx.meter_stop is not None:
525
590
  active_payload["meterStop"] = session_tx.meter_stop
526
591
  if session_tx.stop_time is not None:
@@ -600,8 +665,12 @@ def charger_detail(request, cid, connector=None):
600
665
  "meterStart": tx_obj.meter_start,
601
666
  "startTime": tx_obj.start_time.isoformat(),
602
667
  }
603
- if tx_obj.vin:
604
- tx_data["vin"] = tx_obj.vin
668
+ identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
669
+ if identifier:
670
+ tx_data["vid"] = identifier
671
+ legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
672
+ if legacy_vin:
673
+ tx_data["vin"] = legacy_vin
605
674
  if tx_obj.meter_stop is not None:
606
675
  tx_data["meterStop"] = tx_obj.meter_stop
607
676
  if tx_obj.stop_time is not None:
@@ -617,8 +686,12 @@ def charger_detail(request, cid, connector=None):
617
686
  "meterStart": session_tx.meter_start,
618
687
  "startTime": session_tx.start_time.isoformat(),
619
688
  }
620
- if session_tx.vin:
621
- payload["vin"] = session_tx.vin
689
+ identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
690
+ if identifier:
691
+ payload["vid"] = identifier
692
+ legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
693
+ if legacy_vin:
694
+ payload["vin"] = legacy_vin
622
695
  if session_tx.meter_stop is not None:
623
696
  payload["meterStop"] = session_tx.meter_stop
624
697
  if session_tx.stop_time is not None:
@@ -673,12 +746,12 @@ def dashboard(request):
673
746
  node = Node.get_local()
674
747
  role = node.role if node else None
675
748
  role_name = role.name if role else ""
676
- allow_anonymous_roles = {"Constellation", "Satellite"}
749
+ allow_anonymous_roles = {"Watchtower", "Constellation", "Satellite"}
677
750
  if not request.user.is_authenticated and role_name not in allow_anonymous_roles:
678
751
  return redirect_to_login(
679
752
  request.get_full_path(), login_url=reverse("pages:login")
680
753
  )
681
- is_constellation = role_name == "Constellation"
754
+ is_watchtower = role_name in {"Watchtower", "Constellation"}
682
755
  chargers = []
683
756
  for charger in _visible_chargers(request.user):
684
757
  tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
@@ -695,7 +768,7 @@ def dashboard(request):
695
768
  ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
696
769
  context = {
697
770
  "chargers": chargers,
698
- "show_demo_notice": is_constellation,
771
+ "show_demo_notice": is_watchtower,
699
772
  "demo_ws_url": ws_url,
700
773
  "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
701
774
  }
@@ -842,11 +915,30 @@ def charger_page(request, cid, connector=None):
842
915
  state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
843
916
  state, color = _charger_state(charger, state_source)
844
917
  language_cookie = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
845
- preferred_language = "es"
846
- supported_languages = {code for code, _ in settings.LANGUAGES}
847
- if preferred_language in supported_languages and not language_cookie:
848
- translation.activate(preferred_language)
849
- request.LANGUAGE_CODE = translation.get_language()
918
+ available_languages = [
919
+ str(code).strip()
920
+ for code, _ in settings.LANGUAGES
921
+ if str(code).strip()
922
+ ]
923
+ supported_languages = set(available_languages)
924
+ charger_language = (charger.language or "es").strip()
925
+ if charger_language not in supported_languages:
926
+ fallback = "es" if "es" in supported_languages else ""
927
+ if not fallback and available_languages:
928
+ fallback = available_languages[0]
929
+ charger_language = fallback
930
+ if (
931
+ charger_language
932
+ and (
933
+ not language_cookie
934
+ or language_cookie not in supported_languages
935
+ or language_cookie != charger_language
936
+ )
937
+ ):
938
+ translation.activate(charger_language)
939
+ current_language = translation.get_language()
940
+ request.LANGUAGE_CODE = current_language
941
+ preferred_language = charger_language or current_language
850
942
  connector_links = [
851
943
  {
852
944
  "slug": item["slug"],
@@ -874,6 +966,7 @@ def charger_page(request, cid, connector=None):
874
966
  "active_connector_count": active_connector_count,
875
967
  "status_url": status_url,
876
968
  "landing_translations": _landing_page_translations(),
969
+ "preferred_language": preferred_language,
877
970
  "state": state,
878
971
  "color": color,
879
972
  },
@@ -1023,7 +1116,10 @@ def charger_status(request, cid, connector=None):
1023
1116
  "connector_id": connector_id,
1024
1117
  }
1025
1118
  )
1026
- overview = _connector_overview(charger, request.user)
1119
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1120
+ overview = _connector_overview(
1121
+ charger, request.user, rfid_cache=rfid_cache
1122
+ )
1027
1123
  connector_links = [
1028
1124
  {
1029
1125
  "slug": item["slug"],
@@ -1064,12 +1160,15 @@ def charger_status(request, cid, connector=None):
1064
1160
  action_url = _reverse_connector_url("charger-action", cid, connector_slug)
1065
1161
  chart_should_animate = bool(has_active_session and not past_session)
1066
1162
 
1163
+ tx_rfid_details = _transaction_rfid_details(tx_obj, cache=rfid_cache)
1164
+
1067
1165
  return render(
1068
1166
  request,
1069
1167
  "ocpp/charger_status.html",
1070
1168
  {
1071
1169
  "charger": charger,
1072
1170
  "tx": tx_obj,
1171
+ "tx_rfid_details": tx_rfid_details,
1073
1172
  "state": state,
1074
1173
  "color": color,
1075
1174
  "transactions": transactions,
@@ -1209,8 +1308,11 @@ def charger_log_page(request, cid, connector=None):
1209
1308
  charger_id=cid
1210
1309
  )
1211
1310
  target_id = cid
1311
+
1312
+ slug_source = slugify(target_id) or slugify(cid) or "log"
1313
+ filename_parts = [log_type, slug_source]
1314
+ download_filename = f"{'-'.join(part for part in filename_parts if part)}.log"
1212
1315
  limit_options = [
1213
- {"value": "10", "label": "10"},
1214
1316
  {"value": "20", "label": "20"},
1215
1317
  {"value": "40", "label": "40"},
1216
1318
  {"value": "100", "label": "100"},
@@ -1220,15 +1322,35 @@ def charger_log_page(request, cid, connector=None):
1220
1322
  limit_choice = request.GET.get("limit", "20")
1221
1323
  if limit_choice not in allowed_values:
1222
1324
  limit_choice = "20"
1223
-
1224
- log_entries = list(store.get_logs(target_id, log_type=log_type) or [])
1325
+ limit_index = allowed_values.index(limit_choice)
1326
+
1327
+ log_entries_all = list(store.get_logs(target_id, log_type=log_type) or [])
1328
+ download_requested = request.GET.get("download") == "1"
1329
+ if download_requested:
1330
+ download_content = "\n".join(log_entries_all)
1331
+ if download_content and not download_content.endswith("\n"):
1332
+ download_content = f"{download_content}\n"
1333
+ response = HttpResponse(download_content, content_type="text/plain; charset=utf-8")
1334
+ response["Content-Disposition"] = f'attachment; filename="{download_filename}"'
1335
+ return response
1336
+
1337
+ log_entries = log_entries_all
1225
1338
  if limit_choice != "all":
1226
1339
  try:
1227
1340
  limit_value = int(limit_choice)
1228
1341
  except (TypeError, ValueError):
1229
1342
  limit_value = 20
1230
1343
  limit_choice = "20"
1344
+ limit_index = allowed_values.index(limit_choice)
1231
1345
  log_entries = log_entries[-limit_value:]
1346
+
1347
+ download_params = request.GET.copy()
1348
+ download_params["download"] = "1"
1349
+ download_params.pop("limit", None)
1350
+ download_query = download_params.urlencode()
1351
+ log_download_url = f"{request.path}?{download_query}" if download_query else request.path
1352
+
1353
+ limit_label = limit_options[limit_index]["label"]
1232
1354
  return render(
1233
1355
  request,
1234
1356
  "ocpp/charger_logs.html",
@@ -1240,7 +1362,11 @@ def charger_log_page(request, cid, connector=None):
1240
1362
  "connector_links": connector_links,
1241
1363
  "status_url": status_url,
1242
1364
  "log_limit_options": limit_options,
1243
- "log_limit_index": allowed_values.index(limit_choice),
1365
+ "log_limit_index": limit_index,
1366
+ "log_limit_choice": limit_choice,
1367
+ "log_limit_label": limit_label,
1368
+ "log_download_url": log_download_url,
1369
+ "log_filename": download_filename,
1244
1370
  },
1245
1371
  )
1246
1372