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.

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
- django.setup()
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
- @patch("nodes.models.Node._uses_postgres", return_value=True)
1888
- def test_postgres_detection(self, mock_postgres):
1889
- feature = NodeFeature.objects.create(
1890
- slug="postgres-db", display="PostgreSQL Database"
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
- feature.roles.add(self.role)
1893
- with patch(
1894
- "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1895
- ):
1896
- self.node.refresh_features()
1897
- self.assertTrue(
1898
- NodeFeatureAssignment.objects.filter(
1899
- node=self.node, feature=feature
1900
- ).exists()
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
- @patch("nodes.models.Node._uses_postgres", side_effect=[True, False])
1904
- def test_postgres_removed_when_not_in_use(self, mock_postgres):
1905
- feature = NodeFeature.objects.create(
1906
- slug="postgres-db", display="PostgreSQL Database"
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
- feature.roles.add(self.role)
1909
- with patch(
1910
- "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
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
- with patch(
1919
- "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1920
- ):
1921
- self.node.refresh_features()
1922
- self.assertFalse(
1923
- NodeFeatureAssignment.objects.filter(
1924
- node=self.node, feature=feature
1925
- ).exists()
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