arthexis 0.1.11__py3-none-any.whl → 0.1.13__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.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +49 -78
- config/settings_helpers.py +109 -0
- core/admin.py +293 -78
- core/apps.py +21 -0
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +203 -47
- core/reference_utils.py +1 -1
- core/release.py +42 -20
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +75 -1
- core/views.py +178 -29
- core/widgets.py +43 -0
- nodes/admin.py +583 -10
- nodes/apps.py +15 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +287 -49
- nodes/reports.py +411 -0
- nodes/tests.py +990 -42
- nodes/urls.py +1 -0
- nodes/utils.py +32 -0
- nodes/views.py +173 -5
- ocpp/admin.py +424 -17
- ocpp/consumers.py +630 -15
- ocpp/evcs.py +7 -94
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +236 -4
- ocpp/routing.py +4 -2
- ocpp/simulator.py +346 -26
- ocpp/status_display.py +26 -0
- ocpp/store.py +110 -2
- ocpp/tests.py +1425 -33
- ocpp/transactions_io.py +27 -3
- ocpp/views.py +344 -38
- pages/admin.py +138 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +67 -0
- pages/models.py +136 -1
- pages/tests.py +379 -4
- pages/urls.py +1 -0
- pages/views.py +64 -7
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
ocpp/tests.py
CHANGED
|
@@ -1,44 +1,84 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
from importlib import util as importlib_util
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import ModuleType
|
|
2
7
|
|
|
3
8
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
4
9
|
|
|
10
|
+
try: # pragma: no cover - exercised via test command imports
|
|
11
|
+
import tests.conftest as tests_conftest # type: ignore[import-not-found]
|
|
12
|
+
except ModuleNotFoundError: # pragma: no cover - fallback for pytest importlib mode
|
|
13
|
+
tests_dir = Path(__file__).resolve().parents[1] / "tests"
|
|
14
|
+
spec = importlib_util.spec_from_file_location(
|
|
15
|
+
"tests.conftest", tests_dir / "conftest.py"
|
|
16
|
+
)
|
|
17
|
+
if spec is None or spec.loader is None: # pragma: no cover - defensive
|
|
18
|
+
raise
|
|
19
|
+
tests_conftest = importlib_util.module_from_spec(spec)
|
|
20
|
+
package = sys.modules.setdefault("tests", ModuleType("tests"))
|
|
21
|
+
package.__path__ = [str(tests_dir)]
|
|
22
|
+
sys.modules.setdefault("tests.conftest", tests_conftest)
|
|
23
|
+
spec.loader.exec_module(tests_conftest)
|
|
24
|
+
else:
|
|
25
|
+
sys.modules.setdefault("tests.conftest", tests_conftest)
|
|
26
|
+
|
|
5
27
|
import django
|
|
6
28
|
|
|
29
|
+
django.setup = tests_conftest._original_setup
|
|
7
30
|
django.setup()
|
|
8
31
|
|
|
9
32
|
from asgiref.testing import ApplicationCommunicator
|
|
10
33
|
from channels.testing import WebsocketCommunicator
|
|
11
34
|
from channels.db import database_sync_to_async
|
|
12
35
|
from asgiref.sync import async_to_sync
|
|
13
|
-
from django.test import
|
|
36
|
+
from django.test import (
|
|
37
|
+
Client,
|
|
38
|
+
RequestFactory,
|
|
39
|
+
TransactionTestCase,
|
|
40
|
+
TestCase,
|
|
41
|
+
override_settings,
|
|
42
|
+
)
|
|
14
43
|
from unittest import skip
|
|
15
44
|
from contextlib import suppress
|
|
16
45
|
from types import SimpleNamespace
|
|
17
|
-
from unittest.mock import patch, Mock
|
|
46
|
+
from unittest.mock import patch, Mock, AsyncMock
|
|
18
47
|
from django.contrib.auth import get_user_model
|
|
19
48
|
from django.urls import reverse
|
|
20
49
|
from django.utils import timezone
|
|
21
50
|
from django.utils.dateparse import parse_datetime
|
|
51
|
+
from django.utils.encoding import force_str
|
|
22
52
|
from django.utils.translation import override, gettext as _
|
|
23
53
|
from django.contrib.sites.models import Site
|
|
54
|
+
from django.core.exceptions import ValidationError
|
|
24
55
|
from pages.models import Application, Module
|
|
25
56
|
from nodes.models import Node, NodeRole
|
|
26
57
|
|
|
27
58
|
from config.asgi import application
|
|
28
59
|
|
|
29
|
-
from .models import
|
|
60
|
+
from .models import (
|
|
61
|
+
Transaction,
|
|
62
|
+
Charger,
|
|
63
|
+
Simulator,
|
|
64
|
+
MeterReading,
|
|
65
|
+
Location,
|
|
66
|
+
DataTransferMessage,
|
|
67
|
+
)
|
|
30
68
|
from .consumers import CSMSConsumer
|
|
31
|
-
from
|
|
69
|
+
from .views import dispatch_action
|
|
70
|
+
from .status_display import STATUS_BADGE_MAP
|
|
71
|
+
from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
|
|
32
72
|
from . import store
|
|
33
73
|
from django.db.models.deletion import ProtectedError
|
|
34
74
|
from decimal import Decimal
|
|
35
75
|
import json
|
|
36
76
|
import websockets
|
|
37
77
|
import asyncio
|
|
38
|
-
from pathlib import Path
|
|
39
78
|
from .simulator import SimulatorConfig, ChargePointSimulator
|
|
79
|
+
from .evcs import simulate, SimulatorState, _simulators
|
|
40
80
|
import re
|
|
41
|
-
from datetime import datetime, timedelta
|
|
81
|
+
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
42
82
|
from .tasks import purge_meter_readings
|
|
43
83
|
from django.db import close_old_connections
|
|
44
84
|
from django.db.utils import OperationalError
|
|
@@ -87,6 +127,47 @@ class DummyWebSocket:
|
|
|
87
127
|
self.sent.append(message)
|
|
88
128
|
|
|
89
129
|
|
|
130
|
+
class DispatchActionTests(TestCase):
|
|
131
|
+
def setUp(self):
|
|
132
|
+
self.factory = RequestFactory()
|
|
133
|
+
|
|
134
|
+
def tearDown(self): # pragma: no cover - cleanup guard
|
|
135
|
+
store.pending_calls.clear()
|
|
136
|
+
store.triggered_followups.clear()
|
|
137
|
+
|
|
138
|
+
def test_trigger_message_registers_pending_call(self):
|
|
139
|
+
charger = Charger.objects.create(charger_id="TRIGGER1")
|
|
140
|
+
dummy = DummyWebSocket()
|
|
141
|
+
key = store.set_connection("TRIGGER1", None, dummy)
|
|
142
|
+
self.addCleanup(lambda: store.connections.pop(key, None))
|
|
143
|
+
log_key = store.identity_key("TRIGGER1", None)
|
|
144
|
+
store.clear_log(log_key, log_type="charger")
|
|
145
|
+
self.addCleanup(lambda: store.clear_log(log_key, log_type="charger"))
|
|
146
|
+
|
|
147
|
+
request = self.factory.post(
|
|
148
|
+
"/chargers/TRIGGER1/action/",
|
|
149
|
+
data=json.dumps({"action": "trigger_message", "target": "BootNotification"}),
|
|
150
|
+
content_type="application/json",
|
|
151
|
+
)
|
|
152
|
+
request.user = SimpleNamespace(
|
|
153
|
+
is_authenticated=True,
|
|
154
|
+
is_superuser=True,
|
|
155
|
+
is_staff=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
response = dispatch_action(request, "TRIGGER1")
|
|
159
|
+
self.assertEqual(response.status_code, 200)
|
|
160
|
+
self.assertTrue(dummy.sent)
|
|
161
|
+
frame = json.loads(dummy.sent[-1])
|
|
162
|
+
self.assertEqual(frame[0], 2)
|
|
163
|
+
self.assertEqual(frame[2], "TriggerMessage")
|
|
164
|
+
message_id = frame[1]
|
|
165
|
+
self.assertIn(message_id, store.pending_calls)
|
|
166
|
+
metadata = store.pending_calls[message_id]
|
|
167
|
+
self.assertEqual(metadata.get("action"), "TriggerMessage")
|
|
168
|
+
self.assertEqual(metadata.get("trigger_target"), "BootNotification")
|
|
169
|
+
self.assertEqual(metadata.get("log_key"), log_key)
|
|
170
|
+
|
|
90
171
|
class ChargerFixtureTests(TestCase):
|
|
91
172
|
fixtures = [
|
|
92
173
|
p.name
|
|
@@ -188,6 +269,39 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
188
269
|
|
|
189
270
|
await communicator.disconnect()
|
|
190
271
|
|
|
272
|
+
async def test_rejected_connection_logs_query_string(self):
|
|
273
|
+
raw_serial = "<charger_id>"
|
|
274
|
+
query_string = "chargeboxid=%3Ccharger_id%3E"
|
|
275
|
+
pending_key = store.pending_key(Charger.normalize_serial(raw_serial))
|
|
276
|
+
store.ip_connections.clear()
|
|
277
|
+
store.clear_log(pending_key, log_type="charger")
|
|
278
|
+
|
|
279
|
+
communicator = ClientWebsocketCommunicator(
|
|
280
|
+
application, f"/?{query_string}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
connected = await communicator.connect()
|
|
285
|
+
self.assertEqual(connected, (False, 4003))
|
|
286
|
+
|
|
287
|
+
log_entries = store.get_logs(pending_key, log_type="charger")
|
|
288
|
+
self.assertTrue(
|
|
289
|
+
any(
|
|
290
|
+
"Rejected connection:" in entry and query_string in entry
|
|
291
|
+
for entry in log_entries
|
|
292
|
+
),
|
|
293
|
+
log_entries,
|
|
294
|
+
)
|
|
295
|
+
finally:
|
|
296
|
+
store.ip_connections.clear()
|
|
297
|
+
store.clear_log(pending_key, log_type="charger")
|
|
298
|
+
lower_key = pending_key.lower()
|
|
299
|
+
for key in list(store.logs["charger"].keys()):
|
|
300
|
+
if key.lower() == lower_key:
|
|
301
|
+
store.logs["charger"].pop(key, None)
|
|
302
|
+
with suppress(Exception):
|
|
303
|
+
await communicator.disconnect()
|
|
304
|
+
|
|
191
305
|
async def test_transaction_saved(self):
|
|
192
306
|
communicator = WebsocketCommunicator(application, "/TEST/")
|
|
193
307
|
connected, _ = await communicator.connect()
|
|
@@ -227,9 +341,47 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
227
341
|
|
|
228
342
|
await communicator.disconnect()
|
|
229
343
|
|
|
344
|
+
def test_status_notification_available_clears_active_session(self):
|
|
345
|
+
aggregate = Charger.objects.create(charger_id="STATUSCLR")
|
|
346
|
+
connector = Charger.objects.create(
|
|
347
|
+
charger_id="STATUSCLR",
|
|
348
|
+
connector_id=2,
|
|
349
|
+
)
|
|
350
|
+
tx = Transaction.objects.create(
|
|
351
|
+
charger=connector,
|
|
352
|
+
meter_start=10,
|
|
353
|
+
start_time=timezone.now(),
|
|
354
|
+
)
|
|
355
|
+
store_key = store.identity_key("STATUSCLR", 2)
|
|
356
|
+
store.transactions[store_key] = tx
|
|
357
|
+
consumer = CSMSConsumer()
|
|
358
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
359
|
+
consumer.charger_id = "STATUSCLR"
|
|
360
|
+
consumer.store_key = store_key
|
|
361
|
+
consumer.connector_value = 2
|
|
362
|
+
consumer.charger = connector
|
|
363
|
+
consumer.aggregate_charger = aggregate
|
|
364
|
+
consumer._consumption_task = None
|
|
365
|
+
consumer._consumption_message_uuid = None
|
|
366
|
+
consumer.send = AsyncMock()
|
|
367
|
+
payload = {
|
|
368
|
+
"connectorId": 2,
|
|
369
|
+
"status": "Available",
|
|
370
|
+
"errorCode": "NoError",
|
|
371
|
+
"transactionId": tx.pk,
|
|
372
|
+
}
|
|
373
|
+
try:
|
|
374
|
+
with patch.object(consumer, "_assign_connector", new=AsyncMock()):
|
|
375
|
+
async_to_sync(consumer.receive)(
|
|
376
|
+
text_data=json.dumps([2, "1", "StatusNotification", payload])
|
|
377
|
+
)
|
|
378
|
+
finally:
|
|
379
|
+
store.transactions.pop(store_key, None)
|
|
380
|
+
self.assertNotIn(store_key, store.transactions)
|
|
381
|
+
|
|
230
382
|
async def test_rfid_recorded(self):
|
|
231
383
|
await database_sync_to_async(Charger.objects.create)(charger_id="RFIDREC")
|
|
232
|
-
communicator = WebsocketCommunicator(application, "/RFIDREC
|
|
384
|
+
communicator = WebsocketCommunicator(application, "/RFIDREC/?cid=RFIDREC")
|
|
233
385
|
connected, _ = await communicator.connect()
|
|
234
386
|
self.assertTrue(connected)
|
|
235
387
|
|
|
@@ -244,6 +396,82 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
244
396
|
)
|
|
245
397
|
self.assertEqual(tx.rfid, "TAG123")
|
|
246
398
|
|
|
399
|
+
tag = await database_sync_to_async(RFID.objects.get)(rfid="TAG123")
|
|
400
|
+
self.assertFalse(tag.allowed)
|
|
401
|
+
self.assertIsNotNone(tag.last_seen_on)
|
|
402
|
+
|
|
403
|
+
await communicator.disconnect()
|
|
404
|
+
|
|
405
|
+
async def test_start_transaction_uses_payload_timestamp(self):
|
|
406
|
+
communicator = WebsocketCommunicator(application, "/STAMPED/")
|
|
407
|
+
connected, _ = await communicator.connect()
|
|
408
|
+
self.assertTrue(connected)
|
|
409
|
+
|
|
410
|
+
start_ts = datetime(2025, 9, 26, 18, 1, tzinfo=dt_timezone.utc)
|
|
411
|
+
before = timezone.now()
|
|
412
|
+
await communicator.send_json_to(
|
|
413
|
+
[
|
|
414
|
+
2,
|
|
415
|
+
"1",
|
|
416
|
+
"StartTransaction",
|
|
417
|
+
{"meterStart": 5, "timestamp": start_ts.isoformat()},
|
|
418
|
+
]
|
|
419
|
+
)
|
|
420
|
+
response = await communicator.receive_json_from()
|
|
421
|
+
after = timezone.now()
|
|
422
|
+
tx_id = response[2]["transactionId"]
|
|
423
|
+
|
|
424
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
425
|
+
pk=tx_id, charger__charger_id="STAMPED"
|
|
426
|
+
)
|
|
427
|
+
self.assertEqual(tx.start_time, start_ts)
|
|
428
|
+
self.assertIsNotNone(tx.received_start_time)
|
|
429
|
+
self.assertGreaterEqual(tx.received_start_time, before)
|
|
430
|
+
self.assertLessEqual(tx.received_start_time, after)
|
|
431
|
+
|
|
432
|
+
await communicator.disconnect()
|
|
433
|
+
|
|
434
|
+
async def test_stop_transaction_uses_payload_timestamp(self):
|
|
435
|
+
communicator = WebsocketCommunicator(application, "/STOPSTAMP/")
|
|
436
|
+
connected, _ = await communicator.connect()
|
|
437
|
+
self.assertTrue(connected)
|
|
438
|
+
|
|
439
|
+
await communicator.send_json_to(
|
|
440
|
+
[
|
|
441
|
+
2,
|
|
442
|
+
"1",
|
|
443
|
+
"StartTransaction",
|
|
444
|
+
{"meterStart": 10},
|
|
445
|
+
]
|
|
446
|
+
)
|
|
447
|
+
response = await communicator.receive_json_from()
|
|
448
|
+
tx_id = response[2]["transactionId"]
|
|
449
|
+
|
|
450
|
+
stop_ts = datetime(2025, 9, 26, 18, 5, tzinfo=dt_timezone.utc)
|
|
451
|
+
before = timezone.now()
|
|
452
|
+
await communicator.send_json_to(
|
|
453
|
+
[
|
|
454
|
+
2,
|
|
455
|
+
"2",
|
|
456
|
+
"StopTransaction",
|
|
457
|
+
{
|
|
458
|
+
"transactionId": tx_id,
|
|
459
|
+
"meterStop": 20,
|
|
460
|
+
"timestamp": stop_ts.isoformat(),
|
|
461
|
+
},
|
|
462
|
+
]
|
|
463
|
+
)
|
|
464
|
+
await communicator.receive_json_from()
|
|
465
|
+
after = timezone.now()
|
|
466
|
+
|
|
467
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
468
|
+
pk=tx_id, charger__charger_id="STOPSTAMP"
|
|
469
|
+
)
|
|
470
|
+
self.assertEqual(tx.stop_time, stop_ts)
|
|
471
|
+
self.assertIsNotNone(tx.received_stop_time)
|
|
472
|
+
self.assertGreaterEqual(tx.received_stop_time, before)
|
|
473
|
+
self.assertLessEqual(tx.received_stop_time, after)
|
|
474
|
+
|
|
247
475
|
await communicator.disconnect()
|
|
248
476
|
|
|
249
477
|
async def test_start_transaction_sends_net_message(self):
|
|
@@ -315,6 +543,301 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
315
543
|
self.assertTrue(message_mock.save.called)
|
|
316
544
|
self.assertTrue(message_mock.propagate.called)
|
|
317
545
|
|
|
546
|
+
async def test_assign_connector_promotes_pending_connection(self):
|
|
547
|
+
serial = "ASSIGNPROMOTE"
|
|
548
|
+
path = f"/{serial}/"
|
|
549
|
+
pending_key = store.pending_key(serial)
|
|
550
|
+
new_key = store.identity_key(serial, 1)
|
|
551
|
+
aggregate_key = store.identity_key(serial, None)
|
|
552
|
+
|
|
553
|
+
store.connections.pop(pending_key, None)
|
|
554
|
+
store.connections.pop(new_key, None)
|
|
555
|
+
store.logs["charger"].pop(new_key, None)
|
|
556
|
+
store.log_names["charger"].pop(new_key, None)
|
|
557
|
+
store.log_names["charger"].pop(aggregate_key, None)
|
|
558
|
+
|
|
559
|
+
aggregate = await database_sync_to_async(Charger.objects.create)(
|
|
560
|
+
charger_id=serial,
|
|
561
|
+
connector_id=None,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
consumer = CSMSConsumer()
|
|
565
|
+
consumer.scope = {"path": path, "headers": [], "client": ("127.0.0.1", 1234)}
|
|
566
|
+
consumer.charger_id = serial
|
|
567
|
+
consumer.store_key = pending_key
|
|
568
|
+
consumer.connector_value = None
|
|
569
|
+
consumer.client_ip = "127.0.0.1"
|
|
570
|
+
consumer.charger = aggregate
|
|
571
|
+
consumer.aggregate_charger = aggregate
|
|
572
|
+
|
|
573
|
+
store.connections[pending_key] = consumer
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
with patch.object(Charger, "refresh_manager_node", autospec=True) as mock_refresh:
|
|
577
|
+
mock_refresh.return_value = None
|
|
578
|
+
await consumer._assign_connector(1)
|
|
579
|
+
|
|
580
|
+
self.assertEqual(consumer.store_key, new_key)
|
|
581
|
+
self.assertNotIn(pending_key, store.connections)
|
|
582
|
+
|
|
583
|
+
connector = await database_sync_to_async(Charger.objects.get)(
|
|
584
|
+
charger_id=serial,
|
|
585
|
+
connector_id=1,
|
|
586
|
+
)
|
|
587
|
+
self.assertEqual(consumer.charger.pk, connector.pk)
|
|
588
|
+
self.assertEqual(consumer.charger.connector_id, 1)
|
|
589
|
+
self.assertIsNone(consumer.aggregate_charger.connector_id)
|
|
590
|
+
|
|
591
|
+
self.assertIn(new_key, store.log_names["charger"])
|
|
592
|
+
self.assertIn(aggregate_key, store.log_names["charger"])
|
|
593
|
+
|
|
594
|
+
self.assertNotIn(pending_key, store.connections)
|
|
595
|
+
finally:
|
|
596
|
+
store.connections.pop(new_key, None)
|
|
597
|
+
store.connections.pop(pending_key, None)
|
|
598
|
+
store.logs["charger"].pop(new_key, None)
|
|
599
|
+
store.log_names["charger"].pop(new_key, None)
|
|
600
|
+
store.log_names["charger"].pop(aggregate_key, None)
|
|
601
|
+
await database_sync_to_async(Charger.objects.filter(charger_id=serial).delete)()
|
|
602
|
+
|
|
603
|
+
async def test_change_availability_result_updates_model(self):
|
|
604
|
+
store.pending_calls.clear()
|
|
605
|
+
communicator = WebsocketCommunicator(application, "/AVAILRES/")
|
|
606
|
+
connected, _ = await communicator.connect()
|
|
607
|
+
self.assertTrue(connected)
|
|
608
|
+
|
|
609
|
+
await communicator.send_json_to(
|
|
610
|
+
[
|
|
611
|
+
2,
|
|
612
|
+
"boot",
|
|
613
|
+
"BootNotification",
|
|
614
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
615
|
+
]
|
|
616
|
+
)
|
|
617
|
+
await communicator.receive_json_from()
|
|
618
|
+
|
|
619
|
+
message_id = "ca-result"
|
|
620
|
+
requested_at = timezone.now()
|
|
621
|
+
store.register_pending_call(
|
|
622
|
+
message_id,
|
|
623
|
+
{
|
|
624
|
+
"action": "ChangeAvailability",
|
|
625
|
+
"charger_id": "AVAILRES",
|
|
626
|
+
"connector_id": None,
|
|
627
|
+
"availability_type": "Inoperative",
|
|
628
|
+
"requested_at": requested_at,
|
|
629
|
+
},
|
|
630
|
+
)
|
|
631
|
+
await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
|
|
632
|
+
await asyncio.sleep(0.05)
|
|
633
|
+
|
|
634
|
+
charger = await database_sync_to_async(Charger.objects.get)(
|
|
635
|
+
charger_id="AVAILRES", connector_id=None
|
|
636
|
+
)
|
|
637
|
+
self.assertEqual(charger.availability_state, "Inoperative")
|
|
638
|
+
self.assertEqual(charger.availability_request_status, "Accepted")
|
|
639
|
+
self.assertEqual(charger.availability_requested_state, "Inoperative")
|
|
640
|
+
await communicator.disconnect()
|
|
641
|
+
|
|
642
|
+
async def test_get_configuration_result_logged(self):
|
|
643
|
+
store.pending_calls.clear()
|
|
644
|
+
pending_key = store.pending_key("CFGRES")
|
|
645
|
+
store.clear_log(pending_key, log_type="charger")
|
|
646
|
+
log_key = store.identity_key("CFGRES", None)
|
|
647
|
+
store.clear_log(log_key, log_type="charger")
|
|
648
|
+
communicator = WebsocketCommunicator(application, "/CFGRES/")
|
|
649
|
+
connected, _ = await communicator.connect()
|
|
650
|
+
self.assertTrue(connected)
|
|
651
|
+
|
|
652
|
+
message_id = "cfg-result"
|
|
653
|
+
payload = {
|
|
654
|
+
"configurationKey": [
|
|
655
|
+
{
|
|
656
|
+
"key": "AllowOfflineTxForUnknownId",
|
|
657
|
+
"readonly": True,
|
|
658
|
+
"value": "false",
|
|
659
|
+
}
|
|
660
|
+
]
|
|
661
|
+
}
|
|
662
|
+
store.register_pending_call(
|
|
663
|
+
message_id,
|
|
664
|
+
{
|
|
665
|
+
"action": "GetConfiguration",
|
|
666
|
+
"charger_id": "CFGRES",
|
|
667
|
+
"connector_id": None,
|
|
668
|
+
"log_key": log_key,
|
|
669
|
+
"requested_at": timezone.now(),
|
|
670
|
+
},
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
await communicator.send_json_to([3, message_id, payload])
|
|
674
|
+
await asyncio.sleep(0.05)
|
|
675
|
+
|
|
676
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
677
|
+
self.assertTrue(
|
|
678
|
+
any("GetConfiguration result" in entry for entry in log_entries)
|
|
679
|
+
)
|
|
680
|
+
self.assertNotIn(message_id, store.pending_calls)
|
|
681
|
+
|
|
682
|
+
await communicator.disconnect()
|
|
683
|
+
store.clear_log(log_key, log_type="charger")
|
|
684
|
+
store.clear_log(pending_key, log_type="charger")
|
|
685
|
+
|
|
686
|
+
async def test_get_configuration_error_logged(self):
|
|
687
|
+
store.pending_calls.clear()
|
|
688
|
+
pending_key = store.pending_key("CFGERR")
|
|
689
|
+
store.clear_log(pending_key, log_type="charger")
|
|
690
|
+
log_key = store.identity_key("CFGERR", None)
|
|
691
|
+
store.clear_log(log_key, log_type="charger")
|
|
692
|
+
communicator = WebsocketCommunicator(application, "/CFGERR/")
|
|
693
|
+
connected, _ = await communicator.connect()
|
|
694
|
+
self.assertTrue(connected)
|
|
695
|
+
|
|
696
|
+
message_id = "cfg-error"
|
|
697
|
+
store.register_pending_call(
|
|
698
|
+
message_id,
|
|
699
|
+
{
|
|
700
|
+
"action": "GetConfiguration",
|
|
701
|
+
"charger_id": "CFGERR",
|
|
702
|
+
"connector_id": None,
|
|
703
|
+
"log_key": log_key,
|
|
704
|
+
"requested_at": timezone.now(),
|
|
705
|
+
},
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
await communicator.send_json_to(
|
|
709
|
+
[4, message_id, "InternalError", "Boom", {"detail": "nope"}]
|
|
710
|
+
)
|
|
711
|
+
await asyncio.sleep(0.05)
|
|
712
|
+
|
|
713
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
714
|
+
self.assertTrue(
|
|
715
|
+
any("GetConfiguration error" in entry for entry in log_entries)
|
|
716
|
+
)
|
|
717
|
+
self.assertNotIn(message_id, store.pending_calls)
|
|
718
|
+
|
|
719
|
+
await communicator.disconnect()
|
|
720
|
+
store.clear_log(log_key, log_type="charger")
|
|
721
|
+
store.clear_log(pending_key, log_type="charger")
|
|
722
|
+
|
|
723
|
+
async def test_trigger_message_follow_up_logged(self):
|
|
724
|
+
store.pending_calls.clear()
|
|
725
|
+
cid = "TRIGLOG"
|
|
726
|
+
pending_key = store.pending_key(cid)
|
|
727
|
+
log_key = store.identity_key(cid, None)
|
|
728
|
+
store.clear_log(pending_key, log_type="charger")
|
|
729
|
+
store.clear_log(log_key, log_type="charger")
|
|
730
|
+
|
|
731
|
+
communicator = WebsocketCommunicator(application, f"/{cid}/")
|
|
732
|
+
connected, _ = await communicator.connect()
|
|
733
|
+
self.assertTrue(connected)
|
|
734
|
+
|
|
735
|
+
await communicator.send_json_to(
|
|
736
|
+
[
|
|
737
|
+
2,
|
|
738
|
+
"boot",
|
|
739
|
+
"BootNotification",
|
|
740
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
741
|
+
]
|
|
742
|
+
)
|
|
743
|
+
await communicator.receive_json_from()
|
|
744
|
+
|
|
745
|
+
message_id = "trigger-result"
|
|
746
|
+
store.register_pending_call(
|
|
747
|
+
message_id,
|
|
748
|
+
{
|
|
749
|
+
"action": "TriggerMessage",
|
|
750
|
+
"charger_id": cid,
|
|
751
|
+
"connector_id": None,
|
|
752
|
+
"log_key": log_key,
|
|
753
|
+
"trigger_target": "BootNotification",
|
|
754
|
+
"trigger_connector": None,
|
|
755
|
+
},
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
|
|
759
|
+
await asyncio.sleep(0.05)
|
|
760
|
+
self.assertNotIn(message_id, store.pending_calls)
|
|
761
|
+
|
|
762
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
763
|
+
self.assertTrue(
|
|
764
|
+
any(
|
|
765
|
+
"TriggerMessage BootNotification result" in entry
|
|
766
|
+
or "TriggerMessage result" in entry
|
|
767
|
+
for entry in log_entries
|
|
768
|
+
)
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
await communicator.send_json_to(
|
|
772
|
+
[
|
|
773
|
+
2,
|
|
774
|
+
"trigger-follow",
|
|
775
|
+
"BootNotification",
|
|
776
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
777
|
+
]
|
|
778
|
+
)
|
|
779
|
+
await communicator.receive_json_from()
|
|
780
|
+
await asyncio.sleep(0.05)
|
|
781
|
+
|
|
782
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
783
|
+
self.assertTrue(
|
|
784
|
+
any(
|
|
785
|
+
"TriggerMessage follow-up received: BootNotification" in entry
|
|
786
|
+
for entry in log_entries
|
|
787
|
+
)
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
await communicator.disconnect()
|
|
791
|
+
store.clear_log(log_key, log_type="charger")
|
|
792
|
+
store.clear_log(pending_key, log_type="charger")
|
|
793
|
+
|
|
794
|
+
async def test_status_notification_updates_availability_state(self):
|
|
795
|
+
store.pending_calls.clear()
|
|
796
|
+
communicator = WebsocketCommunicator(application, "/STATAVAIL/")
|
|
797
|
+
connected, _ = await communicator.connect()
|
|
798
|
+
self.assertTrue(connected)
|
|
799
|
+
|
|
800
|
+
await communicator.send_json_to(
|
|
801
|
+
[
|
|
802
|
+
2,
|
|
803
|
+
"boot",
|
|
804
|
+
"BootNotification",
|
|
805
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
806
|
+
]
|
|
807
|
+
)
|
|
808
|
+
await communicator.receive_json_from()
|
|
809
|
+
|
|
810
|
+
await communicator.send_json_to(
|
|
811
|
+
[
|
|
812
|
+
2,
|
|
813
|
+
"stat1",
|
|
814
|
+
"StatusNotification",
|
|
815
|
+
{"connectorId": 1, "errorCode": "NoError", "status": "Unavailable"},
|
|
816
|
+
]
|
|
817
|
+
)
|
|
818
|
+
await communicator.receive_json_from()
|
|
819
|
+
|
|
820
|
+
charger = await database_sync_to_async(Charger.objects.get)(
|
|
821
|
+
charger_id="STATAVAIL", connector_id=1
|
|
822
|
+
)
|
|
823
|
+
self.assertEqual(charger.availability_state, "Inoperative")
|
|
824
|
+
|
|
825
|
+
await communicator.send_json_to(
|
|
826
|
+
[
|
|
827
|
+
2,
|
|
828
|
+
"stat2",
|
|
829
|
+
"StatusNotification",
|
|
830
|
+
{"connectorId": 1, "errorCode": "NoError", "status": "Available"},
|
|
831
|
+
]
|
|
832
|
+
)
|
|
833
|
+
await communicator.receive_json_from()
|
|
834
|
+
|
|
835
|
+
charger = await database_sync_to_async(Charger.objects.get)(
|
|
836
|
+
charger_id="STATAVAIL", connector_id=1
|
|
837
|
+
)
|
|
838
|
+
self.assertEqual(charger.availability_state, "Operative")
|
|
839
|
+
await communicator.disconnect()
|
|
840
|
+
|
|
318
841
|
async def test_consumption_message_final_update_on_disconnect(self):
|
|
319
842
|
await database_sync_to_async(Charger.objects.create)(charger_id="FINALMSG")
|
|
320
843
|
communicator = WebsocketCommunicator(application, "/FINALMSG/")
|
|
@@ -435,14 +958,9 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
435
958
|
self.assertEqual(detail_payload["firmwareStatus"], "Installing")
|
|
436
959
|
self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
|
|
437
960
|
self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
|
|
438
|
-
self.
|
|
439
|
-
self.
|
|
440
|
-
|
|
441
|
-
r'id="firmware-timestamp"[^>]*data-iso="([^"]+)"', html
|
|
442
|
-
)
|
|
443
|
-
self.assertIsNotNone(match)
|
|
444
|
-
parsed_iso = datetime.fromisoformat(match.group(1))
|
|
445
|
-
self.assertAlmostEqual(parsed_iso.timestamp(), ts.timestamp(), places=3)
|
|
961
|
+
self.assertNotIn('id="firmware-status"', html)
|
|
962
|
+
self.assertNotIn('id="firmware-status-info"', html)
|
|
963
|
+
self.assertNotIn('id="firmware-timestamp"', html)
|
|
446
964
|
|
|
447
965
|
matching = [
|
|
448
966
|
item
|
|
@@ -924,7 +1442,11 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
924
1442
|
self.assertContains(status_resp, "background-color: #dc3545")
|
|
925
1443
|
|
|
926
1444
|
aggregate_status = self.client.get(reverse("charger-status", args=[serial]))
|
|
927
|
-
self.assertContains(
|
|
1445
|
+
self.assertContains(
|
|
1446
|
+
aggregate_status,
|
|
1447
|
+
f"Serial Number: {serial}",
|
|
1448
|
+
)
|
|
1449
|
+
self.assertNotContains(aggregate_status, 'id="last-status-raw"')
|
|
928
1450
|
self.assertContains(aggregate_status, "Info: Relay malfunction")
|
|
929
1451
|
|
|
930
1452
|
page_resp = self.client.get(reverse("charger-page", args=[serial]))
|
|
@@ -1153,6 +1675,92 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1153
1675
|
if connected3_retry and communicator3_retry is not None:
|
|
1154
1676
|
await communicator3_retry.disconnect()
|
|
1155
1677
|
|
|
1678
|
+
async def test_data_transfer_inbound_persists_message(self):
|
|
1679
|
+
store.pending_calls.clear()
|
|
1680
|
+
communicator = WebsocketCommunicator(application, "/DTIN/")
|
|
1681
|
+
connected, _ = await communicator.connect()
|
|
1682
|
+
self.assertTrue(connected)
|
|
1683
|
+
|
|
1684
|
+
payload = {"vendorId": "Acme", "messageId": "diag", "data": {"foo": "bar"}}
|
|
1685
|
+
await communicator.send_json_to([2, "dt-msg", "DataTransfer", payload])
|
|
1686
|
+
response = await communicator.receive_json_from()
|
|
1687
|
+
self.assertEqual(response, [3, "dt-msg", {"status": "UnknownVendorId"}])
|
|
1688
|
+
|
|
1689
|
+
await communicator.disconnect()
|
|
1690
|
+
|
|
1691
|
+
message = await database_sync_to_async(DataTransferMessage.objects.get)(
|
|
1692
|
+
ocpp_message_id="dt-msg"
|
|
1693
|
+
)
|
|
1694
|
+
self.assertEqual(
|
|
1695
|
+
message.direction, DataTransferMessage.DIRECTION_CP_TO_CSMS
|
|
1696
|
+
)
|
|
1697
|
+
self.assertEqual(message.vendor_id, "Acme")
|
|
1698
|
+
self.assertEqual(message.message_id, "diag")
|
|
1699
|
+
self.assertEqual(message.payload, payload)
|
|
1700
|
+
self.assertEqual(message.status, "UnknownVendorId")
|
|
1701
|
+
self.assertIsNotNone(message.responded_at)
|
|
1702
|
+
|
|
1703
|
+
async def test_data_transfer_action_round_trip(self):
|
|
1704
|
+
store.pending_calls.clear()
|
|
1705
|
+
communicator = WebsocketCommunicator(application, "/DTOUT/")
|
|
1706
|
+
connected, _ = await communicator.connect()
|
|
1707
|
+
self.assertTrue(connected)
|
|
1708
|
+
|
|
1709
|
+
User = get_user_model()
|
|
1710
|
+
user = await database_sync_to_async(User.objects.create_user)(
|
|
1711
|
+
username="dtuser", password="pw"
|
|
1712
|
+
)
|
|
1713
|
+
await database_sync_to_async(self.client.force_login)(user)
|
|
1714
|
+
|
|
1715
|
+
url = reverse("charger-action", args=["DTOUT"])
|
|
1716
|
+
request_payload = {
|
|
1717
|
+
"action": "data_transfer",
|
|
1718
|
+
"vendorId": "AcmeCorp",
|
|
1719
|
+
"messageId": "ping",
|
|
1720
|
+
"data": {"echo": "value"},
|
|
1721
|
+
}
|
|
1722
|
+
response = await database_sync_to_async(self.client.post)(
|
|
1723
|
+
url,
|
|
1724
|
+
data=json.dumps(request_payload),
|
|
1725
|
+
content_type="application/json",
|
|
1726
|
+
)
|
|
1727
|
+
self.assertEqual(response.status_code, 200)
|
|
1728
|
+
response_body = json.loads(response.content.decode())
|
|
1729
|
+
sent_frame = json.loads(response_body["sent"])
|
|
1730
|
+
self.assertEqual(sent_frame[2], "DataTransfer")
|
|
1731
|
+
sent_payload = sent_frame[3]
|
|
1732
|
+
self.assertEqual(sent_payload["vendorId"], "AcmeCorp")
|
|
1733
|
+
self.assertEqual(sent_payload.get("messageId"), "ping")
|
|
1734
|
+
|
|
1735
|
+
outbound = await communicator.receive_json_from()
|
|
1736
|
+
self.assertEqual(outbound, sent_frame)
|
|
1737
|
+
|
|
1738
|
+
message_id = sent_frame[1]
|
|
1739
|
+
record = await database_sync_to_async(DataTransferMessage.objects.get)(
|
|
1740
|
+
ocpp_message_id=message_id
|
|
1741
|
+
)
|
|
1742
|
+
self.assertEqual(
|
|
1743
|
+
record.direction, DataTransferMessage.DIRECTION_CSMS_TO_CP
|
|
1744
|
+
)
|
|
1745
|
+
self.assertEqual(record.status, "Pending")
|
|
1746
|
+
self.assertIsNone(record.response_data)
|
|
1747
|
+
self.assertIn(message_id, store.pending_calls)
|
|
1748
|
+
self.assertEqual(store.pending_calls[message_id]["message_pk"], record.pk)
|
|
1749
|
+
|
|
1750
|
+
reply_payload = {"status": "Accepted", "data": {"result": "ok"}}
|
|
1751
|
+
await communicator.send_json_to([3, message_id, reply_payload])
|
|
1752
|
+
await asyncio.sleep(0.05)
|
|
1753
|
+
|
|
1754
|
+
updated = await database_sync_to_async(DataTransferMessage.objects.get)(
|
|
1755
|
+
pk=record.pk
|
|
1756
|
+
)
|
|
1757
|
+
self.assertEqual(updated.status, "Accepted")
|
|
1758
|
+
self.assertEqual(updated.response_data, {"result": "ok"})
|
|
1759
|
+
self.assertIsNotNone(updated.responded_at)
|
|
1760
|
+
self.assertNotIn(message_id, store.pending_calls)
|
|
1761
|
+
|
|
1762
|
+
await communicator.disconnect()
|
|
1763
|
+
|
|
1156
1764
|
|
|
1157
1765
|
class ChargerLandingTests(TestCase):
|
|
1158
1766
|
def setUp(self):
|
|
@@ -1175,28 +1783,148 @@ class ChargerLandingTests(TestCase):
|
|
|
1175
1783
|
"Plug in your vehicle and slide your RFID card over the reader to begin charging."
|
|
1176
1784
|
),
|
|
1177
1785
|
)
|
|
1178
|
-
self.assertContains(response, _("Advanced View"))
|
|
1179
|
-
status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
|
|
1180
|
-
self.assertContains(response, status_url)
|
|
1786
|
+
self.assertContains(response, _("Advanced View"))
|
|
1787
|
+
status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
|
|
1788
|
+
self.assertContains(response, status_url)
|
|
1789
|
+
|
|
1790
|
+
def test_status_page_renders(self):
|
|
1791
|
+
charger = Charger.objects.create(charger_id="PAGE2")
|
|
1792
|
+
resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
|
|
1793
|
+
self.assertEqual(resp.status_code, 200)
|
|
1794
|
+
self.assertContains(resp, "PAGE2")
|
|
1795
|
+
|
|
1796
|
+
def test_placeholder_serial_rejected(self):
|
|
1797
|
+
with self.assertRaises(ValidationError):
|
|
1798
|
+
Charger.objects.create(charger_id="<charger_id>")
|
|
1799
|
+
|
|
1800
|
+
def test_placeholder_serial_not_created_from_status_view(self):
|
|
1801
|
+
existing = Charger.objects.count()
|
|
1802
|
+
response = self.client.get(reverse("charger-status", args=["<charger_id>"]))
|
|
1803
|
+
self.assertEqual(response.status_code, 404)
|
|
1804
|
+
self.assertEqual(Charger.objects.count(), existing)
|
|
1805
|
+
self.assertFalse(
|
|
1806
|
+
Location.objects.filter(
|
|
1807
|
+
name__startswith="<", name__endswith=">", chargers__isnull=True
|
|
1808
|
+
).exists()
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
def test_charger_page_shows_progress(self):
|
|
1812
|
+
charger = Charger.objects.create(charger_id="STATS")
|
|
1813
|
+
tx = Transaction.objects.create(
|
|
1814
|
+
charger=charger,
|
|
1815
|
+
meter_start=1000,
|
|
1816
|
+
start_time=timezone.now(),
|
|
1817
|
+
)
|
|
1818
|
+
key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
1819
|
+
store.transactions[key] = tx
|
|
1820
|
+
resp = self.client.get(reverse("charger-page", args=["STATS"]))
|
|
1821
|
+
self.assertContains(resp, "progress-bar")
|
|
1822
|
+
store.transactions.pop(key, None)
|
|
1823
|
+
|
|
1824
|
+
def test_public_page_overrides_available_status_when_charging(self):
|
|
1825
|
+
charger = Charger.objects.create(
|
|
1826
|
+
charger_id="STATEPUB",
|
|
1827
|
+
last_status="Available",
|
|
1828
|
+
)
|
|
1829
|
+
tx = Transaction.objects.create(
|
|
1830
|
+
charger=charger,
|
|
1831
|
+
meter_start=1000,
|
|
1832
|
+
start_time=timezone.now(),
|
|
1833
|
+
)
|
|
1834
|
+
key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
1835
|
+
store.transactions[key] = tx
|
|
1836
|
+
try:
|
|
1837
|
+
response = self.client.get(reverse("charger-page", args=["STATEPUB"]))
|
|
1838
|
+
self.assertEqual(response.status_code, 200)
|
|
1839
|
+
self.assertContains(
|
|
1840
|
+
response,
|
|
1841
|
+
'class="badge" style="background-color: #198754;">Charging</span>',
|
|
1842
|
+
)
|
|
1843
|
+
finally:
|
|
1844
|
+
store.transactions.pop(key, None)
|
|
1845
|
+
|
|
1846
|
+
def test_admin_status_overrides_available_status_when_charging(self):
|
|
1847
|
+
charger = Charger.objects.create(
|
|
1848
|
+
charger_id="STATEADM",
|
|
1849
|
+
last_status="Available",
|
|
1850
|
+
)
|
|
1851
|
+
tx = Transaction.objects.create(
|
|
1852
|
+
charger=charger,
|
|
1853
|
+
meter_start=1000,
|
|
1854
|
+
start_time=timezone.now(),
|
|
1855
|
+
)
|
|
1856
|
+
key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
1857
|
+
store.transactions[key] = tx
|
|
1858
|
+
try:
|
|
1859
|
+
response = self.client.get(reverse("charger-status", args=["STATEADM"]))
|
|
1860
|
+
self.assertEqual(response.status_code, 200)
|
|
1861
|
+
self.assertContains(response, 'id="charger-state">Charging</strong>')
|
|
1862
|
+
self.assertContains(
|
|
1863
|
+
response,
|
|
1864
|
+
'style="width:20px;height:20px;background-color: #198754;"',
|
|
1865
|
+
)
|
|
1866
|
+
finally:
|
|
1867
|
+
store.transactions.pop(key, None)
|
|
1181
1868
|
|
|
1182
|
-
def
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1869
|
+
def test_public_status_shows_rfid_link_for_known_tag(self):
|
|
1870
|
+
aggregate = Charger.objects.create(charger_id="PUBRFID")
|
|
1871
|
+
connector = Charger.objects.create(
|
|
1872
|
+
charger_id="PUBRFID",
|
|
1873
|
+
connector_id=1,
|
|
1874
|
+
)
|
|
1875
|
+
tx = Transaction.objects.create(
|
|
1876
|
+
charger=connector,
|
|
1877
|
+
meter_start=1000,
|
|
1878
|
+
start_time=timezone.now(),
|
|
1879
|
+
rfid="TAGLINK",
|
|
1880
|
+
)
|
|
1881
|
+
key = store.identity_key(connector.charger_id, connector.connector_id)
|
|
1882
|
+
store.transactions[key] = tx
|
|
1883
|
+
tag = RFID.objects.create(rfid="TAGLINK")
|
|
1884
|
+
admin_url = reverse("admin:core_rfid_change", args=[tag.pk])
|
|
1885
|
+
try:
|
|
1886
|
+
response = self.client.get(
|
|
1887
|
+
reverse(
|
|
1888
|
+
"charger-page-connector",
|
|
1889
|
+
args=[connector.charger_id, connector.connector_slug],
|
|
1890
|
+
)
|
|
1891
|
+
)
|
|
1892
|
+
self.assertContains(response, admin_url)
|
|
1893
|
+
self.assertContains(response, "TAGLINK")
|
|
1187
1894
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1895
|
+
overview = self.client.get(reverse("charger-page", args=[aggregate.charger_id]))
|
|
1896
|
+
self.assertContains(overview, admin_url)
|
|
1897
|
+
finally:
|
|
1898
|
+
store.transactions.pop(key, None)
|
|
1899
|
+
|
|
1900
|
+
def test_public_status_shows_rfid_text_when_unknown(self):
|
|
1901
|
+
Charger.objects.create(charger_id="PUBTEXT")
|
|
1902
|
+
connector = Charger.objects.create(
|
|
1903
|
+
charger_id="PUBTEXT",
|
|
1904
|
+
connector_id=1,
|
|
1905
|
+
)
|
|
1190
1906
|
tx = Transaction.objects.create(
|
|
1191
|
-
charger=
|
|
1907
|
+
charger=connector,
|
|
1192
1908
|
meter_start=1000,
|
|
1193
1909
|
start_time=timezone.now(),
|
|
1910
|
+
rfid="TAGNONE",
|
|
1194
1911
|
)
|
|
1195
|
-
key = store.identity_key(
|
|
1912
|
+
key = store.identity_key(connector.charger_id, connector.connector_id)
|
|
1196
1913
|
store.transactions[key] = tx
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1914
|
+
try:
|
|
1915
|
+
response = self.client.get(
|
|
1916
|
+
reverse(
|
|
1917
|
+
"charger-page-connector",
|
|
1918
|
+
args=[connector.charger_id, connector.connector_slug],
|
|
1919
|
+
)
|
|
1920
|
+
)
|
|
1921
|
+
self.assertContains(response, "TAGNONE")
|
|
1922
|
+
self.assertNotContains(response, "TAGNONE</a>")
|
|
1923
|
+
|
|
1924
|
+
overview = self.client.get(reverse("charger-page", args=[connector.charger_id]))
|
|
1925
|
+
self.assertContains(overview, "TAGNONE")
|
|
1926
|
+
finally:
|
|
1927
|
+
store.transactions.pop(key, None)
|
|
1200
1928
|
|
|
1201
1929
|
def test_display_name_used_on_public_pages(self):
|
|
1202
1930
|
charger = Charger.objects.create(
|
|
@@ -1306,6 +2034,12 @@ class SimulatorLandingTests(TestCase):
|
|
|
1306
2034
|
self.assertContains(resp, "/ocpp/")
|
|
1307
2035
|
self.assertContains(resp, "/ocpp/simulator/")
|
|
1308
2036
|
|
|
2037
|
+
def test_cp_simulator_redirects_to_login(self):
|
|
2038
|
+
response = self.client.get(reverse("cp-simulator"))
|
|
2039
|
+
login_url = reverse("pages:login")
|
|
2040
|
+
self.assertEqual(response.status_code, 302)
|
|
2041
|
+
self.assertIn(login_url, response.url)
|
|
2042
|
+
|
|
1309
2043
|
|
|
1310
2044
|
class ChargerAdminTests(TestCase):
|
|
1311
2045
|
def setUp(self):
|
|
@@ -1337,6 +2071,25 @@ class ChargerAdminTests(TestCase):
|
|
|
1337
2071
|
log_url = reverse("admin:ocpp_charger_log", args=[charger.pk])
|
|
1338
2072
|
self.assertContains(resp, log_url)
|
|
1339
2073
|
|
|
2074
|
+
def test_admin_status_overrides_available_when_active_session(self):
|
|
2075
|
+
charger = Charger.objects.create(
|
|
2076
|
+
charger_id="ADMINCHARGE",
|
|
2077
|
+
last_status="Available",
|
|
2078
|
+
)
|
|
2079
|
+
tx = Transaction.objects.create(
|
|
2080
|
+
charger=charger,
|
|
2081
|
+
start_time=timezone.now(),
|
|
2082
|
+
)
|
|
2083
|
+
key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2084
|
+
store.transactions[key] = tx
|
|
2085
|
+
try:
|
|
2086
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2087
|
+
resp = self.client.get(url)
|
|
2088
|
+
charging_label = force_str(STATUS_BADGE_MAP["charging"][0])
|
|
2089
|
+
self.assertContains(resp, f">{charging_label}<")
|
|
2090
|
+
finally:
|
|
2091
|
+
store.transactions.pop(key, None)
|
|
2092
|
+
|
|
1340
2093
|
def test_admin_log_view_displays_entries(self):
|
|
1341
2094
|
charger = Charger.objects.create(charger_id="LOG2")
|
|
1342
2095
|
log_id = store.identity_key(charger.charger_id, charger.connector_id)
|
|
@@ -1374,6 +2127,35 @@ class ChargerAdminTests(TestCase):
|
|
|
1374
2127
|
self.assertNotContains(resp, 'name="last_heartbeat"')
|
|
1375
2128
|
self.assertNotContains(resp, 'name="last_meter_values"')
|
|
1376
2129
|
|
|
2130
|
+
def test_admin_action_sets_availability_state(self):
|
|
2131
|
+
charger = Charger.objects.create(charger_id="AVAIL1")
|
|
2132
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2133
|
+
response = self.client.post(
|
|
2134
|
+
url,
|
|
2135
|
+
{
|
|
2136
|
+
"action": "set_availability_state_inoperative",
|
|
2137
|
+
"_selected_action": [charger.pk],
|
|
2138
|
+
},
|
|
2139
|
+
follow=True,
|
|
2140
|
+
)
|
|
2141
|
+
self.assertEqual(response.status_code, 200)
|
|
2142
|
+
charger.refresh_from_db()
|
|
2143
|
+
self.assertEqual(charger.availability_state, "Inoperative")
|
|
2144
|
+
self.assertIsNotNone(charger.availability_state_updated_at)
|
|
2145
|
+
|
|
2146
|
+
response = self.client.post(
|
|
2147
|
+
url,
|
|
2148
|
+
{
|
|
2149
|
+
"action": "set_availability_state_operative",
|
|
2150
|
+
"_selected_action": [charger.pk],
|
|
2151
|
+
},
|
|
2152
|
+
follow=True,
|
|
2153
|
+
)
|
|
2154
|
+
self.assertEqual(response.status_code, 200)
|
|
2155
|
+
charger.refresh_from_db()
|
|
2156
|
+
self.assertEqual(charger.availability_state, "Operative")
|
|
2157
|
+
self.assertIsNotNone(charger.availability_state_updated_at)
|
|
2158
|
+
|
|
1377
2159
|
def test_purge_action_removes_data(self):
|
|
1378
2160
|
charger = Charger.objects.create(charger_id="PURGE1")
|
|
1379
2161
|
Transaction.objects.create(
|
|
@@ -1411,6 +2193,174 @@ class ChargerAdminTests(TestCase):
|
|
|
1411
2193
|
self.client.post(delete_url, {"post": "yes"})
|
|
1412
2194
|
self.assertFalse(Charger.objects.filter(pk=charger.pk).exists())
|
|
1413
2195
|
|
|
2196
|
+
def test_fetch_configuration_dispatches_request(self):
|
|
2197
|
+
charger = Charger.objects.create(charger_id="CFGADMIN", connector_id=1)
|
|
2198
|
+
ws = DummyWebSocket()
|
|
2199
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2200
|
+
store.clear_log(log_key, log_type="charger")
|
|
2201
|
+
pending_key = store.pending_key(charger.charger_id)
|
|
2202
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2203
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2204
|
+
store.pending_calls.clear()
|
|
2205
|
+
try:
|
|
2206
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2207
|
+
response = self.client.post(
|
|
2208
|
+
url,
|
|
2209
|
+
{
|
|
2210
|
+
"action": "fetch_cp_configuration",
|
|
2211
|
+
"_selected_action": [charger.pk],
|
|
2212
|
+
},
|
|
2213
|
+
follow=True,
|
|
2214
|
+
)
|
|
2215
|
+
self.assertEqual(response.status_code, 200)
|
|
2216
|
+
self.assertEqual(len(ws.sent), 1)
|
|
2217
|
+
frame = json.loads(ws.sent[0])
|
|
2218
|
+
self.assertEqual(frame[0], 2)
|
|
2219
|
+
self.assertEqual(frame[2], "GetConfiguration")
|
|
2220
|
+
self.assertIn(frame[1], store.pending_calls)
|
|
2221
|
+
metadata = store.pending_calls[frame[1]]
|
|
2222
|
+
self.assertEqual(metadata.get("action"), "GetConfiguration")
|
|
2223
|
+
self.assertEqual(metadata.get("charger_id"), charger.charger_id)
|
|
2224
|
+
self.assertEqual(metadata.get("connector_id"), charger.connector_id)
|
|
2225
|
+
self.assertEqual(metadata.get("log_key"), log_key)
|
|
2226
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
2227
|
+
self.assertTrue(
|
|
2228
|
+
any("GetConfiguration" in entry for entry in log_entries)
|
|
2229
|
+
)
|
|
2230
|
+
finally:
|
|
2231
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2232
|
+
store.pending_calls.clear()
|
|
2233
|
+
store.clear_log(log_key, log_type="charger")
|
|
2234
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2235
|
+
|
|
2236
|
+
def test_fetch_configuration_timeout_logged(self):
|
|
2237
|
+
charger = Charger.objects.create(charger_id="CFGWAIT", connector_id=1)
|
|
2238
|
+
ws = DummyWebSocket()
|
|
2239
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2240
|
+
pending_key = store.pending_key(charger.charger_id)
|
|
2241
|
+
store.clear_log(log_key, log_type="charger")
|
|
2242
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2243
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2244
|
+
store.pending_calls.clear()
|
|
2245
|
+
original_schedule = store.schedule_call_timeout
|
|
2246
|
+
try:
|
|
2247
|
+
with patch("ocpp.admin.store.schedule_call_timeout") as mock_schedule:
|
|
2248
|
+
def _side_effect(message_id, *, timeout=5.0, **kwargs):
|
|
2249
|
+
kwargs["timeout"] = 0.05
|
|
2250
|
+
return original_schedule(message_id, **kwargs)
|
|
2251
|
+
|
|
2252
|
+
mock_schedule.side_effect = _side_effect
|
|
2253
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2254
|
+
response = self.client.post(
|
|
2255
|
+
url,
|
|
2256
|
+
{
|
|
2257
|
+
"action": "fetch_cp_configuration",
|
|
2258
|
+
"_selected_action": [charger.pk],
|
|
2259
|
+
},
|
|
2260
|
+
follow=True,
|
|
2261
|
+
)
|
|
2262
|
+
self.assertEqual(response.status_code, 200)
|
|
2263
|
+
mock_schedule.assert_called_once()
|
|
2264
|
+
time.sleep(0.1)
|
|
2265
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
2266
|
+
self.assertTrue(
|
|
2267
|
+
any("GetConfiguration timed out" in entry for entry in log_entries)
|
|
2268
|
+
)
|
|
2269
|
+
finally:
|
|
2270
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2271
|
+
store.pending_calls.clear()
|
|
2272
|
+
store.clear_log(log_key, log_type="charger")
|
|
2273
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2274
|
+
|
|
2275
|
+
def test_remote_stop_action_dispatches_request(self):
|
|
2276
|
+
charger = Charger.objects.create(charger_id="STOPME", connector_id=1)
|
|
2277
|
+
ws = DummyWebSocket()
|
|
2278
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2279
|
+
pending_key = store.pending_key(charger.charger_id)
|
|
2280
|
+
store.clear_log(log_key, log_type="charger")
|
|
2281
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2282
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2283
|
+
tx = Transaction.objects.create(
|
|
2284
|
+
charger=charger,
|
|
2285
|
+
start_time=timezone.now(),
|
|
2286
|
+
)
|
|
2287
|
+
tx_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2288
|
+
store.transactions[tx_key] = tx
|
|
2289
|
+
store.pending_calls.clear()
|
|
2290
|
+
try:
|
|
2291
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2292
|
+
response = self.client.post(
|
|
2293
|
+
url,
|
|
2294
|
+
{
|
|
2295
|
+
"action": "remote_stop_transaction",
|
|
2296
|
+
"_selected_action": [charger.pk],
|
|
2297
|
+
},
|
|
2298
|
+
follow=True,
|
|
2299
|
+
)
|
|
2300
|
+
self.assertEqual(response.status_code, 200)
|
|
2301
|
+
self.assertEqual(len(ws.sent), 1)
|
|
2302
|
+
frame = json.loads(ws.sent[0])
|
|
2303
|
+
self.assertEqual(frame[0], 2)
|
|
2304
|
+
self.assertEqual(frame[2], "RemoteStopTransaction")
|
|
2305
|
+
self.assertIn("transactionId", frame[3])
|
|
2306
|
+
self.assertEqual(frame[3]["transactionId"], tx.pk)
|
|
2307
|
+
self.assertIn(frame[1], store.pending_calls)
|
|
2308
|
+
metadata = store.pending_calls[frame[1]]
|
|
2309
|
+
self.assertEqual(metadata.get("action"), "RemoteStopTransaction")
|
|
2310
|
+
self.assertEqual(metadata.get("charger_id"), charger.charger_id)
|
|
2311
|
+
self.assertEqual(metadata.get("connector_id"), charger.connector_id)
|
|
2312
|
+
self.assertEqual(metadata.get("transaction_id"), tx.pk)
|
|
2313
|
+
self.assertEqual(metadata.get("log_key"), log_key)
|
|
2314
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
2315
|
+
self.assertTrue(
|
|
2316
|
+
any("RemoteStopTransaction" in entry for entry in log_entries)
|
|
2317
|
+
)
|
|
2318
|
+
finally:
|
|
2319
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2320
|
+
store.pending_calls.clear()
|
|
2321
|
+
store.transactions.pop(tx_key, None)
|
|
2322
|
+
store.clear_log(log_key, log_type="charger")
|
|
2323
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2324
|
+
|
|
2325
|
+
def test_reset_action_dispatches_request(self):
|
|
2326
|
+
charger = Charger.objects.create(charger_id="RESETME", connector_id=1)
|
|
2327
|
+
ws = DummyWebSocket()
|
|
2328
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2329
|
+
pending_key = store.pending_key(charger.charger_id)
|
|
2330
|
+
store.clear_log(log_key, log_type="charger")
|
|
2331
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2332
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2333
|
+
store.pending_calls.clear()
|
|
2334
|
+
try:
|
|
2335
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2336
|
+
response = self.client.post(
|
|
2337
|
+
url,
|
|
2338
|
+
{
|
|
2339
|
+
"action": "reset_chargers",
|
|
2340
|
+
"_selected_action": [charger.pk],
|
|
2341
|
+
},
|
|
2342
|
+
follow=True,
|
|
2343
|
+
)
|
|
2344
|
+
self.assertEqual(response.status_code, 200)
|
|
2345
|
+
self.assertEqual(len(ws.sent), 1)
|
|
2346
|
+
frame = json.loads(ws.sent[0])
|
|
2347
|
+
self.assertEqual(frame[0], 2)
|
|
2348
|
+
self.assertEqual(frame[2], "Reset")
|
|
2349
|
+
self.assertEqual(frame[3], {"type": "Soft"})
|
|
2350
|
+
self.assertIn(frame[1], store.pending_calls)
|
|
2351
|
+
metadata = store.pending_calls[frame[1]]
|
|
2352
|
+
self.assertEqual(metadata.get("action"), "Reset")
|
|
2353
|
+
self.assertEqual(metadata.get("charger_id"), charger.charger_id)
|
|
2354
|
+
self.assertEqual(metadata.get("connector_id"), charger.connector_id)
|
|
2355
|
+
self.assertEqual(metadata.get("log_key"), log_key)
|
|
2356
|
+
log_entries = store.get_logs(log_key, log_type="charger")
|
|
2357
|
+
self.assertTrue(any("Reset" in entry for entry in log_entries))
|
|
2358
|
+
finally:
|
|
2359
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2360
|
+
store.pending_calls.clear()
|
|
2361
|
+
store.clear_log(log_key, log_type="charger")
|
|
2362
|
+
store.clear_log(pending_key, log_type="charger")
|
|
2363
|
+
|
|
1414
2364
|
|
|
1415
2365
|
class LocationAdminTests(TestCase):
|
|
1416
2366
|
def setUp(self):
|
|
@@ -1513,6 +2463,20 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1513
2463
|
mock_start.assert_called_once()
|
|
1514
2464
|
store.simulators.clear()
|
|
1515
2465
|
|
|
2466
|
+
@patch("ocpp.admin.ChargePointSimulator.start")
|
|
2467
|
+
def test_start_simulator_change_action(self, mock_start):
|
|
2468
|
+
sim = Simulator.objects.create(name="SIMCHG", cp_path="SIMCHG")
|
|
2469
|
+
mock_start.return_value = (True, "Started", "/tmp/log")
|
|
2470
|
+
resp = self._post_simulator_change(
|
|
2471
|
+
sim,
|
|
2472
|
+
_action="start_simulator_action",
|
|
2473
|
+
)
|
|
2474
|
+
self.assertEqual(resp.status_code, 200)
|
|
2475
|
+
self.assertContains(resp, "View Log")
|
|
2476
|
+
self.assertContains(resp, "/tmp/log")
|
|
2477
|
+
mock_start.assert_called_once()
|
|
2478
|
+
store.simulators.clear()
|
|
2479
|
+
|
|
1516
2480
|
def test_admin_shows_ws_url(self):
|
|
1517
2481
|
sim = Simulator.objects.create(
|
|
1518
2482
|
name="SIM2", cp_path="SIMY", host="h", ws_port=1111
|
|
@@ -1557,6 +2521,36 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1557
2521
|
self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)
|
|
1558
2522
|
store.simulators.pop(sim.pk, None)
|
|
1559
2523
|
|
|
2524
|
+
@patch("ocpp.admin.asyncio.get_running_loop", side_effect=RuntimeError)
|
|
2525
|
+
def test_stop_simulator_runs_without_event_loop(self, mock_get_loop):
|
|
2526
|
+
sim = Simulator.objects.create(name="SIMSTOP", cp_path="SIMSTOP")
|
|
2527
|
+
stopper = SimpleNamespace(stop=AsyncMock())
|
|
2528
|
+
store.simulators[sim.pk] = stopper
|
|
2529
|
+
url = reverse("admin:ocpp_simulator_changelist")
|
|
2530
|
+
resp = self.client.post(
|
|
2531
|
+
url,
|
|
2532
|
+
{"action": "stop_simulator", "_selected_action": [sim.pk]},
|
|
2533
|
+
follow=True,
|
|
2534
|
+
)
|
|
2535
|
+
self.assertEqual(resp.status_code, 200)
|
|
2536
|
+
stopper.stop.assert_awaited_once()
|
|
2537
|
+
self.assertTrue(mock_get_loop.called)
|
|
2538
|
+
self.assertNotIn(sim.pk, store.simulators)
|
|
2539
|
+
|
|
2540
|
+
@patch("ocpp.admin.asyncio.get_running_loop", side_effect=RuntimeError)
|
|
2541
|
+
def test_stop_simulator_change_action(self, mock_get_loop):
|
|
2542
|
+
sim = Simulator.objects.create(name="SIMCHGSTOP", cp_path="SIMCHGSTOP")
|
|
2543
|
+
stopper = SimpleNamespace(stop=AsyncMock())
|
|
2544
|
+
store.simulators[sim.pk] = stopper
|
|
2545
|
+
resp = self._post_simulator_change(
|
|
2546
|
+
sim,
|
|
2547
|
+
_action="stop_simulator_action",
|
|
2548
|
+
)
|
|
2549
|
+
self.assertEqual(resp.status_code, 200)
|
|
2550
|
+
stopper.stop.assert_awaited_once()
|
|
2551
|
+
self.assertTrue(mock_get_loop.called)
|
|
2552
|
+
self.assertNotIn(sim.pk, store.simulators)
|
|
2553
|
+
|
|
1560
2554
|
def test_as_config_includes_custom_fields(self):
|
|
1561
2555
|
sim = Simulator.objects.create(
|
|
1562
2556
|
name="SIM3",
|
|
@@ -1566,6 +2560,10 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1566
2560
|
duration=500,
|
|
1567
2561
|
pre_charge_delay=5,
|
|
1568
2562
|
vin="WP0ZZZ99999999999",
|
|
2563
|
+
configuration_keys=[
|
|
2564
|
+
{"key": "HeartbeatInterval", "value": "300", "readonly": True}
|
|
2565
|
+
],
|
|
2566
|
+
configuration_unknown_keys=["Bogus"],
|
|
1569
2567
|
)
|
|
1570
2568
|
cfg = sim.as_config()
|
|
1571
2569
|
self.assertEqual(cfg.interval, 3.5)
|
|
@@ -1573,6 +2571,11 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1573
2571
|
self.assertEqual(cfg.duration, 500)
|
|
1574
2572
|
self.assertEqual(cfg.pre_charge_delay, 5)
|
|
1575
2573
|
self.assertEqual(cfg.vin, "WP0ZZZ99999999999")
|
|
2574
|
+
self.assertEqual(
|
|
2575
|
+
cfg.configuration_keys,
|
|
2576
|
+
[{"key": "HeartbeatInterval", "value": "300", "readonly": True}],
|
|
2577
|
+
)
|
|
2578
|
+
self.assertEqual(cfg.configuration_unknown_keys, ["Bogus"])
|
|
1576
2579
|
|
|
1577
2580
|
def _post_simulator_change(self, sim: Simulator, **overrides):
|
|
1578
2581
|
url = reverse("admin:ocpp_simulator_change", args=[sim.pk])
|
|
@@ -1590,6 +2593,10 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1590
2593
|
"username": sim.username,
|
|
1591
2594
|
"password": sim.password,
|
|
1592
2595
|
"door_open": "on" if overrides.get("door_open", False) else "",
|
|
2596
|
+
"configuration_keys": json.dumps(sim.configuration_keys or []),
|
|
2597
|
+
"configuration_unknown_keys": json.dumps(
|
|
2598
|
+
sim.configuration_unknown_keys or []
|
|
2599
|
+
),
|
|
1593
2600
|
"_save": "Save",
|
|
1594
2601
|
}
|
|
1595
2602
|
data.update(overrides)
|
|
@@ -1628,6 +2635,104 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1628
2635
|
|
|
1629
2636
|
await communicator.disconnect()
|
|
1630
2637
|
|
|
2638
|
+
async def test_query_string_cid_supported(self):
|
|
2639
|
+
communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
|
|
2640
|
+
connected, _ = await communicator.connect()
|
|
2641
|
+
self.assertTrue(connected)
|
|
2642
|
+
|
|
2643
|
+
await communicator.disconnect()
|
|
2644
|
+
|
|
2645
|
+
charger = await database_sync_to_async(Charger.objects.get)(charger_id="QSERIAL")
|
|
2646
|
+
self.assertEqual(charger.last_path, "/")
|
|
2647
|
+
|
|
2648
|
+
async def test_query_string_charge_point_id_supported(self):
|
|
2649
|
+
communicator = WebsocketCommunicator(
|
|
2650
|
+
application, "/?chargePointId=QCHARGE"
|
|
2651
|
+
)
|
|
2652
|
+
connected, _ = await communicator.connect()
|
|
2653
|
+
self.assertTrue(connected)
|
|
2654
|
+
|
|
2655
|
+
await communicator.disconnect()
|
|
2656
|
+
|
|
2657
|
+
charger = await database_sync_to_async(Charger.objects.get)(charger_id="QCHARGE")
|
|
2658
|
+
self.assertEqual(charger.last_path, "/")
|
|
2659
|
+
|
|
2660
|
+
async def test_query_string_charge_box_id_supported(self):
|
|
2661
|
+
communicator = WebsocketCommunicator(
|
|
2662
|
+
application, "/?chargeBoxId=QBOX"
|
|
2663
|
+
)
|
|
2664
|
+
connected, _ = await communicator.connect()
|
|
2665
|
+
self.assertTrue(connected)
|
|
2666
|
+
|
|
2667
|
+
await communicator.disconnect()
|
|
2668
|
+
|
|
2669
|
+
charger = await database_sync_to_async(Charger.objects.get)(charger_id="QBOX")
|
|
2670
|
+
self.assertEqual(charger.last_path, "/")
|
|
2671
|
+
|
|
2672
|
+
async def test_query_string_charge_box_id_case_insensitive(self):
|
|
2673
|
+
communicator = WebsocketCommunicator(
|
|
2674
|
+
application, "/?CHARGEBOXID=CaseSense"
|
|
2675
|
+
)
|
|
2676
|
+
connected, _ = await communicator.connect()
|
|
2677
|
+
self.assertTrue(connected)
|
|
2678
|
+
|
|
2679
|
+
await communicator.disconnect()
|
|
2680
|
+
|
|
2681
|
+
charger = await database_sync_to_async(Charger.objects.get)(
|
|
2682
|
+
charger_id="CaseSense"
|
|
2683
|
+
)
|
|
2684
|
+
self.assertEqual(charger.last_path, "/")
|
|
2685
|
+
|
|
2686
|
+
async def test_query_string_charge_box_id_snake_case_supported(self):
|
|
2687
|
+
communicator = WebsocketCommunicator(
|
|
2688
|
+
application, "/?charge_box_id=SnakeCase"
|
|
2689
|
+
)
|
|
2690
|
+
connected, _ = await communicator.connect()
|
|
2691
|
+
self.assertTrue(connected)
|
|
2692
|
+
|
|
2693
|
+
await communicator.disconnect()
|
|
2694
|
+
|
|
2695
|
+
charger = await database_sync_to_async(Charger.objects.get)(
|
|
2696
|
+
charger_id="SnakeCase"
|
|
2697
|
+
)
|
|
2698
|
+
self.assertEqual(charger.last_path, "/")
|
|
2699
|
+
|
|
2700
|
+
async def test_query_string_charge_box_id_strips_whitespace(self):
|
|
2701
|
+
communicator = WebsocketCommunicator(
|
|
2702
|
+
application, "/?chargeBoxId=%20Trimmed%20Value%20"
|
|
2703
|
+
)
|
|
2704
|
+
connected, _ = await communicator.connect()
|
|
2705
|
+
self.assertTrue(connected)
|
|
2706
|
+
|
|
2707
|
+
await communicator.disconnect()
|
|
2708
|
+
|
|
2709
|
+
charger = await database_sync_to_async(Charger.objects.get)(
|
|
2710
|
+
charger_id="Trimmed Value"
|
|
2711
|
+
)
|
|
2712
|
+
self.assertEqual(charger.last_path, "/")
|
|
2713
|
+
|
|
2714
|
+
async def test_query_string_cid_overrides_path_segment(self):
|
|
2715
|
+
communicator = WebsocketCommunicator(application, "/ocpp?cid=QSEGOVR")
|
|
2716
|
+
connected, _ = await communicator.connect()
|
|
2717
|
+
self.assertTrue(connected)
|
|
2718
|
+
|
|
2719
|
+
await communicator.disconnect()
|
|
2720
|
+
|
|
2721
|
+
charger = await database_sync_to_async(Charger.objects.get)(charger_id="QSEGOVR")
|
|
2722
|
+
self.assertEqual(charger.last_path, "/ocpp")
|
|
2723
|
+
|
|
2724
|
+
async def test_query_string_charge_point_id_overrides_path_segment(self):
|
|
2725
|
+
communicator = WebsocketCommunicator(
|
|
2726
|
+
application, "/ocpp?chargePointId=QPSEG"
|
|
2727
|
+
)
|
|
2728
|
+
connected, _ = await communicator.connect()
|
|
2729
|
+
self.assertTrue(connected)
|
|
2730
|
+
|
|
2731
|
+
await communicator.disconnect()
|
|
2732
|
+
|
|
2733
|
+
charger = await database_sync_to_async(Charger.objects.get)(charger_id="QPSEG")
|
|
2734
|
+
self.assertEqual(charger.last_path, "/ocpp")
|
|
2735
|
+
|
|
1631
2736
|
async def test_nested_path_accepted_and_recorded(self):
|
|
1632
2737
|
communicator = WebsocketCommunicator(application, "/foo/NEST/")
|
|
1633
2738
|
connected, _ = await communicator.connect()
|
|
@@ -2167,8 +3272,175 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
2167
3272
|
idx for idx, payload in enumerate(status_payloads) if payload.get("errorCode") == "NoError"
|
|
2168
3273
|
)
|
|
2169
3274
|
self.assertLess(first_open, first_close)
|
|
2170
|
-
|
|
2171
|
-
|
|
3275
|
+
|
|
3276
|
+
async def test_get_configuration_uses_configured_keys(self):
|
|
3277
|
+
cfg = SimulatorConfig(
|
|
3278
|
+
configuration_keys=[
|
|
3279
|
+
{"key": "HeartbeatInterval", "value": "300", "readonly": True},
|
|
3280
|
+
{"key": "MeterValueSampleInterval", "value": 900},
|
|
3281
|
+
],
|
|
3282
|
+
configuration_unknown_keys=["UnknownX"],
|
|
3283
|
+
)
|
|
3284
|
+
sim = ChargePointSimulator(cfg)
|
|
3285
|
+
sent: list[list[object]] = []
|
|
3286
|
+
|
|
3287
|
+
async def send(msg: str):
|
|
3288
|
+
sent.append(json.loads(msg))
|
|
3289
|
+
|
|
3290
|
+
async def recv(): # pragma: no cover - should not be called
|
|
3291
|
+
raise AssertionError("recv should not be called for GetConfiguration")
|
|
3292
|
+
|
|
3293
|
+
handled = await sim._handle_csms_call(
|
|
3294
|
+
[
|
|
3295
|
+
2,
|
|
3296
|
+
"cfg-1",
|
|
3297
|
+
"GetConfiguration",
|
|
3298
|
+
{"key": ["HeartbeatInterval", "UnknownX", "MissingKey"]},
|
|
3299
|
+
],
|
|
3300
|
+
send,
|
|
3301
|
+
recv,
|
|
3302
|
+
)
|
|
3303
|
+
self.assertTrue(handled)
|
|
3304
|
+
self.assertEqual(len(sent), 1)
|
|
3305
|
+
frame = sent[0]
|
|
3306
|
+
self.assertEqual(frame[0], 3)
|
|
3307
|
+
self.assertEqual(frame[1], "cfg-1")
|
|
3308
|
+
payload = frame[2]
|
|
3309
|
+
self.assertIn("configurationKey", payload)
|
|
3310
|
+
self.assertEqual(
|
|
3311
|
+
payload["configurationKey"],
|
|
3312
|
+
[{"key": "HeartbeatInterval", "value": "300", "readonly": True}],
|
|
3313
|
+
)
|
|
3314
|
+
self.assertIn("unknownKey", payload)
|
|
3315
|
+
self.assertCountEqual(payload["unknownKey"], ["UnknownX", "MissingKey"])
|
|
3316
|
+
|
|
3317
|
+
async def test_get_configuration_without_filter_returns_all(self):
|
|
3318
|
+
cfg = SimulatorConfig(
|
|
3319
|
+
configuration_keys=[
|
|
3320
|
+
{"key": "AuthorizeRemoteTxRequests", "value": True},
|
|
3321
|
+
{"key": "ConnectorPhaseRotation", "value": "ABC"},
|
|
3322
|
+
],
|
|
3323
|
+
configuration_unknown_keys=["GhostKey"],
|
|
3324
|
+
)
|
|
3325
|
+
sim = ChargePointSimulator(cfg)
|
|
3326
|
+
sent: list[list[object]] = []
|
|
3327
|
+
|
|
3328
|
+
async def send(msg: str):
|
|
3329
|
+
sent.append(json.loads(msg))
|
|
3330
|
+
|
|
3331
|
+
async def recv(): # pragma: no cover - should not be called
|
|
3332
|
+
raise AssertionError("recv should not be called for GetConfiguration")
|
|
3333
|
+
|
|
3334
|
+
handled = await sim._handle_csms_call(
|
|
3335
|
+
[2, "cfg-2", "GetConfiguration", {}],
|
|
3336
|
+
send,
|
|
3337
|
+
recv,
|
|
3338
|
+
)
|
|
3339
|
+
self.assertTrue(handled)
|
|
3340
|
+
frame = sent[0]
|
|
3341
|
+
payload = frame[2]
|
|
3342
|
+
keys = payload.get("configurationKey")
|
|
3343
|
+
self.assertEqual(len(keys), 2)
|
|
3344
|
+
returned_keys = {item["key"] for item in keys}
|
|
3345
|
+
self.assertEqual(
|
|
3346
|
+
returned_keys,
|
|
3347
|
+
{"AuthorizeRemoteTxRequests", "ConnectorPhaseRotation"},
|
|
3348
|
+
)
|
|
3349
|
+
values = {item["key"]: item.get("value") for item in keys}
|
|
3350
|
+
self.assertEqual(values["AuthorizeRemoteTxRequests"], "True")
|
|
3351
|
+
self.assertEqual(values["ConnectorPhaseRotation"], "ABC")
|
|
3352
|
+
self.assertIn("unknownKey", payload)
|
|
3353
|
+
self.assertEqual(payload["unknownKey"], ["GhostKey"])
|
|
3354
|
+
|
|
3355
|
+
async def test_unknown_action_returns_call_error(self):
|
|
3356
|
+
cfg = SimulatorConfig(cp_path="SIM-CALL-ERROR/")
|
|
3357
|
+
sim = ChargePointSimulator(cfg)
|
|
3358
|
+
sent: list[list[object]] = []
|
|
3359
|
+
|
|
3360
|
+
async def send(msg: str):
|
|
3361
|
+
sent.append(json.loads(msg))
|
|
3362
|
+
|
|
3363
|
+
async def recv(): # pragma: no cover - should not be called
|
|
3364
|
+
raise AssertionError("recv should not be called for unsupported actions")
|
|
3365
|
+
|
|
3366
|
+
handled = await sim._handle_csms_call(
|
|
3367
|
+
[2, "msg-1", "Reset", {"type": "Soft"}],
|
|
3368
|
+
send,
|
|
3369
|
+
recv,
|
|
3370
|
+
)
|
|
3371
|
+
|
|
3372
|
+
self.assertTrue(handled)
|
|
3373
|
+
self.assertEqual(len(sent), 1)
|
|
3374
|
+
frame = sent[0]
|
|
3375
|
+
self.assertEqual(frame[0], 4)
|
|
3376
|
+
self.assertEqual(frame[1], "msg-1")
|
|
3377
|
+
self.assertEqual(frame[2], "NotSupported")
|
|
3378
|
+
self.assertIn("Reset", frame[3])
|
|
3379
|
+
self.assertIsInstance(frame[4], dict)
|
|
3380
|
+
|
|
3381
|
+
async def test_trigger_message_heartbeat_follow_up(self):
|
|
3382
|
+
cfg = SimulatorConfig()
|
|
3383
|
+
sim = ChargePointSimulator(cfg)
|
|
3384
|
+
sent: list[list[object]] = []
|
|
3385
|
+
recv_count = 0
|
|
3386
|
+
|
|
3387
|
+
async def send(msg: str):
|
|
3388
|
+
sent.append(json.loads(msg))
|
|
3389
|
+
|
|
3390
|
+
async def recv():
|
|
3391
|
+
nonlocal recv_count
|
|
3392
|
+
recv_count += 1
|
|
3393
|
+
return json.dumps([3, f"ack-{recv_count}", {}])
|
|
3394
|
+
|
|
3395
|
+
handled = await sim._handle_csms_call(
|
|
3396
|
+
[
|
|
3397
|
+
2,
|
|
3398
|
+
"trigger-req",
|
|
3399
|
+
"TriggerMessage",
|
|
3400
|
+
{"requestedMessage": "Heartbeat"},
|
|
3401
|
+
],
|
|
3402
|
+
send,
|
|
3403
|
+
recv,
|
|
3404
|
+
)
|
|
3405
|
+
|
|
3406
|
+
self.assertTrue(handled)
|
|
3407
|
+
self.assertGreaterEqual(len(sent), 2)
|
|
3408
|
+
result_frame = sent[0]
|
|
3409
|
+
follow_up_frame = sent[1]
|
|
3410
|
+
self.assertEqual(result_frame[0], 3)
|
|
3411
|
+
self.assertEqual(result_frame[1], "trigger-req")
|
|
3412
|
+
self.assertEqual(result_frame[2].get("status"), "Accepted")
|
|
3413
|
+
self.assertEqual(follow_up_frame[0], 2)
|
|
3414
|
+
self.assertEqual(follow_up_frame[2], "Heartbeat")
|
|
3415
|
+
self.assertEqual(recv_count, 1)
|
|
3416
|
+
|
|
3417
|
+
async def test_trigger_message_rejected_for_invalid_connector(self):
|
|
3418
|
+
cfg = SimulatorConfig(connector_id=5)
|
|
3419
|
+
sim = ChargePointSimulator(cfg)
|
|
3420
|
+
sent: list[list[object]] = []
|
|
3421
|
+
|
|
3422
|
+
async def send(msg: str):
|
|
3423
|
+
sent.append(json.loads(msg))
|
|
3424
|
+
|
|
3425
|
+
async def recv(): # pragma: no cover - should not be called
|
|
3426
|
+
raise AssertionError("recv should not be called for rejected TriggerMessage")
|
|
3427
|
+
|
|
3428
|
+
handled = await sim._handle_csms_call(
|
|
3429
|
+
[
|
|
3430
|
+
2,
|
|
3431
|
+
"trigger-invalid",
|
|
3432
|
+
"TriggerMessage",
|
|
3433
|
+
{"requestedMessage": "StatusNotification", "connectorId": 1},
|
|
3434
|
+
],
|
|
3435
|
+
send,
|
|
3436
|
+
recv,
|
|
3437
|
+
)
|
|
3438
|
+
|
|
3439
|
+
self.assertTrue(handled)
|
|
3440
|
+
self.assertEqual(len(sent), 1)
|
|
3441
|
+
self.assertEqual(sent[0][0], 3)
|
|
3442
|
+
self.assertEqual(sent[0][1], "trigger-invalid")
|
|
3443
|
+
self.assertEqual(sent[0][2].get("status"), "Rejected")
|
|
2172
3444
|
|
|
2173
3445
|
|
|
2174
3446
|
class PurgeMeterReadingsTaskTests(TestCase):
|
|
@@ -2275,6 +3547,8 @@ class DispatchActionViewTests(TestCase):
|
|
|
2275
3547
|
)
|
|
2276
3548
|
store.clear_log(self.log_key, log_type="charger")
|
|
2277
3549
|
self.addCleanup(store.clear_log, self.log_key, "charger")
|
|
3550
|
+
store.pending_calls.clear()
|
|
3551
|
+
self.addCleanup(store.pending_calls.clear)
|
|
2278
3552
|
self.url = reverse(
|
|
2279
3553
|
"charger-action-connector",
|
|
2280
3554
|
args=[self.charger.charger_id, self.charger.connector_slug],
|
|
@@ -2321,6 +3595,58 @@ class DispatchActionViewTests(TestCase):
|
|
|
2321
3595
|
any("RemoteStartTransaction" in entry for entry in log_entries)
|
|
2322
3596
|
)
|
|
2323
3597
|
|
|
3598
|
+
def test_change_availability_dispatches_frame(self):
|
|
3599
|
+
response = self.client.post(
|
|
3600
|
+
self.url,
|
|
3601
|
+
data=json.dumps({"action": "change_availability", "type": "Inoperative"}),
|
|
3602
|
+
content_type="application/json",
|
|
3603
|
+
)
|
|
3604
|
+
self.assertEqual(response.status_code, 200)
|
|
3605
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
3606
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
3607
|
+
frame = json.loads(self.ws.sent[0])
|
|
3608
|
+
self.assertEqual(frame[0], 2)
|
|
3609
|
+
self.assertEqual(frame[2], "ChangeAvailability")
|
|
3610
|
+
self.assertEqual(frame[3]["type"], "Inoperative")
|
|
3611
|
+
self.assertEqual(frame[3]["connectorId"], 1)
|
|
3612
|
+
self.assertIn(frame[1], store.pending_calls)
|
|
3613
|
+
self.charger.refresh_from_db()
|
|
3614
|
+
self.assertEqual(self.charger.availability_requested_state, "Inoperative")
|
|
3615
|
+
self.assertIsNotNone(self.charger.availability_requested_at)
|
|
3616
|
+
self.assertEqual(self.charger.availability_request_status, "")
|
|
3617
|
+
|
|
3618
|
+
def test_change_availability_requires_valid_type(self):
|
|
3619
|
+
response = self.client.post(
|
|
3620
|
+
self.url,
|
|
3621
|
+
data=json.dumps({"action": "change_availability"}),
|
|
3622
|
+
content_type="application/json",
|
|
3623
|
+
)
|
|
3624
|
+
self.assertEqual(response.status_code, 400)
|
|
3625
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
3626
|
+
self.assertEqual(self.ws.sent, [])
|
|
3627
|
+
self.assertFalse(store.pending_calls)
|
|
3628
|
+
|
|
3629
|
+
|
|
3630
|
+
class SimulatorStateMappingTests(TestCase):
|
|
3631
|
+
def tearDown(self):
|
|
3632
|
+
_simulators[1] = SimulatorState()
|
|
3633
|
+
_simulators[2] = SimulatorState()
|
|
3634
|
+
|
|
3635
|
+
def test_simulate_uses_requested_state(self):
|
|
3636
|
+
calls = []
|
|
3637
|
+
|
|
3638
|
+
async def fake(cp_idx, *args, sim_state=None, **kwargs):
|
|
3639
|
+
calls.append(sim_state)
|
|
3640
|
+
if sim_state is not None:
|
|
3641
|
+
sim_state.running = False
|
|
3642
|
+
|
|
3643
|
+
with patch("ocpp.evcs.simulate_cp", new=fake):
|
|
3644
|
+
coro = simulate(cp=2, daemon=True, threads=1)
|
|
3645
|
+
asyncio.run(coro)
|
|
3646
|
+
|
|
3647
|
+
self.assertEqual(len(calls), 1)
|
|
3648
|
+
self.assertIs(calls[0], _simulators[2])
|
|
3649
|
+
|
|
2324
3650
|
|
|
2325
3651
|
class ChargerStatusViewTests(TestCase):
|
|
2326
3652
|
def setUp(self):
|
|
@@ -2700,3 +4026,69 @@ class LiveUpdateViewTests(TestCase):
|
|
|
2700
4026
|
ids = [item["charger_id"] for item in payload["chargers"]]
|
|
2701
4027
|
self.assertIn(public.charger_id, ids)
|
|
2702
4028
|
self.assertNotIn(private.charger_id, ids)
|
|
4029
|
+
|
|
4030
|
+
def test_dashboard_restricts_to_owner_users(self):
|
|
4031
|
+
User = get_user_model()
|
|
4032
|
+
owner = User.objects.create_user(username="owner", password="pw")
|
|
4033
|
+
other = User.objects.create_user(username="outsider", password="pw")
|
|
4034
|
+
unrestricted = Charger.objects.create(charger_id="UNRESTRICTED")
|
|
4035
|
+
restricted = Charger.objects.create(charger_id="RESTRICTED")
|
|
4036
|
+
restricted.owner_users.add(owner)
|
|
4037
|
+
|
|
4038
|
+
self.client.force_login(owner)
|
|
4039
|
+
owner_resp = self.client.get(reverse("ocpp-dashboard"))
|
|
4040
|
+
owner_chargers = [item["charger"] for item in owner_resp.context["chargers"]]
|
|
4041
|
+
self.assertIn(unrestricted, owner_chargers)
|
|
4042
|
+
self.assertIn(restricted, owner_chargers)
|
|
4043
|
+
|
|
4044
|
+
self.client.force_login(other)
|
|
4045
|
+
other_resp = self.client.get(reverse("ocpp-dashboard"))
|
|
4046
|
+
other_chargers = [item["charger"] for item in other_resp.context["chargers"]]
|
|
4047
|
+
self.assertIn(unrestricted, other_chargers)
|
|
4048
|
+
self.assertNotIn(restricted, other_chargers)
|
|
4049
|
+
|
|
4050
|
+
self.client.force_login(owner)
|
|
4051
|
+
detail_resp = self.client.get(
|
|
4052
|
+
reverse("charger-page", args=[restricted.charger_id])
|
|
4053
|
+
)
|
|
4054
|
+
self.assertEqual(detail_resp.status_code, 200)
|
|
4055
|
+
|
|
4056
|
+
self.client.force_login(other)
|
|
4057
|
+
denied_resp = self.client.get(
|
|
4058
|
+
reverse("charger-page", args=[restricted.charger_id])
|
|
4059
|
+
)
|
|
4060
|
+
self.assertEqual(denied_resp.status_code, 404)
|
|
4061
|
+
|
|
4062
|
+
def test_dashboard_restricts_to_owner_groups(self):
|
|
4063
|
+
User = get_user_model()
|
|
4064
|
+
group = SecurityGroup.objects.create(name="Operations")
|
|
4065
|
+
member = User.objects.create_user(username="member", password="pw")
|
|
4066
|
+
outsider = User.objects.create_user(username="visitor", password="pw")
|
|
4067
|
+
member.groups.add(group)
|
|
4068
|
+
unrestricted = Charger.objects.create(charger_id="GROUPFREE")
|
|
4069
|
+
restricted = Charger.objects.create(charger_id="GROUPLOCKED")
|
|
4070
|
+
restricted.owner_groups.add(group)
|
|
4071
|
+
|
|
4072
|
+
self.client.force_login(member)
|
|
4073
|
+
member_resp = self.client.get(reverse("ocpp-dashboard"))
|
|
4074
|
+
member_chargers = [item["charger"] for item in member_resp.context["chargers"]]
|
|
4075
|
+
self.assertIn(unrestricted, member_chargers)
|
|
4076
|
+
self.assertIn(restricted, member_chargers)
|
|
4077
|
+
|
|
4078
|
+
self.client.force_login(outsider)
|
|
4079
|
+
outsider_resp = self.client.get(reverse("ocpp-dashboard"))
|
|
4080
|
+
outsider_chargers = [item["charger"] for item in outsider_resp.context["chargers"]]
|
|
4081
|
+
self.assertIn(unrestricted, outsider_chargers)
|
|
4082
|
+
self.assertNotIn(restricted, outsider_chargers)
|
|
4083
|
+
|
|
4084
|
+
self.client.force_login(member)
|
|
4085
|
+
status_resp = self.client.get(
|
|
4086
|
+
reverse("charger-status", args=[restricted.charger_id])
|
|
4087
|
+
)
|
|
4088
|
+
self.assertEqual(status_resp.status_code, 200)
|
|
4089
|
+
|
|
4090
|
+
self.client.force_login(outsider)
|
|
4091
|
+
group_denied = self.client.get(
|
|
4092
|
+
reverse("charger-status", args=[restricted.charger_id])
|
|
4093
|
+
)
|
|
4094
|
+
self.assertEqual(group_denied.status_code, 404)
|