arthexis 0.1.14__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.14.dist-info → arthexis-0.1.16.dist-info}/METADATA +3 -2
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/RECORD +41 -39
- config/urls.py +5 -0
- core/admin.py +200 -9
- core/admindocs.py +44 -3
- core/apps.py +1 -1
- core/backends.py +44 -8
- core/entity.py +17 -1
- core/github_issues.py +12 -7
- core/log_paths.py +24 -10
- core/mailer.py +9 -5
- core/models.py +92 -23
- core/release.py +173 -2
- core/system.py +411 -4
- core/tasks.py +5 -1
- core/test_system_info.py +16 -0
- core/tests.py +280 -0
- core/views.py +252 -38
- nodes/admin.py +25 -1
- nodes/models.py +99 -6
- nodes/rfid_sync.py +15 -0
- nodes/tests.py +142 -3
- 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 +188 -5
- pages/context_processors.py +20 -1
- pages/middleware.py +4 -0
- pages/models.py +39 -1
- pages/module_defaults.py +156 -0
- pages/tasks.py +74 -0
- pages/tests.py +629 -8
- pages/urls.py +2 -0
- pages/utils.py +11 -0
- pages/views.py +106 -38
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/WHEEL +0 -0
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.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
|
|
@@ -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")
|
pages/admin.py
CHANGED
|
@@ -18,11 +18,14 @@ import ipaddress
|
|
|
18
18
|
from django.apps import apps as django_apps
|
|
19
19
|
from django.conf import settings
|
|
20
20
|
from django.utils.translation import gettext_lazy as _, ngettext
|
|
21
|
+
from django.core.management import CommandError, call_command
|
|
21
22
|
|
|
22
|
-
from nodes.models import Node
|
|
23
|
+
from nodes.models import Node, NodeRole
|
|
23
24
|
from nodes.utils import capture_screenshot, save_screenshot
|
|
24
25
|
|
|
25
26
|
from .forms import UserManualAdminForm
|
|
27
|
+
from .module_defaults import reload_default_modules as restore_default_modules
|
|
28
|
+
from .utils import landing_leads_supported
|
|
26
29
|
|
|
27
30
|
from .models import (
|
|
28
31
|
SiteBadge,
|
|
@@ -107,6 +110,49 @@ class SiteAdmin(DjangoSiteAdmin):
|
|
|
107
110
|
messages.INFO,
|
|
108
111
|
)
|
|
109
112
|
|
|
113
|
+
def _reload_site_fixtures(self, request):
|
|
114
|
+
fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
|
|
115
|
+
fixture_paths = sorted(fixtures_dir.glob("references__00_site_*.json"))
|
|
116
|
+
sigil_fixture = fixtures_dir / "sigil_roots__site.json"
|
|
117
|
+
if sigil_fixture.exists():
|
|
118
|
+
fixture_paths.append(sigil_fixture)
|
|
119
|
+
|
|
120
|
+
if not fixture_paths:
|
|
121
|
+
self.message_user(request, _("No site fixtures found."), messages.WARNING)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
loaded = 0
|
|
125
|
+
for path in fixture_paths:
|
|
126
|
+
try:
|
|
127
|
+
call_command("loaddata", str(path), verbosity=0)
|
|
128
|
+
except CommandError as exc:
|
|
129
|
+
self.message_user(
|
|
130
|
+
request,
|
|
131
|
+
_("%(fixture)s: %(error)s")
|
|
132
|
+
% {"fixture": path.name, "error": exc},
|
|
133
|
+
messages.ERROR,
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
loaded += 1
|
|
137
|
+
|
|
138
|
+
if loaded:
|
|
139
|
+
message = ngettext(
|
|
140
|
+
"Reloaded %(count)d site fixture.",
|
|
141
|
+
"Reloaded %(count)d site fixtures.",
|
|
142
|
+
loaded,
|
|
143
|
+
) % {"count": loaded}
|
|
144
|
+
self.message_user(request, message, messages.SUCCESS)
|
|
145
|
+
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def reload_site_fixtures(self, request):
|
|
149
|
+
if request.method != "POST":
|
|
150
|
+
return redirect("..")
|
|
151
|
+
|
|
152
|
+
self._reload_site_fixtures(request)
|
|
153
|
+
|
|
154
|
+
return redirect("..")
|
|
155
|
+
|
|
110
156
|
def get_urls(self):
|
|
111
157
|
urls = super().get_urls()
|
|
112
158
|
custom = [
|
|
@@ -114,7 +160,12 @@ class SiteAdmin(DjangoSiteAdmin):
|
|
|
114
160
|
"register-current/",
|
|
115
161
|
self.admin_site.admin_view(self.register_current),
|
|
116
162
|
name="pages_siteproxy_register_current",
|
|
117
|
-
)
|
|
163
|
+
),
|
|
164
|
+
path(
|
|
165
|
+
"reload-site-fixtures/",
|
|
166
|
+
self.admin_site.admin_view(self.reload_site_fixtures),
|
|
167
|
+
name="pages_siteproxy_reload_site_fixtures",
|
|
168
|
+
),
|
|
118
169
|
]
|
|
119
170
|
return custom + urls
|
|
120
171
|
|
|
@@ -179,16 +230,118 @@ class ApplicationAdmin(EntityModelAdmin):
|
|
|
179
230
|
class LandingInline(admin.TabularInline):
|
|
180
231
|
model = Landing
|
|
181
232
|
extra = 0
|
|
182
|
-
fields = ("path", "label", "enabled"
|
|
233
|
+
fields = ("path", "label", "enabled")
|
|
234
|
+
show_change_link = True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@admin.register(Landing)
|
|
238
|
+
class LandingAdmin(EntityModelAdmin):
|
|
239
|
+
list_display = ("label", "path", "module", "enabled")
|
|
240
|
+
list_filter = ("enabled", "module__node_role", "module__application")
|
|
241
|
+
search_fields = (
|
|
242
|
+
"label",
|
|
243
|
+
"path",
|
|
244
|
+
"description",
|
|
245
|
+
"module__path",
|
|
246
|
+
"module__application__name",
|
|
247
|
+
"module__node_role__name",
|
|
248
|
+
)
|
|
249
|
+
fields = ("module", "path", "label", "enabled", "description")
|
|
250
|
+
list_select_related = ("module", "module__application", "module__node_role")
|
|
183
251
|
|
|
184
252
|
|
|
185
253
|
@admin.register(Module)
|
|
186
254
|
class ModuleAdmin(EntityModelAdmin):
|
|
255
|
+
change_list_template = "admin/pages/module/change_list.html"
|
|
187
256
|
list_display = ("application", "node_role", "path", "menu", "is_default")
|
|
188
257
|
list_filter = ("node_role", "application")
|
|
189
258
|
fields = ("node_role", "application", "path", "menu", "is_default", "favicon")
|
|
190
259
|
inlines = [LandingInline]
|
|
191
260
|
|
|
261
|
+
def get_urls(self):
|
|
262
|
+
urls = super().get_urls()
|
|
263
|
+
custom = [
|
|
264
|
+
path(
|
|
265
|
+
"reload-default-modules/",
|
|
266
|
+
self.admin_site.admin_view(self.reload_default_modules_view),
|
|
267
|
+
name="pages_module_reload_default_modules",
|
|
268
|
+
),
|
|
269
|
+
]
|
|
270
|
+
return custom + urls
|
|
271
|
+
|
|
272
|
+
def reload_default_modules_view(self, request):
|
|
273
|
+
if request.method != "POST":
|
|
274
|
+
return redirect("..")
|
|
275
|
+
|
|
276
|
+
summary = restore_default_modules(Application, Module, Landing, NodeRole)
|
|
277
|
+
|
|
278
|
+
if summary.roles_processed == 0:
|
|
279
|
+
self.message_user(
|
|
280
|
+
request,
|
|
281
|
+
_("No default modules were reloaded because the required node roles are missing."),
|
|
282
|
+
messages.WARNING,
|
|
283
|
+
)
|
|
284
|
+
elif summary.has_changes:
|
|
285
|
+
parts: list[str] = []
|
|
286
|
+
if summary.modules_created:
|
|
287
|
+
parts.append(
|
|
288
|
+
ngettext(
|
|
289
|
+
"%(count)d module created",
|
|
290
|
+
"%(count)d modules created",
|
|
291
|
+
summary.modules_created,
|
|
292
|
+
)
|
|
293
|
+
% {"count": summary.modules_created}
|
|
294
|
+
)
|
|
295
|
+
if summary.modules_updated:
|
|
296
|
+
parts.append(
|
|
297
|
+
ngettext(
|
|
298
|
+
"%(count)d module updated",
|
|
299
|
+
"%(count)d modules updated",
|
|
300
|
+
summary.modules_updated,
|
|
301
|
+
)
|
|
302
|
+
% {"count": summary.modules_updated}
|
|
303
|
+
)
|
|
304
|
+
if summary.landings_created:
|
|
305
|
+
parts.append(
|
|
306
|
+
ngettext(
|
|
307
|
+
"%(count)d landing created",
|
|
308
|
+
"%(count)d landings created",
|
|
309
|
+
summary.landings_created,
|
|
310
|
+
)
|
|
311
|
+
% {"count": summary.landings_created}
|
|
312
|
+
)
|
|
313
|
+
if summary.landings_updated:
|
|
314
|
+
parts.append(
|
|
315
|
+
ngettext(
|
|
316
|
+
"%(count)d landing updated",
|
|
317
|
+
"%(count)d landings updated",
|
|
318
|
+
summary.landings_updated,
|
|
319
|
+
)
|
|
320
|
+
% {"count": summary.landings_updated}
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
details = "; ".join(parts)
|
|
324
|
+
if details:
|
|
325
|
+
message = _(
|
|
326
|
+
"Reloaded default modules for %(roles)d role(s). %(details)s."
|
|
327
|
+
) % {"roles": summary.roles_processed, "details": details}
|
|
328
|
+
else:
|
|
329
|
+
message = _(
|
|
330
|
+
"Reloaded default modules for %(roles)d role(s)."
|
|
331
|
+
) % {"roles": summary.roles_processed}
|
|
332
|
+
self.message_user(request, message, messages.SUCCESS)
|
|
333
|
+
else:
|
|
334
|
+
self.message_user(
|
|
335
|
+
request,
|
|
336
|
+
_(
|
|
337
|
+
"Default modules are already up to date for %(roles)d role(s)."
|
|
338
|
+
)
|
|
339
|
+
% {"roles": summary.roles_processed},
|
|
340
|
+
messages.INFO,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
return redirect("..")
|
|
344
|
+
|
|
192
345
|
|
|
193
346
|
@admin.register(LandingLead)
|
|
194
347
|
class LandingLeadAdmin(EntityModelAdmin):
|
|
@@ -237,6 +390,17 @@ class LandingLeadAdmin(EntityModelAdmin):
|
|
|
237
390
|
ordering = ("-created_on",)
|
|
238
391
|
date_hierarchy = "created_on"
|
|
239
392
|
|
|
393
|
+
def changelist_view(self, request, extra_context=None):
|
|
394
|
+
if not landing_leads_supported():
|
|
395
|
+
self.message_user(
|
|
396
|
+
request,
|
|
397
|
+
_(
|
|
398
|
+
"Landing leads are not being recorded because Celery is not running on this node."
|
|
399
|
+
),
|
|
400
|
+
messages.WARNING,
|
|
401
|
+
)
|
|
402
|
+
return super().changelist_view(request, extra_context=extra_context)
|
|
403
|
+
|
|
240
404
|
@admin.display(description=_("Landing"), ordering="landing__label")
|
|
241
405
|
def landing_label(self, obj):
|
|
242
406
|
return obj.landing.label
|
|
@@ -461,13 +625,22 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
461
625
|
"name",
|
|
462
626
|
"rating",
|
|
463
627
|
"path",
|
|
628
|
+
"status",
|
|
464
629
|
"submitted_at",
|
|
465
630
|
"github_issue_display",
|
|
466
631
|
"take_screenshot",
|
|
467
632
|
"owner",
|
|
633
|
+
"assign_to",
|
|
634
|
+
)
|
|
635
|
+
list_filter = ("rating", "status", "submitted_at", "take_screenshot")
|
|
636
|
+
search_fields = (
|
|
637
|
+
"name",
|
|
638
|
+
"comments",
|
|
639
|
+
"path",
|
|
640
|
+
"referer",
|
|
641
|
+
"github_issue_url",
|
|
642
|
+
"ip_address",
|
|
468
643
|
)
|
|
469
|
-
list_filter = ("rating", "submitted_at", "take_screenshot")
|
|
470
|
-
search_fields = ("name", "comments", "path", "github_issue_url")
|
|
471
644
|
readonly_fields = (
|
|
472
645
|
"name",
|
|
473
646
|
"rating",
|
|
@@ -476,6 +649,10 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
476
649
|
"path",
|
|
477
650
|
"user",
|
|
478
651
|
"owner",
|
|
652
|
+
"referer",
|
|
653
|
+
"user_agent",
|
|
654
|
+
"ip_address",
|
|
655
|
+
"created_on",
|
|
479
656
|
"submitted_at",
|
|
480
657
|
"github_issue_number",
|
|
481
658
|
"github_issue_url",
|
|
@@ -489,6 +666,12 @@ class UserStoryAdmin(EntityModelAdmin):
|
|
|
489
666
|
"path",
|
|
490
667
|
"user",
|
|
491
668
|
"owner",
|
|
669
|
+
"status",
|
|
670
|
+
"assign_to",
|
|
671
|
+
"referer",
|
|
672
|
+
"user_agent",
|
|
673
|
+
"ip_address",
|
|
674
|
+
"created_on",
|
|
492
675
|
"submitted_at",
|
|
493
676
|
"github_issue_number",
|
|
494
677
|
"github_issue_url",
|