arthexis 0.1.16__py3-none-any.whl → 0.1.28__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 (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/tests.py CHANGED
@@ -1,6 +1,8 @@
1
1
  import os
2
2
  import sys
3
3
  import time
4
+ import tempfile
5
+ from collections import deque
4
6
  from importlib import util as importlib_util
5
7
  from pathlib import Path
6
8
  from types import ModuleType
@@ -55,19 +57,27 @@ from django.contrib.sites.models import Site
55
57
  from django.core.exceptions import ValidationError
56
58
  from pages.models import Application, Module
57
59
  from nodes.models import Node, NodeRole
60
+ from django.contrib.admin.sites import AdminSite
58
61
 
59
62
  from config.asgi import application
60
63
 
61
64
  from .models import (
62
65
  Transaction,
63
66
  Charger,
67
+ ChargerConfiguration,
68
+ ConfigurationKey,
64
69
  Simulator,
65
70
  MeterReading,
71
+ MeterValue,
66
72
  Location,
67
73
  DataTransferMessage,
74
+ CPReservation,
75
+ CPFirmware,
76
+ CPFirmwareDeployment,
68
77
  )
78
+ from .admin import ChargerConfigurationAdmin, ConfigurationKeyAdmin, ConfigurationKeyInline
69
79
  from .consumers import CSMSConsumer
70
- from .views import dispatch_action
80
+ from .views import dispatch_action, _transaction_rfid_details, _usage_timeline
71
81
  from .status_display import STATUS_BADGE_MAP
72
82
  from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
73
83
  from . import store
@@ -80,8 +90,14 @@ from .simulator import SimulatorConfig, ChargePointSimulator
80
90
  from .evcs import simulate, SimulatorState, _simulators
81
91
  import re
82
92
  from datetime import datetime, timedelta, timezone as dt_timezone
83
- from .tasks import purge_meter_readings, send_daily_session_report
84
- from django.db import close_old_connections
93
+ from .tasks import (
94
+ purge_meter_readings,
95
+ send_daily_session_report,
96
+ check_charge_point_configuration,
97
+ schedule_daily_charge_point_configuration_checks,
98
+ )
99
+ from django.db import close_old_connections, connection
100
+ from django.test.utils import CaptureQueriesContext
85
101
  from django.db.utils import OperationalError
86
102
  from urllib.parse import unquote, urlparse
87
103
 
@@ -169,6 +185,36 @@ class DispatchActionTests(TestCase):
169
185
  self.assertEqual(metadata.get("trigger_target"), "BootNotification")
170
186
  self.assertEqual(metadata.get("log_key"), log_key)
171
187
 
188
+ def test_reset_rejected_when_transaction_active(self):
189
+ charger = Charger.objects.create(charger_id="RESETBLOCK")
190
+ dummy = DummyWebSocket()
191
+ connection_key = store.set_connection(charger.charger_id, charger.connector_id, dummy)
192
+ self.addCleanup(lambda: store.connections.pop(connection_key, None))
193
+ tx_obj = Transaction.objects.create(
194
+ charger=charger,
195
+ connector_id=charger.connector_id,
196
+ start_time=timezone.now(),
197
+ )
198
+ tx_key = store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
199
+ self.addCleanup(lambda: store.transactions.pop(tx_key, None))
200
+
201
+ request = self.factory.post(
202
+ "/chargers/RESETBLOCK/action/",
203
+ data=json.dumps({"action": "reset"}),
204
+ content_type="application/json",
205
+ )
206
+ request.user = SimpleNamespace(
207
+ is_authenticated=True,
208
+ is_superuser=True,
209
+ is_staff=True,
210
+ )
211
+
212
+ response = dispatch_action(request, charger.charger_id)
213
+ self.assertEqual(response.status_code, 409)
214
+ payload = json.loads(response.content.decode("utf-8"))
215
+ self.assertIn("stop the session first", payload.get("detail", "").lower())
216
+ self.assertFalse(dummy.sent)
217
+
172
218
  class ChargerFixtureTests(TestCase):
173
219
  fixtures = [
174
220
  p.name
@@ -210,6 +256,368 @@ class ChargerFixtureTests(TestCase):
210
256
  self.assertEqual(cp2.name, "Simulator #2")
211
257
 
212
258
 
259
+ class ChargerRefreshManagerNodeTests(TestCase):
260
+ @classmethod
261
+ def setUpTestData(cls):
262
+ local = Node.objects.create(
263
+ hostname="local-node",
264
+ address="127.0.0.1",
265
+ port=8000,
266
+ mac_address="aa:bb:cc:dd:ee:ff",
267
+ current_relation=Node.Relation.SELF,
268
+ )
269
+ Node.objects.filter(pk=local.pk).update(mac_address="AA:BB:CC:DD:EE:FF")
270
+ cls.local_node = Node.objects.get(pk=local.pk)
271
+
272
+ def test_refresh_manager_node_assigns_local_to_unsaved_charger(self):
273
+ charger = Charger(charger_id="UNSAVED")
274
+
275
+ with patch("nodes.models.Node.get_current_mac", return_value="aa:bb:cc:dd:ee:ff"):
276
+ result = charger.refresh_manager_node()
277
+
278
+ self.assertEqual(result, self.local_node)
279
+ self.assertEqual(charger.manager_node, self.local_node)
280
+
281
+ def test_refresh_manager_node_updates_persisted_charger(self):
282
+ remote = Node.objects.create(
283
+ hostname="remote-node",
284
+ address="10.0.0.1",
285
+ port=9000,
286
+ mac_address="11:22:33:44:55:66",
287
+ )
288
+ charger = Charger.objects.create(
289
+ charger_id="PERSISTED",
290
+ manager_node=remote,
291
+ )
292
+
293
+ charger.refresh_manager_node(node=self.local_node)
294
+
295
+ self.assertEqual(charger.manager_node, self.local_node)
296
+ charger.refresh_from_db()
297
+ self.assertEqual(charger.manager_node, self.local_node)
298
+
299
+ def test_refresh_manager_node_handles_missing_local_node(self):
300
+ remote = Node.objects.create(
301
+ hostname="existing-manager",
302
+ address="10.0.0.2",
303
+ port=9001,
304
+ mac_address="22:33:44:55:66:77",
305
+ )
306
+ charger = Charger(charger_id="NOLOCAL", manager_node=remote)
307
+
308
+ with patch.object(Node, "get_local", return_value=None):
309
+ result = charger.refresh_manager_node()
310
+
311
+ self.assertIsNone(result)
312
+ self.assertEqual(charger.manager_node, remote)
313
+
314
+
315
+ class CPReservationTests(TransactionTestCase):
316
+ def setUp(self):
317
+ self.location = Location.objects.create(name="Reservation Site")
318
+ self.aggregate = Charger.objects.create(charger_id="RSV100", location=self.location)
319
+ self.connector_one = Charger.objects.create(
320
+ charger_id="RSV100", connector_id=1, location=self.location
321
+ )
322
+ self.connector_two = Charger.objects.create(
323
+ charger_id="RSV100", connector_id=2, location=self.location
324
+ )
325
+ self.addCleanup(store.clear_pending_calls, "RSV100")
326
+
327
+ def test_allocates_preferred_connector(self):
328
+ start = timezone.now() + timedelta(hours=1)
329
+ reservation = CPReservation(
330
+ location=self.location,
331
+ start_time=start,
332
+ duration_minutes=90,
333
+ id_tag="TAG001",
334
+ )
335
+ reservation.save()
336
+ self.assertEqual(reservation.connector, self.connector_two)
337
+
338
+ def test_allocation_falls_back_and_blocks_overlaps(self):
339
+ start = timezone.now() + timedelta(hours=1)
340
+ first = CPReservation.objects.create(
341
+ location=self.location,
342
+ start_time=start,
343
+ duration_minutes=60,
344
+ id_tag="TAG002",
345
+ )
346
+ self.assertEqual(first.connector, self.connector_two)
347
+ second = CPReservation(
348
+ location=self.location,
349
+ start_time=start + timedelta(minutes=15),
350
+ duration_minutes=60,
351
+ id_tag="TAG003",
352
+ )
353
+ second.save()
354
+ self.assertEqual(second.connector, self.connector_one)
355
+ third = CPReservation(
356
+ location=self.location,
357
+ start_time=start + timedelta(minutes=30),
358
+ duration_minutes=45,
359
+ id_tag="TAG004",
360
+ )
361
+ with self.assertRaises(ValidationError):
362
+ third.save()
363
+
364
+ def test_send_reservation_request_dispatches_frame(self):
365
+ start = timezone.now() + timedelta(hours=1)
366
+ reservation = CPReservation.objects.create(
367
+ location=self.location,
368
+ start_time=start,
369
+ duration_minutes=30,
370
+ id_tag="TAG005",
371
+ )
372
+
373
+ class DummyConnection:
374
+ def __init__(self):
375
+ self.sent: list[str] = []
376
+
377
+ async def send(self, message):
378
+ self.sent.append(message)
379
+
380
+ ws = DummyConnection()
381
+ store.set_connection(
382
+ reservation.connector.charger_id,
383
+ reservation.connector.connector_id,
384
+ ws,
385
+ )
386
+ self.addCleanup(
387
+ store.pop_connection,
388
+ reservation.connector.charger_id,
389
+ reservation.connector.connector_id,
390
+ )
391
+
392
+ message_id = reservation.send_reservation_request()
393
+ self.assertTrue(ws.sent)
394
+ frame = json.loads(ws.sent[0])
395
+ self.assertEqual(frame[0], 2)
396
+ self.assertEqual(frame[2], "ReserveNow")
397
+ self.assertEqual(frame[3]["reservationId"], reservation.pk)
398
+ self.assertEqual(frame[3]["connectorId"], reservation.connector.connector_id)
399
+ self.assertEqual(frame[3]["idTag"], "TAG005")
400
+ metadata = store.pending_calls.get(message_id)
401
+ self.assertIsNotNone(metadata)
402
+ self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
403
+
404
+ def test_send_cancel_request_dispatches_frame(self):
405
+ start = timezone.now() + timedelta(hours=1)
406
+ reservation = CPReservation.objects.create(
407
+ location=self.location,
408
+ start_time=start,
409
+ duration_minutes=30,
410
+ id_tag="TAG010",
411
+ )
412
+
413
+ class DummyConnection:
414
+ def __init__(self):
415
+ self.sent: list[str] = []
416
+
417
+ async def send(self, message):
418
+ self.sent.append(message)
419
+
420
+ ws = DummyConnection()
421
+ store.set_connection(
422
+ reservation.connector.charger_id,
423
+ reservation.connector.connector_id,
424
+ ws,
425
+ )
426
+ self.addCleanup(
427
+ store.pop_connection,
428
+ reservation.connector.charger_id,
429
+ reservation.connector.connector_id,
430
+ )
431
+
432
+ message_id = reservation.send_cancel_request()
433
+ self.assertTrue(ws.sent)
434
+ frame = json.loads(ws.sent[0])
435
+ self.assertEqual(frame[0], 2)
436
+ self.assertEqual(frame[2], "CancelReservation")
437
+ self.assertEqual(frame[3]["reservationId"], reservation.pk)
438
+ metadata = store.pending_calls.get(message_id)
439
+ self.assertIsNotNone(metadata)
440
+ self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
441
+ self.assertEqual(metadata.get("action"), "CancelReservation")
442
+
443
+ def test_call_result_marks_reservation_confirmed(self):
444
+ start = timezone.now() + timedelta(hours=1)
445
+ reservation = CPReservation.objects.create(
446
+ location=self.location,
447
+ start_time=start,
448
+ duration_minutes=45,
449
+ id_tag="TAG006",
450
+ )
451
+ log_key = store.identity_key(
452
+ reservation.connector.charger_id, reservation.connector.connector_id
453
+ )
454
+ message_id = "reserve-success"
455
+ store.register_pending_call(
456
+ message_id,
457
+ {
458
+ "action": "ReserveNow",
459
+ "charger_id": reservation.connector.charger_id,
460
+ "connector_id": reservation.connector.connector_id,
461
+ "log_key": log_key,
462
+ "reservation_pk": reservation.pk,
463
+ },
464
+ )
465
+
466
+ consumer = CSMSConsumer()
467
+ consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
468
+ consumer.charger_id = reservation.connector.charger_id
469
+ consumer.store_key = log_key
470
+ consumer.connector_value = reservation.connector.connector_id
471
+ consumer.charger = reservation.connector
472
+ consumer.aggregate_charger = self.aggregate
473
+ consumer._consumption_task = None
474
+ consumer._consumption_message_uuid = None
475
+ consumer.send = AsyncMock()
476
+
477
+ async_to_sync(consumer._handle_call_result)(
478
+ message_id, {"status": "Accepted"}
479
+ )
480
+ reservation.refresh_from_db()
481
+ self.assertTrue(reservation.evcs_confirmed)
482
+ self.assertEqual(reservation.evcs_status, "Accepted")
483
+ self.assertIsNotNone(reservation.evcs_confirmed_at)
484
+
485
+ def test_call_error_updates_reservation_status(self):
486
+ start = timezone.now() + timedelta(hours=1)
487
+ reservation = CPReservation.objects.create(
488
+ location=self.location,
489
+ start_time=start,
490
+ duration_minutes=45,
491
+ id_tag="TAG007",
492
+ )
493
+ log_key = store.identity_key(
494
+ reservation.connector.charger_id, reservation.connector.connector_id
495
+ )
496
+ message_id = "reserve-error"
497
+ store.register_pending_call(
498
+ message_id,
499
+ {
500
+ "action": "ReserveNow",
501
+ "charger_id": reservation.connector.charger_id,
502
+ "connector_id": reservation.connector.connector_id,
503
+ "log_key": log_key,
504
+ "reservation_pk": reservation.pk,
505
+ },
506
+ )
507
+
508
+ consumer = CSMSConsumer()
509
+ consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
510
+ consumer.charger_id = reservation.connector.charger_id
511
+ consumer.store_key = log_key
512
+ consumer.connector_value = reservation.connector.connector_id
513
+ consumer.charger = reservation.connector
514
+ consumer.aggregate_charger = self.aggregate
515
+ consumer._consumption_task = None
516
+ consumer._consumption_message_uuid = None
517
+ consumer.send = AsyncMock()
518
+
519
+ async_to_sync(consumer._handle_call_error)(
520
+ message_id,
521
+ "Rejected",
522
+ "Charger unavailable",
523
+ {"reason": "maintenance"},
524
+ )
525
+ reservation.refresh_from_db()
526
+ self.assertFalse(reservation.evcs_confirmed)
527
+ self.assertEqual(reservation.evcs_status, "")
528
+ self.assertIsNone(reservation.evcs_confirmed_at)
529
+ self.assertIn("Rejected", reservation.evcs_error or "")
530
+
531
+ def test_cancel_reservation_result_updates_status(self):
532
+ start = timezone.now() + timedelta(hours=1)
533
+ reservation = CPReservation.objects.create(
534
+ location=self.location,
535
+ start_time=start,
536
+ duration_minutes=45,
537
+ id_tag="TAG008",
538
+ )
539
+ log_key = store.identity_key(
540
+ reservation.connector.charger_id, reservation.connector.connector_id
541
+ )
542
+ message_id = "cancel-success"
543
+ store.register_pending_call(
544
+ message_id,
545
+ {
546
+ "action": "CancelReservation",
547
+ "charger_id": reservation.connector.charger_id,
548
+ "connector_id": reservation.connector.connector_id,
549
+ "log_key": log_key,
550
+ "reservation_pk": reservation.pk,
551
+ },
552
+ )
553
+
554
+ consumer = CSMSConsumer()
555
+ consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
556
+ consumer.charger_id = reservation.connector.charger_id
557
+ consumer.store_key = log_key
558
+ consumer.connector_value = reservation.connector.connector_id
559
+ consumer.charger = reservation.connector
560
+ consumer.aggregate_charger = self.aggregate
561
+ consumer._consumption_task = None
562
+ consumer._consumption_message_uuid = None
563
+ consumer.send = AsyncMock()
564
+
565
+ async_to_sync(consumer._handle_call_result)(
566
+ message_id, {"status": "Accepted"}
567
+ )
568
+ reservation.refresh_from_db()
569
+ self.assertEqual(reservation.evcs_status, "Accepted")
570
+ self.assertFalse(reservation.evcs_confirmed)
571
+ self.assertIsNone(reservation.evcs_confirmed_at)
572
+ self.assertEqual(reservation.evcs_error, "")
573
+
574
+ def test_cancel_reservation_error_updates_status(self):
575
+ start = timezone.now() + timedelta(hours=1)
576
+ reservation = CPReservation.objects.create(
577
+ location=self.location,
578
+ start_time=start,
579
+ duration_minutes=45,
580
+ id_tag="TAG009",
581
+ )
582
+ log_key = store.identity_key(
583
+ reservation.connector.charger_id, reservation.connector.connector_id
584
+ )
585
+ message_id = "cancel-error"
586
+ store.register_pending_call(
587
+ message_id,
588
+ {
589
+ "action": "CancelReservation",
590
+ "charger_id": reservation.connector.charger_id,
591
+ "connector_id": reservation.connector.connector_id,
592
+ "log_key": log_key,
593
+ "reservation_pk": reservation.pk,
594
+ },
595
+ )
596
+
597
+ consumer = CSMSConsumer()
598
+ consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
599
+ consumer.charger_id = reservation.connector.charger_id
600
+ consumer.store_key = log_key
601
+ consumer.connector_value = reservation.connector.connector_id
602
+ consumer.charger = reservation.connector
603
+ consumer.aggregate_charger = self.aggregate
604
+ consumer._consumption_task = None
605
+ consumer._consumption_message_uuid = None
606
+ consumer.send = AsyncMock()
607
+
608
+ async_to_sync(consumer._handle_call_error)(
609
+ message_id,
610
+ "Rejected",
611
+ "Connector busy",
612
+ {"reason": "occupied"},
613
+ )
614
+ reservation.refresh_from_db()
615
+ self.assertEqual(reservation.evcs_status, "")
616
+ self.assertFalse(reservation.evcs_confirmed)
617
+ self.assertIsNone(reservation.evcs_confirmed_at)
618
+ self.assertIn("Rejected", reservation.evcs_error or "")
619
+
620
+
213
621
  class ChargerUrlFallbackTests(TestCase):
214
622
  @override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
215
623
  def test_reference_created_when_site_missing(self):
@@ -259,6 +667,33 @@ class CSMSConsumerTests(TransactionTestCase):
259
667
  await asyncio.sleep(delay)
260
668
  raise
261
669
 
670
+ def _create_firmware_deployment(self, charger_id: str) -> int:
671
+ try:
672
+ charger = Charger.objects.get(charger_id=charger_id, connector_id=None)
673
+ except Charger.DoesNotExist:
674
+ charger = Charger.objects.create(charger_id=charger_id, connector_id=None)
675
+ firmware = CPFirmware.objects.create(
676
+ name=f"{charger_id} firmware",
677
+ filename=f"{charger_id}.bin",
678
+ payload_binary=b"firmware",
679
+ content_type="application/octet-stream",
680
+ source=CPFirmware.Source.DOWNLOAD,
681
+ is_user_data=True,
682
+ )
683
+ deployment = CPFirmwareDeployment.objects.create(
684
+ firmware=firmware,
685
+ charger=charger,
686
+ node=charger.node_origin,
687
+ ocpp_message_id=f"deploy-{charger_id}",
688
+ status="Pending",
689
+ status_info="",
690
+ status_timestamp=timezone.now(),
691
+ retrieve_date=timezone.now(),
692
+ request_payload={},
693
+ is_user_data=True,
694
+ )
695
+ return deployment.pk
696
+
262
697
  async def _send_status_notification(self, serial: str, payload: dict):
263
698
  communicator = WebsocketCommunicator(application, f"/{serial}/")
264
699
  connected, _ = await communicator.connect()
@@ -439,7 +874,9 @@ class CSMSConsumerTests(TransactionTestCase):
439
874
  store.logs.setdefault("charger", {})
440
875
  store.logs["charger"].clear()
441
876
  for key, entries in original_logs.items():
442
- store.logs["charger"][key] = list(entries)
877
+ store.logs["charger"][key] = deque(
878
+ entries, maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES
879
+ )
443
880
  store.log_names.setdefault("charger", {})
444
881
  store.log_names["charger"].clear()
445
882
  store.log_names["charger"].update(original_log_names)
@@ -704,6 +1141,151 @@ class CSMSConsumerTests(TransactionTestCase):
704
1141
  self.assertEqual(charger.availability_requested_state, "Inoperative")
705
1142
  await communicator.disconnect()
706
1143
 
1144
+ async def test_clear_cache_result_updates_local_auth_version(self):
1145
+ store.pending_calls.clear()
1146
+ log_key = store.identity_key("CLEARCACHE", None)
1147
+ store.clear_log(log_key, log_type="charger")
1148
+ communicator = WebsocketCommunicator(application, "/CLEARCACHE/")
1149
+ connected, _ = await communicator.connect()
1150
+ self.assertTrue(connected)
1151
+
1152
+ await communicator.send_json_to(
1153
+ [
1154
+ 2,
1155
+ "boot",
1156
+ "BootNotification",
1157
+ {"chargePointVendor": "Test", "chargePointModel": "Model"},
1158
+ ]
1159
+ )
1160
+ await communicator.receive_json_from()
1161
+
1162
+ message_id = "cc-accepted"
1163
+ requested_at = timezone.now()
1164
+ store.register_pending_call(
1165
+ message_id,
1166
+ {
1167
+ "action": "ClearCache",
1168
+ "charger_id": "CLEARCACHE",
1169
+ "connector_id": None,
1170
+ "log_key": log_key,
1171
+ "requested_at": requested_at,
1172
+ },
1173
+ )
1174
+
1175
+ mock_update = AsyncMock()
1176
+ with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
1177
+ await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
1178
+ await asyncio.sleep(0.05)
1179
+
1180
+ mock_update.assert_awaited_with(0)
1181
+ result = store.wait_for_pending_call(message_id, timeout=0.1)
1182
+ self.assertIsNotNone(result)
1183
+ payload = result.get("payload") or {}
1184
+ self.assertEqual(payload.get("status"), "Accepted")
1185
+ log_entries = store.logs["charger"].get(log_key, [])
1186
+ self.assertTrue(any("ClearCache result" in entry for entry in log_entries))
1187
+
1188
+ store.clear_log(log_key, log_type="charger")
1189
+ await communicator.disconnect()
1190
+
1191
+ async def test_clear_cache_rejection_updates_timestamp(self):
1192
+ store.pending_calls.clear()
1193
+ log_key = store.identity_key("CLEARCACHE-REJ", None)
1194
+ store.clear_log(log_key, log_type="charger")
1195
+ communicator = WebsocketCommunicator(application, "/CLEARCACHE-REJ/")
1196
+ connected, _ = await communicator.connect()
1197
+ self.assertTrue(connected)
1198
+
1199
+ await communicator.send_json_to(
1200
+ [
1201
+ 2,
1202
+ "boot",
1203
+ "BootNotification",
1204
+ {"chargePointVendor": "Test", "chargePointModel": "Model"},
1205
+ ]
1206
+ )
1207
+ await communicator.receive_json_from()
1208
+
1209
+ message_id = "cc-rejected"
1210
+ store.register_pending_call(
1211
+ message_id,
1212
+ {
1213
+ "action": "ClearCache",
1214
+ "charger_id": "CLEARCACHE-REJ",
1215
+ "connector_id": None,
1216
+ "log_key": log_key,
1217
+ },
1218
+ )
1219
+
1220
+ mock_update = AsyncMock()
1221
+ with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
1222
+ await communicator.send_json_to([3, message_id, {"status": "Rejected"}])
1223
+ await asyncio.sleep(0.05)
1224
+
1225
+ mock_update.assert_awaited_with(None)
1226
+ result = store.wait_for_pending_call(message_id, timeout=0.1)
1227
+ self.assertIsNotNone(result)
1228
+ payload = result.get("payload") or {}
1229
+ self.assertEqual(payload.get("status"), "Rejected")
1230
+ log_entries = store.logs["charger"].get(log_key, [])
1231
+ self.assertTrue(any("ClearCache result" in entry for entry in log_entries))
1232
+
1233
+ store.clear_log(log_key, log_type="charger")
1234
+ await communicator.disconnect()
1235
+
1236
+ async def test_clear_cache_error_records_failure(self):
1237
+ store.pending_calls.clear()
1238
+ log_key = store.identity_key("CLEARCACHE-ERR", None)
1239
+ store.clear_log(log_key, log_type="charger")
1240
+ communicator = WebsocketCommunicator(application, "/CLEARCACHE-ERR/")
1241
+ connected, _ = await communicator.connect()
1242
+ self.assertTrue(connected)
1243
+
1244
+ await communicator.send_json_to(
1245
+ [
1246
+ 2,
1247
+ "boot",
1248
+ "BootNotification",
1249
+ {"chargePointVendor": "Test", "chargePointModel": "Model"},
1250
+ ]
1251
+ )
1252
+ await communicator.receive_json_from()
1253
+
1254
+ message_id = "cc-error"
1255
+ store.register_pending_call(
1256
+ message_id,
1257
+ {
1258
+ "action": "ClearCache",
1259
+ "charger_id": "CLEARCACHE-ERR",
1260
+ "connector_id": None,
1261
+ "log_key": log_key,
1262
+ },
1263
+ )
1264
+
1265
+ mock_update = AsyncMock()
1266
+ with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
1267
+ await communicator.send_json_to(
1268
+ [
1269
+ 4,
1270
+ message_id,
1271
+ "InternalError",
1272
+ "Failed",
1273
+ {"detail": "boom"},
1274
+ ]
1275
+ )
1276
+ await asyncio.sleep(0.05)
1277
+
1278
+ mock_update.assert_awaited_with(None)
1279
+ result = store.wait_for_pending_call(message_id, timeout=0.1)
1280
+ self.assertIsNotNone(result)
1281
+ self.assertFalse(result.get("success"))
1282
+ self.assertEqual(result.get("error_code"), "InternalError")
1283
+ log_entries = store.logs["charger"].get(log_key, [])
1284
+ self.assertTrue(any("ClearCache error" in entry for entry in log_entries))
1285
+
1286
+ store.clear_log(log_key, log_type="charger")
1287
+ await communicator.disconnect()
1288
+
707
1289
  async def test_get_configuration_result_logged(self):
708
1290
  store.pending_calls.clear()
709
1291
  pending_key = store.pending_key("CFGRES")
@@ -714,6 +1296,13 @@ class CSMSConsumerTests(TransactionTestCase):
714
1296
  connected, _ = await communicator.connect()
715
1297
  self.assertTrue(connected)
716
1298
 
1299
+ await database_sync_to_async(Charger.objects.get_or_create)(
1300
+ charger_id="CFGRES", connector_id=1
1301
+ )
1302
+ await database_sync_to_async(Charger.objects.get_or_create)(
1303
+ charger_id="CFGRES", connector_id=2
1304
+ )
1305
+
717
1306
  message_id = "cfg-result"
718
1307
  payload = {
719
1308
  "configurationKey": [
@@ -744,6 +1333,56 @@ class CSMSConsumerTests(TransactionTestCase):
744
1333
  )
745
1334
  self.assertNotIn(message_id, store.pending_calls)
746
1335
 
1336
+ configuration = await database_sync_to_async(
1337
+ lambda: ChargerConfiguration.objects.order_by("-created_at").first()
1338
+ )()
1339
+ self.assertIsNotNone(configuration)
1340
+ self.assertEqual(configuration.charger_identifier, "CFGRES")
1341
+ self.assertIsNotNone(configuration.evcs_snapshot_at)
1342
+ self.assertEqual(
1343
+ configuration.configuration_keys,
1344
+ [
1345
+ {
1346
+ "key": "AllowOfflineTxForUnknownId",
1347
+ "value": "false",
1348
+ "readonly": True,
1349
+ }
1350
+ ],
1351
+ )
1352
+ key_rows = await database_sync_to_async(
1353
+ lambda: [
1354
+ {
1355
+ "key": item.key,
1356
+ "value": item.value,
1357
+ "readonly": item.readonly,
1358
+ "has_value": item.has_value,
1359
+ }
1360
+ for item in ConfigurationKey.objects.filter(
1361
+ configuration=configuration
1362
+ ).order_by("position", "id")
1363
+ ]
1364
+ )()
1365
+ self.assertEqual(
1366
+ key_rows,
1367
+ [
1368
+ {
1369
+ "key": "AllowOfflineTxForUnknownId",
1370
+ "value": "false",
1371
+ "readonly": True,
1372
+ "has_value": True,
1373
+ }
1374
+ ],
1375
+ )
1376
+ self.assertEqual(configuration.unknown_keys, [])
1377
+ config_ids = await database_sync_to_async(
1378
+ lambda: set(
1379
+ Charger.objects.filter(charger_id="CFGRES").values_list(
1380
+ "configuration_id", flat=True
1381
+ )
1382
+ )
1383
+ )()
1384
+ self.assertEqual(config_ids, {configuration.pk})
1385
+
747
1386
  await communicator.disconnect()
748
1387
  store.clear_log(log_key, log_type="charger")
749
1388
  store.clear_log(pending_key, log_type="charger")
@@ -956,91 +1595,179 @@ class CSMSConsumerTests(TransactionTestCase):
956
1595
  await communicator.disconnect()
957
1596
 
958
1597
  async def test_firmware_status_notification_updates_database_and_views(self):
1598
+ store.ip_connections.clear()
1599
+ limit = store.MAX_CONNECTIONS_PER_IP
1600
+ store.MAX_CONNECTIONS_PER_IP = 10
959
1601
  communicator = WebsocketCommunicator(application, "/FWSTAT/")
960
- connected, _ = await communicator.connect()
961
- self.assertTrue(connected)
1602
+ try:
1603
+ connected, detail = await communicator.connect()
1604
+ self.assertTrue(connected, detail)
1605
+
1606
+ deployment_pk = await database_sync_to_async(
1607
+ self._create_firmware_deployment
1608
+ )("FWSTAT")
1609
+ ts = timezone.now().replace(microsecond=0)
1610
+ payload = {
1611
+ "status": "Installing",
1612
+ "statusInfo": "Applying patch",
1613
+ "timestamp": ts.isoformat(),
1614
+ }
962
1615
 
963
- ts = timezone.now().replace(microsecond=0)
964
- payload = {
965
- "status": "Installing",
966
- "statusInfo": "Applying patch",
967
- "timestamp": ts.isoformat(),
968
- }
1616
+ await communicator.send_json_to(
1617
+ [2, "1", "FirmwareStatusNotification", payload]
1618
+ )
1619
+ response = await communicator.receive_json_from()
1620
+ self.assertEqual(response, [3, "1", {}])
969
1621
 
970
- await communicator.send_json_to(
971
- [2, "1", "FirmwareStatusNotification", payload]
972
- )
973
- response = await communicator.receive_json_from()
974
- self.assertEqual(response, [3, "1", {}])
1622
+ def _fetch_status():
1623
+ charger = Charger.objects.get(
1624
+ charger_id="FWSTAT", connector_id=None
1625
+ )
1626
+ return (
1627
+ charger.firmware_status,
1628
+ charger.firmware_status_info,
1629
+ charger.firmware_timestamp,
1630
+ )
975
1631
 
976
- def _fetch_status():
977
- charger = Charger.objects.get(charger_id="FWSTAT", connector_id=None)
978
- return (
979
- charger.firmware_status,
980
- charger.firmware_status_info,
981
- charger.firmware_timestamp,
1632
+ status, info, recorded_ts = await database_sync_to_async(
1633
+ _fetch_status
1634
+ )()
1635
+ self.assertEqual(status, "Installing")
1636
+ self.assertEqual(info, "Applying patch")
1637
+ self.assertIsNotNone(recorded_ts)
1638
+ self.assertEqual(recorded_ts.replace(microsecond=0), ts)
1639
+
1640
+ def _fetch_deployment():
1641
+ return CPFirmwareDeployment.objects.get(pk=deployment_pk)
1642
+
1643
+ deployment = await database_sync_to_async(_fetch_deployment)()
1644
+ self.assertEqual(deployment.status, "Installing")
1645
+ self.assertEqual(deployment.status_info, "Applying patch")
1646
+ self.assertIsNotNone(deployment.status_timestamp)
1647
+ self.assertEqual(
1648
+ deployment.status_timestamp.replace(microsecond=0), ts
982
1649
  )
983
1650
 
984
- status, info, recorded_ts = await database_sync_to_async(_fetch_status)()
985
- self.assertEqual(status, "Installing")
986
- self.assertEqual(info, "Applying patch")
987
- self.assertIsNotNone(recorded_ts)
988
- self.assertEqual(recorded_ts.replace(microsecond=0), ts)
1651
+ log_entries = store.get_logs(
1652
+ store.identity_key("FWSTAT", None), log_type="charger"
1653
+ )
1654
+ self.assertTrue(
1655
+ any("FirmwareStatusNotification" in entry for entry in log_entries)
1656
+ )
989
1657
 
990
- log_entries = store.get_logs(store.identity_key("FWSTAT", None), log_type="charger")
991
- self.assertTrue(
992
- any("FirmwareStatusNotification" in entry for entry in log_entries)
993
- )
1658
+ def _fetch_views():
1659
+ User = get_user_model()
1660
+ user = User.objects.create_user(username="fwstatus", password="pw")
1661
+ client = Client()
1662
+ client.force_login(user)
1663
+ detail = client.get(reverse("charger-detail", args=["FWSTAT"]))
1664
+ status_page = client.get(reverse("charger-status", args=["FWSTAT"]))
1665
+ list_response = client.get(reverse("charger-list"))
1666
+ return (
1667
+ detail.status_code,
1668
+ json.loads(detail.content.decode()),
1669
+ status_page.status_code,
1670
+ status_page.content.decode(),
1671
+ list_response.status_code,
1672
+ json.loads(list_response.content.decode()),
1673
+ )
994
1674
 
995
- def _fetch_views():
996
- User = get_user_model()
997
- user = User.objects.create_user(username="fwstatus", password="pw")
998
- client = Client()
999
- client.force_login(user)
1000
- detail = client.get(reverse("charger-detail", args=["FWSTAT"]))
1001
- status_page = client.get(reverse("charger-status", args=["FWSTAT"]))
1002
- list_response = client.get(reverse("charger-list"))
1003
- return (
1004
- detail.status_code,
1005
- json.loads(detail.content.decode()),
1006
- status_page.status_code,
1007
- status_page.content.decode(),
1008
- list_response.status_code,
1009
- json.loads(list_response.content.decode()),
1675
+ (
1676
+ detail_code,
1677
+ detail_payload,
1678
+ status_code,
1679
+ html,
1680
+ list_code,
1681
+ list_payload,
1682
+ ) = await database_sync_to_async(_fetch_views)()
1683
+ self.assertEqual(detail_code, 200)
1684
+ self.assertEqual(status_code, 200)
1685
+ self.assertEqual(list_code, 200)
1686
+ self.assertEqual(detail_payload["firmwareStatus"], "Installing")
1687
+ self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
1688
+ self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
1689
+ self.assertNotIn('id="firmware-status"', html)
1690
+ self.assertNotIn('id="firmware-status-info"', html)
1691
+ self.assertNotIn('id="firmware-timestamp"', html)
1692
+
1693
+ matching = [
1694
+ item
1695
+ for item in list_payload.get("chargers", [])
1696
+ if item["charger_id"] == "FWSTAT"
1697
+ and item["connector_id"] is None
1698
+ ]
1699
+ self.assertTrue(matching)
1700
+ self.assertEqual(matching[0]["firmwareStatus"], "Installing")
1701
+ self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
1702
+ list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
1703
+ self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)
1704
+
1705
+ store.clear_log(
1706
+ store.identity_key("FWSTAT", None), log_type="charger"
1010
1707
  )
1708
+ finally:
1709
+ with suppress(Exception):
1710
+ await communicator.disconnect()
1711
+ store.MAX_CONNECTIONS_PER_IP = limit
1712
+
1713
+ async def test_update_firmware_call_result_updates_deployment(self):
1714
+ store.ip_connections.clear()
1715
+ limit = store.MAX_CONNECTIONS_PER_IP
1716
+ store.MAX_CONNECTIONS_PER_IP = 10
1717
+ charger = await database_sync_to_async(Charger.objects.create)(
1718
+ charger_id="UPFW", connector_id=None
1719
+ )
1720
+ firmware = await database_sync_to_async(CPFirmware.objects.create)(
1721
+ name="Update firmware",
1722
+ filename="update.bin",
1723
+ payload_binary=b"bin",
1724
+ content_type="application/octet-stream",
1725
+ source=CPFirmware.Source.UPLOAD,
1726
+ is_user_data=True,
1727
+ )
1728
+ deployment = await database_sync_to_async(CPFirmwareDeployment.objects.create)(
1729
+ firmware=firmware,
1730
+ charger=charger,
1731
+ node=charger.node_origin,
1732
+ ocpp_message_id="upfw-msg",
1733
+ status="Pending",
1734
+ status_info="",
1735
+ status_timestamp=timezone.now(),
1736
+ retrieve_date=timezone.now(),
1737
+ request_payload={},
1738
+ is_user_data=True,
1739
+ )
1740
+
1741
+ message_id = "firmware-update"
1742
+ store.register_pending_call(
1743
+ message_id,
1744
+ {
1745
+ "action": "UpdateFirmware",
1746
+ "charger_id": "UPFW",
1747
+ "connector_id": None,
1748
+ "deployment_pk": deployment.pk,
1749
+ },
1750
+ )
1751
+
1752
+ communicator = WebsocketCommunicator(application, "/UPFW/")
1753
+ try:
1754
+ connected, detail = await communicator.connect()
1755
+ self.assertTrue(connected, detail)
1011
1756
 
1012
- (
1013
- detail_code,
1014
- detail_payload,
1015
- status_code,
1016
- html,
1017
- list_code,
1018
- list_payload,
1019
- ) = await database_sync_to_async(_fetch_views)()
1020
- self.assertEqual(detail_code, 200)
1021
- self.assertEqual(status_code, 200)
1022
- self.assertEqual(list_code, 200)
1023
- self.assertEqual(detail_payload["firmwareStatus"], "Installing")
1024
- self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
1025
- self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
1026
- self.assertNotIn('id="firmware-status"', html)
1027
- self.assertNotIn('id="firmware-status-info"', html)
1028
- self.assertNotIn('id="firmware-timestamp"', html)
1029
-
1030
- matching = [
1031
- item
1032
- for item in list_payload.get("chargers", [])
1033
- if item["charger_id"] == "FWSTAT" and item["connector_id"] is None
1034
- ]
1035
- self.assertTrue(matching)
1036
- self.assertEqual(matching[0]["firmwareStatus"], "Installing")
1037
- self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
1038
- list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
1039
- self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)
1757
+ await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
1040
1758
 
1041
- store.clear_log(store.identity_key("FWSTAT", None), log_type="charger")
1759
+ await asyncio.sleep(0.05)
1042
1760
 
1043
- await communicator.disconnect()
1761
+ updated = await database_sync_to_async(CPFirmwareDeployment.objects.get)(
1762
+ pk=deployment.pk
1763
+ )
1764
+ self.assertEqual(updated.status, "Accepted")
1765
+ self.assertIsNotNone(updated.status_timestamp)
1766
+ self.assertFalse(updated.completed_at)
1767
+ finally:
1768
+ with suppress(Exception):
1769
+ await communicator.disconnect()
1770
+ store.MAX_CONNECTIONS_PER_IP = limit
1044
1771
 
1045
1772
  async def test_firmware_status_notification_updates_connector_and_aggregate(
1046
1773
  self,
@@ -1109,7 +1836,7 @@ class CSMSConsumerTests(TransactionTestCase):
1109
1836
 
1110
1837
  await communicator.disconnect()
1111
1838
 
1112
- async def test_vin_recorded(self):
1839
+ async def test_vid_populated_from_vin(self):
1113
1840
  await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
1114
1841
  communicator = WebsocketCommunicator(application, "/VINREC/")
1115
1842
  connected, _ = await communicator.connect()
@@ -1124,7 +1851,29 @@ class CSMSConsumerTests(TransactionTestCase):
1124
1851
  tx = await database_sync_to_async(Transaction.objects.get)(
1125
1852
  pk=tx_id, charger__charger_id="VINREC"
1126
1853
  )
1127
- self.assertEqual(tx.vin, "WP0ZZZ11111111111")
1854
+ self.assertEqual(tx.vid, "WP0ZZZ11111111111")
1855
+ self.assertEqual(tx.vehicle_identifier, "WP0ZZZ11111111111")
1856
+ self.assertEqual(tx.vehicle_identifier_source, "vid")
1857
+
1858
+ await communicator.disconnect()
1859
+
1860
+ async def test_vid_recorded(self):
1861
+ await database_sync_to_async(Charger.objects.create)(charger_id="VIDREC")
1862
+ communicator = WebsocketCommunicator(application, "/VIDREC/")
1863
+ connected, _ = await communicator.connect()
1864
+ self.assertTrue(connected)
1865
+
1866
+ await communicator.send_json_to(
1867
+ [2, "1", "StartTransaction", {"meterStart": 1, "vid": "VID123456"}]
1868
+ )
1869
+ response = await communicator.receive_json_from()
1870
+ tx_id = response[2]["transactionId"]
1871
+
1872
+ tx = await database_sync_to_async(Transaction.objects.get)(
1873
+ pk=tx_id, charger__charger_id="VIDREC"
1874
+ )
1875
+ self.assertEqual(tx.vid, "VID123456")
1876
+ self.assertEqual(tx.rfid, "")
1128
1877
 
1129
1878
  await communicator.disconnect()
1130
1879
 
@@ -1613,6 +2362,35 @@ class CSMSConsumerTests(TransactionTestCase):
1613
2362
  data = json.loads(files[0].read_text())
1614
2363
  self.assertTrue(any("StartTransaction" in m["message"] for m in data))
1615
2364
 
2365
+ def test_session_log_buffer_bounded(self):
2366
+ cid = "BUFFER-LIMIT"
2367
+ session_dir = Path("logs") / "sessions" / cid
2368
+ if session_dir.exists():
2369
+ for file_path in session_dir.glob("*.json"):
2370
+ file_path.unlink()
2371
+
2372
+ tx_id = 999
2373
+ store.start_session_log(cid, tx_id)
2374
+ self.addCleanup(lambda: store.history.pop(cid, None))
2375
+
2376
+ try:
2377
+ metadata = store.history[cid]
2378
+ path = metadata["path"]
2379
+ message_count = store.SESSION_LOG_BUFFER_LIMIT * 3 + 5
2380
+ for idx in range(message_count):
2381
+ store.add_session_message(cid, f"message {idx}")
2382
+ buffer = metadata["buffer"]
2383
+ self.assertLessEqual(len(buffer), store.SESSION_LOG_BUFFER_LIMIT)
2384
+ finally:
2385
+ store.end_session_log(cid)
2386
+
2387
+ self.assertTrue(path.exists())
2388
+ try:
2389
+ payload = json.loads(path.read_text())
2390
+ self.assertEqual(len(payload), message_count)
2391
+ finally:
2392
+ path.unlink(missing_ok=True)
2393
+
1616
2394
  async def test_second_connection_closes_first(self):
1617
2395
  communicator1 = WebsocketCommunicator(application, "/DUPLICATE/")
1618
2396
  connected, _ = await communicator1.connect()
@@ -1852,6 +2630,16 @@ class ChargerLandingTests(TestCase):
1852
2630
  status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
1853
2631
  self.assertContains(response, status_url)
1854
2632
 
2633
+ def test_charger_page_respects_language_configuration(self):
2634
+ charger = Charger.objects.create(charger_id="PAGE-DE", language="de")
2635
+
2636
+ response = self.client.get(reverse("charger-page", args=["PAGE-DE"]))
2637
+
2638
+ self.assertEqual(response.status_code, 200)
2639
+ self.assertEqual(response.context["LANGUAGE_CODE"], "de")
2640
+ self.assertContains(response, 'lang="de"')
2641
+ self.assertContains(response, 'data-preferred-language="de"')
2642
+
1855
2643
  def test_status_page_renders(self):
1856
2644
  charger = Charger.objects.create(charger_id="PAGE2")
1857
2645
  resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
@@ -1931,6 +2719,27 @@ class ChargerLandingTests(TestCase):
1931
2719
  finally:
1932
2720
  store.transactions.pop(key, None)
1933
2721
 
2722
+ def test_public_page_shows_available_when_status_stale(self):
2723
+ charger = Charger.objects.create(
2724
+ charger_id="STALEPUB",
2725
+ last_status="Charging",
2726
+ )
2727
+ response = self.client.get(reverse("charger-page", args=["STALEPUB"]))
2728
+ self.assertEqual(response.status_code, 200)
2729
+ self.assertContains(
2730
+ response,
2731
+ 'style="background-color: #0d6efd; color: #fff;">Available</span>',
2732
+ )
2733
+
2734
+ def test_admin_status_shows_available_when_status_stale(self):
2735
+ charger = Charger.objects.create(
2736
+ charger_id="STALEADM",
2737
+ last_status="Charging",
2738
+ )
2739
+ response = self.client.get(reverse("charger-status", args=["STALEADM"]))
2740
+ self.assertEqual(response.status_code, 200)
2741
+ self.assertContains(response, 'id="charger-state">Available</strong>')
2742
+
1934
2743
  def test_public_status_shows_rfid_link_for_known_tag(self):
1935
2744
  aggregate = Charger.objects.create(charger_id="PUBRFID")
1936
2745
  connector = Charger.objects.create(
@@ -2057,7 +2866,10 @@ class ChargerLandingTests(TestCase):
2057
2866
  log_id = store.identity_key("LOG1", None)
2058
2867
  store.add_log(log_id, "hello", log_type="charger")
2059
2868
  entry = store.get_logs(log_id, log_type="charger")[0]
2060
- self.assertRegex(entry, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} hello$")
2869
+ self.assertRegex(
2870
+ entry,
2871
+ r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} hello$",
2872
+ )
2061
2873
  resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
2062
2874
  self.assertEqual(resp.status_code, 200)
2063
2875
  self.assertContains(resp, "hello")
@@ -2092,12 +2904,12 @@ class SimulatorLandingTests(TestCase):
2092
2904
  @skip("Navigation links unavailable in test environment")
2093
2905
  def test_simulator_app_link_in_nav(self):
2094
2906
  resp = self.client.get(reverse("pages:index"))
2095
- self.assertContains(resp, "/ocpp/")
2096
- self.assertNotContains(resp, "/ocpp/simulator/")
2907
+ self.assertContains(resp, "/ocpp/cpms/dashboard/")
2908
+ self.assertNotContains(resp, "/ocpp/evcs/simulator/")
2097
2909
  self.client.force_login(self.user)
2098
2910
  resp = self.client.get(reverse("pages:index"))
2099
- self.assertContains(resp, "/ocpp/")
2100
- self.assertContains(resp, "/ocpp/simulator/")
2911
+ self.assertContains(resp, "/ocpp/cpms/dashboard/")
2912
+ self.assertContains(resp, "/ocpp/evcs/simulator/")
2101
2913
 
2102
2914
  def test_cp_simulator_redirects_to_login(self):
2103
2915
  response = self.client.get(reverse("cp-simulator"))
@@ -2129,6 +2941,32 @@ class ChargerAdminTests(TestCase):
2129
2941
  resp = self.client.get(url)
2130
2942
  self.assertNotContains(resp, charger.reference.image.url)
2131
2943
 
2944
+ def test_toggle_rfid_authentication_action_toggles_value(self):
2945
+ charger_requires = Charger.objects.create(
2946
+ charger_id="RFIDON", require_rfid=True
2947
+ )
2948
+ charger_optional = Charger.objects.create(
2949
+ charger_id="RFIDOFF", require_rfid=False
2950
+ )
2951
+ url = reverse("admin:ocpp_charger_changelist")
2952
+ response = self.client.post(
2953
+ url,
2954
+ {
2955
+ "action": "toggle_rfid_authentication",
2956
+ "_selected_action": [
2957
+ charger_requires.pk,
2958
+ charger_optional.pk,
2959
+ ],
2960
+ },
2961
+ follow=True,
2962
+ )
2963
+ self.assertEqual(response.status_code, 200)
2964
+ charger_requires.refresh_from_db()
2965
+ charger_optional.refresh_from_db()
2966
+ self.assertFalse(charger_requires.require_rfid)
2967
+ self.assertTrue(charger_optional.require_rfid)
2968
+ self.assertContains(response, "Updated RFID authentication")
2969
+
2132
2970
  def test_admin_lists_log_link(self):
2133
2971
  charger = Charger.objects.create(charger_id="LOG1")
2134
2972
  url = reverse("admin:ocpp_charger_changelist")
@@ -2155,6 +2993,85 @@ class ChargerAdminTests(TestCase):
2155
2993
  finally:
2156
2994
  store.transactions.pop(key, None)
2157
2995
 
2996
+ def test_admin_status_shows_available_when_status_stale(self):
2997
+ charger = Charger.objects.create(
2998
+ charger_id="ADMINSTALE",
2999
+ last_status="Charging",
3000
+ )
3001
+ url = reverse("admin:ocpp_charger_changelist")
3002
+ resp = self.client.get(url)
3003
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
3004
+ self.assertContains(resp, f">{available_label}<")
3005
+
3006
+ def test_recheck_charger_status_action_sends_trigger(self):
3007
+ charger = Charger.objects.create(charger_id="RECHECK1")
3008
+
3009
+ class DummyConnection:
3010
+ def __init__(self):
3011
+ self.sent: list[str] = []
3012
+
3013
+ async def send(self, message):
3014
+ self.sent.append(message)
3015
+
3016
+ ws = DummyConnection()
3017
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
3018
+ try:
3019
+ url = reverse("admin:ocpp_charger_changelist")
3020
+ response = self.client.post(
3021
+ url,
3022
+ {
3023
+ "action": "recheck_charger_status",
3024
+ "index": 0,
3025
+ "select_across": 0,
3026
+ "_selected_action": [charger.pk],
3027
+ },
3028
+ follow=True,
3029
+ )
3030
+ self.assertEqual(response.status_code, 200)
3031
+ self.assertTrue(ws.sent)
3032
+ self.assertIn("TriggerMessage", ws.sent[0])
3033
+ self.assertContains(response, "Requested status update")
3034
+ finally:
3035
+ store.pop_connection(charger.charger_id, charger.connector_id)
3036
+ store.clear_pending_calls(charger.charger_id)
3037
+
3038
+ def test_reset_charger_action_skips_when_transaction_active(self):
3039
+ charger = Charger.objects.create(charger_id="RESETADMIN")
3040
+
3041
+ class DummyConnection:
3042
+ def __init__(self):
3043
+ self.sent: list[str] = []
3044
+
3045
+ async def send(self, message):
3046
+ self.sent.append(message)
3047
+
3048
+ ws = DummyConnection()
3049
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
3050
+ tx_obj = Transaction.objects.create(
3051
+ charger=charger,
3052
+ connector_id=charger.connector_id,
3053
+ start_time=timezone.now(),
3054
+ )
3055
+ store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
3056
+ try:
3057
+ url = reverse("admin:ocpp_charger_changelist")
3058
+ response = self.client.post(
3059
+ url,
3060
+ {
3061
+ "action": "reset_chargers",
3062
+ "index": 0,
3063
+ "select_across": 0,
3064
+ "_selected_action": [charger.pk],
3065
+ },
3066
+ follow=True,
3067
+ )
3068
+ self.assertEqual(response.status_code, 200)
3069
+ self.assertFalse(ws.sent)
3070
+ self.assertContains(response, "stop the session first")
3071
+ finally:
3072
+ store.pop_connection(charger.charger_id, charger.connector_id)
3073
+ store.pop_transaction(charger.charger_id, charger.connector_id)
3074
+
2158
3075
  def test_admin_log_view_displays_entries(self):
2159
3076
  charger = Charger.objects.create(charger_id="LOG2")
2160
3077
  log_id = store.identity_key(charger.charger_id, charger.connector_id)
@@ -2178,6 +3095,36 @@ class ChargerAdminTests(TestCase):
2178
3095
  resp = self.client.get(url)
2179
3096
  self.assertContains(resp, "AdminLoc")
2180
3097
 
3098
+ def test_admin_changelist_displays_quick_stats(self):
3099
+ charger = Charger.objects.create(charger_id="STATMAIN", display_name="Main EVCS")
3100
+ connector = Charger.objects.create(
3101
+ charger_id="STATMAIN", connector_id=1, display_name="Connector 1"
3102
+ )
3103
+ start = timezone.now() - timedelta(minutes=30)
3104
+ Transaction.objects.create(
3105
+ charger=connector,
3106
+ start_time=start,
3107
+ stop_time=start + timedelta(minutes=10),
3108
+ meter_start=1000,
3109
+ meter_stop=6000,
3110
+ )
3111
+
3112
+ url = reverse("admin:ocpp_charger_changelist")
3113
+ resp = self.client.get(url)
3114
+
3115
+ self.assertContains(resp, "Total kW")
3116
+ self.assertContains(resp, "Today kW")
3117
+ self.assertContains(resp, "5.00")
3118
+
3119
+ def test_admin_changelist_does_not_indent_connectors(self):
3120
+ Charger.objects.create(charger_id="INDENTMAIN")
3121
+ Charger.objects.create(charger_id="INDENTMAIN", connector_id=1)
3122
+
3123
+ url = reverse("admin:ocpp_charger_changelist")
3124
+ resp = self.client.get(url)
3125
+
3126
+ self.assertNotContains(resp, 'class="charger-connector-entry"')
3127
+
2181
3128
  def test_last_fields_are_read_only(self):
2182
3129
  now = timezone.now()
2183
3130
  charger = Charger.objects.create(
@@ -2426,6 +3373,208 @@ class ChargerAdminTests(TestCase):
2426
3373
  store.clear_log(log_key, log_type="charger")
2427
3374
  store.clear_log(pending_key, log_type="charger")
2428
3375
 
3376
+ def test_get_diagnostics_downloads_file_to_work_directory(self):
3377
+ charger = Charger.objects.create(
3378
+ charger_id="DIAGADMIN",
3379
+ diagnostics_location="https://example.com/diag.tar.gz",
3380
+ diagnostics_status="Uploaded",
3381
+ )
3382
+ fixed_now = datetime(2024, 1, 2, 3, 4, 5, tzinfo=dt_timezone.utc)
3383
+ with tempfile.TemporaryDirectory() as tempdir:
3384
+ base_path = Path(tempdir)
3385
+ response_mock = Mock()
3386
+ response_mock.status_code = 200
3387
+ response_mock.iter_content.return_value = [b"diagnostics"]
3388
+ response_mock.headers = {
3389
+ "Content-Disposition": 'attachment; filename="diagnostics.tar.gz"'
3390
+ }
3391
+ response_mock.close = Mock()
3392
+ with override_settings(BASE_DIR=base_path):
3393
+ with patch("ocpp.admin.requests.get", return_value=response_mock) as mock_get:
3394
+ with patch("ocpp.admin.timezone.now", return_value=fixed_now):
3395
+ url = reverse("admin:ocpp_charger_changelist")
3396
+ response = self.client.post(
3397
+ url,
3398
+ {
3399
+ "action": "get_diagnostics",
3400
+ "_selected_action": [charger.pk],
3401
+ },
3402
+ follow=True,
3403
+ )
3404
+ self.assertEqual(response.status_code, 200)
3405
+ work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
3406
+ self.assertTrue(work_dir.exists())
3407
+ files = list(work_dir.glob("*"))
3408
+ self.assertEqual(len(files), 1)
3409
+ saved_file = files[0]
3410
+ self.assertEqual(saved_file.read_bytes(), b"diagnostics")
3411
+ asset_path = saved_file.relative_to(base_path / "work" / "ocpp-admin").as_posix()
3412
+ asset_url = "http://testserver" + reverse(
3413
+ "pages:readme-asset", kwargs={"source": "work", "asset": asset_path}
3414
+ )
3415
+ self.assertContains(response, asset_url)
3416
+ self.assertContains(response, str(saved_file))
3417
+ mock_get.assert_called_once_with(
3418
+ "https://example.com/diag.tar.gz", stream=True, timeout=15
3419
+ )
3420
+ response_mock.close.assert_called_once()
3421
+
3422
+ def test_get_diagnostics_requires_location_reports_warning(self):
3423
+ charger = Charger.objects.create(charger_id="DIAGNOLOC")
3424
+ with tempfile.TemporaryDirectory() as tempdir:
3425
+ base_path = Path(tempdir)
3426
+ with override_settings(BASE_DIR=base_path):
3427
+ url = reverse("admin:ocpp_charger_changelist")
3428
+ response = self.client.post(
3429
+ url,
3430
+ {"action": "get_diagnostics", "_selected_action": [charger.pk]},
3431
+ follow=True,
3432
+ )
3433
+ self.assertEqual(response.status_code, 200)
3434
+ self.assertContains(response, "DIAGNOLOC: no diagnostics location reported.")
3435
+ work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
3436
+ self.assertTrue(work_dir.exists())
3437
+ self.assertFalse(list(work_dir.iterdir()))
3438
+
3439
+ def test_get_diagnostics_handles_download_error(self):
3440
+ charger = Charger.objects.create(
3441
+ charger_id="DIAGFAIL",
3442
+ diagnostics_location="https://example.com/diag.tar",
3443
+ )
3444
+ response_mock = Mock()
3445
+ response_mock.status_code = 500
3446
+ response_mock.iter_content.return_value = []
3447
+ response_mock.headers = {}
3448
+ response_mock.close = Mock()
3449
+ with tempfile.TemporaryDirectory() as tempdir:
3450
+ base_path = Path(tempdir)
3451
+ with override_settings(BASE_DIR=base_path):
3452
+ with patch("ocpp.admin.requests.get", return_value=response_mock):
3453
+ url = reverse("admin:ocpp_charger_changelist")
3454
+ response = self.client.post(
3455
+ url,
3456
+ {
3457
+ "action": "get_diagnostics",
3458
+ "_selected_action": [charger.pk],
3459
+ },
3460
+ follow=True,
3461
+ )
3462
+ self.assertEqual(response.status_code, 200)
3463
+ self.assertContains(
3464
+ response, "DIAGFAIL: Diagnostics download returned status 500."
3465
+ )
3466
+ work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
3467
+ self.assertTrue(work_dir.exists())
3468
+ self.assertFalse(list(work_dir.iterdir()))
3469
+ response_mock.close.assert_called_once()
3470
+
3471
+
3472
+ class ChargerConfigurationAdminUnitTests(TestCase):
3473
+ def setUp(self):
3474
+ self.admin = ChargerConfigurationAdmin(ChargerConfiguration, AdminSite())
3475
+ self.request_factory = RequestFactory()
3476
+
3477
+ def test_origin_display_returns_evcs_when_snapshot_present(self):
3478
+ configuration = ChargerConfiguration.objects.create(
3479
+ charger_identifier="CFG-EVCS",
3480
+ evcs_snapshot_at=timezone.now(),
3481
+ )
3482
+ self.assertEqual(self.admin.origin_display(configuration), "EVCS")
3483
+
3484
+ def test_origin_display_returns_local_without_snapshot(self):
3485
+ configuration = ChargerConfiguration.objects.create(
3486
+ charger_identifier="CFG-LOCAL",
3487
+ )
3488
+ self.assertEqual(self.admin.origin_display(configuration), "Local")
3489
+
3490
+ def test_save_model_resets_snapshot_timestamp(self):
3491
+ configuration = ChargerConfiguration.objects.create(
3492
+ charger_identifier="CFG-SAVE",
3493
+ evcs_snapshot_at=timezone.now(),
3494
+ )
3495
+ request = self.request_factory.post("/admin/ocpp/chargerconfiguration/")
3496
+ self.admin.save_model(request, configuration, form=None, change=True)
3497
+ configuration.refresh_from_db()
3498
+ self.assertIsNone(configuration.evcs_snapshot_at)
3499
+
3500
+ def test_configuration_key_inline_readonly_helpers(self):
3501
+ configuration = ChargerConfiguration.objects.create(
3502
+ charger_identifier="CFG-INLINE"
3503
+ )
3504
+ configuration.replace_configuration_keys(
3505
+ [
3506
+ {
3507
+ "key": "HeartbeatInterval",
3508
+ "value": {"interval": 300},
3509
+ "readonly": True,
3510
+ "note": "Check",
3511
+ },
3512
+ {"key": "AuthorizeRemoteTxRequests", "readonly": False},
3513
+ ]
3514
+ )
3515
+ inline = ConfigurationKeyInline(ChargerConfiguration, self.admin.admin_site)
3516
+ entries = list(
3517
+ configuration.configuration_entries.order_by("position", "id")
3518
+ )
3519
+ self.assertEqual(len(entries), 2)
3520
+ self.assertIn("<pre>", inline.value_display(entries[0]))
3521
+ self.assertIn("\"note\"", inline.extra_display(entries[0]))
3522
+ self.assertEqual(inline.value_display(entries[1]), "-")
3523
+ self.assertEqual(inline.extra_display(entries[1]), "-")
3524
+ request = self.request_factory.get("/admin/ocpp/chargerconfiguration/")
3525
+ self.assertFalse(inline.has_add_permission(request, configuration))
3526
+
3527
+ def test_configuration_key_admin_hidden_from_index(self):
3528
+ key_admin = ConfigurationKeyAdmin(ConfigurationKey, AdminSite())
3529
+ perms = key_admin.get_model_perms(self.request_factory.get("/"))
3530
+ self.assertEqual(perms, {})
3531
+
3532
+
3533
+ class ConfigurationTaskTests(TestCase):
3534
+ def tearDown(self):
3535
+ store.pending_calls.clear()
3536
+
3537
+ def test_check_charge_point_configuration_dispatches_request(self):
3538
+ charger = Charger.objects.create(charger_id="TASKCFG")
3539
+ ws = DummyWebSocket()
3540
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
3541
+ pending_key = store.pending_key(charger.charger_id)
3542
+ store.clear_log(log_key, log_type="charger")
3543
+ store.clear_log(pending_key, log_type="charger")
3544
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
3545
+ try:
3546
+ result = check_charge_point_configuration.run(charger.pk)
3547
+ self.assertTrue(result)
3548
+ self.assertEqual(len(ws.sent), 1)
3549
+ frame = json.loads(ws.sent[0])
3550
+ self.assertEqual(frame[0], 2)
3551
+ self.assertEqual(frame[2], "GetConfiguration")
3552
+ self.assertIn(frame[1], store.pending_calls)
3553
+ finally:
3554
+ store.pop_connection(charger.charger_id, charger.connector_id)
3555
+ store.pending_calls.clear()
3556
+ store.clear_log(log_key, log_type="charger")
3557
+ store.clear_log(pending_key, log_type="charger")
3558
+
3559
+ def test_check_charge_point_configuration_without_connection(self):
3560
+ charger = Charger.objects.create(charger_id="TASKNOCONN")
3561
+ result = check_charge_point_configuration.run(charger.pk)
3562
+ self.assertFalse(result)
3563
+
3564
+ def test_schedule_daily_checks_only_includes_root_chargers(self):
3565
+ eligible = Charger.objects.create(charger_id="TASKROOT")
3566
+ Charger.objects.create(charger_id="TASKCONN", connector_id=1)
3567
+ with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
3568
+ scheduled = schedule_daily_charge_point_configuration_checks.run()
3569
+ self.assertEqual(scheduled, 1)
3570
+ mock_delay.assert_called_once_with(eligible.pk)
3571
+
3572
+ def test_schedule_daily_checks_returns_zero_without_chargers(self):
3573
+ with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
3574
+ scheduled = schedule_daily_charge_point_configuration_checks.run()
3575
+ self.assertEqual(scheduled, 0)
3576
+ mock_delay.assert_not_called()
3577
+
2429
3578
 
2430
3579
  class LocationAdminTests(TestCase):
2431
3580
  def setUp(self):
@@ -2700,6 +3849,28 @@ class SimulatorAdminTests(TransactionTestCase):
2700
3849
 
2701
3850
  await communicator.disconnect()
2702
3851
 
3852
+ def test_auto_registered_charger_location_name_sanitized(self):
3853
+ async def exercise():
3854
+ communicator = WebsocketCommunicator(
3855
+ application, "/?cid=ACME%20Charger%20%231"
3856
+ )
3857
+ connected, _ = await communicator.connect()
3858
+ self.assertTrue(connected)
3859
+
3860
+ await communicator.disconnect()
3861
+
3862
+ def fetch_location_name() -> str:
3863
+ charger = (
3864
+ Charger.objects.select_related("location")
3865
+ .get(charger_id="ACME Charger #1")
3866
+ )
3867
+ return charger.location.name
3868
+
3869
+ location_name = await database_sync_to_async(fetch_location_name)()
3870
+ self.assertEqual(location_name, "ACME_Charger_1")
3871
+
3872
+ async_to_sync(exercise)()
3873
+
2703
3874
  async def test_query_string_cid_supported(self):
2704
3875
  communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
2705
3876
  connected, _ = await communicator.connect()
@@ -2860,6 +4031,44 @@ class SimulatorAdminTests(TransactionTestCase):
2860
4031
 
2861
4032
  await communicator.disconnect()
2862
4033
 
4034
+ async def test_authorize_requires_rfid_accepts_allowed_tag_without_account(self):
4035
+ charger_id = "AUTHWARN"
4036
+ tag_value = "WARN01"
4037
+ await database_sync_to_async(Charger.objects.create)(
4038
+ charger_id=charger_id, require_rfid=True
4039
+ )
4040
+ await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
4041
+
4042
+ pending_key = store.pending_key(charger_id)
4043
+ store.clear_log(pending_key, log_type="charger")
4044
+
4045
+ communicator = WebsocketCommunicator(application, f"/{charger_id}/")
4046
+ connected, _ = await communicator.connect()
4047
+ self.assertTrue(connected)
4048
+
4049
+ message_id = "auth-unlinked"
4050
+ await communicator.send_json_to(
4051
+ [2, message_id, "Authorize", {"idTag": tag_value}]
4052
+ )
4053
+ response = await communicator.receive_json_from()
4054
+ self.assertEqual(response[0], 3)
4055
+ self.assertEqual(response[1], message_id)
4056
+ self.assertEqual(response[2], {"idTagInfo": {"status": "Accepted"}})
4057
+
4058
+ log_entries = store.get_logs(pending_key, log_type="charger")
4059
+ self.assertTrue(
4060
+ any(
4061
+ "Authorized RFID" in entry
4062
+ and tag_value in entry
4063
+ and charger_id in entry
4064
+ for entry in log_entries
4065
+ ),
4066
+ log_entries,
4067
+ )
4068
+
4069
+ await communicator.disconnect()
4070
+ store.clear_log(pending_key, log_type="charger")
4071
+
2863
4072
  async def test_authorize_without_requirement_records_rfid(self):
2864
4073
  await database_sync_to_async(Charger.objects.create)(
2865
4074
  charger_id="AUTHOPT", require_rfid=False
@@ -2952,6 +4161,61 @@ class SimulatorAdminTests(TransactionTestCase):
2952
4161
  )
2953
4162
  self.assertEqual(tx.account_id, user.energy_account.id)
2954
4163
 
4164
+ async def test_start_transaction_allows_allowed_tag_without_account(self):
4165
+ charger_id = "STARTWARN"
4166
+ tag_value = "WARN02"
4167
+ await database_sync_to_async(Charger.objects.create)(
4168
+ charger_id=charger_id, require_rfid=True
4169
+ )
4170
+ await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
4171
+
4172
+ pending_key = store.pending_key(charger_id)
4173
+ store.clear_log(pending_key, log_type="charger")
4174
+
4175
+ communicator = WebsocketCommunicator(application, f"/{charger_id}/")
4176
+ connected, _ = await communicator.connect()
4177
+ self.assertTrue(connected)
4178
+
4179
+ start_payload = {
4180
+ "meterStart": 5,
4181
+ "idTag": tag_value,
4182
+ "connectorId": 1,
4183
+ }
4184
+ await communicator.send_json_to([2, "start-1", "StartTransaction", start_payload])
4185
+ response = await communicator.receive_json_from()
4186
+ self.assertEqual(response[0], 3)
4187
+ self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
4188
+ tx_id = response[2]["transactionId"]
4189
+
4190
+ tx = await database_sync_to_async(Transaction.objects.get)(
4191
+ pk=tx_id, charger__charger_id=charger_id
4192
+ )
4193
+ self.assertIsNone(tx.account_id)
4194
+
4195
+ log_entries = store.get_logs(pending_key, log_type="charger")
4196
+ self.assertTrue(
4197
+ any(
4198
+ "Authorized RFID" in entry
4199
+ and tag_value in entry
4200
+ and charger_id in entry
4201
+ for entry in log_entries
4202
+ ),
4203
+ log_entries,
4204
+ )
4205
+
4206
+ await communicator.send_json_to(
4207
+ [
4208
+ 2,
4209
+ "stop-1",
4210
+ "StopTransaction",
4211
+ {"transactionId": tx_id, "meterStop": 6},
4212
+ ]
4213
+ )
4214
+ await communicator.receive_json_from()
4215
+
4216
+ await communicator.disconnect()
4217
+ store.clear_log(pending_key, log_type="charger")
4218
+
2955
4219
  async def test_status_fields_updated(self):
2956
4220
  communicator = WebsocketCommunicator(application, "/STAT/")
2957
4221
  connected, _ = await communicator.connect()
@@ -3017,8 +4281,7 @@ class SimulatorAdminTests(TransactionTestCase):
3017
4281
  self.assertIsNotNone(aggregate.last_heartbeat)
3018
4282
  if previous_heartbeat:
3019
4283
  self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
3020
- if connector.last_heartbeat:
3021
- self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
4284
+ self.assertEqual(connector.last_heartbeat, aggregate.last_heartbeat)
3022
4285
 
3023
4286
  await communicator.disconnect()
3024
4287
 
@@ -3045,6 +4308,10 @@ class ChargerLocationTests(TestCase):
3045
4308
  second = Charger.objects.create(charger_id="SHARE", connector_id=2)
3046
4309
  self.assertEqual(second.location, first.location)
3047
4310
 
4311
+ def test_location_name_sanitized_when_auto_created(self):
4312
+ charger = Charger.objects.create(charger_id="Name With spaces!#1")
4313
+ self.assertEqual(charger.location.name, "Name_With_spaces_1")
4314
+
3048
4315
 
3049
4316
  class MeterReadingTests(TransactionTestCase):
3050
4317
  async def test_meter_values_saved_as_readings(self):
@@ -3391,7 +4658,9 @@ class ChargePointSimulatorTests(TransactionTestCase):
3391
4658
  cfg = SimulatorConfig(cp_path="SIMLOG/")
3392
4659
  sim = ChargePointSimulator(cfg)
3393
4660
  store.clear_log(cfg.cp_path, log_type="simulator")
3394
- store.logs["simulator"][cfg.cp_path] = []
4661
+ store.logs["simulator"][cfg.cp_path] = deque(
4662
+ maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES
4663
+ )
3395
4664
  sent_frames: list[str] = []
3396
4665
 
3397
4666
  async def send(payload: str) -> None:
@@ -3732,6 +5001,28 @@ class DailySessionReportTaskTests(TestCase):
3732
5001
  except FileNotFoundError:
3733
5002
  pass
3734
5003
 
5004
+ def _create_transaction_with_reading(
5005
+ self,
5006
+ charger: Charger,
5007
+ start: datetime,
5008
+ energy: Decimal,
5009
+ ) -> Transaction:
5010
+ transaction = Transaction.objects.create(
5011
+ charger=charger,
5012
+ start_time=start,
5013
+ stop_time=start + timedelta(minutes=30),
5014
+ meter_start=0,
5015
+ connector_id=1,
5016
+ )
5017
+ MeterValue.objects.create(
5018
+ charger=charger,
5019
+ connector_id=1,
5020
+ transaction=transaction,
5021
+ timestamp=start + timedelta(minutes=15),
5022
+ energy=energy,
5023
+ )
5024
+ return transaction
5025
+
3735
5026
  def test_report_sends_email_when_sessions_exist(self):
3736
5027
  User = get_user_model()
3737
5028
  User.objects.create_superuser(
@@ -3785,6 +5076,41 @@ class DailySessionReportTaskTests(TestCase):
3785
5076
  self.assertEqual(count, 0)
3786
5077
  mock_send.assert_not_called()
3787
5078
 
5079
+ def test_report_query_count_constant(self):
5080
+ User = get_user_model()
5081
+ User.objects.create_superuser(
5082
+ username="report-admin", email="report-admin@example.com", password="pw"
5083
+ )
5084
+ charger = Charger.objects.create(charger_id="RPT-QUERY", display_name="Pod Q")
5085
+ base_start = timezone.now().replace(second=0, microsecond=0)
5086
+
5087
+ self._create_transaction_with_reading(
5088
+ charger, base_start, Decimal("1.2")
5089
+ )
5090
+
5091
+ with patch("core.mailer.can_send_email", return_value=True), patch(
5092
+ "core.mailer.send"
5093
+ ) as mock_send, CaptureQueriesContext(connection) as ctx_single:
5094
+ count_single = send_daily_session_report()
5095
+
5096
+ self.assertEqual(count_single, 1)
5097
+ mock_send.assert_called_once()
5098
+ single_query_count = len(ctx_single)
5099
+
5100
+ later_start = base_start + timedelta(hours=1)
5101
+ self._create_transaction_with_reading(
5102
+ charger, later_start, Decimal("3.4")
5103
+ )
5104
+
5105
+ with patch("core.mailer.can_send_email", return_value=True), patch(
5106
+ "core.mailer.send"
5107
+ ) as mock_send_multi, CaptureQueriesContext(connection) as ctx_multi:
5108
+ count_multi = send_daily_session_report()
5109
+
5110
+ self.assertEqual(count_multi, 2)
5111
+ mock_send_multi.assert_called_once()
5112
+ self.assertEqual(len(ctx_multi), single_query_count)
5113
+
3788
5114
 
3789
5115
  class TransactionKwTests(TestCase):
3790
5116
  def test_kw_sums_meter_readings(self):
@@ -3814,6 +5140,43 @@ class TransactionKwTests(TestCase):
3814
5140
  self.assertEqual(tx.kw, 0.0)
3815
5141
 
3816
5142
 
5143
+ class TransactionIdentifierTests(TestCase):
5144
+ def test_vehicle_identifier_prefers_vid(self):
5145
+ charger = Charger.objects.create(charger_id="VIDPREF")
5146
+ tx = Transaction.objects.create(
5147
+ charger=charger,
5148
+ start_time=timezone.now(),
5149
+ vid="VID-123",
5150
+ vin="VIN-456",
5151
+ )
5152
+ self.assertEqual(tx.vehicle_identifier, "VID-123")
5153
+ self.assertEqual(tx.vehicle_identifier_source, "vid")
5154
+
5155
+ def test_vehicle_identifier_falls_back_to_vin(self):
5156
+ charger = Charger.objects.create(charger_id="VINONLY")
5157
+ tx = Transaction.objects.create(
5158
+ charger=charger,
5159
+ start_time=timezone.now(),
5160
+ vin="WP0ZZZ00000000001",
5161
+ )
5162
+ self.assertEqual(tx.vehicle_identifier, "WP0ZZZ00000000001")
5163
+ self.assertEqual(tx.vehicle_identifier_source, "vin")
5164
+
5165
+ def test_transaction_rfid_details_handles_vin(self):
5166
+ charger = Charger.objects.create(charger_id="VINDET")
5167
+ tx = Transaction.objects.create(
5168
+ charger=charger,
5169
+ start_time=timezone.now(),
5170
+ vin="WAUZZZ00000000002",
5171
+ )
5172
+ details = _transaction_rfid_details(tx, cache={})
5173
+ self.assertIsNotNone(details)
5174
+ assert details is not None # for type checkers
5175
+ self.assertEqual(details["value"], "WAUZZZ00000000002")
5176
+ self.assertEqual(details["display_label"], "VIN")
5177
+ self.assertEqual(details["type"], "vin")
5178
+
5179
+
3817
5180
  class DispatchActionViewTests(TestCase):
3818
5181
  def setUp(self):
3819
5182
  self.client = Client()
@@ -3860,6 +5223,9 @@ class DispatchActionViewTests(TestCase):
3860
5223
  )
3861
5224
  self.mock_wait = self.wait_patch.start()
3862
5225
  self.addCleanup(self.wait_patch.stop)
5226
+ self.schedule_patch = patch("ocpp.views.store.schedule_call_timeout")
5227
+ self.mock_schedule = self.schedule_patch.start()
5228
+ self.addCleanup(self.schedule_patch.stop)
3863
5229
 
3864
5230
  def _close_loop(self):
3865
5231
  try:
@@ -3932,6 +5298,75 @@ class DispatchActionViewTests(TestCase):
3932
5298
  self.assertEqual(self.charger.availability_request_status, "")
3933
5299
  self.assertNotIn(frame[1], store.pending_calls)
3934
5300
 
5301
+ def test_clear_cache_dispatches_frame_and_schedules_timeout(self):
5302
+ with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
5303
+ response = self.client.post(
5304
+ self.url,
5305
+ data=json.dumps({"action": "clear_cache"}),
5306
+ content_type="application/json",
5307
+ )
5308
+ self.assertEqual(response.status_code, 200)
5309
+ self.loop.run_until_complete(asyncio.sleep(0))
5310
+ self.assertEqual(len(self.ws.sent), 1)
5311
+ frame = json.loads(self.ws.sent[0])
5312
+ self.assertEqual(frame[0], 2)
5313
+ self.assertEqual(frame[2], "ClearCache")
5314
+ self.assertEqual(frame[3], {})
5315
+ mock_timeout.assert_called_once()
5316
+ timeout_call = mock_timeout.call_args
5317
+ self.assertIsNotNone(timeout_call)
5318
+ self.assertEqual(timeout_call.args[0], frame[1])
5319
+ self.assertEqual(timeout_call.kwargs.get("action"), "ClearCache")
5320
+ log_entries = store.logs["charger"].get(self.log_key, [])
5321
+ self.assertTrue(any("ClearCache" in entry for entry in log_entries))
5322
+
5323
+ def test_clear_cache_allows_rejected_status(self):
5324
+ def wait_rejected(message_id, timeout=5.0):
5325
+ metadata = store.pending_calls.pop(message_id, None)
5326
+ store._pending_call_events.pop(message_id, None)
5327
+ store._pending_call_results.pop(message_id, None)
5328
+ return {
5329
+ "success": True,
5330
+ "payload": {"status": "Rejected"},
5331
+ "metadata": dict(metadata or {}),
5332
+ }
5333
+
5334
+ self.mock_wait.side_effect = wait_rejected
5335
+ with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
5336
+ response = self.client.post(
5337
+ self.url,
5338
+ data=json.dumps({"action": "clear_cache"}),
5339
+ content_type="application/json",
5340
+ )
5341
+ self.assertEqual(response.status_code, 200)
5342
+ payload = response.json()
5343
+ self.assertIn("sent", payload)
5344
+ self.loop.run_until_complete(asyncio.sleep(0))
5345
+ self.assertEqual(len(self.ws.sent), 1)
5346
+ frame = json.loads(self.ws.sent[0])
5347
+ self.assertEqual(frame[2], "ClearCache")
5348
+ mock_timeout.assert_called_once()
5349
+ self.mock_wait.side_effect = self._wait_success
5350
+
5351
+ def test_clear_cache_reports_timeout(self):
5352
+ def no_response(message_id, timeout=5.0):
5353
+ return None
5354
+
5355
+ self.mock_wait.side_effect = no_response
5356
+ with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
5357
+ response = self.client.post(
5358
+ self.url,
5359
+ data=json.dumps({"action": "clear_cache"}),
5360
+ content_type="application/json",
5361
+ )
5362
+ self.assertEqual(response.status_code, 504)
5363
+ detail = response.json().get("detail", "")
5364
+ self.assertIn("did not receive", detail)
5365
+ self.loop.run_until_complete(asyncio.sleep(0))
5366
+ self.assertEqual(len(self.ws.sent), 1)
5367
+ mock_timeout.assert_called_once()
5368
+ self.mock_wait.side_effect = self._wait_success
5369
+
3935
5370
  def test_remote_start_reports_rejection(self):
3936
5371
  def rejected(message_id, timeout=5.0):
3937
5372
  metadata = store.pending_calls.pop(message_id, None)
@@ -3994,6 +5429,118 @@ class DispatchActionViewTests(TestCase):
3994
5429
  self.assertIn("did not receive", detail)
3995
5430
  self.mock_wait.side_effect = self._wait_success
3996
5431
 
5432
+ def test_change_configuration_requires_key(self):
5433
+ self.mock_schedule.reset_mock()
5434
+ response = self.client.post(
5435
+ self.url,
5436
+ data=json.dumps({"action": "change_configuration"}),
5437
+ content_type="application/json",
5438
+ )
5439
+ self.assertEqual(response.status_code, 400)
5440
+ self.assertEqual(response.json().get("detail"), "key required")
5441
+ self.loop.run_until_complete(asyncio.sleep(0))
5442
+ self.assertEqual(self.ws.sent, [])
5443
+ self.mock_schedule.assert_not_called()
5444
+
5445
+ def test_change_configuration_rejects_invalid_value_type(self):
5446
+ self.mock_schedule.reset_mock()
5447
+ response = self.client.post(
5448
+ self.url,
5449
+ data=json.dumps(
5450
+ {
5451
+ "action": "change_configuration",
5452
+ "key": "HeartbeatInterval",
5453
+ "value": {"unexpected": "object"},
5454
+ }
5455
+ ),
5456
+ content_type="application/json",
5457
+ )
5458
+ self.assertEqual(response.status_code, 400)
5459
+ self.assertIn("value must", response.json().get("detail", ""))
5460
+ self.loop.run_until_complete(asyncio.sleep(0))
5461
+ self.assertEqual(self.ws.sent, [])
5462
+ self.mock_schedule.assert_not_called()
5463
+
5464
+ def test_change_configuration_dispatches_frame(self):
5465
+ self.mock_schedule.reset_mock()
5466
+ response = self.client.post(
5467
+ self.url,
5468
+ data=json.dumps(
5469
+ {
5470
+ "action": "change_configuration",
5471
+ "key": "HeartbeatInterval",
5472
+ "value": "120",
5473
+ }
5474
+ ),
5475
+ content_type="application/json",
5476
+ )
5477
+ self.assertEqual(response.status_code, 200)
5478
+ self.loop.run_until_complete(asyncio.sleep(0))
5479
+ self.assertEqual(len(self.ws.sent), 1)
5480
+ frame = json.loads(self.ws.sent[0])
5481
+ self.assertEqual(frame[0], 2)
5482
+ self.assertEqual(frame[2], "ChangeConfiguration")
5483
+ self.assertEqual(frame[3]["key"], "HeartbeatInterval")
5484
+ self.assertEqual(frame[3]["value"], "120")
5485
+ self.mock_schedule.assert_called_once()
5486
+ log_entries = store.logs["charger"].get(self.log_key, [])
5487
+ self.assertTrue(
5488
+ any("Requested configuration change" in entry for entry in log_entries)
5489
+ )
5490
+
5491
+ def test_change_configuration_reports_rejection(self):
5492
+ self.mock_schedule.reset_mock()
5493
+
5494
+ def rejected(message_id, timeout=5.0):
5495
+ metadata = store.pending_calls.pop(message_id, None)
5496
+ store._pending_call_events.pop(message_id, None)
5497
+ store._pending_call_results.pop(message_id, None)
5498
+ return {
5499
+ "success": True,
5500
+ "payload": {"status": "Rejected"},
5501
+ "metadata": dict(metadata or {}),
5502
+ }
5503
+
5504
+ self.mock_wait.side_effect = rejected
5505
+ response = self.client.post(
5506
+ self.url,
5507
+ data=json.dumps(
5508
+ {
5509
+ "action": "change_configuration",
5510
+ "key": "HeartbeatInterval",
5511
+ "value": "120",
5512
+ }
5513
+ ),
5514
+ content_type="application/json",
5515
+ )
5516
+ self.assertEqual(response.status_code, 400)
5517
+ detail = response.json().get("detail", "")
5518
+ self.assertIn("Rejected", detail)
5519
+ self.mock_wait.side_effect = self._wait_success
5520
+
5521
+ def test_change_configuration_reports_timeout(self):
5522
+ self.mock_schedule.reset_mock()
5523
+
5524
+ def no_response(message_id, timeout=5.0):
5525
+ return None
5526
+
5527
+ self.mock_wait.side_effect = no_response
5528
+ response = self.client.post(
5529
+ self.url,
5530
+ data=json.dumps(
5531
+ {
5532
+ "action": "change_configuration",
5533
+ "key": "HeartbeatInterval",
5534
+ "value": "120",
5535
+ }
5536
+ ),
5537
+ content_type="application/json",
5538
+ )
5539
+ self.assertEqual(response.status_code, 504)
5540
+ detail = response.json().get("detail", "")
5541
+ self.assertIn("did not receive", detail)
5542
+ self.mock_wait.side_effect = self._wait_success
5543
+
3997
5544
  def test_change_availability_requires_valid_type(self):
3998
5545
  response = self.client.post(
3999
5546
  self.url,
@@ -4001,6 +5548,9 @@ class DispatchActionViewTests(TestCase):
4001
5548
  content_type="application/json",
4002
5549
  )
4003
5550
  self.assertEqual(response.status_code, 400)
5551
+ self.assertEqual(
5552
+ response.json().get("detail"), "invalid availability type"
5553
+ )
4004
5554
  self.loop.run_until_complete(asyncio.sleep(0))
4005
5555
  self.assertEqual(self.ws.sent, [])
4006
5556
  self.assertFalse(store.pending_calls)
@@ -4112,6 +5662,130 @@ class ChargerStatusViewTests(TestCase):
4112
5662
  self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
4113
5663
  store.transactions.pop(key, None)
4114
5664
 
5665
+ def test_usage_timeline_rendered_when_chart_unavailable(self):
5666
+ original_logs = store.logs["charger"]
5667
+ store.logs["charger"] = {}
5668
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
5669
+ fixed_now = timezone.now().replace(microsecond=0)
5670
+ charger = Charger.objects.create(charger_id="TL1", connector_id=1)
5671
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
5672
+
5673
+ def build_entry(delta, status):
5674
+ timestamp = fixed_now - delta
5675
+ payload = {
5676
+ "connectorId": 1,
5677
+ "status": status,
5678
+ "timestamp": timestamp.isoformat(),
5679
+ }
5680
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
5681
+ return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
5682
+
5683
+ store.logs["charger"][log_key] = deque(
5684
+ [
5685
+ build_entry(timedelta(days=2), "Available"),
5686
+ build_entry(timedelta(days=1), "Charging"),
5687
+ build_entry(timedelta(hours=12), "Available"),
5688
+ ],
5689
+ maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES,
5690
+ )
5691
+
5692
+ data, _window = _usage_timeline(charger, [], now=fixed_now)
5693
+ self.assertEqual(len(data), 1)
5694
+ statuses = {segment["status"] for segment in data[0]["segments"]}
5695
+ self.assertIn("charging", statuses)
5696
+ self.assertIn("available", statuses)
5697
+
5698
+ with patch("ocpp.views.timezone.now", return_value=fixed_now):
5699
+ resp = self.client.get(
5700
+ reverse(
5701
+ "charger-status-connector",
5702
+ args=[charger.charger_id, charger.connector_slug],
5703
+ )
5704
+ )
5705
+
5706
+ self.assertContains(resp, "Usage (last 7 days)")
5707
+ self.assertContains(resp, "usage-timeline-segment usage-charging")
5708
+
5709
+ def test_usage_timeline_includes_multiple_connectors(self):
5710
+ original_logs = store.logs["charger"]
5711
+ store.logs["charger"] = {}
5712
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
5713
+ fixed_now = timezone.now().replace(microsecond=0)
5714
+ aggregate = Charger.objects.create(charger_id="TLAGG")
5715
+ connector_one = Charger.objects.create(charger_id="TLAGG", connector_id=1)
5716
+ connector_two = Charger.objects.create(charger_id="TLAGG", connector_id=2)
5717
+
5718
+ def build_entry(connector_id, delta, status):
5719
+ timestamp = fixed_now - delta
5720
+ payload = {
5721
+ "connectorId": connector_id,
5722
+ "status": status,
5723
+ "timestamp": timestamp.isoformat(),
5724
+ }
5725
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
5726
+ key = store.identity_key(aggregate.charger_id, connector_id)
5727
+ store.logs["charger"].setdefault(
5728
+ key, deque(maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES)
5729
+ ).append(
5730
+ f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
5731
+ )
5732
+
5733
+ build_entry(1, timedelta(days=3), "Available")
5734
+ build_entry(2, timedelta(days=2), "Charging")
5735
+
5736
+ overview = [{"charger": connector_one}, {"charger": connector_two}]
5737
+ data, _window = _usage_timeline(aggregate, overview, now=fixed_now)
5738
+ self.assertEqual(len(data), 2)
5739
+ self.assertTrue(all(entry["segments"] for entry in data))
5740
+
5741
+ with patch("ocpp.views.timezone.now", return_value=fixed_now):
5742
+ resp = self.client.get(reverse("charger-status", args=[aggregate.charger_id]))
5743
+
5744
+ self.assertContains(resp, "Usage (last 7 days)")
5745
+ self.assertContains(resp, connector_one.connector_label)
5746
+ self.assertContains(resp, connector_two.connector_label)
5747
+
5748
+ def test_usage_timeline_merges_repeated_status_entries(self):
5749
+ original_logs = store.logs["charger"]
5750
+ store.logs["charger"] = {}
5751
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
5752
+ fixed_now = timezone.now().replace(microsecond=0)
5753
+ charger = Charger.objects.create(
5754
+ charger_id="TLDEDUP",
5755
+ connector_id=1,
5756
+ last_status="Available",
5757
+ )
5758
+
5759
+ def build_entry(delta, status):
5760
+ timestamp = fixed_now - delta
5761
+ payload = {
5762
+ "connectorId": 1,
5763
+ "status": status,
5764
+ "timestamp": timestamp.isoformat(),
5765
+ }
5766
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
5767
+ return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
5768
+
5769
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
5770
+ store.logs["charger"][log_key] = deque(
5771
+ [
5772
+ build_entry(timedelta(days=6, hours=12), "Available"),
5773
+ build_entry(timedelta(days=5), "Available"),
5774
+ build_entry(timedelta(days=3, hours=6), "Charging"),
5775
+ build_entry(timedelta(days=2), "Charging"),
5776
+ build_entry(timedelta(days=1), "Available"),
5777
+ ],
5778
+ maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES,
5779
+ )
5780
+
5781
+ data, window = _usage_timeline(charger, [], now=fixed_now)
5782
+ self.assertIsNotNone(window)
5783
+ self.assertEqual(len(data), 1)
5784
+ segments = data[0]["segments"]
5785
+ self.assertGreaterEqual(len(segments), 1)
5786
+ statuses = [segment["status"] for segment in segments]
5787
+ self.assertEqual(statuses, ["available", "charging", "available"])
5788
+
4115
5789
  def test_diagnostics_status_displayed(self):
4116
5790
  reported_at = timezone.now().replace(microsecond=0)
4117
5791
  charger = Charger.objects.create(
@@ -4130,6 +5804,37 @@ class ChargerStatusViewTests(TestCase):
4130
5804
  self.assertContains(resp, "id=\"diagnostics-location\"")
4131
5805
  self.assertContains(resp, "https://example.com/report.tar")
4132
5806
 
5807
+ def test_firmware_download_serves_payload(self):
5808
+ charger = Charger.objects.create(charger_id="DLVIEW")
5809
+ firmware = CPFirmware.objects.create(
5810
+ name="Download",
5811
+ filename="download.bin",
5812
+ payload_binary=b"payload",
5813
+ content_type="application/octet-stream",
5814
+ source=CPFirmware.Source.DOWNLOAD,
5815
+ is_user_data=True,
5816
+ )
5817
+ deployment = CPFirmwareDeployment.objects.create(
5818
+ firmware=firmware,
5819
+ charger=charger,
5820
+ node=charger.node_origin,
5821
+ ocpp_message_id="dl-msg",
5822
+ status="Pending",
5823
+ status_info="",
5824
+ status_timestamp=timezone.now(),
5825
+ retrieve_date=timezone.now(),
5826
+ request_payload={},
5827
+ is_user_data=True,
5828
+ )
5829
+ token = deployment.issue_download_token(lifetime=timedelta(hours=1))
5830
+ response = self.client.get(
5831
+ reverse("cp-firmware-download", args=[deployment.pk, token])
5832
+ )
5833
+ self.assertEqual(response.status_code, 200)
5834
+ self.assertEqual(response.content, b"payload")
5835
+ deployment.refresh_from_db()
5836
+ self.assertIsNotNone(deployment.downloaded_at)
5837
+
4133
5838
  def test_connector_status_prefers_connector_diagnostics(self):
4134
5839
  aggregate = Charger.objects.create(
4135
5840
  charger_id="DIAGCONN",
@@ -4442,6 +6147,49 @@ class LiveUpdateViewTests(TestCase):
4442
6147
  )
4443
6148
  self.assertEqual(aggregate_entry["state"], available_label)
4444
6149
 
6150
+ def test_dashboard_connector_treats_finishing_as_available_without_session(self):
6151
+ charger = Charger.objects.create(
6152
+ charger_id="FINISH-STATE",
6153
+ connector_id=1,
6154
+ last_status="Finishing",
6155
+ )
6156
+
6157
+ resp = self.client.get(reverse("ocpp-dashboard"))
6158
+ self.assertEqual(resp.status_code, 200)
6159
+ self.assertIsNotNone(resp.context)
6160
+ context = resp.context
6161
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
6162
+ entry = next(
6163
+ item
6164
+ for item in context["chargers"]
6165
+ if item["charger"].pk == charger.pk
6166
+ )
6167
+ self.assertEqual(entry["state"], available_label)
6168
+
6169
+ def test_dashboard_aggregate_treats_finishing_as_available_without_session(self):
6170
+ aggregate = Charger.objects.create(
6171
+ charger_id="FINISH-AGG",
6172
+ connector_id=None,
6173
+ last_status="Finishing",
6174
+ )
6175
+ Charger.objects.create(
6176
+ charger_id=aggregate.charger_id,
6177
+ connector_id=1,
6178
+ last_status="Finishing",
6179
+ )
6180
+
6181
+ resp = self.client.get(reverse("ocpp-dashboard"))
6182
+ self.assertEqual(resp.status_code, 200)
6183
+ self.assertIsNotNone(resp.context)
6184
+ context = resp.context
6185
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
6186
+ aggregate_entry = next(
6187
+ item
6188
+ for item in context["chargers"]
6189
+ if item["charger"].pk == aggregate.pk
6190
+ )
6191
+ self.assertEqual(aggregate_entry["state"], available_label)
6192
+
4445
6193
  def test_dashboard_aggregate_uses_connection_when_status_missing(self):
4446
6194
  aggregate = Charger.objects.create(
4447
6195
  charger_id="DASHAGG-CONN", last_status="Charging"
@@ -4466,6 +6214,59 @@ class LiveUpdateViewTests(TestCase):
4466
6214
  )
4467
6215
  self.assertEqual(aggregate_entry["state"], available_label)
4468
6216
 
6217
+ def test_dashboard_groups_connectors_under_parent(self):
6218
+ aggregate = Charger.objects.create(charger_id="GROUPED")
6219
+ first = Charger.objects.create(
6220
+ charger_id=aggregate.charger_id, connector_id=1
6221
+ )
6222
+ second = Charger.objects.create(
6223
+ charger_id=aggregate.charger_id, connector_id=2
6224
+ )
6225
+
6226
+ resp = self.client.get(reverse("ocpp-dashboard"))
6227
+ self.assertEqual(resp.status_code, 200)
6228
+ groups = resp.context["charger_groups"]
6229
+ target = next(
6230
+ group
6231
+ for group in groups
6232
+ if group.get("parent")
6233
+ and group["parent"]["charger"].pk == aggregate.pk
6234
+ )
6235
+ child_ids = [item["charger"].pk for item in target["children"]]
6236
+ self.assertEqual(child_ids, [first.pk, second.pk])
6237
+
6238
+ def test_dashboard_includes_energy_totals(self):
6239
+ aggregate = Charger.objects.create(charger_id="KWSTATS")
6240
+ now = timezone.now()
6241
+ Transaction.objects.create(
6242
+ charger=aggregate,
6243
+ start_time=now - timedelta(hours=1),
6244
+ stop_time=now,
6245
+ meter_start=0,
6246
+ meter_stop=3000,
6247
+ )
6248
+ past_start = now - timedelta(days=2)
6249
+ Transaction.objects.create(
6250
+ charger=aggregate,
6251
+ start_time=past_start,
6252
+ stop_time=past_start + timedelta(hours=1),
6253
+ meter_start=0,
6254
+ meter_stop=1000,
6255
+ )
6256
+
6257
+ resp = self.client.get(reverse("ocpp-dashboard"))
6258
+ self.assertEqual(resp.status_code, 200)
6259
+ groups = resp.context["charger_groups"]
6260
+ target = next(
6261
+ group
6262
+ for group in groups
6263
+ if group.get("parent")
6264
+ and group["parent"]["charger"].pk == aggregate.pk
6265
+ )
6266
+ stats = target["parent"]["stats"]
6267
+ self.assertAlmostEqual(stats["total_kw"], 4.0, places=2)
6268
+ self.assertAlmostEqual(stats["today_kw"], 3.0, places=2)
6269
+
4469
6270
  def test_cp_simulator_includes_interval(self):
4470
6271
  resp = self.client.get(reverse("cp-simulator"))
4471
6272
  self.assertEqual(resp.context["request"].live_update_interval, 5)
@@ -4553,3 +6354,33 @@ class LiveUpdateViewTests(TestCase):
4553
6354
  reverse("charger-status", args=[restricted.charger_id])
4554
6355
  )
4555
6356
  self.assertEqual(group_denied.status_code, 404)
6357
+
6358
+
6359
+ class StoreLogBufferTests(TestCase):
6360
+ def test_add_log_enforces_in_memory_cap(self):
6361
+ cid = "BUFFER-CAP-TEST"
6362
+ log_type = "charger"
6363
+ store.clear_log(cid, log_type=log_type)
6364
+ self.addCleanup(lambda: store.clear_log(cid, log_type=log_type))
6365
+
6366
+ with patch("ocpp.store.MAX_IN_MEMORY_LOG_ENTRIES", 3):
6367
+ for index in range(6):
6368
+ store.add_log(cid, f"message {index}", log_type=log_type)
6369
+
6370
+ buffer = None
6371
+ lower = cid.lower()
6372
+ for key, entries in store.logs[log_type].items():
6373
+ if key.lower() == lower:
6374
+ buffer = entries
6375
+ break
6376
+
6377
+ self.assertIsNotNone(buffer, "Expected in-memory log buffer to be created")
6378
+ self.assertIsInstance(buffer, deque)
6379
+ self.assertEqual(len(buffer), 3)
6380
+ self.assertTrue(buffer[0].endswith("message 3"))
6381
+ self.assertTrue(buffer[-1].endswith("message 5"))
6382
+
6383
+ merged = store.get_logs(cid, log_type=log_type)
6384
+
6385
+ self.assertTrue(any(entry.endswith("message 5") for entry in merged))
6386
+ self.assertTrue(any(entry.endswith("message 4") for entry in merged))