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

nodes/tests.py CHANGED
@@ -1293,6 +1293,51 @@ class NodeRegisterCurrentTests(TestCase):
1293
1293
  PeriodicTask.objects.filter(name="pages_purge_landing_leads").exists()
1294
1294
  )
1295
1295
 
1296
+ def test_ocpp_session_report_task_syncs_with_feature(self):
1297
+ feature, _ = NodeFeature.objects.get_or_create(
1298
+ slug="celery-queue", defaults={"display": "Celery Queue"}
1299
+ )
1300
+ node, _ = Node.objects.update_or_create(
1301
+ mac_address=Node.get_current_mac(),
1302
+ defaults={
1303
+ "hostname": socket.gethostname(),
1304
+ "address": "127.0.0.1",
1305
+ "port": 9400,
1306
+ "base_path": settings.BASE_DIR,
1307
+ },
1308
+ )
1309
+ task_name = "ocpp_send_daily_session_report"
1310
+ PeriodicTask.objects.filter(name=task_name).delete()
1311
+
1312
+ with patch("nodes.models.mailer.can_send_email", return_value=True):
1313
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1314
+
1315
+ self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
1316
+
1317
+ NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
1318
+ self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
1319
+
1320
+ def test_ocpp_session_report_task_requires_email(self):
1321
+ feature, _ = NodeFeature.objects.get_or_create(
1322
+ slug="celery-queue", defaults={"display": "Celery Queue"}
1323
+ )
1324
+ node, _ = Node.objects.update_or_create(
1325
+ mac_address=Node.get_current_mac(),
1326
+ defaults={
1327
+ "hostname": socket.gethostname(),
1328
+ "address": "127.0.0.1",
1329
+ "port": 9500,
1330
+ "base_path": settings.BASE_DIR,
1331
+ },
1332
+ )
1333
+ task_name = "ocpp_send_daily_session_report"
1334
+ PeriodicTask.objects.filter(name=task_name).delete()
1335
+
1336
+ with patch("nodes.models.mailer.can_send_email", return_value=False):
1337
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1338
+
1339
+ self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
1340
+
1296
1341
 
1297
1342
  class CheckRegistrationReadyCommandTests(TestCase):
1298
1343
  def test_command_completes_successfully(self):
@@ -1348,6 +1393,16 @@ class NodeAdminTests(TestCase):
1348
1393
  self.assertContains(response, f'href="{snapshot_url}"')
1349
1394
  self.assertContains(response, f'href="{stream_url}"')
1350
1395
 
1396
+ def test_node_feature_list_shows_waveform_action_when_enabled(self):
1397
+ node = self._create_local_node()
1398
+ feature, _ = NodeFeature.objects.get_or_create(
1399
+ slug="audio-capture", defaults={"display": "Audio Capture"}
1400
+ )
1401
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1402
+ response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
1403
+ action_url = reverse("admin:nodes_nodefeature_view_waveform")
1404
+ self.assertContains(response, f'href="{action_url}"')
1405
+
1351
1406
  def test_node_feature_list_hides_default_action_when_disabled(self):
1352
1407
  self._create_local_node()
1353
1408
  NodeFeature.objects.get_or_create(
@@ -1750,6 +1805,34 @@ class NodeAdminTests(TestCase):
1750
1805
  self.assertEqual(response.context_data["stream_embed"], "unsupported")
1751
1806
  self.assertContains(response, "camera-stream__unsupported")
1752
1807
 
1808
+ def test_view_waveform_requires_enabled_feature(self):
1809
+ self._create_local_node()
1810
+ NodeFeature.objects.get_or_create(
1811
+ slug="audio-capture", defaults={"display": "Audio Capture"}
1812
+ )
1813
+ response = self.client.get(
1814
+ reverse("admin:nodes_nodefeature_view_waveform"), follow=True
1815
+ )
1816
+ self.assertEqual(response.status_code, 200)
1817
+ changelist_url = reverse("admin:nodes_nodefeature_changelist")
1818
+ self.assertEqual(response.wsgi_request.path, changelist_url)
1819
+ self.assertContains(
1820
+ response, "Audio Capture feature is not enabled on this node."
1821
+ )
1822
+
1823
+ def test_view_waveform_renders_when_feature_enabled(self):
1824
+ node = self._create_local_node()
1825
+ feature, _ = NodeFeature.objects.get_or_create(
1826
+ slug="audio-capture", defaults={"display": "Audio Capture"}
1827
+ )
1828
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1829
+ response = self.client.get(reverse("admin:nodes_nodefeature_view_waveform"))
1830
+ self.assertEqual(response.status_code, 200)
1831
+ response.render()
1832
+ self.assertEqual(response.context_data["feature"], feature)
1833
+ self.assertEqual(response.context_data["title"], "Audio Capture Waveform")
1834
+ self.assertContains(response, "audio-capture__canvas")
1835
+
1753
1836
  @patch("nodes.admin.requests.post")
1754
1837
  def test_import_rfids_action_fetches_and_imports(self, mock_post):
1755
1838
  local = self._create_local_node()
@@ -2496,6 +2579,8 @@ class NetMessagePropagationTests(TestCase):
2496
2579
  def test_propagate_forwards_to_three_and_notifies_local(
2497
2580
  self, mock_notify, mock_post
2498
2581
  ):
2582
+ mock_post.return_value.ok = True
2583
+ mock_post.return_value.status_code = 200
2499
2584
  msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
2500
2585
  with patch.object(Node, "get_local", return_value=self.local):
2501
2586
  msg.propagate(seen=[str(self.remotes[0].uuid)])
@@ -2512,6 +2597,13 @@ class NetMessagePropagationTests(TestCase):
2512
2597
  self.assertNotIn(sender_addr, targets)
2513
2598
  self.assertEqual(msg.propagated_to.count(), 4)
2514
2599
  self.assertTrue(msg.complete)
2600
+ self.assertEqual(len(msg.confirmed_peers), mock_post.call_count)
2601
+ self.assertTrue(
2602
+ all(entry["status"] == "acknowledged" for entry in msg.confirmed_peers.values())
2603
+ )
2604
+ self.assertTrue(
2605
+ all(entry["status_code"] == 200 for entry in msg.confirmed_peers.values())
2606
+ )
2515
2607
 
2516
2608
  @patch("requests.post")
2517
2609
  @patch("core.notifications.notify", return_value=False)
@@ -2587,6 +2679,21 @@ class NetMessagePropagationTests(TestCase):
2587
2679
  self.assertTrue(NetMessage.objects.filter(pk=old_remote.pk).exists())
2588
2680
  self.assertTrue(NetMessage.objects.filter(pk=msg.pk).exists())
2589
2681
 
2682
+ @patch("core.notifications.notify", return_value=False)
2683
+ def test_propagate_records_error_status(self, mock_notify):
2684
+ msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
2685
+ with (
2686
+ patch.object(Node, "get_local", return_value=self.local),
2687
+ patch("random.shuffle", side_effect=lambda seq: None),
2688
+ patch("requests.post", side_effect=Exception("boom")),
2689
+ ):
2690
+ msg.propagate()
2691
+
2692
+ self.assertTrue(msg.confirmed_peers)
2693
+ self.assertTrue(
2694
+ all(entry["status"] == "error" for entry in msg.confirmed_peers.values())
2695
+ )
2696
+
2590
2697
 
2591
2698
  class NetMessageSignatureTests(TestCase):
2592
2699
  def setUp(self):
@@ -3196,6 +3303,18 @@ class NodeFeatureTests(TestCase):
3196
3303
  self.assertIn("Take a Snapshot", labels)
3197
3304
  self.assertIn("View stream", labels)
3198
3305
 
3306
+ def test_audio_capture_feature_has_view_waveform_action(self):
3307
+ feature = NodeFeature.objects.create(
3308
+ slug="audio-capture", display="Audio Capture"
3309
+ )
3310
+ actions = feature.get_default_actions()
3311
+ self.assertEqual(len(actions), 1)
3312
+ action = actions[0]
3313
+ self.assertEqual(action.label, "View Waveform")
3314
+ self.assertEqual(
3315
+ action.url_name, "admin:nodes_nodefeature_view_waveform"
3316
+ )
3317
+
3199
3318
  def test_default_action_missing_when_unconfigured(self):
3200
3319
  feature = NodeFeature.objects.create(
3201
3320
  slug="custom-feature", display="Custom Feature"
nodes/utils.py CHANGED
@@ -89,6 +89,7 @@ def save_screenshot(
89
89
  transaction_uuid=None,
90
90
  *,
91
91
  content: str | None = None,
92
+ user=None,
92
93
  ):
93
94
  """Save screenshot file info if not already recorded.
94
95
 
@@ -115,6 +116,8 @@ def save_screenshot(
115
116
  data["transaction_uuid"] = transaction_uuid
116
117
  if content is not None:
117
118
  data["content"] = content
119
+ if user is not None:
120
+ data["user"] = user
118
121
  with suppress_default_classifiers():
119
122
  sample = ContentSample.objects.create(**data)
120
123
  run_default_classifiers(sample)
ocpp/consumers.py CHANGED
@@ -321,6 +321,44 @@ class CSMSConsumer(AsyncWebsocketConsumer):
321
321
  except (TypeError, ValueError):
322
322
  return
323
323
  if connector_value is None:
324
+ aggregate = self.aggregate_charger
325
+ if (
326
+ not aggregate
327
+ or aggregate.connector_id is not None
328
+ or aggregate.charger_id != self.charger_id
329
+ ):
330
+ aggregate, _ = await database_sync_to_async(
331
+ Charger.objects.get_or_create
332
+ )(
333
+ charger_id=self.charger_id,
334
+ connector_id=None,
335
+ defaults={"last_path": self.scope.get("path", "")},
336
+ )
337
+ await database_sync_to_async(aggregate.refresh_manager_node)()
338
+ self.aggregate_charger = aggregate
339
+ self.charger = self.aggregate_charger
340
+ previous_key = self.store_key
341
+ new_key = store.identity_key(self.charger_id, None)
342
+ if previous_key != new_key:
343
+ existing_consumer = store.connections.get(new_key)
344
+ if existing_consumer is not None and existing_consumer is not self:
345
+ await existing_consumer.close()
346
+ store.reassign_identity(previous_key, new_key)
347
+ store.connections[new_key] = self
348
+ store.logs["charger"].setdefault(new_key, [])
349
+ aggregate_name = await sync_to_async(
350
+ lambda: self.charger.name or self.charger.charger_id
351
+ )()
352
+ friendly_name = aggregate_name or self.charger_id
353
+ store.register_log_name(new_key, friendly_name, log_type="charger")
354
+ store.register_log_name(
355
+ store.identity_key(self.charger_id, None),
356
+ friendly_name,
357
+ log_type="charger",
358
+ )
359
+ store.register_log_name(self.charger_id, friendly_name, log_type="charger")
360
+ self.store_key = new_key
361
+ self.connector_value = None
324
362
  if not self._header_reference_created and self.client_ip:
325
363
  await database_sync_to_async(self._ensure_console_reference)()
326
364
  self._header_reference_created = True
ocpp/models.py CHANGED
@@ -854,11 +854,26 @@ class DataTransferMessage(models.Model):
854
854
  on_delete=models.CASCADE,
855
855
  related_name="data_transfer_messages",
856
856
  )
857
- connector_id = models.PositiveIntegerField(null=True, blank=True)
857
+ connector_id = models.PositiveIntegerField(
858
+ null=True,
859
+ blank=True,
860
+ verbose_name="Connector ID",
861
+ )
858
862
  direction = models.CharField(max_length=16, choices=DIRECTION_CHOICES)
859
- ocpp_message_id = models.CharField(max_length=64)
860
- vendor_id = models.CharField(max_length=255, blank=True)
861
- message_id = models.CharField(max_length=255, blank=True)
863
+ ocpp_message_id = models.CharField(
864
+ max_length=64,
865
+ verbose_name="OCPP message ID",
866
+ )
867
+ vendor_id = models.CharField(
868
+ max_length=255,
869
+ blank=True,
870
+ verbose_name="Vendor ID",
871
+ )
872
+ message_id = models.CharField(
873
+ max_length=255,
874
+ blank=True,
875
+ verbose_name="Message ID",
876
+ )
862
877
  payload = models.JSONField(default=dict, blank=True)
863
878
  status = models.CharField(max_length=64, blank=True)
864
879
  response_data = models.JSONField(null=True, blank=True)
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
@@ -394,10 +395,11 @@ class ValidateRfidValueTests(SimpleTestCase):
394
395
 
395
396
  @patch("ocpp.rfid.reader.timezone.now")
396
397
  @patch("ocpp.rfid.reader.notify_async")
398
+ @patch("ocpp.rfid.reader.subprocess.Popen")
397
399
  @patch("ocpp.rfid.reader.subprocess.run")
398
400
  @patch("ocpp.rfid.reader.RFID.register_scan")
399
401
  def test_external_command_success(
400
- self, mock_register, mock_run, mock_notify, mock_now
402
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
401
403
  ):
402
404
  fake_now = object()
403
405
  mock_now.return_value = fake_now
@@ -414,6 +416,7 @@ class ValidateRfidValueTests(SimpleTestCase):
414
416
  mock_run.return_value = types.SimpleNamespace(
415
417
  returncode=0, stdout="ok\n", stderr=""
416
418
  )
419
+ mock_popen.return_value = object()
417
420
 
418
421
  result = validate_rfid_value("abcd1234")
419
422
 
@@ -424,6 +427,7 @@ class ValidateRfidValueTests(SimpleTestCase):
424
427
  env = run_kwargs.get("env", {})
425
428
  self.assertEqual(env.get("RFID_VALUE"), "ABCD1234")
426
429
  self.assertEqual(env.get("RFID_LABEL_ID"), "1")
430
+ mock_popen.assert_not_called()
427
431
  mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
428
432
  tag.save.assert_called_once_with(update_fields=["last_seen_on"])
429
433
  self.assertTrue(result["allowed"])
@@ -436,10 +440,11 @@ class ValidateRfidValueTests(SimpleTestCase):
436
440
 
437
441
  @patch("ocpp.rfid.reader.timezone.now")
438
442
  @patch("ocpp.rfid.reader.notify_async")
443
+ @patch("ocpp.rfid.reader.subprocess.Popen")
439
444
  @patch("ocpp.rfid.reader.subprocess.run")
440
445
  @patch("ocpp.rfid.reader.RFID.register_scan")
441
446
  def test_external_command_failure_blocks_tag(
442
- self, mock_register, mock_run, mock_notify, mock_now
447
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
443
448
  ):
444
449
  fake_now = object()
445
450
  mock_now.return_value = fake_now
@@ -456,6 +461,7 @@ class ValidateRfidValueTests(SimpleTestCase):
456
461
  mock_run.return_value = types.SimpleNamespace(
457
462
  returncode=1, stdout="", stderr="failure"
458
463
  )
464
+ mock_popen.return_value = object()
459
465
 
460
466
  result = validate_rfid_value("ffff")
461
467
 
@@ -469,6 +475,42 @@ class ValidateRfidValueTests(SimpleTestCase):
469
475
  self.assertEqual(output.get("stdout"), "")
470
476
  self.assertEqual(output.get("stderr"), "failure")
471
477
  self.assertEqual(output.get("error"), "")
478
+ mock_popen.assert_not_called()
479
+
480
+ @patch("ocpp.rfid.reader.timezone.now")
481
+ @patch("ocpp.rfid.reader.notify_async")
482
+ @patch("ocpp.rfid.reader.subprocess.Popen")
483
+ @patch("ocpp.rfid.reader.subprocess.run")
484
+ @patch("ocpp.rfid.reader.RFID.register_scan")
485
+ def test_post_command_runs_after_success(
486
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
487
+ ):
488
+ fake_now = object()
489
+ mock_now.return_value = fake_now
490
+ tag = MagicMock()
491
+ tag.pk = 3
492
+ tag.label_id = 3
493
+ tag.allowed = True
494
+ tag.external_command = ""
495
+ tag.post_auth_command = "echo done"
496
+ tag.color = "B"
497
+ tag.released = False
498
+ tag.reference = None
499
+ tag.kind = RFID.CLASSIC
500
+ mock_register.return_value = (tag, False)
501
+ result = validate_rfid_value("abcdef")
502
+
503
+ mock_run.assert_not_called()
504
+ mock_popen.assert_called_once()
505
+ args, kwargs = mock_popen.call_args
506
+ self.assertEqual(args[0], "echo done")
507
+ env = kwargs.get("env", {})
508
+ self.assertEqual(env.get("RFID_VALUE"), "ABCDEF")
509
+ self.assertEqual(env.get("RFID_LABEL_ID"), "3")
510
+ self.assertIs(kwargs.get("stdout"), subprocess.DEVNULL)
511
+ self.assertIs(kwargs.get("stderr"), subprocess.DEVNULL)
512
+ self.assertTrue(result["allowed"])
513
+ mock_notify.assert_called_once_with("RFID 3 OK", "ABCDEF B")
472
514
 
473
515
 
474
516
  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
@@ -2981,6 +2982,46 @@ class SimulatorAdminTests(TransactionTestCase):
2981
2982
 
2982
2983
  await communicator.disconnect()
2983
2984
 
2985
+ async def test_heartbeat_refreshes_aggregate_after_connector_status(self):
2986
+ store.ip_connections.clear()
2987
+ store.connections.clear()
2988
+ await database_sync_to_async(Charger.objects.create)(charger_id="HBAGG")
2989
+ communicator = WebsocketCommunicator(application, "/HBAGG/")
2990
+ connect_result = await communicator.connect()
2991
+ self.assertTrue(connect_result[0], connect_result)
2992
+
2993
+ status_payload = {
2994
+ "connectorId": 2,
2995
+ "status": "Faulted",
2996
+ "errorCode": "ReaderFailure",
2997
+ }
2998
+ await communicator.send_json_to(
2999
+ [2, "1", "StatusNotification", status_payload]
3000
+ )
3001
+ await communicator.receive_json_from()
3002
+
3003
+ aggregate = await database_sync_to_async(Charger.objects.get)(
3004
+ charger_id="HBAGG", connector_id=None
3005
+ )
3006
+ connector = await database_sync_to_async(Charger.objects.get)(
3007
+ charger_id="HBAGG", connector_id=2
3008
+ )
3009
+ previous_heartbeat = aggregate.last_heartbeat
3010
+
3011
+ await communicator.send_json_to([2, "2", "Heartbeat", {}])
3012
+ await communicator.receive_json_from()
3013
+
3014
+ await database_sync_to_async(aggregate.refresh_from_db)()
3015
+ await database_sync_to_async(connector.refresh_from_db)()
3016
+
3017
+ self.assertIsNotNone(aggregate.last_heartbeat)
3018
+ if previous_heartbeat:
3019
+ self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
3020
+ if connector.last_heartbeat:
3021
+ self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
3022
+
3023
+ await communicator.disconnect()
3024
+
2984
3025
 
2985
3026
  class ChargerLocationTests(TestCase):
2986
3027
  def test_lat_lon_fields_saved(self):
@@ -3676,6 +3717,75 @@ class PurgeMeterReadingsTaskTests(TestCase):
3676
3717
  self.assertTrue(MeterReading.objects.filter(pk=reading.pk).exists())
3677
3718
 
3678
3719
 
3720
+ class DailySessionReportTaskTests(TestCase):
3721
+ def setUp(self):
3722
+ super().setUp()
3723
+ self.locks_dir = Path(settings.BASE_DIR) / "locks"
3724
+ self.locks_dir.mkdir(parents=True, exist_ok=True)
3725
+ self.celery_lock = self.locks_dir / "celery.lck"
3726
+ self.celery_lock.write_text("")
3727
+ self.addCleanup(self._cleanup_lock)
3728
+
3729
+ def _cleanup_lock(self):
3730
+ try:
3731
+ self.celery_lock.unlink()
3732
+ except FileNotFoundError:
3733
+ pass
3734
+
3735
+ def test_report_sends_email_when_sessions_exist(self):
3736
+ User = get_user_model()
3737
+ User.objects.create_superuser(
3738
+ username="report-admin",
3739
+ email="report-admin@example.com",
3740
+ password="pw",
3741
+ )
3742
+ charger = Charger.objects.create(charger_id="RPT1", display_name="Pod 1")
3743
+ start = timezone.now().replace(hour=10, minute=0, second=0, microsecond=0)
3744
+ Transaction.objects.create(
3745
+ charger=charger,
3746
+ start_time=start,
3747
+ stop_time=start + timedelta(hours=1),
3748
+ meter_start=0,
3749
+ meter_stop=2500,
3750
+ connector_id=2,
3751
+ rfid="AA11",
3752
+ )
3753
+
3754
+ with patch("core.mailer.can_send_email", return_value=True), patch(
3755
+ "core.mailer.send"
3756
+ ) as mock_send:
3757
+ count = send_daily_session_report()
3758
+
3759
+ self.assertEqual(count, 1)
3760
+ self.assertTrue(mock_send.called)
3761
+ args, _kwargs = mock_send.call_args
3762
+ self.assertIn("OCPP session report", args[0])
3763
+ self.assertIn("Pod 1", args[1])
3764
+ self.assertIn("report-admin@example.com", args[2])
3765
+ self.assertGreaterEqual(len(args[2]), 1)
3766
+
3767
+ def test_report_skips_when_no_sessions(self):
3768
+ with patch("core.mailer.can_send_email", return_value=True), patch(
3769
+ "core.mailer.send"
3770
+ ) as mock_send:
3771
+ count = send_daily_session_report()
3772
+
3773
+ self.assertEqual(count, 0)
3774
+ mock_send.assert_not_called()
3775
+
3776
+ def test_report_skips_without_celery_feature(self):
3777
+ if self.celery_lock.exists():
3778
+ self.celery_lock.unlink()
3779
+
3780
+ with patch("core.mailer.can_send_email", return_value=True), patch(
3781
+ "core.mailer.send"
3782
+ ) as mock_send:
3783
+ count = send_daily_session_report()
3784
+
3785
+ self.assertEqual(count, 0)
3786
+ mock_send.assert_not_called()
3787
+
3788
+
3679
3789
  class TransactionKwTests(TestCase):
3680
3790
  def test_kw_sums_meter_readings(self):
3681
3791
  charger = Charger.objects.create(charger_id="SUM1")