arthexis 0.1.8__py3-none-any.whl → 0.1.9__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 (81) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +133 -16
  10. config/urls.py +65 -6
  11. core/admin.py +1226 -191
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1071 -264
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +358 -63
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +7 -3
  42. core/workgroup_views.py +43 -6
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +1 -1
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.8.dist-info/RECORD +0 -80
  77. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  78. config/workgroup_app.py +0 -7
  79. core/checks.py +0 -29
  80. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  81. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
ocpp/tests.py CHANGED
@@ -1,12 +1,15 @@
1
-
1
+ from asgiref.testing import ApplicationCommunicator
2
2
  from channels.testing import WebsocketCommunicator
3
3
  from channels.db import database_sync_to_async
4
- from django.test import Client, TransactionTestCase, TestCase
4
+ from asgiref.sync import async_to_sync
5
+ from django.test import Client, TransactionTestCase, TestCase, override_settings
5
6
  from unittest import skip
6
7
  from unittest.mock import patch
7
8
  from django.contrib.auth import get_user_model
8
9
  from django.urls import reverse
9
10
  from django.utils import timezone
11
+ from django.utils.dateparse import parse_datetime
12
+ from django.utils.translation import override, gettext as _
10
13
  from django.contrib.sites.models import Site
11
14
  from pages.models import Application, Module
12
15
  from nodes.models import Node, NodeRole
@@ -25,13 +28,78 @@ import asyncio
25
28
  from pathlib import Path
26
29
  from .simulator import SimulatorConfig, ChargePointSimulator
27
30
  import re
28
- from datetime import timedelta
31
+ from datetime import datetime, timedelta
29
32
  from .tasks import purge_meter_readings
33
+ from django.db import close_old_connections
34
+ from django.db.utils import OperationalError
35
+ from urllib.parse import unquote, urlparse
36
+
37
+
38
+ class ClientWebsocketCommunicator(WebsocketCommunicator):
39
+ """WebsocketCommunicator that injects a client address into the scope."""
40
+
41
+ def __init__(
42
+ self,
43
+ application,
44
+ path,
45
+ *,
46
+ client=None,
47
+ headers=None,
48
+ subprotocols=None,
49
+ spec_version=None,
50
+ ):
51
+ if not isinstance(path, str):
52
+ raise TypeError(f"Expected str, got {type(path)}")
53
+ parsed = urlparse(path)
54
+ scope = {
55
+ "type": "websocket",
56
+ "path": unquote(parsed.path),
57
+ "query_string": parsed.query.encode("utf-8"),
58
+ "headers": headers or [],
59
+ "subprotocols": subprotocols or [],
60
+ }
61
+ if client is not None:
62
+ scope["client"] = client
63
+ if spec_version:
64
+ scope["spec_version"] = spec_version
65
+ self.scope = scope
66
+ ApplicationCommunicator.__init__(self, application, self.scope)
67
+ self.response_headers = None
68
+
69
+
70
+ class DummyWebSocket:
71
+ """Simple websocket stub that records payloads sent by the view."""
30
72
 
73
+ def __init__(self):
74
+ self.sent: list[str] = []
75
+
76
+ async def send(self, message):
77
+ self.sent.append(message)
31
78
 
32
79
 
33
80
  class ChargerFixtureTests(TestCase):
34
- fixtures = ["initial_data.json"]
81
+ fixtures = [
82
+ p.name
83
+ for p in (Path(__file__).resolve().parent / "fixtures").glob(
84
+ "initial_data__*.json"
85
+ )
86
+ ]
87
+
88
+ @classmethod
89
+ def setUpTestData(cls):
90
+ location = Location.objects.create(name="Simulator")
91
+ Charger.objects.create(
92
+ charger_id="CP1",
93
+ connector_id=1,
94
+ location=location,
95
+ require_rfid=False,
96
+ )
97
+ Charger.objects.create(
98
+ charger_id="CP2",
99
+ connector_id=2,
100
+ location=location,
101
+ require_rfid=True,
102
+ )
35
103
 
36
104
  def test_cp2_requires_rfid(self):
37
105
  cp2 = Charger.objects.get(charger_id="CP2")
@@ -44,12 +112,26 @@ class ChargerFixtureTests(TestCase):
44
112
  def test_charger_connector_ids(self):
45
113
  cp1 = Charger.objects.get(charger_id="CP1")
46
114
  cp2 = Charger.objects.get(charger_id="CP2")
47
- self.assertEqual(cp1.connector_id, "1")
48
- self.assertEqual(cp2.connector_id, "2")
115
+ self.assertEqual(cp1.connector_id, 1)
116
+ self.assertEqual(cp2.connector_id, 2)
49
117
  self.assertEqual(cp1.name, "Simulator #1")
50
118
  self.assertEqual(cp2.name, "Simulator #2")
51
119
 
52
120
 
121
+ class ChargerUrlFallbackTests(TestCase):
122
+ @override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
123
+ def test_reference_created_when_site_missing(self):
124
+ Site.objects.all().delete()
125
+ Site.objects.clear_cache()
126
+
127
+ charger = Charger.objects.create(charger_id="NO_SITE")
128
+ charger.refresh_from_db()
129
+
130
+ self.assertIsNotNone(charger.reference)
131
+ self.assertTrue(charger.reference.value.startswith("http://fallback.example"))
132
+ self.assertTrue(charger.reference.value.endswith("/c/NO_SITE/"))
133
+
134
+
53
135
  class SinkConsumerTests(TransactionTestCase):
54
136
  async def test_sink_replies(self):
55
137
  communicator = WebsocketCommunicator(application, "/ws/sink/")
@@ -64,17 +146,40 @@ class SinkConsumerTests(TransactionTestCase):
64
146
 
65
147
 
66
148
  class CSMSConsumerTests(TransactionTestCase):
149
+ async def _retry_db(self, func, attempts: int = 5, delay: float = 0.1):
150
+ """Run a database function, retrying if the database is locked."""
151
+ for _ in range(attempts):
152
+ try:
153
+ return await database_sync_to_async(func)()
154
+ except OperationalError:
155
+ await database_sync_to_async(close_old_connections)()
156
+ await asyncio.sleep(delay)
157
+ raise
158
+
159
+ async def _send_status_notification(self, serial: str, payload: dict):
160
+ communicator = WebsocketCommunicator(application, f"/{serial}/")
161
+ connected, _ = await communicator.connect()
162
+ self.assertTrue(connected)
163
+
164
+ await communicator.send_json_to([2, "1", "StatusNotification", payload])
165
+ response = await communicator.receive_json_from()
166
+ self.assertEqual(response, [3, "1", {}])
167
+
168
+ await communicator.disconnect()
169
+
67
170
  async def test_transaction_saved(self):
68
171
  communicator = WebsocketCommunicator(application, "/TEST/")
69
172
  connected, _ = await communicator.connect()
70
173
  self.assertTrue(connected)
71
174
 
72
- await communicator.send_json_to([
73
- 2,
74
- "1",
75
- "StartTransaction",
76
- {"meterStart": 10},
77
- ])
175
+ await communicator.send_json_to(
176
+ [
177
+ 2,
178
+ "1",
179
+ "StartTransaction",
180
+ {"meterStart": 10, "connectorId": 3},
181
+ ]
182
+ )
78
183
  response = await communicator.receive_json_from()
79
184
  tx_id = response[2]["transactionId"]
80
185
 
@@ -82,14 +187,17 @@ class CSMSConsumerTests(TransactionTestCase):
82
187
  pk=tx_id, charger__charger_id="TEST"
83
188
  )
84
189
  self.assertEqual(tx.meter_start, 10)
190
+ self.assertEqual(tx.connector_id, 3)
85
191
  self.assertIsNone(tx.stop_time)
86
192
 
87
- await communicator.send_json_to([
88
- 2,
89
- "2",
90
- "StopTransaction",
91
- {"transactionId": tx_id, "meterStop": 20},
92
- ])
193
+ await communicator.send_json_to(
194
+ [
195
+ 2,
196
+ "2",
197
+ "StopTransaction",
198
+ {"transactionId": tx_id, "meterStop": 20},
199
+ ]
200
+ )
93
201
  await communicator.receive_json_from()
94
202
 
95
203
  await database_sync_to_async(tx.refresh_from_db)()
@@ -117,6 +225,214 @@ class CSMSConsumerTests(TransactionTestCase):
117
225
 
118
226
  await communicator.disconnect()
119
227
 
228
+ async def test_start_transaction_sends_net_message(self):
229
+ location = await database_sync_to_async(Location.objects.create)(
230
+ name="Test Location"
231
+ )
232
+ await database_sync_to_async(Charger.objects.create)(
233
+ charger_id="NETMSG", location=location
234
+ )
235
+ communicator = WebsocketCommunicator(application, "/NETMSG/")
236
+ connected, _ = await communicator.connect()
237
+ self.assertTrue(connected)
238
+
239
+ with patch("nodes.models.NetMessage.broadcast") as mock_broadcast:
240
+ await communicator.send_json_to(
241
+ [
242
+ 2,
243
+ "1",
244
+ "StartTransaction",
245
+ {"meterStart": 1, "connectorId": 1},
246
+ ]
247
+ )
248
+ await communicator.receive_json_from()
249
+
250
+ await communicator.disconnect()
251
+
252
+ mock_broadcast.assert_called_once()
253
+ _, kwargs = mock_broadcast.call_args
254
+ self.assertEqual(kwargs["subject"], "charging-started")
255
+ payload = json.loads(kwargs["body"])
256
+ self.assertEqual(payload["location"], "Test Location")
257
+ self.assertEqual(payload["sn"], "NETMSG")
258
+ self.assertEqual(payload["cid"], "1")
259
+
260
+ async def test_rfid_unbound_instance_created(self):
261
+ await database_sync_to_async(Charger.objects.create)(charger_id="NEWRFID")
262
+ communicator = WebsocketCommunicator(application, "/NEWRFID/")
263
+ connected, _ = await communicator.connect()
264
+ self.assertTrue(connected)
265
+
266
+ await communicator.send_json_to(
267
+ [2, "1", "StartTransaction", {"meterStart": 1, "idTag": "TAG456"}]
268
+ )
269
+ await communicator.receive_json_from()
270
+
271
+ tag = await database_sync_to_async(RFID.objects.get)(rfid="TAG456")
272
+ count = await database_sync_to_async(tag.energy_accounts.count)()
273
+ self.assertEqual(count, 0)
274
+
275
+ await communicator.disconnect()
276
+
277
+ async def test_firmware_status_notification_updates_database_and_views(self):
278
+ communicator = WebsocketCommunicator(application, "/FWSTAT/")
279
+ connected, _ = await communicator.connect()
280
+ self.assertTrue(connected)
281
+
282
+ ts = timezone.now().replace(microsecond=0)
283
+ payload = {
284
+ "status": "Installing",
285
+ "statusInfo": "Applying patch",
286
+ "timestamp": ts.isoformat(),
287
+ }
288
+
289
+ await communicator.send_json_to(
290
+ [2, "1", "FirmwareStatusNotification", payload]
291
+ )
292
+ response = await communicator.receive_json_from()
293
+ self.assertEqual(response, [3, "1", {}])
294
+
295
+ def _fetch_status():
296
+ charger = Charger.objects.get(charger_id="FWSTAT", connector_id=None)
297
+ return (
298
+ charger.firmware_status,
299
+ charger.firmware_status_info,
300
+ charger.firmware_timestamp,
301
+ )
302
+
303
+ status, info, recorded_ts = await database_sync_to_async(_fetch_status)()
304
+ self.assertEqual(status, "Installing")
305
+ self.assertEqual(info, "Applying patch")
306
+ self.assertIsNotNone(recorded_ts)
307
+ self.assertEqual(recorded_ts.replace(microsecond=0), ts)
308
+
309
+ log_entries = store.get_logs(store.identity_key("FWSTAT", None), log_type="charger")
310
+ self.assertTrue(
311
+ any("FirmwareStatusNotification" in entry for entry in log_entries)
312
+ )
313
+
314
+ def _fetch_views():
315
+ User = get_user_model()
316
+ user = User.objects.create_user(username="fwstatus", password="pw")
317
+ client = Client()
318
+ client.force_login(user)
319
+ detail = client.get(reverse("charger-detail", args=["FWSTAT"]))
320
+ status_page = client.get(reverse("charger-status", args=["FWSTAT"]))
321
+ list_response = client.get(reverse("charger-list"))
322
+ return (
323
+ detail.status_code,
324
+ json.loads(detail.content.decode()),
325
+ status_page.status_code,
326
+ status_page.content.decode(),
327
+ list_response.status_code,
328
+ json.loads(list_response.content.decode()),
329
+ )
330
+
331
+ (
332
+ detail_code,
333
+ detail_payload,
334
+ status_code,
335
+ html,
336
+ list_code,
337
+ list_payload,
338
+ ) = await database_sync_to_async(_fetch_views)()
339
+ self.assertEqual(detail_code, 200)
340
+ self.assertEqual(status_code, 200)
341
+ self.assertEqual(list_code, 200)
342
+ self.assertEqual(detail_payload["firmwareStatus"], "Installing")
343
+ self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
344
+ self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
345
+ self.assertIn('id="firmware-status">Installing<', html)
346
+ self.assertIn('id="firmware-status-info">Applying patch<', html)
347
+ match = re.search(
348
+ r'id="firmware-timestamp"[^>]*data-iso="([^"]+)"', html
349
+ )
350
+ self.assertIsNotNone(match)
351
+ parsed_iso = datetime.fromisoformat(match.group(1))
352
+ self.assertAlmostEqual(parsed_iso.timestamp(), ts.timestamp(), places=3)
353
+
354
+ matching = [
355
+ item
356
+ for item in list_payload.get("chargers", [])
357
+ if item["charger_id"] == "FWSTAT" and item["connector_id"] is None
358
+ ]
359
+ self.assertTrue(matching)
360
+ self.assertEqual(matching[0]["firmwareStatus"], "Installing")
361
+ self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
362
+ list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
363
+ self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)
364
+
365
+ store.clear_log(store.identity_key("FWSTAT", None), log_type="charger")
366
+
367
+ await communicator.disconnect()
368
+
369
+ async def test_firmware_status_notification_updates_connector_and_aggregate(
370
+ self,
371
+ ):
372
+ communicator = WebsocketCommunicator(application, "/FWCONN/")
373
+ connected, _ = await communicator.connect()
374
+ self.assertTrue(connected)
375
+
376
+ await communicator.send_json_to(
377
+ [
378
+ 2,
379
+ "1",
380
+ "FirmwareStatusNotification",
381
+ {"connectorId": 2, "status": "Downloaded"},
382
+ ]
383
+ )
384
+ response = await communicator.receive_json_from()
385
+ self.assertEqual(response, [3, "1", {}])
386
+
387
+ def _fetch_chargers():
388
+ aggregate = Charger.objects.get(charger_id="FWCONN", connector_id=None)
389
+ connector = Charger.objects.get(charger_id="FWCONN", connector_id=2)
390
+ return (
391
+ aggregate.firmware_status,
392
+ aggregate.firmware_status_info,
393
+ aggregate.firmware_timestamp,
394
+ connector.firmware_status,
395
+ connector.firmware_status_info,
396
+ connector.firmware_timestamp,
397
+ )
398
+
399
+ (
400
+ aggregate_status,
401
+ aggregate_info,
402
+ aggregate_ts,
403
+ connector_status,
404
+ connector_info,
405
+ connector_ts,
406
+ ) = await database_sync_to_async(_fetch_chargers)()
407
+
408
+ self.assertEqual(aggregate_status, "Downloaded")
409
+ self.assertEqual(connector_status, "Downloaded")
410
+ self.assertEqual(aggregate_info, "")
411
+ self.assertEqual(connector_info, "")
412
+ self.assertIsNotNone(aggregate_ts)
413
+ self.assertIsNotNone(connector_ts)
414
+ self.assertAlmostEqual(
415
+ (connector_ts - aggregate_ts).total_seconds(), 0, delta=1.0
416
+ )
417
+
418
+ log_entries = store.get_logs(
419
+ store.identity_key("FWCONN", 2), log_type="charger"
420
+ )
421
+ self.assertTrue(
422
+ any("FirmwareStatusNotification" in entry for entry in log_entries)
423
+ )
424
+ log_entries_agg = store.get_logs(
425
+ store.identity_key("FWCONN", None), log_type="charger"
426
+ )
427
+ self.assertTrue(
428
+ any("FirmwareStatusNotification" in entry for entry in log_entries_agg)
429
+ )
430
+
431
+ store.clear_log(store.identity_key("FWCONN", 2), log_type="charger")
432
+ store.clear_log(store.identity_key("FWCONN", None), log_type="charger")
433
+
434
+ await communicator.disconnect()
435
+
120
436
  async def test_vin_recorded(self):
121
437
  await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
122
438
  communicator = WebsocketCommunicator(application, "/VINREC/")
@@ -153,10 +469,65 @@ class CSMSConsumerTests(TransactionTestCase):
153
469
  await communicator.send_json_to([2, "1", "MeterValues", payload])
154
470
  await communicator.receive_json_from()
155
471
 
156
- charger = await database_sync_to_async(Charger.objects.get)(charger_id="NEWCID")
157
- self.assertEqual(charger.connector_id, "7")
472
+ charger = await database_sync_to_async(Charger.objects.get)(
473
+ charger_id="NEWCID", connector_id=7
474
+ )
475
+ self.assertEqual(charger.connector_id, 7)
476
+
477
+ await communicator.disconnect()
158
478
 
479
+ async def test_new_charger_created_for_different_connector(self):
480
+ communicator = WebsocketCommunicator(application, "/DUPC/")
481
+ connected, _ = await communicator.connect()
482
+ self.assertTrue(connected)
483
+
484
+ payload1 = {
485
+ "connectorId": 1,
486
+ "meterValue": [
487
+ {
488
+ "timestamp": timezone.now().isoformat(),
489
+ "sampledValue": [{"value": "1"}],
490
+ }
491
+ ],
492
+ }
493
+ await communicator.send_json_to([2, "1", "MeterValues", payload1])
494
+ await communicator.receive_json_from()
159
495
  await communicator.disconnect()
496
+ await communicator.wait()
497
+ await database_sync_to_async(close_old_connections)()
498
+
499
+ communicator = WebsocketCommunicator(application, "/DUPC/")
500
+ connected, _ = await communicator.connect()
501
+ self.assertTrue(connected)
502
+ payload2 = {
503
+ "connectorId": 2,
504
+ "meterValue": [
505
+ {
506
+ "timestamp": timezone.now().isoformat(),
507
+ "sampledValue": [{"value": "1"}],
508
+ }
509
+ ],
510
+ }
511
+ await communicator.send_json_to([2, "1", "MeterValues", payload2])
512
+ await communicator.receive_json_from()
513
+ await communicator.disconnect()
514
+ await communicator.wait()
515
+ await database_sync_to_async(close_old_connections)()
516
+
517
+ count = await self._retry_db(
518
+ lambda: Charger.objects.filter(charger_id="DUPC").count()
519
+ )
520
+ self.assertEqual(count, 3)
521
+ connectors = await self._retry_db(
522
+ lambda: list(
523
+ Charger.objects.filter(charger_id="DUPC").values_list(
524
+ "connector_id", flat=True
525
+ )
526
+ )
527
+ )
528
+ self.assertIn(1, connectors)
529
+ self.assertIn(2, connectors)
530
+ self.assertIn(None, connectors)
160
531
 
161
532
  async def test_transaction_created_from_meter_values(self):
162
533
  communicator = WebsocketCommunicator(application, "/NOSTART/")
@@ -208,6 +579,58 @@ class CSMSConsumerTests(TransactionTestCase):
208
579
 
209
580
  await communicator.disconnect()
210
581
 
582
+ async def test_diagnostics_status_notification_updates_records(self):
583
+ communicator = WebsocketCommunicator(application, "/DIAGCP/")
584
+ connected, _ = await communicator.connect()
585
+ self.assertTrue(connected)
586
+
587
+ reported_at = timezone.now().replace(microsecond=0)
588
+ payload = {
589
+ "status": "Uploaded",
590
+ "connectorId": 5,
591
+ "uploadLocation": "https://example.com/diag.tar",
592
+ "timestamp": reported_at.isoformat(),
593
+ }
594
+
595
+ await communicator.send_json_to(
596
+ [2, "1", "DiagnosticsStatusNotification", payload]
597
+ )
598
+ response = await communicator.receive_json_from()
599
+ self.assertEqual(response[0], 3)
600
+ self.assertEqual(response[2], {})
601
+
602
+ def _fetch():
603
+ aggregate = Charger.objects.get(charger_id="DIAGCP", connector_id=None)
604
+ connector = Charger.objects.get(charger_id="DIAGCP", connector_id=5)
605
+ return aggregate, connector
606
+
607
+ aggregate, connector = await database_sync_to_async(_fetch)()
608
+ self.assertEqual(aggregate.diagnostics_status, "Uploaded")
609
+ self.assertEqual(connector.diagnostics_status, "Uploaded")
610
+ self.assertEqual(
611
+ aggregate.diagnostics_location, "https://example.com/diag.tar"
612
+ )
613
+ self.assertEqual(
614
+ connector.diagnostics_location, "https://example.com/diag.tar"
615
+ )
616
+ self.assertEqual(aggregate.diagnostics_timestamp, reported_at)
617
+ self.assertEqual(connector.diagnostics_timestamp, reported_at)
618
+
619
+ connector_logs = store.get_logs(
620
+ store.identity_key("DIAGCP", 5), log_type="charger"
621
+ )
622
+ aggregate_logs = store.get_logs(
623
+ store.identity_key("DIAGCP", None), log_type="charger"
624
+ )
625
+ self.assertTrue(
626
+ any("DiagnosticsStatusNotification" in entry for entry in connector_logs)
627
+ )
628
+ self.assertTrue(
629
+ any("DiagnosticsStatusNotification" in entry for entry in aggregate_logs)
630
+ )
631
+
632
+ await communicator.disconnect()
633
+
211
634
  async def test_temperature_recorded(self):
212
635
  charger = await database_sync_to_async(Charger.objects.create)(
213
636
  charger_id="TEMP1"
@@ -245,6 +668,106 @@ class CSMSConsumerTests(TransactionTestCase):
245
668
 
246
669
  await communicator.disconnect()
247
670
 
671
+ def test_status_notification_updates_models_and_views(self):
672
+ serial = "STATUS-CP"
673
+ payload = {
674
+ "connectorId": 1,
675
+ "status": "Faulted",
676
+ "errorCode": "GroundFailure",
677
+ "info": "Relay malfunction",
678
+ "vendorId": "ACME",
679
+ "timestamp": "2024-01-01T12:34:56Z",
680
+ }
681
+
682
+ async_to_sync(self._send_status_notification)(serial, payload)
683
+
684
+ expected_ts = parse_datetime(payload["timestamp"])
685
+ aggregate = Charger.objects.get(charger_id=serial, connector_id=None)
686
+ connector = Charger.objects.get(charger_id=serial, connector_id=1)
687
+
688
+ vendor_data = {"info": payload["info"], "vendorId": payload["vendorId"]}
689
+ self.assertEqual(aggregate.last_status, payload["status"])
690
+ self.assertEqual(aggregate.last_error_code, payload["errorCode"])
691
+ self.assertEqual(aggregate.last_status_vendor_info, vendor_data)
692
+ self.assertEqual(aggregate.last_status_timestamp, expected_ts)
693
+ self.assertEqual(connector.last_status, payload["status"])
694
+ self.assertEqual(connector.last_error_code, payload["errorCode"])
695
+ self.assertEqual(connector.last_status_vendor_info, vendor_data)
696
+ self.assertEqual(connector.last_status_timestamp, expected_ts)
697
+
698
+ connector_log = store.get_logs(
699
+ store.identity_key(serial, 1), log_type="charger"
700
+ )
701
+ self.assertTrue(
702
+ any("StatusNotification processed" in entry for entry in connector_log)
703
+ )
704
+
705
+ user = get_user_model().objects.create_user(
706
+ username="status", email="status@example.com", password="pwd"
707
+ )
708
+ self.client.force_login(user)
709
+
710
+ list_response = self.client.get(reverse("charger-list"))
711
+ self.assertEqual(list_response.status_code, 200)
712
+ chargers = list_response.json()["chargers"]
713
+ aggregate_entry = next(
714
+ item
715
+ for item in chargers
716
+ if item["charger_id"] == serial and item["connector_id"] is None
717
+ )
718
+ connector_entry = next(
719
+ item
720
+ for item in chargers
721
+ if item["charger_id"] == serial and item["connector_id"] == 1
722
+ )
723
+ expected_iso = expected_ts.isoformat()
724
+ self.assertEqual(aggregate_entry["lastStatus"], payload["status"])
725
+ self.assertEqual(aggregate_entry["lastErrorCode"], payload["errorCode"])
726
+ self.assertEqual(aggregate_entry["lastStatusVendorInfo"], vendor_data)
727
+ self.assertEqual(aggregate_entry["lastStatusTimestamp"], expected_iso)
728
+ self.assertEqual(aggregate_entry["status"], "Faulted (GroundFailure)")
729
+ self.assertEqual(aggregate_entry["statusColor"], "#dc3545")
730
+ self.assertEqual(connector_entry["lastStatus"], payload["status"])
731
+ self.assertEqual(connector_entry["lastErrorCode"], payload["errorCode"])
732
+ self.assertEqual(connector_entry["lastStatusVendorInfo"], vendor_data)
733
+ self.assertEqual(connector_entry["lastStatusTimestamp"], expected_iso)
734
+ self.assertEqual(connector_entry["status"], "Faulted (GroundFailure)")
735
+ self.assertEqual(connector_entry["statusColor"], "#dc3545")
736
+
737
+ detail_response = self.client.get(
738
+ reverse("charger-detail-connector", args=[serial, 1])
739
+ )
740
+ self.assertEqual(detail_response.status_code, 200)
741
+ detail_payload = detail_response.json()
742
+ self.assertEqual(detail_payload["lastStatus"], payload["status"])
743
+ self.assertEqual(detail_payload["lastErrorCode"], payload["errorCode"])
744
+ self.assertEqual(detail_payload["lastStatusVendorInfo"], vendor_data)
745
+ self.assertEqual(detail_payload["lastStatusTimestamp"], expected_iso)
746
+ self.assertEqual(detail_payload["status"], "Faulted (GroundFailure)")
747
+ self.assertEqual(detail_payload["statusColor"], "#dc3545")
748
+
749
+ status_resp = self.client.get(
750
+ reverse("charger-status-connector", args=[serial, "1"])
751
+ )
752
+ self.assertContains(status_resp, "Faulted (GroundFailure)")
753
+ self.assertContains(status_resp, "Error code: GroundFailure")
754
+ self.assertContains(status_resp, "Vendor: ACME")
755
+ self.assertContains(status_resp, "Info: Relay malfunction")
756
+ self.assertContains(status_resp, "background-color: #dc3545")
757
+
758
+ aggregate_status = self.client.get(reverse("charger-status", args=[serial]))
759
+ self.assertContains(aggregate_status, "Reported status")
760
+ self.assertContains(aggregate_status, "Info: Relay malfunction")
761
+
762
+ page_resp = self.client.get(reverse("charger-page", args=[serial]))
763
+ self.assertContains(page_resp, "Faulted (GroundFailure)")
764
+ self.assertContains(page_resp, "Vendor")
765
+ self.assertContains(page_resp, "Relay malfunction")
766
+ self.assertContains(page_resp, "background-color: #dc3545")
767
+
768
+ store.clear_log(store.identity_key(serial, 1), log_type="charger")
769
+ store.clear_log(store.identity_key(serial, None), log_type="charger")
770
+
248
771
  async def test_message_logged_and_session_file_created(self):
249
772
  cid = "LOGTEST1"
250
773
  log_path = Path("logs") / f"charger.{cid}.log"
@@ -258,21 +781,25 @@ class CSMSConsumerTests(TransactionTestCase):
258
781
  connected, _ = await communicator.connect()
259
782
  self.assertTrue(connected)
260
783
 
261
- await communicator.send_json_to([
262
- 2,
263
- "1",
264
- "StartTransaction",
265
- {"meterStart": 1},
266
- ])
784
+ await communicator.send_json_to(
785
+ [
786
+ 2,
787
+ "1",
788
+ "StartTransaction",
789
+ {"meterStart": 1},
790
+ ]
791
+ )
267
792
  response = await communicator.receive_json_from()
268
793
  tx_id = response[2]["transactionId"]
269
794
 
270
- await communicator.send_json_to([
271
- 2,
272
- "2",
273
- "StopTransaction",
274
- {"transactionId": tx_id, "meterStop": 2},
275
- ])
795
+ await communicator.send_json_to(
796
+ [
797
+ 2,
798
+ "2",
799
+ "StopTransaction",
800
+ {"transactionId": tx_id, "meterStop": 2},
801
+ ]
802
+ )
276
803
  await communicator.receive_json_from()
277
804
  await communicator.disconnect()
278
805
 
@@ -313,12 +840,14 @@ class CSMSConsumerTests(TransactionTestCase):
313
840
  connected, _ = await communicator.connect()
314
841
  self.assertTrue(connected)
315
842
 
316
- await communicator.send_json_to([
317
- 2,
318
- "1",
319
- "StartTransaction",
320
- {"meterStart": 5},
321
- ])
843
+ await communicator.send_json_to(
844
+ [
845
+ 2,
846
+ "1",
847
+ "StartTransaction",
848
+ {"meterStart": 5},
849
+ ]
850
+ )
322
851
  await communicator.receive_json_from()
323
852
 
324
853
  await communicator.disconnect()
@@ -333,7 +862,8 @@ class CSMSConsumerTests(TransactionTestCase):
333
862
  communicator1 = WebsocketCommunicator(application, "/DUPLICATE/")
334
863
  connected, _ = await communicator1.connect()
335
864
  self.assertTrue(connected)
336
- first_consumer = store.connections.get("DUPLICATE")
865
+ pending_key = store.pending_key("DUPLICATE")
866
+ first_consumer = store.connections.get(pending_key)
337
867
 
338
868
  communicator2 = WebsocketCommunicator(application, "/DUPLICATE/")
339
869
  connected2, _ = await communicator2.connect()
@@ -341,9 +871,47 @@ class CSMSConsumerTests(TransactionTestCase):
341
871
 
342
872
  # The first communicator should be closed when the second connects.
343
873
  await communicator1.wait()
344
- self.assertIsNot(store.connections.get("DUPLICATE"), first_consumer)
874
+ self.assertIsNot(store.connections.get(pending_key), first_consumer)
875
+
876
+ await communicator2.disconnect()
877
+
878
+ async def test_connectors_share_serial_without_disconnecting(self):
879
+ communicator1 = WebsocketCommunicator(application, "/MULTI/")
880
+ connected1, _ = await communicator1.connect()
881
+ self.assertTrue(connected1)
882
+ await communicator1.send_json_to(
883
+ [
884
+ 2,
885
+ "1",
886
+ "StartTransaction",
887
+ {"connectorId": 1, "meterStart": 10},
888
+ ]
889
+ )
890
+ await communicator1.receive_json_from()
891
+
892
+ communicator2 = WebsocketCommunicator(application, "/MULTI/")
893
+ connected2, _ = await communicator2.connect()
894
+ self.assertTrue(connected2)
895
+ await communicator2.send_json_to(
896
+ [
897
+ 2,
898
+ "2",
899
+ "StartTransaction",
900
+ {"connectorId": 2, "meterStart": 10},
901
+ ]
902
+ )
903
+ await communicator2.receive_json_from()
904
+
905
+ key1 = store.identity_key("MULTI", 1)
906
+ key2 = store.identity_key("MULTI", 2)
907
+ self.assertIn(key1, store.connections)
908
+ self.assertIn(key2, store.connections)
909
+ self.assertIsNot(store.connections[key1], store.connections[key2])
345
910
 
911
+ await communicator1.disconnect()
346
912
  await communicator2.disconnect()
913
+ store.transactions.pop(key1, None)
914
+ store.transactions.pop(key2, None)
347
915
 
348
916
 
349
917
  class ChargerLandingTests(TestCase):
@@ -359,10 +927,17 @@ class ChargerLandingTests(TestCase):
359
927
 
360
928
  response = self.client.get(reverse("charger-page", args=["PAGE1"]))
361
929
  self.assertEqual(response.status_code, 200)
362
- self.assertContains(
363
- response,
364
- "Plug in your vehicle and slide your RFID card over the reader to begin charging.",
365
- )
930
+ self.assertEqual(response.context["LANGUAGE_CODE"], "es")
931
+ with override("es"):
932
+ self.assertContains(
933
+ response,
934
+ _(
935
+ "Plug in your vehicle and slide your RFID card over the reader to begin charging."
936
+ ),
937
+ )
938
+ self.assertContains(response, _("Advanced View"))
939
+ status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
940
+ self.assertContains(response, status_url)
366
941
 
367
942
  def test_status_page_renders(self):
368
943
  charger = Charger.objects.create(charger_id="PAGE2")
@@ -377,10 +952,23 @@ class ChargerLandingTests(TestCase):
377
952
  meter_start=1000,
378
953
  start_time=timezone.now(),
379
954
  )
380
- store.transactions[charger.charger_id] = tx
955
+ key = store.identity_key(charger.charger_id, charger.connector_id)
956
+ store.transactions[key] = tx
381
957
  resp = self.client.get(reverse("charger-page", args=["STATS"]))
382
- self.assertContains(resp, "progress")
383
- store.transactions.pop(charger.charger_id, None)
958
+ self.assertContains(resp, "progress-bar")
959
+ store.transactions.pop(key, None)
960
+
961
+ def test_display_name_used_on_public_pages(self):
962
+ charger = Charger.objects.create(
963
+ charger_id="NAMED",
964
+ display_name="Entrada",
965
+ )
966
+ landing = self.client.get(reverse("charger-page", args=["NAMED"]))
967
+ self.assertContains(landing, "Entrada")
968
+ status = self.client.get(
969
+ reverse("charger-status-connector", args=["NAMED", "all"])
970
+ )
971
+ self.assertContains(status, "Entrada")
384
972
 
385
973
  def test_total_includes_ongoing_transaction(self):
386
974
  charger = Charger.objects.create(charger_id="ONGOING")
@@ -389,7 +977,8 @@ class ChargerLandingTests(TestCase):
389
977
  meter_start=1000,
390
978
  start_time=timezone.now(),
391
979
  )
392
- store.transactions[charger.charger_id] = tx
980
+ key = store.identity_key(charger.charger_id, charger.connector_id)
981
+ store.transactions[key] = tx
393
982
  MeterReading.objects.create(
394
983
  charger=charger,
395
984
  transaction=tx,
@@ -399,10 +988,29 @@ class ChargerLandingTests(TestCase):
399
988
  unit="W",
400
989
  )
401
990
  resp = self.client.get(reverse("charger-status", args=["ONGOING"]))
402
- self.assertContains(
403
- resp, 'Total Energy: <span id="total-kw">1.50</span> kW'
991
+ self.assertContains(resp, 'Total Energy: <span id="total-kw">1.50</span> kW')
992
+ store.transactions.pop(key, None)
993
+
994
+ def test_connector_specific_routes_render(self):
995
+ Charger.objects.create(charger_id="ROUTED")
996
+ connector = Charger.objects.create(charger_id="ROUTED", connector_id=1)
997
+ page = self.client.get(reverse("charger-page-connector", args=["ROUTED", "1"]))
998
+ self.assertEqual(page.status_code, 200)
999
+ status = self.client.get(
1000
+ reverse("charger-status-connector", args=["ROUTED", "1"])
1001
+ )
1002
+ self.assertEqual(status.status_code, 200)
1003
+ search = self.client.get(
1004
+ reverse("charger-session-search-connector", args=["ROUTED", "1"])
404
1005
  )
405
- store.transactions.pop(charger.charger_id, None)
1006
+ self.assertEqual(search.status_code, 200)
1007
+ log_id = store.identity_key("ROUTED", connector.connector_id)
1008
+ store.add_log(log_id, "entry", log_type="charger")
1009
+ log = self.client.get(
1010
+ reverse("charger-log-connector", args=["ROUTED", "1"]) + "?type=charger"
1011
+ )
1012
+ self.assertContains(log, "entry")
1013
+ store.clear_log(log_id, log_type="charger")
406
1014
 
407
1015
  def test_temperature_displayed(self):
408
1016
  charger = Charger.objects.create(
@@ -413,20 +1021,22 @@ class ChargerLandingTests(TestCase):
413
1021
  self.assertContains(resp, "21.5")
414
1022
 
415
1023
  def test_log_page_renders_without_charger(self):
416
- store.add_log("LOG1", "hello", log_type="charger")
417
- entry = store.get_logs("LOG1", log_type="charger")[0]
1024
+ log_id = store.identity_key("LOG1", None)
1025
+ store.add_log(log_id, "hello", log_type="charger")
1026
+ entry = store.get_logs(log_id, log_type="charger")[0]
418
1027
  self.assertRegex(entry, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} hello$")
419
1028
  resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
420
1029
  self.assertEqual(resp.status_code, 200)
421
1030
  self.assertContains(resp, "hello")
422
- store.clear_log("LOG1", log_type="charger")
1031
+ store.clear_log(log_id, log_type="charger")
423
1032
 
424
1033
  def test_log_page_is_case_insensitive(self):
425
- store.add_log("cp2", "entry", log_type="charger")
1034
+ log_id = store.identity_key("cp2", None)
1035
+ store.add_log(log_id, "entry", log_type="charger")
426
1036
  resp = self.client.get(reverse("charger-log", args=["CP2"]) + "?type=charger")
427
1037
  self.assertEqual(resp.status_code, 200)
428
1038
  self.assertContains(resp, "entry")
429
- store.clear_log("cp2", log_type="charger")
1039
+ store.clear_log(log_id, log_type="charger")
430
1040
 
431
1041
 
432
1042
  class SimulatorLandingTests(TestCase):
@@ -471,7 +1081,7 @@ class ChargerAdminTests(TestCase):
471
1081
  url = reverse("admin:ocpp_charger_changelist")
472
1082
  resp = self.client.get(url)
473
1083
  self.assertContains(resp, charger.get_absolute_url())
474
- status_url = reverse("charger-status", args=["ADMIN1"])
1084
+ status_url = reverse("charger-status-connector", args=["ADMIN1", "all"])
475
1085
  self.assertContains(resp, status_url)
476
1086
 
477
1087
  def test_admin_does_not_list_qr_link(self):
@@ -484,9 +1094,19 @@ class ChargerAdminTests(TestCase):
484
1094
  charger = Charger.objects.create(charger_id="LOG1")
485
1095
  url = reverse("admin:ocpp_charger_changelist")
486
1096
  resp = self.client.get(url)
487
- log_url = reverse("charger-log", args=["LOG1"]) + "?type=charger"
1097
+ log_url = reverse("admin:ocpp_charger_log", args=[charger.pk])
488
1098
  self.assertContains(resp, log_url)
489
1099
 
1100
+ def test_admin_log_view_displays_entries(self):
1101
+ charger = Charger.objects.create(charger_id="LOG2")
1102
+ log_id = store.identity_key(charger.charger_id, charger.connector_id)
1103
+ store.add_log(log_id, "entry", log_type="charger")
1104
+ url = reverse("admin:ocpp_charger_log", args=[charger.pk])
1105
+ resp = self.client.get(url)
1106
+ self.assertEqual(resp.status_code, 200)
1107
+ self.assertContains(resp, "entry")
1108
+ store.clear_log(log_id, log_type="charger")
1109
+
490
1110
  def test_admin_change_links_landing_page(self):
491
1111
  charger = Charger.objects.create(charger_id="CHANGE1")
492
1112
  url = reverse("admin:ocpp_charger_change", args=[charger.pk])
@@ -525,12 +1145,14 @@ class ChargerAdminTests(TestCase):
525
1145
  timestamp=timezone.now(),
526
1146
  value=1,
527
1147
  )
528
- store.add_log("PURGE1", "entry", log_type="charger")
1148
+ store.add_log(store.identity_key("PURGE1", None), "entry", log_type="charger")
529
1149
  url = reverse("admin:ocpp_charger_changelist")
530
- self.client.post(url, {"action": "purge_data", "_selected_action": [charger.pk]})
1150
+ self.client.post(
1151
+ url, {"action": "purge_data", "_selected_action": [charger.pk]}
1152
+ )
531
1153
  self.assertFalse(Transaction.objects.filter(charger=charger).exists())
532
1154
  self.assertFalse(MeterReading.objects.filter(charger=charger).exists())
533
- self.assertNotIn("PURGE1", store.logs["charger"])
1155
+ self.assertNotIn(store.identity_key("PURGE1", None), store.logs["charger"])
534
1156
 
535
1157
  def test_delete_requires_purge(self):
536
1158
  charger = Charger.objects.create(charger_id="DEL1")
@@ -543,11 +1165,44 @@ class ChargerAdminTests(TestCase):
543
1165
  self.client.post(delete_url, {"post": "yes"})
544
1166
  self.assertTrue(Charger.objects.filter(pk=charger.pk).exists())
545
1167
  url = reverse("admin:ocpp_charger_changelist")
546
- self.client.post(url, {"action": "purge_data", "_selected_action": [charger.pk]})
1168
+ self.client.post(
1169
+ url, {"action": "purge_data", "_selected_action": [charger.pk]}
1170
+ )
547
1171
  self.client.post(delete_url, {"post": "yes"})
548
1172
  self.assertFalse(Charger.objects.filter(pk=charger.pk).exists())
549
1173
 
550
1174
 
1175
+ class LocationAdminTests(TestCase):
1176
+ def setUp(self):
1177
+ self.client = Client()
1178
+ User = get_user_model()
1179
+ self.admin = User.objects.create_superuser(
1180
+ username="loc-admin", password="secret", email="loc@example.com"
1181
+ )
1182
+ self.client.force_login(self.admin)
1183
+
1184
+ def test_change_form_lists_related_chargers(self):
1185
+ location = Location.objects.create(name="LocAdmin")
1186
+ base = Charger.objects.create(charger_id="LOCBASE", location=location)
1187
+ connector = Charger.objects.create(
1188
+ charger_id="LOCALTWO",
1189
+ connector_id=1,
1190
+ location=location,
1191
+ )
1192
+
1193
+ url = reverse("admin:ocpp_location_change", args=[location.pk])
1194
+ resp = self.client.get(url)
1195
+ self.assertEqual(resp.status_code, 200)
1196
+
1197
+ base_change_url = reverse("admin:ocpp_charger_change", args=[base.pk])
1198
+ connector_change_url = reverse("admin:ocpp_charger_change", args=[connector.pk])
1199
+
1200
+ self.assertContains(resp, base_change_url)
1201
+ self.assertContains(resp, connector_change_url)
1202
+ self.assertContains(resp, f"Charge Point: {base.charger_id}")
1203
+ self.assertContains(resp, f"Charge Point: {connector.charger_id} #1")
1204
+
1205
+
551
1206
  class TransactionAdminTests(TestCase):
552
1207
  def setUp(self):
553
1208
  self.client = Client()
@@ -572,7 +1227,7 @@ class TransactionAdminTests(TestCase):
572
1227
  self.assertContains(resp, str(reading.value))
573
1228
 
574
1229
 
575
- class SimulatorAdminTests(TestCase):
1230
+ class SimulatorAdminTests(TransactionTestCase):
576
1231
  def setUp(self):
577
1232
  self.client = Client()
578
1233
  User = get_user_model()
@@ -580,17 +1235,48 @@ class SimulatorAdminTests(TestCase):
580
1235
  username="admin2", password="secret", email="admin2@example.com"
581
1236
  )
582
1237
  self.client.force_login(self.admin)
1238
+ store.simulators.clear()
1239
+ store.logs["simulator"].clear()
1240
+ store.log_names["simulator"].clear()
583
1241
 
584
1242
  def test_admin_lists_log_link(self):
585
1243
  sim = Simulator.objects.create(name="SIM", cp_path="SIMX")
586
1244
  url = reverse("admin:ocpp_simulator_changelist")
587
1245
  resp = self.client.get(url)
588
- log_url = reverse("charger-log", args=["SIMX"]) + "?type=simulator"
1246
+ log_url = reverse("admin:ocpp_simulator_log", args=[sim.pk])
589
1247
  self.assertContains(resp, log_url)
590
1248
 
1249
+ def test_admin_log_view_displays_entries(self):
1250
+ sim = Simulator.objects.create(name="SIMLOG", cp_path="SIMLOG")
1251
+ store.add_log("SIMLOG", "entry", log_type="simulator")
1252
+ url = reverse("admin:ocpp_simulator_log", args=[sim.pk])
1253
+ resp = self.client.get(url)
1254
+ self.assertEqual(resp.status_code, 200)
1255
+ self.assertContains(resp, "entry")
1256
+ store.clear_log("SIMLOG", log_type="simulator")
1257
+
1258
+ @patch("ocpp.admin.ChargePointSimulator.start")
1259
+ def test_start_simulator_message_includes_log_link(self, mock_start):
1260
+ sim = Simulator.objects.create(name="SIMMSG", cp_path="SIMMSG")
1261
+ mock_start.return_value = (True, "Connection accepted", "/tmp/sim.log")
1262
+ url = reverse("admin:ocpp_simulator_changelist")
1263
+ resp = self.client.post(
1264
+ url,
1265
+ {"action": "start_simulator", "_selected_action": [sim.pk]},
1266
+ follow=True,
1267
+ )
1268
+ self.assertEqual(resp.status_code, 200)
1269
+ log_url = reverse("admin:ocpp_simulator_log", args=[sim.pk])
1270
+ self.assertContains(resp, "View Log")
1271
+ self.assertContains(resp, log_url)
1272
+ self.assertContains(resp, "/tmp/sim.log")
1273
+ mock_start.assert_called_once()
1274
+ store.simulators.clear()
1275
+
591
1276
  def test_admin_shows_ws_url(self):
592
- sim = Simulator.objects.create(name="SIM2", cp_path="SIMY", host="h",
593
- ws_port=1111)
1277
+ sim = Simulator.objects.create(
1278
+ name="SIM2", cp_path="SIMY", host="h", ws_port=1111
1279
+ )
594
1280
  url = reverse("admin:ocpp_simulator_changelist")
595
1281
  resp = self.client.get(url)
596
1282
  self.assertContains(resp, "ws://h:1111/SIMY/")
@@ -617,7 +1303,9 @@ class SimulatorAdminTests(TestCase):
617
1303
  connected, _ = await communicator.connect()
618
1304
  self.assertTrue(connected)
619
1305
 
620
- exists = await database_sync_to_async(Charger.objects.filter(charger_id="NEWCHG").exists)()
1306
+ exists = await database_sync_to_async(
1307
+ Charger.objects.filter(charger_id="NEWCHG").exists
1308
+ )()
621
1309
  self.assertTrue(exists)
622
1310
 
623
1311
  charger = await database_sync_to_async(Charger.objects.get)(charger_id="NEWCHG")
@@ -636,21 +1324,27 @@ class SimulatorAdminTests(TestCase):
636
1324
  self.assertEqual(charger.last_path, "/foo/NEST/")
637
1325
 
638
1326
  async def test_rfid_required_rejects_invalid(self):
639
- await database_sync_to_async(Charger.objects.create)(charger_id="RFID", require_rfid=True)
1327
+ await database_sync_to_async(Charger.objects.create)(
1328
+ charger_id="RFID", require_rfid=True
1329
+ )
640
1330
  communicator = WebsocketCommunicator(application, "/RFID/")
641
1331
  connected, _ = await communicator.connect()
642
1332
  self.assertTrue(connected)
643
1333
 
644
- await communicator.send_json_to([
645
- 2,
646
- "1",
647
- "StartTransaction",
648
- {"meterStart": 0},
649
- ])
1334
+ await communicator.send_json_to(
1335
+ [
1336
+ 2,
1337
+ "1",
1338
+ "StartTransaction",
1339
+ {"meterStart": 0},
1340
+ ]
1341
+ )
650
1342
  response = await communicator.receive_json_from()
651
1343
  self.assertEqual(response[2]["idTagInfo"]["status"], "Invalid")
652
1344
 
653
- exists = await database_sync_to_async(Transaction.objects.filter(charger__charger_id="RFID").exists)()
1345
+ exists = await database_sync_to_async(
1346
+ Transaction.objects.filter(charger__charger_id="RFID").exists
1347
+ )()
654
1348
  self.assertFalse(exists)
655
1349
 
656
1350
  await communicator.disconnect()
@@ -668,22 +1362,28 @@ class SimulatorAdminTests(TestCase):
668
1362
  )
669
1363
  tag = await database_sync_to_async(RFID.objects.create)(rfid="CARDX")
670
1364
  await database_sync_to_async(acc.rfids.add)(tag)
671
- await database_sync_to_async(Charger.objects.create)(charger_id="RFIDOK", require_rfid=True)
1365
+ await database_sync_to_async(Charger.objects.create)(
1366
+ charger_id="RFIDOK", require_rfid=True
1367
+ )
672
1368
  communicator = WebsocketCommunicator(application, "/RFIDOK/")
673
1369
  connected, _ = await communicator.connect()
674
1370
  self.assertTrue(connected)
675
1371
 
676
- await communicator.send_json_to([
677
- 2,
678
- "1",
679
- "StartTransaction",
680
- {"meterStart": 5, "idTag": "CARDX"},
681
- ])
1372
+ await communicator.send_json_to(
1373
+ [
1374
+ 2,
1375
+ "1",
1376
+ "StartTransaction",
1377
+ {"meterStart": 5, "idTag": "CARDX"},
1378
+ ]
1379
+ )
682
1380
  response = await communicator.receive_json_from()
683
1381
  self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
684
1382
  tx_id = response[2]["transactionId"]
685
1383
 
686
- tx = await database_sync_to_async(Transaction.objects.get)(pk=tx_id, charger__charger_id="RFIDOK")
1384
+ tx = await database_sync_to_async(Transaction.objects.get)(
1385
+ pk=tx_id, charger__charger_id="RFIDOK"
1386
+ )
687
1387
  self.assertEqual(tx.account_id, user.energy_account.id)
688
1388
 
689
1389
  async def test_status_fields_updated(self):
@@ -709,7 +1409,10 @@ class SimulatorAdminTests(TestCase):
709
1409
  await communicator.receive_json_from()
710
1410
 
711
1411
  await database_sync_to_async(charger.refresh_from_db)()
712
- self.assertEqual(charger.last_meter_values.get("meterValue")[0]["sampledValue"][0]["value"], "42")
1412
+ self.assertEqual(
1413
+ charger.last_meter_values.get("meterValue")[0]["sampledValue"][0]["value"],
1414
+ "42",
1415
+ )
713
1416
 
714
1417
  await communicator.disconnect()
715
1418
 
@@ -724,6 +1427,18 @@ class ChargerLocationTests(TestCase):
724
1427
  self.assertAlmostEqual(float(charger.longitude), -20.654321)
725
1428
  self.assertEqual(charger.name, "Loc1")
726
1429
 
1430
+ def test_location_created_when_missing(self):
1431
+ charger = Charger.objects.create(charger_id="AUTOLOC")
1432
+ self.assertIsNotNone(charger.location)
1433
+ self.assertEqual(charger.location.name, "AUTOLOC")
1434
+
1435
+ def test_location_reused_for_matching_serial(self):
1436
+ first = Charger.objects.create(charger_id="SHARE", connector_id=1)
1437
+ first.location.name = "Custom"
1438
+ first.location.save()
1439
+ second = Charger.objects.create(charger_id="SHARE", connector_id=2)
1440
+ self.assertEqual(second.location, first.location)
1441
+
727
1442
 
728
1443
  class MeterReadingTests(TransactionTestCase):
729
1444
  async def test_meter_values_saved_as_readings(self):
@@ -750,10 +1465,14 @@ class MeterReadingTests(TransactionTestCase):
750
1465
  await communicator.send_json_to([2, "1", "MeterValues", payload])
751
1466
  await communicator.receive_json_from()
752
1467
 
753
- reading = await database_sync_to_async(MeterReading.objects.get)(charger__charger_id="MR1")
1468
+ reading = await database_sync_to_async(MeterReading.objects.get)(
1469
+ charger__charger_id="MR1"
1470
+ )
754
1471
  self.assertEqual(reading.transaction_id, 100)
755
1472
  self.assertEqual(str(reading.value), "2.749")
756
- tx = await database_sync_to_async(Transaction.objects.get)(pk=100, charger__charger_id="MR1")
1473
+ tx = await database_sync_to_async(Transaction.objects.get)(
1474
+ pk=100, charger__charger_id="MR1"
1475
+ )
757
1476
  self.assertEqual(tx.meter_start, 2749)
758
1477
 
759
1478
  await communicator.disconnect()
@@ -819,7 +1538,9 @@ class ChargePointSimulatorTests(TransactionTestCase):
819
1538
  )
820
1539
  break
821
1540
 
822
- server = await websockets.serve(handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"])
1541
+ server = await websockets.serve(
1542
+ handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
1543
+ )
823
1544
  port = server.sockets[0].getsockname()[1]
824
1545
 
825
1546
  try:
@@ -833,6 +1554,8 @@ class ChargePointSimulatorTests(TransactionTestCase):
833
1554
  kw_min=0.1,
834
1555
  kw_max=0.2,
835
1556
  pre_charge_delay=0.0,
1557
+ serial_number="SN123",
1558
+ connector_id=7,
836
1559
  )
837
1560
  sim = ChargePointSimulator(cfg)
838
1561
  await sim._run_session()
@@ -843,8 +1566,11 @@ class ChargePointSimulatorTests(TransactionTestCase):
843
1566
  actions = [msg[2] for msg in received]
844
1567
  self.assertIn("BootNotification", actions)
845
1568
  self.assertIn("StartTransaction", actions)
1569
+ boot_msg = next(msg for msg in received if msg[2] == "BootNotification")
1570
+ self.assertEqual(boot_msg[3].get("serialNumber"), "SN123")
846
1571
  start_msg = next(msg for msg in received if msg[2] == "StartTransaction")
847
1572
  self.assertEqual(start_msg[3].get("vin"), "WP0ZZZ12345678901")
1573
+ self.assertEqual(start_msg[3].get("connectorId"), 7)
848
1574
 
849
1575
  async def test_start_returns_status_and_log(self):
850
1576
  async def handler(ws):
@@ -867,9 +1593,7 @@ class ChargePointSimulatorTests(TransactionTestCase):
867
1593
  )
868
1594
  elif action == "Authorize":
869
1595
  await ws.send(
870
- json.dumps(
871
- [3, data[1], {"idTagInfo": {"status": "Accepted"}}]
872
- )
1596
+ json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
873
1597
  )
874
1598
  elif action == "StartTransaction":
875
1599
  await ws.send(
@@ -886,15 +1610,15 @@ class ChargePointSimulatorTests(TransactionTestCase):
886
1610
  )
887
1611
  elif action == "StopTransaction":
888
1612
  await ws.send(
889
- json.dumps(
890
- [3, data[1], {"idTagInfo": {"status": "Accepted"}}]
891
- )
1613
+ json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
892
1614
  )
893
1615
  break
894
1616
  else:
895
1617
  await ws.send(json.dumps([3, data[1], {}]))
896
1618
 
897
- server = await websockets.serve(handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"])
1619
+ server = await websockets.serve(
1620
+ handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
1621
+ )
898
1622
  port = server.sockets[0].getsockname()[1]
899
1623
 
900
1624
  cfg = SimulatorConfig(
@@ -927,9 +1651,7 @@ class ChargePointSimulatorTests(TransactionTestCase):
927
1651
  data = json.loads(msg)
928
1652
  action = data[2]
929
1653
  if action == "BootNotification":
930
- await ws.send(
931
- json.dumps([3, data[1], {"status": "Accepted"}])
932
- )
1654
+ await ws.send(json.dumps([3, data[1], {"status": "Accepted"}]))
933
1655
  elif action == "Authorize":
934
1656
  await ws.send(
935
1657
  json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
@@ -937,7 +1659,9 @@ class ChargePointSimulatorTests(TransactionTestCase):
937
1659
  await ws.close()
938
1660
  break
939
1661
 
940
- server = await websockets.serve(handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"])
1662
+ server = await websockets.serve(
1663
+ handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
1664
+ )
941
1665
  port = server.sockets[0].getsockname()[1]
942
1666
 
943
1667
  cfg = SimulatorConfig(
@@ -973,7 +1697,12 @@ class ChargePointSimulatorTests(TransactionTestCase):
973
1697
  action = data[2]
974
1698
  if action == "BootNotification":
975
1699
  await ws.send(json.dumps([3, data[1], {"status": "Accepted"}]))
976
- elif action in {"Authorize", "StatusNotification", "Heartbeat", "MeterValues"}:
1700
+ elif action in {
1701
+ "Authorize",
1702
+ "StatusNotification",
1703
+ "Heartbeat",
1704
+ "MeterValues",
1705
+ }:
977
1706
  await ws.send(json.dumps([3, data[1], {}]))
978
1707
  elif action == "StartTransaction":
979
1708
  await ws.send(
@@ -981,15 +1710,22 @@ class ChargePointSimulatorTests(TransactionTestCase):
981
1710
  [
982
1711
  3,
983
1712
  data[1],
984
- {"transactionId": 1, "idTagInfo": {"status": "Accepted"}},
1713
+ {
1714
+ "transactionId": 1,
1715
+ "idTagInfo": {"status": "Accepted"},
1716
+ },
985
1717
  ]
986
1718
  )
987
1719
  )
988
1720
  elif action == "StopTransaction":
989
- await ws.send(json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}]))
1721
+ await ws.send(
1722
+ json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
1723
+ )
990
1724
  break
991
1725
 
992
- server = await websockets.serve(handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"])
1726
+ server = await websockets.serve(
1727
+ handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
1728
+ )
993
1729
  port = server.sockets[0].getsockname()[1]
994
1730
 
995
1731
  try:
@@ -1020,13 +1756,16 @@ class ChargePointSimulatorTests(TransactionTestCase):
1020
1756
  async for _ in ws:
1021
1757
  pass
1022
1758
 
1023
- server = await websockets.serve(handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"])
1759
+ server = await websockets.serve(
1760
+ handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
1761
+ )
1024
1762
  port = server.sockets[0].getsockname()[1]
1025
1763
 
1026
1764
  cfg = SimulatorConfig(host="127.0.0.1", ws_port=port, cp_path="SIMTO/")
1027
1765
  sim = ChargePointSimulator(cfg)
1028
1766
  store.simulators[99] = sim
1029
1767
  try:
1768
+
1030
1769
  async def fake_wait_for(coro, timeout):
1031
1770
  coro.close()
1032
1771
  raise asyncio.TimeoutError
@@ -1066,7 +1805,9 @@ class PurgeMeterReadingsTaskTests(TestCase):
1066
1805
 
1067
1806
  self.assertEqual(MeterReading.objects.count(), 1)
1068
1807
  self.assertTrue(
1069
- MeterReading.objects.filter(timestamp__gte=recent - timedelta(minutes=1)).exists()
1808
+ MeterReading.objects.filter(
1809
+ timestamp__gte=recent - timedelta(minutes=1)
1810
+ ).exists()
1070
1811
  )
1071
1812
  self.assertTrue(Transaction.objects.filter(pk=tx.pk).exists())
1072
1813
 
@@ -1090,19 +1831,21 @@ class PurgeMeterReadingsTaskTests(TestCase):
1090
1831
  class TransactionKwTests(TestCase):
1091
1832
  def test_kw_sums_meter_readings(self):
1092
1833
  charger = Charger.objects.create(charger_id="SUM1")
1093
- tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
1834
+ tx = Transaction.objects.create(
1835
+ charger=charger, start_time=timezone.now(), meter_start=0
1836
+ )
1094
1837
  MeterReading.objects.create(
1095
1838
  charger=charger,
1096
1839
  transaction=tx,
1097
1840
  timestamp=timezone.now(),
1098
- value=Decimal("1.0"),
1099
- unit="kW",
1841
+ value=Decimal("1000"),
1842
+ unit="W",
1100
1843
  )
1101
1844
  MeterReading.objects.create(
1102
1845
  charger=charger,
1103
1846
  transaction=tx,
1104
1847
  timestamp=timezone.now(),
1105
- value=Decimal("500"),
1848
+ value=Decimal("1500"),
1106
1849
  unit="W",
1107
1850
  )
1108
1851
  self.assertAlmostEqual(tx.kw, 1.5)
@@ -1113,15 +1856,95 @@ class TransactionKwTests(TestCase):
1113
1856
  self.assertEqual(tx.kw, 0.0)
1114
1857
 
1115
1858
 
1859
+ class DispatchActionViewTests(TestCase):
1860
+ def setUp(self):
1861
+ self.client = Client()
1862
+ User = get_user_model()
1863
+ self.user = User.objects.create_user(username="dispatch", password="pw")
1864
+ self.client.force_login(self.user)
1865
+ try:
1866
+ self.previous_loop = asyncio.get_event_loop()
1867
+ except RuntimeError:
1868
+ self.previous_loop = None
1869
+ self.loop = asyncio.new_event_loop()
1870
+ asyncio.set_event_loop(self.loop)
1871
+ self.addCleanup(self._close_loop)
1872
+ self.charger = Charger.objects.create(
1873
+ charger_id="DISPATCH", connector_id=1
1874
+ )
1875
+ self.ws = DummyWebSocket()
1876
+ store.set_connection(
1877
+ self.charger.charger_id, self.charger.connector_id, self.ws
1878
+ )
1879
+ self.addCleanup(
1880
+ store.pop_connection,
1881
+ self.charger.charger_id,
1882
+ self.charger.connector_id,
1883
+ )
1884
+ self.log_key = store.identity_key(
1885
+ self.charger.charger_id, self.charger.connector_id
1886
+ )
1887
+ store.clear_log(self.log_key, log_type="charger")
1888
+ self.addCleanup(store.clear_log, self.log_key, "charger")
1889
+ self.url = reverse(
1890
+ "charger-action-connector",
1891
+ args=[self.charger.charger_id, self.charger.connector_slug],
1892
+ )
1893
+
1894
+ def _close_loop(self):
1895
+ try:
1896
+ if not self.loop.is_closed():
1897
+ self.loop.run_until_complete(asyncio.sleep(0))
1898
+ except RuntimeError:
1899
+ pass
1900
+ finally:
1901
+ if not self.loop.is_closed():
1902
+ self.loop.close()
1903
+ asyncio.set_event_loop(self.previous_loop)
1904
+
1905
+ def test_remote_start_requires_id_tag(self):
1906
+ response = self.client.post(
1907
+ self.url,
1908
+ data=json.dumps({"action": "remote_start"}),
1909
+ content_type="application/json",
1910
+ )
1911
+ self.assertEqual(response.status_code, 400)
1912
+ self.assertEqual(response.json().get("detail"), "idTag required")
1913
+ self.loop.run_until_complete(asyncio.sleep(0))
1914
+ self.assertEqual(self.ws.sent, [])
1915
+
1916
+ def test_remote_start_dispatches_frame(self):
1917
+ response = self.client.post(
1918
+ self.url,
1919
+ data=json.dumps({"action": "remote_start", "idTag": "RF1234"}),
1920
+ content_type="application/json",
1921
+ )
1922
+ self.assertEqual(response.status_code, 200)
1923
+ self.loop.run_until_complete(asyncio.sleep(0))
1924
+ self.assertEqual(len(self.ws.sent), 1)
1925
+ frame = json.loads(self.ws.sent[0])
1926
+ self.assertEqual(frame[0], 2)
1927
+ self.assertEqual(frame[2], "RemoteStartTransaction")
1928
+ self.assertEqual(frame[3]["idTag"], "RF1234")
1929
+ self.assertEqual(frame[3]["connectorId"], 1)
1930
+ log_entries = store.logs["charger"].get(self.log_key, [])
1931
+ self.assertTrue(
1932
+ any("RemoteStartTransaction" in entry for entry in log_entries)
1933
+ )
1934
+
1935
+
1116
1936
  class ChargerStatusViewTests(TestCase):
1117
1937
  def setUp(self):
1118
1938
  self.client = Client()
1119
1939
  User = get_user_model()
1120
1940
  self.user = User.objects.create_user(username="status", password="pwd")
1121
1941
  self.client.force_login(self.user)
1942
+
1122
1943
  def test_chart_data_populated_from_existing_readings(self):
1123
- charger = Charger.objects.create(charger_id="VIEW1")
1124
- tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
1944
+ charger = Charger.objects.create(charger_id="VIEW1", connector_id=1)
1945
+ tx = Transaction.objects.create(
1946
+ charger=charger, start_time=timezone.now(), meter_start=0
1947
+ )
1125
1948
  t0 = timezone.now()
1126
1949
  MeterReading.objects.create(
1127
1950
  charger=charger,
@@ -1134,17 +1957,109 @@ class ChargerStatusViewTests(TestCase):
1134
1957
  charger=charger,
1135
1958
  transaction=tx,
1136
1959
  timestamp=t0 + timedelta(seconds=10),
1137
- value=Decimal("500"),
1960
+ value=Decimal("1500"),
1138
1961
  unit="W",
1139
1962
  )
1140
- store.transactions[charger.charger_id] = tx
1141
- resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
1963
+ key = store.identity_key(charger.charger_id, charger.connector_id)
1964
+ store.transactions[key] = tx
1965
+ resp = self.client.get(
1966
+ reverse(
1967
+ "charger-status-connector",
1968
+ args=[charger.charger_id, charger.connector_slug],
1969
+ )
1970
+ )
1142
1971
  self.assertEqual(resp.status_code, 200)
1143
- chart = json.loads(resp.context["chart_data"])
1972
+ chart = resp.context["chart_data"]
1144
1973
  self.assertEqual(len(chart["labels"]), 2)
1145
- self.assertAlmostEqual(chart["values"][0], 1.0)
1146
- self.assertAlmostEqual(chart["values"][1], 1.5)
1147
- store.transactions.pop(charger.charger_id, None)
1974
+ self.assertEqual(len(chart["datasets"]), 1)
1975
+ values = chart["datasets"][0]["values"]
1976
+ self.assertEqual(chart["datasets"][0]["connector_id"], 1)
1977
+ self.assertAlmostEqual(values[0], 1.0)
1978
+ self.assertAlmostEqual(values[1], 1.5)
1979
+ store.transactions.pop(key, None)
1980
+
1981
+ def test_chart_data_uses_meter_start_for_register_values(self):
1982
+ charger = Charger.objects.create(charger_id="VIEWREG", connector_id=1)
1983
+ tx = Transaction.objects.create(
1984
+ charger=charger, start_time=timezone.now(), meter_start=746060
1985
+ )
1986
+ t0 = timezone.now()
1987
+ MeterReading.objects.create(
1988
+ charger=charger,
1989
+ transaction=tx,
1990
+ timestamp=t0,
1991
+ measurand="Energy.Active.Import.Register",
1992
+ value=Decimal("746.060"),
1993
+ unit="kWh",
1994
+ )
1995
+ MeterReading.objects.create(
1996
+ charger=charger,
1997
+ transaction=tx,
1998
+ timestamp=t0 + timedelta(seconds=10),
1999
+ measurand="Energy.Active.Import.Register",
2000
+ value=Decimal("746.080"),
2001
+ unit="kWh",
2002
+ )
2003
+ key = store.identity_key(charger.charger_id, charger.connector_id)
2004
+ store.transactions[key] = tx
2005
+ resp = self.client.get(
2006
+ reverse(
2007
+ "charger-status-connector",
2008
+ args=[charger.charger_id, charger.connector_slug],
2009
+ )
2010
+ )
2011
+ chart = resp.context["chart_data"]
2012
+ self.assertEqual(len(chart["labels"]), 2)
2013
+ self.assertEqual(len(chart["datasets"]), 1)
2014
+ values = chart["datasets"][0]["values"]
2015
+ self.assertEqual(chart["datasets"][0]["connector_id"], 1)
2016
+ self.assertAlmostEqual(values[0], 0.0)
2017
+ self.assertAlmostEqual(values[1], 0.02)
2018
+ self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
2019
+ store.transactions.pop(key, None)
2020
+
2021
+ def test_diagnostics_status_displayed(self):
2022
+ reported_at = timezone.now().replace(microsecond=0)
2023
+ charger = Charger.objects.create(
2024
+ charger_id="DIAGPAGE",
2025
+ diagnostics_status="Uploaded",
2026
+ diagnostics_location="https://example.com/report.tar",
2027
+ diagnostics_timestamp=reported_at,
2028
+ )
2029
+
2030
+ resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
2031
+ self.assertEqual(resp.status_code, 200)
2032
+ self.assertContains(resp, "Diagnostics")
2033
+ self.assertContains(resp, "id=\"diagnostics-status\"")
2034
+ self.assertContains(resp, "Uploaded")
2035
+ self.assertContains(resp, "id=\"diagnostics-timestamp\"")
2036
+ self.assertContains(resp, "id=\"diagnostics-location\"")
2037
+ self.assertContains(resp, "https://example.com/report.tar")
2038
+
2039
+ def test_connector_status_prefers_connector_diagnostics(self):
2040
+ aggregate = Charger.objects.create(
2041
+ charger_id="DIAGCONN",
2042
+ diagnostics_status="Uploaded",
2043
+ )
2044
+ connector = Charger.objects.create(
2045
+ charger_id="DIAGCONN",
2046
+ connector_id=1,
2047
+ diagnostics_status="Uploading",
2048
+ )
2049
+
2050
+ aggregate_resp = self.client.get(
2051
+ reverse("charger-status", args=[aggregate.charger_id])
2052
+ )
2053
+ self.assertContains(aggregate_resp, "Uploaded")
2054
+ self.assertNotContains(aggregate_resp, "Uploading")
2055
+
2056
+ connector_resp = self.client.get(
2057
+ reverse(
2058
+ "charger-status-connector",
2059
+ args=[connector.charger_id, connector.connector_slug],
2060
+ )
2061
+ )
2062
+ self.assertContains(connector_resp, "Uploading")
1148
2063
 
1149
2064
  def test_sessions_are_linked(self):
1150
2065
  charger = Charger.objects.create(charger_id="LINK1")
@@ -1157,9 +2072,27 @@ class ChargerStatusViewTests(TestCase):
1157
2072
  resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
1158
2073
  self.assertContains(resp, reverse("charger-page", args=[charger.charger_id]))
1159
2074
 
2075
+ def test_configuration_link_hidden_for_non_staff(self):
2076
+ charger = Charger.objects.create(charger_id="CFG-HIDE")
2077
+ response = self.client.get(reverse("charger-status", args=[charger.charger_id]))
2078
+ admin_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
2079
+ self.assertNotContains(response, admin_url)
2080
+ self.assertNotContains(response, _("Configuration"))
2081
+
2082
+ def test_configuration_link_visible_for_staff(self):
2083
+ charger = Charger.objects.create(charger_id="CFG-SHOW")
2084
+ self.user.is_staff = True
2085
+ self.user.save(update_fields=["is_staff"])
2086
+ response = self.client.get(reverse("charger-status", args=[charger.charger_id]))
2087
+ admin_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
2088
+ self.assertContains(response, admin_url)
2089
+ self.assertContains(response, _("Configuration"))
2090
+
1160
2091
  def test_past_session_chart(self):
1161
- charger = Charger.objects.create(charger_id="PAST1")
1162
- tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
2092
+ charger = Charger.objects.create(charger_id="PAST1", connector_id=1)
2093
+ tx = Transaction.objects.create(
2094
+ charger=charger, start_time=timezone.now(), meter_start=0
2095
+ )
1163
2096
  t0 = timezone.now()
1164
2097
  MeterReading.objects.create(
1165
2098
  charger=charger,
@@ -1172,17 +2105,147 @@ class ChargerStatusViewTests(TestCase):
1172
2105
  charger=charger,
1173
2106
  transaction=tx,
1174
2107
  timestamp=t0 + timedelta(seconds=10),
1175
- value=Decimal("1000"),
2108
+ value=Decimal("1500"),
1176
2109
  unit="W",
1177
2110
  )
1178
2111
  resp = self.client.get(
1179
- reverse("charger-status", args=[charger.charger_id]) + f"?session={tx.id}"
2112
+ reverse(
2113
+ "charger-status-connector",
2114
+ args=[charger.charger_id, charger.connector_slug],
2115
+ )
2116
+ + f"?session={tx.id}"
1180
2117
  )
1181
2118
  self.assertContains(resp, "Back to live")
1182
- chart = json.loads(resp.context["chart_data"])
2119
+ chart = resp.context["chart_data"]
1183
2120
  self.assertEqual(len(chart["labels"]), 2)
2121
+ self.assertEqual(len(chart["datasets"]), 1)
2122
+ self.assertEqual(chart["datasets"][0]["connector_id"], 1)
1184
2123
  self.assertTrue(resp.context["past_session"])
1185
2124
 
2125
+ def test_aggregate_chart_includes_multiple_connectors(self):
2126
+ aggregate = Charger.objects.create(charger_id="VIEWAGG")
2127
+ connector_one = Charger.objects.create(charger_id="VIEWAGG", connector_id=1)
2128
+ connector_two = Charger.objects.create(charger_id="VIEWAGG", connector_id=2)
2129
+ base_time = timezone.now()
2130
+ tx_one = Transaction.objects.create(
2131
+ charger=connector_one, start_time=base_time, meter_start=0
2132
+ )
2133
+ tx_two = Transaction.objects.create(
2134
+ charger=connector_two, start_time=base_time, meter_start=0
2135
+ )
2136
+ MeterReading.objects.create(
2137
+ charger=connector_one,
2138
+ transaction=tx_one,
2139
+ timestamp=base_time,
2140
+ value=Decimal("1000"),
2141
+ unit="W",
2142
+ )
2143
+ MeterReading.objects.create(
2144
+ charger=connector_one,
2145
+ transaction=tx_one,
2146
+ timestamp=base_time + timedelta(seconds=15),
2147
+ value=Decimal("1500"),
2148
+ unit="W",
2149
+ )
2150
+ MeterReading.objects.create(
2151
+ charger=connector_two,
2152
+ transaction=tx_two,
2153
+ timestamp=base_time + timedelta(seconds=5),
2154
+ value=Decimal("2000"),
2155
+ unit="W",
2156
+ )
2157
+ MeterReading.objects.create(
2158
+ charger=connector_two,
2159
+ transaction=tx_two,
2160
+ timestamp=base_time + timedelta(seconds=20),
2161
+ value=Decimal("2600"),
2162
+ unit="W",
2163
+ )
2164
+ key_one = store.identity_key(
2165
+ connector_one.charger_id, connector_one.connector_id
2166
+ )
2167
+ key_two = store.identity_key(
2168
+ connector_two.charger_id, connector_two.connector_id
2169
+ )
2170
+ store.transactions[key_one] = tx_one
2171
+ store.transactions[key_two] = tx_two
2172
+ try:
2173
+ resp = self.client.get(
2174
+ reverse("charger-status", args=[aggregate.charger_id])
2175
+ )
2176
+ chart = resp.context["chart_data"]
2177
+ self.assertTrue(resp.context["show_chart"])
2178
+ self.assertEqual(len(chart["datasets"]), 2)
2179
+ data_map = {
2180
+ dataset["label"]: dataset["values"] for dataset in chart["datasets"]
2181
+ }
2182
+ connector_id_map = {
2183
+ dataset["label"]: dataset.get("connector_id")
2184
+ for dataset in chart["datasets"]
2185
+ }
2186
+ label_one = str(connector_one.connector_label)
2187
+ label_two = str(connector_two.connector_label)
2188
+ self.assertEqual(set(data_map), {label_one, label_two})
2189
+ self.assertEqual(len(data_map[label_one]), len(chart["labels"]))
2190
+ self.assertEqual(len(data_map[label_two]), len(chart["labels"]))
2191
+ self.assertTrue(any(value is not None for value in data_map[label_one]))
2192
+ self.assertTrue(any(value is not None for value in data_map[label_two]))
2193
+ self.assertEqual(connector_id_map[label_one], connector_one.connector_id)
2194
+ self.assertEqual(connector_id_map[label_two], connector_two.connector_id)
2195
+ finally:
2196
+ store.transactions.pop(key_one, None)
2197
+ store.transactions.pop(key_two, None)
2198
+
2199
+
2200
+ class ChargerApiDiagnosticsTests(TestCase):
2201
+ def setUp(self):
2202
+ self.client = Client()
2203
+ User = get_user_model()
2204
+ self.user = User.objects.create_user(username="diagapi", password="pwd")
2205
+ self.client.force_login(self.user)
2206
+
2207
+ def test_detail_includes_diagnostics_fields(self):
2208
+ reported_at = timezone.now().replace(microsecond=0)
2209
+ charger = Charger.objects.create(
2210
+ charger_id="APIDIAG",
2211
+ diagnostics_status="Uploaded",
2212
+ diagnostics_timestamp=reported_at,
2213
+ diagnostics_location="https://example.com/diag.tar",
2214
+ )
2215
+
2216
+ resp = self.client.get(reverse("charger-detail", args=[charger.charger_id]))
2217
+ self.assertEqual(resp.status_code, 200)
2218
+ payload = resp.json()
2219
+ self.assertEqual(payload["diagnosticsStatus"], "Uploaded")
2220
+ self.assertEqual(
2221
+ payload["diagnosticsTimestamp"], reported_at.isoformat()
2222
+ )
2223
+ self.assertEqual(
2224
+ payload["diagnosticsLocation"], "https://example.com/diag.tar"
2225
+ )
2226
+
2227
+ def test_list_includes_diagnostics_fields(self):
2228
+ reported_at = timezone.now().replace(microsecond=0)
2229
+ Charger.objects.create(
2230
+ charger_id="APILIST",
2231
+ diagnostics_status="Idle",
2232
+ diagnostics_timestamp=reported_at,
2233
+ diagnostics_location="s3://bucket/diag.zip",
2234
+ )
2235
+
2236
+ resp = self.client.get(reverse("charger-list"))
2237
+ self.assertEqual(resp.status_code, 200)
2238
+ payload = resp.json()
2239
+ self.assertIn("chargers", payload)
2240
+ target = next(
2241
+ item
2242
+ for item in payload["chargers"]
2243
+ if item["charger_id"] == "APILIST" and item["connector_id"] is None
2244
+ )
2245
+ self.assertEqual(target["diagnosticsStatus"], "Idle")
2246
+ self.assertEqual(target["diagnosticsLocation"], "s3://bucket/diag.zip")
2247
+ self.assertEqual(target["diagnosticsTimestamp"], reported_at.isoformat())
2248
+
1186
2249
 
1187
2250
  class ChargerSessionPaginationTests(TestCase):
1188
2251
  def setUp(self):
@@ -1199,7 +2262,9 @@ class ChargerSessionPaginationTests(TestCase):
1199
2262
  )
1200
2263
 
1201
2264
  def test_only_ten_transactions_shown(self):
1202
- resp = self.client.get(reverse("charger-status", args=[self.charger.charger_id]))
2265
+ resp = self.client.get(
2266
+ reverse("charger-status", args=[self.charger.charger_id])
2267
+ )
1203
2268
  self.assertEqual(resp.status_code, 200)
1204
2269
  self.assertEqual(len(resp.context["transactions"]), 10)
1205
2270
  self.assertTrue(resp.context["page_obj"].has_next())
@@ -1214,20 +2279,18 @@ class ChargerSessionPaginationTests(TestCase):
1214
2279
  self.assertEqual(len(resp.context["transactions"]), 15)
1215
2280
 
1216
2281
 
1217
- class EfficiencyCalculatorViewTests(TestCase):
2282
+ class LiveUpdateViewTests(TestCase):
1218
2283
  def setUp(self):
1219
2284
  User = get_user_model()
1220
- self.user = User.objects.create_user(
1221
- username="eff", password="secret", email="eff@example.com"
1222
- )
2285
+ self.user = User.objects.create_user(username="lu", password="pw")
1223
2286
  self.client.force_login(self.user)
1224
2287
 
1225
- def test_get_view(self):
1226
- url = reverse("ev-efficiency")
1227
- resp = self.client.get(url)
1228
- self.assertContains(resp, "EV Efficiency Calculator")
2288
+ def test_dashboard_includes_interval(self):
2289
+ resp = self.client.get(reverse("ocpp-dashboard"))
2290
+ self.assertEqual(resp.context["request"].live_update_interval, 5)
2291
+ self.assertContains(resp, "setInterval(() => location.reload()")
1229
2292
 
1230
- def test_post_calculation(self):
1231
- url = reverse("ev-efficiency")
1232
- resp = self.client.post(url, {"distance": "100", "energy": "20"})
1233
- self.assertContains(resp, "5.00")
2293
+ def test_cp_simulator_includes_interval(self):
2294
+ resp = self.client.get(reverse("cp-simulator"))
2295
+ self.assertEqual(resp.context["request"].live_update_interval, 5)
2296
+ self.assertContains(resp, "setInterval(() => location.reload()")