arthexis 0.1.8__py3-none-any.whl → 0.1.10__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 (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.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 +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
pages/tests.py CHANGED
@@ -1,29 +1,69 @@
1
- from django.test import Client, TestCase, override_settings
1
+ import os
2
+
3
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
+
5
+ import django
6
+
7
+ django.setup()
8
+
9
+ from django.test import Client, RequestFactory, TestCase, override_settings
2
10
  from django.urls import reverse
11
+ from django.templatetags.static import static
3
12
  from urllib.parse import quote
4
13
  from django.contrib.auth import get_user_model
5
14
  from django.contrib.sites.models import Site
6
15
  from django.contrib import admin
7
16
  from django.core.exceptions import DisallowedHost
8
17
  import socket
9
- from pages.models import Application, Module, SiteBadge, Favorite
10
- from core.user_data import UserDatum
18
+ from pages.models import Application, Module, SiteBadge, Favorite, ViewHistory
11
19
  from pages.admin import ApplicationAdmin
20
+ from pages.screenshot_specs import (
21
+ ScreenshotSpec,
22
+ ScreenshotSpecRunner,
23
+ ScreenshotUnavailable,
24
+ registry,
25
+ )
12
26
  from django.apps import apps as django_apps
13
- from core.models import AdminHistory, InviteLead
27
+ from core import mailer
28
+ from core.admin import ProfileAdminMixin
29
+ from core.models import (
30
+ AdminHistory,
31
+ InviteLead,
32
+ Package,
33
+ Reference,
34
+ ReleaseManager,
35
+ Todo,
36
+ )
14
37
  from django.core.files.uploadedfile import SimpleUploadedFile
15
38
  import base64
16
39
  import tempfile
17
40
  import shutil
41
+ from io import StringIO
18
42
  from django.conf import settings
19
43
  from pathlib import Path
20
- from unittest.mock import patch
21
- from django.core import mail
44
+ from unittest.mock import patch, Mock
45
+ from types import SimpleNamespace
22
46
  from django.core.management import call_command
23
47
  import re
24
48
  from django.contrib.contenttypes.models import ContentType
25
-
26
- from nodes.models import Node, ContentSample, NodeRole
49
+ from datetime import date, timedelta
50
+ from django.core import mail
51
+ from django.utils import timezone
52
+ from django.utils.text import slugify
53
+ from django_otp import DEVICE_ID_SESSION_KEY
54
+ from django_otp.oath import TOTP
55
+ from django_otp.plugins.otp_totp.models import TOTPDevice
56
+ from core.backends import TOTP_DEVICE_NAME
57
+ import time
58
+
59
+ from nodes.models import (
60
+ EmailOutbox,
61
+ Node,
62
+ ContentSample,
63
+ NodeRole,
64
+ NodeFeature,
65
+ NodeFeatureAssignment,
66
+ )
27
67
 
28
68
 
29
69
  class LoginViewTests(TestCase):
@@ -40,6 +80,10 @@ class LoginViewTests(TestCase):
40
80
  resp = self.client.get(reverse("pages:index"))
41
81
  self.assertContains(resp, 'href="/login/"')
42
82
 
83
+ def test_login_page_shows_authenticator_toggle(self):
84
+ resp = self.client.get(reverse("pages:login"))
85
+ self.assertContains(resp, "Use Authenticator app")
86
+
43
87
  def test_staff_login_redirects_admin(self):
44
88
  resp = self.client.post(
45
89
  reverse("pages:login"),
@@ -47,6 +91,49 @@ class LoginViewTests(TestCase):
47
91
  )
48
92
  self.assertRedirects(resp, reverse("admin:index"))
49
93
 
94
+ def test_login_with_authenticator_code(self):
95
+ device = TOTPDevice.objects.create(
96
+ user=self.staff,
97
+ name=TOTP_DEVICE_NAME,
98
+ confirmed=True,
99
+ )
100
+ totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift)
101
+ totp.time = time.time()
102
+ token = f"{totp.token():0{device.digits}d}"
103
+
104
+ resp = self.client.post(
105
+ reverse("pages:login"),
106
+ {
107
+ "username": "staff",
108
+ "auth_method": "otp",
109
+ "otp_token": token,
110
+ },
111
+ )
112
+
113
+ self.assertRedirects(resp, reverse("admin:index"))
114
+ session = self.client.session
115
+ self.assertIn(DEVICE_ID_SESSION_KEY, session)
116
+ self.assertEqual(session[DEVICE_ID_SESSION_KEY], device.persistent_id)
117
+
118
+ def test_login_with_invalid_authenticator_code(self):
119
+ TOTPDevice.objects.create(
120
+ user=self.staff,
121
+ name=TOTP_DEVICE_NAME,
122
+ confirmed=True,
123
+ )
124
+
125
+ resp = self.client.post(
126
+ reverse("pages:login"),
127
+ {
128
+ "username": "staff",
129
+ "auth_method": "otp",
130
+ "otp_token": "000000",
131
+ },
132
+ )
133
+
134
+ self.assertEqual(resp.status_code, 200)
135
+ self.assertContains(resp, "authenticator code is invalid", status_code=200)
136
+
50
137
  def test_already_logged_in_staff_redirects(self):
51
138
  self.client.force_login(self.staff)
52
139
  resp = self.client.get(reverse("pages:login"))
@@ -67,6 +154,189 @@ class LoginViewTests(TestCase):
67
154
  self.assertRedirects(resp, "/nodes/list/")
68
155
 
69
156
 
157
+
158
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.dummy.EmailBackend")
159
+ def test_login_page_hides_request_link_without_email_backend(self):
160
+ resp = self.client.get(reverse("pages:login"))
161
+ self.assertFalse(resp.context["can_request_invite"])
162
+ self.assertNotContains(resp, reverse("pages:request-invite"))
163
+
164
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.dummy.EmailBackend")
165
+ def test_login_page_shows_request_link_when_outbox_configured(self):
166
+ EmailOutbox.objects.create(host="smtp.example.com")
167
+ resp = self.client.get(reverse("pages:login"))
168
+ self.assertTrue(resp.context["can_request_invite"])
169
+ self.assertContains(resp, reverse("pages:request-invite"))
170
+
171
+ @override_settings(ALLOWED_HOSTS=["gway-qk32000"])
172
+ def test_login_allows_forwarded_https_origin(self):
173
+ secure_client = Client(enforce_csrf_checks=True)
174
+ login_url = reverse("pages:login")
175
+ response = secure_client.get(login_url, HTTP_HOST="gway-qk32000")
176
+ csrf_cookie = response.cookies["csrftoken"].value
177
+ submit = secure_client.post(
178
+ login_url,
179
+ {
180
+ "username": "staff",
181
+ "password": "pwd",
182
+ "csrfmiddlewaretoken": csrf_cookie,
183
+ },
184
+ HTTP_HOST="gway-qk32000",
185
+ HTTP_ORIGIN="https://gway-qk32000",
186
+ HTTP_X_FORWARDED_PROTO="https",
187
+ HTTP_REFERER="https://gway-qk32000/login/",
188
+ )
189
+ self.assertRedirects(submit, reverse("admin:index"))
190
+
191
+ @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "gway-qk32000"])
192
+ def test_login_allows_forwarded_origin_with_private_host_header(self):
193
+ secure_client = Client(enforce_csrf_checks=True)
194
+ login_url = reverse("pages:login")
195
+ response = secure_client.get(login_url, HTTP_HOST="10.42.0.2")
196
+ csrf_cookie = response.cookies["csrftoken"].value
197
+ submit = secure_client.post(
198
+ login_url,
199
+ {
200
+ "username": "staff",
201
+ "password": "pwd",
202
+ "csrfmiddlewaretoken": csrf_cookie,
203
+ },
204
+ HTTP_HOST="10.42.0.2",
205
+ HTTP_ORIGIN="https://gway-qk32000",
206
+ HTTP_X_FORWARDED_PROTO="https",
207
+ HTTP_X_FORWARDED_HOST="gway-qk32000",
208
+ HTTP_REFERER="https://gway-qk32000/login/",
209
+ )
210
+ self.assertRedirects(submit, reverse("admin:index"))
211
+
212
+ @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "gway-qk32000"])
213
+ def test_login_allows_forwarded_header_host_and_proto(self):
214
+ secure_client = Client(enforce_csrf_checks=True)
215
+ login_url = reverse("pages:login")
216
+ response = secure_client.get(login_url, HTTP_HOST="10.42.0.2")
217
+ csrf_cookie = response.cookies["csrftoken"].value
218
+ submit = secure_client.post(
219
+ login_url,
220
+ {
221
+ "username": "staff",
222
+ "password": "pwd",
223
+ "csrfmiddlewaretoken": csrf_cookie,
224
+ },
225
+ HTTP_HOST="10.42.0.2",
226
+ HTTP_ORIGIN="https://gway-qk32000",
227
+ HTTP_FORWARDED="proto=https;host=gway-qk32000",
228
+ HTTP_REFERER="https://gway-qk32000/login/",
229
+ )
230
+ self.assertRedirects(submit, reverse("admin:index"))
231
+
232
+ @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "gway-qk32000"])
233
+ def test_login_allows_forwarded_referer_without_origin(self):
234
+ secure_client = Client(enforce_csrf_checks=True)
235
+ login_url = reverse("pages:login")
236
+ response = secure_client.get(login_url, HTTP_HOST="10.42.0.2")
237
+ csrf_cookie = response.cookies["csrftoken"].value
238
+ submit = secure_client.post(
239
+ login_url,
240
+ {
241
+ "username": "staff",
242
+ "password": "pwd",
243
+ "csrfmiddlewaretoken": csrf_cookie,
244
+ },
245
+ HTTP_HOST="10.42.0.2",
246
+ HTTP_X_FORWARDED_PROTO="https",
247
+ HTTP_X_FORWARDED_HOST="gway-qk32000",
248
+ HTTP_REFERER="https://gway-qk32000/login/",
249
+ )
250
+ self.assertRedirects(submit, reverse("admin:index"))
251
+
252
+ @override_settings(ALLOWED_HOSTS=["gway-qk32000"])
253
+ def test_login_allows_forwarded_origin_with_explicit_port(self):
254
+ secure_client = Client(enforce_csrf_checks=True)
255
+ login_url = reverse("pages:login")
256
+ response = secure_client.get(login_url, HTTP_HOST="gway-qk32000")
257
+ csrf_cookie = response.cookies["csrftoken"].value
258
+ submit = secure_client.post(
259
+ login_url,
260
+ {
261
+ "username": "staff",
262
+ "password": "pwd",
263
+ "csrfmiddlewaretoken": csrf_cookie,
264
+ },
265
+ HTTP_HOST="gway-qk32000",
266
+ HTTP_ORIGIN="https://gway-qk32000:4443",
267
+ HTTP_X_FORWARDED_PROTO="https",
268
+ HTTP_X_FORWARDED_HOST="gway-qk32000:4443",
269
+ HTTP_REFERER="https://gway-qk32000:4443/login/",
270
+ )
271
+ self.assertRedirects(submit, reverse("admin:index"))
272
+
273
+
274
+ class AuthenticatorSetupTests(TestCase):
275
+ def setUp(self):
276
+ self.client = Client()
277
+ User = get_user_model()
278
+ self.staff = User.objects.create_user(
279
+ username="staffer", password="pwd", is_staff=True
280
+ )
281
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
282
+ self.client.force_login(self.staff)
283
+
284
+ def _current_token(self, device):
285
+ totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift)
286
+ totp.time = time.time()
287
+ return f"{totp.token():0{device.digits}d}"
288
+
289
+ def test_generate_creates_pending_device(self):
290
+ resp = self.client.post(
291
+ reverse("pages:authenticator-setup"), {"action": "generate"}
292
+ )
293
+ self.assertRedirects(resp, reverse("pages:authenticator-setup"))
294
+ device = TOTPDevice.objects.get(user=self.staff)
295
+ self.assertFalse(device.confirmed)
296
+ self.assertEqual(device.name, TOTP_DEVICE_NAME)
297
+
298
+ def test_device_config_url_includes_issuer_prefix(self):
299
+ self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
300
+ device = TOTPDevice.objects.get(user=self.staff)
301
+ config_url = device.config_url
302
+ label = quote(f"{settings.OTP_TOTP_ISSUER}:{self.staff.username}")
303
+ self.assertIn(label, config_url)
304
+ self.assertIn(f"issuer={quote(settings.OTP_TOTP_ISSUER)}", config_url)
305
+
306
+ def test_pending_device_context_includes_qr(self):
307
+ self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
308
+ resp = self.client.get(reverse("pages:authenticator-setup"))
309
+ self.assertEqual(resp.status_code, 200)
310
+ self.assertTrue(resp.context["qr_data_uri"].startswith("data:image/png;base64,"))
311
+ self.assertTrue(resp.context["manual_key"])
312
+
313
+ def test_confirm_pending_device(self):
314
+ self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
315
+ device = TOTPDevice.objects.get(user=self.staff)
316
+ token = self._current_token(device)
317
+ resp = self.client.post(
318
+ reverse("pages:authenticator-setup"),
319
+ {"action": "confirm", "token": token},
320
+ )
321
+ self.assertRedirects(resp, reverse("pages:authenticator-setup"))
322
+ device.refresh_from_db()
323
+ self.assertTrue(device.confirmed)
324
+
325
+ def test_remove_device(self):
326
+ self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
327
+ device = TOTPDevice.objects.get(user=self.staff)
328
+ token = self._current_token(device)
329
+ self.client.post(
330
+ reverse("pages:authenticator-setup"),
331
+ {"action": "confirm", "token": token},
332
+ )
333
+ resp = self.client.post(
334
+ reverse("pages:authenticator-setup"), {"action": "remove"}
335
+ )
336
+ self.assertRedirects(resp, reverse("pages:authenticator-setup"))
337
+ self.assertFalse(TOTPDevice.objects.filter(user=self.staff).exists())
338
+
339
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
70
340
  class InvitationTests(TestCase):
71
341
  def setUp(self):
72
342
  self.client = Client()
@@ -110,14 +380,27 @@ class InvitationTests(TestCase):
110
380
  self.assertIn("_auth_user_id", self.client.session)
111
381
 
112
382
  def test_request_invite_handles_email_errors(self):
113
- with patch("pages.views.send_mail", side_effect=Exception("fail")):
383
+ with patch("pages.views.mailer.send", side_effect=Exception("fail")):
114
384
  resp = self.client.post(
115
385
  reverse("pages:request-invite"), {"email": "invite@example.com"}
116
386
  )
117
387
  self.assertEqual(resp.status_code, 200)
118
- self.assertContains(
119
- resp, "If the email exists, an invitation has been sent."
388
+ self.assertContains(resp, "If the email exists, an invitation has been sent.")
389
+ lead = InviteLead.objects.get()
390
+ self.assertIsNone(lead.sent_on)
391
+ self.assertIn("fail", lead.error)
392
+ self.assertIn("email service", lead.error)
393
+ self.assertEqual(len(mail.outbox), 0)
394
+
395
+ def test_request_invite_records_send_time(self):
396
+ resp = self.client.post(
397
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
120
398
  )
399
+ self.assertEqual(resp.status_code, 200)
400
+ lead = InviteLead.objects.get()
401
+ self.assertIsNotNone(lead.sent_on)
402
+ self.assertEqual(lead.error, "")
403
+ self.assertEqual(len(mail.outbox), 1)
121
404
 
122
405
  def test_request_invite_creates_lead_with_comment(self):
123
406
  resp = self.client.post(
@@ -128,6 +411,76 @@ class InvitationTests(TestCase):
128
411
  lead = InviteLead.objects.get()
129
412
  self.assertEqual(lead.email, "new@example.com")
130
413
  self.assertEqual(lead.comment, "Hello")
414
+ self.assertIsNone(lead.sent_on)
415
+ self.assertEqual(lead.error, "")
416
+ self.assertEqual(lead.mac_address, "")
417
+ self.assertEqual(len(mail.outbox), 0)
418
+
419
+ def test_request_invite_falls_back_to_send_mail(self):
420
+ node = Node.objects.create(
421
+ hostname="local", address="127.0.0.1", mac_address="00:11:22:33:44:55"
422
+ )
423
+ with (
424
+ patch("pages.views.Node.get_local", return_value=node),
425
+ patch.object(
426
+ node, "send_mail", side_effect=Exception("node fail")
427
+ ) as node_send,
428
+ patch("pages.views.mailer.send", wraps=mailer.send) as fallback,
429
+ ):
430
+ resp = self.client.post(
431
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
432
+ )
433
+ self.assertEqual(resp.status_code, 200)
434
+ lead = InviteLead.objects.get()
435
+ self.assertIsNotNone(lead.sent_on)
436
+ self.assertIn("node fail", lead.error)
437
+ self.assertIn("default mail backend", lead.error)
438
+ self.assertTrue(node_send.called)
439
+ self.assertTrue(fallback.called)
440
+ self.assertEqual(len(mail.outbox), 1)
441
+
442
+ @patch(
443
+ "pages.views.public_wifi.resolve_mac_address",
444
+ return_value="aa:bb:cc:dd:ee:ff",
445
+ )
446
+ def test_request_invite_records_mac_address(self, mock_resolve):
447
+ resp = self.client.post(
448
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
449
+ )
450
+ self.assertEqual(resp.status_code, 200)
451
+ lead = InviteLead.objects.get()
452
+ self.assertEqual(lead.mac_address, "aa:bb:cc:dd:ee:ff")
453
+
454
+ @patch("pages.views.public_wifi.grant_public_access")
455
+ @patch(
456
+ "pages.views.public_wifi.resolve_mac_address",
457
+ return_value="aa:bb:cc:dd:ee:ff",
458
+ )
459
+ def test_invitation_login_grants_public_wifi_access(self, mock_resolve, mock_grant):
460
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
461
+ feature = NodeFeature.objects.create(
462
+ slug="ap-public-wifi", display="AP Public Wi-Fi"
463
+ )
464
+ feature.roles.add(control_role)
465
+ node = Node.objects.create(
466
+ hostname="control",
467
+ address="127.0.0.1",
468
+ mac_address=Node.get_current_mac(),
469
+ role=control_role,
470
+ )
471
+ NodeFeatureAssignment.objects.create(node=node, feature=feature)
472
+ with patch("pages.views.Node.get_local", return_value=node):
473
+ resp = self.client.post(
474
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
475
+ )
476
+ self.assertEqual(resp.status_code, 200)
477
+ link = re.search(r"http://testserver[\S]+", mail.outbox[0].body).group(0)
478
+ with patch("pages.views.Node.get_local", return_value=node):
479
+ resp = self.client.post(link)
480
+ self.assertEqual(resp.status_code, 302)
481
+ self.user.refresh_from_db()
482
+ self.assertTrue(self.user.is_active)
483
+ mock_grant.assert_called_once_with(self.user, "aa:bb:cc:dd:ee:ff")
131
484
 
132
485
 
133
486
  class NavbarBrandTests(TestCase):
@@ -139,16 +492,12 @@ class NavbarBrandTests(TestCase):
139
492
 
140
493
  def test_site_name_displayed_when_known(self):
141
494
  resp = self.client.get(reverse("pages:index"))
142
- self.assertContains(
143
- resp, '<a class="navbar-brand" href="/">Terminal</a>'
144
- )
495
+ self.assertContains(resp, '<a class="navbar-brand" href="/">Terminal</a>')
145
496
 
146
497
  def test_default_brand_when_unknown(self):
147
498
  Site.objects.filter(id=1).update(domain="example.com")
148
499
  resp = self.client.get(reverse("pages:index"))
149
- self.assertContains(
150
- resp, '<a class="navbar-brand" href="/">Arthexis</a>'
151
- )
500
+ self.assertContains(resp, '<a class="navbar-brand" href="/">Arthexis</a>')
152
501
 
153
502
  @override_settings(ALLOWED_HOSTS=["127.0.0.1", "testserver"])
154
503
  def test_brand_uses_role_name_when_site_name_blank(self):
@@ -198,7 +547,11 @@ class AdminBadgesTests(TestCase):
198
547
  self.node.role = role
199
548
  self.node.save()
200
549
  resp = self.client.get(reverse("admin:index"))
550
+ role_list = reverse("admin:nodes_noderole_changelist")
551
+ role_change = reverse("admin:nodes_noderole_change", args=[role.pk])
201
552
  self.assertContains(resp, "ROLE: Dev")
553
+ self.assertContains(resp, f'href="{role_list}"')
554
+ self.assertContains(resp, f'href="{role_change}"')
202
555
 
203
556
  def test_badges_warn_when_node_missing(self):
204
557
  from nodes.models import Node
@@ -221,6 +574,56 @@ class AdminBadgesTests(TestCase):
221
574
  self.assertContains(resp, f'href="{node_change}"')
222
575
 
223
576
 
577
+ class AdminDashboardAppListTests(TestCase):
578
+ def setUp(self):
579
+ self.client = Client()
580
+ User = get_user_model()
581
+ self.admin = User.objects.create_superuser(
582
+ username="dashboard_admin", password="pwd", email="admin@example.com"
583
+ )
584
+ self.client.force_login(self.admin)
585
+ Site.objects.update_or_create(
586
+ id=1, defaults={"name": "test", "domain": "testserver"}
587
+ )
588
+ self.locks_dir = Path(settings.BASE_DIR) / "locks"
589
+ self.locks_dir.mkdir(parents=True, exist_ok=True)
590
+ self.celery_lock = self.locks_dir / "celery.lck"
591
+ if self.celery_lock.exists():
592
+ self.celery_lock.unlink()
593
+ self.addCleanup(self._remove_celery_lock)
594
+ self.node, _ = Node.objects.update_or_create(
595
+ mac_address=Node.get_current_mac(),
596
+ defaults={
597
+ "hostname": socket.gethostname(),
598
+ "address": socket.gethostbyname(socket.gethostname()),
599
+ "base_path": settings.BASE_DIR,
600
+ "port": 8000,
601
+ },
602
+ )
603
+ self.node.features.clear()
604
+
605
+ def _remove_celery_lock(self):
606
+ try:
607
+ self.celery_lock.unlink()
608
+ except FileNotFoundError:
609
+ pass
610
+
611
+ def test_horologia_hidden_without_celery_feature(self):
612
+ resp = self.client.get(reverse("admin:index"))
613
+ self.assertNotContains(resp, "5. Horologia MODELS")
614
+
615
+ def test_horologia_visible_with_celery_feature(self):
616
+ feature = NodeFeature.objects.create(slug="celery-queue", display="Celery Queue")
617
+ NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
618
+ resp = self.client.get(reverse("admin:index"))
619
+ self.assertContains(resp, "5. Horologia MODELS")
620
+
621
+ def test_horologia_visible_with_celery_lock(self):
622
+ self.celery_lock.write_text("")
623
+ resp = self.client.get(reverse("admin:index"))
624
+ self.assertContains(resp, "5. Horologia MODELS")
625
+
626
+
224
627
  class AdminSidebarTests(TestCase):
225
628
  def setUp(self):
226
629
  self.client = Client()
@@ -242,6 +645,140 @@ class AdminSidebarTests(TestCase):
242
645
  self.assertContains(resp, 'id="admin-collapsible-apps"')
243
646
 
244
647
 
648
+ class ViewHistoryLoggingTests(TestCase):
649
+ def setUp(self):
650
+ self.client = Client()
651
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
652
+
653
+ def test_successful_visit_creates_entry(self):
654
+ resp = self.client.get(reverse("pages:index"))
655
+ self.assertEqual(resp.status_code, 200)
656
+ entry = ViewHistory.objects.order_by("-visited_at").first()
657
+ self.assertIsNotNone(entry)
658
+ self.assertEqual(entry.path, "/")
659
+ self.assertEqual(entry.status_code, 200)
660
+ self.assertEqual(entry.error_message, "")
661
+
662
+ def test_error_visit_records_message(self):
663
+ resp = self.client.get("/missing-page/")
664
+ self.assertEqual(resp.status_code, 404)
665
+ entry = (
666
+ ViewHistory.objects.filter(path="/missing-page/")
667
+ .order_by("-visited_at")
668
+ .first()
669
+ )
670
+ self.assertIsNotNone(entry)
671
+ self.assertEqual(entry.status_code, 404)
672
+ self.assertNotEqual(entry.error_message, "")
673
+
674
+ def test_debug_toolbar_requests_not_tracked(self):
675
+ resp = self.client.get(reverse("pages:index"), {"djdt": "toolbar"})
676
+ self.assertEqual(resp.status_code, 200)
677
+ self.assertFalse(ViewHistory.objects.exists())
678
+
679
+ def test_authenticated_user_last_visit_ip_updated(self):
680
+ User = get_user_model()
681
+ user = User.objects.create_user(
682
+ username="history_user", password="pwd", email="history@example.com"
683
+ )
684
+ self.assertTrue(self.client.login(username="history_user", password="pwd"))
685
+
686
+ resp = self.client.get(
687
+ reverse("pages:index"),
688
+ HTTP_X_FORWARDED_FOR="203.0.113.5",
689
+ )
690
+
691
+ self.assertEqual(resp.status_code, 200)
692
+ user.refresh_from_db()
693
+ self.assertEqual(user.last_visit_ip_address, "203.0.113.5")
694
+
695
+
696
+ class ViewHistoryAdminTests(TestCase):
697
+ def setUp(self):
698
+ self.client = Client()
699
+ User = get_user_model()
700
+ self.admin = User.objects.create_superuser(
701
+ username="history_admin", password="pwd", email="admin@example.com"
702
+ )
703
+ self.client.force_login(self.admin)
704
+ Site.objects.update_or_create(
705
+ id=1, defaults={"name": "test", "domain": "testserver"}
706
+ )
707
+
708
+ def _create_history(self, path: str, days_offset: int = 0, count: int = 1):
709
+ for _ in range(count):
710
+ entry = ViewHistory.objects.create(
711
+ path=path,
712
+ method="GET",
713
+ status_code=200,
714
+ status_text="OK",
715
+ error_message="",
716
+ view_name="pages:index",
717
+ )
718
+ if days_offset:
719
+ entry.visited_at = timezone.now() - timedelta(days=days_offset)
720
+ entry.save(update_fields=["visited_at"])
721
+
722
+ def test_change_list_includes_graph_link(self):
723
+ resp = self.client.get(reverse("admin:pages_viewhistory_changelist"))
724
+ self.assertContains(resp, reverse("admin:pages_viewhistory_traffic_graph"))
725
+ self.assertContains(resp, "Traffic graph")
726
+
727
+ def test_graph_view_renders_canvas(self):
728
+ resp = self.client.get(reverse("admin:pages_viewhistory_traffic_graph"))
729
+ self.assertContains(resp, "viewhistory-chart")
730
+ self.assertContains(resp, reverse("admin:pages_viewhistory_changelist"))
731
+ self.assertContains(resp, static("core/vendor/chart.umd.min.js"))
732
+
733
+ def test_graph_data_endpoint(self):
734
+ self._create_history("/", count=2)
735
+ self._create_history("/about/", days_offset=1)
736
+ url = reverse("admin:pages_viewhistory_traffic_data")
737
+ resp = self.client.get(url)
738
+ self.assertEqual(resp.status_code, 200)
739
+ data = resp.json()
740
+ self.assertIn("labels", data)
741
+ self.assertIn("datasets", data)
742
+ self.assertGreater(len(data["labels"]), 0)
743
+ totals = {
744
+ dataset["label"]: sum(dataset["data"]) for dataset in data["datasets"]
745
+ }
746
+ self.assertEqual(totals.get("/"), 2)
747
+ self.assertEqual(totals.get("/about/"), 1)
748
+
749
+ def test_admin_index_displays_widget(self):
750
+ resp = self.client.get(reverse("admin:index"))
751
+ self.assertContains(resp, "viewhistory-mini-module")
752
+ self.assertContains(resp, reverse("admin:pages_viewhistory_traffic_graph"))
753
+ self.assertContains(resp, static("core/vendor/chart.umd.min.js"))
754
+
755
+
756
+ class AdminModelStatusTests(TestCase):
757
+ def setUp(self):
758
+ self.client = Client()
759
+ User = get_user_model()
760
+ self.admin = User.objects.create_superuser(
761
+ username="status_admin", password="pwd", email="admin@example.com"
762
+ )
763
+ self.client.force_login(self.admin)
764
+ Site.objects.update_or_create(
765
+ id=1, defaults={"name": "test", "domain": "testserver"}
766
+ )
767
+ from nodes.models import Node
768
+
769
+ Node.objects.create(hostname="testserver", address="127.0.0.1")
770
+
771
+ @patch("pages.templatetags.admin_extras.connection.introspection.table_names")
772
+ def test_status_dots_render(self, mock_tables):
773
+ from django.db import connection
774
+
775
+ tables = type(connection.introspection).table_names(connection.introspection)
776
+ mock_tables.return_value = [t for t in tables if t != "pages_module"]
777
+ resp = self.client.get(reverse("admin:index"))
778
+ self.assertContains(resp, 'class="model-status ok"')
779
+ self.assertContains(resp, 'class="model-status missing"', count=1)
780
+
781
+
245
782
  class SiteAdminRegisterCurrentTests(TestCase):
246
783
  def setUp(self):
247
784
  self.client = Client()
@@ -382,6 +919,175 @@ class NavAppsTests(TestCase):
382
919
  self.assertNotContains(resp, 'href="/core/"')
383
920
 
384
921
 
922
+ class ConstellationNavTests(TestCase):
923
+ def setUp(self):
924
+ self.client = Client()
925
+ role, _ = NodeRole.objects.get_or_create(name="Constellation")
926
+ Node.objects.update_or_create(
927
+ mac_address=Node.get_current_mac(),
928
+ defaults={
929
+ "hostname": "localhost",
930
+ "address": "127.0.0.1",
931
+ "role": role,
932
+ },
933
+ )
934
+ Site.objects.update_or_create(
935
+ id=1, defaults={"domain": "testserver", "name": ""}
936
+ )
937
+ fixtures = [
938
+ Path(
939
+ settings.BASE_DIR,
940
+ "pages",
941
+ "fixtures",
942
+ "constellation__application_ocpp.json",
943
+ ),
944
+ Path(
945
+ settings.BASE_DIR,
946
+ "pages",
947
+ "fixtures",
948
+ "constellation__module_ocpp.json",
949
+ ),
950
+ Path(
951
+ settings.BASE_DIR,
952
+ "pages",
953
+ "fixtures",
954
+ "constellation__landing_ocpp_dashboard.json",
955
+ ),
956
+ Path(
957
+ settings.BASE_DIR,
958
+ "pages",
959
+ "fixtures",
960
+ "constellation__landing_ocpp_cp_simulator.json",
961
+ ),
962
+ Path(
963
+ settings.BASE_DIR,
964
+ "pages",
965
+ "fixtures",
966
+ "constellation__landing_ocpp_rfid.json",
967
+ ),
968
+ ]
969
+ call_command("loaddata", *map(str, fixtures))
970
+
971
+ def test_rfid_pill_hidden(self):
972
+ resp = self.client.get(reverse("pages:index"))
973
+ nav_labels = [
974
+ module.menu_label.upper() for module in resp.context["nav_modules"]
975
+ ]
976
+ self.assertNotIn("RFID", nav_labels)
977
+ self.assertTrue(
978
+ Module.objects.filter(
979
+ path="/ocpp/", node_role__name="Constellation"
980
+ ).exists()
981
+ )
982
+ self.assertFalse(
983
+ Module.objects.filter(
984
+ path="/ocpp/rfid/",
985
+ node_role__name="Constellation",
986
+ is_deleted=False,
987
+ ).exists()
988
+ )
989
+ ocpp_module = next(
990
+ module
991
+ for module in resp.context["nav_modules"]
992
+ if module.menu_label.upper() == "CHARGERS"
993
+ )
994
+ landing_labels = [landing.label for landing in ocpp_module.enabled_landings]
995
+ self.assertIn("RFID Tag Validator", landing_labels)
996
+
997
+ def test_ocpp_dashboard_visible(self):
998
+ resp = self.client.get(reverse("pages:index"))
999
+ self.assertContains(resp, 'href="/ocpp/"')
1000
+
1001
+ def test_header_links_visible_when_defined(self):
1002
+ Reference.objects.create(
1003
+ alt_text="Console",
1004
+ value="https://example.com/console",
1005
+ show_in_header=True,
1006
+ )
1007
+
1008
+ resp = self.client.get(reverse("pages:index"))
1009
+
1010
+ self.assertIn("header_references", resp.context)
1011
+ self.assertTrue(resp.context["header_references"])
1012
+ self.assertContains(resp, "LINKS")
1013
+ self.assertContains(resp, 'href="https://example.com/console"')
1014
+
1015
+ def test_header_links_hidden_when_flag_false(self):
1016
+ Reference.objects.create(
1017
+ alt_text="Hidden",
1018
+ value="https://example.com/hidden",
1019
+ show_in_header=False,
1020
+ )
1021
+
1022
+ resp = self.client.get(reverse("pages:index"))
1023
+
1024
+ self.assertIn("header_references", resp.context)
1025
+ self.assertFalse(resp.context["header_references"])
1026
+ self.assertNotContains(resp, "https://example.com/hidden")
1027
+
1028
+
1029
+ class PowerNavTests(TestCase):
1030
+ def setUp(self):
1031
+ self.client = Client()
1032
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1033
+ Node.objects.update_or_create(
1034
+ mac_address=Node.get_current_mac(),
1035
+ defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1036
+ )
1037
+ Site.objects.update_or_create(
1038
+ id=1, defaults={"domain": "testserver", "name": ""}
1039
+ )
1040
+ awg_app, _ = Application.objects.get_or_create(name="awg")
1041
+ awg_module, _ = Module.objects.get_or_create(
1042
+ node_role=role, application=awg_app, path="/awg/"
1043
+ )
1044
+ awg_module.create_landings()
1045
+ man_app, _ = Application.objects.get_or_create(name="man")
1046
+ man_module, _ = Module.objects.get_or_create(
1047
+ node_role=role, application=man_app, path="/man/"
1048
+ )
1049
+ man_module.create_landings()
1050
+ User = get_user_model()
1051
+ self.user = User.objects.create_user("user", password="pw")
1052
+
1053
+ def test_power_pill_lists_calculators(self):
1054
+ resp = self.client.get(reverse("pages:index"))
1055
+ power_module = None
1056
+ for module in resp.context["nav_modules"]:
1057
+ if module.path == "/awg/":
1058
+ power_module = module
1059
+ break
1060
+ self.assertIsNotNone(power_module)
1061
+ self.assertEqual(power_module.menu_label.upper(), "CALCULATE")
1062
+ landing_labels = {landing.label for landing in power_module.enabled_landings}
1063
+ self.assertIn("AWG Calculator", landing_labels)
1064
+
1065
+ def test_manual_pill_label(self):
1066
+ resp = self.client.get(reverse("pages:index"))
1067
+ manuals_module = None
1068
+ for module in resp.context["nav_modules"]:
1069
+ if module.path == "/man/":
1070
+ manuals_module = module
1071
+ break
1072
+ self.assertIsNotNone(manuals_module)
1073
+ self.assertEqual(manuals_module.menu_label.upper(), "MANUALS")
1074
+ landing_labels = {landing.label for landing in manuals_module.enabled_landings}
1075
+ self.assertIn("Manuals", landing_labels)
1076
+
1077
+ def test_energy_tariff_visible_when_logged_in(self):
1078
+ self.client.force_login(self.user)
1079
+ resp = self.client.get(reverse("pages:index"))
1080
+ power_module = None
1081
+ for module in resp.context["nav_modules"]:
1082
+ if module.path == "/awg/":
1083
+ power_module = module
1084
+ break
1085
+ self.assertIsNotNone(power_module)
1086
+ landing_labels = {landing.label for landing in power_module.enabled_landings}
1087
+ self.assertIn("AWG Calculator", landing_labels)
1088
+ self.assertIn("Energy Tariff Calculator", landing_labels)
1089
+
1090
+
385
1091
  class StaffNavVisibilityTests(TestCase):
386
1092
  def setUp(self):
387
1093
  self.client = Client()
@@ -472,16 +1178,30 @@ class LandingCreationTests(TestCase):
472
1178
  self.role = role
473
1179
 
474
1180
  def test_landings_created_on_module_creation(self):
475
- module = Module.objects.create(node_role=self.role, application=self.app, path="/")
1181
+ module = Module.objects.create(
1182
+ node_role=self.role, application=self.app, path="/"
1183
+ )
476
1184
  self.assertTrue(module.landings.filter(path="/").exists())
477
1185
 
478
1186
 
479
1187
  class LandingFixtureTests(TestCase):
480
1188
  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))
1189
+ from glob import glob
1190
+
1191
+ NodeRole.objects.get_or_create(name="Constellation")
1192
+ fixtures = glob(
1193
+ str(Path(settings.BASE_DIR, "pages", "fixtures", "constellation__*.json"))
1194
+ )
1195
+ fixtures = sorted(
1196
+ fixtures,
1197
+ key=lambda path: (
1198
+ 0 if "__application_" in path else 1 if "__module_" in path else 2
1199
+ ),
1200
+ )
1201
+ call_command("loaddata", *fixtures)
1202
+ call_command("loaddata", *fixtures)
484
1203
  module = Module.objects.get(path="/ocpp/", node_role__name="Constellation")
1204
+ module.create_landings()
485
1205
  self.assertEqual(module.landings.filter(path="/ocpp/rfid/").count(), 1)
486
1206
 
487
1207
 
@@ -494,20 +1214,14 @@ class AllowedHostSubnetTests(TestCase):
494
1214
 
495
1215
  @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "192.168.0.0/16"])
496
1216
  def test_private_network_hosts_allowed(self):
497
- resp = self.client.get(
498
- reverse("pages:index"), HTTP_HOST="10.42.1.5"
499
- )
1217
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="10.42.1.5")
500
1218
  self.assertEqual(resp.status_code, 200)
501
- resp = self.client.get(
502
- reverse("pages:index"), HTTP_HOST="192.168.2.3"
503
- )
1219
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="192.168.2.3")
504
1220
  self.assertEqual(resp.status_code, 200)
505
1221
 
506
1222
  @override_settings(ALLOWED_HOSTS=["10.42.0.0/16"])
507
1223
  def test_host_outside_subnets_disallowed(self):
508
- resp = self.client.get(
509
- reverse("pages:index"), HTTP_HOST="11.0.0.1"
510
- )
1224
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="11.0.0.1")
511
1225
  self.assertEqual(resp.status_code, 400)
512
1226
 
513
1227
 
@@ -540,7 +1254,11 @@ class FaviconTests(TestCase):
540
1254
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
541
1255
  Node.objects.update_or_create(
542
1256
  mac_address=Node.get_current_mac(),
543
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1257
+ defaults={
1258
+ "hostname": "localhost",
1259
+ "address": "127.0.0.1",
1260
+ "role": role,
1261
+ },
544
1262
  )
545
1263
  site, _ = Site.objects.update_or_create(
546
1264
  id=1, defaults={"domain": "testserver", "name": ""}
@@ -564,7 +1282,11 @@ class FaviconTests(TestCase):
564
1282
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
565
1283
  Node.objects.update_or_create(
566
1284
  mac_address=Node.get_current_mac(),
567
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1285
+ defaults={
1286
+ "hostname": "localhost",
1287
+ "address": "127.0.0.1",
1288
+ "role": role,
1289
+ },
568
1290
  )
569
1291
  site, _ = Site.objects.update_or_create(
570
1292
  id=1, defaults={"domain": "testserver", "name": ""}
@@ -584,7 +1306,11 @@ class FaviconTests(TestCase):
584
1306
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
585
1307
  Node.objects.update_or_create(
586
1308
  mac_address=Node.get_current_mac(),
587
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1309
+ defaults={
1310
+ "hostname": "localhost",
1311
+ "address": "127.0.0.1",
1312
+ "role": role,
1313
+ },
588
1314
  )
589
1315
  Site.objects.update_or_create(
590
1316
  id=1, defaults={"domain": "testserver", "name": ""}
@@ -606,13 +1332,30 @@ class FavoriteTests(TestCase):
606
1332
  self.user = User.objects.create_superuser(
607
1333
  username="favadmin", password="pwd", email="fav@example.com"
608
1334
  )
1335
+ ReleaseManager.objects.create(user=self.user)
609
1336
  self.client.force_login(self.user)
610
- Site.objects.update_or_create(id=1, defaults={"name": "test", "domain": "testserver"})
1337
+ Site.objects.update_or_create(
1338
+ id=1, defaults={"name": "test", "domain": "testserver"}
1339
+ )
1340
+ from nodes.models import Node, NodeRole
1341
+
1342
+ terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
1343
+ self.node, _ = Node.objects.update_or_create(
1344
+ mac_address=Node.get_current_mac(),
1345
+ defaults={
1346
+ "hostname": "localhost",
1347
+ "address": "127.0.0.1",
1348
+ "role": terminal_role,
1349
+ },
1350
+ )
1351
+ ContentType.objects.clear_cache()
611
1352
 
612
1353
  def test_add_favorite(self):
613
1354
  ct = ContentType.objects.get_by_natural_key("pages", "application")
614
1355
  next_url = reverse("admin:pages_application_changelist")
615
- url = reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
1356
+ url = (
1357
+ reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
1358
+ )
616
1359
  resp = self.client.post(url, {"custom_label": "Apps", "user_data": "on"})
617
1360
  self.assertRedirects(resp, next_url)
618
1361
  fav = Favorite.objects.get(user=self.user, content_type=ct)
@@ -622,7 +1365,9 @@ class FavoriteTests(TestCase):
622
1365
  def test_cancel_link_uses_next(self):
623
1366
  ct = ContentType.objects.get_by_natural_key("pages", "application")
624
1367
  next_url = reverse("admin:pages_application_changelist")
625
- url = reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
1368
+ url = (
1369
+ reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
1370
+ )
626
1371
  resp = self.client.get(url)
627
1372
  self.assertContains(resp, f'href="{next_url}"')
628
1373
 
@@ -646,10 +1391,10 @@ class FavoriteTests(TestCase):
646
1391
 
647
1392
  def test_dashboard_includes_favorites_and_user_data(self):
648
1393
  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)
1394
+ Favorite.objects.create(
1395
+ user=self.user, content_type=fav_ct, custom_label="Apps"
1396
+ )
1397
+ NodeRole.objects.create(name="DataRole", is_user_data=True)
653
1398
  resp = self.client.get(reverse("admin:index"))
654
1399
  self.assertContains(resp, reverse("admin:pages_application_changelist"))
655
1400
  self.assertContains(resp, reverse("admin:nodes_noderole_changelist"))
@@ -657,8 +1402,7 @@ class FavoriteTests(TestCase):
657
1402
  def test_dashboard_merges_duplicate_future_actions(self):
658
1403
  ct = ContentType.objects.get_for_model(NodeRole)
659
1404
  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)
1405
+ NodeRole.objects.create(name="DataRole2", is_user_data=True)
662
1406
  AdminHistory.objects.create(
663
1407
  user=self.user,
664
1408
  content_type=ct,
@@ -668,3 +1412,399 @@ class FavoriteTests(TestCase):
668
1412
  url = reverse("admin:nodes_noderole_changelist")
669
1413
  self.assertGreaterEqual(resp.content.decode().count(url), 1)
670
1414
  self.assertContains(resp, NodeRole._meta.verbose_name_plural)
1415
+
1416
+ def test_dashboard_limits_future_actions_to_top_four(self):
1417
+ from pages.templatetags.admin_extras import future_action_items
1418
+
1419
+ role_ct = ContentType.objects.get_for_model(NodeRole)
1420
+ role_url = reverse("admin:nodes_noderole_changelist")
1421
+ AdminHistory.objects.create(
1422
+ user=self.user,
1423
+ content_type=role_ct,
1424
+ url=role_url,
1425
+ )
1426
+ AdminHistory.objects.create(
1427
+ user=self.user,
1428
+ content_type=role_ct,
1429
+ url=f"{role_url}?page=2",
1430
+ )
1431
+ AdminHistory.objects.create(
1432
+ user=self.user,
1433
+ content_type=role_ct,
1434
+ url=f"{role_url}?page=3",
1435
+ )
1436
+
1437
+ app_ct = ContentType.objects.get_for_model(Application)
1438
+ app_url = reverse("admin:pages_application_changelist")
1439
+ AdminHistory.objects.create(
1440
+ user=self.user,
1441
+ content_type=app_ct,
1442
+ url=app_url,
1443
+ )
1444
+ AdminHistory.objects.create(
1445
+ user=self.user,
1446
+ content_type=app_ct,
1447
+ url=f"{app_url}?page=2",
1448
+ )
1449
+
1450
+ module_ct = ContentType.objects.get_for_model(Module)
1451
+ module_url = reverse("admin:pages_module_changelist")
1452
+ AdminHistory.objects.create(
1453
+ user=self.user,
1454
+ content_type=module_ct,
1455
+ url=module_url,
1456
+ )
1457
+ AdminHistory.objects.create(
1458
+ user=self.user,
1459
+ content_type=module_ct,
1460
+ url=f"{module_url}?page=2",
1461
+ )
1462
+
1463
+ package_ct = ContentType.objects.get_for_model(Package)
1464
+ package_url = reverse("admin:core_package_changelist")
1465
+ AdminHistory.objects.create(
1466
+ user=self.user,
1467
+ content_type=package_ct,
1468
+ url=package_url,
1469
+ )
1470
+
1471
+ view_history_ct = ContentType.objects.get_for_model(ViewHistory)
1472
+ view_history_url = reverse("admin:pages_viewhistory_changelist")
1473
+ AdminHistory.objects.create(
1474
+ user=self.user,
1475
+ content_type=view_history_ct,
1476
+ url=view_history_url,
1477
+ )
1478
+
1479
+ resp = self.client.get(reverse("admin:index"))
1480
+ items = future_action_items({"request": resp.wsgi_request})["models"]
1481
+ labels = {item["label"] for item in items}
1482
+ self.assertEqual(len(items), 4)
1483
+ self.assertIn("Node Roles", labels)
1484
+ self.assertIn("Modules", labels)
1485
+ self.assertIn("applications", labels)
1486
+ self.assertIn("View Histories", labels)
1487
+ self.assertNotIn("Packages", labels)
1488
+ ContentType.objects.clear_cache()
1489
+
1490
+ def test_favorite_ct_id_recreates_missing_content_type(self):
1491
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
1492
+ ct.delete()
1493
+ from pages.templatetags.favorites import favorite_ct_id
1494
+
1495
+ new_id = favorite_ct_id("pages", "Application")
1496
+ self.assertIsNotNone(new_id)
1497
+ self.assertTrue(
1498
+ ContentType.objects.filter(
1499
+ pk=new_id, app_label="pages", model="application"
1500
+ ).exists()
1501
+ )
1502
+
1503
+ def test_dashboard_uses_change_label(self):
1504
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
1505
+ Favorite.objects.create(user=self.user, content_type=ct)
1506
+ resp = self.client.get(reverse("admin:index"))
1507
+ self.assertContains(resp, "Change Applications")
1508
+ self.assertContains(resp, 'target="_blank" rel="noopener noreferrer"')
1509
+
1510
+ def test_dashboard_links_to_focus_view(self):
1511
+ todo = Todo.objects.create(request="Check docs", url="/docs/")
1512
+ resp = self.client.get(reverse("admin:index"))
1513
+ focus_url = reverse("todo-focus", args=[todo.pk])
1514
+ expected_next = quote(reverse("admin:index"))
1515
+ self.assertContains(
1516
+ resp,
1517
+ f'href="{focus_url}?next={expected_next}"',
1518
+ )
1519
+
1520
+ def test_dashboard_shows_todo_with_done_button(self):
1521
+ todo = Todo.objects.create(request="Do thing")
1522
+ resp = self.client.get(reverse("admin:index"))
1523
+ done_url = reverse("todo-done", args=[todo.pk])
1524
+ self.assertContains(resp, todo.request)
1525
+ self.assertContains(resp, f'action="{done_url}"')
1526
+ self.assertContains(resp, "DONE")
1527
+
1528
+ def test_dashboard_shows_request_details(self):
1529
+ Todo.objects.create(request="Do thing", request_details="More info")
1530
+ resp = self.client.get(reverse("admin:index"))
1531
+ self.assertContains(
1532
+ resp, '<div class="todo-details">More info</div>', html=True
1533
+ )
1534
+
1535
+ def test_dashboard_excludes_todo_changelist_link(self):
1536
+ ct = ContentType.objects.get_for_model(Todo)
1537
+ Favorite.objects.create(user=self.user, content_type=ct)
1538
+ AdminHistory.objects.create(
1539
+ user=self.user,
1540
+ content_type=ct,
1541
+ url=reverse("admin:core_todo_changelist"),
1542
+ )
1543
+ Todo.objects.create(request="Task", is_user_data=True)
1544
+ resp = self.client.get(reverse("admin:index"))
1545
+ changelist = reverse("admin:core_todo_changelist")
1546
+ self.assertNotContains(resp, f'href="{changelist}"')
1547
+
1548
+ def test_dashboard_hides_todos_without_release_manager(self):
1549
+ todo = Todo.objects.create(request="Only Release Manager")
1550
+ User = get_user_model()
1551
+ other_user = User.objects.create_superuser(
1552
+ username="norole", password="pwd", email="norole@example.com"
1553
+ )
1554
+ self.client.force_login(other_user)
1555
+ resp = self.client.get(reverse("admin:index"))
1556
+ self.assertNotContains(resp, "Release manager tasks")
1557
+ self.assertNotContains(resp, todo.request)
1558
+
1559
+ def test_dashboard_hides_todos_for_non_terminal_node(self):
1560
+ todo = Todo.objects.create(request="Terminal Tasks")
1561
+ from nodes.models import NodeRole
1562
+
1563
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
1564
+ self.node.role = control_role
1565
+ self.node.save(update_fields=["role"])
1566
+ resp = self.client.get(reverse("admin:index"))
1567
+ self.assertNotContains(resp, "Release manager tasks")
1568
+ self.assertNotContains(resp, todo.request)
1569
+
1570
+ def test_dashboard_shows_todos_for_delegate_release_manager(self):
1571
+ todo = Todo.objects.create(request="Delegate Task")
1572
+ User = get_user_model()
1573
+ delegate = User.objects.create_superuser(
1574
+ username="delegate",
1575
+ password="pwd",
1576
+ email="delegate@example.com",
1577
+ )
1578
+ ReleaseManager.objects.create(user=delegate)
1579
+ operator = User.objects.create_superuser(
1580
+ username="operator",
1581
+ password="pwd",
1582
+ email="operator@example.com",
1583
+ )
1584
+ operator.operate_as = delegate
1585
+ operator.full_clean()
1586
+ operator.save()
1587
+ self.client.force_login(operator)
1588
+ resp = self.client.get(reverse("admin:index"))
1589
+ self.assertContains(resp, "Release manager tasks")
1590
+ self.assertContains(resp, todo.request)
1591
+
1592
+
1593
+ class AdminActionListTests(TestCase):
1594
+ def setUp(self):
1595
+ User = get_user_model()
1596
+ User.objects.filter(username="action-admin").delete()
1597
+ self.user = User.objects.create_superuser(
1598
+ username="action-admin",
1599
+ password="pwd",
1600
+ email="action@example.com",
1601
+ )
1602
+ self.factory = RequestFactory()
1603
+
1604
+ def test_profile_actions_available_without_selection(self):
1605
+ from pages.templatetags.admin_extras import model_admin_actions
1606
+
1607
+ request = self.factory.get("/")
1608
+ request.user = self.user
1609
+ context = {"request": request}
1610
+
1611
+ registered = [
1612
+ (model._meta.app_label, model._meta.object_name)
1613
+ for model, admin_instance in admin.site._registry.items()
1614
+ if isinstance(admin_instance, ProfileAdminMixin)
1615
+ ]
1616
+
1617
+ for app_label, object_name in registered:
1618
+ with self.subTest(model=f"{app_label}.{object_name}"):
1619
+ actions = model_admin_actions(context, app_label, object_name)
1620
+ labels = {action["label"] for action in actions}
1621
+ self.assertIn("Active Profile", labels)
1622
+
1623
+
1624
+ class AdminModelGraphViewTests(TestCase):
1625
+ def setUp(self):
1626
+ self.client = Client()
1627
+ User = get_user_model()
1628
+ self.user = User.objects.create_user(
1629
+ username="graph-staff", password="pwd", is_staff=True
1630
+ )
1631
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
1632
+ self.client.force_login(self.user)
1633
+
1634
+ def _mock_graph(self):
1635
+ fake_graph = Mock()
1636
+ fake_graph.source = "digraph {}"
1637
+ fake_graph.engine = "dot"
1638
+
1639
+ def pipe_side_effect(*args, **kwargs):
1640
+ fmt = kwargs.get("format") or (args[0] if args else None)
1641
+ if fmt == "svg":
1642
+ return '<svg xmlns="http://www.w3.org/2000/svg"></svg>'
1643
+ if fmt == "pdf":
1644
+ return b"%PDF-1.4 mock"
1645
+ raise AssertionError(f"Unexpected format: {fmt}")
1646
+
1647
+ fake_graph.pipe.side_effect = pipe_side_effect
1648
+ return fake_graph
1649
+
1650
+ def test_model_graph_renders_controls_and_download_link(self):
1651
+ url = reverse("admin-model-graph", args=["pages"])
1652
+ graph = self._mock_graph()
1653
+ with (
1654
+ patch("pages.views._build_model_graph", return_value=graph),
1655
+ patch("pages.views.shutil.which", return_value="/usr/bin/dot"),
1656
+ ):
1657
+ response = self.client.get(url)
1658
+
1659
+ self.assertEqual(response.status_code, 200)
1660
+ self.assertContains(response, "data-model-graph")
1661
+ self.assertContains(response, 'data-graph-action="zoom-in"')
1662
+ self.assertContains(response, "Download PDF")
1663
+ self.assertIn("?format=pdf", response.context_data["download_url"])
1664
+ args, kwargs = graph.pipe.call_args
1665
+ self.assertEqual(kwargs.get("format"), "svg")
1666
+ self.assertEqual(kwargs.get("encoding"), "utf-8")
1667
+
1668
+ def test_model_graph_pdf_download(self):
1669
+ url = reverse("admin-model-graph", args=["pages"])
1670
+ graph = self._mock_graph()
1671
+ with (
1672
+ patch("pages.views._build_model_graph", return_value=graph),
1673
+ patch("pages.views.shutil.which", return_value="/usr/bin/dot"),
1674
+ ):
1675
+ response = self.client.get(url, {"format": "pdf"})
1676
+
1677
+ self.assertEqual(response.status_code, 200)
1678
+ self.assertEqual(response["Content-Type"], "application/pdf")
1679
+ app_config = django_apps.get_app_config("pages")
1680
+ expected_slug = slugify(app_config.verbose_name) or app_config.label
1681
+ self.assertIn(
1682
+ f"{expected_slug}-model-graph.pdf", response["Content-Disposition"]
1683
+ )
1684
+ self.assertEqual(response.content, b"%PDF-1.4 mock")
1685
+ args, kwargs = graph.pipe.call_args
1686
+ self.assertEqual(kwargs.get("format"), "pdf")
1687
+
1688
+
1689
+ class DatasetteTests(TestCase):
1690
+ def setUp(self):
1691
+ self.client = Client()
1692
+ User = get_user_model()
1693
+ self.user = User.objects.create_user(username="ds", password="pwd")
1694
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
1695
+
1696
+ def test_datasette_auth_endpoint(self):
1697
+ resp = self.client.get(reverse("pages:datasette-auth"))
1698
+ self.assertEqual(resp.status_code, 401)
1699
+ self.client.force_login(self.user)
1700
+ resp = self.client.get(reverse("pages:datasette-auth"))
1701
+ self.assertEqual(resp.status_code, 200)
1702
+
1703
+ def test_navbar_includes_datasette_when_enabled(self):
1704
+ lock_dir = Path(settings.BASE_DIR) / "locks"
1705
+ lock_dir.mkdir(exist_ok=True)
1706
+ lock_file = lock_dir / "datasette.lck"
1707
+ try:
1708
+ lock_file.touch()
1709
+ resp = self.client.get(reverse("pages:index"))
1710
+ self.assertContains(resp, 'href="/data/"')
1711
+ finally:
1712
+ lock_file.unlink(missing_ok=True)
1713
+
1714
+
1715
+ class ClientReportLiveUpdateTests(TestCase):
1716
+ def setUp(self):
1717
+ self.client = Client()
1718
+
1719
+ def test_client_report_includes_interval(self):
1720
+ resp = self.client.get(reverse("pages:client-report"))
1721
+ self.assertEqual(resp.context["request"].live_update_interval, 5)
1722
+ self.assertContains(resp, "setInterval(() => location.reload()")
1723
+
1724
+
1725
+ class ScreenshotSpecInfrastructureTests(TestCase):
1726
+ def test_runner_creates_outputs_and_cleans_old_samples(self):
1727
+ spec = ScreenshotSpec(slug="spec-test", url="/")
1728
+ with tempfile.TemporaryDirectory() as tmp:
1729
+ temp_dir = Path(tmp)
1730
+ screenshot_path = temp_dir / "source.png"
1731
+ screenshot_path.write_bytes(b"fake")
1732
+ ContentSample.objects.create(
1733
+ kind=ContentSample.IMAGE,
1734
+ path="old.png",
1735
+ method="spec:old",
1736
+ hash="old-hash",
1737
+ )
1738
+ ContentSample.objects.filter(hash="old-hash").update(
1739
+ created_at=timezone.now() - timedelta(days=8)
1740
+ )
1741
+ with (
1742
+ patch(
1743
+ "pages.screenshot_specs.base.capture_screenshot",
1744
+ return_value=screenshot_path,
1745
+ ) as capture_mock,
1746
+ patch(
1747
+ "pages.screenshot_specs.base.save_screenshot", return_value=None
1748
+ ) as save_mock,
1749
+ ):
1750
+ with ScreenshotSpecRunner(temp_dir) as runner:
1751
+ result = runner.run(spec)
1752
+ self.assertTrue(result.image_path.exists())
1753
+ self.assertTrue(result.base64_path.exists())
1754
+ self.assertEqual(ContentSample.objects.filter(hash="old-hash").count(), 0)
1755
+ capture_mock.assert_called_once()
1756
+ save_mock.assert_called_once_with(screenshot_path, method="spec:spec-test")
1757
+
1758
+ def test_runner_respects_manual_reason(self):
1759
+ spec = ScreenshotSpec(slug="manual-spec", url="/", manual_reason="hardware")
1760
+ with tempfile.TemporaryDirectory() as tmp:
1761
+ with ScreenshotSpecRunner(Path(tmp)) as runner:
1762
+ with self.assertRaises(ScreenshotUnavailable):
1763
+ runner.run(spec)
1764
+
1765
+
1766
+ class CaptureUIScreenshotsCommandTests(TestCase):
1767
+ def tearDown(self):
1768
+ registry.unregister("manual-cmd")
1769
+ registry.unregister("auto-cmd")
1770
+
1771
+ def test_manual_spec_emits_warning(self):
1772
+ spec = ScreenshotSpec(slug="manual-cmd", url="/", manual_reason="manual")
1773
+ registry.register(spec)
1774
+ out = StringIO()
1775
+ call_command("capture_ui_screenshots", "--spec", spec.slug, stdout=out)
1776
+ self.assertIn("Skipping manual screenshot", out.getvalue())
1777
+
1778
+ def test_command_invokes_runner(self):
1779
+ spec = ScreenshotSpec(slug="auto-cmd", url="/")
1780
+ registry.register(spec)
1781
+ with tempfile.TemporaryDirectory() as tmp:
1782
+ tmp_path = Path(tmp)
1783
+ image_path = tmp_path / "auto-cmd.png"
1784
+ base64_path = tmp_path / "auto-cmd.base64"
1785
+ image_path.write_bytes(b"fake")
1786
+ base64_path.write_text("Zg==", encoding="utf-8")
1787
+ runner = Mock()
1788
+ runner.__enter__ = Mock(return_value=runner)
1789
+ runner.__exit__ = Mock(return_value=None)
1790
+ runner.run.return_value = SimpleNamespace(
1791
+ image_path=image_path,
1792
+ base64_path=base64_path,
1793
+ sample=None,
1794
+ )
1795
+ with patch(
1796
+ "pages.management.commands.capture_ui_screenshots.ScreenshotSpecRunner",
1797
+ return_value=runner,
1798
+ ) as runner_cls:
1799
+ out = StringIO()
1800
+ call_command(
1801
+ "capture_ui_screenshots",
1802
+ "--spec",
1803
+ spec.slug,
1804
+ "--output-dir",
1805
+ tmp_path,
1806
+ stdout=out,
1807
+ )
1808
+ runner_cls.assert_called_once()
1809
+ runner.run.assert_called_once_with(spec)
1810
+ self.assertIn("Captured 'auto-cmd'", out.getvalue())