arthexis 0.1.10__py3-none-any.whl → 0.1.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

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