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
pages/tests.py CHANGED
@@ -6,24 +6,42 @@ from django.contrib.sites.models import Site
6
6
  from django.contrib import admin
7
7
  from django.core.exceptions import DisallowedHost
8
8
  import socket
9
- from pages.models import Application, Module, SiteBadge, Favorite
10
- from core.user_data import UserDatum
9
+ from pages.models import Application, Module, SiteBadge, Favorite, ViewHistory
11
10
  from pages.admin import ApplicationAdmin
11
+ from pages.screenshot_specs import (
12
+ ScreenshotSpec,
13
+ ScreenshotSpecRunner,
14
+ ScreenshotUnavailable,
15
+ registry,
16
+ )
12
17
  from django.apps import apps as django_apps
13
- from core.models import AdminHistory, InviteLead
18
+ from core import mailer
19
+ from core.models import AdminHistory, InviteLead, Package, ReleaseManager, Todo
14
20
  from django.core.files.uploadedfile import SimpleUploadedFile
15
21
  import base64
16
22
  import tempfile
17
23
  import shutil
24
+ from io import StringIO
18
25
  from django.conf import settings
19
26
  from pathlib import Path
20
- from unittest.mock import patch
21
- from django.core import mail
27
+ from unittest.mock import patch, Mock
28
+ from types import SimpleNamespace
22
29
  from django.core.management import call_command
23
30
  import re
24
31
  from django.contrib.contenttypes.models import ContentType
32
+ from datetime import date, timedelta
33
+ from django.core import mail
34
+ from django.utils import timezone
35
+ from django.utils.text import slugify
25
36
 
26
- from nodes.models import Node, ContentSample, NodeRole
37
+ from nodes.models import (
38
+ EmailOutbox,
39
+ Node,
40
+ ContentSample,
41
+ NodeRole,
42
+ NodeFeature,
43
+ NodeFeatureAssignment,
44
+ )
27
45
 
28
46
 
29
47
  class LoginViewTests(TestCase):
@@ -66,7 +84,21 @@ class LoginViewTests(TestCase):
66
84
  )
67
85
  self.assertRedirects(resp, "/nodes/list/")
68
86
 
87
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.dummy.EmailBackend")
88
+ def test_login_page_hides_request_link_without_email_backend(self):
89
+ resp = self.client.get(reverse("pages:login"))
90
+ self.assertFalse(resp.context["can_request_invite"])
91
+ self.assertNotContains(resp, reverse("pages:request-invite"))
92
+
93
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.dummy.EmailBackend")
94
+ def test_login_page_shows_request_link_when_outbox_configured(self):
95
+ EmailOutbox.objects.create(host="smtp.example.com")
96
+ resp = self.client.get(reverse("pages:login"))
97
+ self.assertTrue(resp.context["can_request_invite"])
98
+ self.assertContains(resp, reverse("pages:request-invite"))
69
99
 
100
+
101
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
70
102
  class InvitationTests(TestCase):
71
103
  def setUp(self):
72
104
  self.client = Client()
@@ -110,14 +142,27 @@ class InvitationTests(TestCase):
110
142
  self.assertIn("_auth_user_id", self.client.session)
111
143
 
112
144
  def test_request_invite_handles_email_errors(self):
113
- with patch("pages.views.send_mail", side_effect=Exception("fail")):
145
+ with patch("pages.views.mailer.send", side_effect=Exception("fail")):
114
146
  resp = self.client.post(
115
147
  reverse("pages:request-invite"), {"email": "invite@example.com"}
116
148
  )
117
149
  self.assertEqual(resp.status_code, 200)
118
- self.assertContains(
119
- resp, "If the email exists, an invitation has been sent."
150
+ self.assertContains(resp, "If the email exists, an invitation has been sent.")
151
+ lead = InviteLead.objects.get()
152
+ self.assertIsNone(lead.sent_on)
153
+ self.assertIn("fail", lead.error)
154
+ self.assertIn("email service", lead.error)
155
+ self.assertEqual(len(mail.outbox), 0)
156
+
157
+ def test_request_invite_records_send_time(self):
158
+ resp = self.client.post(
159
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
120
160
  )
161
+ self.assertEqual(resp.status_code, 200)
162
+ lead = InviteLead.objects.get()
163
+ self.assertIsNotNone(lead.sent_on)
164
+ self.assertEqual(lead.error, "")
165
+ self.assertEqual(len(mail.outbox), 1)
121
166
 
122
167
  def test_request_invite_creates_lead_with_comment(self):
123
168
  resp = self.client.post(
@@ -128,6 +173,76 @@ class InvitationTests(TestCase):
128
173
  lead = InviteLead.objects.get()
129
174
  self.assertEqual(lead.email, "new@example.com")
130
175
  self.assertEqual(lead.comment, "Hello")
176
+ self.assertIsNone(lead.sent_on)
177
+ self.assertEqual(lead.error, "")
178
+ self.assertEqual(lead.mac_address, "")
179
+ self.assertEqual(len(mail.outbox), 0)
180
+
181
+ def test_request_invite_falls_back_to_send_mail(self):
182
+ node = Node.objects.create(
183
+ hostname="local", address="127.0.0.1", mac_address="00:11:22:33:44:55"
184
+ )
185
+ with (
186
+ patch("pages.views.Node.get_local", return_value=node),
187
+ patch.object(
188
+ node, "send_mail", side_effect=Exception("node fail")
189
+ ) as node_send,
190
+ patch("pages.views.mailer.send", wraps=mailer.send) as fallback,
191
+ ):
192
+ resp = self.client.post(
193
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
194
+ )
195
+ self.assertEqual(resp.status_code, 200)
196
+ lead = InviteLead.objects.get()
197
+ self.assertIsNotNone(lead.sent_on)
198
+ self.assertIn("node fail", lead.error)
199
+ self.assertIn("default mail backend", lead.error)
200
+ self.assertTrue(node_send.called)
201
+ self.assertTrue(fallback.called)
202
+ self.assertEqual(len(mail.outbox), 1)
203
+
204
+ @patch(
205
+ "pages.views.public_wifi.resolve_mac_address",
206
+ return_value="aa:bb:cc:dd:ee:ff",
207
+ )
208
+ def test_request_invite_records_mac_address(self, mock_resolve):
209
+ resp = self.client.post(
210
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
211
+ )
212
+ self.assertEqual(resp.status_code, 200)
213
+ lead = InviteLead.objects.get()
214
+ self.assertEqual(lead.mac_address, "aa:bb:cc:dd:ee:ff")
215
+
216
+ @patch("pages.views.public_wifi.grant_public_access")
217
+ @patch(
218
+ "pages.views.public_wifi.resolve_mac_address",
219
+ return_value="aa:bb:cc:dd:ee:ff",
220
+ )
221
+ def test_invitation_login_grants_public_wifi_access(self, mock_resolve, mock_grant):
222
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
223
+ feature = NodeFeature.objects.create(
224
+ slug="ap-public-wifi", display="AP Public Wi-Fi"
225
+ )
226
+ feature.roles.add(control_role)
227
+ node = Node.objects.create(
228
+ hostname="control",
229
+ address="127.0.0.1",
230
+ mac_address=Node.get_current_mac(),
231
+ role=control_role,
232
+ )
233
+ NodeFeatureAssignment.objects.create(node=node, feature=feature)
234
+ with patch("pages.views.Node.get_local", return_value=node):
235
+ resp = self.client.post(
236
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
237
+ )
238
+ self.assertEqual(resp.status_code, 200)
239
+ link = re.search(r"http://testserver[\S]+", mail.outbox[0].body).group(0)
240
+ with patch("pages.views.Node.get_local", return_value=node):
241
+ resp = self.client.post(link)
242
+ self.assertEqual(resp.status_code, 302)
243
+ self.user.refresh_from_db()
244
+ self.assertTrue(self.user.is_active)
245
+ mock_grant.assert_called_once_with(self.user, "aa:bb:cc:dd:ee:ff")
131
246
 
132
247
 
133
248
  class NavbarBrandTests(TestCase):
@@ -139,16 +254,12 @@ class NavbarBrandTests(TestCase):
139
254
 
140
255
  def test_site_name_displayed_when_known(self):
141
256
  resp = self.client.get(reverse("pages:index"))
142
- self.assertContains(
143
- resp, '<a class="navbar-brand" href="/">Terminal</a>'
144
- )
257
+ self.assertContains(resp, '<a class="navbar-brand" href="/">Terminal</a>')
145
258
 
146
259
  def test_default_brand_when_unknown(self):
147
260
  Site.objects.filter(id=1).update(domain="example.com")
148
261
  resp = self.client.get(reverse("pages:index"))
149
- self.assertContains(
150
- resp, '<a class="navbar-brand" href="/">Arthexis</a>'
151
- )
262
+ self.assertContains(resp, '<a class="navbar-brand" href="/">Arthexis</a>')
152
263
 
153
264
  @override_settings(ALLOWED_HOSTS=["127.0.0.1", "testserver"])
154
265
  def test_brand_uses_role_name_when_site_name_blank(self):
@@ -198,7 +309,11 @@ class AdminBadgesTests(TestCase):
198
309
  self.node.role = role
199
310
  self.node.save()
200
311
  resp = self.client.get(reverse("admin:index"))
312
+ role_list = reverse("admin:nodes_noderole_changelist")
313
+ role_change = reverse("admin:nodes_noderole_change", args=[role.pk])
201
314
  self.assertContains(resp, "ROLE: Dev")
315
+ self.assertContains(resp, f'href="{role_list}"')
316
+ self.assertContains(resp, f'href="{role_change}"')
202
317
 
203
318
  def test_badges_warn_when_node_missing(self):
204
319
  from nodes.models import Node
@@ -242,6 +357,138 @@ class AdminSidebarTests(TestCase):
242
357
  self.assertContains(resp, 'id="admin-collapsible-apps"')
243
358
 
244
359
 
360
+ class ViewHistoryLoggingTests(TestCase):
361
+ def setUp(self):
362
+ self.client = Client()
363
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
364
+
365
+ def test_successful_visit_creates_entry(self):
366
+ resp = self.client.get(reverse("pages:index"))
367
+ self.assertEqual(resp.status_code, 200)
368
+ entry = ViewHistory.objects.order_by("-visited_at").first()
369
+ self.assertIsNotNone(entry)
370
+ self.assertEqual(entry.path, "/")
371
+ self.assertEqual(entry.status_code, 200)
372
+ self.assertEqual(entry.error_message, "")
373
+
374
+ def test_error_visit_records_message(self):
375
+ resp = self.client.get("/missing-page/")
376
+ self.assertEqual(resp.status_code, 404)
377
+ entry = (
378
+ ViewHistory.objects.filter(path="/missing-page/")
379
+ .order_by("-visited_at")
380
+ .first()
381
+ )
382
+ self.assertIsNotNone(entry)
383
+ self.assertEqual(entry.status_code, 404)
384
+ self.assertNotEqual(entry.error_message, "")
385
+
386
+ def test_debug_toolbar_requests_not_tracked(self):
387
+ resp = self.client.get(reverse("pages:index"), {"djdt": "toolbar"})
388
+ self.assertEqual(resp.status_code, 200)
389
+ self.assertFalse(ViewHistory.objects.exists())
390
+
391
+ def test_authenticated_user_last_visit_ip_updated(self):
392
+ User = get_user_model()
393
+ user = User.objects.create_user(
394
+ username="history_user", password="pwd", email="history@example.com"
395
+ )
396
+ self.assertTrue(self.client.login(username="history_user", password="pwd"))
397
+
398
+ resp = self.client.get(
399
+ reverse("pages:index"),
400
+ HTTP_X_FORWARDED_FOR="203.0.113.5",
401
+ )
402
+
403
+ self.assertEqual(resp.status_code, 200)
404
+ user.refresh_from_db()
405
+ self.assertEqual(user.last_visit_ip_address, "203.0.113.5")
406
+
407
+
408
+ class ViewHistoryAdminTests(TestCase):
409
+ def setUp(self):
410
+ self.client = Client()
411
+ User = get_user_model()
412
+ self.admin = User.objects.create_superuser(
413
+ username="history_admin", password="pwd", email="admin@example.com"
414
+ )
415
+ self.client.force_login(self.admin)
416
+ Site.objects.update_or_create(
417
+ id=1, defaults={"name": "test", "domain": "testserver"}
418
+ )
419
+
420
+ def _create_history(self, path: str, days_offset: int = 0, count: int = 1):
421
+ for _ in range(count):
422
+ entry = ViewHistory.objects.create(
423
+ path=path,
424
+ method="GET",
425
+ status_code=200,
426
+ status_text="OK",
427
+ error_message="",
428
+ view_name="pages:index",
429
+ )
430
+ if days_offset:
431
+ entry.visited_at = timezone.now() - timedelta(days=days_offset)
432
+ entry.save(update_fields=["visited_at"])
433
+
434
+ def test_change_list_includes_graph_link(self):
435
+ resp = self.client.get(reverse("admin:pages_viewhistory_changelist"))
436
+ self.assertContains(resp, reverse("admin:pages_viewhistory_traffic_graph"))
437
+ self.assertContains(resp, "Traffic graph")
438
+
439
+ def test_graph_view_renders_canvas(self):
440
+ resp = self.client.get(reverse("admin:pages_viewhistory_traffic_graph"))
441
+ self.assertContains(resp, "viewhistory-chart")
442
+ self.assertContains(resp, reverse("admin:pages_viewhistory_changelist"))
443
+
444
+ def test_graph_data_endpoint(self):
445
+ self._create_history("/", count=2)
446
+ self._create_history("/about/", days_offset=1)
447
+ url = reverse("admin:pages_viewhistory_traffic_data")
448
+ resp = self.client.get(url)
449
+ self.assertEqual(resp.status_code, 200)
450
+ data = resp.json()
451
+ self.assertIn("labels", data)
452
+ self.assertIn("datasets", data)
453
+ self.assertGreater(len(data["labels"]), 0)
454
+ totals = {
455
+ dataset["label"]: sum(dataset["data"]) for dataset in data["datasets"]
456
+ }
457
+ self.assertEqual(totals.get("/"), 2)
458
+ self.assertEqual(totals.get("/about/"), 1)
459
+
460
+ def test_admin_index_displays_widget(self):
461
+ resp = self.client.get(reverse("admin:index"))
462
+ self.assertContains(resp, "viewhistory-mini-module")
463
+ self.assertContains(resp, reverse("admin:pages_viewhistory_traffic_graph"))
464
+
465
+
466
+ class AdminModelStatusTests(TestCase):
467
+ def setUp(self):
468
+ self.client = Client()
469
+ User = get_user_model()
470
+ self.admin = User.objects.create_superuser(
471
+ username="status_admin", password="pwd", email="admin@example.com"
472
+ )
473
+ self.client.force_login(self.admin)
474
+ Site.objects.update_or_create(
475
+ id=1, defaults={"name": "test", "domain": "testserver"}
476
+ )
477
+ from nodes.models import Node
478
+
479
+ Node.objects.create(hostname="testserver", address="127.0.0.1")
480
+
481
+ @patch("pages.templatetags.admin_extras.connection.introspection.table_names")
482
+ def test_status_dots_render(self, mock_tables):
483
+ from django.db import connection
484
+
485
+ tables = type(connection.introspection).table_names(connection.introspection)
486
+ mock_tables.return_value = [t for t in tables if t != "pages_module"]
487
+ resp = self.client.get(reverse("admin:index"))
488
+ self.assertContains(resp, 'class="model-status ok"')
489
+ self.assertContains(resp, 'class="model-status missing"', count=1)
490
+
491
+
245
492
  class SiteAdminRegisterCurrentTests(TestCase):
246
493
  def setUp(self):
247
494
  self.client = Client()
@@ -382,6 +629,81 @@ class NavAppsTests(TestCase):
382
629
  self.assertNotContains(resp, 'href="/core/"')
383
630
 
384
631
 
632
+ class ConstellationNavTests(TestCase):
633
+ def setUp(self):
634
+ self.client = Client()
635
+ role, _ = NodeRole.objects.get_or_create(name="Constellation")
636
+ Node.objects.update_or_create(
637
+ mac_address=Node.get_current_mac(),
638
+ defaults={
639
+ "hostname": "localhost",
640
+ "address": "127.0.0.1",
641
+ "role": role,
642
+ },
643
+ )
644
+ Site.objects.update_or_create(
645
+ id=1, defaults={"domain": "testserver", "name": ""}
646
+ )
647
+ fixtures = [
648
+ Path(
649
+ settings.BASE_DIR,
650
+ "pages",
651
+ "fixtures",
652
+ "constellation__application_ocpp.json",
653
+ ),
654
+ Path(
655
+ settings.BASE_DIR,
656
+ "pages",
657
+ "fixtures",
658
+ "constellation__module_ocpp.json",
659
+ ),
660
+ Path(
661
+ settings.BASE_DIR,
662
+ "pages",
663
+ "fixtures",
664
+ "constellation__module_rfid.json",
665
+ ),
666
+ Path(
667
+ settings.BASE_DIR,
668
+ "pages",
669
+ "fixtures",
670
+ "constellation__landing_ocpp_dashboard.json",
671
+ ),
672
+ Path(
673
+ settings.BASE_DIR,
674
+ "pages",
675
+ "fixtures",
676
+ "constellation__landing_ocpp_cp_simulator.json",
677
+ ),
678
+ Path(
679
+ settings.BASE_DIR,
680
+ "pages",
681
+ "fixtures",
682
+ "constellation__landing_ocpp_rfid.json",
683
+ ),
684
+ ]
685
+ call_command("loaddata", *map(str, fixtures))
686
+
687
+ def test_rfid_pill_hidden(self):
688
+ resp = self.client.get(reverse("pages:index"))
689
+ nav_labels = [
690
+ module.menu_label.upper() for module in resp.context["nav_modules"]
691
+ ]
692
+ self.assertNotIn("RFID", nav_labels)
693
+ self.assertTrue(
694
+ Module.objects.filter(
695
+ path="/ocpp/", node_role__name="Constellation"
696
+ ).exists()
697
+ )
698
+ self.assertFalse(
699
+ Module.objects.filter(
700
+ path="/ocpp/rfid/",
701
+ node_role__name="Constellation",
702
+ is_deleted=False,
703
+ ).exists()
704
+ )
705
+
706
+
385
707
  class StaffNavVisibilityTests(TestCase):
386
708
  def setUp(self):
387
709
  self.client = Client()
@@ -472,16 +794,30 @@ class LandingCreationTests(TestCase):
472
794
  self.role = role
473
795
 
474
796
  def test_landings_created_on_module_creation(self):
475
- module = Module.objects.create(node_role=self.role, application=self.app, path="/")
797
+ module = Module.objects.create(
798
+ node_role=self.role, application=self.app, path="/"
799
+ )
476
800
  self.assertTrue(module.landings.filter(path="/").exists())
477
801
 
478
802
 
479
803
  class LandingFixtureTests(TestCase):
480
804
  def test_constellation_fixture_loads_without_duplicates(self):
481
- fixture = Path(settings.BASE_DIR, "pages", "fixtures", "constellation.json")
482
- call_command("loaddata", str(fixture))
483
- call_command("loaddata", str(fixture))
805
+ from glob import glob
806
+
807
+ NodeRole.objects.get_or_create(name="Constellation")
808
+ fixtures = glob(
809
+ str(Path(settings.BASE_DIR, "pages", "fixtures", "constellation__*.json"))
810
+ )
811
+ fixtures = sorted(
812
+ fixtures,
813
+ key=lambda path: (
814
+ 0 if "__application_" in path else 1 if "__module_" in path else 2
815
+ ),
816
+ )
817
+ call_command("loaddata", *fixtures)
818
+ call_command("loaddata", *fixtures)
484
819
  module = Module.objects.get(path="/ocpp/", node_role__name="Constellation")
820
+ module.create_landings()
485
821
  self.assertEqual(module.landings.filter(path="/ocpp/rfid/").count(), 1)
486
822
 
487
823
 
@@ -494,20 +830,14 @@ class AllowedHostSubnetTests(TestCase):
494
830
 
495
831
  @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "192.168.0.0/16"])
496
832
  def test_private_network_hosts_allowed(self):
497
- resp = self.client.get(
498
- reverse("pages:index"), HTTP_HOST="10.42.1.5"
499
- )
833
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="10.42.1.5")
500
834
  self.assertEqual(resp.status_code, 200)
501
- resp = self.client.get(
502
- reverse("pages:index"), HTTP_HOST="192.168.2.3"
503
- )
835
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="192.168.2.3")
504
836
  self.assertEqual(resp.status_code, 200)
505
837
 
506
838
  @override_settings(ALLOWED_HOSTS=["10.42.0.0/16"])
507
839
  def test_host_outside_subnets_disallowed(self):
508
- resp = self.client.get(
509
- reverse("pages:index"), HTTP_HOST="11.0.0.1"
510
- )
840
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="11.0.0.1")
511
841
  self.assertEqual(resp.status_code, 400)
512
842
 
513
843
 
@@ -540,7 +870,11 @@ class FaviconTests(TestCase):
540
870
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
541
871
  Node.objects.update_or_create(
542
872
  mac_address=Node.get_current_mac(),
543
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
873
+ defaults={
874
+ "hostname": "localhost",
875
+ "address": "127.0.0.1",
876
+ "role": role,
877
+ },
544
878
  )
545
879
  site, _ = Site.objects.update_or_create(
546
880
  id=1, defaults={"domain": "testserver", "name": ""}
@@ -564,7 +898,11 @@ class FaviconTests(TestCase):
564
898
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
565
899
  Node.objects.update_or_create(
566
900
  mac_address=Node.get_current_mac(),
567
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
901
+ defaults={
902
+ "hostname": "localhost",
903
+ "address": "127.0.0.1",
904
+ "role": role,
905
+ },
568
906
  )
569
907
  site, _ = Site.objects.update_or_create(
570
908
  id=1, defaults={"domain": "testserver", "name": ""}
@@ -584,7 +922,11 @@ class FaviconTests(TestCase):
584
922
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
585
923
  Node.objects.update_or_create(
586
924
  mac_address=Node.get_current_mac(),
587
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
925
+ defaults={
926
+ "hostname": "localhost",
927
+ "address": "127.0.0.1",
928
+ "role": role,
929
+ },
588
930
  )
589
931
  Site.objects.update_or_create(
590
932
  id=1, defaults={"domain": "testserver", "name": ""}
@@ -606,13 +948,30 @@ class FavoriteTests(TestCase):
606
948
  self.user = User.objects.create_superuser(
607
949
  username="favadmin", password="pwd", email="fav@example.com"
608
950
  )
951
+ ReleaseManager.objects.create(user=self.user)
609
952
  self.client.force_login(self.user)
610
- Site.objects.update_or_create(id=1, defaults={"name": "test", "domain": "testserver"})
953
+ Site.objects.update_or_create(
954
+ id=1, defaults={"name": "test", "domain": "testserver"}
955
+ )
956
+ from nodes.models import Node, NodeRole
957
+
958
+ terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
959
+ self.node, _ = Node.objects.update_or_create(
960
+ mac_address=Node.get_current_mac(),
961
+ defaults={
962
+ "hostname": "localhost",
963
+ "address": "127.0.0.1",
964
+ "role": terminal_role,
965
+ },
966
+ )
967
+ ContentType.objects.clear_cache()
611
968
 
612
969
  def test_add_favorite(self):
613
970
  ct = ContentType.objects.get_by_natural_key("pages", "application")
614
971
  next_url = reverse("admin:pages_application_changelist")
615
- url = reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
972
+ url = (
973
+ reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
974
+ )
616
975
  resp = self.client.post(url, {"custom_label": "Apps", "user_data": "on"})
617
976
  self.assertRedirects(resp, next_url)
618
977
  fav = Favorite.objects.get(user=self.user, content_type=ct)
@@ -622,7 +981,9 @@ class FavoriteTests(TestCase):
622
981
  def test_cancel_link_uses_next(self):
623
982
  ct = ContentType.objects.get_by_natural_key("pages", "application")
624
983
  next_url = reverse("admin:pages_application_changelist")
625
- url = reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
984
+ url = (
985
+ reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
986
+ )
626
987
  resp = self.client.get(url)
627
988
  self.assertContains(resp, f'href="{next_url}"')
628
989
 
@@ -646,10 +1007,10 @@ class FavoriteTests(TestCase):
646
1007
 
647
1008
  def test_dashboard_includes_favorites_and_user_data(self):
648
1009
  fav_ct = ContentType.objects.get_by_natural_key("pages", "application")
649
- Favorite.objects.create(user=self.user, content_type=fav_ct, custom_label="Apps")
650
- role = NodeRole.objects.create(name="DataRole")
651
- ud_ct = ContentType.objects.get_for_model(NodeRole)
652
- UserDatum.objects.create(user=self.user, content_type=ud_ct, object_id=role.pk)
1010
+ Favorite.objects.create(
1011
+ user=self.user, content_type=fav_ct, custom_label="Apps"
1012
+ )
1013
+ NodeRole.objects.create(name="DataRole", is_user_data=True)
653
1014
  resp = self.client.get(reverse("admin:index"))
654
1015
  self.assertContains(resp, reverse("admin:pages_application_changelist"))
655
1016
  self.assertContains(resp, reverse("admin:nodes_noderole_changelist"))
@@ -657,8 +1018,7 @@ class FavoriteTests(TestCase):
657
1018
  def test_dashboard_merges_duplicate_future_actions(self):
658
1019
  ct = ContentType.objects.get_for_model(NodeRole)
659
1020
  Favorite.objects.create(user=self.user, content_type=ct)
660
- role = NodeRole.objects.create(name="DataRole2")
661
- UserDatum.objects.create(user=self.user, content_type=ct, object_id=role.pk)
1021
+ NodeRole.objects.create(name="DataRole2", is_user_data=True)
662
1022
  AdminHistory.objects.create(
663
1023
  user=self.user,
664
1024
  content_type=ct,
@@ -668,3 +1028,362 @@ class FavoriteTests(TestCase):
668
1028
  url = reverse("admin:nodes_noderole_changelist")
669
1029
  self.assertGreaterEqual(resp.content.decode().count(url), 1)
670
1030
  self.assertContains(resp, NodeRole._meta.verbose_name_plural)
1031
+
1032
+ def test_dashboard_limits_future_actions_to_top_four(self):
1033
+ from pages.templatetags.admin_extras import future_action_items
1034
+
1035
+ role_ct = ContentType.objects.get_for_model(NodeRole)
1036
+ role_url = reverse("admin:nodes_noderole_changelist")
1037
+ AdminHistory.objects.create(
1038
+ user=self.user,
1039
+ content_type=role_ct,
1040
+ url=role_url,
1041
+ )
1042
+ AdminHistory.objects.create(
1043
+ user=self.user,
1044
+ content_type=role_ct,
1045
+ url=f"{role_url}?page=2",
1046
+ )
1047
+ AdminHistory.objects.create(
1048
+ user=self.user,
1049
+ content_type=role_ct,
1050
+ url=f"{role_url}?page=3",
1051
+ )
1052
+
1053
+ app_ct = ContentType.objects.get_for_model(Application)
1054
+ app_url = reverse("admin:pages_application_changelist")
1055
+ AdminHistory.objects.create(
1056
+ user=self.user,
1057
+ content_type=app_ct,
1058
+ url=app_url,
1059
+ )
1060
+ AdminHistory.objects.create(
1061
+ user=self.user,
1062
+ content_type=app_ct,
1063
+ url=f"{app_url}?page=2",
1064
+ )
1065
+
1066
+ module_ct = ContentType.objects.get_for_model(Module)
1067
+ module_url = reverse("admin:pages_module_changelist")
1068
+ AdminHistory.objects.create(
1069
+ user=self.user,
1070
+ content_type=module_ct,
1071
+ url=module_url,
1072
+ )
1073
+ AdminHistory.objects.create(
1074
+ user=self.user,
1075
+ content_type=module_ct,
1076
+ url=f"{module_url}?page=2",
1077
+ )
1078
+
1079
+ package_ct = ContentType.objects.get_for_model(Package)
1080
+ package_url = reverse("admin:core_package_changelist")
1081
+ AdminHistory.objects.create(
1082
+ user=self.user,
1083
+ content_type=package_ct,
1084
+ url=package_url,
1085
+ )
1086
+
1087
+ view_history_ct = ContentType.objects.get_for_model(ViewHistory)
1088
+ view_history_url = reverse("admin:pages_viewhistory_changelist")
1089
+ AdminHistory.objects.create(
1090
+ user=self.user,
1091
+ content_type=view_history_ct,
1092
+ url=view_history_url,
1093
+ )
1094
+
1095
+ resp = self.client.get(reverse("admin:index"))
1096
+ items = future_action_items({"request": resp.wsgi_request})["models"]
1097
+ labels = {item["label"] for item in items}
1098
+ self.assertEqual(len(items), 4)
1099
+ self.assertIn("Node Roles", labels)
1100
+ self.assertIn("Modules", labels)
1101
+ self.assertIn("applications", labels)
1102
+ self.assertIn("View Histories", labels)
1103
+ self.assertNotIn("Packages", labels)
1104
+ ContentType.objects.clear_cache()
1105
+
1106
+ def test_favorite_ct_id_recreates_missing_content_type(self):
1107
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
1108
+ ct.delete()
1109
+ from pages.templatetags.favorites import favorite_ct_id
1110
+
1111
+ new_id = favorite_ct_id("pages", "Application")
1112
+ self.assertIsNotNone(new_id)
1113
+ self.assertTrue(
1114
+ ContentType.objects.filter(
1115
+ pk=new_id, app_label="pages", model="application"
1116
+ ).exists()
1117
+ )
1118
+
1119
+ def test_dashboard_uses_browse_label(self):
1120
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
1121
+ Favorite.objects.create(user=self.user, content_type=ct)
1122
+ resp = self.client.get(reverse("admin:index"))
1123
+ self.assertContains(resp, "Browse Applications")
1124
+
1125
+ def test_dashboard_uses_todo_url_if_set(self):
1126
+ Todo.objects.create(request="Check docs", url="/docs/")
1127
+ resp = self.client.get(reverse("admin:index"))
1128
+ self.assertContains(resp, 'href="/docs/"')
1129
+
1130
+ def test_dashboard_shows_todo_with_done_button(self):
1131
+ todo = Todo.objects.create(request="Do thing")
1132
+ resp = self.client.get(reverse("admin:index"))
1133
+ done_url = reverse("todo-done", args=[todo.pk])
1134
+ self.assertContains(resp, todo.request)
1135
+ self.assertContains(resp, f'action="{done_url}"')
1136
+ self.assertContains(resp, "DONE")
1137
+
1138
+ def test_dashboard_shows_request_details(self):
1139
+ Todo.objects.create(request="Do thing", request_details="More info")
1140
+ resp = self.client.get(reverse("admin:index"))
1141
+ self.assertContains(
1142
+ resp, '<div class="todo-details">More info</div>', html=True
1143
+ )
1144
+
1145
+ def test_dashboard_excludes_todo_changelist_link(self):
1146
+ ct = ContentType.objects.get_for_model(Todo)
1147
+ Favorite.objects.create(user=self.user, content_type=ct)
1148
+ AdminHistory.objects.create(
1149
+ user=self.user,
1150
+ content_type=ct,
1151
+ url=reverse("admin:core_todo_changelist"),
1152
+ )
1153
+ Todo.objects.create(request="Task", is_user_data=True)
1154
+ resp = self.client.get(reverse("admin:index"))
1155
+ changelist = reverse("admin:core_todo_changelist")
1156
+ self.assertNotContains(resp, f'href="{changelist}"')
1157
+
1158
+ def test_dashboard_hides_todos_without_release_manager(self):
1159
+ todo = Todo.objects.create(request="Only Release Manager")
1160
+ User = get_user_model()
1161
+ other_user = User.objects.create_superuser(
1162
+ username="norole", password="pwd", email="norole@example.com"
1163
+ )
1164
+ self.client.force_login(other_user)
1165
+ resp = self.client.get(reverse("admin:index"))
1166
+ self.assertNotContains(resp, "Release manager tasks")
1167
+ self.assertNotContains(resp, todo.request)
1168
+
1169
+ def test_dashboard_hides_todos_for_non_terminal_node(self):
1170
+ todo = Todo.objects.create(request="Terminal Tasks")
1171
+ from nodes.models import NodeRole
1172
+
1173
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
1174
+ self.node.role = control_role
1175
+ self.node.save(update_fields=["role"])
1176
+ resp = self.client.get(reverse("admin:index"))
1177
+ self.assertNotContains(resp, "Release manager tasks")
1178
+ self.assertNotContains(resp, todo.request)
1179
+
1180
+ def test_dashboard_shows_todos_for_delegate_release_manager(self):
1181
+ todo = Todo.objects.create(request="Delegate Task")
1182
+ User = get_user_model()
1183
+ delegate = User.objects.create_superuser(
1184
+ username="delegate",
1185
+ password="pwd",
1186
+ email="delegate@example.com",
1187
+ )
1188
+ ReleaseManager.objects.create(user=delegate)
1189
+ operator = User.objects.create_superuser(
1190
+ username="operator",
1191
+ password="pwd",
1192
+ email="operator@example.com",
1193
+ )
1194
+ operator.operate_as = delegate
1195
+ operator.full_clean()
1196
+ operator.save()
1197
+ self.client.force_login(operator)
1198
+ resp = self.client.get(reverse("admin:index"))
1199
+ self.assertContains(resp, "Release manager tasks")
1200
+ self.assertContains(resp, todo.request)
1201
+
1202
+
1203
+ class AdminModelGraphViewTests(TestCase):
1204
+ def setUp(self):
1205
+ self.client = Client()
1206
+ User = get_user_model()
1207
+ self.user = User.objects.create_user(
1208
+ username="graph-staff", password="pwd", is_staff=True
1209
+ )
1210
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
1211
+ self.client.force_login(self.user)
1212
+
1213
+ def _mock_graph(self):
1214
+ fake_graph = Mock()
1215
+ fake_graph.source = "digraph {}"
1216
+ fake_graph.engine = "dot"
1217
+
1218
+ def pipe_side_effect(*args, **kwargs):
1219
+ fmt = kwargs.get("format") or (args[0] if args else None)
1220
+ if fmt == "svg":
1221
+ return '<svg xmlns="http://www.w3.org/2000/svg"></svg>'
1222
+ if fmt == "pdf":
1223
+ return b"%PDF-1.4 mock"
1224
+ raise AssertionError(f"Unexpected format: {fmt}")
1225
+
1226
+ fake_graph.pipe.side_effect = pipe_side_effect
1227
+ return fake_graph
1228
+
1229
+ def test_model_graph_renders_controls_and_download_link(self):
1230
+ url = reverse("admin-model-graph", args=["pages"])
1231
+ graph = self._mock_graph()
1232
+ with (
1233
+ patch("pages.views._build_model_graph", return_value=graph),
1234
+ patch("pages.views.shutil.which", return_value="/usr/bin/dot"),
1235
+ ):
1236
+ response = self.client.get(url)
1237
+
1238
+ self.assertEqual(response.status_code, 200)
1239
+ self.assertContains(response, "data-model-graph")
1240
+ self.assertContains(response, 'data-graph-action="zoom-in"')
1241
+ self.assertContains(response, "Download PDF")
1242
+ self.assertIn("?format=pdf", response.context_data["download_url"])
1243
+ args, kwargs = graph.pipe.call_args
1244
+ self.assertEqual(kwargs.get("format"), "svg")
1245
+ self.assertEqual(kwargs.get("encoding"), "utf-8")
1246
+
1247
+ def test_model_graph_pdf_download(self):
1248
+ url = reverse("admin-model-graph", args=["pages"])
1249
+ graph = self._mock_graph()
1250
+ with (
1251
+ patch("pages.views._build_model_graph", return_value=graph),
1252
+ patch("pages.views.shutil.which", return_value="/usr/bin/dot"),
1253
+ ):
1254
+ response = self.client.get(url, {"format": "pdf"})
1255
+
1256
+ self.assertEqual(response.status_code, 200)
1257
+ self.assertEqual(response["Content-Type"], "application/pdf")
1258
+ app_config = django_apps.get_app_config("pages")
1259
+ expected_slug = slugify(app_config.verbose_name) or app_config.label
1260
+ self.assertIn(
1261
+ f"{expected_slug}-model-graph.pdf", response["Content-Disposition"]
1262
+ )
1263
+ self.assertEqual(response.content, b"%PDF-1.4 mock")
1264
+ args, kwargs = graph.pipe.call_args
1265
+ self.assertEqual(kwargs.get("format"), "pdf")
1266
+
1267
+
1268
+ class DatasetteTests(TestCase):
1269
+ def setUp(self):
1270
+ self.client = Client()
1271
+ User = get_user_model()
1272
+ self.user = User.objects.create_user(username="ds", password="pwd")
1273
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
1274
+
1275
+ def test_datasette_auth_endpoint(self):
1276
+ resp = self.client.get(reverse("pages:datasette-auth"))
1277
+ self.assertEqual(resp.status_code, 401)
1278
+ self.client.force_login(self.user)
1279
+ resp = self.client.get(reverse("pages:datasette-auth"))
1280
+ self.assertEqual(resp.status_code, 200)
1281
+
1282
+ def test_navbar_includes_datasette_when_enabled(self):
1283
+ lock_dir = Path(settings.BASE_DIR) / "locks"
1284
+ lock_dir.mkdir(exist_ok=True)
1285
+ lock_file = lock_dir / "datasette.lck"
1286
+ try:
1287
+ lock_file.touch()
1288
+ resp = self.client.get(reverse("pages:index"))
1289
+ self.assertContains(resp, 'href="/data/"')
1290
+ finally:
1291
+ lock_file.unlink(missing_ok=True)
1292
+
1293
+
1294
+ class ClientReportLiveUpdateTests(TestCase):
1295
+ def setUp(self):
1296
+ self.client = Client()
1297
+
1298
+ def test_client_report_includes_interval(self):
1299
+ resp = self.client.get(reverse("pages:client-report"))
1300
+ self.assertEqual(resp.context["request"].live_update_interval, 5)
1301
+ self.assertContains(resp, "setInterval(() => location.reload()")
1302
+
1303
+
1304
+ class ScreenshotSpecInfrastructureTests(TestCase):
1305
+ def test_runner_creates_outputs_and_cleans_old_samples(self):
1306
+ spec = ScreenshotSpec(slug="spec-test", url="/")
1307
+ with tempfile.TemporaryDirectory() as tmp:
1308
+ temp_dir = Path(tmp)
1309
+ screenshot_path = temp_dir / "source.png"
1310
+ screenshot_path.write_bytes(b"fake")
1311
+ ContentSample.objects.create(
1312
+ kind=ContentSample.IMAGE,
1313
+ path="old.png",
1314
+ method="spec:old",
1315
+ hash="old-hash",
1316
+ )
1317
+ ContentSample.objects.filter(hash="old-hash").update(
1318
+ created_at=timezone.now() - timedelta(days=8)
1319
+ )
1320
+ with (
1321
+ patch(
1322
+ "pages.screenshot_specs.base.capture_screenshot",
1323
+ return_value=screenshot_path,
1324
+ ) as capture_mock,
1325
+ patch(
1326
+ "pages.screenshot_specs.base.save_screenshot", return_value=None
1327
+ ) as save_mock,
1328
+ ):
1329
+ with ScreenshotSpecRunner(temp_dir) as runner:
1330
+ result = runner.run(spec)
1331
+ self.assertTrue(result.image_path.exists())
1332
+ self.assertTrue(result.base64_path.exists())
1333
+ self.assertEqual(ContentSample.objects.filter(hash="old-hash").count(), 0)
1334
+ capture_mock.assert_called_once()
1335
+ save_mock.assert_called_once_with(screenshot_path, method="spec:spec-test")
1336
+
1337
+ def test_runner_respects_manual_reason(self):
1338
+ spec = ScreenshotSpec(slug="manual-spec", url="/", manual_reason="hardware")
1339
+ with tempfile.TemporaryDirectory() as tmp:
1340
+ with ScreenshotSpecRunner(Path(tmp)) as runner:
1341
+ with self.assertRaises(ScreenshotUnavailable):
1342
+ runner.run(spec)
1343
+
1344
+
1345
+ class CaptureUIScreenshotsCommandTests(TestCase):
1346
+ def tearDown(self):
1347
+ registry.unregister("manual-cmd")
1348
+ registry.unregister("auto-cmd")
1349
+
1350
+ def test_manual_spec_emits_warning(self):
1351
+ spec = ScreenshotSpec(slug="manual-cmd", url="/", manual_reason="manual")
1352
+ registry.register(spec)
1353
+ out = StringIO()
1354
+ call_command("capture_ui_screenshots", "--spec", spec.slug, stdout=out)
1355
+ self.assertIn("Skipping manual screenshot", out.getvalue())
1356
+
1357
+ def test_command_invokes_runner(self):
1358
+ spec = ScreenshotSpec(slug="auto-cmd", url="/")
1359
+ registry.register(spec)
1360
+ with tempfile.TemporaryDirectory() as tmp:
1361
+ tmp_path = Path(tmp)
1362
+ image_path = tmp_path / "auto-cmd.png"
1363
+ base64_path = tmp_path / "auto-cmd.base64"
1364
+ image_path.write_bytes(b"fake")
1365
+ base64_path.write_text("Zg==", encoding="utf-8")
1366
+ runner = Mock()
1367
+ runner.__enter__ = Mock(return_value=runner)
1368
+ runner.__exit__ = Mock(return_value=None)
1369
+ runner.run.return_value = SimpleNamespace(
1370
+ image_path=image_path,
1371
+ base64_path=base64_path,
1372
+ sample=None,
1373
+ )
1374
+ with patch(
1375
+ "pages.management.commands.capture_ui_screenshots.ScreenshotSpecRunner",
1376
+ return_value=runner,
1377
+ ) as runner_cls:
1378
+ out = StringIO()
1379
+ call_command(
1380
+ "capture_ui_screenshots",
1381
+ "--spec",
1382
+ spec.slug,
1383
+ "--output-dir",
1384
+ tmp_path,
1385
+ stdout=out,
1386
+ )
1387
+ runner_cls.assert_called_once()
1388
+ runner.run.assert_called_once_with(spec)
1389
+ self.assertIn("Captured 'auto-cmd'", out.getvalue())