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.

Files changed (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {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
- location, _ = Location.objects.get_or_create(name=self.charger_id)
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 or self.reference.value != ref_value:
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
- kw = tx_active.kw
546
- if kw:
547
- total += kw
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
- vin = models.CharField(max_length=17, blank=True)
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
- readings = list(
653
- self.meter_values.filter(energy__isnull=False).order_by("timestamp")
654
- )
655
- if readings:
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
- start_val = float(readings[0].energy or 0)
658
- # Always use the latest available reading for the end value when a
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
- end_val = float(readings[-1].energy or 0)
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