arthexis 0.1.15__py3-none-any.whl → 0.1.17__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.

ocpp/tasks.py CHANGED
@@ -1,11 +1,17 @@
1
1
  import logging
2
- from datetime import timedelta
2
+ from datetime import date, datetime, time, timedelta
3
+ from pathlib import Path
3
4
 
4
5
  from celery import shared_task
6
+ from django.conf import settings
7
+ from django.contrib.auth import get_user_model
5
8
  from django.utils import timezone
6
9
  from django.db.models import Q
7
10
 
8
- from .models import MeterValue
11
+ from core import mailer
12
+ from nodes.models import Node
13
+
14
+ from .models import MeterValue, Transaction
9
15
 
10
16
  logger = logging.getLogger(__name__)
11
17
 
@@ -29,3 +35,151 @@ def purge_meter_values() -> int:
29
35
 
30
36
  # Backwards compatibility alias
31
37
  purge_meter_readings = purge_meter_values
38
+
39
+
40
+ def _resolve_report_window() -> tuple[datetime, datetime, date]:
41
+ """Return the start/end datetimes for today's reporting window."""
42
+
43
+ current_tz = timezone.get_current_timezone()
44
+ today = timezone.localdate()
45
+ start = timezone.make_aware(datetime.combine(today, time.min), current_tz)
46
+ end = start + timedelta(days=1)
47
+ return start, end, today
48
+
49
+
50
+ def _session_report_recipients() -> list[str]:
51
+ """Return the list of recipients for the daily session report."""
52
+
53
+ User = get_user_model()
54
+ recipients = list(
55
+ User.objects.filter(is_superuser=True)
56
+ .exclude(email="")
57
+ .values_list("email", flat=True)
58
+ )
59
+ if recipients:
60
+ return recipients
61
+
62
+ fallback = getattr(settings, "DEFAULT_FROM_EMAIL", "").strip()
63
+ return [fallback] if fallback else []
64
+
65
+
66
+ def _format_duration(delta: timedelta | None) -> str:
67
+ """Return a compact string for ``delta`` or ``"in progress"``."""
68
+
69
+ if delta is None:
70
+ return "in progress"
71
+ total_seconds = int(delta.total_seconds())
72
+ hours, remainder = divmod(total_seconds, 3600)
73
+ minutes, seconds = divmod(remainder, 60)
74
+ parts: list[str] = []
75
+ if hours:
76
+ parts.append(f"{hours}h")
77
+ if minutes:
78
+ parts.append(f"{minutes}m")
79
+ if seconds or not parts:
80
+ parts.append(f"{seconds}s")
81
+ return " ".join(parts)
82
+
83
+
84
+ def _format_charger(transaction: Transaction) -> str:
85
+ """Return a human friendly label for ``transaction``'s charger."""
86
+
87
+ charger = transaction.charger
88
+ if charger is None:
89
+ return "Unknown charger"
90
+ for attr in ("display_name", "name", "charger_id"):
91
+ value = getattr(charger, attr, "")
92
+ if value:
93
+ return str(value)
94
+ return str(charger)
95
+
96
+
97
+ @shared_task
98
+ def send_daily_session_report() -> int:
99
+ """Send a summary of today's OCPP sessions when email is available."""
100
+
101
+ if not mailer.can_send_email():
102
+ logger.info("Skipping OCPP session report: email not configured")
103
+ return 0
104
+
105
+ celery_lock = Path(settings.BASE_DIR) / "locks" / "celery.lck"
106
+ if not celery_lock.exists():
107
+ logger.info("Skipping OCPP session report: celery feature disabled")
108
+ return 0
109
+
110
+ recipients = _session_report_recipients()
111
+ if not recipients:
112
+ logger.info("Skipping OCPP session report: no recipients found")
113
+ return 0
114
+
115
+ start, end, today = _resolve_report_window()
116
+ transactions = list(
117
+ Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
118
+ .select_related("charger", "account")
119
+ .order_by("start_time")
120
+ )
121
+ if not transactions:
122
+ logger.info("No OCPP sessions recorded on %s", today.isoformat())
123
+ return 0
124
+
125
+ total_energy = sum(transaction.kw for transaction in transactions)
126
+ lines = [
127
+ f"OCPP session report for {today.isoformat()}",
128
+ "",
129
+ f"Total sessions: {len(transactions)}",
130
+ f"Total energy: {total_energy:.2f} kWh",
131
+ "",
132
+ ]
133
+
134
+ for index, transaction in enumerate(transactions, start=1):
135
+ start_local = timezone.localtime(transaction.start_time)
136
+ stop_local = (
137
+ timezone.localtime(transaction.stop_time)
138
+ if transaction.stop_time
139
+ else None
140
+ )
141
+ duration = _format_duration(
142
+ stop_local - start_local if stop_local else None
143
+ )
144
+ account = transaction.account.name if transaction.account else "N/A"
145
+ connector = (
146
+ f"Connector {transaction.connector_id}" if transaction.connector_id else None
147
+ )
148
+ lines.append(f"{index}. {_format_charger(transaction)}")
149
+ lines.append(f" Account: {account}")
150
+ if transaction.rfid:
151
+ lines.append(f" RFID: {transaction.rfid}")
152
+ if connector:
153
+ lines.append(f" {connector}")
154
+ lines.append(
155
+ " Start: "
156
+ f"{start_local.strftime('%H:%M:%S %Z')}"
157
+ )
158
+ if stop_local:
159
+ lines.append(
160
+ " Stop: "
161
+ f"{stop_local.strftime('%H:%M:%S %Z')} ({duration})"
162
+ )
163
+ else:
164
+ lines.append(" Stop: in progress")
165
+ lines.append(f" Energy: {transaction.kw:.2f} kWh")
166
+ lines.append("")
167
+
168
+ subject = f"OCPP session report for {today.isoformat()}"
169
+ body = "\n".join(lines).strip()
170
+
171
+ node = Node.get_local()
172
+ if node is not None:
173
+ node.send_mail(subject, body, recipients)
174
+ else:
175
+ mailer.send(
176
+ subject,
177
+ body,
178
+ recipients,
179
+ getattr(settings, "DEFAULT_FROM_EMAIL", None),
180
+ )
181
+
182
+ logger.info(
183
+ "Sent OCPP session report for %s to %s", today.isoformat(), ", ".join(recipients)
184
+ )
185
+ return len(transactions)
ocpp/test_rfid.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import io
2
2
  import json
3
3
  import os
4
+ import subprocess
4
5
  import sys
5
6
  import types
6
7
  from datetime import datetime, timezone as dt_timezone
@@ -130,7 +131,7 @@ class ScanNextViewTests(TestCase):
130
131
  self.assertEqual(
131
132
  resp.json(), {"rfid": "ABCD1234", "label_id": 1, "created": False}
132
133
  )
133
- mock_validate.assert_called_once_with("ABCD1234", kind=None)
134
+ mock_validate.assert_called_once_with("ABCD1234", kind=None, endianness=None)
134
135
 
135
136
  @patch("config.middleware.Node.get_local", return_value=None)
136
137
  @patch("config.middleware.get_site")
@@ -341,16 +342,20 @@ class ValidateRfidValueTests(SimpleTestCase):
341
342
  tag.released = False
342
343
  tag.reference = None
343
344
  tag.kind = RFID.CLASSIC
345
+ tag.endianness = RFID.BIG_ENDIAN
344
346
  mock_register.return_value = (tag, True)
345
347
 
346
348
  result = validate_rfid_value("abcd1234")
347
349
 
348
- mock_register.assert_called_once_with("ABCD1234", kind=None)
350
+ mock_register.assert_called_once_with(
351
+ "ABCD1234", kind=None, endianness=RFID.BIG_ENDIAN
352
+ )
349
353
  tag.save.assert_called_once_with(update_fields=["last_seen_on"])
350
354
  self.assertIs(tag.last_seen_on, fake_now)
351
355
  mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
352
356
  self.assertTrue(result["created"])
353
357
  self.assertEqual(result["rfid"], "ABCD1234")
358
+ self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
354
359
 
355
360
  @patch("ocpp.rfid.reader.timezone.now")
356
361
  @patch("ocpp.rfid.reader.notify_async")
@@ -366,11 +371,14 @@ class ValidateRfidValueTests(SimpleTestCase):
366
371
  tag.released = True
367
372
  tag.reference = None
368
373
  tag.kind = RFID.CLASSIC
374
+ tag.endianness = RFID.BIG_ENDIAN
369
375
  mock_register.return_value = (tag, False)
370
376
 
371
377
  result = validate_rfid_value("abcd", kind=RFID.NTAG215)
372
378
 
373
- mock_register.assert_called_once_with("ABCD", kind=RFID.NTAG215)
379
+ mock_register.assert_called_once_with(
380
+ "ABCD", kind=RFID.NTAG215, endianness=RFID.BIG_ENDIAN
381
+ )
374
382
  tag.save.assert_called_once_with(update_fields=["kind", "last_seen_on"])
375
383
  self.assertIs(tag.last_seen_on, fake_now)
376
384
  self.assertEqual(tag.kind, RFID.NTAG215)
@@ -378,6 +386,36 @@ class ValidateRfidValueTests(SimpleTestCase):
378
386
  self.assertFalse(result["allowed"])
379
387
  self.assertFalse(result["created"])
380
388
  self.assertEqual(result["kind"], RFID.NTAG215)
389
+ self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
390
+
391
+ @patch("ocpp.rfid.reader.timezone.now")
392
+ @patch("ocpp.rfid.reader.notify_async")
393
+ @patch("ocpp.rfid.reader.RFID.register_scan")
394
+ def test_registers_little_endian_value(
395
+ self, mock_register, mock_notify, mock_now
396
+ ):
397
+ fake_now = object()
398
+ mock_now.return_value = fake_now
399
+ tag = MagicMock()
400
+ tag.pk = 7
401
+ tag.label_id = 7
402
+ tag.allowed = True
403
+ tag.color = "B"
404
+ tag.released = False
405
+ tag.reference = None
406
+ tag.kind = RFID.CLASSIC
407
+ tag.endianness = RFID.LITTLE_ENDIAN
408
+ mock_register.return_value = (tag, True)
409
+
410
+ result = validate_rfid_value("A1B2C3D4", endianness=RFID.LITTLE_ENDIAN)
411
+
412
+ mock_register.assert_called_once_with(
413
+ "D4C3B2A1", kind=None, endianness=RFID.LITTLE_ENDIAN
414
+ )
415
+ tag.save.assert_called_once_with(update_fields=["last_seen_on"])
416
+ self.assertEqual(result["rfid"], "D4C3B2A1")
417
+ self.assertEqual(result["endianness"], RFID.LITTLE_ENDIAN)
418
+ mock_notify.assert_called_once()
381
419
 
382
420
  def test_rejects_invalid_value(self):
383
421
  result = validate_rfid_value("invalid!")
@@ -394,10 +432,11 @@ class ValidateRfidValueTests(SimpleTestCase):
394
432
 
395
433
  @patch("ocpp.rfid.reader.timezone.now")
396
434
  @patch("ocpp.rfid.reader.notify_async")
435
+ @patch("ocpp.rfid.reader.subprocess.Popen")
397
436
  @patch("ocpp.rfid.reader.subprocess.run")
398
437
  @patch("ocpp.rfid.reader.RFID.register_scan")
399
438
  def test_external_command_success(
400
- self, mock_register, mock_run, mock_notify, mock_now
439
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
401
440
  ):
402
441
  fake_now = object()
403
442
  mock_now.return_value = fake_now
@@ -410,10 +449,12 @@ class ValidateRfidValueTests(SimpleTestCase):
410
449
  tag.released = False
411
450
  tag.reference = None
412
451
  tag.kind = RFID.CLASSIC
452
+ tag.endianness = RFID.BIG_ENDIAN
413
453
  mock_register.return_value = (tag, False)
414
454
  mock_run.return_value = types.SimpleNamespace(
415
455
  returncode=0, stdout="ok\n", stderr=""
416
456
  )
457
+ mock_popen.return_value = object()
417
458
 
418
459
  result = validate_rfid_value("abcd1234")
419
460
 
@@ -424,6 +465,8 @@ class ValidateRfidValueTests(SimpleTestCase):
424
465
  env = run_kwargs.get("env", {})
425
466
  self.assertEqual(env.get("RFID_VALUE"), "ABCD1234")
426
467
  self.assertEqual(env.get("RFID_LABEL_ID"), "1")
468
+ self.assertEqual(env.get("RFID_ENDIANNESS"), RFID.BIG_ENDIAN)
469
+ mock_popen.assert_not_called()
427
470
  mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
428
471
  tag.save.assert_called_once_with(update_fields=["last_seen_on"])
429
472
  self.assertTrue(result["allowed"])
@@ -433,13 +476,15 @@ class ValidateRfidValueTests(SimpleTestCase):
433
476
  self.assertEqual(output.get("stderr"), "")
434
477
  self.assertEqual(output.get("returncode"), 0)
435
478
  self.assertEqual(output.get("error"), "")
479
+ self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
436
480
 
437
481
  @patch("ocpp.rfid.reader.timezone.now")
438
482
  @patch("ocpp.rfid.reader.notify_async")
483
+ @patch("ocpp.rfid.reader.subprocess.Popen")
439
484
  @patch("ocpp.rfid.reader.subprocess.run")
440
485
  @patch("ocpp.rfid.reader.RFID.register_scan")
441
486
  def test_external_command_failure_blocks_tag(
442
- self, mock_register, mock_run, mock_notify, mock_now
487
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
443
488
  ):
444
489
  fake_now = object()
445
490
  mock_now.return_value = fake_now
@@ -452,10 +497,12 @@ class ValidateRfidValueTests(SimpleTestCase):
452
497
  tag.released = False
453
498
  tag.reference = None
454
499
  tag.kind = RFID.CLASSIC
500
+ tag.endianness = RFID.BIG_ENDIAN
455
501
  mock_register.return_value = (tag, False)
456
502
  mock_run.return_value = types.SimpleNamespace(
457
503
  returncode=1, stdout="", stderr="failure"
458
504
  )
505
+ mock_popen.return_value = object()
459
506
 
460
507
  result = validate_rfid_value("ffff")
461
508
 
@@ -469,6 +516,46 @@ class ValidateRfidValueTests(SimpleTestCase):
469
516
  self.assertEqual(output.get("stdout"), "")
470
517
  self.assertEqual(output.get("stderr"), "failure")
471
518
  self.assertEqual(output.get("error"), "")
519
+ mock_popen.assert_not_called()
520
+ self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
521
+
522
+ @patch("ocpp.rfid.reader.timezone.now")
523
+ @patch("ocpp.rfid.reader.notify_async")
524
+ @patch("ocpp.rfid.reader.subprocess.Popen")
525
+ @patch("ocpp.rfid.reader.subprocess.run")
526
+ @patch("ocpp.rfid.reader.RFID.register_scan")
527
+ def test_post_command_runs_after_success(
528
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
529
+ ):
530
+ fake_now = object()
531
+ mock_now.return_value = fake_now
532
+ tag = MagicMock()
533
+ tag.pk = 3
534
+ tag.label_id = 3
535
+ tag.allowed = True
536
+ tag.external_command = ""
537
+ tag.post_auth_command = "echo done"
538
+ tag.color = "B"
539
+ tag.released = False
540
+ tag.reference = None
541
+ tag.kind = RFID.CLASSIC
542
+ tag.endianness = RFID.BIG_ENDIAN
543
+ mock_register.return_value = (tag, False)
544
+ result = validate_rfid_value("abcdef")
545
+
546
+ mock_run.assert_not_called()
547
+ mock_popen.assert_called_once()
548
+ args, kwargs = mock_popen.call_args
549
+ self.assertEqual(args[0], "echo done")
550
+ env = kwargs.get("env", {})
551
+ self.assertEqual(env.get("RFID_VALUE"), "ABCDEF")
552
+ self.assertEqual(env.get("RFID_LABEL_ID"), "3")
553
+ self.assertEqual(env.get("RFID_ENDIANNESS"), RFID.BIG_ENDIAN)
554
+ self.assertIs(kwargs.get("stdout"), subprocess.DEVNULL)
555
+ self.assertIs(kwargs.get("stderr"), subprocess.DEVNULL)
556
+ self.assertTrue(result["allowed"])
557
+ mock_notify.assert_called_once_with("RFID 3 OK", "ABCDEF B")
558
+ self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
472
559
 
473
560
 
474
561
  class CardTypeDetectionTests(TestCase):
ocpp/tests.py CHANGED
@@ -40,6 +40,7 @@ from django.test import (
40
40
  TestCase,
41
41
  override_settings,
42
42
  )
43
+ from django.conf import settings
43
44
  from unittest import skip
44
45
  from contextlib import suppress
45
46
  from types import SimpleNamespace
@@ -79,7 +80,7 @@ from .simulator import SimulatorConfig, ChargePointSimulator
79
80
  from .evcs import simulate, SimulatorState, _simulators
80
81
  import re
81
82
  from datetime import datetime, timedelta, timezone as dt_timezone
82
- from .tasks import purge_meter_readings
83
+ from .tasks import purge_meter_readings, send_daily_session_report
83
84
  from django.db import close_old_connections
84
85
  from django.db.utils import OperationalError
85
86
  from urllib.parse import unquote, urlparse
@@ -1930,6 +1931,27 @@ class ChargerLandingTests(TestCase):
1930
1931
  finally:
1931
1932
  store.transactions.pop(key, None)
1932
1933
 
1934
+ def test_public_page_shows_available_when_status_stale(self):
1935
+ charger = Charger.objects.create(
1936
+ charger_id="STALEPUB",
1937
+ last_status="Charging",
1938
+ )
1939
+ response = self.client.get(reverse("charger-page", args=["STALEPUB"]))
1940
+ self.assertEqual(response.status_code, 200)
1941
+ self.assertContains(
1942
+ response,
1943
+ 'style="background-color: #0d6efd; color: #fff;">Available</span>',
1944
+ )
1945
+
1946
+ def test_admin_status_shows_available_when_status_stale(self):
1947
+ charger = Charger.objects.create(
1948
+ charger_id="STALEADM",
1949
+ last_status="Charging",
1950
+ )
1951
+ response = self.client.get(reverse("charger-status", args=["STALEADM"]))
1952
+ self.assertEqual(response.status_code, 200)
1953
+ self.assertContains(response, 'id="charger-state">Available</strong>')
1954
+
1933
1955
  def test_public_status_shows_rfid_link_for_known_tag(self):
1934
1956
  aggregate = Charger.objects.create(charger_id="PUBRFID")
1935
1957
  connector = Charger.objects.create(
@@ -2128,6 +2150,32 @@ class ChargerAdminTests(TestCase):
2128
2150
  resp = self.client.get(url)
2129
2151
  self.assertNotContains(resp, charger.reference.image.url)
2130
2152
 
2153
+ def test_toggle_rfid_authentication_action_toggles_value(self):
2154
+ charger_requires = Charger.objects.create(
2155
+ charger_id="RFIDON", require_rfid=True
2156
+ )
2157
+ charger_optional = Charger.objects.create(
2158
+ charger_id="RFIDOFF", require_rfid=False
2159
+ )
2160
+ url = reverse("admin:ocpp_charger_changelist")
2161
+ response = self.client.post(
2162
+ url,
2163
+ {
2164
+ "action": "toggle_rfid_authentication",
2165
+ "_selected_action": [
2166
+ charger_requires.pk,
2167
+ charger_optional.pk,
2168
+ ],
2169
+ },
2170
+ follow=True,
2171
+ )
2172
+ self.assertEqual(response.status_code, 200)
2173
+ charger_requires.refresh_from_db()
2174
+ charger_optional.refresh_from_db()
2175
+ self.assertFalse(charger_requires.require_rfid)
2176
+ self.assertTrue(charger_optional.require_rfid)
2177
+ self.assertContains(response, "Updated RFID authentication")
2178
+
2131
2179
  def test_admin_lists_log_link(self):
2132
2180
  charger = Charger.objects.create(charger_id="LOG1")
2133
2181
  url = reverse("admin:ocpp_charger_changelist")
@@ -2154,6 +2202,48 @@ class ChargerAdminTests(TestCase):
2154
2202
  finally:
2155
2203
  store.transactions.pop(key, None)
2156
2204
 
2205
+ def test_admin_status_shows_available_when_status_stale(self):
2206
+ charger = Charger.objects.create(
2207
+ charger_id="ADMINSTALE",
2208
+ last_status="Charging",
2209
+ )
2210
+ url = reverse("admin:ocpp_charger_changelist")
2211
+ resp = self.client.get(url)
2212
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
2213
+ self.assertContains(resp, f">{available_label}<")
2214
+
2215
+ def test_recheck_charger_status_action_sends_trigger(self):
2216
+ charger = Charger.objects.create(charger_id="RECHECK1")
2217
+
2218
+ class DummyConnection:
2219
+ def __init__(self):
2220
+ self.sent: list[str] = []
2221
+
2222
+ async def send(self, message):
2223
+ self.sent.append(message)
2224
+
2225
+ ws = DummyConnection()
2226
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2227
+ try:
2228
+ url = reverse("admin:ocpp_charger_changelist")
2229
+ response = self.client.post(
2230
+ url,
2231
+ {
2232
+ "action": "recheck_charger_status",
2233
+ "index": 0,
2234
+ "select_across": 0,
2235
+ "_selected_action": [charger.pk],
2236
+ },
2237
+ follow=True,
2238
+ )
2239
+ self.assertEqual(response.status_code, 200)
2240
+ self.assertTrue(ws.sent)
2241
+ self.assertIn("TriggerMessage", ws.sent[0])
2242
+ self.assertContains(response, "Requested status update")
2243
+ finally:
2244
+ store.pop_connection(charger.charger_id, charger.connector_id)
2245
+ store.clear_pending_calls(charger.charger_id)
2246
+
2157
2247
  def test_admin_log_view_displays_entries(self):
2158
2248
  charger = Charger.objects.create(charger_id="LOG2")
2159
2249
  log_id = store.identity_key(charger.charger_id, charger.connector_id)
@@ -2981,6 +3071,46 @@ class SimulatorAdminTests(TransactionTestCase):
2981
3071
 
2982
3072
  await communicator.disconnect()
2983
3073
 
3074
+ async def test_heartbeat_refreshes_aggregate_after_connector_status(self):
3075
+ store.ip_connections.clear()
3076
+ store.connections.clear()
3077
+ await database_sync_to_async(Charger.objects.create)(charger_id="HBAGG")
3078
+ communicator = WebsocketCommunicator(application, "/HBAGG/")
3079
+ connect_result = await communicator.connect()
3080
+ self.assertTrue(connect_result[0], connect_result)
3081
+
3082
+ status_payload = {
3083
+ "connectorId": 2,
3084
+ "status": "Faulted",
3085
+ "errorCode": "ReaderFailure",
3086
+ }
3087
+ await communicator.send_json_to(
3088
+ [2, "1", "StatusNotification", status_payload]
3089
+ )
3090
+ await communicator.receive_json_from()
3091
+
3092
+ aggregate = await database_sync_to_async(Charger.objects.get)(
3093
+ charger_id="HBAGG", connector_id=None
3094
+ )
3095
+ connector = await database_sync_to_async(Charger.objects.get)(
3096
+ charger_id="HBAGG", connector_id=2
3097
+ )
3098
+ previous_heartbeat = aggregate.last_heartbeat
3099
+
3100
+ await communicator.send_json_to([2, "2", "Heartbeat", {}])
3101
+ await communicator.receive_json_from()
3102
+
3103
+ await database_sync_to_async(aggregate.refresh_from_db)()
3104
+ await database_sync_to_async(connector.refresh_from_db)()
3105
+
3106
+ self.assertIsNotNone(aggregate.last_heartbeat)
3107
+ if previous_heartbeat:
3108
+ self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
3109
+ if connector.last_heartbeat:
3110
+ self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
3111
+
3112
+ await communicator.disconnect()
3113
+
2984
3114
 
2985
3115
  class ChargerLocationTests(TestCase):
2986
3116
  def test_lat_lon_fields_saved(self):
@@ -3676,6 +3806,75 @@ class PurgeMeterReadingsTaskTests(TestCase):
3676
3806
  self.assertTrue(MeterReading.objects.filter(pk=reading.pk).exists())
3677
3807
 
3678
3808
 
3809
+ class DailySessionReportTaskTests(TestCase):
3810
+ def setUp(self):
3811
+ super().setUp()
3812
+ self.locks_dir = Path(settings.BASE_DIR) / "locks"
3813
+ self.locks_dir.mkdir(parents=True, exist_ok=True)
3814
+ self.celery_lock = self.locks_dir / "celery.lck"
3815
+ self.celery_lock.write_text("")
3816
+ self.addCleanup(self._cleanup_lock)
3817
+
3818
+ def _cleanup_lock(self):
3819
+ try:
3820
+ self.celery_lock.unlink()
3821
+ except FileNotFoundError:
3822
+ pass
3823
+
3824
+ def test_report_sends_email_when_sessions_exist(self):
3825
+ User = get_user_model()
3826
+ User.objects.create_superuser(
3827
+ username="report-admin",
3828
+ email="report-admin@example.com",
3829
+ password="pw",
3830
+ )
3831
+ charger = Charger.objects.create(charger_id="RPT1", display_name="Pod 1")
3832
+ start = timezone.now().replace(hour=10, minute=0, second=0, microsecond=0)
3833
+ Transaction.objects.create(
3834
+ charger=charger,
3835
+ start_time=start,
3836
+ stop_time=start + timedelta(hours=1),
3837
+ meter_start=0,
3838
+ meter_stop=2500,
3839
+ connector_id=2,
3840
+ rfid="AA11",
3841
+ )
3842
+
3843
+ with patch("core.mailer.can_send_email", return_value=True), patch(
3844
+ "core.mailer.send"
3845
+ ) as mock_send:
3846
+ count = send_daily_session_report()
3847
+
3848
+ self.assertEqual(count, 1)
3849
+ self.assertTrue(mock_send.called)
3850
+ args, _kwargs = mock_send.call_args
3851
+ self.assertIn("OCPP session report", args[0])
3852
+ self.assertIn("Pod 1", args[1])
3853
+ self.assertIn("report-admin@example.com", args[2])
3854
+ self.assertGreaterEqual(len(args[2]), 1)
3855
+
3856
+ def test_report_skips_when_no_sessions(self):
3857
+ with patch("core.mailer.can_send_email", return_value=True), patch(
3858
+ "core.mailer.send"
3859
+ ) as mock_send:
3860
+ count = send_daily_session_report()
3861
+
3862
+ self.assertEqual(count, 0)
3863
+ mock_send.assert_not_called()
3864
+
3865
+ def test_report_skips_without_celery_feature(self):
3866
+ if self.celery_lock.exists():
3867
+ self.celery_lock.unlink()
3868
+
3869
+ with patch("core.mailer.can_send_email", return_value=True), patch(
3870
+ "core.mailer.send"
3871
+ ) as mock_send:
3872
+ count = send_daily_session_report()
3873
+
3874
+ self.assertEqual(count, 0)
3875
+ mock_send.assert_not_called()
3876
+
3877
+
3679
3878
  class TransactionKwTests(TestCase):
3680
3879
  def test_kw_sums_meter_readings(self):
3681
3880
  charger = Charger.objects.create(charger_id="SUM1")
@@ -4332,6 +4531,49 @@ class LiveUpdateViewTests(TestCase):
4332
4531
  )
4333
4532
  self.assertEqual(aggregate_entry["state"], available_label)
4334
4533
 
4534
+ def test_dashboard_connector_treats_finishing_as_available_without_session(self):
4535
+ charger = Charger.objects.create(
4536
+ charger_id="FINISH-STATE",
4537
+ connector_id=1,
4538
+ last_status="Finishing",
4539
+ )
4540
+
4541
+ resp = self.client.get(reverse("ocpp-dashboard"))
4542
+ self.assertEqual(resp.status_code, 200)
4543
+ self.assertIsNotNone(resp.context)
4544
+ context = resp.context
4545
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
4546
+ entry = next(
4547
+ item
4548
+ for item in context["chargers"]
4549
+ if item["charger"].pk == charger.pk
4550
+ )
4551
+ self.assertEqual(entry["state"], available_label)
4552
+
4553
+ def test_dashboard_aggregate_treats_finishing_as_available_without_session(self):
4554
+ aggregate = Charger.objects.create(
4555
+ charger_id="FINISH-AGG",
4556
+ connector_id=None,
4557
+ last_status="Finishing",
4558
+ )
4559
+ Charger.objects.create(
4560
+ charger_id=aggregate.charger_id,
4561
+ connector_id=1,
4562
+ last_status="Finishing",
4563
+ )
4564
+
4565
+ resp = self.client.get(reverse("ocpp-dashboard"))
4566
+ self.assertEqual(resp.status_code, 200)
4567
+ self.assertIsNotNone(resp.context)
4568
+ context = resp.context
4569
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
4570
+ aggregate_entry = next(
4571
+ item
4572
+ for item in context["chargers"]
4573
+ if item["charger"].pk == aggregate.pk
4574
+ )
4575
+ self.assertEqual(aggregate_entry["state"], available_label)
4576
+
4335
4577
  def test_dashboard_aggregate_uses_connection_when_status_missing(self):
4336
4578
  aggregate = Charger.objects.create(
4337
4579
  charger_id="DASHAGG-CONN", last_status="Charging"