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.

Files changed (50) hide show
  1. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
  2. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
  3. config/asgi.py +15 -1
  4. config/celery.py +8 -1
  5. config/settings.py +49 -78
  6. config/settings_helpers.py +109 -0
  7. core/admin.py +293 -78
  8. core/apps.py +21 -0
  9. core/auto_upgrade.py +2 -2
  10. core/form_fields.py +75 -0
  11. core/models.py +203 -47
  12. core/reference_utils.py +1 -1
  13. core/release.py +42 -20
  14. core/system.py +6 -3
  15. core/tasks.py +92 -40
  16. core/tests.py +75 -1
  17. core/views.py +178 -29
  18. core/widgets.py +43 -0
  19. nodes/admin.py +583 -10
  20. nodes/apps.py +15 -0
  21. nodes/feature_checks.py +133 -0
  22. nodes/models.py +287 -49
  23. nodes/reports.py +411 -0
  24. nodes/tests.py +990 -42
  25. nodes/urls.py +1 -0
  26. nodes/utils.py +32 -0
  27. nodes/views.py +173 -5
  28. ocpp/admin.py +424 -17
  29. ocpp/consumers.py +630 -15
  30. ocpp/evcs.py +7 -94
  31. ocpp/evcs_discovery.py +158 -0
  32. ocpp/models.py +236 -4
  33. ocpp/routing.py +4 -2
  34. ocpp/simulator.py +346 -26
  35. ocpp/status_display.py +26 -0
  36. ocpp/store.py +110 -2
  37. ocpp/tests.py +1425 -33
  38. ocpp/transactions_io.py +27 -3
  39. ocpp/views.py +344 -38
  40. pages/admin.py +138 -3
  41. pages/context_processors.py +15 -1
  42. pages/defaults.py +1 -2
  43. pages/forms.py +67 -0
  44. pages/models.py +136 -1
  45. pages/tests.py +379 -4
  46. pages/urls.py +1 -0
  47. pages/views.py +64 -7
  48. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
  49. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
  50. {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 Client, TransactionTestCase, TestCase, override_settings
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 Transaction, Charger, Simulator, MeterReading, Location
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 core.models import EnergyAccount, EnergyCredit, Reference, RFID
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.assertIn('id="firmware-status">Installing<', html)
439
- self.assertIn('id="firmware-status-info">Applying patch<', html)
440
- match = re.search(
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(aggregate_status, "Reported status")
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 test_status_page_renders(self):
1183
- charger = Charger.objects.create(charger_id="PAGE2")
1184
- resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
1185
- self.assertEqual(resp.status_code, 200)
1186
- self.assertContains(resp, "PAGE2")
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
- def test_charger_page_shows_progress(self):
1189
- charger = Charger.objects.create(charger_id="STATS")
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=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(charger.charger_id, charger.connector_id)
1912
+ key = store.identity_key(connector.charger_id, connector.connector_id)
1196
1913
  store.transactions[key] = tx
1197
- resp = self.client.get(reverse("charger-page", args=["STATS"]))
1198
- self.assertContains(resp, "progress-bar")
1199
- store.transactions.pop(key, None)
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
- self.assertEqual(door_open_messages[0].get("status"), "Faulted")
2171
- self.assertEqual(door_closed_messages[0].get("status"), "Available")
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)