arthexis 0.1.16__py3-none-any.whl → 0.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
ocpp/tests.py
CHANGED
|
@@ -55,19 +55,23 @@ 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
|
|
|
61
62
|
from .models import (
|
|
62
63
|
Transaction,
|
|
63
64
|
Charger,
|
|
65
|
+
ChargerConfiguration,
|
|
64
66
|
Simulator,
|
|
65
67
|
MeterReading,
|
|
66
68
|
Location,
|
|
67
69
|
DataTransferMessage,
|
|
70
|
+
CPReservation,
|
|
68
71
|
)
|
|
72
|
+
from .admin import ChargerConfigurationAdmin
|
|
69
73
|
from .consumers import CSMSConsumer
|
|
70
|
-
from .views import dispatch_action
|
|
74
|
+
from .views import dispatch_action, _transaction_rfid_details, _usage_timeline
|
|
71
75
|
from .status_display import STATUS_BADGE_MAP
|
|
72
76
|
from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
|
|
73
77
|
from . import store
|
|
@@ -80,7 +84,12 @@ from .simulator import SimulatorConfig, ChargePointSimulator
|
|
|
80
84
|
from .evcs import simulate, SimulatorState, _simulators
|
|
81
85
|
import re
|
|
82
86
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
83
|
-
from .tasks import
|
|
87
|
+
from .tasks import (
|
|
88
|
+
purge_meter_readings,
|
|
89
|
+
send_daily_session_report,
|
|
90
|
+
check_charge_point_configuration,
|
|
91
|
+
schedule_daily_charge_point_configuration_checks,
|
|
92
|
+
)
|
|
84
93
|
from django.db import close_old_connections
|
|
85
94
|
from django.db.utils import OperationalError
|
|
86
95
|
from urllib.parse import unquote, urlparse
|
|
@@ -169,6 +178,36 @@ class DispatchActionTests(TestCase):
|
|
|
169
178
|
self.assertEqual(metadata.get("trigger_target"), "BootNotification")
|
|
170
179
|
self.assertEqual(metadata.get("log_key"), log_key)
|
|
171
180
|
|
|
181
|
+
def test_reset_rejected_when_transaction_active(self):
|
|
182
|
+
charger = Charger.objects.create(charger_id="RESETBLOCK")
|
|
183
|
+
dummy = DummyWebSocket()
|
|
184
|
+
connection_key = store.set_connection(charger.charger_id, charger.connector_id, dummy)
|
|
185
|
+
self.addCleanup(lambda: store.connections.pop(connection_key, None))
|
|
186
|
+
tx_obj = Transaction.objects.create(
|
|
187
|
+
charger=charger,
|
|
188
|
+
connector_id=charger.connector_id,
|
|
189
|
+
start_time=timezone.now(),
|
|
190
|
+
)
|
|
191
|
+
tx_key = store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
|
|
192
|
+
self.addCleanup(lambda: store.transactions.pop(tx_key, None))
|
|
193
|
+
|
|
194
|
+
request = self.factory.post(
|
|
195
|
+
"/chargers/RESETBLOCK/action/",
|
|
196
|
+
data=json.dumps({"action": "reset"}),
|
|
197
|
+
content_type="application/json",
|
|
198
|
+
)
|
|
199
|
+
request.user = SimpleNamespace(
|
|
200
|
+
is_authenticated=True,
|
|
201
|
+
is_superuser=True,
|
|
202
|
+
is_staff=True,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
response = dispatch_action(request, charger.charger_id)
|
|
206
|
+
self.assertEqual(response.status_code, 409)
|
|
207
|
+
payload = json.loads(response.content.decode("utf-8"))
|
|
208
|
+
self.assertIn("stop the session first", payload.get("detail", "").lower())
|
|
209
|
+
self.assertFalse(dummy.sent)
|
|
210
|
+
|
|
172
211
|
class ChargerFixtureTests(TestCase):
|
|
173
212
|
fixtures = [
|
|
174
213
|
p.name
|
|
@@ -210,6 +249,240 @@ class ChargerFixtureTests(TestCase):
|
|
|
210
249
|
self.assertEqual(cp2.name, "Simulator #2")
|
|
211
250
|
|
|
212
251
|
|
|
252
|
+
class ChargerRefreshManagerNodeTests(TestCase):
|
|
253
|
+
@classmethod
|
|
254
|
+
def setUpTestData(cls):
|
|
255
|
+
local = Node.objects.create(
|
|
256
|
+
hostname="local-node",
|
|
257
|
+
address="127.0.0.1",
|
|
258
|
+
port=8000,
|
|
259
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
260
|
+
current_relation=Node.Relation.SELF,
|
|
261
|
+
)
|
|
262
|
+
Node.objects.filter(pk=local.pk).update(mac_address="AA:BB:CC:DD:EE:FF")
|
|
263
|
+
cls.local_node = Node.objects.get(pk=local.pk)
|
|
264
|
+
|
|
265
|
+
def test_refresh_manager_node_assigns_local_to_unsaved_charger(self):
|
|
266
|
+
charger = Charger(charger_id="UNSAVED")
|
|
267
|
+
|
|
268
|
+
with patch("nodes.models.Node.get_current_mac", return_value="aa:bb:cc:dd:ee:ff"):
|
|
269
|
+
result = charger.refresh_manager_node()
|
|
270
|
+
|
|
271
|
+
self.assertEqual(result, self.local_node)
|
|
272
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
273
|
+
|
|
274
|
+
def test_refresh_manager_node_updates_persisted_charger(self):
|
|
275
|
+
remote = Node.objects.create(
|
|
276
|
+
hostname="remote-node",
|
|
277
|
+
address="10.0.0.1",
|
|
278
|
+
port=9000,
|
|
279
|
+
mac_address="11:22:33:44:55:66",
|
|
280
|
+
)
|
|
281
|
+
charger = Charger.objects.create(
|
|
282
|
+
charger_id="PERSISTED",
|
|
283
|
+
manager_node=remote,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
charger.refresh_manager_node(node=self.local_node)
|
|
287
|
+
|
|
288
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
289
|
+
charger.refresh_from_db()
|
|
290
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
291
|
+
|
|
292
|
+
def test_refresh_manager_node_handles_missing_local_node(self):
|
|
293
|
+
remote = Node.objects.create(
|
|
294
|
+
hostname="existing-manager",
|
|
295
|
+
address="10.0.0.2",
|
|
296
|
+
port=9001,
|
|
297
|
+
mac_address="22:33:44:55:66:77",
|
|
298
|
+
)
|
|
299
|
+
charger = Charger(charger_id="NOLOCAL", manager_node=remote)
|
|
300
|
+
|
|
301
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
302
|
+
result = charger.refresh_manager_node()
|
|
303
|
+
|
|
304
|
+
self.assertIsNone(result)
|
|
305
|
+
self.assertEqual(charger.manager_node, remote)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class CPReservationTests(TransactionTestCase):
|
|
309
|
+
def setUp(self):
|
|
310
|
+
self.location = Location.objects.create(name="Reservation Site")
|
|
311
|
+
self.aggregate = Charger.objects.create(charger_id="RSV100", location=self.location)
|
|
312
|
+
self.connector_one = Charger.objects.create(
|
|
313
|
+
charger_id="RSV100", connector_id=1, location=self.location
|
|
314
|
+
)
|
|
315
|
+
self.connector_two = Charger.objects.create(
|
|
316
|
+
charger_id="RSV100", connector_id=2, location=self.location
|
|
317
|
+
)
|
|
318
|
+
self.addCleanup(store.clear_pending_calls, "RSV100")
|
|
319
|
+
|
|
320
|
+
def test_allocates_preferred_connector(self):
|
|
321
|
+
start = timezone.now() + timedelta(hours=1)
|
|
322
|
+
reservation = CPReservation(
|
|
323
|
+
location=self.location,
|
|
324
|
+
start_time=start,
|
|
325
|
+
duration_minutes=90,
|
|
326
|
+
id_tag="TAG001",
|
|
327
|
+
)
|
|
328
|
+
reservation.save()
|
|
329
|
+
self.assertEqual(reservation.connector, self.connector_two)
|
|
330
|
+
|
|
331
|
+
def test_allocation_falls_back_and_blocks_overlaps(self):
|
|
332
|
+
start = timezone.now() + timedelta(hours=1)
|
|
333
|
+
first = CPReservation.objects.create(
|
|
334
|
+
location=self.location,
|
|
335
|
+
start_time=start,
|
|
336
|
+
duration_minutes=60,
|
|
337
|
+
id_tag="TAG002",
|
|
338
|
+
)
|
|
339
|
+
self.assertEqual(first.connector, self.connector_two)
|
|
340
|
+
second = CPReservation(
|
|
341
|
+
location=self.location,
|
|
342
|
+
start_time=start + timedelta(minutes=15),
|
|
343
|
+
duration_minutes=60,
|
|
344
|
+
id_tag="TAG003",
|
|
345
|
+
)
|
|
346
|
+
second.save()
|
|
347
|
+
self.assertEqual(second.connector, self.connector_one)
|
|
348
|
+
third = CPReservation(
|
|
349
|
+
location=self.location,
|
|
350
|
+
start_time=start + timedelta(minutes=30),
|
|
351
|
+
duration_minutes=45,
|
|
352
|
+
id_tag="TAG004",
|
|
353
|
+
)
|
|
354
|
+
with self.assertRaises(ValidationError):
|
|
355
|
+
third.save()
|
|
356
|
+
|
|
357
|
+
def test_send_reservation_request_dispatches_frame(self):
|
|
358
|
+
start = timezone.now() + timedelta(hours=1)
|
|
359
|
+
reservation = CPReservation.objects.create(
|
|
360
|
+
location=self.location,
|
|
361
|
+
start_time=start,
|
|
362
|
+
duration_minutes=30,
|
|
363
|
+
id_tag="TAG005",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
class DummyConnection:
|
|
367
|
+
def __init__(self):
|
|
368
|
+
self.sent: list[str] = []
|
|
369
|
+
|
|
370
|
+
async def send(self, message):
|
|
371
|
+
self.sent.append(message)
|
|
372
|
+
|
|
373
|
+
ws = DummyConnection()
|
|
374
|
+
store.set_connection(
|
|
375
|
+
reservation.connector.charger_id,
|
|
376
|
+
reservation.connector.connector_id,
|
|
377
|
+
ws,
|
|
378
|
+
)
|
|
379
|
+
self.addCleanup(
|
|
380
|
+
store.pop_connection,
|
|
381
|
+
reservation.connector.charger_id,
|
|
382
|
+
reservation.connector.connector_id,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
message_id = reservation.send_reservation_request()
|
|
386
|
+
self.assertTrue(ws.sent)
|
|
387
|
+
frame = json.loads(ws.sent[0])
|
|
388
|
+
self.assertEqual(frame[0], 2)
|
|
389
|
+
self.assertEqual(frame[2], "ReserveNow")
|
|
390
|
+
self.assertEqual(frame[3]["reservationId"], reservation.pk)
|
|
391
|
+
self.assertEqual(frame[3]["connectorId"], reservation.connector.connector_id)
|
|
392
|
+
self.assertEqual(frame[3]["idTag"], "TAG005")
|
|
393
|
+
metadata = store.pending_calls.get(message_id)
|
|
394
|
+
self.assertIsNotNone(metadata)
|
|
395
|
+
self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
|
|
396
|
+
|
|
397
|
+
def test_call_result_marks_reservation_confirmed(self):
|
|
398
|
+
start = timezone.now() + timedelta(hours=1)
|
|
399
|
+
reservation = CPReservation.objects.create(
|
|
400
|
+
location=self.location,
|
|
401
|
+
start_time=start,
|
|
402
|
+
duration_minutes=45,
|
|
403
|
+
id_tag="TAG006",
|
|
404
|
+
)
|
|
405
|
+
log_key = store.identity_key(
|
|
406
|
+
reservation.connector.charger_id, reservation.connector.connector_id
|
|
407
|
+
)
|
|
408
|
+
message_id = "reserve-success"
|
|
409
|
+
store.register_pending_call(
|
|
410
|
+
message_id,
|
|
411
|
+
{
|
|
412
|
+
"action": "ReserveNow",
|
|
413
|
+
"charger_id": reservation.connector.charger_id,
|
|
414
|
+
"connector_id": reservation.connector.connector_id,
|
|
415
|
+
"log_key": log_key,
|
|
416
|
+
"reservation_pk": reservation.pk,
|
|
417
|
+
},
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
consumer = CSMSConsumer()
|
|
421
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
422
|
+
consumer.charger_id = reservation.connector.charger_id
|
|
423
|
+
consumer.store_key = log_key
|
|
424
|
+
consumer.connector_value = reservation.connector.connector_id
|
|
425
|
+
consumer.charger = reservation.connector
|
|
426
|
+
consumer.aggregate_charger = self.aggregate
|
|
427
|
+
consumer._consumption_task = None
|
|
428
|
+
consumer._consumption_message_uuid = None
|
|
429
|
+
consumer.send = AsyncMock()
|
|
430
|
+
|
|
431
|
+
async_to_sync(consumer._handle_call_result)(
|
|
432
|
+
message_id, {"status": "Accepted"}
|
|
433
|
+
)
|
|
434
|
+
reservation.refresh_from_db()
|
|
435
|
+
self.assertTrue(reservation.evcs_confirmed)
|
|
436
|
+
self.assertEqual(reservation.evcs_status, "Accepted")
|
|
437
|
+
self.assertIsNotNone(reservation.evcs_confirmed_at)
|
|
438
|
+
|
|
439
|
+
def test_call_error_updates_reservation_status(self):
|
|
440
|
+
start = timezone.now() + timedelta(hours=1)
|
|
441
|
+
reservation = CPReservation.objects.create(
|
|
442
|
+
location=self.location,
|
|
443
|
+
start_time=start,
|
|
444
|
+
duration_minutes=45,
|
|
445
|
+
id_tag="TAG007",
|
|
446
|
+
)
|
|
447
|
+
log_key = store.identity_key(
|
|
448
|
+
reservation.connector.charger_id, reservation.connector.connector_id
|
|
449
|
+
)
|
|
450
|
+
message_id = "reserve-error"
|
|
451
|
+
store.register_pending_call(
|
|
452
|
+
message_id,
|
|
453
|
+
{
|
|
454
|
+
"action": "ReserveNow",
|
|
455
|
+
"charger_id": reservation.connector.charger_id,
|
|
456
|
+
"connector_id": reservation.connector.connector_id,
|
|
457
|
+
"log_key": log_key,
|
|
458
|
+
"reservation_pk": reservation.pk,
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
consumer = CSMSConsumer()
|
|
463
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
464
|
+
consumer.charger_id = reservation.connector.charger_id
|
|
465
|
+
consumer.store_key = log_key
|
|
466
|
+
consumer.connector_value = reservation.connector.connector_id
|
|
467
|
+
consumer.charger = reservation.connector
|
|
468
|
+
consumer.aggregate_charger = self.aggregate
|
|
469
|
+
consumer._consumption_task = None
|
|
470
|
+
consumer._consumption_message_uuid = None
|
|
471
|
+
consumer.send = AsyncMock()
|
|
472
|
+
|
|
473
|
+
async_to_sync(consumer._handle_call_error)(
|
|
474
|
+
message_id,
|
|
475
|
+
"Rejected",
|
|
476
|
+
"Charger unavailable",
|
|
477
|
+
{"reason": "maintenance"},
|
|
478
|
+
)
|
|
479
|
+
reservation.refresh_from_db()
|
|
480
|
+
self.assertFalse(reservation.evcs_confirmed)
|
|
481
|
+
self.assertEqual(reservation.evcs_status, "")
|
|
482
|
+
self.assertIsNone(reservation.evcs_confirmed_at)
|
|
483
|
+
self.assertIn("Rejected", reservation.evcs_error or "")
|
|
484
|
+
|
|
485
|
+
|
|
213
486
|
class ChargerUrlFallbackTests(TestCase):
|
|
214
487
|
@override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
|
|
215
488
|
def test_reference_created_when_site_missing(self):
|
|
@@ -714,6 +987,13 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
714
987
|
connected, _ = await communicator.connect()
|
|
715
988
|
self.assertTrue(connected)
|
|
716
989
|
|
|
990
|
+
await database_sync_to_async(Charger.objects.get_or_create)(
|
|
991
|
+
charger_id="CFGRES", connector_id=1
|
|
992
|
+
)
|
|
993
|
+
await database_sync_to_async(Charger.objects.get_or_create)(
|
|
994
|
+
charger_id="CFGRES", connector_id=2
|
|
995
|
+
)
|
|
996
|
+
|
|
717
997
|
message_id = "cfg-result"
|
|
718
998
|
payload = {
|
|
719
999
|
"configurationKey": [
|
|
@@ -744,6 +1024,32 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
744
1024
|
)
|
|
745
1025
|
self.assertNotIn(message_id, store.pending_calls)
|
|
746
1026
|
|
|
1027
|
+
configuration = await database_sync_to_async(
|
|
1028
|
+
lambda: ChargerConfiguration.objects.order_by("-created_at").first()
|
|
1029
|
+
)()
|
|
1030
|
+
self.assertIsNotNone(configuration)
|
|
1031
|
+
self.assertEqual(configuration.charger_identifier, "CFGRES")
|
|
1032
|
+
self.assertIsNotNone(configuration.evcs_snapshot_at)
|
|
1033
|
+
self.assertEqual(
|
|
1034
|
+
configuration.configuration_keys,
|
|
1035
|
+
[
|
|
1036
|
+
{
|
|
1037
|
+
"key": "AllowOfflineTxForUnknownId",
|
|
1038
|
+
"value": "false",
|
|
1039
|
+
"readonly": True,
|
|
1040
|
+
}
|
|
1041
|
+
],
|
|
1042
|
+
)
|
|
1043
|
+
self.assertEqual(configuration.unknown_keys, [])
|
|
1044
|
+
config_ids = await database_sync_to_async(
|
|
1045
|
+
lambda: set(
|
|
1046
|
+
Charger.objects.filter(charger_id="CFGRES").values_list(
|
|
1047
|
+
"configuration_id", flat=True
|
|
1048
|
+
)
|
|
1049
|
+
)
|
|
1050
|
+
)()
|
|
1051
|
+
self.assertEqual(config_ids, {configuration.pk})
|
|
1052
|
+
|
|
747
1053
|
await communicator.disconnect()
|
|
748
1054
|
store.clear_log(log_key, log_type="charger")
|
|
749
1055
|
store.clear_log(pending_key, log_type="charger")
|
|
@@ -1109,7 +1415,7 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1109
1415
|
|
|
1110
1416
|
await communicator.disconnect()
|
|
1111
1417
|
|
|
1112
|
-
async def
|
|
1418
|
+
async def test_vid_populated_from_vin(self):
|
|
1113
1419
|
await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
|
|
1114
1420
|
communicator = WebsocketCommunicator(application, "/VINREC/")
|
|
1115
1421
|
connected, _ = await communicator.connect()
|
|
@@ -1124,7 +1430,29 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1124
1430
|
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1125
1431
|
pk=tx_id, charger__charger_id="VINREC"
|
|
1126
1432
|
)
|
|
1127
|
-
self.assertEqual(tx.
|
|
1433
|
+
self.assertEqual(tx.vid, "WP0ZZZ11111111111")
|
|
1434
|
+
self.assertEqual(tx.vehicle_identifier, "WP0ZZZ11111111111")
|
|
1435
|
+
self.assertEqual(tx.vehicle_identifier_source, "vid")
|
|
1436
|
+
|
|
1437
|
+
await communicator.disconnect()
|
|
1438
|
+
|
|
1439
|
+
async def test_vid_recorded(self):
|
|
1440
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="VIDREC")
|
|
1441
|
+
communicator = WebsocketCommunicator(application, "/VIDREC/")
|
|
1442
|
+
connected, _ = await communicator.connect()
|
|
1443
|
+
self.assertTrue(connected)
|
|
1444
|
+
|
|
1445
|
+
await communicator.send_json_to(
|
|
1446
|
+
[2, "1", "StartTransaction", {"meterStart": 1, "vid": "VID123456"}]
|
|
1447
|
+
)
|
|
1448
|
+
response = await communicator.receive_json_from()
|
|
1449
|
+
tx_id = response[2]["transactionId"]
|
|
1450
|
+
|
|
1451
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1452
|
+
pk=tx_id, charger__charger_id="VIDREC"
|
|
1453
|
+
)
|
|
1454
|
+
self.assertEqual(tx.vid, "VID123456")
|
|
1455
|
+
self.assertEqual(tx.rfid, "")
|
|
1128
1456
|
|
|
1129
1457
|
await communicator.disconnect()
|
|
1130
1458
|
|
|
@@ -1852,6 +2180,16 @@ class ChargerLandingTests(TestCase):
|
|
|
1852
2180
|
status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
|
|
1853
2181
|
self.assertContains(response, status_url)
|
|
1854
2182
|
|
|
2183
|
+
def test_charger_page_respects_language_configuration(self):
|
|
2184
|
+
charger = Charger.objects.create(charger_id="PAGE-DE", language="de")
|
|
2185
|
+
|
|
2186
|
+
response = self.client.get(reverse("charger-page", args=["PAGE-DE"]))
|
|
2187
|
+
|
|
2188
|
+
self.assertEqual(response.status_code, 200)
|
|
2189
|
+
self.assertEqual(response.context["LANGUAGE_CODE"], "de")
|
|
2190
|
+
self.assertContains(response, 'lang="de"')
|
|
2191
|
+
self.assertContains(response, 'data-preferred-language="de"')
|
|
2192
|
+
|
|
1855
2193
|
def test_status_page_renders(self):
|
|
1856
2194
|
charger = Charger.objects.create(charger_id="PAGE2")
|
|
1857
2195
|
resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
|
|
@@ -1931,6 +2269,27 @@ class ChargerLandingTests(TestCase):
|
|
|
1931
2269
|
finally:
|
|
1932
2270
|
store.transactions.pop(key, None)
|
|
1933
2271
|
|
|
2272
|
+
def test_public_page_shows_available_when_status_stale(self):
|
|
2273
|
+
charger = Charger.objects.create(
|
|
2274
|
+
charger_id="STALEPUB",
|
|
2275
|
+
last_status="Charging",
|
|
2276
|
+
)
|
|
2277
|
+
response = self.client.get(reverse("charger-page", args=["STALEPUB"]))
|
|
2278
|
+
self.assertEqual(response.status_code, 200)
|
|
2279
|
+
self.assertContains(
|
|
2280
|
+
response,
|
|
2281
|
+
'style="background-color: #0d6efd; color: #fff;">Available</span>',
|
|
2282
|
+
)
|
|
2283
|
+
|
|
2284
|
+
def test_admin_status_shows_available_when_status_stale(self):
|
|
2285
|
+
charger = Charger.objects.create(
|
|
2286
|
+
charger_id="STALEADM",
|
|
2287
|
+
last_status="Charging",
|
|
2288
|
+
)
|
|
2289
|
+
response = self.client.get(reverse("charger-status", args=["STALEADM"]))
|
|
2290
|
+
self.assertEqual(response.status_code, 200)
|
|
2291
|
+
self.assertContains(response, 'id="charger-state">Available</strong>')
|
|
2292
|
+
|
|
1934
2293
|
def test_public_status_shows_rfid_link_for_known_tag(self):
|
|
1935
2294
|
aggregate = Charger.objects.create(charger_id="PUBRFID")
|
|
1936
2295
|
connector = Charger.objects.create(
|
|
@@ -2057,7 +2416,10 @@ class ChargerLandingTests(TestCase):
|
|
|
2057
2416
|
log_id = store.identity_key("LOG1", None)
|
|
2058
2417
|
store.add_log(log_id, "hello", log_type="charger")
|
|
2059
2418
|
entry = store.get_logs(log_id, log_type="charger")[0]
|
|
2060
|
-
self.assertRegex(
|
|
2419
|
+
self.assertRegex(
|
|
2420
|
+
entry,
|
|
2421
|
+
r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} hello$",
|
|
2422
|
+
)
|
|
2061
2423
|
resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
|
|
2062
2424
|
self.assertEqual(resp.status_code, 200)
|
|
2063
2425
|
self.assertContains(resp, "hello")
|
|
@@ -2092,12 +2454,12 @@ class SimulatorLandingTests(TestCase):
|
|
|
2092
2454
|
@skip("Navigation links unavailable in test environment")
|
|
2093
2455
|
def test_simulator_app_link_in_nav(self):
|
|
2094
2456
|
resp = self.client.get(reverse("pages:index"))
|
|
2095
|
-
self.assertContains(resp, "/ocpp/")
|
|
2096
|
-
self.assertNotContains(resp, "/ocpp/simulator/")
|
|
2457
|
+
self.assertContains(resp, "/ocpp/cpms/dashboard/")
|
|
2458
|
+
self.assertNotContains(resp, "/ocpp/evcs/simulator/")
|
|
2097
2459
|
self.client.force_login(self.user)
|
|
2098
2460
|
resp = self.client.get(reverse("pages:index"))
|
|
2099
|
-
self.assertContains(resp, "/ocpp/")
|
|
2100
|
-
self.assertContains(resp, "/ocpp/simulator/")
|
|
2461
|
+
self.assertContains(resp, "/ocpp/cpms/dashboard/")
|
|
2462
|
+
self.assertContains(resp, "/ocpp/evcs/simulator/")
|
|
2101
2463
|
|
|
2102
2464
|
def test_cp_simulator_redirects_to_login(self):
|
|
2103
2465
|
response = self.client.get(reverse("cp-simulator"))
|
|
@@ -2129,6 +2491,32 @@ class ChargerAdminTests(TestCase):
|
|
|
2129
2491
|
resp = self.client.get(url)
|
|
2130
2492
|
self.assertNotContains(resp, charger.reference.image.url)
|
|
2131
2493
|
|
|
2494
|
+
def test_toggle_rfid_authentication_action_toggles_value(self):
|
|
2495
|
+
charger_requires = Charger.objects.create(
|
|
2496
|
+
charger_id="RFIDON", require_rfid=True
|
|
2497
|
+
)
|
|
2498
|
+
charger_optional = Charger.objects.create(
|
|
2499
|
+
charger_id="RFIDOFF", require_rfid=False
|
|
2500
|
+
)
|
|
2501
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2502
|
+
response = self.client.post(
|
|
2503
|
+
url,
|
|
2504
|
+
{
|
|
2505
|
+
"action": "toggle_rfid_authentication",
|
|
2506
|
+
"_selected_action": [
|
|
2507
|
+
charger_requires.pk,
|
|
2508
|
+
charger_optional.pk,
|
|
2509
|
+
],
|
|
2510
|
+
},
|
|
2511
|
+
follow=True,
|
|
2512
|
+
)
|
|
2513
|
+
self.assertEqual(response.status_code, 200)
|
|
2514
|
+
charger_requires.refresh_from_db()
|
|
2515
|
+
charger_optional.refresh_from_db()
|
|
2516
|
+
self.assertFalse(charger_requires.require_rfid)
|
|
2517
|
+
self.assertTrue(charger_optional.require_rfid)
|
|
2518
|
+
self.assertContains(response, "Updated RFID authentication")
|
|
2519
|
+
|
|
2132
2520
|
def test_admin_lists_log_link(self):
|
|
2133
2521
|
charger = Charger.objects.create(charger_id="LOG1")
|
|
2134
2522
|
url = reverse("admin:ocpp_charger_changelist")
|
|
@@ -2155,6 +2543,85 @@ class ChargerAdminTests(TestCase):
|
|
|
2155
2543
|
finally:
|
|
2156
2544
|
store.transactions.pop(key, None)
|
|
2157
2545
|
|
|
2546
|
+
def test_admin_status_shows_available_when_status_stale(self):
|
|
2547
|
+
charger = Charger.objects.create(
|
|
2548
|
+
charger_id="ADMINSTALE",
|
|
2549
|
+
last_status="Charging",
|
|
2550
|
+
)
|
|
2551
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2552
|
+
resp = self.client.get(url)
|
|
2553
|
+
available_label = force_str(STATUS_BADGE_MAP["available"][0])
|
|
2554
|
+
self.assertContains(resp, f">{available_label}<")
|
|
2555
|
+
|
|
2556
|
+
def test_recheck_charger_status_action_sends_trigger(self):
|
|
2557
|
+
charger = Charger.objects.create(charger_id="RECHECK1")
|
|
2558
|
+
|
|
2559
|
+
class DummyConnection:
|
|
2560
|
+
def __init__(self):
|
|
2561
|
+
self.sent: list[str] = []
|
|
2562
|
+
|
|
2563
|
+
async def send(self, message):
|
|
2564
|
+
self.sent.append(message)
|
|
2565
|
+
|
|
2566
|
+
ws = DummyConnection()
|
|
2567
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2568
|
+
try:
|
|
2569
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2570
|
+
response = self.client.post(
|
|
2571
|
+
url,
|
|
2572
|
+
{
|
|
2573
|
+
"action": "recheck_charger_status",
|
|
2574
|
+
"index": 0,
|
|
2575
|
+
"select_across": 0,
|
|
2576
|
+
"_selected_action": [charger.pk],
|
|
2577
|
+
},
|
|
2578
|
+
follow=True,
|
|
2579
|
+
)
|
|
2580
|
+
self.assertEqual(response.status_code, 200)
|
|
2581
|
+
self.assertTrue(ws.sent)
|
|
2582
|
+
self.assertIn("TriggerMessage", ws.sent[0])
|
|
2583
|
+
self.assertContains(response, "Requested status update")
|
|
2584
|
+
finally:
|
|
2585
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2586
|
+
store.clear_pending_calls(charger.charger_id)
|
|
2587
|
+
|
|
2588
|
+
def test_reset_charger_action_skips_when_transaction_active(self):
|
|
2589
|
+
charger = Charger.objects.create(charger_id="RESETADMIN")
|
|
2590
|
+
|
|
2591
|
+
class DummyConnection:
|
|
2592
|
+
def __init__(self):
|
|
2593
|
+
self.sent: list[str] = []
|
|
2594
|
+
|
|
2595
|
+
async def send(self, message):
|
|
2596
|
+
self.sent.append(message)
|
|
2597
|
+
|
|
2598
|
+
ws = DummyConnection()
|
|
2599
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2600
|
+
tx_obj = Transaction.objects.create(
|
|
2601
|
+
charger=charger,
|
|
2602
|
+
connector_id=charger.connector_id,
|
|
2603
|
+
start_time=timezone.now(),
|
|
2604
|
+
)
|
|
2605
|
+
store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
|
|
2606
|
+
try:
|
|
2607
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2608
|
+
response = self.client.post(
|
|
2609
|
+
url,
|
|
2610
|
+
{
|
|
2611
|
+
"action": "reset_chargers",
|
|
2612
|
+
"index": 0,
|
|
2613
|
+
"select_across": 0,
|
|
2614
|
+
"_selected_action": [charger.pk],
|
|
2615
|
+
},
|
|
2616
|
+
follow=True,
|
|
2617
|
+
)
|
|
2618
|
+
self.assertEqual(response.status_code, 200)
|
|
2619
|
+
self.assertFalse(ws.sent)
|
|
2620
|
+
self.assertContains(response, "stop the session first")
|
|
2621
|
+
finally:
|
|
2622
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2623
|
+
store.pop_transaction(charger.charger_id, charger.connector_id)
|
|
2624
|
+
|
|
2158
2625
|
def test_admin_log_view_displays_entries(self):
|
|
2159
2626
|
charger = Charger.objects.create(charger_id="LOG2")
|
|
2160
2627
|
log_id = store.identity_key(charger.charger_id, charger.connector_id)
|
|
@@ -2178,6 +2645,36 @@ class ChargerAdminTests(TestCase):
|
|
|
2178
2645
|
resp = self.client.get(url)
|
|
2179
2646
|
self.assertContains(resp, "AdminLoc")
|
|
2180
2647
|
|
|
2648
|
+
def test_admin_changelist_displays_quick_stats(self):
|
|
2649
|
+
charger = Charger.objects.create(charger_id="STATMAIN", display_name="Main EVCS")
|
|
2650
|
+
connector = Charger.objects.create(
|
|
2651
|
+
charger_id="STATMAIN", connector_id=1, display_name="Connector 1"
|
|
2652
|
+
)
|
|
2653
|
+
start = timezone.now() - timedelta(minutes=30)
|
|
2654
|
+
Transaction.objects.create(
|
|
2655
|
+
charger=connector,
|
|
2656
|
+
start_time=start,
|
|
2657
|
+
stop_time=start + timedelta(minutes=10),
|
|
2658
|
+
meter_start=1000,
|
|
2659
|
+
meter_stop=6000,
|
|
2660
|
+
)
|
|
2661
|
+
|
|
2662
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2663
|
+
resp = self.client.get(url)
|
|
2664
|
+
|
|
2665
|
+
self.assertContains(resp, "Total kW")
|
|
2666
|
+
self.assertContains(resp, "Today kW")
|
|
2667
|
+
self.assertContains(resp, "5.00")
|
|
2668
|
+
|
|
2669
|
+
def test_admin_changelist_does_not_indent_connectors(self):
|
|
2670
|
+
Charger.objects.create(charger_id="INDENTMAIN")
|
|
2671
|
+
Charger.objects.create(charger_id="INDENTMAIN", connector_id=1)
|
|
2672
|
+
|
|
2673
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2674
|
+
resp = self.client.get(url)
|
|
2675
|
+
|
|
2676
|
+
self.assertNotContains(resp, 'class="charger-connector-entry"')
|
|
2677
|
+
|
|
2181
2678
|
def test_last_fields_are_read_only(self):
|
|
2182
2679
|
now = timezone.now()
|
|
2183
2680
|
charger = Charger.objects.create(
|
|
@@ -2427,6 +2924,81 @@ class ChargerAdminTests(TestCase):
|
|
|
2427
2924
|
store.clear_log(pending_key, log_type="charger")
|
|
2428
2925
|
|
|
2429
2926
|
|
|
2927
|
+
class ChargerConfigurationAdminUnitTests(TestCase):
|
|
2928
|
+
def setUp(self):
|
|
2929
|
+
self.admin = ChargerConfigurationAdmin(ChargerConfiguration, AdminSite())
|
|
2930
|
+
self.request_factory = RequestFactory()
|
|
2931
|
+
|
|
2932
|
+
def test_origin_display_returns_evcs_when_snapshot_present(self):
|
|
2933
|
+
configuration = ChargerConfiguration.objects.create(
|
|
2934
|
+
charger_identifier="CFG-EVCS",
|
|
2935
|
+
evcs_snapshot_at=timezone.now(),
|
|
2936
|
+
)
|
|
2937
|
+
self.assertEqual(self.admin.origin_display(configuration), "EVCS")
|
|
2938
|
+
|
|
2939
|
+
def test_origin_display_returns_local_without_snapshot(self):
|
|
2940
|
+
configuration = ChargerConfiguration.objects.create(
|
|
2941
|
+
charger_identifier="CFG-LOCAL",
|
|
2942
|
+
)
|
|
2943
|
+
self.assertEqual(self.admin.origin_display(configuration), "Local")
|
|
2944
|
+
|
|
2945
|
+
def test_save_model_resets_snapshot_timestamp(self):
|
|
2946
|
+
configuration = ChargerConfiguration.objects.create(
|
|
2947
|
+
charger_identifier="CFG-SAVE",
|
|
2948
|
+
evcs_snapshot_at=timezone.now(),
|
|
2949
|
+
)
|
|
2950
|
+
request = self.request_factory.post("/admin/ocpp/chargerconfiguration/")
|
|
2951
|
+
self.admin.save_model(request, configuration, form=None, change=True)
|
|
2952
|
+
configuration.refresh_from_db()
|
|
2953
|
+
self.assertIsNone(configuration.evcs_snapshot_at)
|
|
2954
|
+
|
|
2955
|
+
|
|
2956
|
+
class ConfigurationTaskTests(TestCase):
|
|
2957
|
+
def tearDown(self):
|
|
2958
|
+
store.pending_calls.clear()
|
|
2959
|
+
|
|
2960
|
+
def test_check_charge_point_configuration_dispatches_request(self):
|
|
2961
|
+
charger = Charger.objects.create(charger_id="TASKCFG")
|
|
2962
|
+
ws = DummyWebSocket()
|
|
2963
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2964
|
+
pending_key = store.pending_key(charger.charger_id)
|
|
2965
|
+
store.clear_log(log_key, log_type="charger")
|
|
2966
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2967
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2968
|
+
try:
|
|
2969
|
+
result = check_charge_point_configuration.run(charger.pk)
|
|
2970
|
+
self.assertTrue(result)
|
|
2971
|
+
self.assertEqual(len(ws.sent), 1)
|
|
2972
|
+
frame = json.loads(ws.sent[0])
|
|
2973
|
+
self.assertEqual(frame[0], 2)
|
|
2974
|
+
self.assertEqual(frame[2], "GetConfiguration")
|
|
2975
|
+
self.assertIn(frame[1], store.pending_calls)
|
|
2976
|
+
finally:
|
|
2977
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2978
|
+
store.pending_calls.clear()
|
|
2979
|
+
store.clear_log(log_key, log_type="charger")
|
|
2980
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2981
|
+
|
|
2982
|
+
def test_check_charge_point_configuration_without_connection(self):
|
|
2983
|
+
charger = Charger.objects.create(charger_id="TASKNOCONN")
|
|
2984
|
+
result = check_charge_point_configuration.run(charger.pk)
|
|
2985
|
+
self.assertFalse(result)
|
|
2986
|
+
|
|
2987
|
+
def test_schedule_daily_checks_only_includes_root_chargers(self):
|
|
2988
|
+
eligible = Charger.objects.create(charger_id="TASKROOT")
|
|
2989
|
+
Charger.objects.create(charger_id="TASKCONN", connector_id=1)
|
|
2990
|
+
with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
|
|
2991
|
+
scheduled = schedule_daily_charge_point_configuration_checks.run()
|
|
2992
|
+
self.assertEqual(scheduled, 1)
|
|
2993
|
+
mock_delay.assert_called_once_with(eligible.pk)
|
|
2994
|
+
|
|
2995
|
+
def test_schedule_daily_checks_returns_zero_without_chargers(self):
|
|
2996
|
+
with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
|
|
2997
|
+
scheduled = schedule_daily_charge_point_configuration_checks.run()
|
|
2998
|
+
self.assertEqual(scheduled, 0)
|
|
2999
|
+
mock_delay.assert_not_called()
|
|
3000
|
+
|
|
3001
|
+
|
|
2430
3002
|
class LocationAdminTests(TestCase):
|
|
2431
3003
|
def setUp(self):
|
|
2432
3004
|
self.client = Client()
|
|
@@ -2700,6 +3272,28 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2700
3272
|
|
|
2701
3273
|
await communicator.disconnect()
|
|
2702
3274
|
|
|
3275
|
+
def test_auto_registered_charger_location_name_sanitized(self):
|
|
3276
|
+
async def exercise():
|
|
3277
|
+
communicator = WebsocketCommunicator(
|
|
3278
|
+
application, "/?cid=ACME%20Charger%20%231"
|
|
3279
|
+
)
|
|
3280
|
+
connected, _ = await communicator.connect()
|
|
3281
|
+
self.assertTrue(connected)
|
|
3282
|
+
|
|
3283
|
+
await communicator.disconnect()
|
|
3284
|
+
|
|
3285
|
+
def fetch_location_name() -> str:
|
|
3286
|
+
charger = (
|
|
3287
|
+
Charger.objects.select_related("location")
|
|
3288
|
+
.get(charger_id="ACME Charger #1")
|
|
3289
|
+
)
|
|
3290
|
+
return charger.location.name
|
|
3291
|
+
|
|
3292
|
+
location_name = await database_sync_to_async(fetch_location_name)()
|
|
3293
|
+
self.assertEqual(location_name, "ACME_Charger_1")
|
|
3294
|
+
|
|
3295
|
+
async_to_sync(exercise)()
|
|
3296
|
+
|
|
2703
3297
|
async def test_query_string_cid_supported(self):
|
|
2704
3298
|
communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
|
|
2705
3299
|
connected, _ = await communicator.connect()
|
|
@@ -2860,6 +3454,44 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2860
3454
|
|
|
2861
3455
|
await communicator.disconnect()
|
|
2862
3456
|
|
|
3457
|
+
async def test_authorize_requires_rfid_accepts_allowed_tag_without_account(self):
|
|
3458
|
+
charger_id = "AUTHWARN"
|
|
3459
|
+
tag_value = "WARN01"
|
|
3460
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
3461
|
+
charger_id=charger_id, require_rfid=True
|
|
3462
|
+
)
|
|
3463
|
+
await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
|
|
3464
|
+
|
|
3465
|
+
pending_key = store.pending_key(charger_id)
|
|
3466
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3467
|
+
|
|
3468
|
+
communicator = WebsocketCommunicator(application, f"/{charger_id}/")
|
|
3469
|
+
connected, _ = await communicator.connect()
|
|
3470
|
+
self.assertTrue(connected)
|
|
3471
|
+
|
|
3472
|
+
message_id = "auth-unlinked"
|
|
3473
|
+
await communicator.send_json_to(
|
|
3474
|
+
[2, message_id, "Authorize", {"idTag": tag_value}]
|
|
3475
|
+
)
|
|
3476
|
+
response = await communicator.receive_json_from()
|
|
3477
|
+
self.assertEqual(response[0], 3)
|
|
3478
|
+
self.assertEqual(response[1], message_id)
|
|
3479
|
+
self.assertEqual(response[2], {"idTagInfo": {"status": "Accepted"}})
|
|
3480
|
+
|
|
3481
|
+
log_entries = store.get_logs(pending_key, log_type="charger")
|
|
3482
|
+
self.assertTrue(
|
|
3483
|
+
any(
|
|
3484
|
+
"Authorized RFID" in entry
|
|
3485
|
+
and tag_value in entry
|
|
3486
|
+
and charger_id in entry
|
|
3487
|
+
for entry in log_entries
|
|
3488
|
+
),
|
|
3489
|
+
log_entries,
|
|
3490
|
+
)
|
|
3491
|
+
|
|
3492
|
+
await communicator.disconnect()
|
|
3493
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3494
|
+
|
|
2863
3495
|
async def test_authorize_without_requirement_records_rfid(self):
|
|
2864
3496
|
await database_sync_to_async(Charger.objects.create)(
|
|
2865
3497
|
charger_id="AUTHOPT", require_rfid=False
|
|
@@ -2952,6 +3584,61 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2952
3584
|
)
|
|
2953
3585
|
self.assertEqual(tx.account_id, user.energy_account.id)
|
|
2954
3586
|
|
|
3587
|
+
async def test_start_transaction_allows_allowed_tag_without_account(self):
|
|
3588
|
+
charger_id = "STARTWARN"
|
|
3589
|
+
tag_value = "WARN02"
|
|
3590
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
3591
|
+
charger_id=charger_id, require_rfid=True
|
|
3592
|
+
)
|
|
3593
|
+
await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
|
|
3594
|
+
|
|
3595
|
+
pending_key = store.pending_key(charger_id)
|
|
3596
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3597
|
+
|
|
3598
|
+
communicator = WebsocketCommunicator(application, f"/{charger_id}/")
|
|
3599
|
+
connected, _ = await communicator.connect()
|
|
3600
|
+
self.assertTrue(connected)
|
|
3601
|
+
|
|
3602
|
+
start_payload = {
|
|
3603
|
+
"meterStart": 5,
|
|
3604
|
+
"idTag": tag_value,
|
|
3605
|
+
"connectorId": 1,
|
|
3606
|
+
}
|
|
3607
|
+
await communicator.send_json_to([2, "start-1", "StartTransaction", start_payload])
|
|
3608
|
+
response = await communicator.receive_json_from()
|
|
3609
|
+
self.assertEqual(response[0], 3)
|
|
3610
|
+
self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
|
|
3611
|
+
tx_id = response[2]["transactionId"]
|
|
3612
|
+
|
|
3613
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
3614
|
+
pk=tx_id, charger__charger_id=charger_id
|
|
3615
|
+
)
|
|
3616
|
+
self.assertIsNone(tx.account_id)
|
|
3617
|
+
|
|
3618
|
+
log_entries = store.get_logs(pending_key, log_type="charger")
|
|
3619
|
+
self.assertTrue(
|
|
3620
|
+
any(
|
|
3621
|
+
"Authorized RFID" in entry
|
|
3622
|
+
and tag_value in entry
|
|
3623
|
+
and charger_id in entry
|
|
3624
|
+
for entry in log_entries
|
|
3625
|
+
),
|
|
3626
|
+
log_entries,
|
|
3627
|
+
)
|
|
3628
|
+
|
|
3629
|
+
await communicator.send_json_to(
|
|
3630
|
+
[
|
|
3631
|
+
2,
|
|
3632
|
+
"stop-1",
|
|
3633
|
+
"StopTransaction",
|
|
3634
|
+
{"transactionId": tx_id, "meterStop": 6},
|
|
3635
|
+
]
|
|
3636
|
+
)
|
|
3637
|
+
await communicator.receive_json_from()
|
|
3638
|
+
|
|
3639
|
+
await communicator.disconnect()
|
|
3640
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3641
|
+
|
|
2955
3642
|
async def test_status_fields_updated(self):
|
|
2956
3643
|
communicator = WebsocketCommunicator(application, "/STAT/")
|
|
2957
3644
|
connected, _ = await communicator.connect()
|
|
@@ -3017,8 +3704,7 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
3017
3704
|
self.assertIsNotNone(aggregate.last_heartbeat)
|
|
3018
3705
|
if previous_heartbeat:
|
|
3019
3706
|
self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
|
|
3020
|
-
|
|
3021
|
-
self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
|
|
3707
|
+
self.assertEqual(connector.last_heartbeat, aggregate.last_heartbeat)
|
|
3022
3708
|
|
|
3023
3709
|
await communicator.disconnect()
|
|
3024
3710
|
|
|
@@ -3045,6 +3731,10 @@ class ChargerLocationTests(TestCase):
|
|
|
3045
3731
|
second = Charger.objects.create(charger_id="SHARE", connector_id=2)
|
|
3046
3732
|
self.assertEqual(second.location, first.location)
|
|
3047
3733
|
|
|
3734
|
+
def test_location_name_sanitized_when_auto_created(self):
|
|
3735
|
+
charger = Charger.objects.create(charger_id="Name With spaces!#1")
|
|
3736
|
+
self.assertEqual(charger.location.name, "Name_With_spaces_1")
|
|
3737
|
+
|
|
3048
3738
|
|
|
3049
3739
|
class MeterReadingTests(TransactionTestCase):
|
|
3050
3740
|
async def test_meter_values_saved_as_readings(self):
|
|
@@ -3814,6 +4504,43 @@ class TransactionKwTests(TestCase):
|
|
|
3814
4504
|
self.assertEqual(tx.kw, 0.0)
|
|
3815
4505
|
|
|
3816
4506
|
|
|
4507
|
+
class TransactionIdentifierTests(TestCase):
|
|
4508
|
+
def test_vehicle_identifier_prefers_vid(self):
|
|
4509
|
+
charger = Charger.objects.create(charger_id="VIDPREF")
|
|
4510
|
+
tx = Transaction.objects.create(
|
|
4511
|
+
charger=charger,
|
|
4512
|
+
start_time=timezone.now(),
|
|
4513
|
+
vid="VID-123",
|
|
4514
|
+
vin="VIN-456",
|
|
4515
|
+
)
|
|
4516
|
+
self.assertEqual(tx.vehicle_identifier, "VID-123")
|
|
4517
|
+
self.assertEqual(tx.vehicle_identifier_source, "vid")
|
|
4518
|
+
|
|
4519
|
+
def test_vehicle_identifier_falls_back_to_vin(self):
|
|
4520
|
+
charger = Charger.objects.create(charger_id="VINONLY")
|
|
4521
|
+
tx = Transaction.objects.create(
|
|
4522
|
+
charger=charger,
|
|
4523
|
+
start_time=timezone.now(),
|
|
4524
|
+
vin="WP0ZZZ00000000001",
|
|
4525
|
+
)
|
|
4526
|
+
self.assertEqual(tx.vehicle_identifier, "WP0ZZZ00000000001")
|
|
4527
|
+
self.assertEqual(tx.vehicle_identifier_source, "vin")
|
|
4528
|
+
|
|
4529
|
+
def test_transaction_rfid_details_handles_vin(self):
|
|
4530
|
+
charger = Charger.objects.create(charger_id="VINDET")
|
|
4531
|
+
tx = Transaction.objects.create(
|
|
4532
|
+
charger=charger,
|
|
4533
|
+
start_time=timezone.now(),
|
|
4534
|
+
vin="WAUZZZ00000000002",
|
|
4535
|
+
)
|
|
4536
|
+
details = _transaction_rfid_details(tx, cache={})
|
|
4537
|
+
self.assertIsNotNone(details)
|
|
4538
|
+
assert details is not None # for type checkers
|
|
4539
|
+
self.assertEqual(details["value"], "WAUZZZ00000000002")
|
|
4540
|
+
self.assertEqual(details["display_label"], "VIN")
|
|
4541
|
+
self.assertEqual(details["type"], "vin")
|
|
4542
|
+
|
|
4543
|
+
|
|
3817
4544
|
class DispatchActionViewTests(TestCase):
|
|
3818
4545
|
def setUp(self):
|
|
3819
4546
|
self.client = Client()
|
|
@@ -4112,6 +4839,122 @@ class ChargerStatusViewTests(TestCase):
|
|
|
4112
4839
|
self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
|
|
4113
4840
|
store.transactions.pop(key, None)
|
|
4114
4841
|
|
|
4842
|
+
def test_usage_timeline_rendered_when_chart_unavailable(self):
|
|
4843
|
+
original_logs = store.logs["charger"]
|
|
4844
|
+
store.logs["charger"] = {}
|
|
4845
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
4846
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
4847
|
+
charger = Charger.objects.create(charger_id="TL1", connector_id=1)
|
|
4848
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
4849
|
+
|
|
4850
|
+
def build_entry(delta, status):
|
|
4851
|
+
timestamp = fixed_now - delta
|
|
4852
|
+
payload = {
|
|
4853
|
+
"connectorId": 1,
|
|
4854
|
+
"status": status,
|
|
4855
|
+
"timestamp": timestamp.isoformat(),
|
|
4856
|
+
}
|
|
4857
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4858
|
+
return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4859
|
+
|
|
4860
|
+
store.logs["charger"][log_key] = [
|
|
4861
|
+
build_entry(timedelta(days=2), "Available"),
|
|
4862
|
+
build_entry(timedelta(days=1), "Charging"),
|
|
4863
|
+
build_entry(timedelta(hours=12), "Available"),
|
|
4864
|
+
]
|
|
4865
|
+
|
|
4866
|
+
data, _window = _usage_timeline(charger, [], now=fixed_now)
|
|
4867
|
+
self.assertEqual(len(data), 1)
|
|
4868
|
+
statuses = {segment["status"] for segment in data[0]["segments"]}
|
|
4869
|
+
self.assertIn("charging", statuses)
|
|
4870
|
+
self.assertIn("available", statuses)
|
|
4871
|
+
|
|
4872
|
+
with patch("ocpp.views.timezone.now", return_value=fixed_now):
|
|
4873
|
+
resp = self.client.get(
|
|
4874
|
+
reverse(
|
|
4875
|
+
"charger-status-connector",
|
|
4876
|
+
args=[charger.charger_id, charger.connector_slug],
|
|
4877
|
+
)
|
|
4878
|
+
)
|
|
4879
|
+
|
|
4880
|
+
self.assertContains(resp, "Usage (last 7 days)")
|
|
4881
|
+
self.assertContains(resp, "usage-timeline-segment usage-charging")
|
|
4882
|
+
|
|
4883
|
+
def test_usage_timeline_includes_multiple_connectors(self):
|
|
4884
|
+
original_logs = store.logs["charger"]
|
|
4885
|
+
store.logs["charger"] = {}
|
|
4886
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
4887
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
4888
|
+
aggregate = Charger.objects.create(charger_id="TLAGG")
|
|
4889
|
+
connector_one = Charger.objects.create(charger_id="TLAGG", connector_id=1)
|
|
4890
|
+
connector_two = Charger.objects.create(charger_id="TLAGG", connector_id=2)
|
|
4891
|
+
|
|
4892
|
+
def build_entry(connector_id, delta, status):
|
|
4893
|
+
timestamp = fixed_now - delta
|
|
4894
|
+
payload = {
|
|
4895
|
+
"connectorId": connector_id,
|
|
4896
|
+
"status": status,
|
|
4897
|
+
"timestamp": timestamp.isoformat(),
|
|
4898
|
+
}
|
|
4899
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4900
|
+
key = store.identity_key(aggregate.charger_id, connector_id)
|
|
4901
|
+
store.logs["charger"].setdefault(key, []).append(
|
|
4902
|
+
f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4903
|
+
)
|
|
4904
|
+
|
|
4905
|
+
build_entry(1, timedelta(days=3), "Available")
|
|
4906
|
+
build_entry(2, timedelta(days=2), "Charging")
|
|
4907
|
+
|
|
4908
|
+
overview = [{"charger": connector_one}, {"charger": connector_two}]
|
|
4909
|
+
data, _window = _usage_timeline(aggregate, overview, now=fixed_now)
|
|
4910
|
+
self.assertEqual(len(data), 2)
|
|
4911
|
+
self.assertTrue(all(entry["segments"] for entry in data))
|
|
4912
|
+
|
|
4913
|
+
with patch("ocpp.views.timezone.now", return_value=fixed_now):
|
|
4914
|
+
resp = self.client.get(reverse("charger-status", args=[aggregate.charger_id]))
|
|
4915
|
+
|
|
4916
|
+
self.assertContains(resp, "Usage (last 7 days)")
|
|
4917
|
+
self.assertContains(resp, connector_one.connector_label)
|
|
4918
|
+
self.assertContains(resp, connector_two.connector_label)
|
|
4919
|
+
|
|
4920
|
+
def test_usage_timeline_merges_repeated_status_entries(self):
|
|
4921
|
+
original_logs = store.logs["charger"]
|
|
4922
|
+
store.logs["charger"] = {}
|
|
4923
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
4924
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
4925
|
+
charger = Charger.objects.create(
|
|
4926
|
+
charger_id="TLDEDUP",
|
|
4927
|
+
connector_id=1,
|
|
4928
|
+
last_status="Available",
|
|
4929
|
+
)
|
|
4930
|
+
|
|
4931
|
+
def build_entry(delta, status):
|
|
4932
|
+
timestamp = fixed_now - delta
|
|
4933
|
+
payload = {
|
|
4934
|
+
"connectorId": 1,
|
|
4935
|
+
"status": status,
|
|
4936
|
+
"timestamp": timestamp.isoformat(),
|
|
4937
|
+
}
|
|
4938
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
4939
|
+
return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
4940
|
+
|
|
4941
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
4942
|
+
store.logs["charger"][log_key] = [
|
|
4943
|
+
build_entry(timedelta(days=6, hours=12), "Available"),
|
|
4944
|
+
build_entry(timedelta(days=5), "Available"),
|
|
4945
|
+
build_entry(timedelta(days=3, hours=6), "Charging"),
|
|
4946
|
+
build_entry(timedelta(days=2), "Charging"),
|
|
4947
|
+
build_entry(timedelta(days=1), "Available"),
|
|
4948
|
+
]
|
|
4949
|
+
|
|
4950
|
+
data, window = _usage_timeline(charger, [], now=fixed_now)
|
|
4951
|
+
self.assertIsNotNone(window)
|
|
4952
|
+
self.assertEqual(len(data), 1)
|
|
4953
|
+
segments = data[0]["segments"]
|
|
4954
|
+
self.assertGreaterEqual(len(segments), 1)
|
|
4955
|
+
statuses = [segment["status"] for segment in segments]
|
|
4956
|
+
self.assertEqual(statuses, ["available", "charging", "available"])
|
|
4957
|
+
|
|
4115
4958
|
def test_diagnostics_status_displayed(self):
|
|
4116
4959
|
reported_at = timezone.now().replace(microsecond=0)
|
|
4117
4960
|
charger = Charger.objects.create(
|
|
@@ -4442,6 +5285,49 @@ class LiveUpdateViewTests(TestCase):
|
|
|
4442
5285
|
)
|
|
4443
5286
|
self.assertEqual(aggregate_entry["state"], available_label)
|
|
4444
5287
|
|
|
5288
|
+
def test_dashboard_connector_treats_finishing_as_available_without_session(self):
|
|
5289
|
+
charger = Charger.objects.create(
|
|
5290
|
+
charger_id="FINISH-STATE",
|
|
5291
|
+
connector_id=1,
|
|
5292
|
+
last_status="Finishing",
|
|
5293
|
+
)
|
|
5294
|
+
|
|
5295
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
5296
|
+
self.assertEqual(resp.status_code, 200)
|
|
5297
|
+
self.assertIsNotNone(resp.context)
|
|
5298
|
+
context = resp.context
|
|
5299
|
+
available_label = force_str(STATUS_BADGE_MAP["available"][0])
|
|
5300
|
+
entry = next(
|
|
5301
|
+
item
|
|
5302
|
+
for item in context["chargers"]
|
|
5303
|
+
if item["charger"].pk == charger.pk
|
|
5304
|
+
)
|
|
5305
|
+
self.assertEqual(entry["state"], available_label)
|
|
5306
|
+
|
|
5307
|
+
def test_dashboard_aggregate_treats_finishing_as_available_without_session(self):
|
|
5308
|
+
aggregate = Charger.objects.create(
|
|
5309
|
+
charger_id="FINISH-AGG",
|
|
5310
|
+
connector_id=None,
|
|
5311
|
+
last_status="Finishing",
|
|
5312
|
+
)
|
|
5313
|
+
Charger.objects.create(
|
|
5314
|
+
charger_id=aggregate.charger_id,
|
|
5315
|
+
connector_id=1,
|
|
5316
|
+
last_status="Finishing",
|
|
5317
|
+
)
|
|
5318
|
+
|
|
5319
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
5320
|
+
self.assertEqual(resp.status_code, 200)
|
|
5321
|
+
self.assertIsNotNone(resp.context)
|
|
5322
|
+
context = resp.context
|
|
5323
|
+
available_label = force_str(STATUS_BADGE_MAP["available"][0])
|
|
5324
|
+
aggregate_entry = next(
|
|
5325
|
+
item
|
|
5326
|
+
for item in context["chargers"]
|
|
5327
|
+
if item["charger"].pk == aggregate.pk
|
|
5328
|
+
)
|
|
5329
|
+
self.assertEqual(aggregate_entry["state"], available_label)
|
|
5330
|
+
|
|
4445
5331
|
def test_dashboard_aggregate_uses_connection_when_status_missing(self):
|
|
4446
5332
|
aggregate = Charger.objects.create(
|
|
4447
5333
|
charger_id="DASHAGG-CONN", last_status="Charging"
|
|
@@ -4466,6 +5352,59 @@ class LiveUpdateViewTests(TestCase):
|
|
|
4466
5352
|
)
|
|
4467
5353
|
self.assertEqual(aggregate_entry["state"], available_label)
|
|
4468
5354
|
|
|
5355
|
+
def test_dashboard_groups_connectors_under_parent(self):
|
|
5356
|
+
aggregate = Charger.objects.create(charger_id="GROUPED")
|
|
5357
|
+
first = Charger.objects.create(
|
|
5358
|
+
charger_id=aggregate.charger_id, connector_id=1
|
|
5359
|
+
)
|
|
5360
|
+
second = Charger.objects.create(
|
|
5361
|
+
charger_id=aggregate.charger_id, connector_id=2
|
|
5362
|
+
)
|
|
5363
|
+
|
|
5364
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
5365
|
+
self.assertEqual(resp.status_code, 200)
|
|
5366
|
+
groups = resp.context["charger_groups"]
|
|
5367
|
+
target = next(
|
|
5368
|
+
group
|
|
5369
|
+
for group in groups
|
|
5370
|
+
if group.get("parent")
|
|
5371
|
+
and group["parent"]["charger"].pk == aggregate.pk
|
|
5372
|
+
)
|
|
5373
|
+
child_ids = [item["charger"].pk for item in target["children"]]
|
|
5374
|
+
self.assertEqual(child_ids, [first.pk, second.pk])
|
|
5375
|
+
|
|
5376
|
+
def test_dashboard_includes_energy_totals(self):
|
|
5377
|
+
aggregate = Charger.objects.create(charger_id="KWSTATS")
|
|
5378
|
+
now = timezone.now()
|
|
5379
|
+
Transaction.objects.create(
|
|
5380
|
+
charger=aggregate,
|
|
5381
|
+
start_time=now - timedelta(hours=1),
|
|
5382
|
+
stop_time=now,
|
|
5383
|
+
meter_start=0,
|
|
5384
|
+
meter_stop=3000,
|
|
5385
|
+
)
|
|
5386
|
+
past_start = now - timedelta(days=2)
|
|
5387
|
+
Transaction.objects.create(
|
|
5388
|
+
charger=aggregate,
|
|
5389
|
+
start_time=past_start,
|
|
5390
|
+
stop_time=past_start + timedelta(hours=1),
|
|
5391
|
+
meter_start=0,
|
|
5392
|
+
meter_stop=1000,
|
|
5393
|
+
)
|
|
5394
|
+
|
|
5395
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
5396
|
+
self.assertEqual(resp.status_code, 200)
|
|
5397
|
+
groups = resp.context["charger_groups"]
|
|
5398
|
+
target = next(
|
|
5399
|
+
group
|
|
5400
|
+
for group in groups
|
|
5401
|
+
if group.get("parent")
|
|
5402
|
+
and group["parent"]["charger"].pk == aggregate.pk
|
|
5403
|
+
)
|
|
5404
|
+
stats = target["parent"]["stats"]
|
|
5405
|
+
self.assertAlmostEqual(stats["total_kw"], 4.0, places=2)
|
|
5406
|
+
self.assertAlmostEqual(stats["today_kw"], 3.0, places=2)
|
|
5407
|
+
|
|
4469
5408
|
def test_cp_simulator_includes_interval(self):
|
|
4470
5409
|
resp = self.client.get(reverse("cp-simulator"))
|
|
4471
5410
|
self.assertEqual(resp.context["request"].live_update_interval, 5)
|