arthexis 0.1.11__py3-none-any.whl → 0.1.13__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.

Files changed (50) hide show
  1. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
  2. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
  3. config/asgi.py +15 -1
  4. config/celery.py +8 -1
  5. config/settings.py +49 -78
  6. config/settings_helpers.py +109 -0
  7. core/admin.py +293 -78
  8. core/apps.py +21 -0
  9. core/auto_upgrade.py +2 -2
  10. core/form_fields.py +75 -0
  11. core/models.py +203 -47
  12. core/reference_utils.py +1 -1
  13. core/release.py +42 -20
  14. core/system.py +6 -3
  15. core/tasks.py +92 -40
  16. core/tests.py +75 -1
  17. core/views.py +178 -29
  18. core/widgets.py +43 -0
  19. nodes/admin.py +583 -10
  20. nodes/apps.py +15 -0
  21. nodes/feature_checks.py +133 -0
  22. nodes/models.py +287 -49
  23. nodes/reports.py +411 -0
  24. nodes/tests.py +990 -42
  25. nodes/urls.py +1 -0
  26. nodes/utils.py +32 -0
  27. nodes/views.py +173 -5
  28. ocpp/admin.py +424 -17
  29. ocpp/consumers.py +630 -15
  30. ocpp/evcs.py +7 -94
  31. ocpp/evcs_discovery.py +158 -0
  32. ocpp/models.py +236 -4
  33. ocpp/routing.py +4 -2
  34. ocpp/simulator.py +346 -26
  35. ocpp/status_display.py +26 -0
  36. ocpp/store.py +110 -2
  37. ocpp/tests.py +1425 -33
  38. ocpp/transactions_io.py +27 -3
  39. ocpp/views.py +344 -38
  40. pages/admin.py +138 -3
  41. pages/context_processors.py +15 -1
  42. pages/defaults.py +1 -2
  43. pages/forms.py +67 -0
  44. pages/models.py +136 -1
  45. pages/tests.py +379 -4
  46. pages/urls.py +1 -0
  47. pages/views.py +64 -7
  48. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
  49. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
  50. {arthexis-0.1.11.dist-info → arthexis-0.1.13.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
- 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
@@ -26,8 +34,9 @@ from django.test import Client, SimpleTestCase, TestCase, TransactionTestCase, o
26
34
  from django.urls import reverse
27
35
  from django.contrib.auth import get_user_model
28
36
  from django.contrib import admin
37
+ from django.contrib.auth.models import Permission
29
38
  from django.contrib.sites.models import Site
30
- from django_celery_beat.models import PeriodicTask
39
+ from django_celery_beat.models import IntervalSchedule, PeriodicTask
31
40
  from django.conf import settings
32
41
  from django.utils import timezone
33
42
  from dns import resolver as dns_resolver
@@ -52,7 +61,44 @@ from .backends import OutboxEmailBackend
52
61
  from .tasks import capture_node_screenshot, sample_clipboard
53
62
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
54
63
  from cryptography.hazmat.primitives import serialization, hashes
55
- from core.models import PackageRelease, SecurityGroup
64
+ from core.models import Package, PackageRelease, SecurityGroup, RFID
65
+
66
+
67
+ class NodeBadgeColorTests(TestCase):
68
+ def setUp(self):
69
+ self.constellation, _ = NodeRole.objects.get_or_create(name="Constellation")
70
+ self.control, _ = NodeRole.objects.get_or_create(name="Control")
71
+
72
+ def test_constellation_role_defaults_to_goldenrod(self):
73
+ node = Node.objects.create(
74
+ hostname="constellation",
75
+ address="10.1.0.1",
76
+ port=8000,
77
+ mac_address="00:aa:bb:cc:dd:01",
78
+ role=self.constellation,
79
+ )
80
+ self.assertEqual(node.badge_color, "#daa520")
81
+
82
+ def test_control_role_defaults_to_deep_purple(self):
83
+ node = Node.objects.create(
84
+ hostname="control",
85
+ address="10.1.0.2",
86
+ port=8001,
87
+ mac_address="00:aa:bb:cc:dd:02",
88
+ role=self.control,
89
+ )
90
+ self.assertEqual(node.badge_color, "#673ab7")
91
+
92
+ def test_custom_badge_color_is_preserved(self):
93
+ node = Node.objects.create(
94
+ hostname="custom",
95
+ address="10.1.0.3",
96
+ port=8002,
97
+ mac_address="00:aa:bb:cc:dd:03",
98
+ role=self.constellation,
99
+ badge_color="#123456",
100
+ )
101
+ self.assertEqual(node.badge_color, "#123456")
56
102
 
57
103
 
58
104
  class NodeTests(TestCase):
@@ -77,6 +123,32 @@ class NodeGetLocalDatabaseUnavailableTests(SimpleTestCase):
77
123
 
78
124
 
79
125
  class NodeGetLocalTests(TestCase):
126
+ def test_normalize_relation_handles_various_inputs(self):
127
+ self.assertEqual(
128
+ Node.normalize_relation(Node.Relation.UPSTREAM),
129
+ Node.Relation.UPSTREAM,
130
+ )
131
+ self.assertEqual(
132
+ Node.normalize_relation(None),
133
+ Node.Relation.PEER,
134
+ )
135
+ self.assertEqual(
136
+ Node.normalize_relation("Upstream"),
137
+ Node.Relation.UPSTREAM,
138
+ )
139
+ self.assertEqual(
140
+ Node.normalize_relation("DOWNSTREAM"),
141
+ Node.Relation.DOWNSTREAM,
142
+ )
143
+ self.assertEqual(
144
+ Node.normalize_relation("peer"),
145
+ Node.Relation.PEER,
146
+ )
147
+ self.assertEqual(
148
+ Node.normalize_relation("unexpected"),
149
+ Node.Relation.PEER,
150
+ )
151
+
80
152
  def test_register_current_does_not_create_release(self):
81
153
  node = None
82
154
  created = False
@@ -101,6 +173,54 @@ class NodeGetLocalTests(TestCase):
101
173
  self.assertTrue(created)
102
174
  self.assertEqual(node.current_relation, Node.Relation.SELF)
103
175
 
176
+ def test_register_current_updates_role_from_lock_file(self):
177
+ NodeRole.objects.get_or_create(name="Terminal")
178
+ NodeRole.objects.get_or_create(name="Constellation")
179
+ with TemporaryDirectory() as tmp:
180
+ base = Path(tmp)
181
+ lock_dir = base / "locks"
182
+ lock_dir.mkdir(parents=True, exist_ok=True)
183
+ role_file = lock_dir / "role.lck"
184
+ role_file.write_text("Terminal")
185
+ with override_settings(BASE_DIR=base):
186
+ with (
187
+ patch(
188
+ "nodes.models.Node.get_current_mac",
189
+ return_value="00:aa:bb:cc:dd:ee",
190
+ ),
191
+ patch("nodes.models.socket.gethostname", return_value="role-host"),
192
+ patch(
193
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
194
+ ),
195
+ patch("nodes.models.revision.get_revision", return_value="rev"),
196
+ patch.object(Node, "ensure_keys"),
197
+ patch.object(Node, "notify_peers_of_update"),
198
+ ):
199
+ node, created = Node.register_current()
200
+ self.assertTrue(created)
201
+ self.assertEqual(node.role.name, "Terminal")
202
+
203
+ role_file.write_text("Constellation")
204
+ with override_settings(BASE_DIR=base):
205
+ with (
206
+ patch(
207
+ "nodes.models.Node.get_current_mac",
208
+ return_value="00:aa:bb:cc:dd:ee",
209
+ ),
210
+ patch("nodes.models.socket.gethostname", return_value="role-host"),
211
+ patch(
212
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
213
+ ),
214
+ patch("nodes.models.revision.get_revision", return_value="rev"),
215
+ patch.object(Node, "ensure_keys"),
216
+ patch.object(Node, "notify_peers_of_update"),
217
+ ):
218
+ _, created_again = Node.register_current()
219
+
220
+ self.assertFalse(created_again)
221
+ node.refresh_from_db()
222
+ self.assertEqual(node.role.name, "Constellation")
223
+
104
224
  def test_register_and_list_node(self):
105
225
  response = self.client.post(
106
226
  reverse("register-node"),
@@ -152,6 +272,41 @@ class NodeGetLocalTests(TestCase):
152
272
  hostnames = {n["hostname"] for n in data["nodes"]}
153
273
  self.assertEqual(hostnames, {"dup", "local2"})
154
274
 
275
+ def test_register_node_generates_unique_public_endpoint(self):
276
+ url = reverse("register-node")
277
+ User = get_user_model()
278
+ user = User.objects.create_user(username="registrar", password="pwd")
279
+ self.client.force_login(user)
280
+ first = self.client.post(
281
+ url,
282
+ data={
283
+ "hostname": "duplicate-host",
284
+ "address": "10.0.0.10",
285
+ "port": 8080,
286
+ "mac_address": "00:11:22:33:aa:bb",
287
+ },
288
+ content_type="application/json",
289
+ )
290
+ self.assertEqual(first.status_code, 200)
291
+ node_one = Node.objects.get(mac_address="00:11:22:33:aa:bb")
292
+ self.assertEqual(node_one.public_endpoint, "duplicate-host")
293
+
294
+ second = self.client.post(
295
+ url,
296
+ data={
297
+ "hostname": "duplicate-host",
298
+ "address": "10.0.0.11",
299
+ "port": 8081,
300
+ "mac_address": "00:11:22:33:aa:cc",
301
+ },
302
+ content_type="application/json",
303
+ )
304
+ self.assertEqual(second.status_code, 200)
305
+ node_two = Node.objects.get(mac_address="00:11:22:33:aa:cc")
306
+
307
+ self.assertNotEqual(node_one.public_endpoint, node_two.public_endpoint)
308
+ self.assertTrue(node_two.public_endpoint.startswith("duplicate-host-"))
309
+
155
310
  def test_register_node_feature_toggle(self):
156
311
  NodeFeature.objects.get_or_create(
157
312
  slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
@@ -248,6 +403,48 @@ class NodeGetLocalTests(TestCase):
248
403
  self.assertEqual(subject, "UP friend")
249
404
  self.assertEqual(body, "v2.0.0 r123456")
250
405
 
406
+ def test_register_node_marks_nonrelease_version(self):
407
+ package = Package.objects.create(name="pkg-node", is_active=False)
408
+ PackageRelease.objects.create(
409
+ package=package,
410
+ version="2.0.0",
411
+ revision="0" * 40,
412
+ )
413
+
414
+ node = Node.objects.create(
415
+ hostname="friend",
416
+ address="10.1.1.5",
417
+ port=8123,
418
+ mac_address="aa:bb:cc:dd:ee:11",
419
+ installed_version="1.0.0",
420
+ installed_revision="rev-old",
421
+ )
422
+ user = get_user_model().objects.create_user(
423
+ username="node-registrar", password="pwd"
424
+ )
425
+ self.client.force_login(user)
426
+ url = reverse("register-node")
427
+ payload = {
428
+ "hostname": "friend",
429
+ "address": "10.1.1.5",
430
+ "port": 8123,
431
+ "mac_address": "aa:bb:cc:dd:ee:11",
432
+ "installed_version": "2.0.0",
433
+ "installed_revision": "1" * 40,
434
+ }
435
+ with patch("nodes.models.notify_async") as mock_notify:
436
+ response = self.client.post(
437
+ url, data=json.dumps(payload), content_type="application/json"
438
+ )
439
+ self.assertEqual(response.status_code, 200)
440
+ node.refresh_from_db()
441
+ self.assertEqual(node.installed_version, "2.0.0")
442
+ self.assertEqual(node.installed_revision, "1" * 40)
443
+ mock_notify.assert_called_once()
444
+ subject, body = mock_notify.call_args[0]
445
+ self.assertEqual(subject, "UP friend")
446
+ self.assertEqual(body, "v2.0.0+ r111111")
447
+
251
448
  def test_register_node_update_without_version_change_still_notifies(self):
252
449
  node = Node.objects.create(
253
450
  hostname="friend",
@@ -441,6 +638,64 @@ class NodeGetLocalTests(TestCase):
441
638
  self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
442
639
 
443
640
 
641
+ class RegisterVisitorNodeMessageTests(TestCase):
642
+ def setUp(self):
643
+ self.client = Client()
644
+ User = get_user_model()
645
+ self.user = User.objects.create_user(username="visitor", password="pwd")
646
+ self.client.force_login(self.user)
647
+ self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
648
+ self.visitor = Node.objects.create(
649
+ hostname="visitor-node",
650
+ address="10.0.0.100",
651
+ port=8000,
652
+ mac_address="00:10:20:30:40:50",
653
+ role=self.role,
654
+ )
655
+
656
+ def test_register_node_emits_join_message_when_upstream_added(self):
657
+ payload = {
658
+ "hostname": "host-node",
659
+ "address": "10.0.0.10",
660
+ "port": 8100,
661
+ "mac_address": "aa:bb:cc:dd:ee:01",
662
+ "current_relation": "Upstream",
663
+ }
664
+ with patch("nodes.views.Node.get_local", return_value=self.visitor), patch.object(
665
+ NetMessage, "broadcast"
666
+ ) as mock_broadcast:
667
+ response = self.client.post(
668
+ reverse("register-node"),
669
+ data=json.dumps(payload),
670
+ content_type="application/json",
671
+ )
672
+
673
+ self.assertEqual(response.status_code, 200)
674
+ mock_broadcast.assert_called_once_with(
675
+ subject="NODE visitor-node", body="JOINS host-node"
676
+ )
677
+
678
+ def test_register_node_skips_message_when_not_upstream(self):
679
+ payload = {
680
+ "hostname": "remote-node",
681
+ "address": "10.0.0.11",
682
+ "port": 8101,
683
+ "mac_address": "aa:bb:cc:dd:ee:02",
684
+ "current_relation": "Downstream",
685
+ }
686
+ with patch("nodes.views.Node.get_local", return_value=self.visitor), patch.object(
687
+ NetMessage, "broadcast"
688
+ ) as mock_broadcast:
689
+ response = self.client.post(
690
+ reverse("register-node"),
691
+ data=json.dumps(payload),
692
+ content_type="application/json",
693
+ )
694
+
695
+ self.assertEqual(response.status_code, 200)
696
+ mock_broadcast.assert_not_called()
697
+
698
+
444
699
  class NodeRegisterCurrentTests(TestCase):
445
700
  def setUp(self):
446
701
  User = get_user_model()
@@ -752,6 +1007,17 @@ class NodeRegisterCurrentTests(TestCase):
752
1007
  mac_address="00:11:22:33:44:cc",
753
1008
  public_key=public_key,
754
1009
  )
1010
+ target_role, _ = NodeRole.objects.get_or_create(name="Control")
1011
+ feature, _ = NodeFeature.objects.get_or_create(
1012
+ slug="net-message", defaults={"display": "Net Message"}
1013
+ )
1014
+ target = Node.objects.create(
1015
+ hostname="target",
1016
+ address="10.0.0.2",
1017
+ port=8001,
1018
+ mac_address="00:11:22:33:44:dd",
1019
+ role=target_role,
1020
+ )
755
1021
  msg_id = str(uuid.uuid4())
756
1022
  payload = {
757
1023
  "uuid": msg_id,
@@ -760,6 +1026,12 @@ class NodeRegisterCurrentTests(TestCase):
760
1026
  "seen": [],
761
1027
  "sender": str(sender.uuid),
762
1028
  "origin": str(sender.uuid),
1029
+ "filter_node": str(target.uuid),
1030
+ "filter_node_feature": feature.slug,
1031
+ "filter_node_role": target_role.name,
1032
+ "filter_current_relation": Node.Relation.UPSTREAM,
1033
+ "filter_installed_version": "1.0.0",
1034
+ "filter_installed_revision": "rev123",
763
1035
  }
764
1036
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
765
1037
  signature = key.sign(payload_json.encode(), padding.PKCS1v15(), hashes.SHA256())
@@ -773,6 +1045,12 @@ class NodeRegisterCurrentTests(TestCase):
773
1045
  self.assertTrue(NetMessage.objects.filter(uuid=msg_id).exists())
774
1046
  message = NetMessage.objects.get(uuid=msg_id)
775
1047
  self.assertEqual(message.node_origin, sender)
1048
+ self.assertEqual(message.filter_node, target)
1049
+ self.assertEqual(message.filter_node_feature, feature)
1050
+ self.assertEqual(message.filter_node_role, target_role)
1051
+ self.assertEqual(message.filter_current_relation, Node.Relation.UPSTREAM)
1052
+ self.assertEqual(message.filter_installed_version, "1.0.0")
1053
+ self.assertEqual(message.filter_installed_revision, "rev123")
776
1054
 
777
1055
  def test_clipboard_polling_creates_task(self):
778
1056
  feature, _ = NodeFeature.objects.get_or_create(
@@ -833,6 +1111,45 @@ class NodeAdminTests(TestCase):
833
1111
  if security_dir.exists():
834
1112
  shutil.rmtree(security_dir)
835
1113
 
1114
+ def _create_local_node(self):
1115
+ return Node.objects.create(
1116
+ hostname="localnode",
1117
+ address="127.0.0.1",
1118
+ port=8000,
1119
+ mac_address=Node.get_current_mac(),
1120
+ )
1121
+
1122
+ def test_node_feature_list_shows_default_action_when_enabled(self):
1123
+ node = self._create_local_node()
1124
+ feature, _ = NodeFeature.objects.get_or_create(
1125
+ slug="rfid-scanner", defaults={"display": "RFID Scanner"}
1126
+ )
1127
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1128
+ response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
1129
+ action_url = reverse("admin:core_rfid_scan")
1130
+ self.assertContains(response, f'href="{action_url}"')
1131
+
1132
+ def test_node_feature_list_shows_all_actions_for_rpi_camera(self):
1133
+ node = self._create_local_node()
1134
+ feature, _ = NodeFeature.objects.get_or_create(
1135
+ slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
1136
+ )
1137
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1138
+ response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
1139
+ snapshot_url = reverse("admin:nodes_nodefeature_take_snapshot")
1140
+ stream_url = reverse("admin:nodes_nodefeature_view_stream")
1141
+ self.assertContains(response, f'href="{snapshot_url}"')
1142
+ self.assertContains(response, f'href="{stream_url}"')
1143
+
1144
+ def test_node_feature_list_hides_default_action_when_disabled(self):
1145
+ self._create_local_node()
1146
+ NodeFeature.objects.get_or_create(
1147
+ slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
1148
+ )
1149
+ response = self.client.get(reverse("admin:nodes_nodefeature_changelist"))
1150
+ action_url = reverse("admin:nodes_nodefeature_take_screenshot")
1151
+ self.assertNotContains(response, f'href="{action_url}"')
1152
+
836
1153
  def test_register_current_host(self):
837
1154
  url = reverse("admin:nodes_node_register_current")
838
1155
  hostname = socket.gethostname()
@@ -890,6 +1207,34 @@ class NodeAdminTests(TestCase):
890
1207
  )
891
1208
  self.assertIn(node.public_key.strip(), resp.content.decode())
892
1209
 
1210
+ def test_register_current_requires_superuser(self):
1211
+ User = get_user_model()
1212
+ staff = User.objects.create_user(
1213
+ username="staff", password="pass", is_staff=True
1214
+ )
1215
+ permission = Permission.objects.get(codename="view_node")
1216
+ staff.user_permissions.add(permission)
1217
+ self.client.force_login(staff)
1218
+
1219
+ response = self.client.get(reverse("admin:nodes_node_register_current"))
1220
+
1221
+ self.assertEqual(response.status_code, 403)
1222
+
1223
+ def test_register_current_link_hidden_for_non_superusers(self):
1224
+ User = get_user_model()
1225
+ staff = User.objects.create_user(
1226
+ username="linkstaff", password="pass", is_staff=True
1227
+ )
1228
+ permission = Permission.objects.get(codename="view_node")
1229
+ staff.user_permissions.add(permission)
1230
+ self.client.force_login(staff)
1231
+
1232
+ response = self.client.get(reverse("admin:nodes_node_changelist"))
1233
+
1234
+ self.assertNotContains(
1235
+ response, reverse("admin:nodes_node_register_current")
1236
+ )
1237
+
893
1238
  @patch("nodes.admin.capture_screenshot")
894
1239
  def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
895
1240
  screenshot_dir = settings.LOG_DIR / "screenshots"
@@ -967,6 +1312,349 @@ class NodeAdminTests(TestCase):
967
1312
  samples = list(ContentSample.objects.filter(kind=ContentSample.IMAGE))
968
1313
  self.assertEqual(samples[0].transaction_uuid, samples[1].transaction_uuid)
969
1314
 
1315
+ @patch("nodes.admin.capture_screenshot")
1316
+ def test_take_screenshot_default_action_creates_sample(
1317
+ self, mock_capture_screenshot
1318
+ ):
1319
+ node = self._create_local_node()
1320
+ feature, _ = NodeFeature.objects.get_or_create(
1321
+ slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
1322
+ )
1323
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1324
+ screenshot_dir = settings.LOG_DIR / "screenshots"
1325
+ screenshot_dir.mkdir(parents=True, exist_ok=True)
1326
+ file_path = screenshot_dir / "default.png"
1327
+ file_path.write_bytes(b"default")
1328
+ mock_capture_screenshot.return_value = Path("screenshots/default.png")
1329
+ response = self.client.get(
1330
+ reverse("admin:nodes_nodefeature_take_screenshot"), follow=True
1331
+ )
1332
+ self.assertEqual(response.status_code, 200)
1333
+ sample = ContentSample.objects.get(kind=ContentSample.IMAGE)
1334
+ self.assertEqual(sample.node, node)
1335
+ self.assertEqual(sample.method, "DEFAULT_ACTION")
1336
+ mock_capture_screenshot.assert_called_once_with("http://testserver/")
1337
+ change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
1338
+ self.assertEqual(response.redirect_chain[-1][0], change_url)
1339
+
1340
+ def test_check_features_for_eligibility_action_success(self):
1341
+ node = self._create_local_node()
1342
+ feature, _ = NodeFeature.objects.get_or_create(
1343
+ slug="rfid-scanner", defaults={"display": "RFID Scanner"}
1344
+ )
1345
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1346
+ changelist_url = reverse("admin:nodes_nodefeature_changelist")
1347
+ response = self.client.post(
1348
+ changelist_url,
1349
+ {
1350
+ "action": "check_features_for_eligibility",
1351
+ "_selected_action": [str(feature.pk)],
1352
+ },
1353
+ follow=True,
1354
+ )
1355
+ self.assertEqual(response.status_code, 200)
1356
+ self.assertContains(
1357
+ response,
1358
+ "RFID Scanner is enabled on localnode. This feature cannot be enabled manually.",
1359
+ html=False,
1360
+ )
1361
+ self.assertContains(
1362
+ response, "Completed 1 of 1 feature check(s) successfully.", html=False
1363
+ )
1364
+
1365
+ def test_check_features_for_eligibility_action_warns_when_disabled(self):
1366
+ self._create_local_node()
1367
+ feature, _ = NodeFeature.objects.get_or_create(
1368
+ slug="rfid-scanner", defaults={"display": "RFID Scanner"}
1369
+ )
1370
+ changelist_url = reverse("admin:nodes_nodefeature_changelist")
1371
+ response = self.client.post(
1372
+ changelist_url,
1373
+ {
1374
+ "action": "check_features_for_eligibility",
1375
+ "_selected_action": [str(feature.pk)],
1376
+ },
1377
+ follow=True,
1378
+ )
1379
+ self.assertEqual(response.status_code, 200)
1380
+ self.assertContains(
1381
+ response,
1382
+ "RFID Scanner is not enabled on localnode. This feature cannot be enabled manually.",
1383
+ html=False,
1384
+ )
1385
+ self.assertContains(
1386
+ response, "Completed 0 of 1 feature check(s) successfully.", html=False
1387
+ )
1388
+
1389
+ def test_enable_selected_features_enables_manual_feature(self):
1390
+ node = self._create_local_node()
1391
+ feature, _ = NodeFeature.objects.get_or_create(
1392
+ slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
1393
+ )
1394
+ changelist_url = reverse("admin:nodes_nodefeature_changelist")
1395
+ response = self.client.post(
1396
+ changelist_url,
1397
+ {
1398
+ "action": "enable_selected_features",
1399
+ "_selected_action": [str(feature.pk)],
1400
+ },
1401
+ follow=True,
1402
+ )
1403
+ self.assertEqual(response.status_code, 200)
1404
+ self.assertTrue(Node.objects.get(pk=node.pk).has_feature("screenshot-poll"))
1405
+ self.assertContains(
1406
+ response, "Enabled 1 feature(s): Screenshot Poll", html=False
1407
+ )
1408
+
1409
+ def test_enable_selected_features_warns_for_non_manual(self):
1410
+ self._create_local_node()
1411
+ feature, _ = NodeFeature.objects.get_or_create(
1412
+ slug="rfid-scanner", defaults={"display": "RFID Scanner"}
1413
+ )
1414
+ changelist_url = reverse("admin:nodes_nodefeature_changelist")
1415
+ response = self.client.post(
1416
+ changelist_url,
1417
+ {
1418
+ "action": "enable_selected_features",
1419
+ "_selected_action": [str(feature.pk)],
1420
+ },
1421
+ follow=True,
1422
+ )
1423
+ self.assertEqual(response.status_code, 200)
1424
+ self.assertContains(
1425
+ response, "RFID Scanner cannot be enabled manually.", html=False
1426
+ )
1427
+ self.assertContains(
1428
+ response,
1429
+ "None of the selected features can be enabled manually.",
1430
+ html=False,
1431
+ )
1432
+
1433
+ def test_take_screenshot_default_action_requires_enabled_feature(self):
1434
+ self._create_local_node()
1435
+ NodeFeature.objects.get_or_create(
1436
+ slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
1437
+ )
1438
+ response = self.client.get(
1439
+ reverse("admin:nodes_nodefeature_take_screenshot"), follow=True
1440
+ )
1441
+ self.assertEqual(response.status_code, 200)
1442
+ changelist_url = reverse("admin:nodes_nodefeature_changelist")
1443
+ self.assertEqual(response.wsgi_request.path, changelist_url)
1444
+ self.assertEqual(ContentSample.objects.count(), 0)
1445
+ self.assertContains(response, "Screenshot Poll feature is not enabled")
1446
+
1447
+ @patch("nodes.admin.capture_rpi_snapshot")
1448
+ def test_take_snapshot_default_action_creates_sample(self, mock_snapshot):
1449
+ node = self._create_local_node()
1450
+ feature, _ = NodeFeature.objects.get_or_create(
1451
+ slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
1452
+ )
1453
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1454
+ camera_dir = settings.LOG_DIR / "camera"
1455
+ camera_dir.mkdir(parents=True, exist_ok=True)
1456
+ file_path = camera_dir / "snap.jpg"
1457
+ file_path.write_bytes(b"camera")
1458
+ mock_snapshot.return_value = file_path
1459
+ response = self.client.get(
1460
+ reverse("admin:nodes_nodefeature_take_snapshot"), follow=True
1461
+ )
1462
+ self.assertEqual(response.status_code, 200)
1463
+ sample = ContentSample.objects.get(kind=ContentSample.IMAGE)
1464
+ self.assertEqual(sample.node, node)
1465
+ self.assertEqual(sample.method, "RPI_CAMERA")
1466
+ change_url = reverse("admin:nodes_contentsample_change", args=[sample.pk])
1467
+ self.assertEqual(response.redirect_chain[-1][0], change_url)
1468
+
1469
+ def test_view_stream_requires_enabled_feature(self):
1470
+ self._create_local_node()
1471
+ NodeFeature.objects.get_or_create(
1472
+ slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
1473
+ )
1474
+ response = self.client.get(
1475
+ reverse("admin:nodes_nodefeature_view_stream"), follow=True
1476
+ )
1477
+ self.assertEqual(response.status_code, 200)
1478
+ changelist_url = reverse("admin:nodes_nodefeature_changelist")
1479
+ self.assertEqual(response.wsgi_request.path, changelist_url)
1480
+ self.assertContains(
1481
+ response, "Raspberry Pi Camera feature is not enabled on this node."
1482
+ )
1483
+
1484
+ def test_view_stream_renders_when_feature_enabled(self):
1485
+ node = self._create_local_node()
1486
+ feature, _ = NodeFeature.objects.get_or_create(
1487
+ slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
1488
+ )
1489
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1490
+ response = self.client.get(reverse("admin:nodes_nodefeature_view_stream"))
1491
+ self.assertEqual(response.status_code, 200)
1492
+ response.render()
1493
+ expected_stream = "http://testserver:8554/"
1494
+ self.assertEqual(response.context_data["stream_url"], expected_stream)
1495
+ self.assertContains(response, expected_stream)
1496
+ self.assertContains(response, "camera-stream__frame")
1497
+
1498
+ def test_view_stream_uses_configured_stream_url(self):
1499
+ node = self._create_local_node()
1500
+ feature, _ = NodeFeature.objects.get_or_create(
1501
+ slug="rpi-camera", defaults={"display": "Raspberry Pi Camera"}
1502
+ )
1503
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
1504
+ configured_stream = "https://camera.local/stream"
1505
+ with self.settings(RPI_CAMERA_STREAM_URL=configured_stream):
1506
+ response = self.client.get(
1507
+ reverse("admin:nodes_nodefeature_view_stream")
1508
+ )
1509
+ self.assertEqual(response.status_code, 200)
1510
+ response.render()
1511
+ self.assertEqual(response.context_data["stream_url"], configured_stream)
1512
+ self.assertContains(response, configured_stream)
1513
+
1514
+ @patch("nodes.admin.requests.post")
1515
+ def test_fetch_rfids_action_fetches_and_imports(self, mock_post):
1516
+ local = self._create_local_node()
1517
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
1518
+ private_bytes = key.private_bytes(
1519
+ encoding=serialization.Encoding.PEM,
1520
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
1521
+ encryption_algorithm=serialization.NoEncryption(),
1522
+ )
1523
+ public_bytes = key.public_key().public_bytes(
1524
+ encoding=serialization.Encoding.PEM,
1525
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
1526
+ )
1527
+ security_dir = Path(settings.BASE_DIR) / "security"
1528
+ security_dir.mkdir(parents=True, exist_ok=True)
1529
+ (security_dir / f"{local.public_endpoint}").write_bytes(private_bytes)
1530
+ (security_dir / f"{local.public_endpoint}.pub").write_bytes(public_bytes)
1531
+ local.public_key = public_bytes.decode()
1532
+ local.save(update_fields=["public_key"])
1533
+
1534
+ remote = Node.objects.create(
1535
+ hostname="remote",
1536
+ address="127.0.0.2",
1537
+ port=8010,
1538
+ mac_address="aa:bb:cc:dd:ee:ff",
1539
+ )
1540
+
1541
+ mock_response = MagicMock()
1542
+ mock_response.status_code = 200
1543
+ mock_response.json.return_value = {
1544
+ "rfids": [
1545
+ {
1546
+ "rfid": "abc123",
1547
+ "custom_label": "Remote tag",
1548
+ "key_a": "A1B2C3D4E5F6",
1549
+ "key_b": "FFFFFFFFFFFF",
1550
+ "data": ["sector"],
1551
+ "key_a_verified": True,
1552
+ "key_b_verified": False,
1553
+ "allowed": True,
1554
+ "color": RFID.BLACK,
1555
+ "kind": RFID.CLASSIC,
1556
+ "released": False,
1557
+ "last_seen_on": None,
1558
+ }
1559
+ ]
1560
+ }
1561
+ mock_response.text = ""
1562
+ mock_post.return_value = mock_response
1563
+
1564
+ response = self.client.post(
1565
+ reverse("admin:nodes_node_changelist"),
1566
+ {"action": "fetch_rfids", "_selected_action": [str(remote.pk)]},
1567
+ follow=True,
1568
+ )
1569
+ self.assertEqual(response.status_code, 200)
1570
+ self.assertTrue(RFID.objects.filter(rfid="ABC123").exists())
1571
+ tag = RFID.objects.get(rfid="ABC123")
1572
+ self.assertEqual(tag.custom_label, "Remote tag")
1573
+ self.assertEqual(tag.origin_node, remote)
1574
+ self.assertEqual(tag.data, ["sector"])
1575
+
1576
+ self.assertTrue(mock_post.called)
1577
+ call_kwargs = mock_post.call_args.kwargs
1578
+ payload = call_kwargs["data"]
1579
+ headers = call_kwargs["headers"]
1580
+ signature = base64.b64decode(headers["X-Signature"])
1581
+ key.public_key().verify(
1582
+ signature,
1583
+ payload.encode(),
1584
+ padding.PKCS1v15(),
1585
+ hashes.SHA256(),
1586
+ )
1587
+ self.assertContains(response, "Fetched RFIDs from 1 node(s)")
1588
+
1589
+
1590
+ class RFIDExportViewTests(TestCase):
1591
+ def setUp(self):
1592
+ self.client = Client()
1593
+ NodeRole.objects.get_or_create(name="Terminal")
1594
+ self.local_node = Node.objects.create(
1595
+ hostname="local",
1596
+ address="127.0.0.1",
1597
+ port=8000,
1598
+ mac_address=Node.get_current_mac(),
1599
+ )
1600
+ self.remote_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
1601
+ self.remote_public = (
1602
+ self.remote_key.public_key()
1603
+ .public_bytes(
1604
+ encoding=serialization.Encoding.PEM,
1605
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
1606
+ )
1607
+ .decode()
1608
+ )
1609
+ self.remote_node = Node.objects.create(
1610
+ hostname="remote",
1611
+ address="10.0.0.2",
1612
+ port=8100,
1613
+ mac_address="00:11:22:33:44:55",
1614
+ public_key=self.remote_public,
1615
+ )
1616
+
1617
+ def _sign_payload(self, payload):
1618
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
1619
+ signature = self.remote_key.sign(
1620
+ payload_json.encode(),
1621
+ padding.PKCS1v15(),
1622
+ hashes.SHA256(),
1623
+ )
1624
+ return payload_json, base64.b64encode(signature).decode()
1625
+
1626
+ def test_export_requires_signature(self):
1627
+ payload_json = json.dumps(
1628
+ {"requester": str(self.remote_node.uuid)},
1629
+ separators=(",", ":"),
1630
+ sort_keys=True,
1631
+ )
1632
+ response = self.client.post(
1633
+ reverse("node-rfid-export"),
1634
+ data=payload_json,
1635
+ content_type="application/json",
1636
+ )
1637
+ self.assertEqual(response.status_code, 403)
1638
+
1639
+ def test_export_returns_serialized_data(self):
1640
+ RFID.objects.create(rfid="ABCDEF")
1641
+ payload_json, signature = self._sign_payload(
1642
+ {"requester": str(self.remote_node.uuid)}
1643
+ )
1644
+ response = self.client.post(
1645
+ reverse("node-rfid-export"),
1646
+ data=payload_json,
1647
+ content_type="application/json",
1648
+ HTTP_X_SIGNATURE=signature,
1649
+ )
1650
+ self.assertEqual(response.status_code, 200)
1651
+ body = response.json()
1652
+ self.assertIn("rfids", body)
1653
+ self.assertEqual(len(body["rfids"]), 1)
1654
+ tag_data = body["rfids"][0]
1655
+ self.assertEqual(tag_data["rfid"], "ABCDEF")
1656
+ self.assertIn("custom_label", tag_data)
1657
+
970
1658
 
971
1659
  class NetMessageAdminTests(TransactionTestCase):
972
1660
  reset_sequences = True
@@ -999,6 +1687,27 @@ class NetMessageAdminTests(TransactionTestCase):
999
1687
  self.assertEqual(response.status_code, 302)
1000
1688
  mock_propagate.assert_called_once()
1001
1689
 
1690
+ def test_reply_action_prefills_initial_data(self):
1691
+ role = NodeRole.objects.get(name="Terminal")
1692
+ node = Node.objects.create(
1693
+ hostname="remote",
1694
+ address="10.0.0.10",
1695
+ port=8100,
1696
+ mac_address="00:11:22:33:44:55",
1697
+ role=role,
1698
+ )
1699
+ original = NetMessage.objects.create(
1700
+ subject="Ping",
1701
+ body="Hello",
1702
+ node_origin=node,
1703
+ )
1704
+ url = f"{reverse('admin:nodes_netmessage_add')}?reply_to={original.pk}"
1705
+ response = self.client.get(url)
1706
+ self.assertEqual(response.status_code, 200)
1707
+ form = response.context_data["adminform"].form
1708
+ self.assertEqual(form["subject"].value(), "Re: Ping")
1709
+ self.assertEqual(str(form["filter_node"].value()), str(node.pk))
1710
+
1002
1711
 
1003
1712
  class LastNetMessageViewTests(TestCase):
1004
1713
  def setUp(self):
@@ -1007,10 +1716,19 @@ class LastNetMessageViewTests(TestCase):
1007
1716
 
1008
1717
  def test_returns_latest_message(self):
1009
1718
  NetMessage.objects.create(subject="old", body="msg1")
1010
- NetMessage.objects.create(subject="new", body="msg2")
1719
+ latest = NetMessage.objects.create(subject="new", body="msg2")
1011
1720
  resp = self.client.get(reverse("last-net-message"))
1012
1721
  self.assertEqual(resp.status_code, 200)
1013
- self.assertEqual(resp.json(), {"subject": "new", "body": "msg2"})
1722
+ self.assertEqual(
1723
+ resp.json(),
1724
+ {
1725
+ "subject": "new",
1726
+ "body": "msg2",
1727
+ "admin_url": reverse(
1728
+ "admin:nodes_netmessage_change", args=[latest.pk]
1729
+ ),
1730
+ },
1731
+ )
1014
1732
 
1015
1733
 
1016
1734
  class NetMessageReachTests(TestCase):
@@ -1071,8 +1789,10 @@ class NetMessageReachTests(TestCase):
1071
1789
  with patch.object(Node, "get_local", return_value=None):
1072
1790
  msg.propagate()
1073
1791
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
1074
- self.assertEqual(roles, {"Constellation", "Satellite", "Control"})
1075
- self.assertEqual(mock_post.call_count, 3)
1792
+ self.assertEqual(
1793
+ roles, {"Constellation", "Satellite", "Control", "Terminal"}
1794
+ )
1795
+ self.assertEqual(mock_post.call_count, 4)
1076
1796
 
1077
1797
  @patch("requests.post")
1078
1798
  def test_default_reach_not_limited_to_terminal(self, mock_post):
@@ -1083,7 +1803,105 @@ class NetMessageReachTests(TestCase):
1083
1803
  msg.propagate()
1084
1804
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
1085
1805
  self.assertIn("Control", roles)
1086
- self.assertEqual(mock_post.call_count, 3)
1806
+ self.assertEqual(mock_post.call_count, 4)
1807
+
1808
+
1809
+ class NetMessageFilterTests(TestCase):
1810
+ def setUp(self):
1811
+ self.terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
1812
+ self.control_role, _ = NodeRole.objects.get_or_create(name="Control")
1813
+ self.nodes = {
1814
+ "terminal": Node.objects.create(
1815
+ hostname="terminal-filter",
1816
+ address="10.20.0.1",
1817
+ port=8020,
1818
+ mac_address="00:11:22:33:55:01",
1819
+ role=self.terminal_role,
1820
+ ),
1821
+ "control": Node.objects.create(
1822
+ hostname="control-filter",
1823
+ address="10.20.0.2",
1824
+ port=8021,
1825
+ mac_address="00:11:22:33:55:02",
1826
+ role=self.control_role,
1827
+ ),
1828
+ }
1829
+ self.feature, _ = NodeFeature.objects.get_or_create(
1830
+ slug="filter-test", defaults={"display": "Filter Test"}
1831
+ )
1832
+ NodeFeatureAssignment.objects.get_or_create(
1833
+ node=self.nodes["control"], feature=self.feature
1834
+ )
1835
+ self.nodes["control"].current_relation = Node.Relation.UPSTREAM
1836
+ self.nodes["control"].installed_version = "1.2.3"
1837
+ self.nodes["control"].installed_revision = "abc123"
1838
+ self.nodes["control"].save(
1839
+ update_fields=[
1840
+ "current_relation",
1841
+ "installed_version",
1842
+ "installed_revision",
1843
+ ]
1844
+ )
1845
+
1846
+ @patch("requests.post")
1847
+ def test_filter_node_limits_targets(self, mock_post):
1848
+ msg = NetMessage.objects.create(
1849
+ subject="s", body="b", filter_node=self.nodes["control"]
1850
+ )
1851
+ with patch.object(Node, "get_local", return_value=None):
1852
+ msg.propagate()
1853
+ self.assertEqual(
1854
+ list(msg.propagated_to.values_list("pk", flat=True)),
1855
+ [self.nodes["control"].pk],
1856
+ )
1857
+ mock_post.assert_called_once()
1858
+
1859
+ @patch("requests.post")
1860
+ def test_filter_fields_limit_queryset(self, mock_post):
1861
+ msg = NetMessage.objects.create(
1862
+ subject="s",
1863
+ body="b",
1864
+ filter_node_feature=self.feature,
1865
+ filter_node_role=self.control_role,
1866
+ filter_current_relation=Node.Relation.UPSTREAM,
1867
+ filter_installed_version="1.2.3",
1868
+ filter_installed_revision="abc123",
1869
+ )
1870
+ with patch.object(Node, "get_local", return_value=None):
1871
+ msg.propagate()
1872
+ self.assertEqual(
1873
+ list(msg.propagated_to.values_list("pk", flat=True)),
1874
+ [self.nodes["control"].pk],
1875
+ )
1876
+ mock_post.assert_called_once()
1877
+
1878
+
1879
+ class NetMessageBroadcastStringReachTests(TestCase):
1880
+ def test_broadcast_uses_role_lookup_for_string_reach(self):
1881
+ role = NodeRole.objects.create(name="Terminal")
1882
+ local = Node.objects.create(
1883
+ hostname="terminal-local",
1884
+ address="10.10.0.1",
1885
+ port=8010,
1886
+ mac_address="00:aa:bb:cc:dd:ff",
1887
+ role=role,
1888
+ public_endpoint="terminal-local",
1889
+ )
1890
+ seen = ["existing"]
1891
+
1892
+ with patch.object(Node, "get_local", return_value=local), patch.object(
1893
+ NetMessage, "propagate"
1894
+ ) as mock_propagate:
1895
+ msg = NetMessage.broadcast(
1896
+ "Subject", "Body", reach="Terminal", seen=seen
1897
+ )
1898
+
1899
+ self.assertEqual(msg.reach, role)
1900
+ self.assertEqual(msg.node_origin, local)
1901
+ mock_propagate.assert_called_once()
1902
+ called_args = mock_propagate.call_args
1903
+ self.assertIn("seen", called_args.kwargs)
1904
+ self.assertIs(called_args.kwargs["seen"], seen)
1087
1905
 
1088
1906
 
1089
1907
  class NetMessagePropagationTests(TestCase):
@@ -1138,6 +1956,29 @@ class NetMessagePropagationTests(TestCase):
1138
1956
  self.assertEqual(msg.propagated_to.count(), 4)
1139
1957
  self.assertTrue(msg.complete)
1140
1958
 
1959
+ @patch("requests.post")
1960
+ @patch("core.notifications.notify", return_value=False)
1961
+ def test_propagate_defaults_to_six_when_available(
1962
+ self, mock_notify, mock_post
1963
+ ):
1964
+ for idx in range(6, 12):
1965
+ self.remotes.append(
1966
+ Node.objects.create(
1967
+ hostname=f"n{idx}",
1968
+ address=f"10.0.0.{idx}",
1969
+ port=8000 + idx,
1970
+ mac_address=f"00:11:22:33:44:{idx:02x}",
1971
+ role=self.role,
1972
+ public_endpoint=f"n{idx}",
1973
+ )
1974
+ )
1975
+ msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
1976
+ with patch.object(Node, "get_local", return_value=self.local):
1977
+ msg.propagate()
1978
+ self.assertEqual(mock_post.call_count, 6)
1979
+ self.assertEqual(msg.propagated_to.count(), 6)
1980
+ self.assertFalse(msg.complete)
1981
+
1141
1982
  @patch("requests.post")
1142
1983
  @patch("core.notifications.notify", return_value=True)
1143
1984
  def test_propagate_prunes_old_local_messages(self, mock_notify, mock_post):
@@ -1271,6 +2112,36 @@ class StartupNotificationTests(TestCase):
1271
2112
  self.assertEqual(kwargs["subject"], "host:9000")
1272
2113
  self.assertTrue(kwargs["body"].startswith("1.2.3 r"))
1273
2114
 
2115
+ def test_startup_notification_marks_nonrelease_version(self):
2116
+ from nodes.apps import _startup_notification
2117
+
2118
+ package = Package.objects.create(name="pkg-start", is_active=False)
2119
+ PackageRelease.objects.create(
2120
+ package=package,
2121
+ version="1.2.3",
2122
+ revision="0" * 40,
2123
+ )
2124
+
2125
+ with TemporaryDirectory() as tmp:
2126
+ tmp_path = Path(tmp)
2127
+ (tmp_path / "VERSION").write_text("1.2.3")
2128
+ with self.settings(BASE_DIR=tmp_path):
2129
+ with patch(
2130
+ "nodes.apps.revision.get_revision", return_value="1" * 40
2131
+ ):
2132
+ with patch("nodes.models.NetMessage.broadcast") as mock_broadcast:
2133
+ with patch(
2134
+ "nodes.apps.socket.gethostname", return_value="host"
2135
+ ):
2136
+ with patch.dict(os.environ, {"PORT": "9000"}):
2137
+ _startup_notification()
2138
+ time.sleep(0.1)
2139
+
2140
+ mock_broadcast.assert_called_once()
2141
+ _, kwargs = mock_broadcast.call_args
2142
+ self.assertEqual(kwargs["subject"], "host:9000")
2143
+ self.assertEqual(kwargs["body"], "1.2.3+ r111111")
2144
+
1274
2145
 
1275
2146
  class StartupHandlerTests(TestCase):
1276
2147
  def test_handler_logs_db_errors(self):
@@ -1494,6 +2365,16 @@ class EmailOutboxTests(TestCase):
1494
2365
 
1495
2366
  self.assertEqual(str(outbox), "mailer@example.com")
1496
2367
 
2368
+ def test_string_representation_trims_trailing_at_symbol(self):
2369
+ outbox = EmailOutbox.objects.create(
2370
+ host="smtp.example.com",
2371
+ port=587,
2372
+ username="mailer@",
2373
+ password="secret",
2374
+ )
2375
+
2376
+ self.assertEqual(str(outbox), "mailer@smtp.example.com")
2377
+
1497
2378
  def test_unattached_outbox_used_as_fallback(self):
1498
2379
  EmailOutbox.objects.create(
1499
2380
  group=SecurityGroup.objects.create(name="Attached"),
@@ -1713,6 +2594,45 @@ class NodeFeatureTests(TestCase):
1713
2594
  role=self.role,
1714
2595
  )
1715
2596
 
2597
+ def test_default_action_mapping_for_known_feature(self):
2598
+ feature = NodeFeature.objects.create(
2599
+ slug="rfid-scanner", display="RFID Scanner"
2600
+ )
2601
+ actions = feature.get_default_actions()
2602
+ self.assertEqual(len(actions), 1)
2603
+ action = actions[0]
2604
+ self.assertEqual(action.label, "Scan RFIDs")
2605
+ self.assertEqual(action.url_name, "admin:core_rfid_scan")
2606
+ self.assertEqual(feature.get_default_action(), action)
2607
+
2608
+ def test_celery_feature_default_action(self):
2609
+ feature = NodeFeature.objects.create(
2610
+ slug="celery-queue", display="Celery Queue"
2611
+ )
2612
+ actions = feature.get_default_actions()
2613
+ self.assertEqual(len(actions), 1)
2614
+ action = actions[0]
2615
+ self.assertEqual(action.label, "Celery Report")
2616
+ self.assertEqual(action.url_name, "admin:nodes_nodefeature_celery_report")
2617
+ self.assertEqual(feature.get_default_action(), action)
2618
+
2619
+ def test_rpi_camera_feature_has_multiple_actions(self):
2620
+ feature = NodeFeature.objects.create(
2621
+ slug="rpi-camera", display="Raspberry Pi Camera"
2622
+ )
2623
+ actions = feature.get_default_actions()
2624
+ self.assertEqual(len(actions), 2)
2625
+ labels = {action.label for action in actions}
2626
+ self.assertIn("Take a Snapshot", labels)
2627
+ self.assertIn("View stream", labels)
2628
+
2629
+ def test_default_action_missing_when_unconfigured(self):
2630
+ feature = NodeFeature.objects.create(
2631
+ slug="custom-feature", display="Custom Feature"
2632
+ )
2633
+ self.assertEqual(feature.get_default_actions(), ())
2634
+ self.assertIsNone(feature.get_default_action())
2635
+
1716
2636
  def test_lcd_screen_enabled(self):
1717
2637
  feature = NodeFeature.objects.create(slug="lcd-screen", display="LCD")
1718
2638
  feature.roles.add(self.role)
@@ -1727,6 +2647,13 @@ class NodeFeatureTests(TestCase):
1727
2647
  ):
1728
2648
  self.assertFalse(feature.is_enabled)
1729
2649
 
2650
+ def test_feature_disabled_when_local_node_missing(self):
2651
+ feature = NodeFeature.objects.create(slug="lcd-screen", display="LCD")
2652
+ with patch("nodes.models.Node.get_local", return_value=None):
2653
+ with patch("core.notifications.supports_gui_toast") as mock_toast:
2654
+ self.assertFalse(feature.is_enabled)
2655
+ mock_toast.assert_not_called()
2656
+
1730
2657
  def test_rfid_scanner_lock(self):
1731
2658
  feature = NodeFeature.objects.create(slug="rfid-scanner", display="RFID")
1732
2659
  feature.roles.add(self.role)
@@ -1884,45 +2811,66 @@ class NodeFeatureTests(TestCase):
1884
2811
  NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
1885
2812
  )
1886
2813
 
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"
2814
+
2815
+ class CeleryReportAdminViewTests(TestCase):
2816
+ def setUp(self):
2817
+ User = get_user_model()
2818
+ self.superuser = User.objects.create_superuser(
2819
+ username="admin", email="admin@example.com", password="secret"
1891
2820
  )
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()
2821
+ self.client.force_login(self.superuser)
2822
+
2823
+ self.log_file = Path(settings.LOG_DIR) / settings.LOG_FILE_NAME
2824
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
2825
+ self._original_log_contents: str | None = None
2826
+ if self.log_file.exists():
2827
+ self._original_log_contents = self.log_file.read_text(encoding="utf-8")
2828
+ self.addCleanup(self._restore_log_file)
2829
+
2830
+ PeriodicTask.objects.all().delete()
2831
+
2832
+ def _restore_log_file(self):
2833
+ if self._original_log_contents is None:
2834
+ try:
2835
+ self.log_file.unlink()
2836
+ except FileNotFoundError:
2837
+ pass
2838
+ else:
2839
+ self.log_file.write_text(
2840
+ self._original_log_contents, encoding="utf-8"
2841
+ )
2842
+
2843
+ def test_report_includes_tasks_and_logs(self):
2844
+ now = timezone.now()
2845
+ schedule = IntervalSchedule.objects.create(
2846
+ every=1, period=IntervalSchedule.HOURS
2847
+ )
2848
+ PeriodicTask.objects.create(
2849
+ name="test-task",
2850
+ task="core.tasks.heartbeat",
2851
+ interval=schedule,
2852
+ enabled=True,
2853
+ last_run_at=now - timedelta(minutes=30),
1901
2854
  )
1902
2855
 
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"
2856
+ localized = timezone.localtime(now)
2857
+ log_line = (
2858
+ f"{localized.strftime('%Y-%m-%d %H:%M:%S,%f')} "
2859
+ "[INFO] core.tasks: Heartbeat task executed\n"
1907
2860
  )
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()
2861
+ self.log_file.write_text(log_line, encoding="utf-8")
2862
+
2863
+ response = self.client.get(
2864
+ reverse("admin:nodes_nodefeature_celery_report")
1917
2865
  )
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()
2866
+
2867
+ self.assertEqual(response.status_code, 200)
2868
+ self.assertContains(response, "Celery Report")
2869
+ self.assertContains(response, "test-task")
2870
+ self.assertContains(response, settings.LOG_FILE_NAME)
2871
+ entries = response.context_data["log_entries"]
2872
+ self.assertTrue(
2873
+ any("Heartbeat task executed" in entry.message for entry in entries)
1926
2874
  )
1927
2875
 
1928
2876