arthexis 0.1.19__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/admin.py CHANGED
@@ -16,6 +16,7 @@ from asgiref.sync import async_to_sync
16
16
 
17
17
  from .models import (
18
18
  Charger,
19
+ ChargerConfiguration,
19
20
  Simulator,
20
21
  MeterValue,
21
22
  Transaction,
@@ -108,6 +109,82 @@ class LogViewAdminMixin:
108
109
  return TemplateResponse(request, self.log_template_name, context)
109
110
 
110
111
 
112
+ @admin.register(ChargerConfiguration)
113
+ class ChargerConfigurationAdmin(admin.ModelAdmin):
114
+ list_display = ("charger_identifier", "connector_display", "created_at")
115
+ list_filter = ("connector_id",)
116
+ search_fields = ("charger_identifier",)
117
+ readonly_fields = (
118
+ "charger_identifier",
119
+ "connector_id",
120
+ "created_at",
121
+ "updated_at",
122
+ "linked_chargers",
123
+ "configuration_keys_display",
124
+ "unknown_keys_display",
125
+ "raw_payload_display",
126
+ )
127
+ fieldsets = (
128
+ (
129
+ None,
130
+ {
131
+ "fields": (
132
+ "charger_identifier",
133
+ "connector_id",
134
+ "linked_chargers",
135
+ "created_at",
136
+ "updated_at",
137
+ )
138
+ },
139
+ ),
140
+ (
141
+ "Payload",
142
+ {
143
+ "fields": (
144
+ "configuration_keys_display",
145
+ "unknown_keys_display",
146
+ "raw_payload_display",
147
+ )
148
+ },
149
+ ),
150
+ )
151
+
152
+ @admin.display(description="Connector")
153
+ def connector_display(self, obj):
154
+ if obj.connector_id is None:
155
+ return "All"
156
+ return obj.connector_id
157
+
158
+ @admin.display(description="Linked charge points")
159
+ def linked_chargers(self, obj):
160
+ if obj.pk is None:
161
+ return ""
162
+ linked = [charger.identity_slug() for charger in obj.chargers.all()]
163
+ if not linked:
164
+ return "-"
165
+ return ", ".join(sorted(linked))
166
+
167
+ def _render_json(self, data):
168
+ from django.utils.html import format_html
169
+
170
+ if not data:
171
+ return "-"
172
+ formatted = json.dumps(data, indent=2, ensure_ascii=False)
173
+ return format_html("<pre>{}</pre>", formatted)
174
+
175
+ @admin.display(description="configurationKey")
176
+ def configuration_keys_display(self, obj):
177
+ return self._render_json(obj.configuration_keys)
178
+
179
+ @admin.display(description="unknownKey")
180
+ def unknown_keys_display(self, obj):
181
+ return self._render_json(obj.unknown_keys)
182
+
183
+ @admin.display(description="Raw payload")
184
+ def raw_payload_display(self, obj):
185
+ return self._render_json(obj.raw_payload)
186
+
187
+
111
188
  @admin.register(Location)
112
189
  class LocationAdmin(EntityModelAdmin):
113
190
  form = LocationAdminForm
@@ -207,7 +284,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
207
284
  ),
208
285
  (
209
286
  "Configuration",
210
- {"fields": ("public_display", "require_rfid")},
287
+ {"fields": ("public_display", "require_rfid", "configuration")},
211
288
  ),
212
289
  (
213
290
  "References",
@@ -236,11 +313,12 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
236
313
  "availability_request_status",
237
314
  "availability_request_status_at",
238
315
  "availability_request_details",
316
+ "configuration",
239
317
  )
240
318
  list_display = (
241
- "charger_id",
319
+ "display_name_with_fallback",
242
320
  "connector_number",
243
- "location_name",
321
+ "serial_number_display",
244
322
  "require_rfid_display",
245
323
  "public_display",
246
324
  "last_heartbeat",
@@ -349,6 +427,18 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
349
427
  return True
350
428
  return False
351
429
 
430
+ @admin.display(description="Display Name", ordering="display_name")
431
+ def display_name_with_fallback(self, obj):
432
+ if obj.display_name:
433
+ return obj.display_name
434
+ if obj.location:
435
+ return obj.location.name
436
+ return obj.charger_id
437
+
438
+ @admin.display(description="Serial Number", ordering="charger_id")
439
+ def serial_number_display(self, obj):
440
+ return obj.charger_id
441
+
352
442
  def location_name(self, obj):
353
443
  return obj.location.name if obj.location else ""
354
444
 
@@ -933,8 +1023,10 @@ class TransactionAdmin(EntityModelAdmin):
933
1023
  change_list_template = "admin/ocpp/transaction/change_list.html"
934
1024
  list_display = (
935
1025
  "charger",
1026
+ "connector_number",
936
1027
  "account",
937
1028
  "rfid",
1029
+ "vid",
938
1030
  "meter_start",
939
1031
  "meter_stop",
940
1032
  "start_time",
@@ -946,6 +1038,12 @@ class TransactionAdmin(EntityModelAdmin):
946
1038
  date_hierarchy = "start_time"
947
1039
  inlines = [MeterValueInline]
948
1040
 
1041
+ def connector_number(self, obj):
1042
+ return obj.connector_id or ""
1043
+
1044
+ connector_number.short_description = "#"
1045
+ connector_number.admin_order_field = "connector_id"
1046
+
949
1047
  def get_urls(self):
950
1048
  urls = super().get_urls()
951
1049
  custom = [
ocpp/consumers.py CHANGED
@@ -20,7 +20,13 @@ from config.offline import requires_network
20
20
  from . import store
21
21
  from decimal import Decimal
22
22
  from django.utils.dateparse import parse_datetime
23
- from .models import Transaction, Charger, MeterValue, DataTransferMessage
23
+ from .models import (
24
+ Transaction,
25
+ Charger,
26
+ ChargerConfiguration,
27
+ MeterValue,
28
+ DataTransferMessage,
29
+ )
24
30
  from .reference_utils import host_is_local_loopback
25
31
  from .evcs_discovery import (
26
32
  DEFAULT_CONSOLE_PORT,
@@ -138,6 +144,18 @@ def _parse_ocpp_timestamp(value) -> datetime | None:
138
144
  return timestamp
139
145
 
140
146
 
147
+ def _extract_vehicle_identifier(payload: dict) -> tuple[str, str]:
148
+ """Return normalized VID and VIN values from an OCPP message payload."""
149
+
150
+ raw_vid = payload.get("vid")
151
+ vid_value = str(raw_vid).strip() if raw_vid is not None else ""
152
+ raw_vin = payload.get("vin")
153
+ vin_value = str(raw_vin).strip() if raw_vin is not None else ""
154
+ if not vid_value and vin_value:
155
+ vid_value = vin_value
156
+ return vid_value, vin_value
157
+
158
+
141
159
  class SinkConsumer(AsyncWebsocketConsumer):
142
160
  """Accept any message without validation."""
143
161
 
@@ -283,11 +301,18 @@ class CSMSConsumer(AsyncWebsocketConsumer):
283
301
  """Return the energy account for the provided RFID if valid."""
284
302
  if not id_tag:
285
303
  return None
286
- return await database_sync_to_async(
287
- EnergyAccount.objects.filter(
288
- rfids__rfid=id_tag.upper(), rfids__allowed=True
289
- ).first
290
- )()
304
+
305
+ def _resolve() -> EnergyAccount | None:
306
+ matches = CoreRFID.matching_queryset(id_tag).filter(allowed=True)
307
+ if not matches.exists():
308
+ return None
309
+ return (
310
+ EnergyAccount.objects.filter(rfids__in=matches)
311
+ .distinct()
312
+ .first()
313
+ )
314
+
315
+ return await database_sync_to_async(_resolve)()
291
316
 
292
317
  async def _ensure_rfid_seen(self, id_tag: str) -> CoreRFID | None:
293
318
  """Ensure an RFID record exists and update its last seen timestamp."""
@@ -726,6 +751,53 @@ class CSMSConsumer(AsyncWebsocketConsumer):
726
751
  task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
727
752
  self._consumption_task = task
728
753
 
754
+ def _persist_configuration_result(
755
+ self, payload: dict, connector_hint: int | str | None
756
+ ) -> ChargerConfiguration | None:
757
+ if not isinstance(payload, dict):
758
+ return None
759
+
760
+ connector_value: int | None = None
761
+ if connector_hint not in (None, ""):
762
+ try:
763
+ connector_value = int(connector_hint)
764
+ except (TypeError, ValueError):
765
+ connector_value = None
766
+
767
+ normalized_entries: list[dict[str, object]] = []
768
+ for entry in payload.get("configurationKey") or []:
769
+ if not isinstance(entry, dict):
770
+ continue
771
+ key = str(entry.get("key") or "")
772
+ normalized: dict[str, object] = {"key": key}
773
+ if "value" in entry:
774
+ normalized["value"] = entry.get("value")
775
+ normalized["readonly"] = bool(entry.get("readonly"))
776
+ normalized_entries.append(normalized)
777
+
778
+ unknown_values: list[str] = []
779
+ for value in payload.get("unknownKey") or []:
780
+ if value is None:
781
+ continue
782
+ unknown_values.append(str(value))
783
+
784
+ try:
785
+ raw_payload = json.loads(json.dumps(payload, ensure_ascii=False))
786
+ except (TypeError, ValueError):
787
+ raw_payload = payload
788
+
789
+ configuration = ChargerConfiguration.objects.create(
790
+ charger_identifier=self.charger_id,
791
+ connector_id=connector_value,
792
+ configuration_keys=normalized_entries,
793
+ unknown_keys=unknown_values,
794
+ raw_payload=raw_payload,
795
+ )
796
+ Charger.objects.filter(charger_id=self.charger_id).update(
797
+ configuration=configuration
798
+ )
799
+ return configuration
800
+
729
801
  async def _handle_call_result(self, message_id: str, payload: dict | None) -> None:
730
802
  metadata = store.pop_pending_call(message_id)
731
803
  if not metadata:
@@ -787,6 +859,17 @@ class CSMSConsumer(AsyncWebsocketConsumer):
787
859
  f"GetConfiguration result: {payload_text}",
788
860
  log_type="charger",
789
861
  )
862
+ configuration = await database_sync_to_async(
863
+ self._persist_configuration_result
864
+ )(payload_data, metadata.get("connector_id"))
865
+ if configuration:
866
+ if self.charger and self.charger.charger_id == self.charger_id:
867
+ self.charger.configuration = configuration
868
+ if (
869
+ self.aggregate_charger
870
+ and self.aggregate_charger.charger_id == self.charger_id
871
+ ):
872
+ self.aggregate_charger.configuration = configuration
790
873
  store.record_pending_call_result(
791
874
  message_id,
792
875
  metadata=metadata,
@@ -1338,8 +1421,13 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1338
1421
  reply_payload = {"currentTime": datetime.utcnow().isoformat() + "Z"}
1339
1422
  now = timezone.now()
1340
1423
  self.charger.last_heartbeat = now
1424
+ if (
1425
+ self.aggregate_charger
1426
+ and self.aggregate_charger is not self.charger
1427
+ ):
1428
+ self.aggregate_charger.last_heartbeat = now
1341
1429
  await database_sync_to_async(
1342
- Charger.objects.filter(pk=self.charger.pk).update
1430
+ Charger.objects.filter(charger_id=self.charger_id).update
1343
1431
  )(last_heartbeat=now)
1344
1432
  elif action == "StatusNotification":
1345
1433
  await self._assign_connector(payload.get("connectorId"))
@@ -1538,11 +1626,13 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1538
1626
  self._log_unlinked_rfid(tag.rfid)
1539
1627
  start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1540
1628
  received_start = timezone.now()
1629
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1541
1630
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
1542
1631
  charger=self.charger,
1543
1632
  account=account,
1544
1633
  rfid=(id_tag or ""),
1545
- vin=(payload.get("vin") or ""),
1634
+ vid=vid_value,
1635
+ vin=vin_value,
1546
1636
  connector_id=payload.get("connectorId"),
1547
1637
  meter_start=payload.get("meterStart"),
1548
1638
  start_time=start_timestamp or received_start,
@@ -1568,6 +1658,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1568
1658
  )()
1569
1659
  if not tx_obj and tx_id is not None:
1570
1660
  received_start = timezone.now()
1661
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1571
1662
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
1572
1663
  pk=tx_id,
1573
1664
  charger=self.charger,
@@ -1575,12 +1666,18 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1575
1666
  received_start_time=received_start,
1576
1667
  meter_start=payload.get("meterStart")
1577
1668
  or payload.get("meterStop"),
1578
- vin=(payload.get("vin") or ""),
1669
+ vid=vid_value,
1670
+ vin=vin_value,
1579
1671
  )
1580
1672
  if tx_obj:
1581
1673
  stop_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1582
1674
  received_stop = timezone.now()
1583
1675
  tx_obj.meter_stop = payload.get("meterStop")
1676
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1677
+ if vid_value:
1678
+ tx_obj.vid = vid_value
1679
+ if vin_value:
1680
+ tx_obj.vin = vin_value
1584
1681
  tx_obj.stop_time = stop_timestamp or received_stop
1585
1682
  tx_obj.received_stop_time = received_stop
1586
1683
  await database_sync_to_async(tx_obj.save)()
ocpp/models.py CHANGED
@@ -204,6 +204,16 @@ class Charger(Entity):
204
204
  related_name="chargers",
205
205
  )
206
206
  last_path = models.CharField(max_length=255, blank=True)
207
+ configuration = models.ForeignKey(
208
+ "ChargerConfiguration",
209
+ null=True,
210
+ blank=True,
211
+ on_delete=models.SET_NULL,
212
+ related_name="chargers",
213
+ help_text=_(
214
+ "Latest GetConfiguration response received from this charge point."
215
+ ),
216
+ )
207
217
  manager_node = models.ForeignKey(
208
218
  "nodes.Node",
209
219
  on_delete=models.SET_NULL,
@@ -585,6 +595,51 @@ class Charger(Entity):
585
595
  super().delete(*args, **kwargs)
586
596
 
587
597
 
598
+ class ChargerConfiguration(models.Model):
599
+ """Persisted configuration package returned by a charge point."""
600
+
601
+ charger_identifier = models.CharField(_("Serial Number"), max_length=100)
602
+ connector_id = models.PositiveIntegerField(
603
+ _("Connector ID"),
604
+ null=True,
605
+ blank=True,
606
+ help_text=_("Connector that returned this configuration (if specified)."),
607
+ )
608
+ configuration_keys = models.JSONField(
609
+ default=list,
610
+ blank=True,
611
+ help_text=_("Entries from the configurationKey list."),
612
+ )
613
+ unknown_keys = models.JSONField(
614
+ default=list,
615
+ blank=True,
616
+ help_text=_("Keys returned in the unknownKey list."),
617
+ )
618
+ raw_payload = models.JSONField(
619
+ default=dict,
620
+ blank=True,
621
+ help_text=_("Raw payload returned by the GetConfiguration call."),
622
+ )
623
+ created_at = models.DateTimeField(auto_now_add=True)
624
+ updated_at = models.DateTimeField(auto_now=True)
625
+
626
+ class Meta:
627
+ ordering = ["-created_at"]
628
+ verbose_name = _("CP Configuration")
629
+ verbose_name_plural = _("CP Configurations")
630
+
631
+ def __str__(self) -> str: # pragma: no cover - simple representation
632
+ connector = (
633
+ _("connector %(number)s") % {"number": self.connector_id}
634
+ if self.connector_id is not None
635
+ else _("all connectors")
636
+ )
637
+ return _("%(serial)s configuration (%(connector)s)") % {
638
+ "serial": self.charger_identifier,
639
+ "connector": connector,
640
+ }
641
+
642
+
588
643
  class Transaction(Entity):
589
644
  """Charging session data stored for each charger."""
590
645
 
@@ -599,7 +654,18 @@ class Transaction(Entity):
599
654
  blank=True,
600
655
  verbose_name=_("RFID"),
601
656
  )
602
- vin = models.CharField(max_length=17, blank=True)
657
+ vid = models.CharField(
658
+ max_length=64,
659
+ blank=True,
660
+ default="",
661
+ verbose_name=_("VID"),
662
+ help_text=_("Vehicle identifier reported by the charger."),
663
+ )
664
+ vin = models.CharField(
665
+ max_length=17,
666
+ blank=True,
667
+ help_text=_("Deprecated. Use VID instead."),
668
+ )
603
669
  connector_id = models.PositiveIntegerField(null=True, blank=True)
604
670
  meter_start = models.IntegerField(null=True, blank=True)
605
671
  meter_stop = models.IntegerField(null=True, blank=True)
@@ -645,6 +711,22 @@ class Transaction(Entity):
645
711
  verbose_name = _("Transaction")
646
712
  verbose_name_plural = _("CP Transactions")
647
713
 
714
+ @property
715
+ def vehicle_identifier(self) -> str:
716
+ """Return the preferred vehicle identifier for this transaction."""
717
+
718
+ return (self.vid or self.vin or "").strip()
719
+
720
+ @property
721
+ def vehicle_identifier_source(self) -> str:
722
+ """Return which field supplies :pyattr:`vehicle_identifier`."""
723
+
724
+ if (self.vid or "").strip():
725
+ return "vid"
726
+ if (self.vin or "").strip():
727
+ return "vin"
728
+ return ""
729
+
648
730
  @property
649
731
  def kw(self) -> float:
650
732
  """Return consumed energy in kW for this session."""
ocpp/tasks.py CHANGED
@@ -149,6 +149,10 @@ def send_daily_session_report() -> int:
149
149
  lines.append(f" Account: {account}")
150
150
  if transaction.rfid:
151
151
  lines.append(f" RFID: {transaction.rfid}")
152
+ identifier = transaction.vehicle_identifier
153
+ if identifier:
154
+ label = "VID" if transaction.vehicle_identifier_source == "vid" else "VIN"
155
+ lines.append(f" {label}: {identifier}")
152
156
  if connector:
153
157
  lines.append(f" {connector}")
154
158
  lines.append(
@@ -111,6 +111,7 @@ class TransactionAdminExportImportTests(TestCase):
111
111
  "charger": "C9",
112
112
  "account": None,
113
113
  "rfid": "",
114
+ "vid": "",
114
115
  "vin": "",
115
116
  "meter_start": 0,
116
117
  "meter_stop": 0,
ocpp/test_rfid.py CHANGED
@@ -771,7 +771,9 @@ class RFIDLandingTests(TestCase):
771
771
  app = Application.objects.create(name="Ocpp")
772
772
  module = Module.objects.create(node_role=role, application=app, path="/ocpp/")
773
773
  module.create_landings()
774
- self.assertTrue(module.landings.filter(path="/ocpp/rfid/").exists())
774
+ self.assertTrue(
775
+ module.landings.filter(path="/ocpp/rfid/validator/").exists()
776
+ )
775
777
 
776
778
 
777
779
  class ScannerTemplateTests(TestCase):
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
 
@@ -2126,12 +2181,12 @@ class SimulatorLandingTests(TestCase):
2126
2181
  @skip("Navigation links unavailable in test environment")
2127
2182
  def test_simulator_app_link_in_nav(self):
2128
2183
  resp = self.client.get(reverse("pages:index"))
2129
- self.assertContains(resp, "/ocpp/")
2130
- self.assertNotContains(resp, "/ocpp/simulator/")
2184
+ self.assertContains(resp, "/ocpp/cpms/dashboard/")
2185
+ self.assertNotContains(resp, "/ocpp/evcs/simulator/")
2131
2186
  self.client.force_login(self.user)
2132
2187
  resp = self.client.get(reverse("pages:index"))
2133
- self.assertContains(resp, "/ocpp/")
2134
- self.assertContains(resp, "/ocpp/simulator/")
2188
+ self.assertContains(resp, "/ocpp/cpms/dashboard/")
2189
+ self.assertContains(resp, "/ocpp/evcs/simulator/")
2135
2190
 
2136
2191
  def test_cp_simulator_redirects_to_login(self):
2137
2192
  response = self.client.get(reverse("cp-simulator"))
@@ -3212,8 +3267,7 @@ class SimulatorAdminTests(TransactionTestCase):
3212
3267
  self.assertIsNotNone(aggregate.last_heartbeat)
3213
3268
  if previous_heartbeat:
3214
3269
  self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
3215
- if connector.last_heartbeat:
3216
- self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
3270
+ self.assertEqual(connector.last_heartbeat, aggregate.last_heartbeat)
3217
3271
 
3218
3272
  await communicator.disconnect()
3219
3273
 
@@ -4009,6 +4063,43 @@ class TransactionKwTests(TestCase):
4009
4063
  self.assertEqual(tx.kw, 0.0)
4010
4064
 
4011
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
+
4012
4103
  class DispatchActionViewTests(TestCase):
4013
4104
  def setUp(self):
4014
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"),