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/models.py CHANGED
@@ -1,5 +1,8 @@
1
+ import json
1
2
  import re
2
3
  import socket
4
+ import uuid
5
+ from datetime import timedelta
3
6
  from decimal import Decimal, InvalidOperation
4
7
 
5
8
  from django.conf import settings
@@ -9,12 +12,16 @@ from django.db.models import Q
9
12
  from django.core.exceptions import ValidationError
10
13
  from django.urls import reverse
11
14
  from django.utils.translation import gettext_lazy as _
15
+ from django.utils import timezone
16
+
17
+ from asgiref.sync import async_to_sync
12
18
 
13
19
  from core.entity import Entity, EntityManager
14
20
  from nodes.models import Node
15
21
 
16
22
  from core.models import (
17
23
  EnergyAccount,
24
+ EnergyTariff,
18
25
  Reference,
19
26
  RFID as CoreRFID,
20
27
  ElectricVehicle as CoreElectricVehicle,
@@ -22,6 +29,7 @@ from core.models import (
22
29
  EVModel as CoreEVModel,
23
30
  SecurityGroup,
24
31
  )
32
+ from . import store
25
33
  from .reference_utils import url_targets_local_loopback
26
34
 
27
35
 
@@ -35,6 +43,22 @@ class Location(Entity):
35
43
  longitude = models.DecimalField(
36
44
  max_digits=9, decimal_places=6, null=True, blank=True
37
45
  )
46
+ zone = models.CharField(
47
+ max_length=3,
48
+ choices=EnergyTariff.Zone.choices,
49
+ blank=True,
50
+ null=True,
51
+ help_text=_("CFE climate zone used to select matching energy tariffs."),
52
+ )
53
+ contract_type = models.CharField(
54
+ max_length=16,
55
+ choices=EnergyTariff.ContractType.choices,
56
+ blank=True,
57
+ null=True,
58
+ help_text=_(
59
+ "CFE service contract type required to match energy tariff pricing."
60
+ ),
61
+ )
38
62
 
39
63
  def __str__(self) -> str: # pragma: no cover - simple representation
40
64
  return self.name
@@ -48,6 +72,7 @@ class Charger(Entity):
48
72
  """Known charge point."""
49
73
 
50
74
  _PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
75
+ _AUTO_LOCATION_SANITIZE_RE = re.compile(r"[^0-9A-Za-z_-]+")
51
76
 
52
77
  OPERATIVE_STATUSES = {
53
78
  "Available",
@@ -82,6 +107,13 @@ class Charger(Entity):
82
107
  default=True,
83
108
  help_text="Display this charger on the public status dashboard.",
84
109
  )
110
+ language = models.CharField(
111
+ _("Language"),
112
+ max_length=12,
113
+ choices=settings.LANGUAGES,
114
+ default="es",
115
+ help_text=_("Preferred language for the public landing page."),
116
+ )
85
117
  require_rfid = models.BooleanField(
86
118
  _("Require RFID Authorization"),
87
119
  default=False,
@@ -197,6 +229,23 @@ class Charger(Entity):
197
229
  related_name="chargers",
198
230
  )
199
231
  last_path = models.CharField(max_length=255, blank=True)
232
+ configuration = models.ForeignKey(
233
+ "ChargerConfiguration",
234
+ null=True,
235
+ blank=True,
236
+ on_delete=models.SET_NULL,
237
+ related_name="chargers",
238
+ help_text=_(
239
+ "Latest GetConfiguration response received from this charge point."
240
+ ),
241
+ )
242
+ node_origin = models.ForeignKey(
243
+ "nodes.Node",
244
+ on_delete=models.SET_NULL,
245
+ null=True,
246
+ blank=True,
247
+ related_name="origin_chargers",
248
+ )
200
249
  manager_node = models.ForeignKey(
201
250
  "nodes.Node",
202
251
  on_delete=models.SET_NULL,
@@ -204,6 +253,22 @@ class Charger(Entity):
204
253
  blank=True,
205
254
  related_name="managed_chargers",
206
255
  )
256
+ forwarded_to = models.ForeignKey(
257
+ "nodes.Node",
258
+ on_delete=models.SET_NULL,
259
+ null=True,
260
+ blank=True,
261
+ related_name="forwarded_chargers",
262
+ help_text=_("Remote node receiving forwarded transactions."),
263
+ )
264
+ forwarding_watermark = models.DateTimeField(
265
+ null=True,
266
+ blank=True,
267
+ help_text=_("Timestamp of the last forwarded transaction."),
268
+ )
269
+ allow_remote = models.BooleanField(default=False)
270
+ export_transactions = models.BooleanField(default=False)
271
+ last_online_at = models.DateTimeField(null=True, blank=True)
207
272
  owner_users = models.ManyToManyField(
208
273
  settings.AUTH_USER_MODEL,
209
274
  blank=True,
@@ -258,6 +323,24 @@ class Charger(Entity):
258
323
  user_group_ids = user.groups.values_list("pk", flat=True)
259
324
  return self.owner_groups.filter(pk__in=user_group_ids).exists()
260
325
 
326
+ @property
327
+ def is_local(self) -> bool:
328
+ """Return ``True`` when this charger originates from the local node."""
329
+
330
+ local = Node.get_local()
331
+ if not local:
332
+ return False
333
+ if self.node_origin_id is None:
334
+ return True
335
+ return self.node_origin_id == local.pk
336
+
337
+ def save(self, *args, **kwargs):
338
+ if self.node_origin_id is None:
339
+ local = Node.get_local()
340
+ if local:
341
+ self.node_origin = local
342
+ super().save(*args, **kwargs)
343
+
261
344
  class Meta:
262
345
  verbose_name = _("Charge Point")
263
346
  verbose_name_plural = _("Charge Points")
@@ -307,6 +390,16 @@ class Charger(Entity):
307
390
  )
308
391
  return normalized
309
392
 
393
+ @classmethod
394
+ def sanitize_auto_location_name(cls, value: str) -> str:
395
+ """Return a location name containing only safe characters."""
396
+
397
+ sanitized = cls._AUTO_LOCATION_SANITIZE_RE.sub("_", value)
398
+ sanitized = re.sub(r"_+", "_", sanitized).strip("_")
399
+ if not sanitized:
400
+ return "Charger"
401
+ return sanitized
402
+
310
403
  AGGREGATE_CONNECTOR_SLUG = "all"
311
404
 
312
405
  def identity_tuple(self) -> tuple[str, int | None]:
@@ -442,7 +535,8 @@ class Charger(Entity):
442
535
  if existing:
443
536
  self.location = existing.location
444
537
  else:
445
- location, _ = Location.objects.get_or_create(name=self.charger_id)
538
+ auto_name = type(self).sanitize_auto_location_name(self.charger_id)
539
+ location, _ = Location.objects.get_or_create(name=auto_name)
446
540
  self.location = location
447
541
  if update_list is not None and "location" not in update_list:
448
542
  update_list.append("location")
@@ -452,11 +546,17 @@ class Charger(Entity):
452
546
  ref_value = self._full_url()
453
547
  if url_targets_local_loopback(ref_value):
454
548
  return
455
- if not self.reference or self.reference.value != ref_value:
549
+ if not self.reference:
456
550
  self.reference = Reference.objects.create(
457
551
  value=ref_value, alt_text=self.charger_id
458
552
  )
459
553
  super().save(update_fields=["reference"])
554
+ elif self.reference.value != ref_value:
555
+ Reference.objects.filter(pk=self.reference_id).update(
556
+ value=ref_value, alt_text=self.charger_id
557
+ )
558
+ self.reference.value = ref_value
559
+ self.reference.alt_text = self.charger_id
460
560
 
461
561
  def refresh_manager_node(self, node: Node | None = None) -> Node | None:
462
562
  """Ensure ``manager_node`` matches the provided or local node."""
@@ -527,6 +627,20 @@ class Charger(Entity):
527
627
  return qs
528
628
  return qs.filter(pk=self.pk)
529
629
 
630
+ def total_kw_for_range(
631
+ self,
632
+ start=None,
633
+ end=None,
634
+ ) -> float:
635
+ """Return total energy delivered within ``start``/``end`` window."""
636
+
637
+ from . import store
638
+
639
+ total = 0.0
640
+ for charger in self._target_chargers():
641
+ total += charger._total_kw_range_single(store, start, end)
642
+ return total
643
+
530
644
  def _total_kw_single(self, store_module) -> float:
531
645
  """Return total kW for this specific charger identity."""
532
646
 
@@ -547,6 +661,40 @@ class Charger(Entity):
547
661
  total += kw
548
662
  return total
549
663
 
664
+ def _total_kw_range_single(self, store_module, start=None, end=None) -> float:
665
+ """Return total kW for a date range for this charger."""
666
+
667
+ tx_active = None
668
+ if self.connector_id is not None:
669
+ tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
670
+
671
+ qs = self.transactions.all()
672
+ if start is not None:
673
+ qs = qs.filter(start_time__gte=start)
674
+ if end is not None:
675
+ qs = qs.filter(start_time__lt=end)
676
+ if tx_active and tx_active.pk is not None:
677
+ qs = qs.exclude(pk=tx_active.pk)
678
+
679
+ total = 0.0
680
+ for tx in qs:
681
+ kw = tx.kw
682
+ if kw:
683
+ total += kw
684
+
685
+ if tx_active:
686
+ start_time = getattr(tx_active, "start_time", None)
687
+ include = True
688
+ if start is not None and start_time and start_time < start:
689
+ include = False
690
+ if end is not None and start_time and start_time >= end:
691
+ include = False
692
+ if include:
693
+ kw = tx_active.kw
694
+ if kw:
695
+ total += kw
696
+ return total
697
+
550
698
  def purge(self):
551
699
  from . import store
552
700
 
@@ -578,6 +726,59 @@ class Charger(Entity):
578
726
  super().delete(*args, **kwargs)
579
727
 
580
728
 
729
+ class ChargerConfiguration(models.Model):
730
+ """Persisted configuration package returned by a charge point."""
731
+
732
+ charger_identifier = models.CharField(_("Serial Number"), max_length=100)
733
+ connector_id = models.PositiveIntegerField(
734
+ _("Connector ID"),
735
+ null=True,
736
+ blank=True,
737
+ help_text=_("Connector that returned this configuration (if specified)."),
738
+ )
739
+ configuration_keys = models.JSONField(
740
+ default=list,
741
+ blank=True,
742
+ help_text=_("Entries from the configurationKey list."),
743
+ )
744
+ unknown_keys = models.JSONField(
745
+ default=list,
746
+ blank=True,
747
+ help_text=_("Keys returned in the unknownKey list."),
748
+ )
749
+ evcs_snapshot_at = models.DateTimeField(
750
+ _("EVCS snapshot at"),
751
+ null=True,
752
+ blank=True,
753
+ help_text=_(
754
+ "Timestamp when this configuration was received from the charge point."
755
+ ),
756
+ )
757
+ raw_payload = models.JSONField(
758
+ default=dict,
759
+ blank=True,
760
+ help_text=_("Raw payload returned by the GetConfiguration call."),
761
+ )
762
+ created_at = models.DateTimeField(auto_now_add=True)
763
+ updated_at = models.DateTimeField(auto_now=True)
764
+
765
+ class Meta:
766
+ ordering = ["-created_at"]
767
+ verbose_name = _("CP Configuration")
768
+ verbose_name_plural = _("CP Configurations")
769
+
770
+ def __str__(self) -> str: # pragma: no cover - simple representation
771
+ connector = (
772
+ _("connector %(number)s") % {"number": self.connector_id}
773
+ if self.connector_id is not None
774
+ else _("all connectors")
775
+ )
776
+ return _("%(serial)s configuration (%(connector)s)") % {
777
+ "serial": self.charger_identifier,
778
+ "connector": connector,
779
+ }
780
+
781
+
581
782
  class Transaction(Entity):
582
783
  """Charging session data stored for each charger."""
583
784
 
@@ -592,7 +793,18 @@ class Transaction(Entity):
592
793
  blank=True,
593
794
  verbose_name=_("RFID"),
594
795
  )
595
- vin = models.CharField(max_length=17, blank=True)
796
+ vid = models.CharField(
797
+ max_length=64,
798
+ blank=True,
799
+ default="",
800
+ verbose_name=_("VID"),
801
+ help_text=_("Vehicle identifier reported by the charger."),
802
+ )
803
+ vin = models.CharField(
804
+ max_length=17,
805
+ blank=True,
806
+ help_text=_("Deprecated. Use VID instead."),
807
+ )
596
808
  connector_id = models.PositiveIntegerField(null=True, blank=True)
597
809
  meter_start = models.IntegerField(null=True, blank=True)
598
810
  meter_stop = models.IntegerField(null=True, blank=True)
@@ -638,6 +850,26 @@ class Transaction(Entity):
638
850
  verbose_name = _("Transaction")
639
851
  verbose_name_plural = _("CP Transactions")
640
852
 
853
+ @property
854
+ def vehicle_identifier(self) -> str:
855
+ """Return the preferred vehicle identifier for this transaction."""
856
+
857
+ vid = (self.vid or "").strip()
858
+ if vid:
859
+ return vid
860
+
861
+ return (self.vin or "").strip()
862
+
863
+ @property
864
+ def vehicle_identifier_source(self) -> str:
865
+ """Return which field supplies :pyattr:`vehicle_identifier`."""
866
+
867
+ if (self.vid or "").strip():
868
+ return "vid"
869
+ if (self.vin or "").strip():
870
+ return "vin"
871
+ return ""
872
+
641
873
  @property
642
874
  def kw(self) -> float:
643
875
  """Return consumed energy in kW for this session."""
@@ -886,6 +1118,8 @@ class DataTransferMessage(models.Model):
886
1118
 
887
1119
  class Meta:
888
1120
  ordering = ["-created_at"]
1121
+ verbose_name = _("Data Message")
1122
+ verbose_name_plural = _("Data Messages")
889
1123
  indexes = [
890
1124
  models.Index(
891
1125
  fields=["ocpp_message_id"],
@@ -900,6 +1134,257 @@ class DataTransferMessage(models.Model):
900
1134
  return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
901
1135
 
902
1136
 
1137
+ class CPReservation(Entity):
1138
+ """Track connector reservations dispatched to an EVCS."""
1139
+
1140
+ location = models.ForeignKey(
1141
+ Location,
1142
+ on_delete=models.PROTECT,
1143
+ related_name="reservations",
1144
+ verbose_name=_("Location"),
1145
+ )
1146
+ connector = models.ForeignKey(
1147
+ Charger,
1148
+ on_delete=models.PROTECT,
1149
+ related_name="reservations",
1150
+ verbose_name=_("Connector"),
1151
+ )
1152
+ account = models.ForeignKey(
1153
+ EnergyAccount,
1154
+ on_delete=models.SET_NULL,
1155
+ null=True,
1156
+ blank=True,
1157
+ related_name="cp_reservations",
1158
+ verbose_name=_("Energy account"),
1159
+ )
1160
+ rfid = models.ForeignKey(
1161
+ CoreRFID,
1162
+ on_delete=models.SET_NULL,
1163
+ null=True,
1164
+ blank=True,
1165
+ related_name="cp_reservations",
1166
+ verbose_name=_("RFID"),
1167
+ )
1168
+ id_tag = models.CharField(
1169
+ _("Id Tag"),
1170
+ max_length=255,
1171
+ blank=True,
1172
+ default="",
1173
+ help_text=_("Identifier sent to the EVCS when reserving the connector."),
1174
+ )
1175
+ start_time = models.DateTimeField(verbose_name=_("Start time"))
1176
+ duration_minutes = models.PositiveIntegerField(
1177
+ verbose_name=_("Duration (minutes)"),
1178
+ default=120,
1179
+ help_text=_("Reservation window length in minutes."),
1180
+ )
1181
+ evcs_status = models.CharField(
1182
+ max_length=32,
1183
+ blank=True,
1184
+ default="",
1185
+ verbose_name=_("EVCS status"),
1186
+ )
1187
+ evcs_error = models.CharField(
1188
+ max_length=255,
1189
+ blank=True,
1190
+ default="",
1191
+ verbose_name=_("EVCS error"),
1192
+ )
1193
+ evcs_confirmed = models.BooleanField(
1194
+ default=False,
1195
+ verbose_name=_("Reservation confirmed"),
1196
+ )
1197
+ evcs_confirmed_at = models.DateTimeField(
1198
+ null=True,
1199
+ blank=True,
1200
+ verbose_name=_("Confirmed at"),
1201
+ )
1202
+ ocpp_message_id = models.CharField(
1203
+ max_length=36,
1204
+ blank=True,
1205
+ default="",
1206
+ editable=False,
1207
+ verbose_name=_("OCPP message id"),
1208
+ )
1209
+ created_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Created on"))
1210
+ updated_on = models.DateTimeField(auto_now=True, verbose_name=_("Updated on"))
1211
+
1212
+ class Meta:
1213
+ ordering = ["-start_time"]
1214
+ verbose_name = _("CP Reservation")
1215
+ verbose_name_plural = _("CP Reservations")
1216
+
1217
+ def __str__(self) -> str: # pragma: no cover - simple representation
1218
+ start = timezone.localtime(self.start_time) if self.start_time else ""
1219
+ return f"{self.location} @ {start}" if self.location else str(start)
1220
+
1221
+ @property
1222
+ def end_time(self):
1223
+ duration = max(int(self.duration_minutes or 0), 0)
1224
+ return self.start_time + timedelta(minutes=duration)
1225
+
1226
+ @property
1227
+ def connector_label(self) -> str:
1228
+ if self.connector_id:
1229
+ return self.connector.connector_label
1230
+ return ""
1231
+
1232
+ @property
1233
+ def id_tag_value(self) -> str:
1234
+ if self.id_tag:
1235
+ return self.id_tag.strip()
1236
+ if self.rfid_id:
1237
+ return (self.rfid.rfid or "").strip()
1238
+ return ""
1239
+
1240
+ def allocate_connector(self, *, force: bool = False) -> Charger:
1241
+ """Select an available connector for this reservation."""
1242
+
1243
+ if not self.location_id:
1244
+ raise ValidationError({"location": _("Select a location for the reservation.")})
1245
+ if not self.start_time:
1246
+ raise ValidationError({"start_time": _("Provide a start time for the reservation.")})
1247
+ if self.duration_minutes <= 0:
1248
+ raise ValidationError(
1249
+ {"duration_minutes": _("Reservation window must be at least one minute.")}
1250
+ )
1251
+
1252
+ candidates = list(
1253
+ Charger.objects.filter(
1254
+ location=self.location, connector_id__isnull=False
1255
+ ).order_by("connector_id")
1256
+ )
1257
+ if not candidates:
1258
+ raise ValidationError(
1259
+ {"location": _("No connectors are configured for the selected location.")}
1260
+ )
1261
+
1262
+ def _priority(charger: Charger) -> tuple[int, int]:
1263
+ connector_id = charger.connector_id or 0
1264
+ if connector_id == 2:
1265
+ return (0, connector_id)
1266
+ if connector_id == 1:
1267
+ return (1, connector_id)
1268
+ return (2, connector_id)
1269
+
1270
+ def _is_available(charger: Charger) -> bool:
1271
+ existing = type(self).objects.filter(connector=charger).exclude(pk=self.pk)
1272
+ start = self.start_time
1273
+ end = self.end_time
1274
+ for entry in existing:
1275
+ if entry.start_time < end and entry.end_time > start:
1276
+ return False
1277
+ return True
1278
+
1279
+ if self.connector_id:
1280
+ current = next((c for c in candidates if c.pk == self.connector_id), None)
1281
+ if current and _is_available(current) and not force:
1282
+ return current
1283
+
1284
+ for charger in sorted(candidates, key=_priority):
1285
+ if _is_available(charger):
1286
+ self.connector = charger
1287
+ return charger
1288
+
1289
+ raise ValidationError(
1290
+ _("All connectors at this location are reserved for the selected time window.")
1291
+ )
1292
+
1293
+ def clean(self):
1294
+ super().clean()
1295
+ if self.start_time and timezone.is_naive(self.start_time):
1296
+ self.start_time = timezone.make_aware(
1297
+ self.start_time, timezone.get_current_timezone()
1298
+ )
1299
+ if self.duration_minutes <= 0:
1300
+ raise ValidationError(
1301
+ {"duration_minutes": _("Reservation window must be at least one minute.")}
1302
+ )
1303
+ try:
1304
+ self.allocate_connector(force=bool(self.pk))
1305
+ except ValidationError as exc:
1306
+ raise ValidationError(exc) from exc
1307
+
1308
+ def save(self, *args, **kwargs):
1309
+ if self.start_time and timezone.is_naive(self.start_time):
1310
+ self.start_time = timezone.make_aware(
1311
+ self.start_time, timezone.get_current_timezone()
1312
+ )
1313
+ update_fields = kwargs.get("update_fields")
1314
+ relevant_fields = {"location", "start_time", "duration_minutes", "connector"}
1315
+ should_allocate = True
1316
+ if update_fields is not None and not relevant_fields.intersection(update_fields):
1317
+ should_allocate = False
1318
+ if should_allocate:
1319
+ self.allocate_connector(force=bool(self.pk))
1320
+ super().save(*args, **kwargs)
1321
+
1322
+ def send_reservation_request(self) -> str:
1323
+ """Dispatch a ReserveNow request to the associated connector."""
1324
+
1325
+ if not self.pk:
1326
+ raise ValidationError(_("Save the reservation before sending it to the EVCS."))
1327
+ connector = self.connector
1328
+ if connector is None or connector.connector_id is None:
1329
+ raise ValidationError(_("Unable to determine which connector to reserve."))
1330
+ id_tag = self.id_tag_value
1331
+ if not id_tag:
1332
+ raise ValidationError(
1333
+ _("Provide an RFID or idTag before creating the reservation.")
1334
+ )
1335
+ connection = store.get_connection(connector.charger_id, connector.connector_id)
1336
+ if connection is None:
1337
+ raise ValidationError(
1338
+ _("The selected charge point is not currently connected to the system.")
1339
+ )
1340
+
1341
+ message_id = uuid.uuid4().hex
1342
+ expiry = timezone.localtime(self.end_time)
1343
+ payload = {
1344
+ "connectorId": connector.connector_id,
1345
+ "expiryDate": expiry.isoformat(),
1346
+ "idTag": id_tag,
1347
+ "reservationId": self.pk,
1348
+ }
1349
+ frame = json.dumps([2, message_id, "ReserveNow", payload])
1350
+
1351
+ log_key = store.identity_key(connector.charger_id, connector.connector_id)
1352
+ store.add_log(
1353
+ log_key,
1354
+ f"ReserveNow request: reservation={self.pk}, expiry={expiry.isoformat()}",
1355
+ log_type="charger",
1356
+ )
1357
+ async_to_sync(connection.send)(frame)
1358
+
1359
+ metadata = {
1360
+ "action": "ReserveNow",
1361
+ "charger_id": connector.charger_id,
1362
+ "connector_id": connector.connector_id,
1363
+ "log_key": log_key,
1364
+ "reservation_pk": self.pk,
1365
+ "requested_at": timezone.now(),
1366
+ }
1367
+ store.register_pending_call(message_id, metadata)
1368
+ store.schedule_call_timeout(message_id, action="ReserveNow", log_key=log_key)
1369
+
1370
+ self.ocpp_message_id = message_id
1371
+ self.evcs_status = ""
1372
+ self.evcs_error = ""
1373
+ self.evcs_confirmed = False
1374
+ self.evcs_confirmed_at = None
1375
+ super().save(
1376
+ update_fields=[
1377
+ "ocpp_message_id",
1378
+ "evcs_status",
1379
+ "evcs_error",
1380
+ "evcs_confirmed",
1381
+ "evcs_confirmed_at",
1382
+ "updated_on",
1383
+ ]
1384
+ )
1385
+ return message_id
1386
+
1387
+
903
1388
  class RFID(CoreRFID):
904
1389
  class Meta:
905
1390
  proxy = True