arthexis 0.1.16__py3-none-any.whl → 0.1.28__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.28.dist-info}/METADATA +95 -41
- arthexis-0.1.28.dist-info/RECORD +112 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +21 -30
- config/settings_helpers.py +176 -1
- config/urls.py +69 -1
- core/admin.py +805 -473
- core/apps.py +6 -8
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/celery_utils.py +73 -0
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1825 -218
- 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 +285 -4
- core/tasks.py +439 -138
- core/test_system_info.py +43 -5
- core/tests.py +516 -18
- core/user_data.py +94 -21
- core/views.py +348 -186
- nodes/admin.py +904 -67
- nodes/apps.py +12 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +800 -127
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +98 -3
- nodes/tests.py +1381 -152
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1382 -152
- ocpp/admin.py +1970 -152
- ocpp/consumers.py +839 -34
- ocpp/models.py +968 -17
- ocpp/network.py +398 -0
- ocpp/store.py +411 -43
- ocpp/tasks.py +261 -3
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +194 -6
- ocpp/tests.py +1918 -87
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +8 -3
- ocpp/views.py +700 -53
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +28 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +86 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +1050 -126
- pages/urls.py +14 -2
- pages/utils.py +70 -0
- pages/views.py +622 -56
- 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.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/models.py
CHANGED
|
@@ -1,20 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import binascii
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
1
7
|
import re
|
|
8
|
+
import secrets
|
|
2
9
|
import socket
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timedelta
|
|
3
12
|
from decimal import Decimal, InvalidOperation
|
|
4
13
|
|
|
5
14
|
from django.conf import settings
|
|
6
15
|
from django.contrib.sites.models import Site
|
|
7
16
|
from django.db import models
|
|
8
|
-
from django.db.models import Q
|
|
17
|
+
from django.db.models import DecimalField, OuterRef, Prefetch, Q, Subquery
|
|
9
18
|
from django.core.exceptions import ValidationError
|
|
10
19
|
from django.urls import reverse
|
|
11
20
|
from django.utils.translation import gettext_lazy as _
|
|
21
|
+
from django.utils import timezone
|
|
22
|
+
|
|
23
|
+
from asgiref.sync import async_to_sync
|
|
12
24
|
|
|
13
25
|
from core.entity import Entity, EntityManager
|
|
14
26
|
from nodes.models import Node
|
|
15
27
|
|
|
16
28
|
from core.models import (
|
|
17
29
|
EnergyAccount,
|
|
30
|
+
EnergyTariff,
|
|
18
31
|
Reference,
|
|
19
32
|
RFID as CoreRFID,
|
|
20
33
|
ElectricVehicle as CoreElectricVehicle,
|
|
@@ -22,6 +35,7 @@ from core.models import (
|
|
|
22
35
|
EVModel as CoreEVModel,
|
|
23
36
|
SecurityGroup,
|
|
24
37
|
)
|
|
38
|
+
from . import store
|
|
25
39
|
from .reference_utils import url_targets_local_loopback
|
|
26
40
|
|
|
27
41
|
|
|
@@ -35,6 +49,22 @@ class Location(Entity):
|
|
|
35
49
|
longitude = models.DecimalField(
|
|
36
50
|
max_digits=9, decimal_places=6, null=True, blank=True
|
|
37
51
|
)
|
|
52
|
+
zone = models.CharField(
|
|
53
|
+
max_length=3,
|
|
54
|
+
choices=EnergyTariff.Zone.choices,
|
|
55
|
+
blank=True,
|
|
56
|
+
null=True,
|
|
57
|
+
help_text=_("CFE climate zone used to select matching energy tariffs."),
|
|
58
|
+
)
|
|
59
|
+
contract_type = models.CharField(
|
|
60
|
+
max_length=16,
|
|
61
|
+
choices=EnergyTariff.ContractType.choices,
|
|
62
|
+
blank=True,
|
|
63
|
+
null=True,
|
|
64
|
+
help_text=_(
|
|
65
|
+
"CFE service contract type required to match energy tariff pricing."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
38
68
|
|
|
39
69
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
40
70
|
return self.name
|
|
@@ -48,6 +78,7 @@ class Charger(Entity):
|
|
|
48
78
|
"""Known charge point."""
|
|
49
79
|
|
|
50
80
|
_PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
|
|
81
|
+
_AUTO_LOCATION_SANITIZE_RE = re.compile(r"[^0-9A-Za-z_-]+")
|
|
51
82
|
|
|
52
83
|
OPERATIVE_STATUSES = {
|
|
53
84
|
"Available",
|
|
@@ -82,6 +113,13 @@ class Charger(Entity):
|
|
|
82
113
|
default=True,
|
|
83
114
|
help_text="Display this charger on the public status dashboard.",
|
|
84
115
|
)
|
|
116
|
+
language = models.CharField(
|
|
117
|
+
_("Language"),
|
|
118
|
+
max_length=12,
|
|
119
|
+
choices=settings.LANGUAGES,
|
|
120
|
+
default="es",
|
|
121
|
+
help_text=_("Preferred language for the public landing page."),
|
|
122
|
+
)
|
|
85
123
|
require_rfid = models.BooleanField(
|
|
86
124
|
_("Require RFID Authorization"),
|
|
87
125
|
default=False,
|
|
@@ -197,6 +235,35 @@ class Charger(Entity):
|
|
|
197
235
|
related_name="chargers",
|
|
198
236
|
)
|
|
199
237
|
last_path = models.CharField(max_length=255, blank=True)
|
|
238
|
+
configuration = models.ForeignKey(
|
|
239
|
+
"ChargerConfiguration",
|
|
240
|
+
null=True,
|
|
241
|
+
blank=True,
|
|
242
|
+
on_delete=models.SET_NULL,
|
|
243
|
+
related_name="chargers",
|
|
244
|
+
help_text=_(
|
|
245
|
+
"Latest GetConfiguration response received from this charge point."
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
local_auth_list_version = models.PositiveIntegerField(
|
|
249
|
+
_("Local list version"),
|
|
250
|
+
null=True,
|
|
251
|
+
blank=True,
|
|
252
|
+
help_text=_("Last RFID list version acknowledged by the charge point."),
|
|
253
|
+
)
|
|
254
|
+
local_auth_list_updated_at = models.DateTimeField(
|
|
255
|
+
_("Local list updated at"),
|
|
256
|
+
null=True,
|
|
257
|
+
blank=True,
|
|
258
|
+
help_text=_("When the charge point reported or accepted the RFID list."),
|
|
259
|
+
)
|
|
260
|
+
node_origin = models.ForeignKey(
|
|
261
|
+
"nodes.Node",
|
|
262
|
+
on_delete=models.SET_NULL,
|
|
263
|
+
null=True,
|
|
264
|
+
blank=True,
|
|
265
|
+
related_name="origin_chargers",
|
|
266
|
+
)
|
|
200
267
|
manager_node = models.ForeignKey(
|
|
201
268
|
"nodes.Node",
|
|
202
269
|
on_delete=models.SET_NULL,
|
|
@@ -204,6 +271,22 @@ class Charger(Entity):
|
|
|
204
271
|
blank=True,
|
|
205
272
|
related_name="managed_chargers",
|
|
206
273
|
)
|
|
274
|
+
forwarded_to = models.ForeignKey(
|
|
275
|
+
"nodes.Node",
|
|
276
|
+
on_delete=models.SET_NULL,
|
|
277
|
+
null=True,
|
|
278
|
+
blank=True,
|
|
279
|
+
related_name="forwarded_chargers",
|
|
280
|
+
help_text=_("Remote node receiving forwarded transactions."),
|
|
281
|
+
)
|
|
282
|
+
forwarding_watermark = models.DateTimeField(
|
|
283
|
+
null=True,
|
|
284
|
+
blank=True,
|
|
285
|
+
help_text=_("Timestamp of the last forwarded transaction."),
|
|
286
|
+
)
|
|
287
|
+
allow_remote = models.BooleanField(default=False)
|
|
288
|
+
export_transactions = models.BooleanField(default=False)
|
|
289
|
+
last_online_at = models.DateTimeField(null=True, blank=True)
|
|
207
290
|
owner_users = models.ManyToManyField(
|
|
208
291
|
settings.AUTH_USER_MODEL,
|
|
209
292
|
blank=True,
|
|
@@ -258,6 +341,24 @@ class Charger(Entity):
|
|
|
258
341
|
user_group_ids = user.groups.values_list("pk", flat=True)
|
|
259
342
|
return self.owner_groups.filter(pk__in=user_group_ids).exists()
|
|
260
343
|
|
|
344
|
+
@property
|
|
345
|
+
def is_local(self) -> bool:
|
|
346
|
+
"""Return ``True`` when this charger originates from the local node."""
|
|
347
|
+
|
|
348
|
+
local = Node.get_local()
|
|
349
|
+
if not local:
|
|
350
|
+
return False
|
|
351
|
+
if self.node_origin_id is None:
|
|
352
|
+
return True
|
|
353
|
+
return self.node_origin_id == local.pk
|
|
354
|
+
|
|
355
|
+
def save(self, *args, **kwargs):
|
|
356
|
+
if self.node_origin_id is None:
|
|
357
|
+
local = Node.get_local()
|
|
358
|
+
if local:
|
|
359
|
+
self.node_origin = local
|
|
360
|
+
super().save(*args, **kwargs)
|
|
361
|
+
|
|
261
362
|
class Meta:
|
|
262
363
|
verbose_name = _("Charge Point")
|
|
263
364
|
verbose_name_plural = _("Charge Points")
|
|
@@ -307,6 +408,16 @@ class Charger(Entity):
|
|
|
307
408
|
)
|
|
308
409
|
return normalized
|
|
309
410
|
|
|
411
|
+
@classmethod
|
|
412
|
+
def sanitize_auto_location_name(cls, value: str) -> str:
|
|
413
|
+
"""Return a location name containing only safe characters."""
|
|
414
|
+
|
|
415
|
+
sanitized = cls._AUTO_LOCATION_SANITIZE_RE.sub("_", value)
|
|
416
|
+
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
|
|
417
|
+
if not sanitized:
|
|
418
|
+
return "Charger"
|
|
419
|
+
return sanitized
|
|
420
|
+
|
|
310
421
|
AGGREGATE_CONNECTOR_SLUG = "all"
|
|
311
422
|
|
|
312
423
|
def identity_tuple(self) -> tuple[str, int | None]:
|
|
@@ -442,7 +553,8 @@ class Charger(Entity):
|
|
|
442
553
|
if existing:
|
|
443
554
|
self.location = existing.location
|
|
444
555
|
else:
|
|
445
|
-
|
|
556
|
+
auto_name = type(self).sanitize_auto_location_name(self.charger_id)
|
|
557
|
+
location, _ = Location.objects.get_or_create(name=auto_name)
|
|
446
558
|
self.location = location
|
|
447
559
|
if update_list is not None and "location" not in update_list:
|
|
448
560
|
update_list.append("location")
|
|
@@ -452,11 +564,17 @@ class Charger(Entity):
|
|
|
452
564
|
ref_value = self._full_url()
|
|
453
565
|
if url_targets_local_loopback(ref_value):
|
|
454
566
|
return
|
|
455
|
-
if not self.reference
|
|
567
|
+
if not self.reference:
|
|
456
568
|
self.reference = Reference.objects.create(
|
|
457
569
|
value=ref_value, alt_text=self.charger_id
|
|
458
570
|
)
|
|
459
571
|
super().save(update_fields=["reference"])
|
|
572
|
+
elif self.reference.value != ref_value:
|
|
573
|
+
Reference.objects.filter(pk=self.reference_id).update(
|
|
574
|
+
value=ref_value, alt_text=self.charger_id
|
|
575
|
+
)
|
|
576
|
+
self.reference.value = ref_value
|
|
577
|
+
self.reference.alt_text = self.charger_id
|
|
460
578
|
|
|
461
579
|
def refresh_manager_node(self, node: Node | None = None) -> Node | None:
|
|
462
580
|
"""Ensure ``manager_node`` matches the provided or local node."""
|
|
@@ -527,24 +645,58 @@ class Charger(Entity):
|
|
|
527
645
|
return qs
|
|
528
646
|
return qs.filter(pk=self.pk)
|
|
529
647
|
|
|
648
|
+
def total_kw_for_range(
|
|
649
|
+
self,
|
|
650
|
+
start=None,
|
|
651
|
+
end=None,
|
|
652
|
+
) -> float:
|
|
653
|
+
"""Return total energy delivered within ``start``/``end`` window."""
|
|
654
|
+
|
|
655
|
+
from . import store
|
|
656
|
+
|
|
657
|
+
total = 0.0
|
|
658
|
+
for charger in self._target_chargers():
|
|
659
|
+
total += charger._total_kw_range_single(store, start, end)
|
|
660
|
+
return total
|
|
661
|
+
|
|
530
662
|
def _total_kw_single(self, store_module) -> float:
|
|
531
663
|
"""Return total kW for this specific charger identity."""
|
|
532
664
|
|
|
665
|
+
return self._total_kw_range_single(store_module)
|
|
666
|
+
|
|
667
|
+
def _total_kw_range_single(self, store_module, start=None, end=None) -> float:
|
|
668
|
+
"""Return total kW for a date range for this charger."""
|
|
669
|
+
|
|
533
670
|
tx_active = None
|
|
534
671
|
if self.connector_id is not None:
|
|
535
672
|
tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
|
|
673
|
+
|
|
536
674
|
qs = self.transactions.all()
|
|
675
|
+
if start is not None:
|
|
676
|
+
qs = qs.filter(start_time__gte=start)
|
|
677
|
+
if end is not None:
|
|
678
|
+
qs = qs.filter(start_time__lt=end)
|
|
537
679
|
if tx_active and tx_active.pk is not None:
|
|
538
680
|
qs = qs.exclude(pk=tx_active.pk)
|
|
681
|
+
qs = annotate_transaction_energy_bounds(qs)
|
|
682
|
+
|
|
539
683
|
total = 0.0
|
|
540
|
-
for tx in qs:
|
|
684
|
+
for tx in qs.iterator():
|
|
541
685
|
kw = tx.kw
|
|
542
686
|
if kw:
|
|
543
687
|
total += kw
|
|
688
|
+
|
|
544
689
|
if tx_active:
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
690
|
+
start_time = getattr(tx_active, "start_time", None)
|
|
691
|
+
include = True
|
|
692
|
+
if start is not None and start_time and start_time < start:
|
|
693
|
+
include = False
|
|
694
|
+
if end is not None and start_time and start_time >= end:
|
|
695
|
+
include = False
|
|
696
|
+
if include:
|
|
697
|
+
kw = tx_active.kw
|
|
698
|
+
if kw:
|
|
699
|
+
total += kw
|
|
548
700
|
return total
|
|
549
701
|
|
|
550
702
|
def purge(self):
|
|
@@ -578,6 +730,141 @@ class Charger(Entity):
|
|
|
578
730
|
super().delete(*args, **kwargs)
|
|
579
731
|
|
|
580
732
|
|
|
733
|
+
class ConfigurationKey(models.Model):
|
|
734
|
+
"""Single configurationKey entry from a GetConfiguration payload."""
|
|
735
|
+
|
|
736
|
+
configuration = models.ForeignKey(
|
|
737
|
+
"ChargerConfiguration",
|
|
738
|
+
on_delete=models.CASCADE,
|
|
739
|
+
related_name="configuration_entries",
|
|
740
|
+
)
|
|
741
|
+
position = models.PositiveIntegerField(default=0)
|
|
742
|
+
key = models.CharField(max_length=255)
|
|
743
|
+
readonly = models.BooleanField(default=False)
|
|
744
|
+
has_value = models.BooleanField(default=False)
|
|
745
|
+
value = models.JSONField(null=True, blank=True)
|
|
746
|
+
extra_data = models.JSONField(default=dict, blank=True)
|
|
747
|
+
|
|
748
|
+
class Meta:
|
|
749
|
+
ordering = ["position", "id"]
|
|
750
|
+
verbose_name = _("Configuration Key")
|
|
751
|
+
verbose_name_plural = _("Configuration Keys")
|
|
752
|
+
|
|
753
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
754
|
+
return self.key
|
|
755
|
+
|
|
756
|
+
def as_dict(self) -> dict[str, object]:
|
|
757
|
+
data: dict[str, object] = {"key": self.key, "readonly": self.readonly}
|
|
758
|
+
if self.has_value:
|
|
759
|
+
data["value"] = self.value
|
|
760
|
+
if self.extra_data:
|
|
761
|
+
data.update(self.extra_data)
|
|
762
|
+
return data
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
class ChargerConfigurationManager(models.Manager):
|
|
766
|
+
def get_queryset(self):
|
|
767
|
+
return super().get_queryset().prefetch_related("configuration_entries")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
class ChargerConfiguration(models.Model):
|
|
771
|
+
"""Persisted configuration package returned by a charge point."""
|
|
772
|
+
|
|
773
|
+
charger_identifier = models.CharField(_("Serial Number"), max_length=100)
|
|
774
|
+
connector_id = models.PositiveIntegerField(
|
|
775
|
+
_("Connector ID"),
|
|
776
|
+
null=True,
|
|
777
|
+
blank=True,
|
|
778
|
+
help_text=_("Connector that returned this configuration (if specified)."),
|
|
779
|
+
)
|
|
780
|
+
unknown_keys = models.JSONField(
|
|
781
|
+
default=list,
|
|
782
|
+
blank=True,
|
|
783
|
+
help_text=_("Keys returned in the unknownKey list."),
|
|
784
|
+
)
|
|
785
|
+
evcs_snapshot_at = models.DateTimeField(
|
|
786
|
+
_("EVCS snapshot at"),
|
|
787
|
+
null=True,
|
|
788
|
+
blank=True,
|
|
789
|
+
help_text=_(
|
|
790
|
+
"Timestamp when this configuration was received from the charge point."
|
|
791
|
+
),
|
|
792
|
+
)
|
|
793
|
+
raw_payload = models.JSONField(
|
|
794
|
+
default=dict,
|
|
795
|
+
blank=True,
|
|
796
|
+
help_text=_("Raw payload returned by the GetConfiguration call."),
|
|
797
|
+
)
|
|
798
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
799
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
800
|
+
|
|
801
|
+
objects = ChargerConfigurationManager()
|
|
802
|
+
|
|
803
|
+
class Meta:
|
|
804
|
+
ordering = ["-created_at"]
|
|
805
|
+
verbose_name = _("CP Configuration")
|
|
806
|
+
verbose_name_plural = _("CP Configurations")
|
|
807
|
+
|
|
808
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
809
|
+
connector = (
|
|
810
|
+
_("connector %(number)s") % {"number": self.connector_id}
|
|
811
|
+
if self.connector_id is not None
|
|
812
|
+
else _("all connectors")
|
|
813
|
+
)
|
|
814
|
+
return _("%(serial)s configuration (%(connector)s)") % {
|
|
815
|
+
"serial": self.charger_identifier,
|
|
816
|
+
"connector": connector,
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
@property
|
|
820
|
+
def configuration_keys(self) -> list[dict[str, object]]:
|
|
821
|
+
return [entry.as_dict() for entry in self.configuration_entries.all()]
|
|
822
|
+
|
|
823
|
+
def replace_configuration_keys(self, entries: list[dict[str, object]] | None) -> None:
|
|
824
|
+
ConfigurationKey.objects.filter(configuration=self).delete()
|
|
825
|
+
if not entries:
|
|
826
|
+
if hasattr(self, "_prefetched_objects_cache"):
|
|
827
|
+
self._prefetched_objects_cache.pop("configuration_entries", None)
|
|
828
|
+
return
|
|
829
|
+
|
|
830
|
+
key_objects: list[ConfigurationKey] = []
|
|
831
|
+
for position, entry in enumerate(entries):
|
|
832
|
+
if not isinstance(entry, dict):
|
|
833
|
+
continue
|
|
834
|
+
key_text = str(entry.get("key") or "").strip()
|
|
835
|
+
if not key_text:
|
|
836
|
+
continue
|
|
837
|
+
readonly = bool(entry.get("readonly"))
|
|
838
|
+
has_value = "value" in entry
|
|
839
|
+
value = entry.get("value") if has_value else None
|
|
840
|
+
extras = {
|
|
841
|
+
field_key: field_value
|
|
842
|
+
for field_key, field_value in entry.items()
|
|
843
|
+
if field_key not in {"key", "readonly", "value"}
|
|
844
|
+
}
|
|
845
|
+
key_objects.append(
|
|
846
|
+
ConfigurationKey(
|
|
847
|
+
configuration=self,
|
|
848
|
+
position=position,
|
|
849
|
+
key=key_text,
|
|
850
|
+
readonly=readonly,
|
|
851
|
+
has_value=has_value,
|
|
852
|
+
value=value,
|
|
853
|
+
extra_data=extras,
|
|
854
|
+
)
|
|
855
|
+
)
|
|
856
|
+
created_keys = ConfigurationKey.objects.bulk_create(key_objects)
|
|
857
|
+
if hasattr(self, "_prefetched_objects_cache"):
|
|
858
|
+
if created_keys:
|
|
859
|
+
refreshed = list(
|
|
860
|
+
ConfigurationKey.objects.filter(configuration=self)
|
|
861
|
+
.order_by("position", "id")
|
|
862
|
+
)
|
|
863
|
+
self._prefetched_objects_cache["configuration_entries"] = refreshed
|
|
864
|
+
else:
|
|
865
|
+
self._prefetched_objects_cache.pop("configuration_entries", None)
|
|
866
|
+
|
|
867
|
+
|
|
581
868
|
class Transaction(Entity):
|
|
582
869
|
"""Charging session data stored for each charger."""
|
|
583
870
|
|
|
@@ -592,7 +879,18 @@ class Transaction(Entity):
|
|
|
592
879
|
blank=True,
|
|
593
880
|
verbose_name=_("RFID"),
|
|
594
881
|
)
|
|
595
|
-
|
|
882
|
+
vid = models.CharField(
|
|
883
|
+
max_length=64,
|
|
884
|
+
blank=True,
|
|
885
|
+
default="",
|
|
886
|
+
verbose_name=_("VID"),
|
|
887
|
+
help_text=_("Vehicle identifier reported by the charger."),
|
|
888
|
+
)
|
|
889
|
+
vin = models.CharField(
|
|
890
|
+
max_length=17,
|
|
891
|
+
blank=True,
|
|
892
|
+
help_text=_("Deprecated. Use VID instead."),
|
|
893
|
+
)
|
|
596
894
|
connector_id = models.PositiveIntegerField(null=True, blank=True)
|
|
597
895
|
meter_start = models.IntegerField(null=True, blank=True)
|
|
598
896
|
meter_stop = models.IntegerField(null=True, blank=True)
|
|
@@ -638,6 +936,26 @@ class Transaction(Entity):
|
|
|
638
936
|
verbose_name = _("Transaction")
|
|
639
937
|
verbose_name_plural = _("CP Transactions")
|
|
640
938
|
|
|
939
|
+
@property
|
|
940
|
+
def vehicle_identifier(self) -> str:
|
|
941
|
+
"""Return the preferred vehicle identifier for this transaction."""
|
|
942
|
+
|
|
943
|
+
vid = (self.vid or "").strip()
|
|
944
|
+
if vid:
|
|
945
|
+
return vid
|
|
946
|
+
|
|
947
|
+
return (self.vin or "").strip()
|
|
948
|
+
|
|
949
|
+
@property
|
|
950
|
+
def vehicle_identifier_source(self) -> str:
|
|
951
|
+
"""Return which field supplies :pyattr:`vehicle_identifier`."""
|
|
952
|
+
|
|
953
|
+
if (self.vid or "").strip():
|
|
954
|
+
return "vid"
|
|
955
|
+
if (self.vin or "").strip():
|
|
956
|
+
return "vin"
|
|
957
|
+
return ""
|
|
958
|
+
|
|
641
959
|
@property
|
|
642
960
|
def kw(self) -> float:
|
|
643
961
|
"""Return consumed energy in kW for this session."""
|
|
@@ -649,17 +967,63 @@ class Transaction(Entity):
|
|
|
649
967
|
if self.meter_stop is not None:
|
|
650
968
|
end_val = float(self.meter_stop) / 1000.0
|
|
651
969
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
970
|
+
def _coerce(value):
|
|
971
|
+
if value in {None, ""}:
|
|
972
|
+
return None
|
|
973
|
+
try:
|
|
974
|
+
return float(value)
|
|
975
|
+
except (TypeError, ValueError, InvalidOperation):
|
|
976
|
+
return None
|
|
977
|
+
|
|
978
|
+
if start_val is None:
|
|
979
|
+
annotated_start = getattr(self, "meter_energy_start", None)
|
|
980
|
+
if annotated_start is None:
|
|
981
|
+
annotated_start = getattr(self, "report_meter_energy_start", None)
|
|
982
|
+
start_val = _coerce(annotated_start)
|
|
983
|
+
|
|
984
|
+
if end_val is None:
|
|
985
|
+
annotated_end = getattr(self, "meter_energy_end", None)
|
|
986
|
+
if annotated_end is None:
|
|
987
|
+
annotated_end = getattr(self, "report_meter_energy_end", None)
|
|
988
|
+
end_val = _coerce(annotated_end)
|
|
989
|
+
|
|
990
|
+
readings: list[MeterValue] | None = None
|
|
991
|
+
if start_val is None or end_val is None:
|
|
992
|
+
if hasattr(self, "prefetched_meter_values"):
|
|
993
|
+
readings = [
|
|
994
|
+
reading
|
|
995
|
+
for reading in getattr(self, "prefetched_meter_values")
|
|
996
|
+
if getattr(reading, "energy", None) is not None
|
|
997
|
+
]
|
|
998
|
+
else:
|
|
999
|
+
cache = getattr(self, "_prefetched_objects_cache", None)
|
|
1000
|
+
if cache and "meter_values" in cache:
|
|
1001
|
+
readings = [
|
|
1002
|
+
reading
|
|
1003
|
+
for reading in cache["meter_values"]
|
|
1004
|
+
if getattr(reading, "energy", None) is not None
|
|
1005
|
+
]
|
|
1006
|
+
|
|
1007
|
+
if readings is not None:
|
|
1008
|
+
readings.sort(key=lambda reading: reading.timestamp)
|
|
1009
|
+
|
|
1010
|
+
if readings is not None and readings:
|
|
1011
|
+
if start_val is None:
|
|
1012
|
+
start_val = _coerce(readings[0].energy)
|
|
1013
|
+
if end_val is None:
|
|
1014
|
+
end_val = _coerce(readings[-1].energy)
|
|
1015
|
+
elif start_val is None or end_val is None:
|
|
1016
|
+
readings_qs = self.meter_values.filter(energy__isnull=False).order_by(
|
|
1017
|
+
"timestamp"
|
|
1018
|
+
)
|
|
656
1019
|
if start_val is None:
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
# stop meter has not been recorded yet. This allows active
|
|
660
|
-
# transactions to report totals using their most recent reading.
|
|
1020
|
+
first_energy = readings_qs.values_list("energy", flat=True).first()
|
|
1021
|
+
start_val = _coerce(first_energy)
|
|
661
1022
|
if end_val is None:
|
|
662
|
-
|
|
1023
|
+
last_energy = readings_qs.order_by("-timestamp").values_list(
|
|
1024
|
+
"energy", flat=True
|
|
1025
|
+
).first()
|
|
1026
|
+
end_val = _coerce(last_energy)
|
|
663
1027
|
|
|
664
1028
|
if start_val is None or end_val is None:
|
|
665
1029
|
return 0.0
|
|
@@ -668,6 +1032,49 @@ class Transaction(Entity):
|
|
|
668
1032
|
return max(total, 0.0)
|
|
669
1033
|
|
|
670
1034
|
|
|
1035
|
+
class RFIDSessionAttempt(Entity):
|
|
1036
|
+
"""Record RFID authorisation attempts received via StartTransaction."""
|
|
1037
|
+
|
|
1038
|
+
class Status(models.TextChoices):
|
|
1039
|
+
ACCEPTED = "accepted", _("Accepted")
|
|
1040
|
+
REJECTED = "rejected", _("Rejected")
|
|
1041
|
+
|
|
1042
|
+
charger = models.ForeignKey(
|
|
1043
|
+
"Charger",
|
|
1044
|
+
on_delete=models.CASCADE,
|
|
1045
|
+
related_name="rfid_attempts",
|
|
1046
|
+
null=True,
|
|
1047
|
+
blank=True,
|
|
1048
|
+
)
|
|
1049
|
+
rfid = models.CharField(_("RFID"), max_length=255, blank=True)
|
|
1050
|
+
status = models.CharField(max_length=16, choices=Status.choices)
|
|
1051
|
+
attempted_at = models.DateTimeField(auto_now_add=True)
|
|
1052
|
+
account = models.ForeignKey(
|
|
1053
|
+
EnergyAccount,
|
|
1054
|
+
on_delete=models.SET_NULL,
|
|
1055
|
+
null=True,
|
|
1056
|
+
blank=True,
|
|
1057
|
+
related_name="rfid_attempts",
|
|
1058
|
+
)
|
|
1059
|
+
transaction = models.ForeignKey(
|
|
1060
|
+
"Transaction",
|
|
1061
|
+
on_delete=models.SET_NULL,
|
|
1062
|
+
null=True,
|
|
1063
|
+
blank=True,
|
|
1064
|
+
related_name="rfid_attempts",
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
class Meta:
|
|
1068
|
+
ordering = ["-attempted_at"]
|
|
1069
|
+
verbose_name = _("RFID Session Attempt")
|
|
1070
|
+
verbose_name_plural = _("RFID Session Attempts")
|
|
1071
|
+
|
|
1072
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1073
|
+
status = self.get_status_display() or ""
|
|
1074
|
+
tag = self.rfid or "-"
|
|
1075
|
+
return f"{tag} ({status})"
|
|
1076
|
+
|
|
1077
|
+
|
|
671
1078
|
class MeterValue(Entity):
|
|
672
1079
|
"""Parsed meter values reported by chargers."""
|
|
673
1080
|
|
|
@@ -756,6 +1163,28 @@ class MeterReading(MeterValue):
|
|
|
756
1163
|
verbose_name_plural = _("Meter Values")
|
|
757
1164
|
|
|
758
1165
|
|
|
1166
|
+
def annotate_transaction_energy_bounds(
|
|
1167
|
+
queryset, *, start_field: str = "meter_energy_start", end_field: str = "meter_energy_end"
|
|
1168
|
+
):
|
|
1169
|
+
"""Annotate transactions with their earliest and latest energy readings."""
|
|
1170
|
+
|
|
1171
|
+
energy_qs = MeterValue.objects.filter(
|
|
1172
|
+
transaction=OuterRef("pk"), energy__isnull=False
|
|
1173
|
+
)
|
|
1174
|
+
start_subquery = energy_qs.order_by("timestamp").values("energy")[:1]
|
|
1175
|
+
end_subquery = energy_qs.order_by("-timestamp").values("energy")[:1]
|
|
1176
|
+
|
|
1177
|
+
annotations = {
|
|
1178
|
+
start_field: Subquery(
|
|
1179
|
+
start_subquery, output_field=DecimalField(max_digits=12, decimal_places=3)
|
|
1180
|
+
),
|
|
1181
|
+
end_field: Subquery(
|
|
1182
|
+
end_subquery, output_field=DecimalField(max_digits=12, decimal_places=3)
|
|
1183
|
+
),
|
|
1184
|
+
}
|
|
1185
|
+
return queryset.annotate(**annotations)
|
|
1186
|
+
|
|
1187
|
+
|
|
759
1188
|
class Simulator(Entity):
|
|
760
1189
|
"""Preconfigured simulator that can be started from the admin."""
|
|
761
1190
|
|
|
@@ -886,6 +1315,8 @@ class DataTransferMessage(models.Model):
|
|
|
886
1315
|
|
|
887
1316
|
class Meta:
|
|
888
1317
|
ordering = ["-created_at"]
|
|
1318
|
+
verbose_name = _("Data Message")
|
|
1319
|
+
verbose_name_plural = _("Data Messages")
|
|
889
1320
|
indexes = [
|
|
890
1321
|
models.Index(
|
|
891
1322
|
fields=["ocpp_message_id"],
|
|
@@ -900,6 +1331,526 @@ class DataTransferMessage(models.Model):
|
|
|
900
1331
|
return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
|
|
901
1332
|
|
|
902
1333
|
|
|
1334
|
+
class CPFirmware(Entity):
|
|
1335
|
+
"""Persisted firmware packages associated with charge points."""
|
|
1336
|
+
|
|
1337
|
+
class Source(models.TextChoices):
|
|
1338
|
+
DOWNLOAD = "download", _("Downloaded")
|
|
1339
|
+
UPLOAD = "upload", _("Uploaded")
|
|
1340
|
+
|
|
1341
|
+
name = models.CharField(_("Name"), max_length=200, blank=True)
|
|
1342
|
+
description = models.TextField(_("Description"), blank=True)
|
|
1343
|
+
source = models.CharField(
|
|
1344
|
+
max_length=16,
|
|
1345
|
+
choices=Source.choices,
|
|
1346
|
+
default=Source.DOWNLOAD,
|
|
1347
|
+
verbose_name=_("Source"),
|
|
1348
|
+
)
|
|
1349
|
+
source_node = models.ForeignKey(
|
|
1350
|
+
Node,
|
|
1351
|
+
on_delete=models.SET_NULL,
|
|
1352
|
+
null=True,
|
|
1353
|
+
blank=True,
|
|
1354
|
+
related_name="downloaded_firmware",
|
|
1355
|
+
verbose_name=_("Source node"),
|
|
1356
|
+
)
|
|
1357
|
+
source_charger = models.ForeignKey(
|
|
1358
|
+
"Charger",
|
|
1359
|
+
on_delete=models.SET_NULL,
|
|
1360
|
+
null=True,
|
|
1361
|
+
blank=True,
|
|
1362
|
+
related_name="downloaded_firmware",
|
|
1363
|
+
verbose_name=_("Source charge point"),
|
|
1364
|
+
)
|
|
1365
|
+
content_type = models.CharField(
|
|
1366
|
+
_("Content type"),
|
|
1367
|
+
max_length=100,
|
|
1368
|
+
default="application/octet-stream",
|
|
1369
|
+
blank=True,
|
|
1370
|
+
)
|
|
1371
|
+
filename = models.CharField(_("Filename"), max_length=255, blank=True)
|
|
1372
|
+
payload_json = models.JSONField(null=True, blank=True)
|
|
1373
|
+
payload_binary = models.BinaryField(null=True, blank=True)
|
|
1374
|
+
payload_encoding = models.CharField(_("Encoding"), max_length=32, blank=True)
|
|
1375
|
+
payload_size = models.PositiveIntegerField(_("Payload size"), default=0)
|
|
1376
|
+
checksum = models.CharField(_("Checksum"), max_length=128, blank=True)
|
|
1377
|
+
metadata = models.JSONField(default=dict, blank=True)
|
|
1378
|
+
download_vendor_id = models.CharField(
|
|
1379
|
+
_("Vendor ID"), max_length=255, blank=True
|
|
1380
|
+
)
|
|
1381
|
+
download_message_id = models.CharField(
|
|
1382
|
+
_("Message ID"), max_length=64, blank=True
|
|
1383
|
+
)
|
|
1384
|
+
downloaded_at = models.DateTimeField(_("Downloaded at"), null=True, blank=True)
|
|
1385
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
1386
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
1387
|
+
|
|
1388
|
+
class Meta:
|
|
1389
|
+
ordering = ["-created_at"]
|
|
1390
|
+
verbose_name = _("CP Firmware")
|
|
1391
|
+
verbose_name_plural = _("CP Firmware")
|
|
1392
|
+
|
|
1393
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1394
|
+
label = self.name or self.filename or ""
|
|
1395
|
+
if label:
|
|
1396
|
+
return label
|
|
1397
|
+
return f"Firmware #{self.pk}" if self.pk else "Firmware"
|
|
1398
|
+
|
|
1399
|
+
def save(self, *args, **kwargs):
|
|
1400
|
+
if self.filename:
|
|
1401
|
+
self.filename = os.path.basename(self.filename)
|
|
1402
|
+
payload_bytes = self.get_payload_bytes()
|
|
1403
|
+
self.payload_size = len(payload_bytes)
|
|
1404
|
+
if payload_bytes:
|
|
1405
|
+
self.checksum = hashlib.sha256(payload_bytes).hexdigest()
|
|
1406
|
+
elif not self.checksum:
|
|
1407
|
+
self.checksum = ""
|
|
1408
|
+
if not self.content_type:
|
|
1409
|
+
if self.payload_binary:
|
|
1410
|
+
self.content_type = "application/octet-stream"
|
|
1411
|
+
elif self.payload_json is not None:
|
|
1412
|
+
self.content_type = "application/json"
|
|
1413
|
+
if not self.payload_encoding:
|
|
1414
|
+
self.payload_encoding = ""
|
|
1415
|
+
super().save(*args, **kwargs)
|
|
1416
|
+
|
|
1417
|
+
def get_payload_bytes(self) -> bytes:
|
|
1418
|
+
if self.payload_binary:
|
|
1419
|
+
return bytes(self.payload_binary)
|
|
1420
|
+
if self.payload_json is not None:
|
|
1421
|
+
try:
|
|
1422
|
+
return json.dumps(
|
|
1423
|
+
self.payload_json,
|
|
1424
|
+
ensure_ascii=False,
|
|
1425
|
+
separators=(",", ":"),
|
|
1426
|
+
sort_keys=True,
|
|
1427
|
+
).encode("utf-8")
|
|
1428
|
+
except (TypeError, ValueError):
|
|
1429
|
+
return str(self.payload_json).encode("utf-8")
|
|
1430
|
+
return b""
|
|
1431
|
+
|
|
1432
|
+
@property
|
|
1433
|
+
def has_binary(self) -> bool:
|
|
1434
|
+
return bool(self.payload_binary)
|
|
1435
|
+
|
|
1436
|
+
@property
|
|
1437
|
+
def has_json(self) -> bool:
|
|
1438
|
+
return self.payload_json is not None
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
class CPFirmwareDeployment(Entity):
|
|
1442
|
+
"""Track firmware rollout attempts for specific charge points."""
|
|
1443
|
+
|
|
1444
|
+
TERMINAL_STATUSES = {"Installed", "InstallationFailed", "DownloadFailed"}
|
|
1445
|
+
|
|
1446
|
+
firmware = models.ForeignKey(
|
|
1447
|
+
CPFirmware,
|
|
1448
|
+
on_delete=models.CASCADE,
|
|
1449
|
+
related_name="deployments",
|
|
1450
|
+
verbose_name=_("Firmware"),
|
|
1451
|
+
)
|
|
1452
|
+
charger = models.ForeignKey(
|
|
1453
|
+
"Charger",
|
|
1454
|
+
on_delete=models.PROTECT,
|
|
1455
|
+
related_name="firmware_deployments",
|
|
1456
|
+
verbose_name=_("Charge point"),
|
|
1457
|
+
)
|
|
1458
|
+
node = models.ForeignKey(
|
|
1459
|
+
Node,
|
|
1460
|
+
on_delete=models.SET_NULL,
|
|
1461
|
+
null=True,
|
|
1462
|
+
blank=True,
|
|
1463
|
+
related_name="firmware_deployments",
|
|
1464
|
+
verbose_name=_("Node"),
|
|
1465
|
+
)
|
|
1466
|
+
ocpp_message_id = models.CharField(
|
|
1467
|
+
_("OCPP message ID"), max_length=64, blank=True
|
|
1468
|
+
)
|
|
1469
|
+
status = models.CharField(_("Status"), max_length=32, blank=True)
|
|
1470
|
+
status_info = models.CharField(_("Status details"), max_length=255, blank=True)
|
|
1471
|
+
status_timestamp = models.DateTimeField(_("Status timestamp"), null=True, blank=True)
|
|
1472
|
+
requested_at = models.DateTimeField(_("Requested at"), auto_now_add=True)
|
|
1473
|
+
completed_at = models.DateTimeField(_("Completed at"), null=True, blank=True)
|
|
1474
|
+
retrieve_date = models.DateTimeField(_("Retrieve date"), null=True, blank=True)
|
|
1475
|
+
retry_count = models.PositiveIntegerField(_("Retries"), default=0)
|
|
1476
|
+
retry_interval = models.PositiveIntegerField(
|
|
1477
|
+
_("Retry interval (seconds)"), default=0
|
|
1478
|
+
)
|
|
1479
|
+
download_token = models.CharField(_("Download token"), max_length=64, blank=True)
|
|
1480
|
+
download_token_expires_at = models.DateTimeField(
|
|
1481
|
+
_("Token expires at"), null=True, blank=True
|
|
1482
|
+
)
|
|
1483
|
+
downloaded_at = models.DateTimeField(_("Downloaded at"), null=True, blank=True)
|
|
1484
|
+
request_payload = models.JSONField(default=dict, blank=True)
|
|
1485
|
+
response_payload = models.JSONField(default=dict, blank=True)
|
|
1486
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
1487
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
1488
|
+
|
|
1489
|
+
class Meta:
|
|
1490
|
+
ordering = ["-requested_at"]
|
|
1491
|
+
verbose_name = _("CP Firmware Deployment")
|
|
1492
|
+
verbose_name_plural = _("CP Firmware Deployments")
|
|
1493
|
+
indexes = [
|
|
1494
|
+
models.Index(fields=["ocpp_message_id"]),
|
|
1495
|
+
models.Index(fields=["download_token"]),
|
|
1496
|
+
]
|
|
1497
|
+
|
|
1498
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1499
|
+
return f"{self.firmware} → {self.charger}" if self.pk else "Firmware Deployment"
|
|
1500
|
+
|
|
1501
|
+
def issue_download_token(self, *, lifetime: timedelta | None = None) -> str:
|
|
1502
|
+
if lifetime is None:
|
|
1503
|
+
lifetime = timedelta(hours=1)
|
|
1504
|
+
deadline = timezone.now() + lifetime
|
|
1505
|
+
token = secrets.token_urlsafe(24)
|
|
1506
|
+
while type(self).all_objects.filter(download_token=token).exists():
|
|
1507
|
+
token = secrets.token_urlsafe(24)
|
|
1508
|
+
self.download_token = token
|
|
1509
|
+
self.download_token_expires_at = deadline
|
|
1510
|
+
self.save(
|
|
1511
|
+
update_fields=["download_token", "download_token_expires_at", "updated_at"]
|
|
1512
|
+
)
|
|
1513
|
+
return token
|
|
1514
|
+
|
|
1515
|
+
def mark_status(
|
|
1516
|
+
self,
|
|
1517
|
+
status: str,
|
|
1518
|
+
info: str = "",
|
|
1519
|
+
timestamp: datetime | None = None,
|
|
1520
|
+
*,
|
|
1521
|
+
response: dict | None = None,
|
|
1522
|
+
) -> None:
|
|
1523
|
+
timestamp_value = timestamp or timezone.now()
|
|
1524
|
+
self.status = status
|
|
1525
|
+
self.status_info = info
|
|
1526
|
+
self.status_timestamp = timestamp_value
|
|
1527
|
+
if response is not None:
|
|
1528
|
+
self.response_payload = response
|
|
1529
|
+
if status in self.TERMINAL_STATUSES and not self.completed_at:
|
|
1530
|
+
self.completed_at = timezone.now()
|
|
1531
|
+
self.save(
|
|
1532
|
+
update_fields=[
|
|
1533
|
+
"status",
|
|
1534
|
+
"status_info",
|
|
1535
|
+
"status_timestamp",
|
|
1536
|
+
"response_payload",
|
|
1537
|
+
"completed_at",
|
|
1538
|
+
"updated_at",
|
|
1539
|
+
]
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
@property
|
|
1543
|
+
def is_terminal(self) -> bool:
|
|
1544
|
+
return self.status in self.TERMINAL_STATUSES and bool(self.completed_at)
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
class CPReservation(Entity):
|
|
1548
|
+
"""Track connector reservations dispatched to an EVCS."""
|
|
1549
|
+
|
|
1550
|
+
location = models.ForeignKey(
|
|
1551
|
+
Location,
|
|
1552
|
+
on_delete=models.PROTECT,
|
|
1553
|
+
related_name="reservations",
|
|
1554
|
+
verbose_name=_("Location"),
|
|
1555
|
+
)
|
|
1556
|
+
connector = models.ForeignKey(
|
|
1557
|
+
Charger,
|
|
1558
|
+
on_delete=models.PROTECT,
|
|
1559
|
+
related_name="reservations",
|
|
1560
|
+
verbose_name=_("Connector"),
|
|
1561
|
+
)
|
|
1562
|
+
account = models.ForeignKey(
|
|
1563
|
+
EnergyAccount,
|
|
1564
|
+
on_delete=models.SET_NULL,
|
|
1565
|
+
null=True,
|
|
1566
|
+
blank=True,
|
|
1567
|
+
related_name="cp_reservations",
|
|
1568
|
+
verbose_name=_("Energy account"),
|
|
1569
|
+
)
|
|
1570
|
+
rfid = models.ForeignKey(
|
|
1571
|
+
CoreRFID,
|
|
1572
|
+
on_delete=models.SET_NULL,
|
|
1573
|
+
null=True,
|
|
1574
|
+
blank=True,
|
|
1575
|
+
related_name="cp_reservations",
|
|
1576
|
+
verbose_name=_("RFID"),
|
|
1577
|
+
)
|
|
1578
|
+
id_tag = models.CharField(
|
|
1579
|
+
_("Id Tag"),
|
|
1580
|
+
max_length=255,
|
|
1581
|
+
blank=True,
|
|
1582
|
+
default="",
|
|
1583
|
+
help_text=_("Identifier sent to the EVCS when reserving the connector."),
|
|
1584
|
+
)
|
|
1585
|
+
start_time = models.DateTimeField(verbose_name=_("Start time"))
|
|
1586
|
+
duration_minutes = models.PositiveIntegerField(
|
|
1587
|
+
verbose_name=_("Duration (minutes)"),
|
|
1588
|
+
default=120,
|
|
1589
|
+
help_text=_("Reservation window length in minutes."),
|
|
1590
|
+
)
|
|
1591
|
+
evcs_status = models.CharField(
|
|
1592
|
+
max_length=32,
|
|
1593
|
+
blank=True,
|
|
1594
|
+
default="",
|
|
1595
|
+
verbose_name=_("EVCS status"),
|
|
1596
|
+
)
|
|
1597
|
+
evcs_error = models.CharField(
|
|
1598
|
+
max_length=255,
|
|
1599
|
+
blank=True,
|
|
1600
|
+
default="",
|
|
1601
|
+
verbose_name=_("EVCS error"),
|
|
1602
|
+
)
|
|
1603
|
+
evcs_confirmed = models.BooleanField(
|
|
1604
|
+
default=False,
|
|
1605
|
+
verbose_name=_("Reservation confirmed"),
|
|
1606
|
+
)
|
|
1607
|
+
evcs_confirmed_at = models.DateTimeField(
|
|
1608
|
+
null=True,
|
|
1609
|
+
blank=True,
|
|
1610
|
+
verbose_name=_("Confirmed at"),
|
|
1611
|
+
)
|
|
1612
|
+
ocpp_message_id = models.CharField(
|
|
1613
|
+
max_length=36,
|
|
1614
|
+
blank=True,
|
|
1615
|
+
default="",
|
|
1616
|
+
editable=False,
|
|
1617
|
+
verbose_name=_("OCPP message id"),
|
|
1618
|
+
)
|
|
1619
|
+
created_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Created on"))
|
|
1620
|
+
updated_on = models.DateTimeField(auto_now=True, verbose_name=_("Updated on"))
|
|
1621
|
+
|
|
1622
|
+
class Meta:
|
|
1623
|
+
ordering = ["-start_time"]
|
|
1624
|
+
verbose_name = _("CP Reservation")
|
|
1625
|
+
verbose_name_plural = _("CP Reservations")
|
|
1626
|
+
|
|
1627
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1628
|
+
start = timezone.localtime(self.start_time) if self.start_time else ""
|
|
1629
|
+
return f"{self.location} @ {start}" if self.location else str(start)
|
|
1630
|
+
|
|
1631
|
+
@property
|
|
1632
|
+
def end_time(self):
|
|
1633
|
+
duration = max(int(self.duration_minutes or 0), 0)
|
|
1634
|
+
return self.start_time + timedelta(minutes=duration)
|
|
1635
|
+
|
|
1636
|
+
@property
|
|
1637
|
+
def connector_label(self) -> str:
|
|
1638
|
+
if self.connector_id:
|
|
1639
|
+
return self.connector.connector_label
|
|
1640
|
+
return ""
|
|
1641
|
+
|
|
1642
|
+
@property
|
|
1643
|
+
def id_tag_value(self) -> str:
|
|
1644
|
+
if self.id_tag:
|
|
1645
|
+
return self.id_tag.strip()
|
|
1646
|
+
if self.rfid_id:
|
|
1647
|
+
return (self.rfid.rfid or "").strip()
|
|
1648
|
+
return ""
|
|
1649
|
+
|
|
1650
|
+
def allocate_connector(self, *, force: bool = False) -> Charger:
|
|
1651
|
+
"""Select an available connector for this reservation."""
|
|
1652
|
+
|
|
1653
|
+
if not self.location_id:
|
|
1654
|
+
raise ValidationError({"location": _("Select a location for the reservation.")})
|
|
1655
|
+
if not self.start_time:
|
|
1656
|
+
raise ValidationError({"start_time": _("Provide a start time for the reservation.")})
|
|
1657
|
+
if self.duration_minutes <= 0:
|
|
1658
|
+
raise ValidationError(
|
|
1659
|
+
{"duration_minutes": _("Reservation window must be at least one minute.")}
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
candidates = list(
|
|
1663
|
+
Charger.objects.filter(
|
|
1664
|
+
location=self.location, connector_id__isnull=False
|
|
1665
|
+
).order_by("connector_id")
|
|
1666
|
+
)
|
|
1667
|
+
if not candidates:
|
|
1668
|
+
raise ValidationError(
|
|
1669
|
+
{"location": _("No connectors are configured for the selected location.")}
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
def _priority(charger: Charger) -> tuple[int, int]:
|
|
1673
|
+
connector_id = charger.connector_id or 0
|
|
1674
|
+
if connector_id == 2:
|
|
1675
|
+
return (0, connector_id)
|
|
1676
|
+
if connector_id == 1:
|
|
1677
|
+
return (1, connector_id)
|
|
1678
|
+
return (2, connector_id)
|
|
1679
|
+
|
|
1680
|
+
def _is_available(charger: Charger) -> bool:
|
|
1681
|
+
existing = type(self).objects.filter(connector=charger).exclude(pk=self.pk)
|
|
1682
|
+
start = self.start_time
|
|
1683
|
+
end = self.end_time
|
|
1684
|
+
for entry in existing:
|
|
1685
|
+
if entry.start_time < end and entry.end_time > start:
|
|
1686
|
+
return False
|
|
1687
|
+
return True
|
|
1688
|
+
|
|
1689
|
+
if self.connector_id:
|
|
1690
|
+
current = next((c for c in candidates if c.pk == self.connector_id), None)
|
|
1691
|
+
if current and _is_available(current) and not force:
|
|
1692
|
+
return current
|
|
1693
|
+
|
|
1694
|
+
for charger in sorted(candidates, key=_priority):
|
|
1695
|
+
if _is_available(charger):
|
|
1696
|
+
self.connector = charger
|
|
1697
|
+
return charger
|
|
1698
|
+
|
|
1699
|
+
raise ValidationError(
|
|
1700
|
+
_("All connectors at this location are reserved for the selected time window.")
|
|
1701
|
+
)
|
|
1702
|
+
|
|
1703
|
+
def clean(self):
|
|
1704
|
+
super().clean()
|
|
1705
|
+
if self.start_time and timezone.is_naive(self.start_time):
|
|
1706
|
+
self.start_time = timezone.make_aware(
|
|
1707
|
+
self.start_time, timezone.get_current_timezone()
|
|
1708
|
+
)
|
|
1709
|
+
if self.duration_minutes <= 0:
|
|
1710
|
+
raise ValidationError(
|
|
1711
|
+
{"duration_minutes": _("Reservation window must be at least one minute.")}
|
|
1712
|
+
)
|
|
1713
|
+
try:
|
|
1714
|
+
self.allocate_connector(force=bool(self.pk))
|
|
1715
|
+
except ValidationError as exc:
|
|
1716
|
+
raise ValidationError(exc) from exc
|
|
1717
|
+
|
|
1718
|
+
def save(self, *args, **kwargs):
|
|
1719
|
+
if self.start_time and timezone.is_naive(self.start_time):
|
|
1720
|
+
self.start_time = timezone.make_aware(
|
|
1721
|
+
self.start_time, timezone.get_current_timezone()
|
|
1722
|
+
)
|
|
1723
|
+
update_fields = kwargs.get("update_fields")
|
|
1724
|
+
relevant_fields = {"location", "start_time", "duration_minutes", "connector"}
|
|
1725
|
+
should_allocate = True
|
|
1726
|
+
if update_fields is not None and not relevant_fields.intersection(update_fields):
|
|
1727
|
+
should_allocate = False
|
|
1728
|
+
if should_allocate:
|
|
1729
|
+
self.allocate_connector(force=bool(self.pk))
|
|
1730
|
+
super().save(*args, **kwargs)
|
|
1731
|
+
|
|
1732
|
+
def send_reservation_request(self) -> str:
|
|
1733
|
+
"""Dispatch a ReserveNow request to the associated connector."""
|
|
1734
|
+
|
|
1735
|
+
if not self.pk:
|
|
1736
|
+
raise ValidationError(_("Save the reservation before sending it to the EVCS."))
|
|
1737
|
+
connector = self.connector
|
|
1738
|
+
if connector is None or connector.connector_id is None:
|
|
1739
|
+
raise ValidationError(_("Unable to determine which connector to reserve."))
|
|
1740
|
+
id_tag = self.id_tag_value
|
|
1741
|
+
if not id_tag:
|
|
1742
|
+
raise ValidationError(
|
|
1743
|
+
_("Provide an RFID or idTag before creating the reservation.")
|
|
1744
|
+
)
|
|
1745
|
+
connection = store.get_connection(connector.charger_id, connector.connector_id)
|
|
1746
|
+
if connection is None:
|
|
1747
|
+
raise ValidationError(
|
|
1748
|
+
_("The selected charge point is not currently connected to the system.")
|
|
1749
|
+
)
|
|
1750
|
+
|
|
1751
|
+
message_id = uuid.uuid4().hex
|
|
1752
|
+
expiry = timezone.localtime(self.end_time)
|
|
1753
|
+
payload = {
|
|
1754
|
+
"connectorId": connector.connector_id,
|
|
1755
|
+
"expiryDate": expiry.isoformat(),
|
|
1756
|
+
"idTag": id_tag,
|
|
1757
|
+
"reservationId": self.pk,
|
|
1758
|
+
}
|
|
1759
|
+
frame = json.dumps([2, message_id, "ReserveNow", payload])
|
|
1760
|
+
|
|
1761
|
+
log_key = store.identity_key(connector.charger_id, connector.connector_id)
|
|
1762
|
+
store.add_log(
|
|
1763
|
+
log_key,
|
|
1764
|
+
f"ReserveNow request: reservation={self.pk}, expiry={expiry.isoformat()}",
|
|
1765
|
+
log_type="charger",
|
|
1766
|
+
)
|
|
1767
|
+
async_to_sync(connection.send)(frame)
|
|
1768
|
+
|
|
1769
|
+
metadata = {
|
|
1770
|
+
"action": "ReserveNow",
|
|
1771
|
+
"charger_id": connector.charger_id,
|
|
1772
|
+
"connector_id": connector.connector_id,
|
|
1773
|
+
"log_key": log_key,
|
|
1774
|
+
"reservation_pk": self.pk,
|
|
1775
|
+
"requested_at": timezone.now(),
|
|
1776
|
+
}
|
|
1777
|
+
store.register_pending_call(message_id, metadata)
|
|
1778
|
+
store.schedule_call_timeout(message_id, action="ReserveNow", log_key=log_key)
|
|
1779
|
+
|
|
1780
|
+
self.ocpp_message_id = message_id
|
|
1781
|
+
self.evcs_status = ""
|
|
1782
|
+
self.evcs_error = ""
|
|
1783
|
+
self.evcs_confirmed = False
|
|
1784
|
+
self.evcs_confirmed_at = None
|
|
1785
|
+
super().save(
|
|
1786
|
+
update_fields=[
|
|
1787
|
+
"ocpp_message_id",
|
|
1788
|
+
"evcs_status",
|
|
1789
|
+
"evcs_error",
|
|
1790
|
+
"evcs_confirmed",
|
|
1791
|
+
"evcs_confirmed_at",
|
|
1792
|
+
"updated_on",
|
|
1793
|
+
]
|
|
1794
|
+
)
|
|
1795
|
+
return message_id
|
|
1796
|
+
|
|
1797
|
+
def send_cancel_request(self) -> str:
|
|
1798
|
+
"""Dispatch a CancelReservation request for this reservation."""
|
|
1799
|
+
|
|
1800
|
+
if not self.pk:
|
|
1801
|
+
raise ValidationError(_("Save the reservation before sending it to the EVCS."))
|
|
1802
|
+
connector = self.connector
|
|
1803
|
+
if connector is None or connector.connector_id is None:
|
|
1804
|
+
raise ValidationError(_("Unable to determine which connector to cancel."))
|
|
1805
|
+
connection = store.get_connection(connector.charger_id, connector.connector_id)
|
|
1806
|
+
if connection is None:
|
|
1807
|
+
raise ValidationError(
|
|
1808
|
+
_("The selected charge point is not currently connected to the system.")
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
message_id = uuid.uuid4().hex
|
|
1812
|
+
payload = {"reservationId": self.pk}
|
|
1813
|
+
frame = json.dumps([2, message_id, "CancelReservation", payload])
|
|
1814
|
+
|
|
1815
|
+
log_key = store.identity_key(connector.charger_id, connector.connector_id)
|
|
1816
|
+
store.add_log(
|
|
1817
|
+
log_key,
|
|
1818
|
+
f"CancelReservation request: reservation={self.pk}",
|
|
1819
|
+
log_type="charger",
|
|
1820
|
+
)
|
|
1821
|
+
async_to_sync(connection.send)(frame)
|
|
1822
|
+
|
|
1823
|
+
metadata = {
|
|
1824
|
+
"action": "CancelReservation",
|
|
1825
|
+
"charger_id": connector.charger_id,
|
|
1826
|
+
"connector_id": connector.connector_id,
|
|
1827
|
+
"log_key": log_key,
|
|
1828
|
+
"reservation_pk": self.pk,
|
|
1829
|
+
"requested_at": timezone.now(),
|
|
1830
|
+
}
|
|
1831
|
+
store.register_pending_call(message_id, metadata)
|
|
1832
|
+
store.schedule_call_timeout(
|
|
1833
|
+
message_id, action="CancelReservation", log_key=log_key
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
self.ocpp_message_id = message_id
|
|
1837
|
+
self.evcs_status = ""
|
|
1838
|
+
self.evcs_error = ""
|
|
1839
|
+
self.evcs_confirmed = False
|
|
1840
|
+
self.evcs_confirmed_at = None
|
|
1841
|
+
super().save(
|
|
1842
|
+
update_fields=[
|
|
1843
|
+
"ocpp_message_id",
|
|
1844
|
+
"evcs_status",
|
|
1845
|
+
"evcs_error",
|
|
1846
|
+
"evcs_confirmed",
|
|
1847
|
+
"evcs_confirmed_at",
|
|
1848
|
+
"updated_on",
|
|
1849
|
+
]
|
|
1850
|
+
)
|
|
1851
|
+
return message_id
|
|
1852
|
+
|
|
1853
|
+
|
|
903
1854
|
class RFID(CoreRFID):
|
|
904
1855
|
class Meta:
|
|
905
1856
|
proxy = True
|