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.
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/METADATA +1 -2
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/RECORD +40 -39
- config/settings.py +3 -0
- config/urls.py +5 -0
- core/admin.py +242 -15
- core/admindocs.py +44 -3
- core/apps.py +1 -1
- core/backends.py +46 -8
- core/changelog.py +66 -5
- core/github_issues.py +12 -7
- core/mailer.py +9 -5
- core/models.py +121 -29
- core/release.py +107 -2
- core/system.py +209 -2
- core/tasks.py +5 -7
- core/test_system_info.py +16 -0
- core/tests.py +329 -0
- core/views.py +279 -40
- 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/admin.py +92 -10
- ocpp/consumers.py +38 -0
- ocpp/models.py +19 -4
- ocpp/tasks.py +156 -2
- ocpp/test_rfid.py +92 -5
- ocpp/tests.py +243 -1
- ocpp/views.py +23 -5
- 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 +241 -8
- pages/urls.py +1 -0
- pages/views.py +61 -4
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/WHEEL +0 -0
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/top_level.txt +0 -0
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
|
|
@@ -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(
|
|
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(
|
|
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"
|