arthexis 0.1.11__py3-none-any.whl → 0.1.12__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.11.dist-info → arthexis-0.1.12.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/RECORD +38 -35
- config/settings.py +7 -2
- core/admin.py +246 -68
- core/apps.py +21 -0
- core/models.py +41 -8
- core/reference_utils.py +1 -1
- core/release.py +4 -0
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +64 -0
- core/views.py +131 -17
- nodes/admin.py +316 -6
- nodes/feature_checks.py +133 -0
- nodes/models.py +83 -26
- nodes/reports.py +411 -0
- nodes/tests.py +365 -36
- nodes/utils.py +32 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +506 -8
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +234 -4
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/tests.py +789 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +225 -19
- pages/admin.py +135 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +38 -0
- pages/models.py +136 -1
- pages/tests.py +262 -4
- pages/urls.py +1 -0
- pages/views.py +52 -3
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
nodes/tests.py
CHANGED
|
@@ -3,7 +3,15 @@ import os
|
|
|
3
3
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
4
4
|
import django
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
try: # Use the pytest-specific setup when available for database readiness
|
|
7
|
+
from tests.conftest import safe_setup as _safe_setup # type: ignore
|
|
8
|
+
except Exception: # pragma: no cover - fallback for direct execution
|
|
9
|
+
_safe_setup = None
|
|
10
|
+
|
|
11
|
+
if _safe_setup is not None:
|
|
12
|
+
_safe_setup()
|
|
13
|
+
else: # pragma: no cover - fallback when pytest fixtures are unavailable
|
|
14
|
+
django.setup()
|
|
7
15
|
|
|
8
16
|
from pathlib import Path
|
|
9
17
|
from types import SimpleNamespace
|
|
@@ -27,7 +35,7 @@ from django.urls import reverse
|
|
|
27
35
|
from django.contrib.auth import get_user_model
|
|
28
36
|
from django.contrib import admin
|
|
29
37
|
from django.contrib.sites.models import Site
|
|
30
|
-
from django_celery_beat.models import PeriodicTask
|
|
38
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
31
39
|
from django.conf import settings
|
|
32
40
|
from django.utils import timezone
|
|
33
41
|
from dns import resolver as dns_resolver
|
|
@@ -55,6 +63,43 @@ from cryptography.hazmat.primitives import serialization, hashes
|
|
|
55
63
|
from core.models import PackageRelease, SecurityGroup
|
|
56
64
|
|
|
57
65
|
|
|
66
|
+
class NodeBadgeColorTests(TestCase):
|
|
67
|
+
def setUp(self):
|
|
68
|
+
self.constellation, _ = NodeRole.objects.get_or_create(name="Constellation")
|
|
69
|
+
self.control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
70
|
+
|
|
71
|
+
def test_constellation_role_defaults_to_goldenrod(self):
|
|
72
|
+
node = Node.objects.create(
|
|
73
|
+
hostname="constellation",
|
|
74
|
+
address="10.1.0.1",
|
|
75
|
+
port=8000,
|
|
76
|
+
mac_address="00:aa:bb:cc:dd:01",
|
|
77
|
+
role=self.constellation,
|
|
78
|
+
)
|
|
79
|
+
self.assertEqual(node.badge_color, "#daa520")
|
|
80
|
+
|
|
81
|
+
def test_control_role_defaults_to_deep_purple(self):
|
|
82
|
+
node = Node.objects.create(
|
|
83
|
+
hostname="control",
|
|
84
|
+
address="10.1.0.2",
|
|
85
|
+
port=8001,
|
|
86
|
+
mac_address="00:aa:bb:cc:dd:02",
|
|
87
|
+
role=self.control,
|
|
88
|
+
)
|
|
89
|
+
self.assertEqual(node.badge_color, "#673ab7")
|
|
90
|
+
|
|
91
|
+
def test_custom_badge_color_is_preserved(self):
|
|
92
|
+
node = Node.objects.create(
|
|
93
|
+
hostname="custom",
|
|
94
|
+
address="10.1.0.3",
|
|
95
|
+
port=8002,
|
|
96
|
+
mac_address="00:aa:bb:cc:dd:03",
|
|
97
|
+
role=self.constellation,
|
|
98
|
+
badge_color="#123456",
|
|
99
|
+
)
|
|
100
|
+
self.assertEqual(node.badge_color, "#123456")
|
|
101
|
+
|
|
102
|
+
|
|
58
103
|
class NodeTests(TestCase):
|
|
59
104
|
def setUp(self):
|
|
60
105
|
self.client = Client()
|
|
@@ -101,6 +146,54 @@ class NodeGetLocalTests(TestCase):
|
|
|
101
146
|
self.assertTrue(created)
|
|
102
147
|
self.assertEqual(node.current_relation, Node.Relation.SELF)
|
|
103
148
|
|
|
149
|
+
def test_register_current_updates_role_from_lock_file(self):
|
|
150
|
+
NodeRole.objects.get_or_create(name="Terminal")
|
|
151
|
+
NodeRole.objects.get_or_create(name="Constellation")
|
|
152
|
+
with TemporaryDirectory() as tmp:
|
|
153
|
+
base = Path(tmp)
|
|
154
|
+
lock_dir = base / "locks"
|
|
155
|
+
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
role_file = lock_dir / "role.lck"
|
|
157
|
+
role_file.write_text("Terminal")
|
|
158
|
+
with override_settings(BASE_DIR=base):
|
|
159
|
+
with (
|
|
160
|
+
patch(
|
|
161
|
+
"nodes.models.Node.get_current_mac",
|
|
162
|
+
return_value="00:aa:bb:cc:dd:ee",
|
|
163
|
+
),
|
|
164
|
+
patch("nodes.models.socket.gethostname", return_value="role-host"),
|
|
165
|
+
patch(
|
|
166
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
167
|
+
),
|
|
168
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
169
|
+
patch.object(Node, "ensure_keys"),
|
|
170
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
171
|
+
):
|
|
172
|
+
node, created = Node.register_current()
|
|
173
|
+
self.assertTrue(created)
|
|
174
|
+
self.assertEqual(node.role.name, "Terminal")
|
|
175
|
+
|
|
176
|
+
role_file.write_text("Constellation")
|
|
177
|
+
with override_settings(BASE_DIR=base):
|
|
178
|
+
with (
|
|
179
|
+
patch(
|
|
180
|
+
"nodes.models.Node.get_current_mac",
|
|
181
|
+
return_value="00:aa:bb:cc:dd:ee",
|
|
182
|
+
),
|
|
183
|
+
patch("nodes.models.socket.gethostname", return_value="role-host"),
|
|
184
|
+
patch(
|
|
185
|
+
"nodes.models.socket.gethostbyname", return_value="127.0.0.1"
|
|
186
|
+
),
|
|
187
|
+
patch("nodes.models.revision.get_revision", return_value="rev"),
|
|
188
|
+
patch.object(Node, "ensure_keys"),
|
|
189
|
+
patch.object(Node, "notify_peers_of_update"),
|
|
190
|
+
):
|
|
191
|
+
_, created_again = Node.register_current()
|
|
192
|
+
|
|
193
|
+
self.assertFalse(created_again)
|
|
194
|
+
node.refresh_from_db()
|
|
195
|
+
self.assertEqual(node.role.name, "Constellation")
|
|
196
|
+
|
|
104
197
|
def test_register_and_list_node(self):
|
|
105
198
|
response = self.client.post(
|
|
106
199
|
reverse("register-node"),
|
|
@@ -833,6 +926,33 @@ class NodeAdminTests(TestCase):
|
|
|
833
926
|
if security_dir.exists():
|
|
834
927
|
shutil.rmtree(security_dir)
|
|
835
928
|
|
|
929
|
+
def _create_local_node(self):
|
|
930
|
+
return Node.objects.create(
|
|
931
|
+
hostname="localnode",
|
|
932
|
+
address="127.0.0.1",
|
|
933
|
+
port=8000,
|
|
934
|
+
mac_address=Node.get_current_mac(),
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
def test_node_feature_list_shows_default_action_when_enabled(self):
|
|
938
|
+
node = self._create_local_node()
|
|
939
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
940
|
+
slug="rfid-scanner", defaults={"display": "RFID Scanner"}
|
|
941
|
+
)
|
|
942
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
943
|
+
response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
|
|
944
|
+
action_url = reverse("admin:core_rfid_scan")
|
|
945
|
+
self.assertContains(response, f'href="{action_url}"')
|
|
946
|
+
|
|
947
|
+
def test_node_feature_list_hides_default_action_when_disabled(self):
|
|
948
|
+
self._create_local_node()
|
|
949
|
+
NodeFeature.objects.get_or_create(
|
|
950
|
+
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
951
|
+
)
|
|
952
|
+
response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
|
|
953
|
+
action_url = reverse("admin:nodes_nodefeature_take_screenshot")
|
|
954
|
+
self.assertNotContains(response, f'href="{action_url}"')
|
|
955
|
+
|
|
836
956
|
def test_register_current_host(self):
|
|
837
957
|
url = reverse("admin:nodes_node_register_current")
|
|
838
958
|
hostname = socket.gethostname()
|
|
@@ -967,6 +1087,160 @@ class NodeAdminTests(TestCase):
|
|
|
967
1087
|
samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
|
|
968
1088
|
self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
|
|
969
1089
|
|
|
1090
|
+
@patch("nodes.admin.capture_screenshot")
|
|
1091
|
+
def test_take_screenshot_default_action_creates_sample(
|
|
1092
|
+
self, mock_capture_screenshot
|
|
1093
|
+
):
|
|
1094
|
+
node = self._create_local_node()
|
|
1095
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1096
|
+
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
1097
|
+
)
|
|
1098
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1099
|
+
screenshot_dir = settings.LOG_DIR / "screenshots"
|
|
1100
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
1101
|
+
file_path = screenshot_dir / "default.png"
|
|
1102
|
+
file_path.write_bytes(b"default")
|
|
1103
|
+
mock_capture_screenshot.return_value = Path("screenshots/default.png")
|
|
1104
|
+
response = self.client.get(
|
|
1105
|
+
reverse("admin:nodes_nodefeature_take_screenshot"), follow=True
|
|
1106
|
+
)
|
|
1107
|
+
self.assertEqual(response.status_code, 200)
|
|
1108
|
+
sample = ContentSample.objects.get(kind=ContentSample.IMAGE)
|
|
1109
|
+
self.assertEqual(sample.node, node)
|
|
1110
|
+
self.assertEqual(sample.method, "DEFAULT_ACTION")
|
|
1111
|
+
mock_capture_screenshot.assert_called_once_with("http://testserver/")
|
|
1112
|
+
change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
|
|
1113
|
+
self.assertEqual(response.redirect_chain[-1][0], change_url)
|
|
1114
|
+
|
|
1115
|
+
def test_check_features_for_eligibility_action_success(self):
|
|
1116
|
+
node = self._create_local_node()
|
|
1117
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1118
|
+
slug="rfid-scanner", defaults={"display": "RFID Scanner"}
|
|
1119
|
+
)
|
|
1120
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1121
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1122
|
+
response = self.client.post(
|
|
1123
|
+
changelist_url,
|
|
1124
|
+
{
|
|
1125
|
+
"action": "check_features_for_eligibility",
|
|
1126
|
+
"_selected_action": [str(feature.pk)],
|
|
1127
|
+
},
|
|
1128
|
+
follow=True,
|
|
1129
|
+
)
|
|
1130
|
+
self.assertEqual(response.status_code, 200)
|
|
1131
|
+
self.assertContains(
|
|
1132
|
+
response,
|
|
1133
|
+
"RFID Scanner is enabled on localnode. This feature cannot be enabled manually.",
|
|
1134
|
+
html=False,
|
|
1135
|
+
)
|
|
1136
|
+
self.assertContains(
|
|
1137
|
+
response, "Completed 1 of 1 feature check(s) successfully.", html=False
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
def test_check_features_for_eligibility_action_warns_when_disabled(self):
|
|
1141
|
+
self._create_local_node()
|
|
1142
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1143
|
+
slug="rfid-scanner", defaults={"display": "RFID Scanner"}
|
|
1144
|
+
)
|
|
1145
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1146
|
+
response = self.client.post(
|
|
1147
|
+
changelist_url,
|
|
1148
|
+
{
|
|
1149
|
+
"action": "check_features_for_eligibility",
|
|
1150
|
+
"_selected_action": [str(feature.pk)],
|
|
1151
|
+
},
|
|
1152
|
+
follow=True,
|
|
1153
|
+
)
|
|
1154
|
+
self.assertEqual(response.status_code, 200)
|
|
1155
|
+
self.assertContains(
|
|
1156
|
+
response,
|
|
1157
|
+
"RFID Scanner is not enabled on localnode. This feature cannot be enabled manually.",
|
|
1158
|
+
html=False,
|
|
1159
|
+
)
|
|
1160
|
+
self.assertContains(
|
|
1161
|
+
response, "Completed 0 of 1 feature check(s) successfully.", html=False
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
def test_enable_selected_features_enables_manual_feature(self):
|
|
1165
|
+
node = self._create_local_node()
|
|
1166
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1167
|
+
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
1168
|
+
)
|
|
1169
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1170
|
+
response = self.client.post(
|
|
1171
|
+
changelist_url,
|
|
1172
|
+
{
|
|
1173
|
+
"action": "enable_selected_features",
|
|
1174
|
+
"_selected_action": [str(feature.pk)],
|
|
1175
|
+
},
|
|
1176
|
+
follow=True,
|
|
1177
|
+
)
|
|
1178
|
+
self.assertEqual(response.status_code, 200)
|
|
1179
|
+
self.assertTrue(Node.objects.get(pk=node.pk).has_feature("screenshot-poll"))
|
|
1180
|
+
self.assertContains(
|
|
1181
|
+
response, "Enabled 1 feature(s): Screenshot Poll", html=False
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
def test_enable_selected_features_warns_for_non_manual(self):
|
|
1185
|
+
self._create_local_node()
|
|
1186
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1187
|
+
slug="rfid-scanner", defaults={"display": "RFID Scanner"}
|
|
1188
|
+
)
|
|
1189
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1190
|
+
response = self.client.post(
|
|
1191
|
+
changelist_url,
|
|
1192
|
+
{
|
|
1193
|
+
"action": "enable_selected_features",
|
|
1194
|
+
"_selected_action": [str(feature.pk)],
|
|
1195
|
+
},
|
|
1196
|
+
follow=True,
|
|
1197
|
+
)
|
|
1198
|
+
self.assertEqual(response.status_code, 200)
|
|
1199
|
+
self.assertContains(
|
|
1200
|
+
response, "RFID Scanner cannot be enabled manually.", html=False
|
|
1201
|
+
)
|
|
1202
|
+
self.assertContains(
|
|
1203
|
+
response,
|
|
1204
|
+
"None of the selected features can be enabled manually.",
|
|
1205
|
+
html=False,
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
def test_take_screenshot_default_action_requires_enabled_feature(self):
|
|
1209
|
+
self._create_local_node()
|
|
1210
|
+
NodeFeature.objects.get_or_create(
|
|
1211
|
+
slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
|
|
1212
|
+
)
|
|
1213
|
+
response = self.client.get(
|
|
1214
|
+
reverse("admin:nodes_nodefeature_take_screenshot"), follow=True
|
|
1215
|
+
)
|
|
1216
|
+
self.assertEqual(response.status_code, 200)
|
|
1217
|
+
changelist_url = reverse("admin:nodes_nodefeature_changelist")
|
|
1218
|
+
self.assertEqual(response.wsgi_request.path, changelist_url)
|
|
1219
|
+
self.assertEqual(ContentSample.objects.count(), 0)
|
|
1220
|
+
self.assertContains(response, "Screenshot Poll feature is not enabled")
|
|
1221
|
+
|
|
1222
|
+
@patch("nodes.admin.capture_rpi_snapshot")
|
|
1223
|
+
def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
|
|
1224
|
+
node = self._create_local_node()
|
|
1225
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1226
|
+
slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
|
|
1227
|
+
)
|
|
1228
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1229
|
+
camera_dir = settings.LOG_DIR / "camera"
|
|
1230
|
+
camera_dir.mkdir(parents=True, exist_ok=True)
|
|
1231
|
+
file_path = camera_dir / "snap.jpg"
|
|
1232
|
+
file_path.write_bytes(b"camera")
|
|
1233
|
+
mock_snapshot.return_value = file_path
|
|
1234
|
+
response = self.client.get(
|
|
1235
|
+
reverse("admin:nodes_nodefeature_take_snapshot"), follow=True
|
|
1236
|
+
)
|
|
1237
|
+
self.assertEqual(response.status_code, 200)
|
|
1238
|
+
sample = ContentSample.objects.get(kind=ContentSample.IMAGE)
|
|
1239
|
+
self.assertEqual(sample.node, node)
|
|
1240
|
+
self.assertEqual(sample.method, "RPI_CAMERA")
|
|
1241
|
+
change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
|
|
1242
|
+
self.assertEqual(response.redirect_chain[-1][0], change_url)
|
|
1243
|
+
|
|
970
1244
|
|
|
971
1245
|
class NetMessageAdminTests(TransactionTestCase):
|
|
972
1246
|
reset_sequences = True
|
|
@@ -1494,6 +1768,16 @@ class EmailOutboxTests(TestCase):
|
|
|
1494
1768
|
|
|
1495
1769
|
self.assertEqual(str(outbox), "mailer@example.com")
|
|
1496
1770
|
|
|
1771
|
+
def test_string_representation_trims_trailing_at_symbol(self):
|
|
1772
|
+
outbox = EmailOutbox.objects.create(
|
|
1773
|
+
host="smtp.example.com",
|
|
1774
|
+
port=587,
|
|
1775
|
+
username="mailer@",
|
|
1776
|
+
password="secret",
|
|
1777
|
+
)
|
|
1778
|
+
|
|
1779
|
+
self.assertEqual(str(outbox), "mailer@smtp.example.com")
|
|
1780
|
+
|
|
1497
1781
|
def test_unattached_outbox_used_as_fallback(self):
|
|
1498
1782
|
EmailOutbox.objects.create(
|
|
1499
1783
|
group=SecurityGroup.objects.create(name="Attached"),
|
|
@@ -1713,6 +1997,30 @@ class NodeFeatureTests(TestCase):
|
|
|
1713
1997
|
role=self.role,
|
|
1714
1998
|
)
|
|
1715
1999
|
|
|
2000
|
+
def test_default_action_mapping_for_known_feature(self):
|
|
2001
|
+
feature = NodeFeature.objects.create(
|
|
2002
|
+
slug="rfid-scanner", display="RFID Scanner"
|
|
2003
|
+
)
|
|
2004
|
+
action = feature.get_default_action()
|
|
2005
|
+
self.assertIsNotNone(action)
|
|
2006
|
+
self.assertEqual(action.label, "Scan RFIDs")
|
|
2007
|
+
self.assertEqual(action.url_name, "admin:core_rfid_scan")
|
|
2008
|
+
|
|
2009
|
+
def test_celery_feature_default_action(self):
|
|
2010
|
+
feature = NodeFeature.objects.create(
|
|
2011
|
+
slug="celery-queue", display="Celery Queue"
|
|
2012
|
+
)
|
|
2013
|
+
action = feature.get_default_action()
|
|
2014
|
+
self.assertIsNotNone(action)
|
|
2015
|
+
self.assertEqual(action.label, "Celery Report")
|
|
2016
|
+
self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
|
|
2017
|
+
|
|
2018
|
+
def test_default_action_missing_when_unconfigured(self):
|
|
2019
|
+
feature = NodeFeature.objects.create(
|
|
2020
|
+
slug="custom-feature", display="Custom Feature"
|
|
2021
|
+
)
|
|
2022
|
+
self.assertIsNone(feature.get_default_action())
|
|
2023
|
+
|
|
1716
2024
|
def test_lcd_screen_enabled(self):
|
|
1717
2025
|
feature = NodeFeature.objects.create(slug="lcd-screen", display="LCD")
|
|
1718
2026
|
feature.roles.add(self.role)
|
|
@@ -1884,45 +2192,66 @@ class NodeFeatureTests(TestCase):
|
|
|
1884
2192
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
|
|
1885
2193
|
)
|
|
1886
2194
|
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2195
|
+
|
|
2196
|
+
class CeleryReportAdminViewTests(TestCase):
|
|
2197
|
+
def setUp(self):
|
|
2198
|
+
User = get_user_model()
|
|
2199
|
+
self.superuser = User.objects.create_superuser(
|
|
2200
|
+
username="admin", email="admin@example.com", password="secret"
|
|
1891
2201
|
)
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
)
|
|
1896
|
-
|
|
1897
|
-
self.
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
2202
|
+
self.client.force_login(self.superuser)
|
|
2203
|
+
|
|
2204
|
+
self.log_file = Path(settings.LOG_DIR) / settings.LOG_FILE_NAME
|
|
2205
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
2206
|
+
self._original_log_contents: str | None = None
|
|
2207
|
+
if self.log_file.exists():
|
|
2208
|
+
self._original_log_contents = self.log_file.read_text(encoding="utf-8")
|
|
2209
|
+
self.addCleanup(self._restore_log_file)
|
|
2210
|
+
|
|
2211
|
+
PeriodicTask.objects.all().delete()
|
|
2212
|
+
|
|
2213
|
+
def _restore_log_file(self):
|
|
2214
|
+
if self._original_log_contents is None:
|
|
2215
|
+
try:
|
|
2216
|
+
self.log_file.unlink()
|
|
2217
|
+
except FileNotFoundError:
|
|
2218
|
+
pass
|
|
2219
|
+
else:
|
|
2220
|
+
self.log_file.write_text(
|
|
2221
|
+
self._original_log_contents, encoding="utf-8"
|
|
2222
|
+
)
|
|
2223
|
+
|
|
2224
|
+
def test_report_includes_tasks_and_logs(self):
|
|
2225
|
+
now = timezone.now()
|
|
2226
|
+
schedule = IntervalSchedule.objects.create(
|
|
2227
|
+
every=1, period=IntervalSchedule.HOURS
|
|
2228
|
+
)
|
|
2229
|
+
PeriodicTask.objects.create(
|
|
2230
|
+
name="test-task",
|
|
2231
|
+
task="core.tasks.heartbeat",
|
|
2232
|
+
interval=schedule,
|
|
2233
|
+
enabled=True,
|
|
2234
|
+
last_run_at=now - timedelta(minutes=30),
|
|
1901
2235
|
)
|
|
1902
2236
|
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
2237
|
+
localized = timezone.localtime(now)
|
|
2238
|
+
log_line = (
|
|
2239
|
+
f"{localized.strftime('%Y-%m-%d %H:%M:%S,%f')} "
|
|
2240
|
+
"[INFO] core.tasks: Heartbeat task executed\n"
|
|
1907
2241
|
)
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
self.node.refresh_features()
|
|
1913
|
-
self.assertTrue(
|
|
1914
|
-
NodeFeatureAssignment.objects.filter(
|
|
1915
|
-
node=self.node, feature=feature
|
|
1916
|
-
).exists()
|
|
2242
|
+
self.log_file.write_text(log_line, encoding="utf-8")
|
|
2243
|
+
|
|
2244
|
+
response = self.client.get(
|
|
2245
|
+
reverse("admin:nodes_nodefeature_celery_report")
|
|
1917
2246
|
)
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
)
|
|
1921
|
-
|
|
1922
|
-
self.
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2247
|
+
|
|
2248
|
+
self.assertEqual(response.status_code, 200)
|
|
2249
|
+
self.assertContains(response, "Celery Report")
|
|
2250
|
+
self.assertContains(response, "test-task")
|
|
2251
|
+
self.assertContains(response, settings.LOG_FILE_NAME)
|
|
2252
|
+
entries = response.context_data["log_entries"]
|
|
2253
|
+
self.assertTrue(
|
|
2254
|
+
any("Heartbeat task executed" in entry.message for entry in entries)
|
|
1926
2255
|
)
|
|
1927
2256
|
|
|
1928
2257
|
|
nodes/utils.py
CHANGED
|
@@ -2,6 +2,8 @@ from datetime import datetime
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
import hashlib
|
|
4
4
|
import logging
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
5
7
|
|
|
6
8
|
from django.conf import settings
|
|
7
9
|
from selenium import webdriver
|
|
@@ -11,6 +13,7 @@ from selenium.common.exceptions import WebDriverException
|
|
|
11
13
|
from .models import ContentSample
|
|
12
14
|
|
|
13
15
|
SCREENSHOT_DIR = settings.LOG_DIR / "screenshots"
|
|
16
|
+
CAMERA_DIR = settings.LOG_DIR / "camera"
|
|
14
17
|
logger = logging.getLogger(__name__)
|
|
15
18
|
|
|
16
19
|
|
|
@@ -46,6 +49,35 @@ def capture_screenshot(url: str, cookies=None) -> Path:
|
|
|
46
49
|
raise RuntimeError(f"Screenshot capture failed: {exc}") from exc
|
|
47
50
|
|
|
48
51
|
|
|
52
|
+
def capture_rpi_snapshot(timeout: int = 10) -> Path:
|
|
53
|
+
"""Capture a snapshot using the Raspberry Pi camera stack."""
|
|
54
|
+
|
|
55
|
+
tool_path = shutil.which("rpicam-still")
|
|
56
|
+
if not tool_path:
|
|
57
|
+
raise RuntimeError("rpicam-still is not available")
|
|
58
|
+
CAMERA_DIR.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
filename = CAMERA_DIR / f"{datetime.utcnow():%Y%m%d%H%M%S}.jpg"
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
[tool_path, "-o", str(filename), "-t", "1"],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
check=False,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
)
|
|
68
|
+
except Exception as exc: # pragma: no cover - depends on camera stack
|
|
69
|
+
logger.error("Failed to invoke %s: %s", tool_path, exc)
|
|
70
|
+
raise RuntimeError(f"Snapshot capture failed: {exc}") from exc
|
|
71
|
+
if result.returncode != 0:
|
|
72
|
+
error = (result.stderr or result.stdout or "Snapshot capture failed").strip()
|
|
73
|
+
logger.error("rpicam-still exited with %s: %s", result.returncode, error)
|
|
74
|
+
raise RuntimeError(error)
|
|
75
|
+
if not filename.exists():
|
|
76
|
+
logger.error("Snapshot file %s was not created", filename)
|
|
77
|
+
raise RuntimeError("Snapshot capture failed")
|
|
78
|
+
return filename
|
|
79
|
+
|
|
80
|
+
|
|
49
81
|
def save_screenshot(path: Path, node=None, method: str = "", transaction_uuid=None):
|
|
50
82
|
"""Save screenshot file info if not already recorded.
|
|
51
83
|
|