arthexis 0.1.16__py3-none-any.whl → 0.1.26__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 (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
ocpp/tests.py CHANGED
@@ -55,19 +55,23 @@ from django.contrib.sites.models import Site
55
55
  from django.core.exceptions import ValidationError
56
56
  from pages.models import Application, Module
57
57
  from nodes.models import Node, NodeRole
58
+ from django.contrib.admin.sites import AdminSite
58
59
 
59
60
  from config.asgi import application
60
61
 
61
62
  from .models import (
62
63
  Transaction,
63
64
  Charger,
65
+ ChargerConfiguration,
64
66
  Simulator,
65
67
  MeterReading,
66
68
  Location,
67
69
  DataTransferMessage,
70
+ CPReservation,
68
71
  )
72
+ from .admin import ChargerConfigurationAdmin
69
73
  from .consumers import CSMSConsumer
70
- from .views import dispatch_action
74
+ from .views import dispatch_action, _transaction_rfid_details, _usage_timeline
71
75
  from .status_display import STATUS_BADGE_MAP
72
76
  from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
73
77
  from . import store
@@ -80,7 +84,12 @@ from .simulator import SimulatorConfig, ChargePointSimulator
80
84
  from .evcs import simulate, SimulatorState, _simulators
81
85
  import re
82
86
  from datetime import datetime, timedelta, timezone as dt_timezone
83
- from .tasks import purge_meter_readings, send_daily_session_report
87
+ from .tasks import (
88
+ purge_meter_readings,
89
+ send_daily_session_report,
90
+ check_charge_point_configuration,
91
+ schedule_daily_charge_point_configuration_checks,
92
+ )
84
93
  from django.db import close_old_connections
85
94
  from django.db.utils import OperationalError
86
95
  from urllib.parse import unquote, urlparse
@@ -169,6 +178,36 @@ class DispatchActionTests(TestCase):
169
178
  self.assertEqual(metadata.get("trigger_target"), "BootNotification")
170
179
  self.assertEqual(metadata.get("log_key"), log_key)
171
180
 
181
+ def test_reset_rejected_when_transaction_active(self):
182
+ charger = Charger.objects.create(charger_id="RESETBLOCK")
183
+ dummy = DummyWebSocket()
184
+ connection_key = store.set_connection(charger.charger_id, charger.connector_id, dummy)
185
+ self.addCleanup(lambda: store.connections.pop(connection_key, None))
186
+ tx_obj = Transaction.objects.create(
187
+ charger=charger,
188
+ connector_id=charger.connector_id,
189
+ start_time=timezone.now(),
190
+ )
191
+ tx_key = store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
192
+ self.addCleanup(lambda: store.transactions.pop(tx_key, None))
193
+
194
+ request = self.factory.post(
195
+ "/chargers/RESETBLOCK/action/",
196
+ data=json.dumps({"action": "reset"}),
197
+ content_type="application/json",
198
+ )
199
+ request.user = SimpleNamespace(
200
+ is_authenticated=True,
201
+ is_superuser=True,
202
+ is_staff=True,
203
+ )
204
+
205
+ response = dispatch_action(request, charger.charger_id)
206
+ self.assertEqual(response.status_code, 409)
207
+ payload = json.loads(response.content.decode("utf-8"))
208
+ self.assertIn("stop the session first", payload.get("detail", "").lower())
209
+ self.assertFalse(dummy.sent)
210
+
172
211
  class ChargerFixtureTests(TestCase):
173
212
  fixtures = [
174
213
  p.name
@@ -210,6 +249,240 @@ class ChargerFixtureTests(TestCase):
210
249
  self.assertEqual(cp2.name, "Simulator #2")
211
250
 
212
251
 
252
+ class ChargerRefreshManagerNodeTests(TestCase):
253
+ @classmethod
254
+ def setUpTestData(cls):
255
+ local = Node.objects.create(
256
+ hostname="local-node",
257
+ address="127.0.0.1",
258
+ port=8000,
259
+ mac_address="aa:bb:cc:dd:ee:ff",
260
+ current_relation=Node.Relation.SELF,
261
+ )
262
+ Node.objects.filter(pk=local.pk).update(mac_address="AA:BB:CC:DD:EE:FF")
263
+ cls.local_node = Node.objects.get(pk=local.pk)
264
+
265
+ def test_refresh_manager_node_assigns_local_to_unsaved_charger(self):
266
+ charger = Charger(charger_id="UNSAVED")
267
+
268
+ with patch("nodes.models.Node.get_current_mac", return_value="aa:bb:cc:dd:ee:ff"):
269
+ result = charger.refresh_manager_node()
270
+
271
+ self.assertEqual(result, self.local_node)
272
+ self.assertEqual(charger.manager_node, self.local_node)
273
+
274
+ def test_refresh_manager_node_updates_persisted_charger(self):
275
+ remote = Node.objects.create(
276
+ hostname="remote-node",
277
+ address="10.0.0.1",
278
+ port=9000,
279
+ mac_address="11:22:33:44:55:66",
280
+ )
281
+ charger = Charger.objects.create(
282
+ charger_id="PERSISTED",
283
+ manager_node=remote,
284
+ )
285
+
286
+ charger.refresh_manager_node(node=self.local_node)
287
+
288
+ self.assertEqual(charger.manager_node, self.local_node)
289
+ charger.refresh_from_db()
290
+ self.assertEqual(charger.manager_node, self.local_node)
291
+
292
+ def test_refresh_manager_node_handles_missing_local_node(self):
293
+ remote = Node.objects.create(
294
+ hostname="existing-manager",
295
+ address="10.0.0.2",
296
+ port=9001,
297
+ mac_address="22:33:44:55:66:77",
298
+ )
299
+ charger = Charger(charger_id="NOLOCAL", manager_node=remote)
300
+
301
+ with patch.object(Node, "get_local", return_value=None):
302
+ result = charger.refresh_manager_node()
303
+
304
+ self.assertIsNone(result)
305
+ self.assertEqual(charger.manager_node, remote)
306
+
307
+
308
+ class CPReservationTests(TransactionTestCase):
309
+ def setUp(self):
310
+ self.location = Location.objects.create(name="Reservation Site")
311
+ self.aggregate = Charger.objects.create(charger_id="RSV100", location=self.location)
312
+ self.connector_one = Charger.objects.create(
313
+ charger_id="RSV100", connector_id=1, location=self.location
314
+ )
315
+ self.connector_two = Charger.objects.create(
316
+ charger_id="RSV100", connector_id=2, location=self.location
317
+ )
318
+ self.addCleanup(store.clear_pending_calls, "RSV100")
319
+
320
+ def test_allocates_preferred_connector(self):
321
+ start = timezone.now() + timedelta(hours=1)
322
+ reservation = CPReservation(
323
+ location=self.location,
324
+ start_time=start,
325
+ duration_minutes=90,
326
+ id_tag="TAG001",
327
+ )
328
+ reservation.save()
329
+ self.assertEqual(reservation.connector, self.connector_two)
330
+
331
+ def test_allocation_falls_back_and_blocks_overlaps(self):
332
+ start = timezone.now() + timedelta(hours=1)
333
+ first = CPReservation.objects.create(
334
+ location=self.location,
335
+ start_time=start,
336
+ duration_minutes=60,
337
+ id_tag="TAG002",
338
+ )
339
+ self.assertEqual(first.connector, self.connector_two)
340
+ second = CPReservation(
341
+ location=self.location,
342
+ start_time=start + timedelta(minutes=15),
343
+ duration_minutes=60,
344
+ id_tag="TAG003",
345
+ )
346
+ second.save()
347
+ self.assertEqual(second.connector, self.connector_one)
348
+ third = CPReservation(
349
+ location=self.location,
350
+ start_time=start + timedelta(minutes=30),
351
+ duration_minutes=45,
352
+ id_tag="TAG004",
353
+ )
354
+ with self.assertRaises(ValidationError):
355
+ third.save()
356
+
357
+ def test_send_reservation_request_dispatches_frame(self):
358
+ start = timezone.now() + timedelta(hours=1)
359
+ reservation = CPReservation.objects.create(
360
+ location=self.location,
361
+ start_time=start,
362
+ duration_minutes=30,
363
+ id_tag="TAG005",
364
+ )
365
+
366
+ class DummyConnection:
367
+ def __init__(self):
368
+ self.sent: list[str] = []
369
+
370
+ async def send(self, message):
371
+ self.sent.append(message)
372
+
373
+ ws = DummyConnection()
374
+ store.set_connection(
375
+ reservation.connector.charger_id,
376
+ reservation.connector.connector_id,
377
+ ws,
378
+ )
379
+ self.addCleanup(
380
+ store.pop_connection,
381
+ reservation.connector.charger_id,
382
+ reservation.connector.connector_id,
383
+ )
384
+
385
+ message_id = reservation.send_reservation_request()
386
+ self.assertTrue(ws.sent)
387
+ frame = json.loads(ws.sent[0])
388
+ self.assertEqual(frame[0], 2)
389
+ self.assertEqual(frame[2], "ReserveNow")
390
+ self.assertEqual(frame[3]["reservationId"], reservation.pk)
391
+ self.assertEqual(frame[3]["connectorId"], reservation.connector.connector_id)
392
+ self.assertEqual(frame[3]["idTag"], "TAG005")
393
+ metadata = store.pending_calls.get(message_id)
394
+ self.assertIsNotNone(metadata)
395
+ self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
396
+
397
+ def test_call_result_marks_reservation_confirmed(self):
398
+ start = timezone.now() + timedelta(hours=1)
399
+ reservation = CPReservation.objects.create(
400
+ location=self.location,
401
+ start_time=start,
402
+ duration_minutes=45,
403
+ id_tag="TAG006",
404
+ )
405
+ log_key = store.identity_key(
406
+ reservation.connector.charger_id, reservation.connector.connector_id
407
+ )
408
+ message_id = "reserve-success"
409
+ store.register_pending_call(
410
+ message_id,
411
+ {
412
+ "action": "ReserveNow",
413
+ "charger_id": reservation.connector.charger_id,
414
+ "connector_id": reservation.connector.connector_id,
415
+ "log_key": log_key,
416
+ "reservation_pk": reservation.pk,
417
+ },
418
+ )
419
+
420
+ consumer = CSMSConsumer()
421
+ consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
422
+ consumer.charger_id = reservation.connector.charger_id
423
+ consumer.store_key = log_key
424
+ consumer.connector_value = reservation.connector.connector_id
425
+ consumer.charger = reservation.connector
426
+ consumer.aggregate_charger = self.aggregate
427
+ consumer._consumption_task = None
428
+ consumer._consumption_message_uuid = None
429
+ consumer.send = AsyncMock()
430
+
431
+ async_to_sync(consumer._handle_call_result)(
432
+ message_id, {"status": "Accepted"}
433
+ )
434
+ reservation.refresh_from_db()
435
+ self.assertTrue(reservation.evcs_confirmed)
436
+ self.assertEqual(reservation.evcs_status, "Accepted")
437
+ self.assertIsNotNone(reservation.evcs_confirmed_at)
438
+
439
+ def test_call_error_updates_reservation_status(self):
440
+ start = timezone.now() + timedelta(hours=1)
441
+ reservation = CPReservation.objects.create(
442
+ location=self.location,
443
+ start_time=start,
444
+ duration_minutes=45,
445
+ id_tag="TAG007",
446
+ )
447
+ log_key = store.identity_key(
448
+ reservation.connector.charger_id, reservation.connector.connector_id
449
+ )
450
+ message_id = "reserve-error"
451
+ store.register_pending_call(
452
+ message_id,
453
+ {
454
+ "action": "ReserveNow",
455
+ "charger_id": reservation.connector.charger_id,
456
+ "connector_id": reservation.connector.connector_id,
457
+ "log_key": log_key,
458
+ "reservation_pk": reservation.pk,
459
+ },
460
+ )
461
+
462
+ consumer = CSMSConsumer()
463
+ consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
464
+ consumer.charger_id = reservation.connector.charger_id
465
+ consumer.store_key = log_key
466
+ consumer.connector_value = reservation.connector.connector_id
467
+ consumer.charger = reservation.connector
468
+ consumer.aggregate_charger = self.aggregate
469
+ consumer._consumption_task = None
470
+ consumer._consumption_message_uuid = None
471
+ consumer.send = AsyncMock()
472
+
473
+ async_to_sync(consumer._handle_call_error)(
474
+ message_id,
475
+ "Rejected",
476
+ "Charger unavailable",
477
+ {"reason": "maintenance"},
478
+ )
479
+ reservation.refresh_from_db()
480
+ self.assertFalse(reservation.evcs_confirmed)
481
+ self.assertEqual(reservation.evcs_status, "")
482
+ self.assertIsNone(reservation.evcs_confirmed_at)
483
+ self.assertIn("Rejected", reservation.evcs_error or "")
484
+
485
+
213
486
  class ChargerUrlFallbackTests(TestCase):
214
487
  @override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
215
488
  def test_reference_created_when_site_missing(self):
@@ -714,6 +987,13 @@ class CSMSConsumerTests(TransactionTestCase):
714
987
  connected, _ = await communicator.connect()
715
988
  self.assertTrue(connected)
716
989
 
990
+ await database_sync_to_async(Charger.objects.get_or_create)(
991
+ charger_id="CFGRES", connector_id=1
992
+ )
993
+ await database_sync_to_async(Charger.objects.get_or_create)(
994
+ charger_id="CFGRES", connector_id=2
995
+ )
996
+
717
997
  message_id = "cfg-result"
718
998
  payload = {
719
999
  "configurationKey": [
@@ -744,6 +1024,32 @@ class CSMSConsumerTests(TransactionTestCase):
744
1024
  )
745
1025
  self.assertNotIn(message_id, store.pending_calls)
746
1026
 
1027
+ configuration = await database_sync_to_async(
1028
+ lambda: ChargerConfiguration.objects.order_by("-created_at").first()
1029
+ )()
1030
+ self.assertIsNotNone(configuration)
1031
+ self.assertEqual(configuration.charger_identifier, "CFGRES")
1032
+ self.assertIsNotNone(configuration.evcs_snapshot_at)
1033
+ self.assertEqual(
1034
+ configuration.configuration_keys,
1035
+ [
1036
+ {
1037
+ "key": "AllowOfflineTxForUnknownId",
1038
+ "value": "false",
1039
+ "readonly": True,
1040
+ }
1041
+ ],
1042
+ )
1043
+ self.assertEqual(configuration.unknown_keys, [])
1044
+ config_ids = await database_sync_to_async(
1045
+ lambda: set(
1046
+ Charger.objects.filter(charger_id="CFGRES").values_list(
1047
+ "configuration_id", flat=True
1048
+ )
1049
+ )
1050
+ )()
1051
+ self.assertEqual(config_ids, {configuration.pk})
1052
+
747
1053
  await communicator.disconnect()
748
1054
  store.clear_log(log_key, log_type="charger")
749
1055
  store.clear_log(pending_key, log_type="charger")
@@ -1109,7 +1415,7 @@ class CSMSConsumerTests(TransactionTestCase):
1109
1415
 
1110
1416
  await communicator.disconnect()
1111
1417
 
1112
- async def test_vin_recorded(self):
1418
+ async def test_vid_populated_from_vin(self):
1113
1419
  await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
1114
1420
  communicator = WebsocketCommunicator(application, "/VINREC/")
1115
1421
  connected, _ = await communicator.connect()
@@ -1124,7 +1430,29 @@ class CSMSConsumerTests(TransactionTestCase):
1124
1430
  tx = await database_sync_to_async(Transaction.objects.get)(
1125
1431
  pk=tx_id, charger__charger_id="VINREC"
1126
1432
  )
1127
- self.assertEqual(tx.vin, "WP0ZZZ11111111111")
1433
+ self.assertEqual(tx.vid, "WP0ZZZ11111111111")
1434
+ self.assertEqual(tx.vehicle_identifier, "WP0ZZZ11111111111")
1435
+ self.assertEqual(tx.vehicle_identifier_source, "vid")
1436
+
1437
+ await communicator.disconnect()
1438
+
1439
+ async def test_vid_recorded(self):
1440
+ await database_sync_to_async(Charger.objects.create)(charger_id="VIDREC")
1441
+ communicator = WebsocketCommunicator(application, "/VIDREC/")
1442
+ connected, _ = await communicator.connect()
1443
+ self.assertTrue(connected)
1444
+
1445
+ await communicator.send_json_to(
1446
+ [2, "1", "StartTransaction", {"meterStart": 1, "vid": "VID123456"}]
1447
+ )
1448
+ response = await communicator.receive_json_from()
1449
+ tx_id = response[2]["transactionId"]
1450
+
1451
+ tx = await database_sync_to_async(Transaction.objects.get)(
1452
+ pk=tx_id, charger__charger_id="VIDREC"
1453
+ )
1454
+ self.assertEqual(tx.vid, "VID123456")
1455
+ self.assertEqual(tx.rfid, "")
1128
1456
 
1129
1457
  await communicator.disconnect()
1130
1458
 
@@ -1852,6 +2180,16 @@ class ChargerLandingTests(TestCase):
1852
2180
  status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
1853
2181
  self.assertContains(response, status_url)
1854
2182
 
2183
+ def test_charger_page_respects_language_configuration(self):
2184
+ charger = Charger.objects.create(charger_id="PAGE-DE", language="de")
2185
+
2186
+ response = self.client.get(reverse("charger-page", args=["PAGE-DE"]))
2187
+
2188
+ self.assertEqual(response.status_code, 200)
2189
+ self.assertEqual(response.context["LANGUAGE_CODE"], "de")
2190
+ self.assertContains(response, 'lang="de"')
2191
+ self.assertContains(response, 'data-preferred-language="de"')
2192
+
1855
2193
  def test_status_page_renders(self):
1856
2194
  charger = Charger.objects.create(charger_id="PAGE2")
1857
2195
  resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
@@ -1931,6 +2269,27 @@ class ChargerLandingTests(TestCase):
1931
2269
  finally:
1932
2270
  store.transactions.pop(key, None)
1933
2271
 
2272
+ def test_public_page_shows_available_when_status_stale(self):
2273
+ charger = Charger.objects.create(
2274
+ charger_id="STALEPUB",
2275
+ last_status="Charging",
2276
+ )
2277
+ response = self.client.get(reverse("charger-page", args=["STALEPUB"]))
2278
+ self.assertEqual(response.status_code, 200)
2279
+ self.assertContains(
2280
+ response,
2281
+ 'style="background-color: #0d6efd; color: #fff;">Available</span>',
2282
+ )
2283
+
2284
+ def test_admin_status_shows_available_when_status_stale(self):
2285
+ charger = Charger.objects.create(
2286
+ charger_id="STALEADM",
2287
+ last_status="Charging",
2288
+ )
2289
+ response = self.client.get(reverse("charger-status", args=["STALEADM"]))
2290
+ self.assertEqual(response.status_code, 200)
2291
+ self.assertContains(response, 'id="charger-state">Available</strong>')
2292
+
1934
2293
  def test_public_status_shows_rfid_link_for_known_tag(self):
1935
2294
  aggregate = Charger.objects.create(charger_id="PUBRFID")
1936
2295
  connector = Charger.objects.create(
@@ -2057,7 +2416,10 @@ class ChargerLandingTests(TestCase):
2057
2416
  log_id = store.identity_key("LOG1", None)
2058
2417
  store.add_log(log_id, "hello", log_type="charger")
2059
2418
  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$")
2419
+ self.assertRegex(
2420
+ entry,
2421
+ r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} hello$",
2422
+ )
2061
2423
  resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
2062
2424
  self.assertEqual(resp.status_code, 200)
2063
2425
  self.assertContains(resp, "hello")
@@ -2092,12 +2454,12 @@ class SimulatorLandingTests(TestCase):
2092
2454
  @skip("Navigation links unavailable in test environment")
2093
2455
  def test_simulator_app_link_in_nav(self):
2094
2456
  resp = self.client.get(reverse("pages:index"))
2095
- self.assertContains(resp, "/ocpp/")
2096
- self.assertNotContains(resp, "/ocpp/simulator/")
2457
+ self.assertContains(resp, "/ocpp/cpms/dashboard/")
2458
+ self.assertNotContains(resp, "/ocpp/evcs/simulator/")
2097
2459
  self.client.force_login(self.user)
2098
2460
  resp = self.client.get(reverse("pages:index"))
2099
- self.assertContains(resp, "/ocpp/")
2100
- self.assertContains(resp, "/ocpp/simulator/")
2461
+ self.assertContains(resp, "/ocpp/cpms/dashboard/")
2462
+ self.assertContains(resp, "/ocpp/evcs/simulator/")
2101
2463
 
2102
2464
  def test_cp_simulator_redirects_to_login(self):
2103
2465
  response = self.client.get(reverse("cp-simulator"))
@@ -2129,6 +2491,32 @@ class ChargerAdminTests(TestCase):
2129
2491
  resp = self.client.get(url)
2130
2492
  self.assertNotContains(resp, charger.reference.image.url)
2131
2493
 
2494
+ def test_toggle_rfid_authentication_action_toggles_value(self):
2495
+ charger_requires = Charger.objects.create(
2496
+ charger_id="RFIDON", require_rfid=True
2497
+ )
2498
+ charger_optional = Charger.objects.create(
2499
+ charger_id="RFIDOFF", require_rfid=False
2500
+ )
2501
+ url = reverse("admin:ocpp_charger_changelist")
2502
+ response = self.client.post(
2503
+ url,
2504
+ {
2505
+ "action": "toggle_rfid_authentication",
2506
+ "_selected_action": [
2507
+ charger_requires.pk,
2508
+ charger_optional.pk,
2509
+ ],
2510
+ },
2511
+ follow=True,
2512
+ )
2513
+ self.assertEqual(response.status_code, 200)
2514
+ charger_requires.refresh_from_db()
2515
+ charger_optional.refresh_from_db()
2516
+ self.assertFalse(charger_requires.require_rfid)
2517
+ self.assertTrue(charger_optional.require_rfid)
2518
+ self.assertContains(response, "Updated RFID authentication")
2519
+
2132
2520
  def test_admin_lists_log_link(self):
2133
2521
  charger = Charger.objects.create(charger_id="LOG1")
2134
2522
  url = reverse("admin:ocpp_charger_changelist")
@@ -2155,6 +2543,85 @@ class ChargerAdminTests(TestCase):
2155
2543
  finally:
2156
2544
  store.transactions.pop(key, None)
2157
2545
 
2546
+ def test_admin_status_shows_available_when_status_stale(self):
2547
+ charger = Charger.objects.create(
2548
+ charger_id="ADMINSTALE",
2549
+ last_status="Charging",
2550
+ )
2551
+ url = reverse("admin:ocpp_charger_changelist")
2552
+ resp = self.client.get(url)
2553
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
2554
+ self.assertContains(resp, f">{available_label}<")
2555
+
2556
+ def test_recheck_charger_status_action_sends_trigger(self):
2557
+ charger = Charger.objects.create(charger_id="RECHECK1")
2558
+
2559
+ class DummyConnection:
2560
+ def __init__(self):
2561
+ self.sent: list[str] = []
2562
+
2563
+ async def send(self, message):
2564
+ self.sent.append(message)
2565
+
2566
+ ws = DummyConnection()
2567
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2568
+ try:
2569
+ url = reverse("admin:ocpp_charger_changelist")
2570
+ response = self.client.post(
2571
+ url,
2572
+ {
2573
+ "action": "recheck_charger_status",
2574
+ "index": 0,
2575
+ "select_across": 0,
2576
+ "_selected_action": [charger.pk],
2577
+ },
2578
+ follow=True,
2579
+ )
2580
+ self.assertEqual(response.status_code, 200)
2581
+ self.assertTrue(ws.sent)
2582
+ self.assertIn("TriggerMessage", ws.sent[0])
2583
+ self.assertContains(response, "Requested status update")
2584
+ finally:
2585
+ store.pop_connection(charger.charger_id, charger.connector_id)
2586
+ store.clear_pending_calls(charger.charger_id)
2587
+
2588
+ def test_reset_charger_action_skips_when_transaction_active(self):
2589
+ charger = Charger.objects.create(charger_id="RESETADMIN")
2590
+
2591
+ class DummyConnection:
2592
+ def __init__(self):
2593
+ self.sent: list[str] = []
2594
+
2595
+ async def send(self, message):
2596
+ self.sent.append(message)
2597
+
2598
+ ws = DummyConnection()
2599
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2600
+ tx_obj = Transaction.objects.create(
2601
+ charger=charger,
2602
+ connector_id=charger.connector_id,
2603
+ start_time=timezone.now(),
2604
+ )
2605
+ store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
2606
+ try:
2607
+ url = reverse("admin:ocpp_charger_changelist")
2608
+ response = self.client.post(
2609
+ url,
2610
+ {
2611
+ "action": "reset_chargers",
2612
+ "index": 0,
2613
+ "select_across": 0,
2614
+ "_selected_action": [charger.pk],
2615
+ },
2616
+ follow=True,
2617
+ )
2618
+ self.assertEqual(response.status_code, 200)
2619
+ self.assertFalse(ws.sent)
2620
+ self.assertContains(response, "stop the session first")
2621
+ finally:
2622
+ store.pop_connection(charger.charger_id, charger.connector_id)
2623
+ store.pop_transaction(charger.charger_id, charger.connector_id)
2624
+
2158
2625
  def test_admin_log_view_displays_entries(self):
2159
2626
  charger = Charger.objects.create(charger_id="LOG2")
2160
2627
  log_id = store.identity_key(charger.charger_id, charger.connector_id)
@@ -2178,6 +2645,36 @@ class ChargerAdminTests(TestCase):
2178
2645
  resp = self.client.get(url)
2179
2646
  self.assertContains(resp, "AdminLoc")
2180
2647
 
2648
+ def test_admin_changelist_displays_quick_stats(self):
2649
+ charger = Charger.objects.create(charger_id="STATMAIN", display_name="Main EVCS")
2650
+ connector = Charger.objects.create(
2651
+ charger_id="STATMAIN", connector_id=1, display_name="Connector 1"
2652
+ )
2653
+ start = timezone.now() - timedelta(minutes=30)
2654
+ Transaction.objects.create(
2655
+ charger=connector,
2656
+ start_time=start,
2657
+ stop_time=start + timedelta(minutes=10),
2658
+ meter_start=1000,
2659
+ meter_stop=6000,
2660
+ )
2661
+
2662
+ url = reverse("admin:ocpp_charger_changelist")
2663
+ resp = self.client.get(url)
2664
+
2665
+ self.assertContains(resp, "Total kW")
2666
+ self.assertContains(resp, "Today kW")
2667
+ self.assertContains(resp, "5.00")
2668
+
2669
+ def test_admin_changelist_does_not_indent_connectors(self):
2670
+ Charger.objects.create(charger_id="INDENTMAIN")
2671
+ Charger.objects.create(charger_id="INDENTMAIN", connector_id=1)
2672
+
2673
+ url = reverse("admin:ocpp_charger_changelist")
2674
+ resp = self.client.get(url)
2675
+
2676
+ self.assertNotContains(resp, 'class="charger-connector-entry"')
2677
+
2181
2678
  def test_last_fields_are_read_only(self):
2182
2679
  now = timezone.now()
2183
2680
  charger = Charger.objects.create(
@@ -2427,6 +2924,81 @@ class ChargerAdminTests(TestCase):
2427
2924
  store.clear_log(pending_key, log_type="charger")
2428
2925
 
2429
2926
 
2927
+ class ChargerConfigurationAdminUnitTests(TestCase):
2928
+ def setUp(self):
2929
+ self.admin = ChargerConfigurationAdmin(ChargerConfiguration, AdminSite())
2930
+ self.request_factory = RequestFactory()
2931
+
2932
+ def test_origin_display_returns_evcs_when_snapshot_present(self):
2933
+ configuration = ChargerConfiguration.objects.create(
2934
+ charger_identifier="CFG-EVCS",
2935
+ evcs_snapshot_at=timezone.now(),
2936
+ )
2937
+ self.assertEqual(self.admin.origin_display(configuration), "EVCS")
2938
+
2939
+ def test_origin_display_returns_local_without_snapshot(self):
2940
+ configuration = ChargerConfiguration.objects.create(
2941
+ charger_identifier="CFG-LOCAL",
2942
+ )
2943
+ self.assertEqual(self.admin.origin_display(configuration), "Local")
2944
+
2945
+ def test_save_model_resets_snapshot_timestamp(self):
2946
+ configuration = ChargerConfiguration.objects.create(
2947
+ charger_identifier="CFG-SAVE",
2948
+ evcs_snapshot_at=timezone.now(),
2949
+ )
2950
+ request = self.request_factory.post("/admin/ocpp/chargerconfiguration/")
2951
+ self.admin.save_model(request, configuration, form=None, change=True)
2952
+ configuration.refresh_from_db()
2953
+ self.assertIsNone(configuration.evcs_snapshot_at)
2954
+
2955
+
2956
+ class ConfigurationTaskTests(TestCase):
2957
+ def tearDown(self):
2958
+ store.pending_calls.clear()
2959
+
2960
+ def test_check_charge_point_configuration_dispatches_request(self):
2961
+ charger = Charger.objects.create(charger_id="TASKCFG")
2962
+ ws = DummyWebSocket()
2963
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
2964
+ pending_key = store.pending_key(charger.charger_id)
2965
+ store.clear_log(log_key, log_type="charger")
2966
+ store.clear_log(pending_key, log_type="charger")
2967
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2968
+ try:
2969
+ result = check_charge_point_configuration.run(charger.pk)
2970
+ self.assertTrue(result)
2971
+ self.assertEqual(len(ws.sent), 1)
2972
+ frame = json.loads(ws.sent[0])
2973
+ self.assertEqual(frame[0], 2)
2974
+ self.assertEqual(frame[2], "GetConfiguration")
2975
+ self.assertIn(frame[1], store.pending_calls)
2976
+ finally:
2977
+ store.pop_connection(charger.charger_id, charger.connector_id)
2978
+ store.pending_calls.clear()
2979
+ store.clear_log(log_key, log_type="charger")
2980
+ store.clear_log(pending_key, log_type="charger")
2981
+
2982
+ def test_check_charge_point_configuration_without_connection(self):
2983
+ charger = Charger.objects.create(charger_id="TASKNOCONN")
2984
+ result = check_charge_point_configuration.run(charger.pk)
2985
+ self.assertFalse(result)
2986
+
2987
+ def test_schedule_daily_checks_only_includes_root_chargers(self):
2988
+ eligible = Charger.objects.create(charger_id="TASKROOT")
2989
+ Charger.objects.create(charger_id="TASKCONN", connector_id=1)
2990
+ with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
2991
+ scheduled = schedule_daily_charge_point_configuration_checks.run()
2992
+ self.assertEqual(scheduled, 1)
2993
+ mock_delay.assert_called_once_with(eligible.pk)
2994
+
2995
+ def test_schedule_daily_checks_returns_zero_without_chargers(self):
2996
+ with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
2997
+ scheduled = schedule_daily_charge_point_configuration_checks.run()
2998
+ self.assertEqual(scheduled, 0)
2999
+ mock_delay.assert_not_called()
3000
+
3001
+
2430
3002
  class LocationAdminTests(TestCase):
2431
3003
  def setUp(self):
2432
3004
  self.client = Client()
@@ -2700,6 +3272,28 @@ class SimulatorAdminTests(TransactionTestCase):
2700
3272
 
2701
3273
  await communicator.disconnect()
2702
3274
 
3275
+ def test_auto_registered_charger_location_name_sanitized(self):
3276
+ async def exercise():
3277
+ communicator = WebsocketCommunicator(
3278
+ application, "/?cid=ACME%20Charger%20%231"
3279
+ )
3280
+ connected, _ = await communicator.connect()
3281
+ self.assertTrue(connected)
3282
+
3283
+ await communicator.disconnect()
3284
+
3285
+ def fetch_location_name() -> str:
3286
+ charger = (
3287
+ Charger.objects.select_related("location")
3288
+ .get(charger_id="ACME Charger #1")
3289
+ )
3290
+ return charger.location.name
3291
+
3292
+ location_name = await database_sync_to_async(fetch_location_name)()
3293
+ self.assertEqual(location_name, "ACME_Charger_1")
3294
+
3295
+ async_to_sync(exercise)()
3296
+
2703
3297
  async def test_query_string_cid_supported(self):
2704
3298
  communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
2705
3299
  connected, _ = await communicator.connect()
@@ -2860,6 +3454,44 @@ class SimulatorAdminTests(TransactionTestCase):
2860
3454
 
2861
3455
  await communicator.disconnect()
2862
3456
 
3457
+ async def test_authorize_requires_rfid_accepts_allowed_tag_without_account(self):
3458
+ charger_id = "AUTHWARN"
3459
+ tag_value = "WARN01"
3460
+ await database_sync_to_async(Charger.objects.create)(
3461
+ charger_id=charger_id, require_rfid=True
3462
+ )
3463
+ await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
3464
+
3465
+ pending_key = store.pending_key(charger_id)
3466
+ store.clear_log(pending_key, log_type="charger")
3467
+
3468
+ communicator = WebsocketCommunicator(application, f"/{charger_id}/")
3469
+ connected, _ = await communicator.connect()
3470
+ self.assertTrue(connected)
3471
+
3472
+ message_id = "auth-unlinked"
3473
+ await communicator.send_json_to(
3474
+ [2, message_id, "Authorize", {"idTag": tag_value}]
3475
+ )
3476
+ response = await communicator.receive_json_from()
3477
+ self.assertEqual(response[0], 3)
3478
+ self.assertEqual(response[1], message_id)
3479
+ self.assertEqual(response[2], {"idTagInfo": {"status": "Accepted"}})
3480
+
3481
+ log_entries = store.get_logs(pending_key, log_type="charger")
3482
+ self.assertTrue(
3483
+ any(
3484
+ "Authorized RFID" in entry
3485
+ and tag_value in entry
3486
+ and charger_id in entry
3487
+ for entry in log_entries
3488
+ ),
3489
+ log_entries,
3490
+ )
3491
+
3492
+ await communicator.disconnect()
3493
+ store.clear_log(pending_key, log_type="charger")
3494
+
2863
3495
  async def test_authorize_without_requirement_records_rfid(self):
2864
3496
  await database_sync_to_async(Charger.objects.create)(
2865
3497
  charger_id="AUTHOPT", require_rfid=False
@@ -2952,6 +3584,61 @@ class SimulatorAdminTests(TransactionTestCase):
2952
3584
  )
2953
3585
  self.assertEqual(tx.account_id, user.energy_account.id)
2954
3586
 
3587
+ async def test_start_transaction_allows_allowed_tag_without_account(self):
3588
+ charger_id = "STARTWARN"
3589
+ tag_value = "WARN02"
3590
+ await database_sync_to_async(Charger.objects.create)(
3591
+ charger_id=charger_id, require_rfid=True
3592
+ )
3593
+ await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
3594
+
3595
+ pending_key = store.pending_key(charger_id)
3596
+ store.clear_log(pending_key, log_type="charger")
3597
+
3598
+ communicator = WebsocketCommunicator(application, f"/{charger_id}/")
3599
+ connected, _ = await communicator.connect()
3600
+ self.assertTrue(connected)
3601
+
3602
+ start_payload = {
3603
+ "meterStart": 5,
3604
+ "idTag": tag_value,
3605
+ "connectorId": 1,
3606
+ }
3607
+ await communicator.send_json_to([2, "start-1", "StartTransaction", start_payload])
3608
+ response = await communicator.receive_json_from()
3609
+ self.assertEqual(response[0], 3)
3610
+ self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
3611
+ tx_id = response[2]["transactionId"]
3612
+
3613
+ tx = await database_sync_to_async(Transaction.objects.get)(
3614
+ pk=tx_id, charger__charger_id=charger_id
3615
+ )
3616
+ self.assertIsNone(tx.account_id)
3617
+
3618
+ log_entries = store.get_logs(pending_key, log_type="charger")
3619
+ self.assertTrue(
3620
+ any(
3621
+ "Authorized RFID" in entry
3622
+ and tag_value in entry
3623
+ and charger_id in entry
3624
+ for entry in log_entries
3625
+ ),
3626
+ log_entries,
3627
+ )
3628
+
3629
+ await communicator.send_json_to(
3630
+ [
3631
+ 2,
3632
+ "stop-1",
3633
+ "StopTransaction",
3634
+ {"transactionId": tx_id, "meterStop": 6},
3635
+ ]
3636
+ )
3637
+ await communicator.receive_json_from()
3638
+
3639
+ await communicator.disconnect()
3640
+ store.clear_log(pending_key, log_type="charger")
3641
+
2955
3642
  async def test_status_fields_updated(self):
2956
3643
  communicator = WebsocketCommunicator(application, "/STAT/")
2957
3644
  connected, _ = await communicator.connect()
@@ -3017,8 +3704,7 @@ class SimulatorAdminTests(TransactionTestCase):
3017
3704
  self.assertIsNotNone(aggregate.last_heartbeat)
3018
3705
  if previous_heartbeat:
3019
3706
  self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
3020
- if connector.last_heartbeat:
3021
- self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
3707
+ self.assertEqual(connector.last_heartbeat, aggregate.last_heartbeat)
3022
3708
 
3023
3709
  await communicator.disconnect()
3024
3710
 
@@ -3045,6 +3731,10 @@ class ChargerLocationTests(TestCase):
3045
3731
  second = Charger.objects.create(charger_id="SHARE", connector_id=2)
3046
3732
  self.assertEqual(second.location, first.location)
3047
3733
 
3734
+ def test_location_name_sanitized_when_auto_created(self):
3735
+ charger = Charger.objects.create(charger_id="Name With spaces!#1")
3736
+ self.assertEqual(charger.location.name, "Name_With_spaces_1")
3737
+
3048
3738
 
3049
3739
  class MeterReadingTests(TransactionTestCase):
3050
3740
  async def test_meter_values_saved_as_readings(self):
@@ -3814,6 +4504,43 @@ class TransactionKwTests(TestCase):
3814
4504
  self.assertEqual(tx.kw, 0.0)
3815
4505
 
3816
4506
 
4507
+ class TransactionIdentifierTests(TestCase):
4508
+ def test_vehicle_identifier_prefers_vid(self):
4509
+ charger = Charger.objects.create(charger_id="VIDPREF")
4510
+ tx = Transaction.objects.create(
4511
+ charger=charger,
4512
+ start_time=timezone.now(),
4513
+ vid="VID-123",
4514
+ vin="VIN-456",
4515
+ )
4516
+ self.assertEqual(tx.vehicle_identifier, "VID-123")
4517
+ self.assertEqual(tx.vehicle_identifier_source, "vid")
4518
+
4519
+ def test_vehicle_identifier_falls_back_to_vin(self):
4520
+ charger = Charger.objects.create(charger_id="VINONLY")
4521
+ tx = Transaction.objects.create(
4522
+ charger=charger,
4523
+ start_time=timezone.now(),
4524
+ vin="WP0ZZZ00000000001",
4525
+ )
4526
+ self.assertEqual(tx.vehicle_identifier, "WP0ZZZ00000000001")
4527
+ self.assertEqual(tx.vehicle_identifier_source, "vin")
4528
+
4529
+ def test_transaction_rfid_details_handles_vin(self):
4530
+ charger = Charger.objects.create(charger_id="VINDET")
4531
+ tx = Transaction.objects.create(
4532
+ charger=charger,
4533
+ start_time=timezone.now(),
4534
+ vin="WAUZZZ00000000002",
4535
+ )
4536
+ details = _transaction_rfid_details(tx, cache={})
4537
+ self.assertIsNotNone(details)
4538
+ assert details is not None # for type checkers
4539
+ self.assertEqual(details["value"], "WAUZZZ00000000002")
4540
+ self.assertEqual(details["display_label"], "VIN")
4541
+ self.assertEqual(details["type"], "vin")
4542
+
4543
+
3817
4544
  class DispatchActionViewTests(TestCase):
3818
4545
  def setUp(self):
3819
4546
  self.client = Client()
@@ -4112,6 +4839,122 @@ class ChargerStatusViewTests(TestCase):
4112
4839
  self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
4113
4840
  store.transactions.pop(key, None)
4114
4841
 
4842
+ def test_usage_timeline_rendered_when_chart_unavailable(self):
4843
+ original_logs = store.logs["charger"]
4844
+ store.logs["charger"] = {}
4845
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
4846
+ fixed_now = timezone.now().replace(microsecond=0)
4847
+ charger = Charger.objects.create(charger_id="TL1", connector_id=1)
4848
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
4849
+
4850
+ def build_entry(delta, status):
4851
+ timestamp = fixed_now - delta
4852
+ payload = {
4853
+ "connectorId": 1,
4854
+ "status": status,
4855
+ "timestamp": timestamp.isoformat(),
4856
+ }
4857
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4858
+ return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4859
+
4860
+ store.logs["charger"][log_key] = [
4861
+ build_entry(timedelta(days=2), "Available"),
4862
+ build_entry(timedelta(days=1), "Charging"),
4863
+ build_entry(timedelta(hours=12), "Available"),
4864
+ ]
4865
+
4866
+ data, _window = _usage_timeline(charger, [], now=fixed_now)
4867
+ self.assertEqual(len(data), 1)
4868
+ statuses = {segment["status"] for segment in data[0]["segments"]}
4869
+ self.assertIn("charging", statuses)
4870
+ self.assertIn("available", statuses)
4871
+
4872
+ with patch("ocpp.views.timezone.now", return_value=fixed_now):
4873
+ resp = self.client.get(
4874
+ reverse(
4875
+ "charger-status-connector",
4876
+ args=[charger.charger_id, charger.connector_slug],
4877
+ )
4878
+ )
4879
+
4880
+ self.assertContains(resp, "Usage (last 7 days)")
4881
+ self.assertContains(resp, "usage-timeline-segment usage-charging")
4882
+
4883
+ def test_usage_timeline_includes_multiple_connectors(self):
4884
+ original_logs = store.logs["charger"]
4885
+ store.logs["charger"] = {}
4886
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
4887
+ fixed_now = timezone.now().replace(microsecond=0)
4888
+ aggregate = Charger.objects.create(charger_id="TLAGG")
4889
+ connector_one = Charger.objects.create(charger_id="TLAGG", connector_id=1)
4890
+ connector_two = Charger.objects.create(charger_id="TLAGG", connector_id=2)
4891
+
4892
+ def build_entry(connector_id, delta, status):
4893
+ timestamp = fixed_now - delta
4894
+ payload = {
4895
+ "connectorId": connector_id,
4896
+ "status": status,
4897
+ "timestamp": timestamp.isoformat(),
4898
+ }
4899
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4900
+ key = store.identity_key(aggregate.charger_id, connector_id)
4901
+ store.logs["charger"].setdefault(key, []).append(
4902
+ f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4903
+ )
4904
+
4905
+ build_entry(1, timedelta(days=3), "Available")
4906
+ build_entry(2, timedelta(days=2), "Charging")
4907
+
4908
+ overview = [{"charger": connector_one}, {"charger": connector_two}]
4909
+ data, _window = _usage_timeline(aggregate, overview, now=fixed_now)
4910
+ self.assertEqual(len(data), 2)
4911
+ self.assertTrue(all(entry["segments"] for entry in data))
4912
+
4913
+ with patch("ocpp.views.timezone.now", return_value=fixed_now):
4914
+ resp = self.client.get(reverse("charger-status", args=[aggregate.charger_id]))
4915
+
4916
+ self.assertContains(resp, "Usage (last 7 days)")
4917
+ self.assertContains(resp, connector_one.connector_label)
4918
+ self.assertContains(resp, connector_two.connector_label)
4919
+
4920
+ def test_usage_timeline_merges_repeated_status_entries(self):
4921
+ original_logs = store.logs["charger"]
4922
+ store.logs["charger"] = {}
4923
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
4924
+ fixed_now = timezone.now().replace(microsecond=0)
4925
+ charger = Charger.objects.create(
4926
+ charger_id="TLDEDUP",
4927
+ connector_id=1,
4928
+ last_status="Available",
4929
+ )
4930
+
4931
+ def build_entry(delta, status):
4932
+ timestamp = fixed_now - delta
4933
+ payload = {
4934
+ "connectorId": 1,
4935
+ "status": status,
4936
+ "timestamp": timestamp.isoformat(),
4937
+ }
4938
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4939
+ return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4940
+
4941
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
4942
+ store.logs["charger"][log_key] = [
4943
+ build_entry(timedelta(days=6, hours=12), "Available"),
4944
+ build_entry(timedelta(days=5), "Available"),
4945
+ build_entry(timedelta(days=3, hours=6), "Charging"),
4946
+ build_entry(timedelta(days=2), "Charging"),
4947
+ build_entry(timedelta(days=1), "Available"),
4948
+ ]
4949
+
4950
+ data, window = _usage_timeline(charger, [], now=fixed_now)
4951
+ self.assertIsNotNone(window)
4952
+ self.assertEqual(len(data), 1)
4953
+ segments = data[0]["segments"]
4954
+ self.assertGreaterEqual(len(segments), 1)
4955
+ statuses = [segment["status"] for segment in segments]
4956
+ self.assertEqual(statuses, ["available", "charging", "available"])
4957
+
4115
4958
  def test_diagnostics_status_displayed(self):
4116
4959
  reported_at = timezone.now().replace(microsecond=0)
4117
4960
  charger = Charger.objects.create(
@@ -4442,6 +5285,49 @@ class LiveUpdateViewTests(TestCase):
4442
5285
  )
4443
5286
  self.assertEqual(aggregate_entry["state"], available_label)
4444
5287
 
5288
+ def test_dashboard_connector_treats_finishing_as_available_without_session(self):
5289
+ charger = Charger.objects.create(
5290
+ charger_id="FINISH-STATE",
5291
+ connector_id=1,
5292
+ last_status="Finishing",
5293
+ )
5294
+
5295
+ resp = self.client.get(reverse("ocpp-dashboard"))
5296
+ self.assertEqual(resp.status_code, 200)
5297
+ self.assertIsNotNone(resp.context)
5298
+ context = resp.context
5299
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
5300
+ entry = next(
5301
+ item
5302
+ for item in context["chargers"]
5303
+ if item["charger"].pk == charger.pk
5304
+ )
5305
+ self.assertEqual(entry["state"], available_label)
5306
+
5307
+ def test_dashboard_aggregate_treats_finishing_as_available_without_session(self):
5308
+ aggregate = Charger.objects.create(
5309
+ charger_id="FINISH-AGG",
5310
+ connector_id=None,
5311
+ last_status="Finishing",
5312
+ )
5313
+ Charger.objects.create(
5314
+ charger_id=aggregate.charger_id,
5315
+ connector_id=1,
5316
+ last_status="Finishing",
5317
+ )
5318
+
5319
+ resp = self.client.get(reverse("ocpp-dashboard"))
5320
+ self.assertEqual(resp.status_code, 200)
5321
+ self.assertIsNotNone(resp.context)
5322
+ context = resp.context
5323
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
5324
+ aggregate_entry = next(
5325
+ item
5326
+ for item in context["chargers"]
5327
+ if item["charger"].pk == aggregate.pk
5328
+ )
5329
+ self.assertEqual(aggregate_entry["state"], available_label)
5330
+
4445
5331
  def test_dashboard_aggregate_uses_connection_when_status_missing(self):
4446
5332
  aggregate = Charger.objects.create(
4447
5333
  charger_id="DASHAGG-CONN", last_status="Charging"
@@ -4466,6 +5352,59 @@ class LiveUpdateViewTests(TestCase):
4466
5352
  )
4467
5353
  self.assertEqual(aggregate_entry["state"], available_label)
4468
5354
 
5355
+ def test_dashboard_groups_connectors_under_parent(self):
5356
+ aggregate = Charger.objects.create(charger_id="GROUPED")
5357
+ first = Charger.objects.create(
5358
+ charger_id=aggregate.charger_id, connector_id=1
5359
+ )
5360
+ second = Charger.objects.create(
5361
+ charger_id=aggregate.charger_id, connector_id=2
5362
+ )
5363
+
5364
+ resp = self.client.get(reverse("ocpp-dashboard"))
5365
+ self.assertEqual(resp.status_code, 200)
5366
+ groups = resp.context["charger_groups"]
5367
+ target = next(
5368
+ group
5369
+ for group in groups
5370
+ if group.get("parent")
5371
+ and group["parent"]["charger"].pk == aggregate.pk
5372
+ )
5373
+ child_ids = [item["charger"].pk for item in target["children"]]
5374
+ self.assertEqual(child_ids, [first.pk, second.pk])
5375
+
5376
+ def test_dashboard_includes_energy_totals(self):
5377
+ aggregate = Charger.objects.create(charger_id="KWSTATS")
5378
+ now = timezone.now()
5379
+ Transaction.objects.create(
5380
+ charger=aggregate,
5381
+ start_time=now - timedelta(hours=1),
5382
+ stop_time=now,
5383
+ meter_start=0,
5384
+ meter_stop=3000,
5385
+ )
5386
+ past_start = now - timedelta(days=2)
5387
+ Transaction.objects.create(
5388
+ charger=aggregate,
5389
+ start_time=past_start,
5390
+ stop_time=past_start + timedelta(hours=1),
5391
+ meter_start=0,
5392
+ meter_stop=1000,
5393
+ )
5394
+
5395
+ resp = self.client.get(reverse("ocpp-dashboard"))
5396
+ self.assertEqual(resp.status_code, 200)
5397
+ groups = resp.context["charger_groups"]
5398
+ target = next(
5399
+ group
5400
+ for group in groups
5401
+ if group.get("parent")
5402
+ and group["parent"]["charger"].pk == aggregate.pk
5403
+ )
5404
+ stats = target["parent"]["stats"]
5405
+ self.assertAlmostEqual(stats["total_kw"], 4.0, places=2)
5406
+ self.assertAlmostEqual(stats["today_kw"], 3.0, places=2)
5407
+
4469
5408
  def test_cp_simulator_includes_interval(self):
4470
5409
  resp = self.client.get(reverse("cp-simulator"))
4471
5410
  self.assertEqual(resp.context["request"].live_update_interval, 5)