arthexis 0.1.10__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.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
- arthexis-0.1.12.dist-info/RECORD +102 -0
- config/context_processors.py +1 -0
- config/settings.py +31 -5
- config/urls.py +5 -4
- core/admin.py +430 -90
- core/apps.py +48 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +303 -31
- core/reference_utils.py +20 -9
- core/release.py +4 -0
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +250 -1
- core/tasks.py +92 -40
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +169 -3
- core/user_data.py +51 -8
- core/views.py +371 -20
- nodes/admin.py +453 -8
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +374 -31
- nodes/reports.py +411 -0
- nodes/tests.py +677 -38
- nodes/utils.py +32 -0
- nodes/views.py +14 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +517 -16
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +237 -4
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +819 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +233 -19
- pages/admin.py +144 -4
- pages/context_processors.py +21 -7
- pages/defaults.py +13 -0
- pages/forms.py +38 -0
- pages/models.py +189 -15
- pages/tests.py +281 -8
- pages/urls.py +4 -0
- pages/views.py +137 -21
- arthexis-0.1.10.dist-info/RECORD +0 -95
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.10.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
|
|
@@ -141,6 +202,17 @@ class ChargerUrlFallbackTests(TestCase):
|
|
|
141
202
|
self.assertTrue(charger.reference.value.startswith("http://fallback.example"))
|
|
142
203
|
self.assertTrue(charger.reference.value.endswith("/c/NO_SITE/"))
|
|
143
204
|
|
|
205
|
+
def test_reference_not_created_for_loopback_domain(self):
|
|
206
|
+
site = Site.objects.get_current()
|
|
207
|
+
site.domain = "127.0.0.1"
|
|
208
|
+
site.save()
|
|
209
|
+
Site.objects.clear_cache()
|
|
210
|
+
|
|
211
|
+
charger = Charger.objects.create(charger_id="LOCAL_LOOP")
|
|
212
|
+
charger.refresh_from_db()
|
|
213
|
+
|
|
214
|
+
self.assertIsNone(charger.reference)
|
|
215
|
+
|
|
144
216
|
|
|
145
217
|
class SinkConsumerTests(TransactionTestCase):
|
|
146
218
|
async def test_sink_replies(self):
|
|
@@ -304,6 +376,244 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
304
376
|
self.assertTrue(message_mock.save.called)
|
|
305
377
|
self.assertTrue(message_mock.propagate.called)
|
|
306
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
|
+
|
|
307
617
|
async def test_consumption_message_final_update_on_disconnect(self):
|
|
308
618
|
await database_sync_to_async(Charger.objects.create)(charger_id="FINALMSG")
|
|
309
619
|
communicator = WebsocketCommunicator(application, "/FINALMSG/")
|
|
@@ -667,6 +977,25 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
667
977
|
|
|
668
978
|
await communicator.disconnect()
|
|
669
979
|
|
|
980
|
+
async def test_console_reference_skips_loopback_ip(self):
|
|
981
|
+
communicator = ClientWebsocketCommunicator(
|
|
982
|
+
application,
|
|
983
|
+
"/LOCAL/",
|
|
984
|
+
client=("127.0.0.1", 34567),
|
|
985
|
+
)
|
|
986
|
+
connected, _ = await communicator.connect()
|
|
987
|
+
self.assertTrue(connected)
|
|
988
|
+
|
|
989
|
+
await communicator.send_json_to([2, "1", "BootNotification", {}])
|
|
990
|
+
await communicator.receive_json_from()
|
|
991
|
+
|
|
992
|
+
exists = await database_sync_to_async(
|
|
993
|
+
lambda: Reference.objects.filter(alt_text="LOCAL Console").exists()
|
|
994
|
+
)()
|
|
995
|
+
self.assertFalse(exists)
|
|
996
|
+
|
|
997
|
+
await communicator.disconnect()
|
|
998
|
+
|
|
670
999
|
async def test_transaction_created_from_meter_values(self):
|
|
671
1000
|
communicator = WebsocketCommunicator(application, "/NOSTART/")
|
|
672
1001
|
connected, _ = await communicator.connect()
|
|
@@ -1123,6 +1452,92 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1123
1452
|
if connected3_retry and communicator3_retry is not None:
|
|
1124
1453
|
await communicator3_retry.disconnect()
|
|
1125
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
|
+
|
|
1126
1541
|
|
|
1127
1542
|
class ChargerLandingTests(TestCase):
|
|
1128
1543
|
def setUp(self):
|
|
@@ -1155,6 +1570,21 @@ class ChargerLandingTests(TestCase):
|
|
|
1155
1570
|
self.assertEqual(resp.status_code, 200)
|
|
1156
1571
|
self.assertContains(resp, "PAGE2")
|
|
1157
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
|
+
|
|
1158
1588
|
def test_charger_page_shows_progress(self):
|
|
1159
1589
|
charger = Charger.objects.create(charger_id="STATS")
|
|
1160
1590
|
tx = Transaction.objects.create(
|
|
@@ -1276,6 +1706,12 @@ class SimulatorLandingTests(TestCase):
|
|
|
1276
1706
|
self.assertContains(resp, "/ocpp/")
|
|
1277
1707
|
self.assertContains(resp, "/ocpp/simulator/")
|
|
1278
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
|
+
|
|
1279
1715
|
|
|
1280
1716
|
class ChargerAdminTests(TestCase):
|
|
1281
1717
|
def setUp(self):
|
|
@@ -1344,6 +1780,35 @@ class ChargerAdminTests(TestCase):
|
|
|
1344
1780
|
self.assertNotContains(resp, 'name="last_heartbeat"')
|
|
1345
1781
|
self.assertNotContains(resp, 'name="last_meter_values"')
|
|
1346
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
|
+
|
|
1347
1812
|
def test_purge_action_removes_data(self):
|
|
1348
1813
|
charger = Charger.objects.create(charger_id="PURGE1")
|
|
1349
1814
|
Transaction.objects.create(
|
|
@@ -1381,6 +1846,85 @@ class ChargerAdminTests(TestCase):
|
|
|
1381
1846
|
self.client.post(delete_url, {"post": "yes"})
|
|
1382
1847
|
self.assertFalse(Charger.objects.filter(pk=charger.pk).exists())
|
|
1383
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
|
+
|
|
1384
1928
|
|
|
1385
1929
|
class LocationAdminTests(TestCase):
|
|
1386
1930
|
def setUp(self):
|
|
@@ -1527,6 +2071,22 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1527
2071
|
self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)
|
|
1528
2072
|
store.simulators.pop(sim.pk, None)
|
|
1529
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
|
+
|
|
1530
2090
|
def test_as_config_includes_custom_fields(self):
|
|
1531
2091
|
sim = Simulator.objects.create(
|
|
1532
2092
|
name="SIM3",
|
|
@@ -1536,6 +2096,10 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1536
2096
|
duration=500,
|
|
1537
2097
|
pre_charge_delay=5,
|
|
1538
2098
|
vin="WP0ZZZ99999999999",
|
|
2099
|
+
configuration_keys=[
|
|
2100
|
+
{"key": "HeartbeatInterval", "value": "300", "readonly": True}
|
|
2101
|
+
],
|
|
2102
|
+
configuration_unknown_keys=["Bogus"],
|
|
1539
2103
|
)
|
|
1540
2104
|
cfg = sim.as_config()
|
|
1541
2105
|
self.assertEqual(cfg.interval, 3.5)
|
|
@@ -1543,6 +2107,11 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1543
2107
|
self.assertEqual(cfg.duration, 500)
|
|
1544
2108
|
self.assertEqual(cfg.pre_charge_delay, 5)
|
|
1545
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"])
|
|
1546
2115
|
|
|
1547
2116
|
def _post_simulator_change(self, sim: Simulator, **overrides):
|
|
1548
2117
|
url = reverse("admin:ocpp_simulator_change", args=[sim.pk])
|
|
@@ -1560,6 +2129,10 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1560
2129
|
"username": sim.username,
|
|
1561
2130
|
"password": sim.password,
|
|
1562
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
|
+
),
|
|
1563
2136
|
"_save": "Save",
|
|
1564
2137
|
}
|
|
1565
2138
|
data.update(overrides)
|
|
@@ -2137,8 +2710,149 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
2137
2710
|
idx for idx, payload in enumerate(status_payloads) if payload.get("errorCode") == "NoError"
|
|
2138
2711
|
)
|
|
2139
2712
|
self.assertLess(first_open, first_close)
|
|
2140
|
-
|
|
2141
|
-
|
|
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")
|
|
2142
2856
|
|
|
2143
2857
|
|
|
2144
2858
|
class PurgeMeterReadingsTaskTests(TestCase):
|
|
@@ -2245,6 +2959,8 @@ class DispatchActionViewTests(TestCase):
|
|
|
2245
2959
|
)
|
|
2246
2960
|
store.clear_log(self.log_key, log_type="charger")
|
|
2247
2961
|
self.addCleanup(store.clear_log, self.log_key, "charger")
|
|
2962
|
+
store.pending_calls.clear()
|
|
2963
|
+
self.addCleanup(store.pending_calls.clear)
|
|
2248
2964
|
self.url = reverse(
|
|
2249
2965
|
"charger-action-connector",
|
|
2250
2966
|
args=[self.charger.charger_id, self.charger.connector_slug],
|
|
@@ -2291,6 +3007,37 @@ class DispatchActionViewTests(TestCase):
|
|
|
2291
3007
|
any("RemoteStartTransaction" in entry for entry in log_entries)
|
|
2292
3008
|
)
|
|
2293
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
|
+
|
|
2294
3041
|
|
|
2295
3042
|
class ChargerStatusViewTests(TestCase):
|
|
2296
3043
|
def setUp(self):
|
|
@@ -2670,3 +3417,69 @@ class LiveUpdateViewTests(TestCase):
|
|
|
2670
3417
|
ids = [item["charger_id"] for item in payload["chargers"]]
|
|
2671
3418
|
self.assertIn(public.charger_id, ids)
|
|
2672
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)
|