arthexis 0.1.11__py3-none-any.whl → 0.1.12__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.11.dist-info → arthexis-0.1.12.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/RECORD +38 -35
- config/settings.py +7 -2
- core/admin.py +246 -68
- core/apps.py +21 -0
- core/models.py +41 -8
- core/reference_utils.py +1 -1
- core/release.py +4 -0
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +64 -0
- core/views.py +131 -17
- nodes/admin.py +316 -6
- nodes/feature_checks.py +133 -0
- nodes/models.py +83 -26
- nodes/reports.py +411 -0
- nodes/tests.py +365 -36
- nodes/utils.py +32 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +506 -8
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +234 -4
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/tests.py +789 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +225 -19
- pages/admin.py +135 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +38 -0
- pages/models.py +136 -1
- pages/tests.py +262 -4
- pages/urls.py +1 -0
- pages/views.py +52 -3
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
ocpp/tests.py
CHANGED
|
@@ -2,33 +2,53 @@ import os
|
|
|
2
2
|
|
|
3
3
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
4
4
|
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import tests.conftest # noqa: F401
|
|
8
|
+
|
|
5
9
|
import django
|
|
6
10
|
|
|
11
|
+
django.setup = tests.conftest._original_setup
|
|
7
12
|
django.setup()
|
|
8
13
|
|
|
9
14
|
from asgiref.testing import ApplicationCommunicator
|
|
10
15
|
from channels.testing import WebsocketCommunicator
|
|
11
16
|
from channels.db import database_sync_to_async
|
|
12
17
|
from asgiref.sync import async_to_sync
|
|
13
|
-
from django.test import
|
|
18
|
+
from django.test import (
|
|
19
|
+
Client,
|
|
20
|
+
RequestFactory,
|
|
21
|
+
TransactionTestCase,
|
|
22
|
+
TestCase,
|
|
23
|
+
override_settings,
|
|
24
|
+
)
|
|
14
25
|
from unittest import skip
|
|
15
26
|
from contextlib import suppress
|
|
16
27
|
from types import SimpleNamespace
|
|
17
|
-
from unittest.mock import patch, Mock
|
|
28
|
+
from unittest.mock import patch, Mock, AsyncMock
|
|
18
29
|
from django.contrib.auth import get_user_model
|
|
19
30
|
from django.urls import reverse
|
|
20
31
|
from django.utils import timezone
|
|
21
32
|
from django.utils.dateparse import parse_datetime
|
|
22
33
|
from django.utils.translation import override, gettext as _
|
|
23
34
|
from django.contrib.sites.models import Site
|
|
35
|
+
from django.core.exceptions import ValidationError
|
|
24
36
|
from pages.models import Application, Module
|
|
25
37
|
from nodes.models import Node, NodeRole
|
|
26
38
|
|
|
27
39
|
from config.asgi import application
|
|
28
40
|
|
|
29
|
-
from .models import
|
|
41
|
+
from .models import (
|
|
42
|
+
Transaction,
|
|
43
|
+
Charger,
|
|
44
|
+
Simulator,
|
|
45
|
+
MeterReading,
|
|
46
|
+
Location,
|
|
47
|
+
DataTransferMessage,
|
|
48
|
+
)
|
|
30
49
|
from .consumers import CSMSConsumer
|
|
31
|
-
from
|
|
50
|
+
from .views import dispatch_action
|
|
51
|
+
from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
|
|
32
52
|
from . import store
|
|
33
53
|
from django.db.models.deletion import ProtectedError
|
|
34
54
|
from decimal import Decimal
|
|
@@ -87,6 +107,47 @@ class DummyWebSocket:
|
|
|
87
107
|
self.sent.append(message)
|
|
88
108
|
|
|
89
109
|
|
|
110
|
+
class DispatchActionTests(TestCase):
|
|
111
|
+
def setUp(self):
|
|
112
|
+
self.factory = RequestFactory()
|
|
113
|
+
|
|
114
|
+
def tearDown(self): # pragma: no cover - cleanup guard
|
|
115
|
+
store.pending_calls.clear()
|
|
116
|
+
store.triggered_followups.clear()
|
|
117
|
+
|
|
118
|
+
def test_trigger_message_registers_pending_call(self):
|
|
119
|
+
charger = Charger.objects.create(charger_id="TRIGGER1")
|
|
120
|
+
dummy = DummyWebSocket()
|
|
121
|
+
key = store.set_connection("TRIGGER1", None, dummy)
|
|
122
|
+
self.addCleanup(lambda: store.connections.pop(key, None))
|
|
123
|
+
log_key = store.identity_key("TRIGGER1", None)
|
|
124
|
+
store.clear_log(log_key, log_type="charger")
|
|
125
|
+
self.addCleanup(lambda: store.clear_log(log_key, log_type="charger"))
|
|
126
|
+
|
|
127
|
+
request = self.factory.post(
|
|
128
|
+
"/chargers/TRIGGER1/action/",
|
|
129
|
+
data=json.dumps({"action": "trigger_message", "target": "BootNotification"}),
|
|
130
|
+
content_type="application/json",
|
|
131
|
+
)
|
|
132
|
+
request.user = SimpleNamespace(
|
|
133
|
+
is_authenticated=True,
|
|
134
|
+
is_superuser=True,
|
|
135
|
+
is_staff=True,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
response = dispatch_action(request, "TRIGGER1")
|
|
139
|
+
self.assertEqual(response.status_code, 200)
|
|
140
|
+
self.assertTrue(dummy.sent)
|
|
141
|
+
frame = json.loads(dummy.sent[-1])
|
|
142
|
+
self.assertEqual(frame[0], 2)
|
|
143
|
+
self.assertEqual(frame[2], "TriggerMessage")
|
|
144
|
+
message_id = frame[1]
|
|
145
|
+
self.assertIn(message_id, store.pending_calls)
|
|
146
|
+
metadata = store.pending_calls[message_id]
|
|
147
|
+
self.assertEqual(metadata.get("action"), "TriggerMessage")
|
|
148
|
+
self.assertEqual(metadata.get("trigger_target"), "BootNotification")
|
|
149
|
+
self.assertEqual(metadata.get("log_key"), log_key)
|
|
150
|
+
|
|
90
151
|
class ChargerFixtureTests(TestCase):
|
|
91
152
|
fixtures = [
|
|
92
153
|
p.name
|
|
@@ -315,6 +376,244 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
315
376
|
self.assertTrue(message_mock.save.called)
|
|
316
377
|
self.assertTrue(message_mock.propagate.called)
|
|
317
378
|
|
|
379
|
+
async def test_change_availability_result_updates_model(self):
|
|
380
|
+
store.pending_calls.clear()
|
|
381
|
+
communicator = WebsocketCommunicator(application, "/AVAILRES/")
|
|
382
|
+
connected, _ = await communicator.connect()
|
|
383
|
+
self.assertTrue(connected)
|
|
384
|
+
|
|
385
|
+
await communicator.send_json_to(
|
|
386
|
+
[
|
|
387
|
+
2,
|
|
388
|
+
"boot",
|
|
389
|
+
"BootNotification",
|
|
390
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
391
|
+
]
|
|
392
|
+
)
|
|
393
|
+
await communicator.receive_json_from()
|
|
394
|
+
|
|
395
|
+
message_id = "ca-result"
|
|
396
|
+
requested_at = timezone.now()
|
|
397
|
+
store.register_pending_call(
|
|
398
|
+
message_id,
|
|
399
|
+
{
|
|
400
|
+
"action": "ChangeAvailability",
|
|
401
|
+
"charger_id": "AVAILRES",
|
|
402
|
+
"connector_id": None,
|
|
403
|
+
"availability_type": "Inoperative",
|
|
404
|
+
"requested_at": requested_at,
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
|
|
408
|
+
await asyncio.sleep(0.05)
|
|
409
|
+
|
|
410
|
+
charger = await database_sync_to_async(Charger.objects.get)(
|
|
411
|
+
charger_id="AVAILRES", connector_id=None
|
|
412
|
+
)
|
|
413
|
+
self.assertEqual(charger.availability_state, "Inoperative")
|
|
414
|
+
self.assertEqual(charger.availability_request_status, "Accepted")
|
|
415
|
+
self.assertEqual(charger.availability_requested_state, "Inoperative")
|
|
416
|
+
await communicator.disconnect()
|
|
417
|
+
|
|
418
|
+
async def test_get_configuration_result_logged(self):
|
|
419
|
+
store.pending_calls.clear()
|
|
420
|
+
pending_key = store.pending_key("CFGRES")
|
|
421
|
+
store.clear_log(pending_key, log_type="charger")
|
|
422
|
+
log_key = store.identity_key("CFGRES", None)
|
|
423
|
+
store.clear_log(log_key, log_type="charger")
|
|
424
|
+
communicator = WebsocketCommunicator(application, "/CFGRES/")
|
|
425
|
+
connected, _ = await communicator.connect()
|
|
426
|
+
self.assertTrue(connected)
|
|
427
|
+
|
|
428
|
+
message_id = "cfg-result"
|
|
429
|
+
payload = {
|
|
430
|
+
"configurationKey": [
|
|
431
|
+
{
|
|
432
|
+
"key": "AllowOfflineTxForUnknownId",
|
|
433
|
+
"readonly": True,
|
|
434
|
+
"value": "false",
|
|
435
|
+
}
|
|
436
|
+
]
|
|
437
|
+
}
|
|
438
|
+
store.register_pending_call(
|
|
439
|
+
message_id,
|
|
440
|
+
{
|
|
441
|
+
"action": "GetConfiguration",
|
|
442
|
+
"charger_id": "CFGRES",
|
|
443
|
+
"connector_id": None,
|
|
444
|
+
"log_key": log_key,
|
|
445
|
+
"requested_at": timezone.now(),
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
await communicator.send_json_to([3, message_id, payload])
|
|
450
|
+
await asyncio.sleep(0.05)
|
|
451
|
+
|
|
452
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
453
|
+
self.assertTrue(
|
|
454
|
+
any("GetConfiguration result" in entry for entry in log_entries)
|
|
455
|
+
)
|
|
456
|
+
self.assertNotIn(message_id, store.pending_calls)
|
|
457
|
+
|
|
458
|
+
await communicator.disconnect()
|
|
459
|
+
store.clear_log(log_key, log_type="charger")
|
|
460
|
+
store.clear_log(pending_key, log_type="charger")
|
|
461
|
+
|
|
462
|
+
async def test_get_configuration_error_logged(self):
|
|
463
|
+
store.pending_calls.clear()
|
|
464
|
+
pending_key = store.pending_key("CFGERR")
|
|
465
|
+
store.clear_log(pending_key, log_type="charger")
|
|
466
|
+
log_key = store.identity_key("CFGERR", None)
|
|
467
|
+
store.clear_log(log_key, log_type="charger")
|
|
468
|
+
communicator = WebsocketCommunicator(application, "/CFGERR/")
|
|
469
|
+
connected, _ = await communicator.connect()
|
|
470
|
+
self.assertTrue(connected)
|
|
471
|
+
|
|
472
|
+
message_id = "cfg-error"
|
|
473
|
+
store.register_pending_call(
|
|
474
|
+
message_id,
|
|
475
|
+
{
|
|
476
|
+
"action": "GetConfiguration",
|
|
477
|
+
"charger_id": "CFGERR",
|
|
478
|
+
"connector_id": None,
|
|
479
|
+
"log_key": log_key,
|
|
480
|
+
"requested_at": timezone.now(),
|
|
481
|
+
},
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
await communicator.send_json_to(
|
|
485
|
+
[4, message_id, "InternalError", "Boom", {"detail": "nope"}]
|
|
486
|
+
)
|
|
487
|
+
await asyncio.sleep(0.05)
|
|
488
|
+
|
|
489
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
490
|
+
self.assertTrue(
|
|
491
|
+
any("GetConfiguration error" in entry for entry in log_entries)
|
|
492
|
+
)
|
|
493
|
+
self.assertNotIn(message_id, store.pending_calls)
|
|
494
|
+
|
|
495
|
+
await communicator.disconnect()
|
|
496
|
+
store.clear_log(log_key, log_type="charger")
|
|
497
|
+
store.clear_log(pending_key, log_type="charger")
|
|
498
|
+
|
|
499
|
+
async def test_trigger_message_follow_up_logged(self):
|
|
500
|
+
store.pending_calls.clear()
|
|
501
|
+
cid = "TRIGLOG"
|
|
502
|
+
pending_key = store.pending_key(cid)
|
|
503
|
+
log_key = store.identity_key(cid, None)
|
|
504
|
+
store.clear_log(pending_key, log_type="charger")
|
|
505
|
+
store.clear_log(log_key, log_type="charger")
|
|
506
|
+
|
|
507
|
+
communicator = WebsocketCommunicator(application, f"/{cid}/")
|
|
508
|
+
connected, _ = await communicator.connect()
|
|
509
|
+
self.assertTrue(connected)
|
|
510
|
+
|
|
511
|
+
await communicator.send_json_to(
|
|
512
|
+
[
|
|
513
|
+
2,
|
|
514
|
+
"boot",
|
|
515
|
+
"BootNotification",
|
|
516
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
517
|
+
]
|
|
518
|
+
)
|
|
519
|
+
await communicator.receive_json_from()
|
|
520
|
+
|
|
521
|
+
message_id = "trigger-result"
|
|
522
|
+
store.register_pending_call(
|
|
523
|
+
message_id,
|
|
524
|
+
{
|
|
525
|
+
"action": "TriggerMessage",
|
|
526
|
+
"charger_id": cid,
|
|
527
|
+
"connector_id": None,
|
|
528
|
+
"log_key": log_key,
|
|
529
|
+
"trigger_target": "BootNotification",
|
|
530
|
+
"trigger_connector": None,
|
|
531
|
+
},
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
|
|
535
|
+
await asyncio.sleep(0.05)
|
|
536
|
+
self.assertNotIn(message_id, store.pending_calls)
|
|
537
|
+
|
|
538
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
539
|
+
self.assertTrue(
|
|
540
|
+
any(
|
|
541
|
+
"TriggerMessage BootNotification result" in entry
|
|
542
|
+
or "TriggerMessage result" in entry
|
|
543
|
+
for entry in log_entries
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
await communicator.send_json_to(
|
|
548
|
+
[
|
|
549
|
+
2,
|
|
550
|
+
"trigger-follow",
|
|
551
|
+
"BootNotification",
|
|
552
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
553
|
+
]
|
|
554
|
+
)
|
|
555
|
+
await communicator.receive_json_from()
|
|
556
|
+
await asyncio.sleep(0.05)
|
|
557
|
+
|
|
558
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
559
|
+
self.assertTrue(
|
|
560
|
+
any(
|
|
561
|
+
"TriggerMessage follow-up received: BootNotification" in entry
|
|
562
|
+
for entry in log_entries
|
|
563
|
+
)
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
await communicator.disconnect()
|
|
567
|
+
store.clear_log(log_key, log_type="charger")
|
|
568
|
+
store.clear_log(pending_key, log_type="charger")
|
|
569
|
+
|
|
570
|
+
async def test_status_notification_updates_availability_state(self):
|
|
571
|
+
store.pending_calls.clear()
|
|
572
|
+
communicator = WebsocketCommunicator(application, "/STATAVAIL/")
|
|
573
|
+
connected, _ = await communicator.connect()
|
|
574
|
+
self.assertTrue(connected)
|
|
575
|
+
|
|
576
|
+
await communicator.send_json_to(
|
|
577
|
+
[
|
|
578
|
+
2,
|
|
579
|
+
"boot",
|
|
580
|
+
"BootNotification",
|
|
581
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
582
|
+
]
|
|
583
|
+
)
|
|
584
|
+
await communicator.receive_json_from()
|
|
585
|
+
|
|
586
|
+
await communicator.send_json_to(
|
|
587
|
+
[
|
|
588
|
+
2,
|
|
589
|
+
"stat1",
|
|
590
|
+
"StatusNotification",
|
|
591
|
+
{"connectorId": 1, "errorCode": "NoError", "status": "Unavailable"},
|
|
592
|
+
]
|
|
593
|
+
)
|
|
594
|
+
await communicator.receive_json_from()
|
|
595
|
+
|
|
596
|
+
charger = await database_sync_to_async(Charger.objects.get)(
|
|
597
|
+
charger_id="STATAVAIL", connector_id=1
|
|
598
|
+
)
|
|
599
|
+
self.assertEqual(charger.availability_state, "Inoperative")
|
|
600
|
+
|
|
601
|
+
await communicator.send_json_to(
|
|
602
|
+
[
|
|
603
|
+
2,
|
|
604
|
+
"stat2",
|
|
605
|
+
"StatusNotification",
|
|
606
|
+
{"connectorId": 1, "errorCode": "NoError", "status": "Available"},
|
|
607
|
+
]
|
|
608
|
+
)
|
|
609
|
+
await communicator.receive_json_from()
|
|
610
|
+
|
|
611
|
+
charger = await database_sync_to_async(Charger.objects.get)(
|
|
612
|
+
charger_id="STATAVAIL", connector_id=1
|
|
613
|
+
)
|
|
614
|
+
self.assertEqual(charger.availability_state, "Operative")
|
|
615
|
+
await communicator.disconnect()
|
|
616
|
+
|
|
318
617
|
async def test_consumption_message_final_update_on_disconnect(self):
|
|
319
618
|
await database_sync_to_async(Charger.objects.create)(charger_id="FINALMSG")
|
|
320
619
|
communicator = WebsocketCommunicator(application, "/FINALMSG/")
|
|
@@ -1153,6 +1452,92 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1153
1452
|
if connected3_retry and communicator3_retry is not None:
|
|
1154
1453
|
await communicator3_retry.disconnect()
|
|
1155
1454
|
|
|
1455
|
+
async def test_data_transfer_inbound_persists_message(self):
|
|
1456
|
+
store.pending_calls.clear()
|
|
1457
|
+
communicator = WebsocketCommunicator(application, "/DTIN/")
|
|
1458
|
+
connected, _ = await communicator.connect()
|
|
1459
|
+
self.assertTrue(connected)
|
|
1460
|
+
|
|
1461
|
+
payload = {"vendorId": "Acme", "messageId": "diag", "data": {"foo": "bar"}}
|
|
1462
|
+
await communicator.send_json_to([2, "dt-msg", "DataTransfer", payload])
|
|
1463
|
+
response = await communicator.receive_json_from()
|
|
1464
|
+
self.assertEqual(response, [3, "dt-msg", {"status": "UnknownVendorId"}])
|
|
1465
|
+
|
|
1466
|
+
await communicator.disconnect()
|
|
1467
|
+
|
|
1468
|
+
message = await database_sync_to_async(DataTransferMessage.objects.get)(
|
|
1469
|
+
ocpp_message_id="dt-msg"
|
|
1470
|
+
)
|
|
1471
|
+
self.assertEqual(
|
|
1472
|
+
message.direction, DataTransferMessage.DIRECTION_CP_TO_CSMS
|
|
1473
|
+
)
|
|
1474
|
+
self.assertEqual(message.vendor_id, "Acme")
|
|
1475
|
+
self.assertEqual(message.message_id, "diag")
|
|
1476
|
+
self.assertEqual(message.payload, payload)
|
|
1477
|
+
self.assertEqual(message.status, "UnknownVendorId")
|
|
1478
|
+
self.assertIsNotNone(message.responded_at)
|
|
1479
|
+
|
|
1480
|
+
async def test_data_transfer_action_round_trip(self):
|
|
1481
|
+
store.pending_calls.clear()
|
|
1482
|
+
communicator = WebsocketCommunicator(application, "/DTOUT/")
|
|
1483
|
+
connected, _ = await communicator.connect()
|
|
1484
|
+
self.assertTrue(connected)
|
|
1485
|
+
|
|
1486
|
+
User = get_user_model()
|
|
1487
|
+
user = await database_sync_to_async(User.objects.create_user)(
|
|
1488
|
+
username="dtuser", password="pw"
|
|
1489
|
+
)
|
|
1490
|
+
await database_sync_to_async(self.client.force_login)(user)
|
|
1491
|
+
|
|
1492
|
+
url = reverse("charger-action", args=["DTOUT"])
|
|
1493
|
+
request_payload = {
|
|
1494
|
+
"action": "data_transfer",
|
|
1495
|
+
"vendorId": "AcmeCorp",
|
|
1496
|
+
"messageId": "ping",
|
|
1497
|
+
"data": {"echo": "value"},
|
|
1498
|
+
}
|
|
1499
|
+
response = await database_sync_to_async(self.client.post)(
|
|
1500
|
+
url,
|
|
1501
|
+
data=json.dumps(request_payload),
|
|
1502
|
+
content_type="application/json",
|
|
1503
|
+
)
|
|
1504
|
+
self.assertEqual(response.status_code, 200)
|
|
1505
|
+
response_body = json.loads(response.content.decode())
|
|
1506
|
+
sent_frame = json.loads(response_body["sent"])
|
|
1507
|
+
self.assertEqual(sent_frame[2], "DataTransfer")
|
|
1508
|
+
sent_payload = sent_frame[3]
|
|
1509
|
+
self.assertEqual(sent_payload["vendorId"], "AcmeCorp")
|
|
1510
|
+
self.assertEqual(sent_payload.get("messageId"), "ping")
|
|
1511
|
+
|
|
1512
|
+
outbound = await communicator.receive_json_from()
|
|
1513
|
+
self.assertEqual(outbound, sent_frame)
|
|
1514
|
+
|
|
1515
|
+
message_id = sent_frame[1]
|
|
1516
|
+
record = await database_sync_to_async(DataTransferMessage.objects.get)(
|
|
1517
|
+
ocpp_message_id=message_id
|
|
1518
|
+
)
|
|
1519
|
+
self.assertEqual(
|
|
1520
|
+
record.direction, DataTransferMessage.DIRECTION_CSMS_TO_CP
|
|
1521
|
+
)
|
|
1522
|
+
self.assertEqual(record.status, "Pending")
|
|
1523
|
+
self.assertIsNone(record.response_data)
|
|
1524
|
+
self.assertIn(message_id, store.pending_calls)
|
|
1525
|
+
self.assertEqual(store.pending_calls[message_id]["message_pk"], record.pk)
|
|
1526
|
+
|
|
1527
|
+
reply_payload = {"status": "Accepted", "data": {"result": "ok"}}
|
|
1528
|
+
await communicator.send_json_to([3, message_id, reply_payload])
|
|
1529
|
+
await asyncio.sleep(0.05)
|
|
1530
|
+
|
|
1531
|
+
updated = await database_sync_to_async(DataTransferMessage.objects.get)(
|
|
1532
|
+
pk=record.pk
|
|
1533
|
+
)
|
|
1534
|
+
self.assertEqual(updated.status, "Accepted")
|
|
1535
|
+
self.assertEqual(updated.response_data, {"result": "ok"})
|
|
1536
|
+
self.assertIsNotNone(updated.responded_at)
|
|
1537
|
+
self.assertNotIn(message_id, store.pending_calls)
|
|
1538
|
+
|
|
1539
|
+
await communicator.disconnect()
|
|
1540
|
+
|
|
1156
1541
|
|
|
1157
1542
|
class ChargerLandingTests(TestCase):
|
|
1158
1543
|
def setUp(self):
|
|
@@ -1185,6 +1570,21 @@ class ChargerLandingTests(TestCase):
|
|
|
1185
1570
|
self.assertEqual(resp.status_code, 200)
|
|
1186
1571
|
self.assertContains(resp, "PAGE2")
|
|
1187
1572
|
|
|
1573
|
+
def test_placeholder_serial_rejected(self):
|
|
1574
|
+
with self.assertRaises(ValidationError):
|
|
1575
|
+
Charger.objects.create(charger_id="<charger_id>")
|
|
1576
|
+
|
|
1577
|
+
def test_placeholder_serial_not_created_from_status_view(self):
|
|
1578
|
+
existing = Charger.objects.count()
|
|
1579
|
+
response = self.client.get(reverse("charger-status", args=["<charger_id>"]))
|
|
1580
|
+
self.assertEqual(response.status_code, 404)
|
|
1581
|
+
self.assertEqual(Charger.objects.count(), existing)
|
|
1582
|
+
self.assertFalse(
|
|
1583
|
+
Location.objects.filter(
|
|
1584
|
+
name__startswith="<", name__endswith=">", chargers__isnull=True
|
|
1585
|
+
).exists()
|
|
1586
|
+
)
|
|
1587
|
+
|
|
1188
1588
|
def test_charger_page_shows_progress(self):
|
|
1189
1589
|
charger = Charger.objects.create(charger_id="STATS")
|
|
1190
1590
|
tx = Transaction.objects.create(
|
|
@@ -1306,6 +1706,12 @@ class SimulatorLandingTests(TestCase):
|
|
|
1306
1706
|
self.assertContains(resp, "/ocpp/")
|
|
1307
1707
|
self.assertContains(resp, "/ocpp/simulator/")
|
|
1308
1708
|
|
|
1709
|
+
def test_cp_simulator_redirects_to_login(self):
|
|
1710
|
+
response = self.client.get(reverse("cp-simulator"))
|
|
1711
|
+
login_url = reverse("pages:login")
|
|
1712
|
+
self.assertEqual(response.status_code, 302)
|
|
1713
|
+
self.assertIn(login_url, response.url)
|
|
1714
|
+
|
|
1309
1715
|
|
|
1310
1716
|
class ChargerAdminTests(TestCase):
|
|
1311
1717
|
def setUp(self):
|
|
@@ -1374,6 +1780,35 @@ class ChargerAdminTests(TestCase):
|
|
|
1374
1780
|
self.assertNotContains(resp, 'name="last_heartbeat"')
|
|
1375
1781
|
self.assertNotContains(resp, 'name="last_meter_values"')
|
|
1376
1782
|
|
|
1783
|
+
def test_admin_action_sets_availability_state(self):
|
|
1784
|
+
charger = Charger.objects.create(charger_id="AVAIL1")
|
|
1785
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
1786
|
+
response = self.client.post(
|
|
1787
|
+
url,
|
|
1788
|
+
{
|
|
1789
|
+
"action": "set_availability_state_inoperative",
|
|
1790
|
+
"_selected_action": [charger.pk],
|
|
1791
|
+
},
|
|
1792
|
+
follow=True,
|
|
1793
|
+
)
|
|
1794
|
+
self.assertEqual(response.status_code, 200)
|
|
1795
|
+
charger.refresh_from_db()
|
|
1796
|
+
self.assertEqual(charger.availability_state, "Inoperative")
|
|
1797
|
+
self.assertIsNotNone(charger.availability_state_updated_at)
|
|
1798
|
+
|
|
1799
|
+
response = self.client.post(
|
|
1800
|
+
url,
|
|
1801
|
+
{
|
|
1802
|
+
"action": "set_availability_state_operative",
|
|
1803
|
+
"_selected_action": [charger.pk],
|
|
1804
|
+
},
|
|
1805
|
+
follow=True,
|
|
1806
|
+
)
|
|
1807
|
+
self.assertEqual(response.status_code, 200)
|
|
1808
|
+
charger.refresh_from_db()
|
|
1809
|
+
self.assertEqual(charger.availability_state, "Operative")
|
|
1810
|
+
self.assertIsNotNone(charger.availability_state_updated_at)
|
|
1811
|
+
|
|
1377
1812
|
def test_purge_action_removes_data(self):
|
|
1378
1813
|
charger = Charger.objects.create(charger_id="PURGE1")
|
|
1379
1814
|
Transaction.objects.create(
|
|
@@ -1411,6 +1846,85 @@ class ChargerAdminTests(TestCase):
|
|
|
1411
1846
|
self.client.post(delete_url, {"post": "yes"})
|
|
1412
1847
|
self.assertFalse(Charger.objects.filter(pk=charger.pk).exists())
|
|
1413
1848
|
|
|
1849
|
+
def test_fetch_configuration_dispatches_request(self):
|
|
1850
|
+
charger = Charger.objects.create(charger_id="CFGADMIN", connector_id=1)
|
|
1851
|
+
ws = DummyWebSocket()
|
|
1852
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
1853
|
+
store.clear_log(log_key, log_type="charger")
|
|
1854
|
+
pending_key = store.pending_key(charger.charger_id)
|
|
1855
|
+
store.clear_log(pending_key, log_type="charger")
|
|
1856
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
1857
|
+
store.pending_calls.clear()
|
|
1858
|
+
try:
|
|
1859
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
1860
|
+
response = self.client.post(
|
|
1861
|
+
url,
|
|
1862
|
+
{
|
|
1863
|
+
"action": "fetch_cp_configuration",
|
|
1864
|
+
"_selected_action": [charger.pk],
|
|
1865
|
+
},
|
|
1866
|
+
follow=True,
|
|
1867
|
+
)
|
|
1868
|
+
self.assertEqual(response.status_code, 200)
|
|
1869
|
+
self.assertEqual(len(ws.sent), 1)
|
|
1870
|
+
frame = json.loads(ws.sent[0])
|
|
1871
|
+
self.assertEqual(frame[0], 2)
|
|
1872
|
+
self.assertEqual(frame[2], "GetConfiguration")
|
|
1873
|
+
self.assertIn(frame[1], store.pending_calls)
|
|
1874
|
+
metadata = store.pending_calls[frame[1]]
|
|
1875
|
+
self.assertEqual(metadata.get("action"), "GetConfiguration")
|
|
1876
|
+
self.assertEqual(metadata.get("charger_id"), charger.charger_id)
|
|
1877
|
+
self.assertEqual(metadata.get("connector_id"), charger.connector_id)
|
|
1878
|
+
self.assertEqual(metadata.get("log_key"), log_key)
|
|
1879
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
1880
|
+
self.assertTrue(
|
|
1881
|
+
any("GetConfiguration" in entry for entry in log_entries)
|
|
1882
|
+
)
|
|
1883
|
+
finally:
|
|
1884
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
1885
|
+
store.pending_calls.clear()
|
|
1886
|
+
store.clear_log(log_key, log_type="charger")
|
|
1887
|
+
store.clear_log(pending_key, log_type="charger")
|
|
1888
|
+
|
|
1889
|
+
def test_fetch_configuration_timeout_logged(self):
|
|
1890
|
+
charger = Charger.objects.create(charger_id="CFGWAIT", connector_id=1)
|
|
1891
|
+
ws = DummyWebSocket()
|
|
1892
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
1893
|
+
pending_key = store.pending_key(charger.charger_id)
|
|
1894
|
+
store.clear_log(log_key, log_type="charger")
|
|
1895
|
+
store.clear_log(pending_key, log_type="charger")
|
|
1896
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
1897
|
+
store.pending_calls.clear()
|
|
1898
|
+
original_schedule = store.schedule_call_timeout
|
|
1899
|
+
try:
|
|
1900
|
+
with patch("ocpp.admin.store.schedule_call_timeout") as mock_schedule:
|
|
1901
|
+
def _side_effect(message_id, *, timeout=5.0, **kwargs):
|
|
1902
|
+
kwargs["timeout"] = 0.05
|
|
1903
|
+
return original_schedule(message_id, **kwargs)
|
|
1904
|
+
|
|
1905
|
+
mock_schedule.side_effect = _side_effect
|
|
1906
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
1907
|
+
response = self.client.post(
|
|
1908
|
+
url,
|
|
1909
|
+
{
|
|
1910
|
+
"action": "fetch_cp_configuration",
|
|
1911
|
+
"_selected_action": [charger.pk],
|
|
1912
|
+
},
|
|
1913
|
+
follow=True,
|
|
1914
|
+
)
|
|
1915
|
+
self.assertEqual(response.status_code, 200)
|
|
1916
|
+
mock_schedule.assert_called_once()
|
|
1917
|
+
time.sleep(0.1)
|
|
1918
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
1919
|
+
self.assertTrue(
|
|
1920
|
+
any("GetConfiguration timed out" in entry for entry in log_entries)
|
|
1921
|
+
)
|
|
1922
|
+
finally:
|
|
1923
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
1924
|
+
store.pending_calls.clear()
|
|
1925
|
+
store.clear_log(log_key, log_type="charger")
|
|
1926
|
+
store.clear_log(pending_key, log_type="charger")
|
|
1927
|
+
|
|
1414
1928
|
|
|
1415
1929
|
class LocationAdminTests(TestCase):
|
|
1416
1930
|
def setUp(self):
|
|
@@ -1557,6 +2071,22 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1557
2071
|
self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)
|
|
1558
2072
|
store.simulators.pop(sim.pk, None)
|
|
1559
2073
|
|
|
2074
|
+
@patch("ocpp.admin.asyncio.get_running_loop", side_effect=RuntimeError)
|
|
2075
|
+
def test_stop_simulator_runs_without_event_loop(self, mock_get_loop):
|
|
2076
|
+
sim = Simulator.objects.create(name="SIMSTOP", cp_path="SIMSTOP")
|
|
2077
|
+
stopper = SimpleNamespace(stop=AsyncMock())
|
|
2078
|
+
store.simulators[sim.pk] = stopper
|
|
2079
|
+
url = reverse("admin:ocpp_simulator_changelist")
|
|
2080
|
+
resp = self.client.post(
|
|
2081
|
+
url,
|
|
2082
|
+
{"action": "stop_simulator", "_selected_action": [sim.pk]},
|
|
2083
|
+
follow=True,
|
|
2084
|
+
)
|
|
2085
|
+
self.assertEqual(resp.status_code, 200)
|
|
2086
|
+
stopper.stop.assert_awaited_once()
|
|
2087
|
+
self.assertTrue(mock_get_loop.called)
|
|
2088
|
+
self.assertNotIn(sim.pk, store.simulators)
|
|
2089
|
+
|
|
1560
2090
|
def test_as_config_includes_custom_fields(self):
|
|
1561
2091
|
sim = Simulator.objects.create(
|
|
1562
2092
|
name="SIM3",
|
|
@@ -1566,6 +2096,10 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1566
2096
|
duration=500,
|
|
1567
2097
|
pre_charge_delay=5,
|
|
1568
2098
|
vin="WP0ZZZ99999999999",
|
|
2099
|
+
configuration_keys=[
|
|
2100
|
+
{"key": "HeartbeatInterval", "value": "300", "readonly": True}
|
|
2101
|
+
],
|
|
2102
|
+
configuration_unknown_keys=["Bogus"],
|
|
1569
2103
|
)
|
|
1570
2104
|
cfg = sim.as_config()
|
|
1571
2105
|
self.assertEqual(cfg.interval, 3.5)
|
|
@@ -1573,6 +2107,11 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1573
2107
|
self.assertEqual(cfg.duration, 500)
|
|
1574
2108
|
self.assertEqual(cfg.pre_charge_delay, 5)
|
|
1575
2109
|
self.assertEqual(cfg.vin, "WP0ZZZ99999999999")
|
|
2110
|
+
self.assertEqual(
|
|
2111
|
+
cfg.configuration_keys,
|
|
2112
|
+
[{"key": "HeartbeatInterval", "value": "300", "readonly": True}],
|
|
2113
|
+
)
|
|
2114
|
+
self.assertEqual(cfg.configuration_unknown_keys, ["Bogus"])
|
|
1576
2115
|
|
|
1577
2116
|
def _post_simulator_change(self, sim: Simulator, **overrides):
|
|
1578
2117
|
url = reverse("admin:ocpp_simulator_change", args=[sim.pk])
|
|
@@ -1590,6 +2129,10 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1590
2129
|
"username": sim.username,
|
|
1591
2130
|
"password": sim.password,
|
|
1592
2131
|
"door_open": "on" if overrides.get("door_open", False) else "",
|
|
2132
|
+
"configuration_keys": json.dumps(sim.configuration_keys or []),
|
|
2133
|
+
"configuration_unknown_keys": json.dumps(
|
|
2134
|
+
sim.configuration_unknown_keys or []
|
|
2135
|
+
),
|
|
1593
2136
|
"_save": "Save",
|
|
1594
2137
|
}
|
|
1595
2138
|
data.update(overrides)
|
|
@@ -2167,8 +2710,149 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
2167
2710
|
idx for idx, payload in enumerate(status_payloads) if payload.get("errorCode") == "NoError"
|
|
2168
2711
|
)
|
|
2169
2712
|
self.assertLess(first_open, first_close)
|
|
2170
|
-
|
|
2171
|
-
|
|
2713
|
+
|
|
2714
|
+
async def test_get_configuration_uses_configured_keys(self):
|
|
2715
|
+
cfg = SimulatorConfig(
|
|
2716
|
+
configuration_keys=[
|
|
2717
|
+
{"key": "HeartbeatInterval", "value": "300", "readonly": True},
|
|
2718
|
+
{"key": "MeterValueSampleInterval", "value": 900},
|
|
2719
|
+
],
|
|
2720
|
+
configuration_unknown_keys=["UnknownX"],
|
|
2721
|
+
)
|
|
2722
|
+
sim = ChargePointSimulator(cfg)
|
|
2723
|
+
sent: list[list[object]] = []
|
|
2724
|
+
|
|
2725
|
+
async def send(msg: str):
|
|
2726
|
+
sent.append(json.loads(msg))
|
|
2727
|
+
|
|
2728
|
+
async def recv(): # pragma: no cover - should not be called
|
|
2729
|
+
raise AssertionError("recv should not be called for GetConfiguration")
|
|
2730
|
+
|
|
2731
|
+
handled = await sim._handle_csms_call(
|
|
2732
|
+
[
|
|
2733
|
+
2,
|
|
2734
|
+
"cfg-1",
|
|
2735
|
+
"GetConfiguration",
|
|
2736
|
+
{"key": ["HeartbeatInterval", "UnknownX", "MissingKey"]},
|
|
2737
|
+
],
|
|
2738
|
+
send,
|
|
2739
|
+
recv,
|
|
2740
|
+
)
|
|
2741
|
+
self.assertTrue(handled)
|
|
2742
|
+
self.assertEqual(len(sent), 1)
|
|
2743
|
+
frame = sent[0]
|
|
2744
|
+
self.assertEqual(frame[0], 3)
|
|
2745
|
+
self.assertEqual(frame[1], "cfg-1")
|
|
2746
|
+
payload = frame[2]
|
|
2747
|
+
self.assertIn("configurationKey", payload)
|
|
2748
|
+
self.assertEqual(
|
|
2749
|
+
payload["configurationKey"],
|
|
2750
|
+
[{"key": "HeartbeatInterval", "value": "300", "readonly": True}],
|
|
2751
|
+
)
|
|
2752
|
+
self.assertIn("unknownKey", payload)
|
|
2753
|
+
self.assertCountEqual(payload["unknownKey"], ["UnknownX", "MissingKey"])
|
|
2754
|
+
|
|
2755
|
+
async def test_get_configuration_without_filter_returns_all(self):
|
|
2756
|
+
cfg = SimulatorConfig(
|
|
2757
|
+
configuration_keys=[
|
|
2758
|
+
{"key": "AuthorizeRemoteTxRequests", "value": True},
|
|
2759
|
+
{"key": "ConnectorPhaseRotation", "value": "ABC"},
|
|
2760
|
+
],
|
|
2761
|
+
configuration_unknown_keys=["GhostKey"],
|
|
2762
|
+
)
|
|
2763
|
+
sim = ChargePointSimulator(cfg)
|
|
2764
|
+
sent: list[list[object]] = []
|
|
2765
|
+
|
|
2766
|
+
async def send(msg: str):
|
|
2767
|
+
sent.append(json.loads(msg))
|
|
2768
|
+
|
|
2769
|
+
async def recv(): # pragma: no cover - should not be called
|
|
2770
|
+
raise AssertionError("recv should not be called for GetConfiguration")
|
|
2771
|
+
|
|
2772
|
+
handled = await sim._handle_csms_call(
|
|
2773
|
+
[2, "cfg-2", "GetConfiguration", {}],
|
|
2774
|
+
send,
|
|
2775
|
+
recv,
|
|
2776
|
+
)
|
|
2777
|
+
self.assertTrue(handled)
|
|
2778
|
+
frame = sent[0]
|
|
2779
|
+
payload = frame[2]
|
|
2780
|
+
keys = payload.get("configurationKey")
|
|
2781
|
+
self.assertEqual(len(keys), 2)
|
|
2782
|
+
returned_keys = {item["key"] for item in keys}
|
|
2783
|
+
self.assertEqual(
|
|
2784
|
+
returned_keys,
|
|
2785
|
+
{"AuthorizeRemoteTxRequests", "ConnectorPhaseRotation"},
|
|
2786
|
+
)
|
|
2787
|
+
values = {item["key"]: item.get("value") for item in keys}
|
|
2788
|
+
self.assertEqual(values["AuthorizeRemoteTxRequests"], "True")
|
|
2789
|
+
self.assertEqual(values["ConnectorPhaseRotation"], "ABC")
|
|
2790
|
+
self.assertIn("unknownKey", payload)
|
|
2791
|
+
self.assertEqual(payload["unknownKey"], ["GhostKey"])
|
|
2792
|
+
|
|
2793
|
+
async def test_trigger_message_heartbeat_follow_up(self):
|
|
2794
|
+
cfg = SimulatorConfig()
|
|
2795
|
+
sim = ChargePointSimulator(cfg)
|
|
2796
|
+
sent: list[list[object]] = []
|
|
2797
|
+
recv_count = 0
|
|
2798
|
+
|
|
2799
|
+
async def send(msg: str):
|
|
2800
|
+
sent.append(json.loads(msg))
|
|
2801
|
+
|
|
2802
|
+
async def recv():
|
|
2803
|
+
nonlocal recv_count
|
|
2804
|
+
recv_count += 1
|
|
2805
|
+
return json.dumps([3, f"ack-{recv_count}", {}])
|
|
2806
|
+
|
|
2807
|
+
handled = await sim._handle_csms_call(
|
|
2808
|
+
[
|
|
2809
|
+
2,
|
|
2810
|
+
"trigger-req",
|
|
2811
|
+
"TriggerMessage",
|
|
2812
|
+
{"requestedMessage": "Heartbeat"},
|
|
2813
|
+
],
|
|
2814
|
+
send,
|
|
2815
|
+
recv,
|
|
2816
|
+
)
|
|
2817
|
+
|
|
2818
|
+
self.assertTrue(handled)
|
|
2819
|
+
self.assertGreaterEqual(len(sent), 2)
|
|
2820
|
+
result_frame = sent[0]
|
|
2821
|
+
follow_up_frame = sent[1]
|
|
2822
|
+
self.assertEqual(result_frame[0], 3)
|
|
2823
|
+
self.assertEqual(result_frame[1], "trigger-req")
|
|
2824
|
+
self.assertEqual(result_frame[2].get("status"), "Accepted")
|
|
2825
|
+
self.assertEqual(follow_up_frame[0], 2)
|
|
2826
|
+
self.assertEqual(follow_up_frame[2], "Heartbeat")
|
|
2827
|
+
self.assertEqual(recv_count, 1)
|
|
2828
|
+
|
|
2829
|
+
async def test_trigger_message_rejected_for_invalid_connector(self):
|
|
2830
|
+
cfg = SimulatorConfig(connector_id=5)
|
|
2831
|
+
sim = ChargePointSimulator(cfg)
|
|
2832
|
+
sent: list[list[object]] = []
|
|
2833
|
+
|
|
2834
|
+
async def send(msg: str):
|
|
2835
|
+
sent.append(json.loads(msg))
|
|
2836
|
+
|
|
2837
|
+
async def recv(): # pragma: no cover - should not be called
|
|
2838
|
+
raise AssertionError("recv should not be called for rejected TriggerMessage")
|
|
2839
|
+
|
|
2840
|
+
handled = await sim._handle_csms_call(
|
|
2841
|
+
[
|
|
2842
|
+
2,
|
|
2843
|
+
"trigger-invalid",
|
|
2844
|
+
"TriggerMessage",
|
|
2845
|
+
{"requestedMessage": "StatusNotification", "connectorId": 1},
|
|
2846
|
+
],
|
|
2847
|
+
send,
|
|
2848
|
+
recv,
|
|
2849
|
+
)
|
|
2850
|
+
|
|
2851
|
+
self.assertTrue(handled)
|
|
2852
|
+
self.assertEqual(len(sent), 1)
|
|
2853
|
+
self.assertEqual(sent[0][0], 3)
|
|
2854
|
+
self.assertEqual(sent[0][1], "trigger-invalid")
|
|
2855
|
+
self.assertEqual(sent[0][2].get("status"), "Rejected")
|
|
2172
2856
|
|
|
2173
2857
|
|
|
2174
2858
|
class PurgeMeterReadingsTaskTests(TestCase):
|
|
@@ -2275,6 +2959,8 @@ class DispatchActionViewTests(TestCase):
|
|
|
2275
2959
|
)
|
|
2276
2960
|
store.clear_log(self.log_key, log_type="charger")
|
|
2277
2961
|
self.addCleanup(store.clear_log, self.log_key, "charger")
|
|
2962
|
+
store.pending_calls.clear()
|
|
2963
|
+
self.addCleanup(store.pending_calls.clear)
|
|
2278
2964
|
self.url = reverse(
|
|
2279
2965
|
"charger-action-connector",
|
|
2280
2966
|
args=[self.charger.charger_id, self.charger.connector_slug],
|
|
@@ -2321,6 +3007,37 @@ class DispatchActionViewTests(TestCase):
|
|
|
2321
3007
|
any("RemoteStartTransaction" in entry for entry in log_entries)
|
|
2322
3008
|
)
|
|
2323
3009
|
|
|
3010
|
+
def test_change_availability_dispatches_frame(self):
|
|
3011
|
+
response = self.client.post(
|
|
3012
|
+
self.url,
|
|
3013
|
+
data=json.dumps({"action": "change_availability", "type": "Inoperative"}),
|
|
3014
|
+
content_type="application/json",
|
|
3015
|
+
)
|
|
3016
|
+
self.assertEqual(response.status_code, 200)
|
|
3017
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
3018
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
3019
|
+
frame = json.loads(self.ws.sent[0])
|
|
3020
|
+
self.assertEqual(frame[0], 2)
|
|
3021
|
+
self.assertEqual(frame[2], "ChangeAvailability")
|
|
3022
|
+
self.assertEqual(frame[3]["type"], "Inoperative")
|
|
3023
|
+
self.assertEqual(frame[3]["connectorId"], 1)
|
|
3024
|
+
self.assertIn(frame[1], store.pending_calls)
|
|
3025
|
+
self.charger.refresh_from_db()
|
|
3026
|
+
self.assertEqual(self.charger.availability_requested_state, "Inoperative")
|
|
3027
|
+
self.assertIsNotNone(self.charger.availability_requested_at)
|
|
3028
|
+
self.assertEqual(self.charger.availability_request_status, "")
|
|
3029
|
+
|
|
3030
|
+
def test_change_availability_requires_valid_type(self):
|
|
3031
|
+
response = self.client.post(
|
|
3032
|
+
self.url,
|
|
3033
|
+
data=json.dumps({"action": "change_availability"}),
|
|
3034
|
+
content_type="application/json",
|
|
3035
|
+
)
|
|
3036
|
+
self.assertEqual(response.status_code, 400)
|
|
3037
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
3038
|
+
self.assertEqual(self.ws.sent, [])
|
|
3039
|
+
self.assertFalse(store.pending_calls)
|
|
3040
|
+
|
|
2324
3041
|
|
|
2325
3042
|
class ChargerStatusViewTests(TestCase):
|
|
2326
3043
|
def setUp(self):
|
|
@@ -2700,3 +3417,69 @@ class LiveUpdateViewTests(TestCase):
|
|
|
2700
3417
|
ids = [item["charger_id"] for item in payload["chargers"]]
|
|
2701
3418
|
self.assertIn(public.charger_id, ids)
|
|
2702
3419
|
self.assertNotIn(private.charger_id, ids)
|
|
3420
|
+
|
|
3421
|
+
def test_dashboard_restricts_to_owner_users(self):
|
|
3422
|
+
User = get_user_model()
|
|
3423
|
+
owner = User.objects.create_user(username="owner", password="pw")
|
|
3424
|
+
other = User.objects.create_user(username="outsider", password="pw")
|
|
3425
|
+
unrestricted = Charger.objects.create(charger_id="UNRESTRICTED")
|
|
3426
|
+
restricted = Charger.objects.create(charger_id="RESTRICTED")
|
|
3427
|
+
restricted.owner_users.add(owner)
|
|
3428
|
+
|
|
3429
|
+
self.client.force_login(owner)
|
|
3430
|
+
owner_resp = self.client.get(reverse("ocpp-dashboard"))
|
|
3431
|
+
owner_chargers = [item["charger"] for item in owner_resp.context["chargers"]]
|
|
3432
|
+
self.assertIn(unrestricted, owner_chargers)
|
|
3433
|
+
self.assertIn(restricted, owner_chargers)
|
|
3434
|
+
|
|
3435
|
+
self.client.force_login(other)
|
|
3436
|
+
other_resp = self.client.get(reverse("ocpp-dashboard"))
|
|
3437
|
+
other_chargers = [item["charger"] for item in other_resp.context["chargers"]]
|
|
3438
|
+
self.assertIn(unrestricted, other_chargers)
|
|
3439
|
+
self.assertNotIn(restricted, other_chargers)
|
|
3440
|
+
|
|
3441
|
+
self.client.force_login(owner)
|
|
3442
|
+
detail_resp = self.client.get(
|
|
3443
|
+
reverse("charger-page", args=[restricted.charger_id])
|
|
3444
|
+
)
|
|
3445
|
+
self.assertEqual(detail_resp.status_code, 200)
|
|
3446
|
+
|
|
3447
|
+
self.client.force_login(other)
|
|
3448
|
+
denied_resp = self.client.get(
|
|
3449
|
+
reverse("charger-page", args=[restricted.charger_id])
|
|
3450
|
+
)
|
|
3451
|
+
self.assertEqual(denied_resp.status_code, 404)
|
|
3452
|
+
|
|
3453
|
+
def test_dashboard_restricts_to_owner_groups(self):
|
|
3454
|
+
User = get_user_model()
|
|
3455
|
+
group = SecurityGroup.objects.create(name="Operations")
|
|
3456
|
+
member = User.objects.create_user(username="member", password="pw")
|
|
3457
|
+
outsider = User.objects.create_user(username="visitor", password="pw")
|
|
3458
|
+
member.groups.add(group)
|
|
3459
|
+
unrestricted = Charger.objects.create(charger_id="GROUPFREE")
|
|
3460
|
+
restricted = Charger.objects.create(charger_id="GROUPLOCKED")
|
|
3461
|
+
restricted.owner_groups.add(group)
|
|
3462
|
+
|
|
3463
|
+
self.client.force_login(member)
|
|
3464
|
+
member_resp = self.client.get(reverse("ocpp-dashboard"))
|
|
3465
|
+
member_chargers = [item["charger"] for item in member_resp.context["chargers"]]
|
|
3466
|
+
self.assertIn(unrestricted, member_chargers)
|
|
3467
|
+
self.assertIn(restricted, member_chargers)
|
|
3468
|
+
|
|
3469
|
+
self.client.force_login(outsider)
|
|
3470
|
+
outsider_resp = self.client.get(reverse("ocpp-dashboard"))
|
|
3471
|
+
outsider_chargers = [item["charger"] for item in outsider_resp.context["chargers"]]
|
|
3472
|
+
self.assertIn(unrestricted, outsider_chargers)
|
|
3473
|
+
self.assertNotIn(restricted, outsider_chargers)
|
|
3474
|
+
|
|
3475
|
+
self.client.force_login(member)
|
|
3476
|
+
status_resp = self.client.get(
|
|
3477
|
+
reverse("charger-status", args=[restricted.charger_id])
|
|
3478
|
+
)
|
|
3479
|
+
self.assertEqual(status_resp.status_code, 200)
|
|
3480
|
+
|
|
3481
|
+
self.client.force_login(outsider)
|
|
3482
|
+
group_denied = self.client.get(
|
|
3483
|
+
reverse("charger-status", args=[restricted.charger_id])
|
|
3484
|
+
)
|
|
3485
|
+
self.assertEqual(group_denied.status_code, 404)
|