arthexis 0.1.16__py3-none-any.whl → 0.1.26__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
ocpp/consumers.py CHANGED
@@ -5,6 +5,7 @@ from datetime import datetime
5
5
  import asyncio
6
6
  import inspect
7
7
  import json
8
+ import logging
8
9
  from urllib.parse import parse_qs
9
10
  from django.utils import timezone
10
11
  from core.models import EnergyAccount, Reference, RFID as CoreRFID
@@ -19,7 +20,14 @@ from config.offline import requires_network
19
20
  from . import store
20
21
  from decimal import Decimal
21
22
  from django.utils.dateparse import parse_datetime
22
- from .models import Transaction, Charger, MeterValue, DataTransferMessage
23
+ from .models import (
24
+ Transaction,
25
+ Charger,
26
+ ChargerConfiguration,
27
+ MeterValue,
28
+ DataTransferMessage,
29
+ CPReservation,
30
+ )
23
31
  from .reference_utils import host_is_local_loopback
24
32
  from .evcs_discovery import (
25
33
  DEFAULT_CONSOLE_PORT,
@@ -32,6 +40,9 @@ from .evcs_discovery import (
32
40
  FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
33
41
 
34
42
 
43
+ logger = logging.getLogger(__name__)
44
+
45
+
35
46
  # Query parameter keys that may contain the charge point serial. Keys are
36
47
  # matched case-insensitively and trimmed before use.
37
48
  SERIAL_QUERY_PARAM_NAMES = (
@@ -134,6 +145,18 @@ def _parse_ocpp_timestamp(value) -> datetime | None:
134
145
  return timestamp
135
146
 
136
147
 
148
+ def _extract_vehicle_identifier(payload: dict) -> tuple[str, str]:
149
+ """Return normalized VID and VIN values from an OCPP message payload."""
150
+
151
+ raw_vid = payload.get("vid")
152
+ vid_value = str(raw_vid).strip() if raw_vid is not None else ""
153
+ raw_vin = payload.get("vin")
154
+ vin_value = str(raw_vin).strip() if raw_vin is not None else ""
155
+ if not vid_value and vin_value:
156
+ vid_value = vin_value
157
+ return vid_value, vin_value
158
+
159
+
137
160
  class SinkConsumer(AsyncWebsocketConsumer):
138
161
  """Accept any message without validation."""
139
162
 
@@ -279,11 +302,18 @@ class CSMSConsumer(AsyncWebsocketConsumer):
279
302
  """Return the energy account for the provided RFID if valid."""
280
303
  if not id_tag:
281
304
  return None
282
- return await database_sync_to_async(
283
- EnergyAccount.objects.filter(
284
- rfids__rfid=id_tag.upper(), rfids__allowed=True
285
- ).first
286
- )()
305
+
306
+ def _resolve() -> EnergyAccount | None:
307
+ matches = CoreRFID.matching_queryset(id_tag).filter(allowed=True)
308
+ if not matches.exists():
309
+ return None
310
+ return (
311
+ EnergyAccount.objects.filter(rfids__in=matches)
312
+ .distinct()
313
+ .first()
314
+ )
315
+
316
+ return await database_sync_to_async(_resolve)()
287
317
 
288
318
  async def _ensure_rfid_seen(self, id_tag: str) -> CoreRFID | None:
289
319
  """Ensure an RFID record exists and update its last seen timestamp."""
@@ -309,6 +339,19 @@ class CSMSConsumer(AsyncWebsocketConsumer):
309
339
 
310
340
  return await database_sync_to_async(_ensure)()
311
341
 
342
+ def _log_unlinked_rfid(self, rfid: str) -> None:
343
+ """Record a warning when an RFID is authorized without an account."""
344
+
345
+ message = (
346
+ f"Authorized RFID {rfid} on charger {self.charger_id} without linked energy account"
347
+ )
348
+ logger.warning(message)
349
+ store.add_log(
350
+ store.pending_key(self.charger_id),
351
+ message,
352
+ log_type="charger",
353
+ )
354
+
312
355
  async def _assign_connector(self, connector: int | str | None) -> None:
313
356
  """Ensure ``self.charger`` matches the provided connector id."""
314
357
  if connector in (None, "", "-"):
@@ -709,6 +752,54 @@ class CSMSConsumer(AsyncWebsocketConsumer):
709
752
  task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
710
753
  self._consumption_task = task
711
754
 
755
+ def _persist_configuration_result(
756
+ self, payload: dict, connector_hint: int | str | None
757
+ ) -> ChargerConfiguration | None:
758
+ if not isinstance(payload, dict):
759
+ return None
760
+
761
+ connector_value: int | None = None
762
+ if connector_hint not in (None, ""):
763
+ try:
764
+ connector_value = int(connector_hint)
765
+ except (TypeError, ValueError):
766
+ connector_value = None
767
+
768
+ normalized_entries: list[dict[str, object]] = []
769
+ for entry in payload.get("configurationKey") or []:
770
+ if not isinstance(entry, dict):
771
+ continue
772
+ key = str(entry.get("key") or "")
773
+ normalized: dict[str, object] = {"key": key}
774
+ if "value" in entry:
775
+ normalized["value"] = entry.get("value")
776
+ normalized["readonly"] = bool(entry.get("readonly"))
777
+ normalized_entries.append(normalized)
778
+
779
+ unknown_values: list[str] = []
780
+ for value in payload.get("unknownKey") or []:
781
+ if value is None:
782
+ continue
783
+ unknown_values.append(str(value))
784
+
785
+ try:
786
+ raw_payload = json.loads(json.dumps(payload, ensure_ascii=False))
787
+ except (TypeError, ValueError):
788
+ raw_payload = payload
789
+
790
+ configuration = ChargerConfiguration.objects.create(
791
+ charger_identifier=self.charger_id,
792
+ connector_id=connector_value,
793
+ configuration_keys=normalized_entries,
794
+ unknown_keys=unknown_values,
795
+ evcs_snapshot_at=timezone.now(),
796
+ raw_payload=raw_payload,
797
+ )
798
+ Charger.objects.filter(charger_id=self.charger_id).update(
799
+ configuration=configuration
800
+ )
801
+ return configuration
802
+
712
803
  async def _handle_call_result(self, message_id: str, payload: dict | None) -> None:
713
804
  metadata = store.pop_pending_call(message_id)
714
805
  if not metadata:
@@ -770,6 +861,17 @@ class CSMSConsumer(AsyncWebsocketConsumer):
770
861
  f"GetConfiguration result: {payload_text}",
771
862
  log_type="charger",
772
863
  )
864
+ configuration = await database_sync_to_async(
865
+ self._persist_configuration_result
866
+ )(payload_data, metadata.get("connector_id"))
867
+ if configuration:
868
+ if self.charger and self.charger.charger_id == self.charger_id:
869
+ self.charger.configuration = configuration
870
+ if (
871
+ self.aggregate_charger
872
+ and self.aggregate_charger.charger_id == self.charger_id
873
+ ):
874
+ self.aggregate_charger.configuration = configuration
773
875
  store.record_pending_call_result(
774
876
  message_id,
775
877
  metadata=metadata,
@@ -802,6 +904,43 @@ class CSMSConsumer(AsyncWebsocketConsumer):
802
904
  payload=payload_data,
803
905
  )
804
906
  return
907
+ if action == "ReserveNow":
908
+ status_value = str(payload_data.get("status") or "").strip()
909
+ message = "ReserveNow result"
910
+ if status_value:
911
+ message += f": status={status_value}"
912
+ store.add_log(log_key, message, log_type="charger")
913
+
914
+ reservation_pk = metadata.get("reservation_pk")
915
+
916
+ def _apply():
917
+ if not reservation_pk:
918
+ return
919
+ reservation = CPReservation.objects.filter(pk=reservation_pk).first()
920
+ if not reservation:
921
+ return
922
+ reservation.evcs_status = status_value
923
+ reservation.evcs_error = ""
924
+ confirmed = status_value.casefold() == "accepted"
925
+ reservation.evcs_confirmed = confirmed
926
+ reservation.evcs_confirmed_at = timezone.now() if confirmed else None
927
+ reservation.save(
928
+ update_fields=[
929
+ "evcs_status",
930
+ "evcs_error",
931
+ "evcs_confirmed",
932
+ "evcs_confirmed_at",
933
+ "updated_on",
934
+ ]
935
+ )
936
+
937
+ await database_sync_to_async(_apply)()
938
+ store.record_pending_call_result(
939
+ message_id,
940
+ metadata=metadata,
941
+ payload=payload_data,
942
+ )
943
+ return
805
944
  if action == "RemoteStartTransaction":
806
945
  status_value = str(payload_data.get("status") or "").strip()
807
946
  message = "RemoteStartTransaction result"
@@ -983,6 +1122,66 @@ class CSMSConsumer(AsyncWebsocketConsumer):
983
1122
  error_details=details,
984
1123
  )
985
1124
  return
1125
+ if action == "ReserveNow":
1126
+ parts: list[str] = []
1127
+ code_text = (error_code or "").strip() if error_code else ""
1128
+ if code_text:
1129
+ parts.append(f"code={code_text}")
1130
+ description_text = (description or "").strip() if description else ""
1131
+ if description_text:
1132
+ parts.append(f"description={description_text}")
1133
+ details_text = ""
1134
+ if details:
1135
+ try:
1136
+ details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
1137
+ except TypeError:
1138
+ details_text = str(details)
1139
+ if details_text:
1140
+ parts.append(f"details={details_text}")
1141
+ message = "ReserveNow error"
1142
+ if parts:
1143
+ message += ": " + ", ".join(parts)
1144
+ store.add_log(log_key, message, log_type="charger")
1145
+
1146
+ reservation_pk = metadata.get("reservation_pk")
1147
+
1148
+ def _apply():
1149
+ if not reservation_pk:
1150
+ return
1151
+ reservation = CPReservation.objects.filter(pk=reservation_pk).first()
1152
+ if not reservation:
1153
+ return
1154
+ summary_parts = []
1155
+ if code_text:
1156
+ summary_parts.append(code_text)
1157
+ if description_text:
1158
+ summary_parts.append(description_text)
1159
+ if details_text:
1160
+ summary_parts.append(details_text)
1161
+ reservation.evcs_status = ""
1162
+ reservation.evcs_error = "; ".join(summary_parts)
1163
+ reservation.evcs_confirmed = False
1164
+ reservation.evcs_confirmed_at = None
1165
+ reservation.save(
1166
+ update_fields=[
1167
+ "evcs_status",
1168
+ "evcs_error",
1169
+ "evcs_confirmed",
1170
+ "evcs_confirmed_at",
1171
+ "updated_on",
1172
+ ]
1173
+ )
1174
+
1175
+ await database_sync_to_async(_apply)()
1176
+ store.record_pending_call_result(
1177
+ message_id,
1178
+ metadata=metadata,
1179
+ success=False,
1180
+ error_code=error_code,
1181
+ error_description=description,
1182
+ error_details=details,
1183
+ )
1184
+ return
986
1185
  if action == "RemoteStartTransaction":
987
1186
  message = "RemoteStartTransaction error"
988
1187
  if error_code:
@@ -1321,8 +1520,13 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1321
1520
  reply_payload = {"currentTime": datetime.utcnow().isoformat() + "Z"}
1322
1521
  now = timezone.now()
1323
1522
  self.charger.last_heartbeat = now
1523
+ if (
1524
+ self.aggregate_charger
1525
+ and self.aggregate_charger is not self.charger
1526
+ ):
1527
+ self.aggregate_charger.last_heartbeat = now
1324
1528
  await database_sync_to_async(
1325
- Charger.objects.filter(pk=self.charger.pk).update
1529
+ Charger.objects.filter(charger_id=self.charger_id).update
1326
1530
  )(last_heartbeat=now)
1327
1531
  elif action == "StatusNotification":
1328
1532
  await self._assign_connector(payload.get("connectorId"))
@@ -1395,13 +1599,25 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1395
1599
  elif action == "Authorize":
1396
1600
  id_tag = payload.get("idTag")
1397
1601
  account = await self._get_account(id_tag)
1602
+ status = "Invalid"
1398
1603
  if self.charger.require_rfid:
1399
- status = (
1400
- "Accepted"
1401
- if account
1402
- and await database_sync_to_async(account.can_authorize)()
1403
- else "Invalid"
1404
- )
1604
+ tag = None
1605
+ tag_created = False
1606
+ if id_tag:
1607
+ tag, tag_created = await database_sync_to_async(
1608
+ CoreRFID.register_scan
1609
+ )(id_tag)
1610
+ if account:
1611
+ if await database_sync_to_async(account.can_authorize)():
1612
+ status = "Accepted"
1613
+ elif (
1614
+ id_tag
1615
+ and tag
1616
+ and not tag_created
1617
+ and tag.allowed
1618
+ ):
1619
+ status = "Accepted"
1620
+ self._log_unlinked_rfid(tag.rfid)
1405
1621
  else:
1406
1622
  await self._ensure_rfid_seen(id_tag)
1407
1623
  status = "Accepted"
@@ -1475,30 +1691,47 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1475
1691
  reply_payload = {}
1476
1692
  elif action == "StartTransaction":
1477
1693
  id_tag = payload.get("idTag")
1478
- account = await self._get_account(id_tag)
1694
+ tag = None
1695
+ tag_created = False
1479
1696
  if id_tag:
1480
- if self.charger.require_rfid:
1481
- await database_sync_to_async(CoreRFID.register_scan)(
1482
- id_tag.upper()
1483
- )
1484
- else:
1485
- await self._ensure_rfid_seen(id_tag)
1697
+ tag, tag_created = await database_sync_to_async(
1698
+ CoreRFID.register_scan
1699
+ )(id_tag)
1700
+ account = await self._get_account(id_tag)
1701
+ if id_tag and not self.charger.require_rfid:
1702
+ seen_tag = await self._ensure_rfid_seen(id_tag)
1703
+ if seen_tag:
1704
+ tag = seen_tag
1486
1705
  await self._assign_connector(payload.get("connectorId"))
1706
+ authorized = True
1707
+ authorized_via_tag = False
1487
1708
  if self.charger.require_rfid:
1488
- authorized = (
1489
- account is not None
1490
- and await database_sync_to_async(account.can_authorize)()
1491
- )
1492
- else:
1493
- authorized = True
1709
+ if account is not None:
1710
+ authorized = await database_sync_to_async(
1711
+ account.can_authorize
1712
+ )()
1713
+ elif (
1714
+ id_tag
1715
+ and tag
1716
+ and not tag_created
1717
+ and getattr(tag, "allowed", False)
1718
+ ):
1719
+ authorized = True
1720
+ authorized_via_tag = True
1721
+ else:
1722
+ authorized = False
1494
1723
  if authorized:
1724
+ if authorized_via_tag and tag:
1725
+ self._log_unlinked_rfid(tag.rfid)
1495
1726
  start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1496
1727
  received_start = timezone.now()
1728
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1497
1729
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
1498
1730
  charger=self.charger,
1499
1731
  account=account,
1500
1732
  rfid=(id_tag or ""),
1501
- vin=(payload.get("vin") or ""),
1733
+ vid=vid_value,
1734
+ vin=vin_value,
1502
1735
  connector_id=payload.get("connectorId"),
1503
1736
  meter_start=payload.get("meterStart"),
1504
1737
  start_time=start_timestamp or received_start,
@@ -1524,6 +1757,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1524
1757
  )()
1525
1758
  if not tx_obj and tx_id is not None:
1526
1759
  received_start = timezone.now()
1760
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1527
1761
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
1528
1762
  pk=tx_id,
1529
1763
  charger=self.charger,
@@ -1531,12 +1765,18 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1531
1765
  received_start_time=received_start,
1532
1766
  meter_start=payload.get("meterStart")
1533
1767
  or payload.get("meterStop"),
1534
- vin=(payload.get("vin") or ""),
1768
+ vid=vid_value,
1769
+ vin=vin_value,
1535
1770
  )
1536
1771
  if tx_obj:
1537
1772
  stop_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1538
1773
  received_stop = timezone.now()
1539
1774
  tx_obj.meter_stop = payload.get("meterStop")
1775
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1776
+ if vid_value:
1777
+ tx_obj.vid = vid_value
1778
+ if vin_value:
1779
+ tx_obj.vin = vin_value
1540
1780
  tx_obj.stop_time = stop_timestamp or received_stop
1541
1781
  tx_obj.received_stop_time = received_stop
1542
1782
  await database_sync_to_async(tx_obj.save)()