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.
- {arthexis-0.1.15.dist-info → arthexis-0.1.16.dist-info}/METADATA +1 -2
- {arthexis-0.1.15.dist-info → arthexis-0.1.16.dist-info}/RECORD +36 -35
- config/urls.py +5 -0
- core/admin.py +174 -7
- core/admindocs.py +44 -3
- core/apps.py +1 -1
- core/backends.py +44 -8
- core/github_issues.py +12 -7
- core/mailer.py +9 -5
- core/models.py +64 -23
- core/release.py +52 -0
- core/system.py +208 -1
- core/tasks.py +5 -1
- core/test_system_info.py +16 -0
- core/tests.py +207 -0
- core/views.py +221 -33
- nodes/admin.py +25 -1
- nodes/models.py +70 -4
- nodes/rfid_sync.py +15 -0
- nodes/tests.py +119 -0
- nodes/utils.py +3 -0
- ocpp/consumers.py +38 -0
- ocpp/models.py +19 -4
- ocpp/tasks.py +156 -2
- ocpp/test_rfid.py +44 -2
- ocpp/tests.py +111 -1
- pages/admin.py +126 -4
- pages/context_processors.py +20 -1
- pages/models.py +3 -1
- pages/module_defaults.py +156 -0
- pages/tests.py +215 -7
- pages/urls.py +1 -0
- pages/views.py +61 -4
- {arthexis-0.1.15.dist-info → arthexis-0.1.16.dist-info}/WHEEL +0 -0
- {arthexis-0.1.15.dist-info → arthexis-0.1.16.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.15.dist-info → arthexis-0.1.16.dist-info}/top_level.txt +0 -0
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(
|
|
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(
|
|
860
|
-
|
|
861
|
-
|
|
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
|
|
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")
|