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.
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
|
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
|
-
|
|
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
|