arthexis 0.1.21__py3-none-any.whl → 0.1.23__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.21.dist-info → arthexis-0.1.23.dist-info}/METADATA +9 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/RECORD +33 -33
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +224 -32
- core/environment.py +2 -239
- core/models.py +903 -65
- core/release.py +0 -5
- core/system.py +76 -0
- core/tests.py +181 -9
- core/user_data.py +42 -2
- core/views.py +68 -27
- nodes/admin.py +211 -60
- nodes/apps.py +11 -0
- nodes/models.py +35 -7
- nodes/tests.py +288 -1
- nodes/views.py +101 -48
- ocpp/admin.py +32 -2
- ocpp/consumers.py +1 -0
- ocpp/models.py +52 -3
- ocpp/tasks.py +99 -1
- ocpp/tests.py +350 -2
- ocpp/views.py +300 -6
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +386 -28
- pages/urls.py +10 -0
- pages/views.py +347 -18
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/top_level.txt +0 -0
ocpp/models.py
CHANGED
|
@@ -15,6 +15,7 @@ from nodes.models import Node
|
|
|
15
15
|
|
|
16
16
|
from core.models import (
|
|
17
17
|
EnergyAccount,
|
|
18
|
+
EnergyTariff,
|
|
18
19
|
Reference,
|
|
19
20
|
RFID as CoreRFID,
|
|
20
21
|
ElectricVehicle as CoreElectricVehicle,
|
|
@@ -35,6 +36,22 @@ class Location(Entity):
|
|
|
35
36
|
longitude = models.DecimalField(
|
|
36
37
|
max_digits=9, decimal_places=6, null=True, blank=True
|
|
37
38
|
)
|
|
39
|
+
zone = models.CharField(
|
|
40
|
+
max_length=3,
|
|
41
|
+
choices=EnergyTariff.Zone.choices,
|
|
42
|
+
blank=True,
|
|
43
|
+
null=True,
|
|
44
|
+
help_text=_("CFE climate zone used to select matching energy tariffs."),
|
|
45
|
+
)
|
|
46
|
+
contract_type = models.CharField(
|
|
47
|
+
max_length=16,
|
|
48
|
+
choices=EnergyTariff.ContractType.choices,
|
|
49
|
+
blank=True,
|
|
50
|
+
null=True,
|
|
51
|
+
help_text=_(
|
|
52
|
+
"CFE service contract type required to match energy tariff pricing."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
38
55
|
|
|
39
56
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
40
57
|
return self.name
|
|
@@ -48,6 +65,7 @@ class Charger(Entity):
|
|
|
48
65
|
"""Known charge point."""
|
|
49
66
|
|
|
50
67
|
_PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
|
|
68
|
+
_AUTO_LOCATION_SANITIZE_RE = re.compile(r"[^0-9A-Za-z_-]+")
|
|
51
69
|
|
|
52
70
|
OPERATIVE_STATUSES = {
|
|
53
71
|
"Available",
|
|
@@ -324,6 +342,16 @@ class Charger(Entity):
|
|
|
324
342
|
)
|
|
325
343
|
return normalized
|
|
326
344
|
|
|
345
|
+
@classmethod
|
|
346
|
+
def sanitize_auto_location_name(cls, value: str) -> str:
|
|
347
|
+
"""Return a location name containing only safe characters."""
|
|
348
|
+
|
|
349
|
+
sanitized = cls._AUTO_LOCATION_SANITIZE_RE.sub("_", value)
|
|
350
|
+
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
|
|
351
|
+
if not sanitized:
|
|
352
|
+
return "Charger"
|
|
353
|
+
return sanitized
|
|
354
|
+
|
|
327
355
|
AGGREGATE_CONNECTOR_SLUG = "all"
|
|
328
356
|
|
|
329
357
|
def identity_tuple(self) -> tuple[str, int | None]:
|
|
@@ -459,7 +487,8 @@ class Charger(Entity):
|
|
|
459
487
|
if existing:
|
|
460
488
|
self.location = existing.location
|
|
461
489
|
else:
|
|
462
|
-
|
|
490
|
+
auto_name = type(self).sanitize_auto_location_name(self.charger_id)
|
|
491
|
+
location, _ = Location.objects.get_or_create(name=auto_name)
|
|
463
492
|
self.location = location
|
|
464
493
|
if update_list is not None and "location" not in update_list:
|
|
465
494
|
update_list.append("location")
|
|
@@ -469,11 +498,17 @@ class Charger(Entity):
|
|
|
469
498
|
ref_value = self._full_url()
|
|
470
499
|
if url_targets_local_loopback(ref_value):
|
|
471
500
|
return
|
|
472
|
-
if not self.reference
|
|
501
|
+
if not self.reference:
|
|
473
502
|
self.reference = Reference.objects.create(
|
|
474
503
|
value=ref_value, alt_text=self.charger_id
|
|
475
504
|
)
|
|
476
505
|
super().save(update_fields=["reference"])
|
|
506
|
+
elif self.reference.value != ref_value:
|
|
507
|
+
Reference.objects.filter(pk=self.reference_id).update(
|
|
508
|
+
value=ref_value, alt_text=self.charger_id
|
|
509
|
+
)
|
|
510
|
+
self.reference.value = ref_value
|
|
511
|
+
self.reference.alt_text = self.charger_id
|
|
477
512
|
|
|
478
513
|
def refresh_manager_node(self, node: Node | None = None) -> Node | None:
|
|
479
514
|
"""Ensure ``manager_node`` matches the provided or local node."""
|
|
@@ -663,6 +698,14 @@ class ChargerConfiguration(models.Model):
|
|
|
663
698
|
blank=True,
|
|
664
699
|
help_text=_("Keys returned in the unknownKey list."),
|
|
665
700
|
)
|
|
701
|
+
evcs_snapshot_at = models.DateTimeField(
|
|
702
|
+
_("EVCS snapshot at"),
|
|
703
|
+
null=True,
|
|
704
|
+
blank=True,
|
|
705
|
+
help_text=_(
|
|
706
|
+
"Timestamp when this configuration was received from the charge point."
|
|
707
|
+
),
|
|
708
|
+
)
|
|
666
709
|
raw_payload = models.JSONField(
|
|
667
710
|
default=dict,
|
|
668
711
|
blank=True,
|
|
@@ -763,7 +806,11 @@ class Transaction(Entity):
|
|
|
763
806
|
def vehicle_identifier(self) -> str:
|
|
764
807
|
"""Return the preferred vehicle identifier for this transaction."""
|
|
765
808
|
|
|
766
|
-
|
|
809
|
+
vid = (self.vid or "").strip()
|
|
810
|
+
if vid:
|
|
811
|
+
return vid
|
|
812
|
+
|
|
813
|
+
return (self.vin or "").strip()
|
|
767
814
|
|
|
768
815
|
@property
|
|
769
816
|
def vehicle_identifier_source(self) -> str:
|
|
@@ -1023,6 +1070,8 @@ class DataTransferMessage(models.Model):
|
|
|
1023
1070
|
|
|
1024
1071
|
class Meta:
|
|
1025
1072
|
ordering = ["-created_at"]
|
|
1073
|
+
verbose_name = _("Data Message")
|
|
1074
|
+
verbose_name_plural = _("Data Messages")
|
|
1026
1075
|
indexes = [
|
|
1027
1076
|
models.Index(
|
|
1028
1077
|
fields=["ocpp_message_id"],
|
ocpp/tasks.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import logging
|
|
3
|
+
import uuid
|
|
2
4
|
from datetime import date, datetime, time, timedelta
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
|
|
7
|
+
from asgiref.sync import async_to_sync
|
|
5
8
|
from celery import shared_task
|
|
6
9
|
from django.conf import settings
|
|
7
10
|
from django.contrib.auth import get_user_model
|
|
@@ -11,11 +14,106 @@ from django.db.models import Q
|
|
|
11
14
|
from core import mailer
|
|
12
15
|
from nodes.models import Node
|
|
13
16
|
|
|
14
|
-
from .
|
|
17
|
+
from . import store
|
|
18
|
+
from .models import Charger, MeterValue, Transaction
|
|
15
19
|
|
|
16
20
|
logger = logging.getLogger(__name__)
|
|
17
21
|
|
|
18
22
|
|
|
23
|
+
@shared_task
|
|
24
|
+
def check_charge_point_configuration(charger_pk: int) -> bool:
|
|
25
|
+
"""Request the latest configuration from a connected charge point."""
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
charger = Charger.objects.get(pk=charger_pk)
|
|
29
|
+
except Charger.DoesNotExist:
|
|
30
|
+
logger.warning(
|
|
31
|
+
"Unable to request configuration for missing charger %s",
|
|
32
|
+
charger_pk,
|
|
33
|
+
)
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
connector_value = charger.connector_id
|
|
37
|
+
if connector_value is not None:
|
|
38
|
+
logger.debug(
|
|
39
|
+
"Skipping charger %s: connector %s is not eligible for automatic configuration checks",
|
|
40
|
+
charger.charger_id,
|
|
41
|
+
connector_value,
|
|
42
|
+
)
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
46
|
+
if ws is None:
|
|
47
|
+
logger.info(
|
|
48
|
+
"Charge point %s is not connected; configuration request skipped",
|
|
49
|
+
charger.charger_id,
|
|
50
|
+
)
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
message_id = uuid.uuid4().hex
|
|
54
|
+
payload: dict[str, object] = {}
|
|
55
|
+
msg = json.dumps([2, message_id, "GetConfiguration", payload])
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
async_to_sync(ws.send)(msg)
|
|
59
|
+
except Exception as exc: # pragma: no cover - network error
|
|
60
|
+
logger.warning(
|
|
61
|
+
"Failed to send GetConfiguration to %s (%s)",
|
|
62
|
+
charger.charger_id,
|
|
63
|
+
exc,
|
|
64
|
+
)
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
68
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
69
|
+
store.register_pending_call(
|
|
70
|
+
message_id,
|
|
71
|
+
{
|
|
72
|
+
"action": "GetConfiguration",
|
|
73
|
+
"charger_id": charger.charger_id,
|
|
74
|
+
"connector_id": connector_value,
|
|
75
|
+
"log_key": log_key,
|
|
76
|
+
"requested_at": timezone.now(),
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
store.schedule_call_timeout(
|
|
80
|
+
message_id,
|
|
81
|
+
timeout=5.0,
|
|
82
|
+
action="GetConfiguration",
|
|
83
|
+
log_key=log_key,
|
|
84
|
+
message=(
|
|
85
|
+
"GetConfiguration timed out: charger did not respond"
|
|
86
|
+
" (operation may not be supported)"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
logger.info(
|
|
90
|
+
"Requested configuration from charge point %s",
|
|
91
|
+
charger.charger_id,
|
|
92
|
+
)
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@shared_task
|
|
97
|
+
def schedule_daily_charge_point_configuration_checks() -> int:
|
|
98
|
+
"""Dispatch configuration requests for eligible charge points."""
|
|
99
|
+
|
|
100
|
+
charger_ids = list(
|
|
101
|
+
Charger.objects.filter(connector_id__isnull=True).values_list("pk", flat=True)
|
|
102
|
+
)
|
|
103
|
+
if not charger_ids:
|
|
104
|
+
logger.debug("No eligible charge points available for configuration check")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
scheduled = 0
|
|
108
|
+
for charger_pk in charger_ids:
|
|
109
|
+
check_charge_point_configuration.delay(charger_pk)
|
|
110
|
+
scheduled += 1
|
|
111
|
+
logger.info(
|
|
112
|
+
"Scheduled configuration checks for %s charge point(s)", scheduled
|
|
113
|
+
)
|
|
114
|
+
return scheduled
|
|
115
|
+
|
|
116
|
+
|
|
19
117
|
@shared_task
|
|
20
118
|
def purge_meter_values() -> int:
|
|
21
119
|
"""Delete meter values older than 7 days.
|
ocpp/tests.py
CHANGED
|
@@ -55,6 +55,7 @@ from django.contrib.sites.models import Site
|
|
|
55
55
|
from django.core.exceptions import ValidationError
|
|
56
56
|
from pages.models import Application, Module
|
|
57
57
|
from nodes.models import Node, NodeRole
|
|
58
|
+
from django.contrib.admin.sites import AdminSite
|
|
58
59
|
|
|
59
60
|
from config.asgi import application
|
|
60
61
|
|
|
@@ -67,8 +68,9 @@ from .models import (
|
|
|
67
68
|
Location,
|
|
68
69
|
DataTransferMessage,
|
|
69
70
|
)
|
|
71
|
+
from .admin import ChargerConfigurationAdmin
|
|
70
72
|
from .consumers import CSMSConsumer
|
|
71
|
-
from .views import dispatch_action, _transaction_rfid_details
|
|
73
|
+
from .views import dispatch_action, _transaction_rfid_details, _usage_timeline
|
|
72
74
|
from .status_display import STATUS_BADGE_MAP
|
|
73
75
|
from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
|
|
74
76
|
from . import store
|
|
@@ -81,7 +83,12 @@ from .simulator import SimulatorConfig, ChargePointSimulator
|
|
|
81
83
|
from .evcs import simulate, SimulatorState, _simulators
|
|
82
84
|
import re
|
|
83
85
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
84
|
-
from .tasks import
|
|
86
|
+
from .tasks import (
|
|
87
|
+
purge_meter_readings,
|
|
88
|
+
send_daily_session_report,
|
|
89
|
+
check_charge_point_configuration,
|
|
90
|
+
schedule_daily_charge_point_configuration_checks,
|
|
91
|
+
)
|
|
85
92
|
from django.db import close_old_connections
|
|
86
93
|
from django.db.utils import OperationalError
|
|
87
94
|
from urllib.parse import unquote, urlparse
|
|
@@ -170,6 +177,36 @@ class DispatchActionTests(TestCase):
|
|
|
170
177
|
self.assertEqual(metadata.get("trigger_target"), "BootNotification")
|
|
171
178
|
self.assertEqual(metadata.get("log_key"), log_key)
|
|
172
179
|
|
|
180
|
+
def test_reset_rejected_when_transaction_active(self):
|
|
181
|
+
charger = Charger.objects.create(charger_id="RESETBLOCK")
|
|
182
|
+
dummy = DummyWebSocket()
|
|
183
|
+
connection_key = store.set_connection(charger.charger_id, charger.connector_id, dummy)
|
|
184
|
+
self.addCleanup(lambda: store.connections.pop(connection_key, None))
|
|
185
|
+
tx_obj = Transaction.objects.create(
|
|
186
|
+
charger=charger,
|
|
187
|
+
connector_id=charger.connector_id,
|
|
188
|
+
start_time=timezone.now(),
|
|
189
|
+
)
|
|
190
|
+
tx_key = store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
|
|
191
|
+
self.addCleanup(lambda: store.transactions.pop(tx_key, None))
|
|
192
|
+
|
|
193
|
+
request = self.factory.post(
|
|
194
|
+
"/chargers/RESETBLOCK/action/",
|
|
195
|
+
data=json.dumps({"action": "reset"}),
|
|
196
|
+
content_type="application/json",
|
|
197
|
+
)
|
|
198
|
+
request.user = SimpleNamespace(
|
|
199
|
+
is_authenticated=True,
|
|
200
|
+
is_superuser=True,
|
|
201
|
+
is_staff=True,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
response = dispatch_action(request, charger.charger_id)
|
|
205
|
+
self.assertEqual(response.status_code, 409)
|
|
206
|
+
payload = json.loads(response.content.decode("utf-8"))
|
|
207
|
+
self.assertIn("stop the session first", payload.get("detail", "").lower())
|
|
208
|
+
self.assertFalse(dummy.sent)
|
|
209
|
+
|
|
173
210
|
class ChargerFixtureTests(TestCase):
|
|
174
211
|
fixtures = [
|
|
175
212
|
p.name
|
|
@@ -211,6 +248,62 @@ class ChargerFixtureTests(TestCase):
|
|
|
211
248
|
self.assertEqual(cp2.name, "Simulator #2")
|
|
212
249
|
|
|
213
250
|
|
|
251
|
+
class ChargerRefreshManagerNodeTests(TestCase):
|
|
252
|
+
@classmethod
|
|
253
|
+
def setUpTestData(cls):
|
|
254
|
+
local = Node.objects.create(
|
|
255
|
+
hostname="local-node",
|
|
256
|
+
address="127.0.0.1",
|
|
257
|
+
port=8000,
|
|
258
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
259
|
+
current_relation=Node.Relation.SELF,
|
|
260
|
+
)
|
|
261
|
+
Node.objects.filter(pk=local.pk).update(mac_address="AA:BB:CC:DD:EE:FF")
|
|
262
|
+
cls.local_node = Node.objects.get(pk=local.pk)
|
|
263
|
+
|
|
264
|
+
def test_refresh_manager_node_assigns_local_to_unsaved_charger(self):
|
|
265
|
+
charger = Charger(charger_id="UNSAVED")
|
|
266
|
+
|
|
267
|
+
with patch("nodes.models.Node.get_current_mac", return_value="aa:bb:cc:dd:ee:ff"):
|
|
268
|
+
result = charger.refresh_manager_node()
|
|
269
|
+
|
|
270
|
+
self.assertEqual(result, self.local_node)
|
|
271
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
272
|
+
|
|
273
|
+
def test_refresh_manager_node_updates_persisted_charger(self):
|
|
274
|
+
remote = Node.objects.create(
|
|
275
|
+
hostname="remote-node",
|
|
276
|
+
address="10.0.0.1",
|
|
277
|
+
port=9000,
|
|
278
|
+
mac_address="11:22:33:44:55:66",
|
|
279
|
+
)
|
|
280
|
+
charger = Charger.objects.create(
|
|
281
|
+
charger_id="PERSISTED",
|
|
282
|
+
manager_node=remote,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
charger.refresh_manager_node(node=self.local_node)
|
|
286
|
+
|
|
287
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
288
|
+
charger.refresh_from_db()
|
|
289
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
290
|
+
|
|
291
|
+
def test_refresh_manager_node_handles_missing_local_node(self):
|
|
292
|
+
remote = Node.objects.create(
|
|
293
|
+
hostname="existing-manager",
|
|
294
|
+
address="10.0.0.2",
|
|
295
|
+
port=9001,
|
|
296
|
+
mac_address="22:33:44:55:66:77",
|
|
297
|
+
)
|
|
298
|
+
charger = Charger(charger_id="NOLOCAL", manager_node=remote)
|
|
299
|
+
|
|
300
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
301
|
+
result = charger.refresh_manager_node()
|
|
302
|
+
|
|
303
|
+
self.assertIsNone(result)
|
|
304
|
+
self.assertEqual(charger.manager_node, remote)
|
|
305
|
+
|
|
306
|
+
|
|
214
307
|
class ChargerUrlFallbackTests(TestCase):
|
|
215
308
|
@override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
|
|
216
309
|
def test_reference_created_when_site_missing(self):
|
|
@@ -757,6 +850,7 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
757
850
|
)()
|
|
758
851
|
self.assertIsNotNone(configuration)
|
|
759
852
|
self.assertEqual(configuration.charger_identifier, "CFGRES")
|
|
853
|
+
self.assertIsNotNone(configuration.evcs_snapshot_at)
|
|
760
854
|
self.assertEqual(
|
|
761
855
|
configuration.configuration_keys,
|
|
762
856
|
[
|
|
@@ -2312,6 +2406,43 @@ class ChargerAdminTests(TestCase):
|
|
|
2312
2406
|
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2313
2407
|
store.clear_pending_calls(charger.charger_id)
|
|
2314
2408
|
|
|
2409
|
+
def test_reset_charger_action_skips_when_transaction_active(self):
|
|
2410
|
+
charger = Charger.objects.create(charger_id="RESETADMIN")
|
|
2411
|
+
|
|
2412
|
+
class DummyConnection:
|
|
2413
|
+
def __init__(self):
|
|
2414
|
+
self.sent: list[str] = []
|
|
2415
|
+
|
|
2416
|
+
async def send(self, message):
|
|
2417
|
+
self.sent.append(message)
|
|
2418
|
+
|
|
2419
|
+
ws = DummyConnection()
|
|
2420
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2421
|
+
tx_obj = Transaction.objects.create(
|
|
2422
|
+
charger=charger,
|
|
2423
|
+
connector_id=charger.connector_id,
|
|
2424
|
+
start_time=timezone.now(),
|
|
2425
|
+
)
|
|
2426
|
+
store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
|
|
2427
|
+
try:
|
|
2428
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2429
|
+
response = self.client.post(
|
|
2430
|
+
url,
|
|
2431
|
+
{
|
|
2432
|
+
"action": "reset_chargers",
|
|
2433
|
+
"index": 0,
|
|
2434
|
+
"select_across": 0,
|
|
2435
|
+
"_selected_action": [charger.pk],
|
|
2436
|
+
},
|
|
2437
|
+
follow=True,
|
|
2438
|
+
)
|
|
2439
|
+
self.assertEqual(response.status_code, 200)
|
|
2440
|
+
self.assertFalse(ws.sent)
|
|
2441
|
+
self.assertContains(response, "stop the session first")
|
|
2442
|
+
finally:
|
|
2443
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2444
|
+
store.pop_transaction(charger.charger_id, charger.connector_id)
|
|
2445
|
+
|
|
2315
2446
|
def test_admin_log_view_displays_entries(self):
|
|
2316
2447
|
charger = Charger.objects.create(charger_id="LOG2")
|
|
2317
2448
|
log_id = store.identity_key(charger.charger_id, charger.connector_id)
|
|
@@ -2614,6 +2745,81 @@ class ChargerAdminTests(TestCase):
|
|
|
2614
2745
|
store.clear_log(pending_key, log_type="charger")
|
|
2615
2746
|
|
|
2616
2747
|
|
|
2748
|
+
class ChargerConfigurationAdminUnitTests(TestCase):
|
|
2749
|
+
def setUp(self):
|
|
2750
|
+
self.admin = ChargerConfigurationAdmin(ChargerConfiguration, AdminSite())
|
|
2751
|
+
self.request_factory = RequestFactory()
|
|
2752
|
+
|
|
2753
|
+
def test_origin_display_returns_evcs_when_snapshot_present(self):
|
|
2754
|
+
configuration = ChargerConfiguration.objects.create(
|
|
2755
|
+
charger_identifier="CFG-EVCS",
|
|
2756
|
+
evcs_snapshot_at=timezone.now(),
|
|
2757
|
+
)
|
|
2758
|
+
self.assertEqual(self.admin.origin_display(configuration), "EVCS")
|
|
2759
|
+
|
|
2760
|
+
def test_origin_display_returns_local_without_snapshot(self):
|
|
2761
|
+
configuration = ChargerConfiguration.objects.create(
|
|
2762
|
+
charger_identifier="CFG-LOCAL",
|
|
2763
|
+
)
|
|
2764
|
+
self.assertEqual(self.admin.origin_display(configuration), "Local")
|
|
2765
|
+
|
|
2766
|
+
def test_save_model_resets_snapshot_timestamp(self):
|
|
2767
|
+
configuration = ChargerConfiguration.objects.create(
|
|
2768
|
+
charger_identifier="CFG-SAVE",
|
|
2769
|
+
evcs_snapshot_at=timezone.now(),
|
|
2770
|
+
)
|
|
2771
|
+
request = self.request_factory.post("/admin/ocpp/chargerconfiguration/")
|
|
2772
|
+
self.admin.save_model(request, configuration, form=None, change=True)
|
|
2773
|
+
configuration.refresh_from_db()
|
|
2774
|
+
self.assertIsNone(configuration.evcs_snapshot_at)
|
|
2775
|
+
|
|
2776
|
+
|
|
2777
|
+
class ConfigurationTaskTests(TestCase):
|
|
2778
|
+
def tearDown(self):
|
|
2779
|
+
store.pending_calls.clear()
|
|
2780
|
+
|
|
2781
|
+
def test_check_charge_point_configuration_dispatches_request(self):
|
|
2782
|
+
charger = Charger.objects.create(charger_id="TASKCFG")
|
|
2783
|
+
ws = DummyWebSocket()
|
|
2784
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2785
|
+
pending_key = store.pending_key(charger.charger_id)
|
|
2786
|
+
store.clear_log(log_key, log_type="charger")
|
|
2787
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2788
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2789
|
+
try:
|
|
2790
|
+
result = check_charge_point_configuration.run(charger.pk)
|
|
2791
|
+
self.assertTrue(result)
|
|
2792
|
+
self.assertEqual(len(ws.sent), 1)
|
|
2793
|
+
frame = json.loads(ws.sent[0])
|
|
2794
|
+
self.assertEqual(frame[0], 2)
|
|
2795
|
+
self.assertEqual(frame[2], "GetConfiguration")
|
|
2796
|
+
self.assertIn(frame[1], store.pending_calls)
|
|
2797
|
+
finally:
|
|
2798
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2799
|
+
store.pending_calls.clear()
|
|
2800
|
+
store.clear_log(log_key, log_type="charger")
|
|
2801
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2802
|
+
|
|
2803
|
+
def test_check_charge_point_configuration_without_connection(self):
|
|
2804
|
+
charger = Charger.objects.create(charger_id="TASKNOCONN")
|
|
2805
|
+
result = check_charge_point_configuration.run(charger.pk)
|
|
2806
|
+
self.assertFalse(result)
|
|
2807
|
+
|
|
2808
|
+
def test_schedule_daily_checks_only_includes_root_chargers(self):
|
|
2809
|
+
eligible = Charger.objects.create(charger_id="TASKROOT")
|
|
2810
|
+
Charger.objects.create(charger_id="TASKCONN", connector_id=1)
|
|
2811
|
+
with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
|
|
2812
|
+
scheduled = schedule_daily_charge_point_configuration_checks.run()
|
|
2813
|
+
self.assertEqual(scheduled, 1)
|
|
2814
|
+
mock_delay.assert_called_once_with(eligible.pk)
|
|
2815
|
+
|
|
2816
|
+
def test_schedule_daily_checks_returns_zero_without_chargers(self):
|
|
2817
|
+
with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
|
|
2818
|
+
scheduled = schedule_daily_charge_point_configuration_checks.run()
|
|
2819
|
+
self.assertEqual(scheduled, 0)
|
|
2820
|
+
mock_delay.assert_not_called()
|
|
2821
|
+
|
|
2822
|
+
|
|
2617
2823
|
class LocationAdminTests(TestCase):
|
|
2618
2824
|
def setUp(self):
|
|
2619
2825
|
self.client = Client()
|
|
@@ -2887,6 +3093,28 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2887
3093
|
|
|
2888
3094
|
await communicator.disconnect()
|
|
2889
3095
|
|
|
3096
|
+
def test_auto_registered_charger_location_name_sanitized(self):
|
|
3097
|
+
async def exercise():
|
|
3098
|
+
communicator = WebsocketCommunicator(
|
|
3099
|
+
application, "/?cid=ACME%20Charger%20%231"
|
|
3100
|
+
)
|
|
3101
|
+
connected, _ = await communicator.connect()
|
|
3102
|
+
self.assertTrue(connected)
|
|
3103
|
+
|
|
3104
|
+
await communicator.disconnect()
|
|
3105
|
+
|
|
3106
|
+
def fetch_location_name() -> str:
|
|
3107
|
+
charger = (
|
|
3108
|
+
Charger.objects.select_related("location")
|
|
3109
|
+
.get(charger_id="ACME Charger #1")
|
|
3110
|
+
)
|
|
3111
|
+
return charger.location.name
|
|
3112
|
+
|
|
3113
|
+
location_name = await database_sync_to_async(fetch_location_name)()
|
|
3114
|
+
self.assertEqual(location_name, "ACME_Charger_1")
|
|
3115
|
+
|
|
3116
|
+
async_to_sync(exercise)()
|
|
3117
|
+
|
|
2890
3118
|
async def test_query_string_cid_supported(self):
|
|
2891
3119
|
communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
|
|
2892
3120
|
connected, _ = await communicator.connect()
|
|
@@ -3324,6 +3552,10 @@ class ChargerLocationTests(TestCase):
|
|
|
3324
3552
|
second = Charger.objects.create(charger_id="SHARE", connector_id=2)
|
|
3325
3553
|
self.assertEqual(second.location, first.location)
|
|
3326
3554
|
|
|
3555
|
+
def test_location_name_sanitized_when_auto_created(self):
|
|
3556
|
+
charger = Charger.objects.create(charger_id="Name With spaces!#1")
|
|
3557
|
+
self.assertEqual(charger.location.name, "Name_With_spaces_1")
|
|
3558
|
+
|
|
3327
3559
|
|
|
3328
3560
|
class MeterReadingTests(TransactionTestCase):
|
|
3329
3561
|
async def test_meter_values_saved_as_readings(self):
|
|
@@ -4428,6 +4660,122 @@ class ChargerStatusViewTests(TestCase):
|
|
|
4428
4660
|
self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
|
|
4429
4661
|
store.transactions.pop(key, None)
|
|
4430
4662
|
|
|
4663
|
+
def test_usage_timeline_rendered_when_chart_unavailable(self):
|
|
4664
|
+
original_logs = store.logs["charger"]
|
|
4665
|
+
store.logs["charger"] = {}
|
|
4666
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
4667
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
4668
|
+
charger = Charger.objects.create(charger_id="TL1", connector_id=1)
|
|
4669
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
4670
|
+
|
|
4671
|
+
def build_entry(delta, status):
|
|
4672
|
+
timestamp = fixed_now - delta
|
|
4673
|
+
payload = {
|
|
4674
|
+
"connectorId": 1,
|
|
4675
|
+
"status": status,
|
|
4676
|
+
"timestamp": timestamp.isoformat(),
|
|
4677
|
+
}
|
|
4678
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4679
|
+
return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4680
|
+
|
|
4681
|
+
store.logs["charger"][log_key] = [
|
|
4682
|
+
build_entry(timedelta(days=2), "Available"),
|
|
4683
|
+
build_entry(timedelta(days=1), "Charging"),
|
|
4684
|
+
build_entry(timedelta(hours=12), "Available"),
|
|
4685
|
+
]
|
|
4686
|
+
|
|
4687
|
+
data, _window = _usage_timeline(charger, [], now=fixed_now)
|
|
4688
|
+
self.assertEqual(len(data), 1)
|
|
4689
|
+
statuses = {segment["status"] for segment in data[0]["segments"]}
|
|
4690
|
+
self.assertIn("charging", statuses)
|
|
4691
|
+
self.assertIn("available", statuses)
|
|
4692
|
+
|
|
4693
|
+
with patch("ocpp.views.timezone.now", return_value=fixed_now):
|
|
4694
|
+
resp = self.client.get(
|
|
4695
|
+
reverse(
|
|
4696
|
+
"charger-status-connector",
|
|
4697
|
+
args=[charger.charger_id, charger.connector_slug],
|
|
4698
|
+
)
|
|
4699
|
+
)
|
|
4700
|
+
|
|
4701
|
+
self.assertContains(resp, "Usage (last 7 days)")
|
|
4702
|
+
self.assertContains(resp, "usage-timeline-segment usage-charging")
|
|
4703
|
+
|
|
4704
|
+
def test_usage_timeline_includes_multiple_connectors(self):
|
|
4705
|
+
original_logs = store.logs["charger"]
|
|
4706
|
+
store.logs["charger"] = {}
|
|
4707
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
4708
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
4709
|
+
aggregate = Charger.objects.create(charger_id="TLAGG")
|
|
4710
|
+
connector_one = Charger.objects.create(charger_id="TLAGG", connector_id=1)
|
|
4711
|
+
connector_two = Charger.objects.create(charger_id="TLAGG", connector_id=2)
|
|
4712
|
+
|
|
4713
|
+
def build_entry(connector_id, delta, status):
|
|
4714
|
+
timestamp = fixed_now - delta
|
|
4715
|
+
payload = {
|
|
4716
|
+
"connectorId": connector_id,
|
|
4717
|
+
"status": status,
|
|
4718
|
+
"timestamp": timestamp.isoformat(),
|
|
4719
|
+
}
|
|
4720
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4721
|
+
key = store.identity_key(aggregate.charger_id, connector_id)
|
|
4722
|
+
store.logs["charger"].setdefault(key, []).append(
|
|
4723
|
+
f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4724
|
+
)
|
|
4725
|
+
|
|
4726
|
+
build_entry(1, timedelta(days=3), "Available")
|
|
4727
|
+
build_entry(2, timedelta(days=2), "Charging")
|
|
4728
|
+
|
|
4729
|
+
overview = [{"charger": connector_one}, {"charger": connector_two}]
|
|
4730
|
+
data, _window = _usage_timeline(aggregate, overview, now=fixed_now)
|
|
4731
|
+
self.assertEqual(len(data), 2)
|
|
4732
|
+
self.assertTrue(all(entry["segments"] for entry in data))
|
|
4733
|
+
|
|
4734
|
+
with patch("ocpp.views.timezone.now", return_value=fixed_now):
|
|
4735
|
+
resp = self.client.get(reverse("charger-status", args=[aggregate.charger_id]))
|
|
4736
|
+
|
|
4737
|
+
self.assertContains(resp, "Usage (last 7 days)")
|
|
4738
|
+
self.assertContains(resp, connector_one.connector_label)
|
|
4739
|
+
self.assertContains(resp, connector_two.connector_label)
|
|
4740
|
+
|
|
4741
|
+
def test_usage_timeline_merges_repeated_status_entries(self):
|
|
4742
|
+
original_logs = store.logs["charger"]
|
|
4743
|
+
store.logs["charger"] = {}
|
|
4744
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
4745
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
4746
|
+
charger = Charger.objects.create(
|
|
4747
|
+
charger_id="TLDEDUP",
|
|
4748
|
+
connector_id=1,
|
|
4749
|
+
last_status="Available",
|
|
4750
|
+
)
|
|
4751
|
+
|
|
4752
|
+
def build_entry(delta, status):
|
|
4753
|
+
timestamp = fixed_now - delta
|
|
4754
|
+
payload = {
|
|
4755
|
+
"connectorId": 1,
|
|
4756
|
+
"status": status,
|
|
4757
|
+
"timestamp": timestamp.isoformat(),
|
|
4758
|
+
}
|
|
4759
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4760
|
+
return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4761
|
+
|
|
4762
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
4763
|
+
store.logs["charger"][log_key] = [
|
|
4764
|
+
build_entry(timedelta(days=6, hours=12), "Available"),
|
|
4765
|
+
build_entry(timedelta(days=5), "Available"),
|
|
4766
|
+
build_entry(timedelta(days=3, hours=6), "Charging"),
|
|
4767
|
+
build_entry(timedelta(days=2), "Charging"),
|
|
4768
|
+
build_entry(timedelta(days=1), "Available"),
|
|
4769
|
+
]
|
|
4770
|
+
|
|
4771
|
+
data, window = _usage_timeline(charger, [], now=fixed_now)
|
|
4772
|
+
self.assertIsNotNone(window)
|
|
4773
|
+
self.assertEqual(len(data), 1)
|
|
4774
|
+
segments = data[0]["segments"]
|
|
4775
|
+
self.assertGreaterEqual(len(segments), 1)
|
|
4776
|
+
statuses = [segment["status"] for segment in segments]
|
|
4777
|
+
self.assertEqual(statuses, ["available", "charging", "available"])
|
|
4778
|
+
|
|
4431
4779
|
def test_diagnostics_status_displayed(self):
|
|
4432
4780
|
reported_at = timezone.now().replace(microsecond=0)
|
|
4433
4781
|
charger = Charger.objects.create(
|