arthexis 0.1.7__py3-none-any.whl → 0.1.9__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 (82) hide show
  1. arthexis-0.1.9.dist-info/METADATA +168 -0
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +134 -16
  10. config/urls.py +71 -3
  11. core/admin.py +1331 -165
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +151 -0
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1136 -259
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +445 -58
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +17 -0
  42. core/workgroup_views.py +94 -0
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +4 -3
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.7.dist-info/METADATA +0 -126
  77. arthexis-0.1.7.dist-info/RECORD +0 -77
  78. arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
  79. config/workgroup_app.py +0 -7
  80. core/checks.py +0 -29
  81. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  82. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
nodes/tests.py CHANGED
@@ -6,13 +6,17 @@ import django
6
6
  django.setup()
7
7
 
8
8
  from pathlib import Path
9
+ from types import SimpleNamespace
9
10
  from unittest.mock import patch, call, MagicMock
11
+ from django.core import mail
12
+ from django.core.management import call_command
10
13
  import socket
11
14
  import base64
12
15
  import json
13
16
  import uuid
14
17
  from tempfile import TemporaryDirectory
15
18
  import shutil
19
+ import stat
16
20
  import time
17
21
 
18
22
  from django.test import Client, TestCase, TransactionTestCase, override_settings
@@ -31,23 +35,22 @@ from .models import (
31
35
  EmailOutbox,
32
36
  ContentSample,
33
37
  NodeRole,
38
+ NodeFeature,
39
+ NodeFeatureAssignment,
34
40
  NetMessage,
35
41
  )
42
+ from .backends import OutboxEmailBackend
36
43
  from .tasks import capture_node_screenshot, sample_clipboard
37
44
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
38
45
  from cryptography.hazmat.primitives import serialization, hashes
39
46
  from core.models import PackageRelease
40
- from .models import Operation
41
- from .admin import RUN_CONTEXTS
42
47
 
43
48
 
44
49
  class NodeTests(TestCase):
45
50
  def setUp(self):
46
51
  self.client = Client()
47
52
  User = get_user_model()
48
- self.user = User.objects.create_user(
49
- username="nodeuser", password="pwd"
50
- )
53
+ self.user = User.objects.create_user(username="nodeuser", password="pwd")
51
54
  self.client.force_login(self.user)
52
55
  NodeRole.objects.get_or_create(name="Terminal")
53
56
 
@@ -55,16 +58,18 @@ class NodeTests(TestCase):
55
58
  with TemporaryDirectory() as tmp:
56
59
  base = Path(tmp)
57
60
  with override_settings(BASE_DIR=base):
58
- with patch(
59
- "nodes.models.Node.get_current_mac",
60
- return_value="00:ff:ee:dd:cc:bb",
61
- ), patch(
62
- "nodes.models.socket.gethostname", return_value="testhost"
63
- ), patch(
64
- "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
65
- ), patch(
66
- "nodes.models.revision.get_revision", return_value="rev"
67
- ), patch.object(Node, "ensure_keys"):
61
+ with (
62
+ patch(
63
+ "nodes.models.Node.get_current_mac",
64
+ return_value="00:ff:ee:dd:cc:bb",
65
+ ),
66
+ patch("nodes.models.socket.gethostname", return_value="testhost"),
67
+ patch(
68
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
69
+ ),
70
+ patch("nodes.models.revision.get_revision", return_value="rev"),
71
+ patch.object(Node, "ensure_keys"),
72
+ ):
68
73
  Node.register_current()
69
74
  self.assertEqual(PackageRelease.objects.count(), 0)
70
75
 
@@ -117,7 +122,10 @@ class NodeTests(TestCase):
117
122
  hostnames = {n["hostname"] for n in data["nodes"]}
118
123
  self.assertEqual(hostnames, {"dup", "local2"})
119
124
 
120
- def test_register_node_has_lcd_screen_toggle(self):
125
+ def test_register_node_feature_toggle(self):
126
+ NodeFeature.objects.get_or_create(
127
+ slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
128
+ )
121
129
  url = reverse("register-node")
122
130
  first = self.client.post(
123
131
  url,
@@ -126,13 +134,13 @@ class NodeTests(TestCase):
126
134
  "address": "127.0.0.1",
127
135
  "port": 8000,
128
136
  "mac_address": "00:aa:bb:cc:dd:ee",
129
- "has_lcd_screen": True,
137
+ "features": ["clipboard-poll"],
130
138
  },
131
139
  content_type="application/json",
132
140
  )
133
141
  self.assertEqual(first.status_code, 200)
134
142
  node = Node.objects.get(mac_address="00:aa:bb:cc:dd:ee")
135
- self.assertTrue(node.has_lcd_screen)
143
+ self.assertTrue(node.has_feature("clipboard-poll"))
136
144
 
137
145
  self.client.post(
138
146
  url,
@@ -141,12 +149,44 @@ class NodeTests(TestCase):
141
149
  "address": "127.0.0.1",
142
150
  "port": 8000,
143
151
  "mac_address": "00:aa:bb:cc:dd:ee",
144
- "has_lcd_screen": False,
152
+ "features": [],
145
153
  },
146
154
  content_type="application/json",
147
155
  )
148
156
  node.refresh_from_db()
149
- self.assertFalse(node.has_lcd_screen)
157
+ self.assertFalse(node.has_feature("clipboard-poll"))
158
+
159
+ def test_register_node_sets_cors_headers(self):
160
+ payload = {
161
+ "hostname": "cors",
162
+ "address": "127.0.0.1",
163
+ "port": 8000,
164
+ "mac_address": "10:20:30:40:50:60",
165
+ }
166
+ response = self.client.post(
167
+ reverse("register-node"),
168
+ data=json.dumps(payload),
169
+ content_type="application/json",
170
+ HTTP_ORIGIN="http://example.com",
171
+ )
172
+ self.assertEqual(response.status_code, 200)
173
+ self.assertEqual(response["Access-Control-Allow-Origin"], "http://example.com")
174
+ self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
175
+
176
+ def test_register_node_accepts_text_plain_payload(self):
177
+ payload = {
178
+ "hostname": "plain",
179
+ "address": "127.0.0.1",
180
+ "port": 8001,
181
+ "mac_address": "aa:bb:cc:dd:ee:ff",
182
+ }
183
+ response = self.client.post(
184
+ reverse("register-node"),
185
+ data=json.dumps(payload),
186
+ content_type="text/plain",
187
+ )
188
+ self.assertEqual(response.status_code, 200)
189
+ self.assertTrue(Node.objects.filter(mac_address="aa:bb:cc:dd:ee:ff").exists())
150
190
 
151
191
 
152
192
  class NodeRegisterCurrentTests(TestCase):
@@ -156,39 +196,71 @@ class NodeRegisterCurrentTests(TestCase):
156
196
  self.user = User.objects.create_user(username="nodeuser", password="pwd")
157
197
  self.client.force_login(self.user)
158
198
  NodeRole.objects.get_or_create(name="Terminal")
159
- def test_register_current_sets_and_retains_lcd(self):
199
+
200
+ def test_register_current_refreshes_lcd_feature(self):
201
+ NodeFeature.objects.get_or_create(
202
+ slug="lcd-screen", defaults={"display": "LCD Screen"}
203
+ )
160
204
  with TemporaryDirectory() as tmp:
161
205
  base = Path(tmp)
162
206
  locks = base / "locks"
163
207
  locks.mkdir()
164
- (locks / "lcd_screen.lck").touch()
208
+ lock = locks / "lcd_screen.lck"
209
+ lock.touch()
165
210
  with override_settings(BASE_DIR=base):
166
- with patch("nodes.models.Node.get_current_mac", return_value="00:ff:ee:dd:cc:bb"), patch(
167
- "nodes.models.socket.gethostname", return_value="testhost"
168
- ), patch(
169
- "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
170
- ), patch(
171
- "nodes.models.revision.get_revision", return_value="rev"
172
- ), patch.object(Node, "ensure_keys"):
211
+ with (
212
+ patch(
213
+ "nodes.models.Node.get_current_mac",
214
+ return_value="00:ff:ee:dd:cc:bb",
215
+ ),
216
+ patch("nodes.models.socket.gethostname", return_value="testhost"),
217
+ patch(
218
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
219
+ ),
220
+ patch("nodes.models.revision.get_revision", return_value="rev"),
221
+ patch.object(Node, "ensure_keys"),
222
+ ):
173
223
  node, created = Node.register_current()
174
224
  self.assertTrue(created)
175
- self.assertTrue(node.has_lcd_screen)
176
-
177
- node.has_lcd_screen = False
178
- node.save(update_fields=["has_lcd_screen"])
225
+ self.assertTrue(node.has_feature("lcd-screen"))
179
226
 
227
+ lock.unlink()
180
228
  with override_settings(BASE_DIR=base):
181
- with patch("nodes.models.Node.get_current_mac", return_value="00:ff:ee:dd:cc:bb"), patch(
182
- "nodes.models.socket.gethostname", return_value="testhost"
183
- ), patch(
184
- "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
185
- ), patch(
186
- "nodes.models.revision.get_revision", return_value="rev"
187
- ), patch.object(Node, "ensure_keys"):
188
- node2, created2 = Node.register_current()
229
+ with (
230
+ patch(
231
+ "nodes.models.Node.get_current_mac",
232
+ return_value="00:ff:ee:dd:cc:bb",
233
+ ),
234
+ patch("nodes.models.socket.gethostname", return_value="testhost"),
235
+ patch(
236
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
237
+ ),
238
+ patch("nodes.models.revision.get_revision", return_value="rev"),
239
+ patch.object(Node, "ensure_keys"),
240
+ ):
241
+ _, created2 = Node.register_current()
189
242
  self.assertFalse(created2)
190
243
  node.refresh_from_db()
191
- self.assertFalse(node.has_lcd_screen)
244
+ self.assertFalse(node.has_feature("lcd-screen"))
245
+
246
+ lock.touch()
247
+ with override_settings(BASE_DIR=base):
248
+ with (
249
+ patch(
250
+ "nodes.models.Node.get_current_mac",
251
+ return_value="00:ff:ee:dd:cc:bb",
252
+ ),
253
+ patch("nodes.models.socket.gethostname", return_value="testhost"),
254
+ patch(
255
+ "nodes.models.socket.gethostbyname", return_value="127.0.0.1"
256
+ ),
257
+ patch("nodes.models.revision.get_revision", return_value="rev"),
258
+ patch.object(Node, "ensure_keys"),
259
+ ):
260
+ node, created3 = Node.register_current()
261
+ self.assertFalse(created3)
262
+ node.refresh_from_db()
263
+ self.assertTrue(node.has_feature("lcd-screen"))
192
264
 
193
265
  @patch("nodes.views.capture_screenshot")
194
266
  def test_capture_screenshot(self, mock_capture):
@@ -270,9 +342,7 @@ class NodeRegisterCurrentTests(TestCase):
270
342
  self.assertEqual(get_resp.json()["hostname"], "public")
271
343
 
272
344
  pre_count = NetMessage.objects.count()
273
- post_resp = self.client.post(
274
- url, data="hello", content_type="text/plain"
275
- )
345
+ post_resp = self.client.post(url, data="hello", content_type="text/plain")
276
346
  self.assertEqual(post_resp.status_code, 200)
277
347
  self.assertEqual(NetMessage.objects.count(), pre_count + 1)
278
348
  msg = NetMessage.objects.order_by("-created").first()
@@ -307,10 +377,14 @@ class NodeRegisterCurrentTests(TestCase):
307
377
 
308
378
  def test_net_message_with_valid_signature(self):
309
379
  key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
310
- public_key = key.public_key().public_bytes(
311
- encoding=serialization.Encoding.PEM,
312
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
313
- ).decode()
380
+ public_key = (
381
+ key.public_key()
382
+ .public_bytes(
383
+ encoding=serialization.Encoding.PEM,
384
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
385
+ )
386
+ .decode()
387
+ )
314
388
  sender = Node.objects.create(
315
389
  hostname="sender",
316
390
  address="10.0.0.1",
@@ -327,9 +401,7 @@ class NodeRegisterCurrentTests(TestCase):
327
401
  "sender": str(sender.uuid),
328
402
  }
329
403
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
330
- signature = key.sign(
331
- payload_json.encode(), padding.PKCS1v15(), hashes.SHA256()
332
- )
404
+ signature = key.sign(payload_json.encode(), padding.PKCS1v15(), hashes.SHA256())
333
405
  resp = self.client.post(
334
406
  reverse("net-message"),
335
407
  data=payload_json,
@@ -340,6 +412,9 @@ class NodeRegisterCurrentTests(TestCase):
340
412
  self.assertTrue(NetMessage.objects.filter(uuid=msg_id).exists())
341
413
 
342
414
  def test_clipboard_polling_creates_task(self):
415
+ feature, _ = NodeFeature.objects.get_or_create(
416
+ slug="clipboard-poll", defaults={"display": "Clipboard Poll"}
417
+ )
343
418
  node = Node.objects.create(
344
419
  hostname="clip",
345
420
  address="127.0.0.1",
@@ -347,15 +422,16 @@ class NodeRegisterCurrentTests(TestCase):
347
422
  mac_address="00:11:22:33:44:99",
348
423
  )
349
424
  task_name = f"poll_clipboard_node_{node.pk}"
350
- self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
351
- node.clipboard_polling = True
352
- node.save()
425
+ PeriodicTask.objects.filter(name=task_name).delete()
426
+ NodeFeatureAssignment.objects.create(node=node, feature=feature)
353
427
  self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
354
- node.clipboard_polling = False
355
- node.save()
428
+ NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
356
429
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
357
430
 
358
431
  def test_screenshot_polling_creates_task(self):
432
+ feature, _ = NodeFeature.objects.get_or_create(
433
+ slug="screenshot-poll", defaults={"display": "Screenshot Poll"}
434
+ )
359
435
  node = Node.objects.create(
360
436
  hostname="shot",
361
437
  address="127.0.0.1",
@@ -363,14 +439,13 @@ class NodeRegisterCurrentTests(TestCase):
363
439
  mac_address="00:11:22:33:44:aa",
364
440
  )
365
441
  task_name = f"capture_screenshot_node_{node.pk}"
366
- self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
367
- node.screenshot_polling = True
368
- node.save()
442
+ PeriodicTask.objects.filter(name=task_name).delete()
443
+ NodeFeatureAssignment.objects.create(node=node, feature=feature)
369
444
  self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists())
370
- node.screenshot_polling = False
371
- node.save()
445
+ NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
372
446
  self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
373
447
 
448
+
374
449
  class NodeAdminTests(TestCase):
375
450
 
376
451
  def setUp(self):
@@ -395,7 +470,7 @@ class NodeAdminTests(TestCase):
395
470
  self.assertTemplateUsed(response, "admin/nodes/node/register_remote.html")
396
471
  self.assertEqual(Node.objects.count(), 1)
397
472
  node = Node.objects.first()
398
- ver = Path('VERSION').read_text().strip()
473
+ ver = Path("VERSION").read_text().strip()
399
474
  rev = "abcdef123456"
400
475
  self.assertEqual(node.base_path, str(settings.BASE_DIR))
401
476
  self.assertEqual(node.installed_version, ver)
@@ -408,9 +483,7 @@ class NodeAdminTests(TestCase):
408
483
  self.assertTrue(priv.exists())
409
484
  self.assertTrue(pub.exists())
410
485
  self.assertTrue(node.public_key)
411
- self.assertTrue(
412
- Site.objects.filter(domain=hostname, name="host").exists()
413
- )
486
+ self.assertTrue(Site.objects.filter(domain=hostname, name="host").exists())
414
487
 
415
488
  def test_register_current_updates_existing_node(self):
416
489
  hostname = socket.gethostname()
@@ -446,9 +519,7 @@ class NodeAdminTests(TestCase):
446
519
  self.assertIn(node.public_key.strip(), resp.content.decode())
447
520
 
448
521
  @patch("nodes.admin.capture_screenshot")
449
- def test_capture_site_screenshot_from_admin(
450
- self, mock_capture_screenshot
451
- ):
522
+ def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
452
523
  screenshot_dir = settings.LOG_DIR / "screenshots"
453
524
  screenshot_dir.mkdir(parents=True, exist_ok=True)
454
525
  file_path = screenshot_dir / "test.png"
@@ -472,9 +543,7 @@ class NodeAdminTests(TestCase):
472
543
  self.assertEqual(screenshot.path, "screenshots/test.png")
473
544
  self.assertEqual(screenshot.method, "ADMIN")
474
545
  mock_capture_screenshot.assert_called_once_with("http://testserver/")
475
- self.assertContains(
476
- response, "Screenshot saved to screenshots/test.png"
477
- )
546
+ self.assertContains(response, "Screenshot saved to screenshots/test.png")
478
547
 
479
548
  def test_view_screenshot_in_change_admin(self):
480
549
  screenshot_dir = settings.LOG_DIR / "screenshots"
@@ -578,7 +647,9 @@ class NetMessageReachTests(TestCase):
578
647
  for name in ["Terminal", "Control", "Satellite", "Constellation"]:
579
648
  self.roles[name], _ = NodeRole.objects.get_or_create(name=name)
580
649
  self.nodes = {}
581
- for idx, name in enumerate(["Terminal", "Control", "Satellite", "Constellation"], start=1):
650
+ for idx, name in enumerate(
651
+ ["Terminal", "Control", "Satellite", "Constellation"], start=1
652
+ ):
582
653
  self.nodes[name] = Node.objects.create(
583
654
  hostname=name.lower(),
584
655
  address=f"10.0.0.{idx}",
@@ -589,7 +660,9 @@ class NetMessageReachTests(TestCase):
589
660
 
590
661
  @patch("requests.post")
591
662
  def test_terminal_reach_limits_nodes(self, mock_post):
592
- msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Terminal"])
663
+ msg = NetMessage.objects.create(
664
+ subject="s", body="b", reach=self.roles["Terminal"]
665
+ )
593
666
  with patch.object(Node, "get_local", return_value=None):
594
667
  msg.propagate()
595
668
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
@@ -598,7 +671,9 @@ class NetMessageReachTests(TestCase):
598
671
 
599
672
  @patch("requests.post")
600
673
  def test_control_reach_includes_control_and_terminal(self, mock_post):
601
- msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Control"])
674
+ msg = NetMessage.objects.create(
675
+ subject="s", body="b", reach=self.roles["Control"]
676
+ )
602
677
  with patch.object(Node, "get_local", return_value=None):
603
678
  msg.propagate()
604
679
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
@@ -607,7 +682,9 @@ class NetMessageReachTests(TestCase):
607
682
 
608
683
  @patch("requests.post")
609
684
  def test_satellite_reach_includes_lower_roles(self, mock_post):
610
- msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Satellite"])
685
+ msg = NetMessage.objects.create(
686
+ subject="s", body="b", reach=self.roles["Satellite"]
687
+ )
611
688
  with patch.object(Node, "get_local", return_value=None):
612
689
  msg.propagate()
613
690
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
@@ -616,7 +693,9 @@ class NetMessageReachTests(TestCase):
616
693
 
617
694
  @patch("requests.post")
618
695
  def test_constellation_reach_prioritizes_constellation(self, mock_post):
619
- msg = NetMessage.objects.create(subject="s", body="b", reach=self.roles["Constellation"])
696
+ msg = NetMessage.objects.create(
697
+ subject="s", body="b", reach=self.roles["Constellation"]
698
+ )
620
699
  with patch.object(Node, "get_local", return_value=None):
621
700
  msg.propagate()
622
701
  roles = set(msg.propagated_to.values_list("role__name", flat=True))
@@ -650,7 +729,9 @@ class NetMessagePropagationTests(TestCase):
650
729
 
651
730
  @patch("requests.post")
652
731
  @patch("core.notifications.notify")
653
- def test_propagate_forwards_to_three_and_notifies_local(self, mock_notify, mock_post):
732
+ def test_propagate_forwards_to_three_and_notifies_local(
733
+ self, mock_notify, mock_post
734
+ ):
654
735
  msg = NetMessage.objects.create(subject="s", body="b", reach=self.role)
655
736
  with patch.object(Node, "get_local", return_value=self.local):
656
737
  msg.propagate(seen=[str(self.remotes[0].uuid)])
@@ -665,6 +746,7 @@ class NetMessagePropagationTests(TestCase):
665
746
  self.assertEqual(msg.propagated_to.count(), 4)
666
747
  self.assertTrue(msg.complete)
667
748
 
749
+
668
750
  class NodeActionTests(TestCase):
669
751
  def setUp(self):
670
752
  self.client = Client()
@@ -739,7 +821,7 @@ class NodeActionTests(TestCase):
739
821
 
740
822
 
741
823
  class StartupNotificationTests(TestCase):
742
- def test_startup_notification_uses_ip_and_revision(self):
824
+ def test_startup_notification_uses_hostname_and_revision(self):
743
825
  from nodes.apps import _startup_notification
744
826
 
745
827
  with TemporaryDirectory() as tmp:
@@ -750,20 +832,17 @@ class StartupNotificationTests(TestCase):
750
832
  "nodes.apps.revision.get_revision", return_value="abcdef123456"
751
833
  ):
752
834
  with patch("nodes.models.NetMessage.broadcast") as mock_broadcast:
753
- with patch("nodes.apps.socket.gethostname", return_value="host"):
754
- with patch(
755
- "nodes.apps.socket.gethostbyname", return_value="1.2.3.4"
756
- ):
757
- with patch.dict(
758
- os.environ, {"PORT": "9000"}
759
- ):
760
- _startup_notification()
761
- time.sleep(0.1)
835
+ with patch(
836
+ "nodes.apps.socket.gethostname", return_value="host"
837
+ ):
838
+ with patch.dict(os.environ, {"PORT": "9000"}):
839
+ _startup_notification()
840
+ time.sleep(0.1)
762
841
 
763
842
  mock_broadcast.assert_called_once()
764
843
  _, kwargs = mock_broadcast.call_args
765
- self.assertEqual(kwargs["subject"], "1.2.3.4:9000")
766
- self.assertTrue(kwargs["body"].startswith("v1.2.3 r"))
844
+ self.assertEqual(kwargs["subject"], "host:9000")
845
+ self.assertTrue(kwargs["body"].startswith("1.2.3 r"))
767
846
 
768
847
 
769
848
  class StartupHandlerTests(TestCase):
@@ -787,11 +866,14 @@ class StartupHandlerTests(TestCase):
787
866
 
788
867
  with patch("nodes.apps._startup_notification") as mock_start:
789
868
  with patch("nodes.apps.connections") as mock_connections:
790
- mock_connections.__getitem__.return_value.ensure_connection.return_value = None
869
+ mock_connections.__getitem__.return_value.ensure_connection.return_value = (
870
+ None
871
+ )
791
872
  _trigger_startup_notification()
792
873
 
793
874
  mock_start.assert_called_once()
794
875
 
876
+
795
877
  class NotificationManagerTests(TestCase):
796
878
  def test_send_writes_trimmed_lines(self):
797
879
  from core.notifications import NotificationManager
@@ -921,6 +1003,7 @@ class ContentSampleAdminTests(TestCase):
921
1003
  self.assertContains(resp, "Duplicate sample not created")
922
1004
 
923
1005
 
1006
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
924
1007
  class EmailOutboxTests(TestCase):
925
1008
  def test_node_send_mail_uses_outbox(self):
926
1009
  node = Node.objects.create(
@@ -929,31 +1012,28 @@ class EmailOutboxTests(TestCase):
929
1012
  port=8000,
930
1013
  mac_address="00:11:22:33:aa:bb",
931
1014
  )
932
- EmailOutbox.objects.create(
1015
+ outbox = EmailOutbox.objects.create(
933
1016
  node=node, host="smtp.example.com", port=25, username="u", password="p"
934
1017
  )
935
- with patch("nodes.models.get_connection") as gc, patch(
936
- "nodes.models.send_mail"
937
- ) as sm:
938
- conn = MagicMock()
939
- gc.return_value = conn
1018
+ with patch("nodes.models.mailer.send") as ms:
940
1019
  node.send_mail("sub", "msg", ["to@example.com"])
941
- gc.assert_called_once_with(
942
- host="smtp.example.com",
943
- port=25,
944
- username="u",
945
- password="p",
946
- use_tls=True,
947
- use_ssl=False,
948
- )
949
- sm.assert_called_once_with(
950
- "sub",
951
- "msg",
952
- settings.DEFAULT_FROM_EMAIL,
953
- ["to@example.com"],
954
- connection=conn,
1020
+ ms.assert_called_once_with(
1021
+ "sub", "msg", ["to@example.com"], None, outbox=outbox
955
1022
  )
956
1023
 
1024
+ def test_node_send_mail_queues_email(self):
1025
+ node = Node.objects.create(
1026
+ hostname="host",
1027
+ address="127.0.0.1",
1028
+ port=8000,
1029
+ mac_address="00:11:22:33:cc:dd",
1030
+ )
1031
+ node.send_mail("sub", "msg", ["to@example.com"])
1032
+ self.assertEqual(len(mail.outbox), 1)
1033
+ email = mail.outbox[0]
1034
+ self.assertEqual(email.subject, "sub")
1035
+ self.assertEqual(email.to, ["to@example.com"])
1036
+
957
1037
 
958
1038
  class ClipboardTaskTests(TestCase):
959
1039
  @patch("nodes.tasks.pyperclip.paste")
@@ -1066,29 +1146,344 @@ class NodeRoleAdminTests(TestCase):
1066
1146
  self.assertIsNone(node1.role)
1067
1147
  self.assertEqual(node2.role, role)
1068
1148
 
1149
+ def test_registered_count_displayed(self):
1150
+ role = NodeRole.objects.create(name="ViewRole")
1151
+ Node.objects.create(
1152
+ hostname="n1",
1153
+ address="127.0.0.1",
1154
+ port=8000,
1155
+ mac_address="00:11:22:33:44:77",
1156
+ role=role,
1157
+ )
1158
+ resp = self.client.get(reverse("admin:nodes_noderole_changelist"))
1159
+ self.assertContains(resp, '<td class="field-registered">1</td>', html=True)
1160
+
1161
+
1162
+ class NodeFeatureFixtureTests(TestCase):
1163
+ def test_rfid_scanner_fixture_includes_control_role(self):
1164
+ for name in ("Terminal", "Satellite", "Constellation", "Control"):
1165
+ NodeRole.objects.get_or_create(name=name)
1166
+ fixture_path = (
1167
+ Path(__file__).resolve().parent
1168
+ / "fixtures"
1169
+ / "node_features__nodefeature_rfid_scanner.json"
1170
+ )
1171
+ call_command("loaddata", str(fixture_path), verbosity=0)
1172
+ feature = NodeFeature.objects.get(slug="rfid-scanner")
1173
+ role_names = set(feature.roles.values_list("name", flat=True))
1174
+ self.assertIn("Control", role_names)
1175
+
1176
+ def test_ap_router_fixture_limits_roles(self):
1177
+ for name in ("Control", "Satellite"):
1178
+ NodeRole.objects.get_or_create(name=name)
1179
+ fixture_path = (
1180
+ Path(__file__).resolve().parent
1181
+ / "fixtures"
1182
+ / "node_features__nodefeature_ap_router.json"
1183
+ )
1184
+ call_command("loaddata", str(fixture_path), verbosity=0)
1185
+ feature = NodeFeature.objects.get(slug="ap-router")
1186
+ role_names = set(feature.roles.values_list("name", flat=True))
1187
+ self.assertEqual(role_names, {"Satellite"})
1069
1188
 
1070
- class OperationWorkflowTests(TestCase):
1189
+
1190
+ class NodeFeatureTests(TestCase):
1071
1191
  def setUp(self):
1072
- self.client = Client()
1073
- User = get_user_model()
1074
- self.user = User.objects.create_superuser(
1075
- username="admin", email="admin@example.com", password="pwd"
1192
+ self.role, _ = NodeRole.objects.get_or_create(name="Terminal")
1193
+ with patch(
1194
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1195
+ ):
1196
+ self.node = Node.objects.create(
1197
+ hostname="local",
1198
+ address="127.0.0.1",
1199
+ port=8000,
1200
+ mac_address="00:11:22:33:44:55",
1201
+ role=self.role,
1202
+ )
1203
+
1204
+ def test_lcd_screen_enabled(self):
1205
+ feature = NodeFeature.objects.create(slug="lcd-screen", display="LCD")
1206
+ feature.roles.add(self.role)
1207
+ NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
1208
+ with patch(
1209
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1210
+ ):
1211
+ self.assertTrue(feature.is_enabled)
1212
+ NodeFeatureAssignment.objects.filter(node=self.node, feature=feature).delete()
1213
+ with patch(
1214
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1215
+ ):
1216
+ self.assertFalse(feature.is_enabled)
1217
+
1218
+ def test_rfid_scanner_lock(self):
1219
+ feature = NodeFeature.objects.create(slug="rfid-scanner", display="RFID")
1220
+ feature.roles.add(self.role)
1221
+ with TemporaryDirectory() as tmp:
1222
+ base = Path(tmp)
1223
+ locks = base / "locks"
1224
+ locks.mkdir()
1225
+ with override_settings(BASE_DIR=base):
1226
+ with patch(
1227
+ "nodes.models.Node.get_current_mac",
1228
+ return_value="00:11:22:33:44:55",
1229
+ ):
1230
+ self.assertFalse(feature.is_enabled)
1231
+ (locks / "rfid.lck").touch()
1232
+ self.assertTrue(feature.is_enabled)
1233
+
1234
+ def test_gui_toast_detection(self):
1235
+ feature = NodeFeature.objects.create(slug="gui-toast", display="GUI Toast")
1236
+ feature.roles.add(self.role)
1237
+ with patch(
1238
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1239
+ ):
1240
+ with patch("core.notifications.supports_gui_toast", return_value=True):
1241
+ self.assertTrue(feature.is_enabled)
1242
+ with patch(
1243
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1244
+ ):
1245
+ with patch("core.notifications.supports_gui_toast", return_value=False):
1246
+ self.assertFalse(feature.is_enabled)
1247
+
1248
+ def test_role_membership_alone_does_not_enable_feature(self):
1249
+ feature = NodeFeature.objects.create(
1250
+ slug="custom-feature", display="Custom Feature"
1251
+ )
1252
+ feature.roles.add(self.role)
1253
+ with patch(
1254
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1255
+ ):
1256
+ self.assertFalse(feature.is_enabled)
1257
+ NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
1258
+ with patch(
1259
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1260
+ ):
1261
+ self.assertTrue(feature.is_enabled)
1262
+
1263
+ @patch("nodes.models.Node._has_rpi_camera", return_value=True)
1264
+ def test_rpi_camera_detection(self, mock_camera):
1265
+ feature = NodeFeature.objects.create(
1266
+ slug="rpi-camera", display="Raspberry Pi Camera"
1267
+ )
1268
+ feature.roles.add(self.role)
1269
+ with patch(
1270
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1271
+ ):
1272
+ self.node.refresh_features()
1273
+ self.assertTrue(
1274
+ NodeFeatureAssignment.objects.filter(
1275
+ node=self.node, feature=feature
1276
+ ).exists()
1076
1277
  )
1077
- self.client.force_login(self.user)
1078
1278
 
1079
- def tearDown(self):
1080
- RUN_CONTEXTS.clear()
1279
+ @patch("nodes.models.Node._has_rpi_camera", side_effect=[True, False])
1280
+ def test_rpi_camera_removed_when_unavailable(self, mock_camera):
1281
+ feature = NodeFeature.objects.create(
1282
+ slug="rpi-camera", display="Raspberry Pi Camera"
1283
+ )
1284
+ feature.roles.add(self.role)
1285
+ with patch(
1286
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1287
+ ):
1288
+ self.node.refresh_features()
1289
+ self.assertTrue(
1290
+ NodeFeatureAssignment.objects.filter(
1291
+ node=self.node, feature=feature
1292
+ ).exists()
1293
+ )
1294
+ self.node.refresh_features()
1295
+ self.assertFalse(
1296
+ NodeFeatureAssignment.objects.filter(
1297
+ node=self.node, feature=feature
1298
+ ).exists()
1299
+ )
1300
+
1301
+ @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
1302
+ def test_ap_router_detection(self, mock_hosts):
1303
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
1304
+ feature = NodeFeature.objects.create(slug="ap-router", display="AP Router")
1305
+ feature.roles.add(control_role)
1306
+ mac = "00:11:22:33:44:66"
1307
+ with patch("nodes.models.Node.get_current_mac", return_value=mac):
1308
+ node = Node.objects.create(
1309
+ hostname="control",
1310
+ address="127.0.0.1",
1311
+ port=8000,
1312
+ mac_address=mac,
1313
+ role=control_role,
1314
+ )
1315
+ node.refresh_features()
1316
+ self.assertTrue(
1317
+ NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
1318
+ )
1319
+
1320
+ @patch("nodes.models.Node._hosts_gelectriic_ap", return_value=True)
1321
+ def test_ap_public_wifi_detection(self, mock_hosts):
1322
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
1323
+ router = NodeFeature.objects.create(slug="ap-router", display="AP Router")
1324
+ router.roles.add(control_role)
1325
+ public = NodeFeature.objects.create(
1326
+ slug="ap-public-wifi", display="AP Public Wi-Fi"
1327
+ )
1328
+ public.roles.add(control_role)
1329
+ mac = "00:11:22:33:44:88"
1330
+ with TemporaryDirectory() as tmp, override_settings(BASE_DIR=Path(tmp)):
1331
+ locks = Path(tmp) / "locks"
1332
+ locks.mkdir(parents=True, exist_ok=True)
1333
+ (locks / "public_wifi_mode.lck").touch()
1334
+ with patch("nodes.models.Node.get_current_mac", return_value=mac):
1335
+ node = Node.objects.create(
1336
+ hostname="control",
1337
+ address="127.0.0.1",
1338
+ port=8000,
1339
+ mac_address=mac,
1340
+ role=control_role,
1341
+ base_path=str(Path(tmp)),
1342
+ )
1343
+ node.refresh_features()
1344
+ self.assertTrue(
1345
+ NodeFeatureAssignment.objects.filter(node=node, feature=public).exists()
1346
+ )
1347
+ self.assertFalse(
1348
+ NodeFeatureAssignment.objects.filter(node=node, feature=router).exists()
1349
+ )
1350
+
1351
+ @patch("nodes.models.Node._hosts_gelectriic_ap", side_effect=[True, False])
1352
+ def test_ap_router_removed_when_not_hosting(self, mock_hosts):
1353
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
1354
+ feature = NodeFeature.objects.create(slug="ap-router", display="AP Router")
1355
+ feature.roles.add(control_role)
1356
+ mac = "00:11:22:33:44:77"
1357
+ with patch("nodes.models.Node.get_current_mac", return_value=mac):
1358
+ node = Node.objects.create(
1359
+ hostname="control",
1360
+ address="127.0.0.1",
1361
+ port=8000,
1362
+ mac_address=mac,
1363
+ role=control_role,
1364
+ )
1365
+ self.assertTrue(
1366
+ NodeFeatureAssignment.objects.filter(
1367
+ node=node, feature=feature
1368
+ ).exists()
1369
+ )
1370
+ node.refresh_features()
1371
+ self.assertFalse(
1372
+ NodeFeatureAssignment.objects.filter(node=node, feature=feature).exists()
1373
+ )
1374
+
1375
+ @patch("nodes.models.Node._uses_postgres", return_value=True)
1376
+ def test_postgres_detection(self, mock_postgres):
1377
+ feature = NodeFeature.objects.create(
1378
+ slug="postgres-db", display="PostgreSQL Database"
1379
+ )
1380
+ feature.roles.add(self.role)
1381
+ with patch(
1382
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1383
+ ):
1384
+ self.node.refresh_features()
1385
+ self.assertTrue(
1386
+ NodeFeatureAssignment.objects.filter(
1387
+ node=self.node, feature=feature
1388
+ ).exists()
1389
+ )
1081
1390
 
1082
- def test_unresolved_sigils_prompt(self):
1083
- op = Operation.objects.create(name="op1", command="[ENV.MISSING]")
1084
- url = reverse("admin:nodes_operation_run", args=[op.pk])
1085
- resp = self.client.post(url, follow=True)
1086
- self.assertContains(resp, 'name="ENV__MISSING"')
1391
+ @patch("nodes.models.Node._uses_postgres", side_effect=[True, False])
1392
+ def test_postgres_removed_when_not_in_use(self, mock_postgres):
1393
+ feature = NodeFeature.objects.create(
1394
+ slug="postgres-db", display="PostgreSQL Database"
1395
+ )
1396
+ feature.roles.add(self.role)
1397
+ with patch(
1398
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1399
+ ):
1400
+ self.node.refresh_features()
1401
+ self.assertTrue(
1402
+ NodeFeatureAssignment.objects.filter(
1403
+ node=self.node, feature=feature
1404
+ ).exists()
1405
+ )
1406
+ with patch(
1407
+ "nodes.models.Node.get_current_mac", return_value="00:11:22:33:44:55"
1408
+ ):
1409
+ self.node.refresh_features()
1410
+ self.assertFalse(
1411
+ NodeFeatureAssignment.objects.filter(
1412
+ node=self.node, feature=feature
1413
+ ).exists()
1414
+ )
1415
+
1416
+
1417
+ class NodeRpiCameraDetectionTests(TestCase):
1418
+ @patch("nodes.models.subprocess.run")
1419
+ @patch("nodes.models.shutil.which")
1420
+ @patch("nodes.models.os.access")
1421
+ @patch("nodes.models.os.stat")
1422
+ @patch("nodes.models.Path.exists")
1423
+ def test_has_rpi_camera_true(
1424
+ self,
1425
+ mock_exists,
1426
+ mock_stat,
1427
+ mock_access,
1428
+ mock_which,
1429
+ mock_run,
1430
+ ):
1431
+ mock_exists.return_value = True
1432
+ mock_stat.return_value = SimpleNamespace(st_mode=stat.S_IFCHR)
1433
+ mock_access.return_value = True
1434
+ mock_which.side_effect = lambda name: f"/usr/bin/{name}"
1435
+ mock_run.return_value = SimpleNamespace(returncode=0)
1436
+
1437
+ self.assertTrue(Node._has_rpi_camera())
1438
+ self.assertEqual(mock_which.call_count, len(Node.RPI_CAMERA_BINARIES))
1439
+ self.assertEqual(mock_run.call_count, len(Node.RPI_CAMERA_BINARIES))
1440
+
1441
+ @patch("nodes.models.subprocess.run")
1442
+ @patch("nodes.models.shutil.which")
1443
+ @patch("nodes.models.os.access")
1444
+ @patch("nodes.models.os.stat")
1445
+ @patch("nodes.models.Path.exists")
1446
+ def test_has_rpi_camera_missing_device(
1447
+ self,
1448
+ mock_exists,
1449
+ mock_stat,
1450
+ mock_access,
1451
+ mock_which,
1452
+ mock_run,
1453
+ ):
1454
+ mock_exists.return_value = False
1455
+
1456
+ self.assertFalse(Node._has_rpi_camera())
1457
+ mock_stat.assert_not_called()
1458
+ mock_access.assert_not_called()
1459
+ mock_which.assert_not_called()
1460
+ mock_run.assert_not_called()
1461
+
1462
+ @patch("nodes.models.subprocess.run")
1463
+ @patch("nodes.models.shutil.which")
1464
+ @patch("nodes.models.os.access")
1465
+ @patch("nodes.models.os.stat")
1466
+ @patch("nodes.models.Path.exists")
1467
+ def test_has_rpi_camera_missing_tool(
1468
+ self,
1469
+ mock_exists,
1470
+ mock_stat,
1471
+ mock_access,
1472
+ mock_which,
1473
+ mock_run,
1474
+ ):
1475
+ mock_exists.return_value = True
1476
+ mock_stat.return_value = SimpleNamespace(st_mode=stat.S_IFCHR)
1477
+ mock_access.return_value = True
1478
+ mock_run.return_value = SimpleNamespace(returncode=0)
1087
1479
 
1088
- def test_continue_effect(self):
1089
- op = Operation.objects.create(name="op2", command="...")
1090
- url = reverse("admin:nodes_operation_run", args=[op.pk])
1091
- resp = self.client.post(url, follow=True)
1092
- self.assertContains(resp, 'value="Continue"')
1480
+ def tool_lookup(name):
1481
+ if name == Node.RPI_CAMERA_BINARIES[-1]:
1482
+ return None
1483
+ return f"/usr/bin/{name}"
1093
1484
 
1485
+ mock_which.side_effect = tool_lookup
1094
1486
 
1487
+ self.assertFalse(Node._has_rpi_camera())
1488
+ missing_index = Node.RPI_CAMERA_BINARIES.index(Node.RPI_CAMERA_BINARIES[-1])
1489
+ self.assertEqual(mock_run.call_count, missing_index)