arthexis 0.1.13__py3-none-any.whl → 0.1.14__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 (107) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +708 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2200
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1128
  104. arthexis-0.1.13.dist-info/RECORD +0 -105
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
pages/tests.py CHANGED
@@ -1,2200 +1,2612 @@
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
10
- from django.urls import reverse
11
- from django.templatetags.static import static
12
- from urllib.parse import quote
13
- from django.contrib.auth import get_user_model
14
- from django.contrib.sites.models import Site
15
- from django.contrib import admin
16
- from django.contrib.messages.storage.fallback import FallbackStorage
17
- from django.core.exceptions import DisallowedHost
18
- import socket
19
- from pages.models import (
20
- Application,
21
- Module,
22
- SiteBadge,
23
- Favorite,
24
- ViewHistory,
25
- UserManual,
26
- UserStory,
27
- )
28
- from pages.admin import (
29
- ApplicationAdmin,
30
- UserManualAdmin,
31
- UserStoryAdmin,
32
- ViewHistoryAdmin,
33
- )
34
- from pages.screenshot_specs import (
35
- ScreenshotSpec,
36
- ScreenshotSpecRunner,
37
- ScreenshotUnavailable,
38
- registry,
39
- )
40
- from django.apps import apps as django_apps
41
- from core import mailer
42
- from core.admin import ProfileAdminMixin
43
- from core.models import (
44
- AdminHistory,
45
- InviteLead,
46
- Package,
47
- Reference,
48
- ReleaseManager,
49
- Todo,
50
- TOTPDeviceSettings,
51
- )
52
- from django.core.files.uploadedfile import SimpleUploadedFile
53
- import base64
54
- import tempfile
55
- import shutil
56
- from io import StringIO
57
- from django.conf import settings
58
- from pathlib import Path
59
- from unittest.mock import MagicMock, Mock, patch
60
- from types import SimpleNamespace
61
- from django.core.management import call_command
62
- import re
63
- from django.contrib.contenttypes.models import ContentType
64
- from datetime import (
65
- date,
66
- datetime,
67
- time as datetime_time,
68
- timedelta,
69
- timezone as datetime_timezone,
70
- )
71
- from django.core import mail
72
- from django.utils import timezone
73
- from django.utils.text import slugify
74
- from django_otp import DEVICE_ID_SESSION_KEY
75
- from django_otp.oath import TOTP
76
- from django_otp.plugins.otp_totp.models import TOTPDevice
77
- from core.backends import TOTP_DEVICE_NAME
78
- import time
79
-
80
- from nodes.models import (
81
- EmailOutbox,
82
- Node,
83
- ContentSample,
84
- NodeRole,
85
- NodeFeature,
86
- NodeFeatureAssignment,
87
- )
88
-
89
-
90
- class LoginViewTests(TestCase):
91
- def setUp(self):
92
- self.client = Client()
93
- User = get_user_model()
94
- self.staff = User.objects.create_user(
95
- username="staff", password="pwd", is_staff=True
96
- )
97
- self.user = User.objects.create_user(username="user", password="pwd")
98
- Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
99
-
100
- def test_login_link_in_navbar(self):
101
- resp = self.client.get(reverse("pages:index"))
102
- self.assertContains(resp, 'href="/login/"')
103
-
104
- def test_login_page_shows_authenticator_toggle(self):
105
- resp = self.client.get(reverse("pages:login"))
106
- self.assertContains(resp, "Use Authenticator app")
107
-
108
- def test_cp_simulator_redirect_shows_restricted_message(self):
109
- simulator_path = reverse("cp-simulator")
110
- resp = self.client.get(f"{reverse('pages:login')}?next={simulator_path}")
111
- self.assertContains(
112
- resp,
113
- "This page is reserved for members only. Please log in to continue.",
114
- )
115
-
116
- def test_staff_login_redirects_admin(self):
117
- resp = self.client.post(
118
- reverse("pages:login"),
119
- {"username": "staff", "password": "pwd"},
120
- )
121
- self.assertRedirects(resp, reverse("admin:index"))
122
-
123
- def test_login_with_authenticator_code(self):
124
- device = TOTPDevice.objects.create(
125
- user=self.staff,
126
- name=TOTP_DEVICE_NAME,
127
- confirmed=True,
128
- )
129
- totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift)
130
- totp.time = time.time()
131
- token = f"{totp.token():0{device.digits}d}"
132
-
133
- resp = self.client.post(
134
- reverse("pages:login"),
135
- {
136
- "username": "staff",
137
- "auth_method": "otp",
138
- "otp_token": token,
139
- },
140
- )
141
-
142
- self.assertRedirects(resp, reverse("admin:index"))
143
- session = self.client.session
144
- self.assertIn(DEVICE_ID_SESSION_KEY, session)
145
- self.assertEqual(session[DEVICE_ID_SESSION_KEY], device.persistent_id)
146
-
147
- def test_login_with_invalid_authenticator_code(self):
148
- TOTPDevice.objects.create(
149
- user=self.staff,
150
- name=TOTP_DEVICE_NAME,
151
- confirmed=True,
152
- )
153
-
154
- resp = self.client.post(
155
- reverse("pages:login"),
156
- {
157
- "username": "staff",
158
- "auth_method": "otp",
159
- "otp_token": "000000",
160
- },
161
- )
162
-
163
- self.assertEqual(resp.status_code, 200)
164
- self.assertContains(resp, "authenticator code is invalid", status_code=200)
165
-
166
- def test_already_logged_in_staff_redirects(self):
167
- self.client.force_login(self.staff)
168
- resp = self.client.get(reverse("pages:login"))
169
- self.assertRedirects(resp, reverse("admin:index"))
170
-
171
- def test_regular_user_redirects_next(self):
172
- resp = self.client.post(
173
- reverse("pages:login") + "?next=/nodes/list/",
174
- {"username": "user", "password": "pwd"},
175
- )
176
- self.assertRedirects(resp, "/nodes/list/")
177
-
178
- def test_staff_redirects_next_when_specified(self):
179
- resp = self.client.post(
180
- reverse("pages:login") + "?next=/nodes/list/",
181
- {"username": "staff", "password": "pwd"},
182
- )
183
- self.assertRedirects(resp, "/nodes/list/")
184
-
185
-
186
-
187
- @override_settings(EMAIL_BACKEND="django.core.mail.backends.dummy.EmailBackend")
188
- def test_login_page_hides_request_link_without_email_backend(self):
189
- resp = self.client.get(reverse("pages:login"))
190
- self.assertFalse(resp.context["can_request_invite"])
191
- self.assertNotContains(resp, reverse("pages:request-invite"))
192
-
193
- @override_settings(EMAIL_BACKEND="django.core.mail.backends.dummy.EmailBackend")
194
- def test_login_page_shows_request_link_when_outbox_configured(self):
195
- EmailOutbox.objects.create(host="smtp.example.com")
196
- resp = self.client.get(reverse("pages:login"))
197
- self.assertTrue(resp.context["can_request_invite"])
198
- self.assertContains(resp, reverse("pages:request-invite"))
199
-
200
- @override_settings(ALLOWED_HOSTS=["gway-qk32000"])
201
- def test_login_allows_forwarded_https_origin(self):
202
- secure_client = Client(enforce_csrf_checks=True)
203
- login_url = reverse("pages:login")
204
- response = secure_client.get(login_url, HTTP_HOST="gway-qk32000")
205
- csrf_cookie = response.cookies["csrftoken"].value
206
- submit = secure_client.post(
207
- login_url,
208
- {
209
- "username": "staff",
210
- "password": "pwd",
211
- "csrfmiddlewaretoken": csrf_cookie,
212
- },
213
- HTTP_HOST="gway-qk32000",
214
- HTTP_ORIGIN="https://gway-qk32000",
215
- HTTP_X_FORWARDED_PROTO="https",
216
- HTTP_REFERER="https://gway-qk32000/login/",
217
- )
218
- self.assertRedirects(submit, reverse("admin:index"))
219
-
220
- @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "gway-qk32000"])
221
- def test_login_allows_forwarded_origin_with_private_host_header(self):
222
- secure_client = Client(enforce_csrf_checks=True)
223
- login_url = reverse("pages:login")
224
- response = secure_client.get(login_url, HTTP_HOST="10.42.0.2")
225
- csrf_cookie = response.cookies["csrftoken"].value
226
- submit = secure_client.post(
227
- login_url,
228
- {
229
- "username": "staff",
230
- "password": "pwd",
231
- "csrfmiddlewaretoken": csrf_cookie,
232
- },
233
- HTTP_HOST="10.42.0.2",
234
- HTTP_ORIGIN="https://gway-qk32000",
235
- HTTP_X_FORWARDED_PROTO="https",
236
- HTTP_X_FORWARDED_HOST="gway-qk32000",
237
- HTTP_REFERER="https://gway-qk32000/login/",
238
- )
239
- self.assertRedirects(submit, reverse("admin:index"))
240
-
241
- @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "gway-qk32000"])
242
- def test_login_allows_forwarded_header_host_and_proto(self):
243
- secure_client = Client(enforce_csrf_checks=True)
244
- login_url = reverse("pages:login")
245
- response = secure_client.get(login_url, HTTP_HOST="10.42.0.2")
246
- csrf_cookie = response.cookies["csrftoken"].value
247
- submit = secure_client.post(
248
- login_url,
249
- {
250
- "username": "staff",
251
- "password": "pwd",
252
- "csrfmiddlewaretoken": csrf_cookie,
253
- },
254
- HTTP_HOST="10.42.0.2",
255
- HTTP_ORIGIN="https://gway-qk32000",
256
- HTTP_FORWARDED="proto=https;host=gway-qk32000",
257
- HTTP_REFERER="https://gway-qk32000/login/",
258
- )
259
- self.assertRedirects(submit, reverse("admin:index"))
260
-
261
- @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "gway-qk32000"])
262
- def test_login_allows_forwarded_referer_without_origin(self):
263
- secure_client = Client(enforce_csrf_checks=True)
264
- login_url = reverse("pages:login")
265
- response = secure_client.get(login_url, HTTP_HOST="10.42.0.2")
266
- csrf_cookie = response.cookies["csrftoken"].value
267
- submit = secure_client.post(
268
- login_url,
269
- {
270
- "username": "staff",
271
- "password": "pwd",
272
- "csrfmiddlewaretoken": csrf_cookie,
273
- },
274
- HTTP_HOST="10.42.0.2",
275
- HTTP_X_FORWARDED_PROTO="https",
276
- HTTP_X_FORWARDED_HOST="gway-qk32000",
277
- HTTP_REFERER="https://gway-qk32000/login/",
278
- )
279
- self.assertRedirects(submit, reverse("admin:index"))
280
-
281
- @override_settings(ALLOWED_HOSTS=["gway-qk32000"])
282
- def test_login_allows_forwarded_origin_with_explicit_port(self):
283
- secure_client = Client(enforce_csrf_checks=True)
284
- login_url = reverse("pages:login")
285
- response = secure_client.get(login_url, HTTP_HOST="gway-qk32000")
286
- csrf_cookie = response.cookies["csrftoken"].value
287
- submit = secure_client.post(
288
- login_url,
289
- {
290
- "username": "staff",
291
- "password": "pwd",
292
- "csrfmiddlewaretoken": csrf_cookie,
293
- },
294
- HTTP_HOST="gway-qk32000",
295
- HTTP_ORIGIN="https://gway-qk32000:4443",
296
- HTTP_X_FORWARDED_PROTO="https",
297
- HTTP_X_FORWARDED_HOST="gway-qk32000:4443",
298
- HTTP_REFERER="https://gway-qk32000:4443/login/",
299
- )
300
- self.assertRedirects(submit, reverse("admin:index"))
301
-
302
-
303
- class AuthenticatorSetupTests(TestCase):
304
- def setUp(self):
305
- self.client = Client()
306
- User = get_user_model()
307
- self.staff = User.objects.create_user(
308
- username="staffer", password="pwd", is_staff=True
309
- )
310
- Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
311
- self.client.force_login(self.staff)
312
-
313
- def _current_token(self, device):
314
- totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift)
315
- totp.time = time.time()
316
- return f"{totp.token():0{device.digits}d}"
317
-
318
- def test_generate_creates_pending_device(self):
319
- resp = self.client.post(
320
- reverse("pages:authenticator-setup"), {"action": "generate"}
321
- )
322
- self.assertRedirects(resp, reverse("pages:authenticator-setup"))
323
- device = TOTPDevice.objects.get(user=self.staff)
324
- self.assertFalse(device.confirmed)
325
- self.assertEqual(device.name, TOTP_DEVICE_NAME)
326
-
327
- def test_device_config_url_includes_issuer_prefix(self):
328
- self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
329
- device = TOTPDevice.objects.get(user=self.staff)
330
- config_url = device.config_url
331
- label = quote(f"{settings.OTP_TOTP_ISSUER}:{self.staff.username}")
332
- self.assertIn(label, config_url)
333
- self.assertIn(f"issuer={quote(settings.OTP_TOTP_ISSUER)}", config_url)
334
-
335
- def test_device_config_url_uses_custom_issuer_when_available(self):
336
- self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
337
- device = TOTPDevice.objects.get(user=self.staff)
338
- TOTPDeviceSettings.objects.create(device=device, issuer="Custom Co")
339
- config_url = device.config_url
340
- quoted_issuer = quote("Custom Co")
341
- self.assertIn(quoted_issuer, config_url)
342
- self.assertIn(f"issuer={quoted_issuer}", config_url)
343
-
344
- def test_pending_device_context_includes_qr(self):
345
- self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
346
- resp = self.client.get(reverse("pages:authenticator-setup"))
347
- self.assertEqual(resp.status_code, 200)
348
- self.assertTrue(resp.context["qr_data_uri"].startswith("data:image/png;base64,"))
349
- self.assertTrue(resp.context["manual_key"])
350
-
351
- def test_confirm_pending_device(self):
352
- self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
353
- device = TOTPDevice.objects.get(user=self.staff)
354
- token = self._current_token(device)
355
- resp = self.client.post(
356
- reverse("pages:authenticator-setup"),
357
- {"action": "confirm", "token": token},
358
- )
359
- self.assertRedirects(resp, reverse("pages:authenticator-setup"))
360
- device.refresh_from_db()
361
- self.assertTrue(device.confirmed)
362
-
363
- def test_remove_device(self):
364
- self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
365
- device = TOTPDevice.objects.get(user=self.staff)
366
- token = self._current_token(device)
367
- self.client.post(
368
- reverse("pages:authenticator-setup"),
369
- {"action": "confirm", "token": token},
370
- )
371
- resp = self.client.post(
372
- reverse("pages:authenticator-setup"), {"action": "remove"}
373
- )
374
- self.assertRedirects(resp, reverse("pages:authenticator-setup"))
375
- self.assertFalse(TOTPDevice.objects.filter(user=self.staff).exists())
376
-
377
- @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
378
- class InvitationTests(TestCase):
379
- def setUp(self):
380
- self.client = Client()
381
- User = get_user_model()
382
- self.user = User.objects.create_user(
383
- username="invited",
384
- email="invite@example.com",
385
- is_active=False,
386
- )
387
- self.user.set_unusable_password()
388
- self.user.save()
389
- Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
390
-
391
- def test_login_page_has_request_link(self):
392
- resp = self.client.get(reverse("pages:login"))
393
- self.assertContains(resp, reverse("pages:request-invite"))
394
-
395
- def test_request_invite_sets_csrf_cookie(self):
396
- resp = self.client.get(reverse("pages:request-invite"))
397
- self.assertIn("csrftoken", resp.cookies)
398
-
399
- def test_request_invite_allows_post_without_csrf(self):
400
- client = Client(enforce_csrf_checks=True)
401
- resp = client.post(
402
- reverse("pages:request-invite"), {"email": "invite@example.com"}
403
- )
404
- self.assertEqual(resp.status_code, 200)
405
-
406
- def test_invitation_flow(self):
407
- resp = self.client.post(
408
- reverse("pages:request-invite"), {"email": "invite@example.com"}
409
- )
410
- self.assertEqual(resp.status_code, 200)
411
- self.assertEqual(len(mail.outbox), 1)
412
- link = re.search(r"http://testserver[\S]+", mail.outbox[0].body).group(0)
413
- resp = self.client.get(link)
414
- self.assertEqual(resp.status_code, 200)
415
- resp = self.client.post(link)
416
- self.user.refresh_from_db()
417
- self.assertTrue(self.user.is_active)
418
- self.assertIn("_auth_user_id", self.client.session)
419
-
420
- def test_request_invite_handles_email_errors(self):
421
- with patch("pages.views.mailer.send", side_effect=Exception("fail")):
422
- resp = self.client.post(
423
- reverse("pages:request-invite"), {"email": "invite@example.com"}
424
- )
425
- self.assertEqual(resp.status_code, 200)
426
- self.assertContains(resp, "If the email exists, an invitation has been sent.")
427
- lead = InviteLead.objects.get()
428
- self.assertIsNone(lead.sent_on)
429
- self.assertIn("fail", lead.error)
430
- self.assertIn("email service", lead.error)
431
- self.assertEqual(len(mail.outbox), 0)
432
-
433
- def test_request_invite_records_send_time(self):
434
- resp = self.client.post(
435
- reverse("pages:request-invite"), {"email": "invite@example.com"}
436
- )
437
- self.assertEqual(resp.status_code, 200)
438
- lead = InviteLead.objects.get()
439
- self.assertIsNotNone(lead.sent_on)
440
- self.assertEqual(lead.error, "")
441
- self.assertEqual(len(mail.outbox), 1)
442
-
443
- def test_request_invite_creates_lead_with_comment(self):
444
- resp = self.client.post(
445
- reverse("pages:request-invite"),
446
- {"email": "new@example.com", "comment": "Hello"},
447
- )
448
- self.assertEqual(resp.status_code, 200)
449
- lead = InviteLead.objects.get()
450
- self.assertEqual(lead.email, "new@example.com")
451
- self.assertEqual(lead.comment, "Hello")
452
- self.assertIsNone(lead.sent_on)
453
- self.assertEqual(lead.error, "")
454
- self.assertEqual(lead.mac_address, "")
455
- self.assertEqual(len(mail.outbox), 0)
456
-
457
- def test_request_invite_falls_back_to_send_mail(self):
458
- node = Node.objects.create(
459
- hostname="local", address="127.0.0.1", mac_address="00:11:22:33:44:55"
460
- )
461
- with (
462
- patch("pages.views.Node.get_local", return_value=node),
463
- patch.object(
464
- node, "send_mail", side_effect=Exception("node fail")
465
- ) as node_send,
466
- patch("pages.views.mailer.send", wraps=mailer.send) as fallback,
467
- ):
468
- resp = self.client.post(
469
- reverse("pages:request-invite"), {"email": "invite@example.com"}
470
- )
471
- self.assertEqual(resp.status_code, 200)
472
- lead = InviteLead.objects.get()
473
- self.assertIsNotNone(lead.sent_on)
474
- self.assertIn("node fail", lead.error)
475
- self.assertIn("default mail backend", lead.error)
476
- self.assertTrue(node_send.called)
477
- self.assertTrue(fallback.called)
478
- self.assertEqual(len(mail.outbox), 1)
479
-
480
- @patch(
481
- "pages.views.public_wifi.resolve_mac_address",
482
- return_value="aa:bb:cc:dd:ee:ff",
483
- )
484
- def test_request_invite_records_mac_address(self, mock_resolve):
485
- resp = self.client.post(
486
- reverse("pages:request-invite"), {"email": "invite@example.com"}
487
- )
488
- self.assertEqual(resp.status_code, 200)
489
- lead = InviteLead.objects.get()
490
- self.assertEqual(lead.mac_address, "aa:bb:cc:dd:ee:ff")
491
-
492
- @patch("pages.views.public_wifi.grant_public_access")
493
- @patch(
494
- "pages.views.public_wifi.resolve_mac_address",
495
- return_value="aa:bb:cc:dd:ee:ff",
496
- )
497
- def test_invitation_login_grants_public_wifi_access(self, mock_resolve, mock_grant):
498
- control_role, _ = NodeRole.objects.get_or_create(name="Control")
499
- feature = NodeFeature.objects.create(
500
- slug="ap-public-wifi", display="AP Public Wi-Fi"
501
- )
502
- feature.roles.add(control_role)
503
- node = Node.objects.create(
504
- hostname="control",
505
- address="127.0.0.1",
506
- mac_address=Node.get_current_mac(),
507
- role=control_role,
508
- )
509
- NodeFeatureAssignment.objects.create(node=node, feature=feature)
510
- with patch("pages.views.Node.get_local", return_value=node):
511
- resp = self.client.post(
512
- reverse("pages:request-invite"), {"email": "invite@example.com"}
513
- )
514
- self.assertEqual(resp.status_code, 200)
515
- link = re.search(r"http://testserver[\S]+", mail.outbox[0].body).group(0)
516
- with patch("pages.views.Node.get_local", return_value=node):
517
- resp = self.client.post(link)
518
- self.assertEqual(resp.status_code, 302)
519
- self.user.refresh_from_db()
520
- self.assertTrue(self.user.is_active)
521
- mock_grant.assert_called_once_with(self.user, "aa:bb:cc:dd:ee:ff")
522
-
523
-
524
- class NavbarBrandTests(TestCase):
525
- def setUp(self):
526
- self.client = Client()
527
- Site.objects.update_or_create(
528
- id=1, defaults={"name": "Terminal", "domain": "testserver"}
529
- )
530
-
531
- def test_site_name_displayed_when_known(self):
532
- resp = self.client.get(reverse("pages:index"))
533
- self.assertContains(resp, '<a class="navbar-brand" href="/">Terminal</a>')
534
-
535
- def test_default_brand_when_unknown(self):
536
- Site.objects.filter(id=1).update(domain="example.com")
537
- resp = self.client.get(reverse("pages:index"))
538
- self.assertContains(resp, '<a class="navbar-brand" href="/">Arthexis</a>')
539
-
540
- @override_settings(ALLOWED_HOSTS=["127.0.0.1", "testserver"])
541
- def test_brand_uses_role_name_when_site_name_blank(self):
542
- role, _ = NodeRole.objects.get_or_create(name="Terminal")
543
- Node.objects.update_or_create(
544
- mac_address=Node.get_current_mac(),
545
- defaults={
546
- "hostname": "localhost",
547
- "address": "127.0.0.1",
548
- "role": role,
549
- },
550
- )
551
- Site.objects.filter(id=1).update(name="", domain="127.0.0.1")
552
- resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1")
553
- self.assertEqual(resp.context["badge_site_name"], "Terminal")
554
- self.assertContains(resp, '<a class="navbar-brand" href="/">Terminal</a>')
555
-
556
-
557
- class AdminBadgesTests(TestCase):
558
- def setUp(self):
559
- self.client = Client()
560
- User = get_user_model()
561
- self.admin = User.objects.create_superuser(
562
- username="badge-admin", password="pwd", email="admin@example.com"
563
- )
564
- self.client.force_login(self.admin)
565
- Site.objects.update_or_create(
566
- id=1, defaults={"name": "test", "domain": "testserver"}
567
- )
568
- from nodes.models import Node
569
-
570
- self.node_hostname = "otherhost"
571
- self.node = Node.objects.create(
572
- hostname=self.node_hostname,
573
- address=socket.gethostbyname(socket.gethostname()),
574
- )
575
-
576
- def test_badges_show_site_and_node(self):
577
- resp = self.client.get(reverse("admin:index"))
578
- self.assertContains(resp, "SITE: test")
579
- self.assertContains(resp, f"NODE: {self.node_hostname}")
580
-
581
- def test_badges_show_node_role(self):
582
- from nodes.models import NodeRole
583
-
584
- role = NodeRole.objects.create(name="Dev")
585
- self.node.role = role
586
- self.node.save()
587
- resp = self.client.get(reverse("admin:index"))
588
- role_list = reverse("admin:nodes_noderole_changelist")
589
- role_change = reverse("admin:nodes_noderole_change", args=[role.pk])
590
- self.assertContains(resp, "ROLE: Dev")
591
- self.assertContains(resp, f'href="{role_list}"')
592
- self.assertContains(resp, f'href="{role_change}"')
593
-
594
- def test_badges_warn_when_node_missing(self):
595
- from nodes.models import Node
596
-
597
- Node.objects.all().delete()
598
- resp = self.client.get(reverse("admin:index"))
599
- self.assertContains(resp, "NODE: Unknown")
600
- self.assertContains(resp, "badge-unknown")
601
- self.assertContains(resp, "#6c757d")
602
-
603
- def test_badges_link_to_admin(self):
604
- resp = self.client.get(reverse("admin:index"))
605
- site_list = reverse("admin:pages_siteproxy_changelist")
606
- site_change = reverse("admin:pages_siteproxy_change", args=[1])
607
- node_list = reverse("admin:nodes_node_changelist")
608
- node_change = reverse("admin:nodes_node_change", args=[self.node.pk])
609
- self.assertContains(resp, f'href="{site_list}"')
610
- self.assertContains(resp, f'href="{site_change}"')
611
- self.assertContains(resp, f'href="{node_list}"')
612
- self.assertContains(resp, f'href="{node_change}"')
613
-
614
-
615
- class AdminDashboardAppListTests(TestCase):
616
- def setUp(self):
617
- self.client = Client()
618
- User = get_user_model()
619
- self.admin = User.objects.create_superuser(
620
- username="dashboard_admin", password="pwd", email="admin@example.com"
621
- )
622
- self.client.force_login(self.admin)
623
- Site.objects.update_or_create(
624
- id=1, defaults={"name": "test", "domain": "testserver"}
625
- )
626
- self.locks_dir = Path(settings.BASE_DIR) / "locks"
627
- self.locks_dir.mkdir(parents=True, exist_ok=True)
628
- self.celery_lock = self.locks_dir / "celery.lck"
629
- if self.celery_lock.exists():
630
- self.celery_lock.unlink()
631
- self.addCleanup(self._remove_celery_lock)
632
- self.node, _ = Node.objects.update_or_create(
633
- mac_address=Node.get_current_mac(),
634
- defaults={
635
- "hostname": socket.gethostname(),
636
- "address": socket.gethostbyname(socket.gethostname()),
637
- "base_path": settings.BASE_DIR,
638
- "port": 8000,
639
- },
640
- )
641
- self.node.features.clear()
642
-
643
- def _remove_celery_lock(self):
644
- try:
645
- self.celery_lock.unlink()
646
- except FileNotFoundError:
647
- pass
648
-
649
- def test_horologia_hidden_without_celery_feature(self):
650
- resp = self.client.get(reverse("admin:index"))
651
- self.assertNotContains(resp, "5. Horologia MODELS")
652
-
653
- def test_horologia_visible_with_celery_feature(self):
654
- feature = NodeFeature.objects.create(slug="celery-queue", display="Celery Queue")
655
- NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
656
- resp = self.client.get(reverse("admin:index"))
657
- self.assertContains(resp, "5. Horologia MODELS")
658
-
659
- def test_horologia_visible_with_celery_lock(self):
660
- self.celery_lock.write_text("")
661
- resp = self.client.get(reverse("admin:index"))
662
- self.assertContains(resp, "5. Horologia MODELS")
663
-
664
-
665
- class AdminSidebarTests(TestCase):
666
- def setUp(self):
667
- self.client = Client()
668
- User = get_user_model()
669
- self.admin = User.objects.create_superuser(
670
- username="sidebar_admin", password="pwd", email="admin@example.com"
671
- )
672
- self.client.force_login(self.admin)
673
- Site.objects.update_or_create(
674
- id=1, defaults={"name": "test", "domain": "testserver"}
675
- )
676
- from nodes.models import Node
677
-
678
- Node.objects.create(hostname="testserver", address="127.0.0.1")
679
-
680
- def test_sidebar_app_groups_collapsible_script_present(self):
681
- url = reverse("admin:nodes_node_changelist")
682
- resp = self.client.get(url)
683
- self.assertContains(resp, 'id="admin-collapsible-apps"')
684
-
685
-
686
- class ViewHistoryLoggingTests(TestCase):
687
- def setUp(self):
688
- self.client = Client()
689
- Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
690
-
691
- def test_successful_visit_creates_entry(self):
692
- resp = self.client.get(reverse("pages:index"))
693
- self.assertEqual(resp.status_code, 200)
694
- entry = ViewHistory.objects.order_by("-visited_at").first()
695
- self.assertIsNotNone(entry)
696
- self.assertEqual(entry.path, "/")
697
- self.assertEqual(entry.status_code, 200)
698
- self.assertEqual(entry.error_message, "")
699
-
700
- def test_error_visit_records_message(self):
701
- resp = self.client.get("/missing-page/")
702
- self.assertEqual(resp.status_code, 404)
703
- entry = (
704
- ViewHistory.objects.filter(path="/missing-page/")
705
- .order_by("-visited_at")
706
- .first()
707
- )
708
- self.assertIsNotNone(entry)
709
- self.assertEqual(entry.status_code, 404)
710
- self.assertNotEqual(entry.error_message, "")
711
-
712
- def test_debug_toolbar_requests_not_tracked(self):
713
- resp = self.client.get(reverse("pages:index"), {"djdt": "toolbar"})
714
- self.assertEqual(resp.status_code, 200)
715
- self.assertFalse(ViewHistory.objects.exists())
716
-
717
- def test_authenticated_user_last_visit_ip_updated(self):
718
- User = get_user_model()
719
- user = User.objects.create_user(
720
- username="history_user", password="pwd", email="history@example.com"
721
- )
722
- self.assertTrue(self.client.login(username="history_user", password="pwd"))
723
-
724
- resp = self.client.get(
725
- reverse("pages:index"),
726
- HTTP_X_FORWARDED_FOR="203.0.113.5",
727
- )
728
-
729
- self.assertEqual(resp.status_code, 200)
730
- user.refresh_from_db()
731
- self.assertEqual(user.last_visit_ip_address, "203.0.113.5")
732
-
733
-
734
- class ViewHistoryAdminTests(TestCase):
735
- def setUp(self):
736
- self.client = Client()
737
- User = get_user_model()
738
- self.admin = User.objects.create_superuser(
739
- username="history_admin", password="pwd", email="admin@example.com"
740
- )
741
- self.client.force_login(self.admin)
742
- Site.objects.update_or_create(
743
- id=1, defaults={"name": "test", "domain": "testserver"}
744
- )
745
-
746
- def _create_history(self, path: str, days_offset: int = 0, count: int = 1):
747
- for _ in range(count):
748
- entry = ViewHistory.objects.create(
749
- path=path,
750
- method="GET",
751
- status_code=200,
752
- status_text="OK",
753
- error_message="",
754
- view_name="pages:index",
755
- )
756
- if days_offset:
757
- entry.visited_at = timezone.now() - timedelta(days=days_offset)
758
- entry.save(update_fields=["visited_at"])
759
-
760
- def test_change_list_includes_graph_link(self):
761
- resp = self.client.get(reverse("admin:pages_viewhistory_changelist"))
762
- self.assertContains(resp, reverse("admin:pages_viewhistory_traffic_graph"))
763
- self.assertContains(resp, "Traffic graph")
764
-
765
- def test_graph_view_renders_canvas(self):
766
- resp = self.client.get(reverse("admin:pages_viewhistory_traffic_graph"))
767
- self.assertContains(resp, "viewhistory-chart")
768
- self.assertContains(resp, reverse("admin:pages_viewhistory_changelist"))
769
- self.assertContains(resp, static("core/vendor/chart.umd.min.js"))
770
-
771
- def test_graph_data_endpoint(self):
772
- ViewHistory.all_objects.all().delete()
773
- self._create_history("/", count=2)
774
- self._create_history("/about/", days_offset=1)
775
- url = reverse("admin:pages_viewhistory_traffic_data")
776
- resp = self.client.get(url)
777
- self.assertEqual(resp.status_code, 200)
778
- data = resp.json()
779
- self.assertIn("labels", data)
780
- self.assertIn("datasets", data)
781
- self.assertGreater(len(data["labels"]), 0)
782
- totals = {
783
- dataset["label"]: sum(dataset["data"]) for dataset in data["datasets"]
784
- }
785
- self.assertEqual(totals.get("/"), 2)
786
- self.assertEqual(totals.get("/about/"), 1)
787
-
788
- def test_graph_data_includes_late_evening_visits(self):
789
- target_date = date(2025, 9, 27)
790
- entry = ViewHistory.objects.create(
791
- path="/late/",
792
- method="GET",
793
- status_code=200,
794
- status_text="OK",
795
- error_message="",
796
- view_name="pages:index",
797
- )
798
- local_evening = datetime.combine(target_date, datetime_time(21, 30))
799
- aware_evening = timezone.make_aware(
800
- local_evening, timezone.get_current_timezone()
801
- )
802
- entry.visited_at = aware_evening.astimezone(datetime_timezone.utc)
803
- entry.save(update_fields=["visited_at"])
804
-
805
- url = reverse("admin:pages_viewhistory_traffic_data")
806
- with patch("pages.admin.timezone.localdate", return_value=target_date):
807
- resp = self.client.get(url)
808
- self.assertEqual(resp.status_code, 200)
809
- data = resp.json()
810
- totals = {
811
- dataset["label"]: sum(dataset["data"]) for dataset in data["datasets"]
812
- }
813
- self.assertEqual(totals.get("/late/"), 1)
814
-
815
- def test_graph_data_filters_using_datetime_range(self):
816
- admin_view = ViewHistoryAdmin(ViewHistory, admin.site)
817
- with patch.object(ViewHistory.objects, "filter") as mock_filter:
818
- mock_queryset = mock_filter.return_value
819
- mock_queryset.exists.return_value = False
820
- admin_view._build_chart_data()
821
-
822
- kwargs = mock_filter.call_args.kwargs
823
- self.assertIn("visited_at__gte", kwargs)
824
- self.assertIn("visited_at__lt", kwargs)
825
-
826
- def test_admin_index_displays_widget(self):
827
- resp = self.client.get(reverse("admin:index"))
828
- self.assertContains(resp, "viewhistory-mini-module")
829
- self.assertContains(resp, reverse("admin:pages_viewhistory_traffic_graph"))
830
- self.assertContains(resp, static("core/vendor/chart.umd.min.js"))
831
-
832
-
833
- class AdminModelStatusTests(TestCase):
834
- def setUp(self):
835
- self.client = Client()
836
- User = get_user_model()
837
- self.admin = User.objects.create_superuser(
838
- username="status_admin", password="pwd", email="admin@example.com"
839
- )
840
- self.client.force_login(self.admin)
841
- Site.objects.update_or_create(
842
- id=1, defaults={"name": "test", "domain": "testserver"}
843
- )
844
- from nodes.models import Node
845
-
846
- Node.objects.create(hostname="testserver", address="127.0.0.1")
847
-
848
- @patch("pages.templatetags.admin_extras.connection.introspection.table_names")
849
- def test_status_dots_render(self, mock_tables):
850
- from django.db import connection
851
-
852
- tables = type(connection.introspection).table_names(connection.introspection)
853
- mock_tables.return_value = [t for t in tables if t != "pages_module"]
854
- resp = self.client.get(reverse("admin:index"))
855
- self.assertContains(resp, 'class="model-status ok"')
856
- self.assertContains(resp, 'class="model-status missing"', count=1)
857
-
858
-
859
- class SiteAdminRegisterCurrentTests(TestCase):
860
- def setUp(self):
861
- self.client = Client()
862
- User = get_user_model()
863
- self.admin = User.objects.create_superuser(
864
- username="site-admin", password="pwd", email="admin@example.com"
865
- )
866
- self.client.force_login(self.admin)
867
- Site.objects.update_or_create(
868
- id=1, defaults={"name": "Constellation", "domain": "arthexis.com"}
869
- )
870
-
871
- def test_register_current_creates_site(self):
872
- resp = self.client.get(reverse("admin:pages_siteproxy_changelist"))
873
- self.assertContains(resp, "Register Current")
874
-
875
- resp = self.client.get(reverse("admin:pages_siteproxy_register_current"))
876
- self.assertRedirects(resp, reverse("admin:pages_siteproxy_changelist"))
877
- self.assertTrue(Site.objects.filter(domain="testserver").exists())
878
- site = Site.objects.get(domain="testserver")
879
- self.assertEqual(site.name, "testserver")
880
-
881
- @override_settings(ALLOWED_HOSTS=["127.0.0.1", "testserver"])
882
- def test_register_current_ip_sets_pages_name(self):
883
- resp = self.client.get(
884
- reverse("admin:pages_siteproxy_register_current"), HTTP_HOST="127.0.0.1"
885
- )
886
- self.assertRedirects(resp, reverse("admin:pages_siteproxy_changelist"))
887
- site = Site.objects.get(domain="127.0.0.1")
888
- self.assertEqual(site.name, "")
889
-
890
-
891
- class SiteAdminScreenshotTests(TestCase):
892
- def setUp(self):
893
- self.client = Client()
894
- User = get_user_model()
895
- self.admin = User.objects.create_superuser(
896
- username="screenshot-admin", password="pwd", email="admin@example.com"
897
- )
898
- self.client.force_login(self.admin)
899
- Site.objects.update_or_create(
900
- id=1, defaults={"name": "Terminal", "domain": "testserver"}
901
- )
902
- self.node = Node.objects.create(
903
- hostname="localhost",
904
- address="127.0.0.1",
905
- port=80,
906
- mac_address=Node.get_current_mac(),
907
- )
908
-
909
- @patch("pages.admin.capture_screenshot")
910
- def test_capture_screenshot_action(self, mock_capture):
911
- screenshot_dir = settings.LOG_DIR / "screenshots"
912
- screenshot_dir.mkdir(parents=True, exist_ok=True)
913
- file_path = screenshot_dir / "test.png"
914
- file_path.write_bytes(b"frontpage")
915
- mock_capture.return_value = Path("screenshots/test.png")
916
- url = reverse("admin:pages_siteproxy_changelist")
917
- response = self.client.post(
918
- url,
919
- {"action": "capture_screenshot", "_selected_action": [1]},
920
- follow=True,
921
- )
922
- self.assertEqual(response.status_code, 200)
923
- self.assertEqual(
924
- ContentSample.objects.filter(kind=ContentSample.IMAGE).count(), 1
925
- )
926
- screenshot = ContentSample.objects.filter(kind=ContentSample.IMAGE).first()
927
- self.assertEqual(screenshot.node, self.node)
928
- self.assertEqual(screenshot.path, "screenshots/test.png")
929
- self.assertEqual(screenshot.method, "ADMIN")
930
- link = reverse("admin:nodes_contentsample_change", args=[screenshot.pk])
931
- self.assertContains(response, link)
932
- mock_capture.assert_called_once_with("http://testserver/")
933
-
934
-
935
- class AdminBadgesWebsiteTests(TestCase):
936
- def setUp(self):
937
- self.client = Client()
938
- User = get_user_model()
939
- self.admin = User.objects.create_superuser(
940
- username="badge-admin2", password="pwd", email="admin@example.com"
941
- )
942
- self.client.force_login(self.admin)
943
- role, _ = NodeRole.objects.get_or_create(name="Terminal")
944
- Node.objects.update_or_create(
945
- mac_address=Node.get_current_mac(),
946
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
947
- )
948
- Site.objects.update_or_create(
949
- id=1, defaults={"name": "", "domain": "127.0.0.1"}
950
- )
951
-
952
- @override_settings(ALLOWED_HOSTS=["127.0.0.1", "testserver"])
953
- def test_badge_shows_domain_when_site_name_blank(self):
954
- resp = self.client.get(reverse("admin:index"), HTTP_HOST="127.0.0.1")
955
- self.assertContains(resp, "SITE: 127.0.0.1")
956
-
957
-
958
- class NavAppsTests(TestCase):
959
- def setUp(self):
960
- self.client = Client()
961
- role, _ = NodeRole.objects.get_or_create(name="Terminal")
962
- Node.objects.update_or_create(
963
- mac_address=Node.get_current_mac(),
964
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
965
- )
966
- Site.objects.update_or_create(
967
- id=1, defaults={"domain": "127.0.0.1", "name": ""}
968
- )
969
- app = Application.objects.create(name="Readme")
970
- Module.objects.create(
971
- node_role=role, application=app, path="/", is_default=True
972
- )
973
-
974
- def test_nav_pill_renders(self):
975
- resp = self.client.get(reverse("pages:index"))
976
- self.assertContains(resp, "README")
977
- self.assertContains(resp, "badge rounded-pill")
978
-
979
- def test_nav_pill_renders_with_port(self):
980
- resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8000")
981
- self.assertContains(resp, "README")
982
-
983
- def test_nav_pill_uses_menu_field(self):
984
- site_app = Module.objects.get()
985
- site_app.menu = "Docs"
986
- site_app.save()
987
- resp = self.client.get(reverse("pages:index"))
988
- self.assertContains(resp, 'badge rounded-pill text-bg-secondary">DOCS')
989
- self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">README')
990
-
991
- def test_app_without_root_url_excluded(self):
992
- role = NodeRole.objects.get(name="Terminal")
993
- app = Application.objects.create(name="core")
994
- Module.objects.create(node_role=role, application=app, path="/core/")
995
- resp = self.client.get(reverse("pages:index"))
996
- self.assertNotContains(resp, 'href="/core/"')
997
-
998
-
999
- class ConstellationNavTests(TestCase):
1000
- def setUp(self):
1001
- self.client = Client()
1002
- role, _ = NodeRole.objects.get_or_create(name="Constellation")
1003
- Node.objects.update_or_create(
1004
- mac_address=Node.get_current_mac(),
1005
- defaults={
1006
- "hostname": "localhost",
1007
- "address": "127.0.0.1",
1008
- "role": role,
1009
- },
1010
- )
1011
- Site.objects.update_or_create(
1012
- id=1, defaults={"domain": "testserver", "name": ""}
1013
- )
1014
- fixtures = [
1015
- Path(
1016
- settings.BASE_DIR,
1017
- "pages",
1018
- "fixtures",
1019
- "constellation__application_ocpp.json",
1020
- ),
1021
- Path(
1022
- settings.BASE_DIR,
1023
- "pages",
1024
- "fixtures",
1025
- "constellation__module_ocpp.json",
1026
- ),
1027
- Path(
1028
- settings.BASE_DIR,
1029
- "pages",
1030
- "fixtures",
1031
- "constellation__landing_ocpp_dashboard.json",
1032
- ),
1033
- Path(
1034
- settings.BASE_DIR,
1035
- "pages",
1036
- "fixtures",
1037
- "constellation__landing_ocpp_cp_simulator.json",
1038
- ),
1039
- Path(
1040
- settings.BASE_DIR,
1041
- "pages",
1042
- "fixtures",
1043
- "constellation__landing_ocpp_rfid.json",
1044
- ),
1045
- ]
1046
- call_command("loaddata", *map(str, fixtures))
1047
-
1048
- def test_rfid_pill_hidden(self):
1049
- resp = self.client.get(reverse("pages:index"))
1050
- nav_labels = [
1051
- module.menu_label.upper() for module in resp.context["nav_modules"]
1052
- ]
1053
- self.assertNotIn("RFID", nav_labels)
1054
- self.assertTrue(
1055
- Module.objects.filter(
1056
- path="/ocpp/", node_role__name="Constellation"
1057
- ).exists()
1058
- )
1059
- self.assertFalse(
1060
- Module.objects.filter(
1061
- path="/ocpp/rfid/",
1062
- node_role__name="Constellation",
1063
- is_deleted=False,
1064
- ).exists()
1065
- )
1066
- ocpp_module = next(
1067
- module
1068
- for module in resp.context["nav_modules"]
1069
- if module.menu_label.upper() == "CHARGERS"
1070
- )
1071
- landing_labels = [landing.label for landing in ocpp_module.enabled_landings]
1072
- self.assertIn("RFID Tag Validator", landing_labels)
1073
-
1074
- def test_ocpp_dashboard_visible(self):
1075
- resp = self.client.get(reverse("pages:index"))
1076
- self.assertContains(resp, 'href="/ocpp/"')
1077
-
1078
- class ControlNavTests(TestCase):
1079
- def setUp(self):
1080
- self.client = Client()
1081
- role, _ = NodeRole.objects.get_or_create(name="Control")
1082
- Node.objects.update_or_create(
1083
- mac_address=Node.get_current_mac(),
1084
- defaults={
1085
- "hostname": "localhost",
1086
- "address": "127.0.0.1",
1087
- "role": role,
1088
- },
1089
- )
1090
- Site.objects.update_or_create(
1091
- id=1, defaults={"domain": "testserver", "name": ""}
1092
- )
1093
- fixtures = [
1094
- Path(
1095
- settings.BASE_DIR,
1096
- "pages",
1097
- "fixtures",
1098
- "control__application_ocpp.json",
1099
- ),
1100
- Path(
1101
- settings.BASE_DIR,
1102
- "pages",
1103
- "fixtures",
1104
- "control__module_ocpp.json",
1105
- ),
1106
- Path(
1107
- settings.BASE_DIR,
1108
- "pages",
1109
- "fixtures",
1110
- "control__landing_ocpp_dashboard.json",
1111
- ),
1112
- Path(
1113
- settings.BASE_DIR,
1114
- "pages",
1115
- "fixtures",
1116
- "control__landing_ocpp_cp_simulator.json",
1117
- ),
1118
- Path(
1119
- settings.BASE_DIR,
1120
- "pages",
1121
- "fixtures",
1122
- "control__landing_ocpp_rfid.json",
1123
- ),
1124
- ]
1125
- call_command("loaddata", *map(str, fixtures))
1126
-
1127
- def test_ocpp_dashboard_visible(self):
1128
- user = get_user_model().objects.create_user("control", password="pw")
1129
- self.client.force_login(user)
1130
- resp = self.client.get(reverse("pages:index"))
1131
- self.assertEqual(resp.status_code, 200)
1132
- self.assertContains(resp, 'href="/ocpp/"')
1133
- self.assertContains(
1134
- resp, 'badge rounded-pill text-bg-secondary">CHARGERS'
1135
- )
1136
-
1137
- def test_header_links_visible_when_defined(self):
1138
- Reference.objects.create(
1139
- alt_text="Console",
1140
- value="https://example.com/console",
1141
- show_in_header=True,
1142
- )
1143
-
1144
- resp = self.client.get(reverse("pages:index"))
1145
-
1146
- self.assertIn("header_references", resp.context)
1147
- self.assertTrue(resp.context["header_references"])
1148
- self.assertContains(resp, "LINKS")
1149
- self.assertContains(resp, 'href="https://example.com/console"')
1150
-
1151
- def test_header_links_hidden_when_flag_false(self):
1152
- Reference.objects.create(
1153
- alt_text="Hidden",
1154
- value="https://example.com/hidden",
1155
- show_in_header=False,
1156
- )
1157
-
1158
- resp = self.client.get(reverse("pages:index"))
1159
-
1160
- self.assertIn("header_references", resp.context)
1161
- self.assertFalse(resp.context["header_references"])
1162
- self.assertNotContains(resp, "https://example.com/hidden")
1163
-
1164
-
1165
- class PowerNavTests(TestCase):
1166
- def setUp(self):
1167
- self.client = Client()
1168
- role, _ = NodeRole.objects.get_or_create(name="Terminal")
1169
- Node.objects.update_or_create(
1170
- mac_address=Node.get_current_mac(),
1171
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1172
- )
1173
- Site.objects.update_or_create(
1174
- id=1, defaults={"domain": "testserver", "name": ""}
1175
- )
1176
- awg_app, _ = Application.objects.get_or_create(name="awg")
1177
- awg_module, _ = Module.objects.get_or_create(
1178
- node_role=role, application=awg_app, path="/awg/"
1179
- )
1180
- awg_module.create_landings()
1181
- manuals_app, _ = Application.objects.get_or_create(name="pages")
1182
- man_module, _ = Module.objects.get_or_create(
1183
- node_role=role, application=manuals_app, path="/man/"
1184
- )
1185
- man_module.create_landings()
1186
- User = get_user_model()
1187
- self.user = User.objects.create_user("user", password="pw")
1188
-
1189
- def test_power_pill_lists_calculators(self):
1190
- resp = self.client.get(reverse("pages:index"))
1191
- power_module = None
1192
- for module in resp.context["nav_modules"]:
1193
- if module.path == "/awg/":
1194
- power_module = module
1195
- break
1196
- self.assertIsNotNone(power_module)
1197
- self.assertEqual(power_module.menu_label.upper(), "CALCULATE")
1198
- landing_labels = {landing.label for landing in power_module.enabled_landings}
1199
- self.assertIn("AWG Calculator", landing_labels)
1200
-
1201
- def test_manual_pill_label(self):
1202
- resp = self.client.get(reverse("pages:index"))
1203
- manuals_module = None
1204
- for module in resp.context["nav_modules"]:
1205
- if module.path == "/man/":
1206
- manuals_module = module
1207
- break
1208
- self.assertIsNotNone(manuals_module)
1209
- self.assertEqual(manuals_module.menu_label.upper(), "MANUAL")
1210
- landing_labels = {landing.label for landing in manuals_module.enabled_landings}
1211
- self.assertIn("Manuals", landing_labels)
1212
-
1213
- def test_energy_tariff_visible_when_logged_in(self):
1214
- self.client.force_login(self.user)
1215
- resp = self.client.get(reverse("pages:index"))
1216
- power_module = None
1217
- for module in resp.context["nav_modules"]:
1218
- if module.path == "/awg/":
1219
- power_module = module
1220
- break
1221
- self.assertIsNotNone(power_module)
1222
- landing_labels = {landing.label for landing in power_module.enabled_landings}
1223
- self.assertIn("AWG Calculator", landing_labels)
1224
- self.assertIn("Energy Tariff Calculator", landing_labels)
1225
-
1226
-
1227
- class StaffNavVisibilityTests(TestCase):
1228
- def setUp(self):
1229
- self.client = Client()
1230
- role, _ = NodeRole.objects.get_or_create(name="Terminal")
1231
- Node.objects.update_or_create(
1232
- mac_address=Node.get_current_mac(),
1233
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1234
- )
1235
- Site.objects.update_or_create(
1236
- id=1, defaults={"domain": "testserver", "name": ""}
1237
- )
1238
- app = Application.objects.create(name="ocpp")
1239
- Module.objects.create(node_role=role, application=app, path="/ocpp/")
1240
- User = get_user_model()
1241
- self.user = User.objects.create_user("user", password="pw")
1242
- self.staff = User.objects.create_user("staff", password="pw", is_staff=True)
1243
-
1244
- def test_nonstaff_pill_hidden(self):
1245
- self.client.login(username="user", password="pw")
1246
- resp = self.client.get(reverse("pages:index"))
1247
- self.assertContains(resp, 'href="/ocpp/"')
1248
-
1249
- def test_staff_sees_pill(self):
1250
- self.client.login(username="staff", password="pw")
1251
- resp = self.client.get(reverse("pages:index"))
1252
- self.assertContains(resp, 'href="/ocpp/"')
1253
-
1254
-
1255
- class ApplicationModelTests(TestCase):
1256
- def test_path_defaults_to_slugified_name(self):
1257
- role, _ = NodeRole.objects.get_or_create(name="Terminal")
1258
- Node.objects.update_or_create(
1259
- mac_address=Node.get_current_mac(),
1260
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1261
- )
1262
- Site.objects.update_or_create(
1263
- id=1, defaults={"domain": "testserver", "name": ""}
1264
- )
1265
- app = Application.objects.create(name="core")
1266
- site_app = Module.objects.create(node_role=role, application=app)
1267
- self.assertEqual(site_app.path, "/core/")
1268
-
1269
- def test_installed_flag_false_when_missing(self):
1270
- app = Application.objects.create(name="missing")
1271
- self.assertFalse(app.installed)
1272
-
1273
- def test_verbose_name_property(self):
1274
- app = Application.objects.create(name="ocpp")
1275
- config = django_apps.get_app_config("ocpp")
1276
- self.assertEqual(app.verbose_name, config.verbose_name)
1277
-
1278
-
1279
- class ApplicationAdminFormTests(TestCase):
1280
- def test_name_field_uses_local_apps(self):
1281
- admin_instance = ApplicationAdmin(Application, admin.site)
1282
- form = admin_instance.get_form(request=None)()
1283
- choices = [choice[0] for choice in form.fields["name"].choices]
1284
- self.assertIn("core", choices)
1285
-
1286
-
1287
- class ApplicationAdminDisplayTests(TestCase):
1288
- def setUp(self):
1289
- User = get_user_model()
1290
- self.admin = User.objects.create_superuser(
1291
- username="app-admin", password="pwd", email="admin@example.com"
1292
- )
1293
- self.client = Client()
1294
- self.client.force_login(self.admin)
1295
-
1296
- def test_changelist_shows_verbose_name(self):
1297
- Application.objects.create(name="ocpp")
1298
- resp = self.client.get(reverse("admin:pages_application_changelist"))
1299
- config = django_apps.get_app_config("ocpp")
1300
- self.assertContains(resp, config.verbose_name)
1301
-
1302
- def test_changelist_shows_description(self):
1303
- Application.objects.create(
1304
- name="awg", description="Power, Energy and Cost calculations."
1305
- )
1306
- resp = self.client.get(reverse("admin:pages_application_changelist"))
1307
- self.assertContains(resp, "Power, Energy and Cost calculations.")
1308
-
1309
-
1310
- class UserManualAdminFormTests(TestCase):
1311
- def setUp(self):
1312
- self.manual = UserManual.objects.create(
1313
- slug="manual-one",
1314
- title="Manual One",
1315
- description="Test manual",
1316
- languages="en",
1317
- content_html="<p>Manual</p>",
1318
- content_pdf=base64.b64encode(b"initial").decode("ascii"),
1319
- )
1320
-
1321
- def test_widget_uses_slug_for_download(self):
1322
- admin_instance = UserManualAdmin(UserManual, admin.site)
1323
- form_class = admin_instance.get_form(request=None, obj=self.manual)
1324
- form = form_class(instance=self.manual)
1325
- field = form.fields["content_pdf"]
1326
- self.assertEqual(field.widget.download_name, f"{self.manual.slug}.pdf")
1327
- self.assertEqual(field.widget.content_type, "application/pdf")
1328
-
1329
- def test_upload_encodes_content_pdf(self):
1330
- admin_instance = UserManualAdmin(UserManual, admin.site)
1331
- form_class = admin_instance.get_form(request=None, obj=self.manual)
1332
- payload = {
1333
- "slug": self.manual.slug,
1334
- "title": self.manual.title,
1335
- "description": self.manual.description,
1336
- "languages": self.manual.languages,
1337
- "content_html": self.manual.content_html,
1338
- }
1339
- upload = SimpleUploadedFile("manual.pdf", b"PDF data")
1340
- form = form_class(data=payload, files={"content_pdf": upload}, instance=self.manual)
1341
- self.assertTrue(form.is_valid(), form.errors.as_json())
1342
- self.assertEqual(
1343
- form.cleaned_data["content_pdf"],
1344
- base64.b64encode(b"PDF data").decode("ascii"),
1345
- )
1346
-
1347
- def test_initial_base64_preserved_without_upload(self):
1348
- admin_instance = UserManualAdmin(UserManual, admin.site)
1349
- form_class = admin_instance.get_form(request=None, obj=self.manual)
1350
- payload = {
1351
- "slug": self.manual.slug,
1352
- "title": self.manual.title,
1353
- "description": self.manual.description,
1354
- "languages": self.manual.languages,
1355
- "content_html": self.manual.content_html,
1356
- }
1357
- form = form_class(data=payload, files={}, instance=self.manual)
1358
- self.assertTrue(form.is_valid(), form.errors.as_json())
1359
- self.assertEqual(form.cleaned_data["content_pdf"], self.manual.content_pdf)
1360
-
1361
-
1362
- class LandingCreationTests(TestCase):
1363
- def setUp(self):
1364
- role, _ = NodeRole.objects.get_or_create(name="Terminal")
1365
- Node.objects.update_or_create(
1366
- mac_address=Node.get_current_mac(),
1367
- defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1368
- )
1369
- self.app, _ = Application.objects.get_or_create(name="pages")
1370
- Site.objects.update_or_create(
1371
- id=1, defaults={"domain": "testserver", "name": ""}
1372
- )
1373
- self.role = role
1374
-
1375
- def test_landings_created_on_module_creation(self):
1376
- module = Module.objects.create(
1377
- node_role=self.role, application=self.app, path="/"
1378
- )
1379
- self.assertTrue(module.landings.filter(path="/").exists())
1380
-
1381
-
1382
- class LandingFixtureTests(TestCase):
1383
- def test_constellation_fixture_loads_without_duplicates(self):
1384
- from glob import glob
1385
-
1386
- NodeRole.objects.get_or_create(name="Constellation")
1387
- fixtures = glob(
1388
- str(Path(settings.BASE_DIR, "pages", "fixtures", "constellation__*.json"))
1389
- )
1390
- fixtures = sorted(
1391
- fixtures,
1392
- key=lambda path: (
1393
- 0 if "__application_" in path else 1 if "__module_" in path else 2
1394
- ),
1395
- )
1396
- call_command("loaddata", *fixtures)
1397
- call_command("loaddata", *fixtures)
1398
- module = Module.objects.get(path="/ocpp/", node_role__name="Constellation")
1399
- module.create_landings()
1400
- self.assertEqual(module.landings.filter(path="/ocpp/rfid/").count(), 1)
1401
-
1402
-
1403
- class AllowedHostSubnetTests(TestCase):
1404
- def setUp(self):
1405
- self.client = Client()
1406
- Site.objects.update_or_create(
1407
- id=1, defaults={"domain": "testserver", "name": "pages"}
1408
- )
1409
-
1410
- @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "192.168.0.0/16"])
1411
- def test_private_network_hosts_allowed(self):
1412
- resp = self.client.get(reverse("pages:index"), HTTP_HOST="10.42.1.5")
1413
- self.assertEqual(resp.status_code, 200)
1414
- resp = self.client.get(reverse("pages:index"), HTTP_HOST="192.168.2.3")
1415
- self.assertEqual(resp.status_code, 200)
1416
-
1417
- @override_settings(ALLOWED_HOSTS=["10.42.0.0/16"])
1418
- def test_host_outside_subnets_disallowed(self):
1419
- resp = self.client.get(reverse("pages:index"), HTTP_HOST="11.0.0.1")
1420
- self.assertEqual(resp.status_code, 400)
1421
-
1422
-
1423
- class RFIDPageTests(TestCase):
1424
- def setUp(self):
1425
- self.client = Client()
1426
- Site.objects.update_or_create(
1427
- id=1, defaults={"domain": "testserver", "name": "pages"}
1428
- )
1429
- User = get_user_model()
1430
- self.user = User.objects.create_user("rfid-user", password="pwd")
1431
-
1432
- def test_page_redirects_when_anonymous(self):
1433
- resp = self.client.get(reverse("rfid-reader"))
1434
- self.assertEqual(resp.status_code, 302)
1435
- self.assertIn(reverse("pages:login"), resp.url)
1436
-
1437
- def test_page_renders_for_authenticated_user(self):
1438
- self.client.force_login(self.user)
1439
- resp = self.client.get(reverse("rfid-reader"))
1440
- self.assertContains(resp, "Scanner ready")
1441
-
1442
-
1443
- class FaviconTests(TestCase):
1444
- def setUp(self):
1445
- self.client = Client()
1446
- self.tmpdir = tempfile.mkdtemp()
1447
- self.addCleanup(shutil.rmtree, self.tmpdir)
1448
-
1449
- def _png(self, name):
1450
- data = base64.b64decode(
1451
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="
1452
- )
1453
- return SimpleUploadedFile(name, data, content_type="image/png")
1454
-
1455
- def test_site_app_favicon_preferred_over_site(self):
1456
- with override_settings(MEDIA_ROOT=self.tmpdir):
1457
- role, _ = NodeRole.objects.get_or_create(name="Terminal")
1458
- Node.objects.update_or_create(
1459
- mac_address=Node.get_current_mac(),
1460
- defaults={
1461
- "hostname": "localhost",
1462
- "address": "127.0.0.1",
1463
- "role": role,
1464
- },
1465
- )
1466
- site, _ = Site.objects.update_or_create(
1467
- id=1, defaults={"domain": "testserver", "name": ""}
1468
- )
1469
- SiteBadge.objects.create(
1470
- site=site, badge_color="#28a745", favicon=self._png("site.png")
1471
- )
1472
- app = Application.objects.create(name="readme")
1473
- Module.objects.create(
1474
- node_role=role,
1475
- application=app,
1476
- path="/",
1477
- is_default=True,
1478
- favicon=self._png("app.png"),
1479
- )
1480
- resp = self.client.get(reverse("pages:index"))
1481
- self.assertContains(resp, "app.png")
1482
-
1483
- def test_site_favicon_used_when_app_missing(self):
1484
- with override_settings(MEDIA_ROOT=self.tmpdir):
1485
- role, _ = NodeRole.objects.get_or_create(name="Terminal")
1486
- Node.objects.update_or_create(
1487
- mac_address=Node.get_current_mac(),
1488
- defaults={
1489
- "hostname": "localhost",
1490
- "address": "127.0.0.1",
1491
- "role": role,
1492
- },
1493
- )
1494
- site, _ = Site.objects.update_or_create(
1495
- id=1, defaults={"domain": "testserver", "name": ""}
1496
- )
1497
- SiteBadge.objects.create(
1498
- site=site, badge_color="#28a745", favicon=self._png("site.png")
1499
- )
1500
- app = Application.objects.create(name="readme")
1501
- Module.objects.create(
1502
- node_role=role, application=app, path="/", is_default=True
1503
- )
1504
- resp = self.client.get(reverse("pages:index"))
1505
- self.assertContains(resp, "site.png")
1506
-
1507
- def test_default_favicon_used_when_none_defined(self):
1508
- with override_settings(MEDIA_ROOT=self.tmpdir):
1509
- role, _ = NodeRole.objects.get_or_create(name="Terminal")
1510
- Node.objects.update_or_create(
1511
- mac_address=Node.get_current_mac(),
1512
- defaults={
1513
- "hostname": "localhost",
1514
- "address": "127.0.0.1",
1515
- "role": role,
1516
- },
1517
- )
1518
- Site.objects.update_or_create(
1519
- id=1, defaults={"domain": "testserver", "name": ""}
1520
- )
1521
- resp = self.client.get(reverse("pages:index"))
1522
- b64 = (
1523
- Path(settings.BASE_DIR)
1524
- .joinpath("pages", "fixtures", "data", "favicon.txt")
1525
- .read_text()
1526
- .strip()
1527
- )
1528
- self.assertContains(resp, b64)
1529
-
1530
- def test_control_nodes_use_purple_favicon(self):
1531
- with override_settings(MEDIA_ROOT=self.tmpdir):
1532
- role, _ = NodeRole.objects.get_or_create(name="Control")
1533
- Node.objects.update_or_create(
1534
- mac_address=Node.get_current_mac(),
1535
- defaults={
1536
- "hostname": "localhost",
1537
- "address": "127.0.0.1",
1538
- "role": role,
1539
- },
1540
- )
1541
- Site.objects.update_or_create(
1542
- id=1, defaults={"domain": "testserver", "name": ""}
1543
- )
1544
- resp = self.client.get(reverse("pages:index"))
1545
- b64 = (
1546
- Path(settings.BASE_DIR)
1547
- .joinpath("pages", "fixtures", "data", "favicon_control.txt")
1548
- .read_text()
1549
- .strip()
1550
- )
1551
- self.assertContains(resp, b64)
1552
-
1553
-
1554
- class FavoriteTests(TestCase):
1555
- def setUp(self):
1556
- self.client = Client()
1557
- User = get_user_model()
1558
- self.user = User.objects.create_superuser(
1559
- username="favadmin", password="pwd", email="fav@example.com"
1560
- )
1561
- ReleaseManager.objects.create(user=self.user)
1562
- self.client.force_login(self.user)
1563
- Site.objects.update_or_create(
1564
- id=1, defaults={"name": "test", "domain": "testserver"}
1565
- )
1566
- from nodes.models import Node, NodeRole
1567
-
1568
- terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
1569
- self.node, _ = Node.objects.update_or_create(
1570
- mac_address=Node.get_current_mac(),
1571
- defaults={
1572
- "hostname": "localhost",
1573
- "address": "127.0.0.1",
1574
- "role": terminal_role,
1575
- },
1576
- )
1577
- ContentType.objects.clear_cache()
1578
-
1579
- def test_add_favorite(self):
1580
- ct = ContentType.objects.get_by_natural_key("pages", "application")
1581
- next_url = reverse("admin:pages_application_changelist")
1582
- url = (
1583
- reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
1584
- )
1585
- resp = self.client.post(url, {"custom_label": "Apps", "user_data": "on"})
1586
- self.assertRedirects(resp, next_url)
1587
- fav = Favorite.objects.get(user=self.user, content_type=ct)
1588
- self.assertEqual(fav.custom_label, "Apps")
1589
- self.assertTrue(fav.user_data)
1590
-
1591
- def test_cancel_link_uses_next(self):
1592
- ct = ContentType.objects.get_by_natural_key("pages", "application")
1593
- next_url = reverse("admin:pages_application_changelist")
1594
- url = (
1595
- reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
1596
- )
1597
- resp = self.client.get(url)
1598
- self.assertContains(resp, f'href="{next_url}"')
1599
-
1600
- def test_existing_favorite_redirects_to_list(self):
1601
- ct = ContentType.objects.get_by_natural_key("pages", "application")
1602
- Favorite.objects.create(user=self.user, content_type=ct)
1603
- url = reverse("admin:favorite_toggle", args=[ct.id])
1604
- resp = self.client.get(url)
1605
- self.assertRedirects(resp, reverse("admin:favorite_list"))
1606
- resp = self.client.get(reverse("admin:favorite_list"))
1607
- self.assertContains(resp, ct.name)
1608
-
1609
- def test_update_user_data_from_list(self):
1610
- ct = ContentType.objects.get_by_natural_key("pages", "application")
1611
- fav = Favorite.objects.create(user=self.user, content_type=ct)
1612
- url = reverse("admin:favorite_list")
1613
- resp = self.client.post(url, {"user_data": [str(fav.pk)]})
1614
- self.assertRedirects(resp, url)
1615
- fav.refresh_from_db()
1616
- self.assertTrue(fav.user_data)
1617
-
1618
- def test_dashboard_includes_favorites_and_user_data(self):
1619
- fav_ct = ContentType.objects.get_by_natural_key("pages", "application")
1620
- Favorite.objects.create(
1621
- user=self.user, content_type=fav_ct, custom_label="Apps"
1622
- )
1623
- NodeRole.objects.create(name="DataRole", is_user_data=True)
1624
- resp = self.client.get(reverse("admin:index"))
1625
- self.assertContains(resp, reverse("admin:pages_application_changelist"))
1626
- self.assertContains(resp, reverse("admin:nodes_noderole_changelist"))
1627
-
1628
- def test_dashboard_merges_duplicate_future_actions(self):
1629
- ct = ContentType.objects.get_for_model(NodeRole)
1630
- Favorite.objects.create(user=self.user, content_type=ct)
1631
- NodeRole.objects.create(name="DataRole2", is_user_data=True)
1632
- AdminHistory.objects.create(
1633
- user=self.user,
1634
- content_type=ct,
1635
- url=reverse("admin:nodes_noderole_changelist"),
1636
- )
1637
- resp = self.client.get(reverse("admin:index"))
1638
- url = reverse("admin:nodes_noderole_changelist")
1639
- self.assertGreaterEqual(resp.content.decode().count(url), 1)
1640
- self.assertContains(resp, NodeRole._meta.verbose_name_plural)
1641
-
1642
- def test_dashboard_limits_future_actions_to_top_four(self):
1643
- from pages.templatetags.admin_extras import future_action_items
1644
-
1645
- role_ct = ContentType.objects.get_for_model(NodeRole)
1646
- role_url = reverse("admin:nodes_noderole_changelist")
1647
- AdminHistory.objects.create(
1648
- user=self.user,
1649
- content_type=role_ct,
1650
- url=role_url,
1651
- )
1652
- AdminHistory.objects.create(
1653
- user=self.user,
1654
- content_type=role_ct,
1655
- url=f"{role_url}?page=2",
1656
- )
1657
- AdminHistory.objects.create(
1658
- user=self.user,
1659
- content_type=role_ct,
1660
- url=f"{role_url}?page=3",
1661
- )
1662
-
1663
- app_ct = ContentType.objects.get_for_model(Application)
1664
- app_url = reverse("admin:pages_application_changelist")
1665
- AdminHistory.objects.create(
1666
- user=self.user,
1667
- content_type=app_ct,
1668
- url=app_url,
1669
- )
1670
- AdminHistory.objects.create(
1671
- user=self.user,
1672
- content_type=app_ct,
1673
- url=f"{app_url}?page=2",
1674
- )
1675
-
1676
- module_ct = ContentType.objects.get_for_model(Module)
1677
- module_url = reverse("admin:pages_module_changelist")
1678
- AdminHistory.objects.create(
1679
- user=self.user,
1680
- content_type=module_ct,
1681
- url=module_url,
1682
- )
1683
- AdminHistory.objects.create(
1684
- user=self.user,
1685
- content_type=module_ct,
1686
- url=f"{module_url}?page=2",
1687
- )
1688
-
1689
- package_ct = ContentType.objects.get_for_model(Package)
1690
- package_url = reverse("admin:core_package_changelist")
1691
- AdminHistory.objects.create(
1692
- user=self.user,
1693
- content_type=package_ct,
1694
- url=package_url,
1695
- )
1696
-
1697
- view_history_ct = ContentType.objects.get_for_model(ViewHistory)
1698
- view_history_url = reverse("admin:pages_viewhistory_changelist")
1699
- AdminHistory.objects.create(
1700
- user=self.user,
1701
- content_type=view_history_ct,
1702
- url=view_history_url,
1703
- )
1704
-
1705
- resp = self.client.get(reverse("admin:index"))
1706
- items = future_action_items({"request": resp.wsgi_request})["models"]
1707
- labels = {item["label"] for item in items}
1708
- self.assertEqual(len(items), 4)
1709
- self.assertIn("Node Roles", labels)
1710
- self.assertIn("Modules", labels)
1711
- self.assertIn("applications", labels)
1712
- self.assertIn("View Histories", labels)
1713
- self.assertNotIn("Packages", labels)
1714
- ContentType.objects.clear_cache()
1715
-
1716
- def test_favorite_ct_id_recreates_missing_content_type(self):
1717
- ct = ContentType.objects.get_by_natural_key("pages", "application")
1718
- ct.delete()
1719
- from pages.templatetags.favorites import favorite_ct_id
1720
-
1721
- new_id = favorite_ct_id("pages", "Application")
1722
- self.assertIsNotNone(new_id)
1723
- self.assertTrue(
1724
- ContentType.objects.filter(
1725
- pk=new_id, app_label="pages", model="application"
1726
- ).exists()
1727
- )
1728
-
1729
- def test_dashboard_uses_change_label(self):
1730
- ct = ContentType.objects.get_by_natural_key("pages", "application")
1731
- Favorite.objects.create(user=self.user, content_type=ct)
1732
- resp = self.client.get(reverse("admin:index"))
1733
- self.assertContains(resp, "Change Applications")
1734
- self.assertContains(resp, 'target="_blank" rel="noopener noreferrer"')
1735
-
1736
- def test_dashboard_links_to_focus_view(self):
1737
- todo = Todo.objects.create(request="Check docs", url="/docs/")
1738
- resp = self.client.get(reverse("admin:index"))
1739
- focus_url = reverse("todo-focus", args=[todo.pk])
1740
- expected_next = quote(reverse("admin:index"))
1741
- self.assertContains(
1742
- resp,
1743
- f'href="{focus_url}?next={expected_next}"',
1744
- )
1745
-
1746
- def test_dashboard_shows_todo_with_done_button(self):
1747
- todo = Todo.objects.create(request="Do thing")
1748
- resp = self.client.get(reverse("admin:index"))
1749
- done_url = reverse("todo-done", args=[todo.pk])
1750
- self.assertContains(resp, todo.request)
1751
- self.assertContains(resp, f'action="{done_url}"')
1752
- self.assertContains(resp, "DONE")
1753
-
1754
- def test_dashboard_shows_request_details(self):
1755
- Todo.objects.create(request="Do thing", request_details="More info")
1756
- resp = self.client.get(reverse("admin:index"))
1757
- self.assertContains(
1758
- resp, '<div class="todo-details">More info</div>', html=True
1759
- )
1760
-
1761
- def test_dashboard_excludes_todo_changelist_link(self):
1762
- ct = ContentType.objects.get_for_model(Todo)
1763
- Favorite.objects.create(user=self.user, content_type=ct)
1764
- AdminHistory.objects.create(
1765
- user=self.user,
1766
- content_type=ct,
1767
- url=reverse("admin:core_todo_changelist"),
1768
- )
1769
- Todo.objects.create(request="Task", is_user_data=True)
1770
- resp = self.client.get(reverse("admin:index"))
1771
- changelist = reverse("admin:core_todo_changelist")
1772
- self.assertNotContains(resp, f'href="{changelist}"')
1773
-
1774
- def test_dashboard_hides_todos_without_release_manager(self):
1775
- todo = Todo.objects.create(request="Only Release Manager")
1776
- User = get_user_model()
1777
- other_user = User.objects.create_superuser(
1778
- username="norole", password="pwd", email="norole@example.com"
1779
- )
1780
- self.client.force_login(other_user)
1781
- resp = self.client.get(reverse("admin:index"))
1782
- self.assertNotContains(resp, "Release manager tasks")
1783
- self.assertNotContains(resp, todo.request)
1784
-
1785
- def test_dashboard_hides_todos_for_non_terminal_node(self):
1786
- todo = Todo.objects.create(request="Terminal Tasks")
1787
- from nodes.models import NodeRole
1788
-
1789
- control_role, _ = NodeRole.objects.get_or_create(name="Control")
1790
- self.node.role = control_role
1791
- self.node.save(update_fields=["role"])
1792
- resp = self.client.get(reverse("admin:index"))
1793
- self.assertNotContains(resp, "Release manager tasks")
1794
- self.assertNotContains(resp, todo.request)
1795
-
1796
- def test_dashboard_shows_todos_for_delegate_release_manager(self):
1797
- todo = Todo.objects.create(request="Delegate Task")
1798
- User = get_user_model()
1799
- delegate = User.objects.create_superuser(
1800
- username="delegate",
1801
- password="pwd",
1802
- email="delegate@example.com",
1803
- )
1804
- ReleaseManager.objects.create(user=delegate)
1805
- operator = User.objects.create_superuser(
1806
- username="operator",
1807
- password="pwd",
1808
- email="operator@example.com",
1809
- )
1810
- operator.operate_as = delegate
1811
- operator.full_clean()
1812
- operator.save()
1813
- self.client.force_login(operator)
1814
- resp = self.client.get(reverse("admin:index"))
1815
- self.assertContains(resp, "Release manager tasks")
1816
- self.assertContains(resp, todo.request)
1817
-
1818
-
1819
- class AdminActionListTests(TestCase):
1820
- def setUp(self):
1821
- User = get_user_model()
1822
- User.objects.filter(username="action-admin").delete()
1823
- self.user = User.objects.create_superuser(
1824
- username="action-admin",
1825
- password="pwd",
1826
- email="action@example.com",
1827
- )
1828
- self.factory = RequestFactory()
1829
-
1830
- def test_profile_actions_available_without_selection(self):
1831
- from pages.templatetags.admin_extras import model_admin_actions
1832
-
1833
- request = self.factory.get("/")
1834
- request.user = self.user
1835
- context = {"request": request}
1836
-
1837
- registered = [
1838
- (model._meta.app_label, model._meta.object_name)
1839
- for model, admin_instance in admin.site._registry.items()
1840
- if isinstance(admin_instance, ProfileAdminMixin)
1841
- ]
1842
-
1843
- for app_label, object_name in registered:
1844
- with self.subTest(model=f"{app_label}.{object_name}"):
1845
- actions = model_admin_actions(context, app_label, object_name)
1846
- labels = {action["label"] for action in actions}
1847
- self.assertIn("Active Profile", labels)
1848
-
1849
-
1850
- class AdminModelGraphViewTests(TestCase):
1851
- def setUp(self):
1852
- self.client = Client()
1853
- User = get_user_model()
1854
- self.user = User.objects.create_user(
1855
- username="graph-staff", password="pwd", is_staff=True
1856
- )
1857
- Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
1858
- self.client.force_login(self.user)
1859
-
1860
- def _mock_graph(self):
1861
- fake_graph = Mock()
1862
- fake_graph.source = "digraph {}"
1863
- fake_graph.engine = "dot"
1864
-
1865
- def pipe_side_effect(*args, **kwargs):
1866
- fmt = kwargs.get("format") or (args[0] if args else None)
1867
- if fmt == "svg":
1868
- return '<svg xmlns="http://www.w3.org/2000/svg"></svg>'
1869
- if fmt == "pdf":
1870
- return b"%PDF-1.4 mock"
1871
- raise AssertionError(f"Unexpected format: {fmt}")
1872
-
1873
- fake_graph.pipe.side_effect = pipe_side_effect
1874
- return fake_graph
1875
-
1876
- def test_model_graph_renders_controls_and_download_link(self):
1877
- url = reverse("admin-model-graph", args=["pages"])
1878
- graph = self._mock_graph()
1879
- with (
1880
- patch("pages.views._build_model_graph", return_value=graph),
1881
- patch("pages.views.shutil.which", return_value="/usr/bin/dot"),
1882
- ):
1883
- response = self.client.get(url)
1884
-
1885
- self.assertEqual(response.status_code, 200)
1886
- self.assertContains(response, "data-model-graph")
1887
- self.assertContains(response, 'data-graph-action="zoom-in"')
1888
- self.assertContains(response, "Download PDF")
1889
- self.assertIn("?format=pdf", response.context_data["download_url"])
1890
- args, kwargs = graph.pipe.call_args
1891
- self.assertEqual(kwargs.get("format"), "svg")
1892
- self.assertEqual(kwargs.get("encoding"), "utf-8")
1893
-
1894
- def test_model_graph_pdf_download(self):
1895
- url = reverse("admin-model-graph", args=["pages"])
1896
- graph = self._mock_graph()
1897
- with (
1898
- patch("pages.views._build_model_graph", return_value=graph),
1899
- patch("pages.views.shutil.which", return_value="/usr/bin/dot"),
1900
- ):
1901
- response = self.client.get(url, {"format": "pdf"})
1902
-
1903
- self.assertEqual(response.status_code, 200)
1904
- self.assertEqual(response["Content-Type"], "application/pdf")
1905
- app_config = django_apps.get_app_config("pages")
1906
- expected_slug = slugify(app_config.verbose_name) or app_config.label
1907
- self.assertIn(
1908
- f"{expected_slug}-model-graph.pdf", response["Content-Disposition"]
1909
- )
1910
- self.assertEqual(response.content, b"%PDF-1.4 mock")
1911
- args, kwargs = graph.pipe.call_args
1912
- self.assertEqual(kwargs.get("format"), "pdf")
1913
-
1914
-
1915
- class DatasetteTests(TestCase):
1916
- def setUp(self):
1917
- self.client = Client()
1918
- User = get_user_model()
1919
- self.user = User.objects.create_user(username="ds", password="pwd")
1920
- Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
1921
-
1922
- def test_datasette_auth_endpoint(self):
1923
- resp = self.client.get(reverse("pages:datasette-auth"))
1924
- self.assertEqual(resp.status_code, 401)
1925
- self.client.force_login(self.user)
1926
- resp = self.client.get(reverse("pages:datasette-auth"))
1927
- self.assertEqual(resp.status_code, 200)
1928
-
1929
- def test_navbar_includes_datasette_when_enabled(self):
1930
- lock_dir = Path(settings.BASE_DIR) / "locks"
1931
- lock_dir.mkdir(exist_ok=True)
1932
- lock_file = lock_dir / "datasette.lck"
1933
- try:
1934
- lock_file.touch()
1935
- resp = self.client.get(reverse("pages:index"))
1936
- self.assertContains(resp, 'href="/data/"')
1937
- finally:
1938
- lock_file.unlink(missing_ok=True)
1939
-
1940
-
1941
- class UserStorySubmissionTests(TestCase):
1942
- def setUp(self):
1943
- self.client = Client()
1944
- self.url = reverse("pages:user-story-submit")
1945
- User = get_user_model()
1946
- self.user = User.objects.create_user(username="feedbacker", password="pwd")
1947
-
1948
- def test_authenticated_submission_defaults_to_username(self):
1949
- self.client.force_login(self.user)
1950
- response = self.client.post(
1951
- self.url,
1952
- {
1953
- "rating": 5,
1954
- "comments": "Loved the experience!",
1955
- "path": "/wizard/step-1/",
1956
- "take_screenshot": "1",
1957
- },
1958
- )
1959
- self.assertEqual(response.status_code, 200)
1960
- self.assertEqual(response.json(), {"success": True})
1961
- story = UserStory.objects.get()
1962
- self.assertEqual(story.name, "feedbacker")
1963
- self.assertEqual(story.rating, 5)
1964
- self.assertEqual(story.path, "/wizard/step-1/")
1965
- self.assertEqual(story.user, self.user)
1966
- self.assertEqual(story.owner, self.user)
1967
- self.assertTrue(story.is_user_data)
1968
- self.assertTrue(story.take_screenshot)
1969
-
1970
- def test_anonymous_submission_uses_provided_name(self):
1971
- response = self.client.post(
1972
- self.url,
1973
- {
1974
- "name": "Guest Reviewer",
1975
- "rating": 3,
1976
- "comments": "It was fine.",
1977
- "path": "/status/",
1978
- "take_screenshot": "on",
1979
- },
1980
- )
1981
- self.assertEqual(response.status_code, 200)
1982
- self.assertEqual(UserStory.objects.count(), 1)
1983
- story = UserStory.objects.get()
1984
- self.assertEqual(story.name, "Guest Reviewer")
1985
- self.assertIsNone(story.user)
1986
- self.assertIsNone(story.owner)
1987
- self.assertEqual(story.comments, "It was fine.")
1988
- self.assertTrue(story.take_screenshot)
1989
-
1990
- def test_invalid_rating_returns_errors(self):
1991
- response = self.client.post(
1992
- self.url,
1993
- {
1994
- "rating": 7,
1995
- "comments": "Way off the scale",
1996
- "path": "/feedback/",
1997
- "take_screenshot": "1",
1998
- },
1999
- )
2000
- self.assertEqual(response.status_code, 400)
2001
- data = response.json()
2002
- self.assertFalse(UserStory.objects.exists())
2003
- self.assertIn("rating", data.get("errors", {}))
2004
-
2005
- def test_anonymous_submission_without_name_uses_fallback(self):
2006
- response = self.client.post(
2007
- self.url,
2008
- {
2009
- "rating": 2,
2010
- "comments": "Could be better.",
2011
- "path": "/feedback/",
2012
- "take_screenshot": "1",
2013
- },
2014
- )
2015
- self.assertEqual(response.status_code, 200)
2016
- story = UserStory.objects.get()
2017
- self.assertEqual(story.name, "Anonymous")
2018
- self.assertIsNone(story.user)
2019
- self.assertIsNone(story.owner)
2020
- self.assertTrue(story.take_screenshot)
2021
-
2022
- def test_submission_without_screenshot_request(self):
2023
- response = self.client.post(
2024
- self.url,
2025
- {
2026
- "rating": 4,
2027
- "comments": "Skip the screenshot, please.",
2028
- "path": "/feedback/",
2029
- },
2030
- )
2031
- self.assertEqual(response.status_code, 200)
2032
- story = UserStory.objects.get()
2033
- self.assertFalse(story.take_screenshot)
2034
- self.assertIsNone(story.owner)
2035
-
2036
-
2037
- class UserStoryAdminActionTests(TestCase):
2038
- def setUp(self):
2039
- self.client = Client()
2040
- self.factory = RequestFactory()
2041
- User = get_user_model()
2042
- self.admin_user = User.objects.create_superuser(
2043
- username="admin",
2044
- email="admin@example.com",
2045
- password="pwd",
2046
- )
2047
- self.story = UserStory.objects.create(
2048
- path="/",
2049
- name="Feedback",
2050
- rating=4,
2051
- comments="Helpful notes",
2052
- take_screenshot=True,
2053
- )
2054
- self.admin = UserStoryAdmin(UserStory, admin.site)
2055
-
2056
- def _build_request(self):
2057
- request = self.factory.post("/admin/pages/userstory/")
2058
- request.user = self.admin_user
2059
- request.session = self.client.session
2060
- setattr(request, "_messages", FallbackStorage(request))
2061
- return request
2062
-
2063
- @patch("pages.models.github_issues.create_issue")
2064
- def test_create_github_issues_action_updates_issue_fields(self, mock_create_issue):
2065
- response = MagicMock()
2066
- response.json.return_value = {
2067
- "html_url": "https://github.com/example/repo/issues/123",
2068
- "number": 123,
2069
- }
2070
- mock_create_issue.return_value = response
2071
-
2072
- request = self._build_request()
2073
- queryset = UserStory.objects.filter(pk=self.story.pk)
2074
- self.admin.create_github_issues(request, queryset)
2075
-
2076
- self.story.refresh_from_db()
2077
- self.assertEqual(self.story.github_issue_number, 123)
2078
- self.assertEqual(
2079
- self.story.github_issue_url,
2080
- "https://github.com/example/repo/issues/123",
2081
- )
2082
-
2083
- mock_create_issue.assert_called_once()
2084
- args, kwargs = mock_create_issue.call_args
2085
- self.assertIn("Feedback for", args[0])
2086
- self.assertIn("**Rating:**", args[1])
2087
- self.assertEqual(kwargs.get("labels"), ["feedback"])
2088
- self.assertEqual(
2089
- kwargs.get("fingerprint"), f"user-story:{self.story.pk}"
2090
- )
2091
-
2092
- @patch("pages.models.github_issues.create_issue")
2093
- def test_create_github_issues_action_skips_existing_issue(self, mock_create_issue):
2094
- self.story.github_issue_url = "https://github.com/example/repo/issues/5"
2095
- self.story.github_issue_number = 5
2096
- self.story.save(update_fields=["github_issue_url", "github_issue_number"])
2097
-
2098
- request = self._build_request()
2099
- queryset = UserStory.objects.filter(pk=self.story.pk)
2100
- self.admin.create_github_issues(request, queryset)
2101
-
2102
- mock_create_issue.assert_not_called()
2103
-
2104
-
2105
- class ClientReportLiveUpdateTests(TestCase):
2106
- def setUp(self):
2107
- self.client = Client()
2108
-
2109
- def test_client_report_includes_interval(self):
2110
- resp = self.client.get(reverse("pages:client-report"))
2111
- self.assertEqual(resp.context["request"].live_update_interval, 5)
2112
- self.assertContains(resp, "setInterval(() => location.reload()")
2113
-
2114
-
2115
- class ScreenshotSpecInfrastructureTests(TestCase):
2116
- def test_runner_creates_outputs_and_cleans_old_samples(self):
2117
- spec = ScreenshotSpec(slug="spec-test", url="/")
2118
- with tempfile.TemporaryDirectory() as tmp:
2119
- temp_dir = Path(tmp)
2120
- screenshot_path = temp_dir / "source.png"
2121
- screenshot_path.write_bytes(b"fake")
2122
- ContentSample.objects.create(
2123
- kind=ContentSample.IMAGE,
2124
- path="old.png",
2125
- method="spec:old",
2126
- hash="old-hash",
2127
- )
2128
- ContentSample.objects.filter(hash="old-hash").update(
2129
- created_at=timezone.now() - timedelta(days=8)
2130
- )
2131
- with (
2132
- patch(
2133
- "pages.screenshot_specs.base.capture_screenshot",
2134
- return_value=screenshot_path,
2135
- ) as capture_mock,
2136
- patch(
2137
- "pages.screenshot_specs.base.save_screenshot", return_value=None
2138
- ) as save_mock,
2139
- ):
2140
- with ScreenshotSpecRunner(temp_dir) as runner:
2141
- result = runner.run(spec)
2142
- self.assertTrue(result.image_path.exists())
2143
- self.assertTrue(result.base64_path.exists())
2144
- self.assertEqual(ContentSample.objects.filter(hash="old-hash").count(), 0)
2145
- capture_mock.assert_called_once()
2146
- save_mock.assert_called_once_with(screenshot_path, method="spec:spec-test")
2147
-
2148
- def test_runner_respects_manual_reason(self):
2149
- spec = ScreenshotSpec(slug="manual-spec", url="/", manual_reason="hardware")
2150
- with tempfile.TemporaryDirectory() as tmp:
2151
- with ScreenshotSpecRunner(Path(tmp)) as runner:
2152
- with self.assertRaises(ScreenshotUnavailable):
2153
- runner.run(spec)
2154
-
2155
-
2156
- class CaptureUIScreenshotsCommandTests(TestCase):
2157
- def tearDown(self):
2158
- registry.unregister("manual-cmd")
2159
- registry.unregister("auto-cmd")
2160
-
2161
- def test_manual_spec_emits_warning(self):
2162
- spec = ScreenshotSpec(slug="manual-cmd", url="/", manual_reason="manual")
2163
- registry.register(spec)
2164
- out = StringIO()
2165
- call_command("capture_ui_screenshots", "--spec", spec.slug, stdout=out)
2166
- self.assertIn("Skipping manual screenshot", out.getvalue())
2167
-
2168
- def test_command_invokes_runner(self):
2169
- spec = ScreenshotSpec(slug="auto-cmd", url="/")
2170
- registry.register(spec)
2171
- with tempfile.TemporaryDirectory() as tmp:
2172
- tmp_path = Path(tmp)
2173
- image_path = tmp_path / "auto-cmd.png"
2174
- base64_path = tmp_path / "auto-cmd.base64"
2175
- image_path.write_bytes(b"fake")
2176
- base64_path.write_text("Zg==", encoding="utf-8")
2177
- runner = Mock()
2178
- runner.__enter__ = Mock(return_value=runner)
2179
- runner.__exit__ = Mock(return_value=None)
2180
- runner.run.return_value = SimpleNamespace(
2181
- image_path=image_path,
2182
- base64_path=base64_path,
2183
- sample=None,
2184
- )
2185
- with patch(
2186
- "pages.management.commands.capture_ui_screenshots.ScreenshotSpecRunner",
2187
- return_value=runner,
2188
- ) as runner_cls:
2189
- out = StringIO()
2190
- call_command(
2191
- "capture_ui_screenshots",
2192
- "--spec",
2193
- spec.slug,
2194
- "--output-dir",
2195
- tmp_path,
2196
- stdout=out,
2197
- )
2198
- runner_cls.assert_called_once()
2199
- runner.run.assert_called_once_with(spec)
2200
- self.assertIn("Captured 'auto-cmd'", out.getvalue())
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, SimpleTestCase, override_settings
10
+ from django.urls import reverse
11
+ from django.templatetags.static import static
12
+ from urllib.parse import quote
13
+ from django.contrib.auth import get_user_model
14
+ from django.contrib.sites.models import Site
15
+ from django.contrib import admin
16
+ from django.contrib.messages.storage.fallback import FallbackStorage
17
+ from django.core.exceptions import DisallowedHost
18
+ import socket
19
+ from pages.models import (
20
+ Application,
21
+ Landing,
22
+ Module,
23
+ RoleLanding,
24
+ SiteBadge,
25
+ Favorite,
26
+ ViewHistory,
27
+ LandingLead,
28
+ UserManual,
29
+ UserStory,
30
+ )
31
+ from pages.admin import (
32
+ ApplicationAdmin,
33
+ UserManualAdmin,
34
+ UserStoryAdmin,
35
+ ViewHistoryAdmin,
36
+ log_viewer,
37
+ )
38
+ from pages.screenshot_specs import (
39
+ ScreenshotSpec,
40
+ ScreenshotSpecRunner,
41
+ ScreenshotUnavailable,
42
+ registry,
43
+ )
44
+ from django.apps import apps as django_apps
45
+ from core import mailer
46
+ from core.admin import ProfileAdminMixin
47
+ from core.models import (
48
+ AdminHistory,
49
+ InviteLead,
50
+ Package,
51
+ Reference,
52
+ RFID,
53
+ ReleaseManager,
54
+ SecurityGroup,
55
+ Todo,
56
+ TOTPDeviceSettings,
57
+ )
58
+ from django.core.files.uploadedfile import SimpleUploadedFile
59
+ import base64
60
+ import tempfile
61
+ import shutil
62
+ from io import StringIO
63
+ from django.conf import settings
64
+ from pathlib import Path
65
+ from unittest.mock import MagicMock, Mock, patch
66
+ from types import SimpleNamespace
67
+ from django.core.management import call_command
68
+ import re
69
+ from django.contrib.contenttypes.models import ContentType
70
+ from datetime import (
71
+ date,
72
+ datetime,
73
+ time as datetime_time,
74
+ timedelta,
75
+ timezone as datetime_timezone,
76
+ )
77
+ from django.core import mail
78
+ from django.utils import timezone
79
+ from django.utils.text import slugify
80
+ from django.utils.translation import gettext
81
+ from django_otp import DEVICE_ID_SESSION_KEY
82
+ from django_otp.oath import TOTP
83
+ from django_otp.plugins.otp_totp.models import TOTPDevice
84
+ from core.backends import TOTP_DEVICE_NAME
85
+ import time
86
+
87
+ from nodes.models import (
88
+ EmailOutbox,
89
+ Node,
90
+ ContentSample,
91
+ NodeRole,
92
+ NodeFeature,
93
+ NodeFeatureAssignment,
94
+ )
95
+
96
+
97
+ class LoginViewTests(TestCase):
98
+ def setUp(self):
99
+ self.client = Client()
100
+ User = get_user_model()
101
+ self.staff = User.objects.create_user(
102
+ username="staff", password="pwd", is_staff=True
103
+ )
104
+ self.user = User.objects.create_user(username="user", password="pwd")
105
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
106
+
107
+ def test_login_link_in_navbar(self):
108
+ resp = self.client.get(reverse("pages:index"))
109
+ self.assertContains(resp, 'href="/login/"')
110
+
111
+ def test_login_page_shows_authenticator_toggle(self):
112
+ resp = self.client.get(reverse("pages:login"))
113
+ self.assertContains(resp, "Use Authenticator app")
114
+
115
+ def test_cp_simulator_redirect_shows_restricted_message(self):
116
+ simulator_path = reverse("cp-simulator")
117
+ resp = self.client.get(f"{reverse('pages:login')}?next={simulator_path}")
118
+ self.assertContains(
119
+ resp,
120
+ "This page is reserved for members only. Please log in to continue.",
121
+ )
122
+
123
+ def test_staff_login_redirects_admin(self):
124
+ resp = self.client.post(
125
+ reverse("pages:login"),
126
+ {"username": "staff", "password": "pwd"},
127
+ )
128
+ self.assertRedirects(resp, reverse("admin:index"))
129
+
130
+ def test_login_with_authenticator_code(self):
131
+ device = TOTPDevice.objects.create(
132
+ user=self.staff,
133
+ name=TOTP_DEVICE_NAME,
134
+ confirmed=True,
135
+ )
136
+ totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift)
137
+ totp.time = time.time()
138
+ token = f"{totp.token():0{device.digits}d}"
139
+
140
+ resp = self.client.post(
141
+ reverse("pages:login"),
142
+ {
143
+ "username": "staff",
144
+ "auth_method": "otp",
145
+ "otp_token": token,
146
+ },
147
+ )
148
+
149
+ self.assertRedirects(resp, reverse("admin:index"))
150
+ session = self.client.session
151
+ self.assertIn(DEVICE_ID_SESSION_KEY, session)
152
+ self.assertEqual(session[DEVICE_ID_SESSION_KEY], device.persistent_id)
153
+
154
+ def test_login_with_invalid_authenticator_code(self):
155
+ TOTPDevice.objects.create(
156
+ user=self.staff,
157
+ name=TOTP_DEVICE_NAME,
158
+ confirmed=True,
159
+ )
160
+
161
+ resp = self.client.post(
162
+ reverse("pages:login"),
163
+ {
164
+ "username": "staff",
165
+ "auth_method": "otp",
166
+ "otp_token": "000000",
167
+ },
168
+ )
169
+
170
+ self.assertEqual(resp.status_code, 200)
171
+ self.assertContains(resp, "authenticator code is invalid", status_code=200)
172
+
173
+ def test_already_logged_in_staff_redirects(self):
174
+ self.client.force_login(self.staff)
175
+ resp = self.client.get(reverse("pages:login"))
176
+ self.assertRedirects(resp, reverse("admin:index"))
177
+
178
+ def test_regular_user_redirects_next(self):
179
+ resp = self.client.post(
180
+ reverse("pages:login") + "?next=/nodes/list/",
181
+ {"username": "user", "password": "pwd"},
182
+ )
183
+ self.assertRedirects(resp, "/nodes/list/")
184
+
185
+ def test_staff_redirects_next_when_specified(self):
186
+ resp = self.client.post(
187
+ reverse("pages:login") + "?next=/nodes/list/",
188
+ {"username": "staff", "password": "pwd"},
189
+ )
190
+ self.assertRedirects(resp, "/nodes/list/")
191
+
192
+
193
+
194
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.dummy.EmailBackend")
195
+ def test_login_page_hides_request_link_without_email_backend(self):
196
+ resp = self.client.get(reverse("pages:login"))
197
+ self.assertFalse(resp.context["can_request_invite"])
198
+ self.assertNotContains(resp, reverse("pages:request-invite"))
199
+
200
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.dummy.EmailBackend")
201
+ def test_login_page_shows_request_link_when_outbox_configured(self):
202
+ EmailOutbox.objects.create(host="smtp.example.com")
203
+ resp = self.client.get(reverse("pages:login"))
204
+ self.assertTrue(resp.context["can_request_invite"])
205
+ self.assertContains(resp, reverse("pages:request-invite"))
206
+
207
+ @override_settings(ALLOWED_HOSTS=["gway-qk32000"])
208
+ def test_login_allows_forwarded_https_origin(self):
209
+ secure_client = Client(enforce_csrf_checks=True)
210
+ login_url = reverse("pages:login")
211
+ response = secure_client.get(login_url, HTTP_HOST="gway-qk32000")
212
+ csrf_cookie = response.cookies["csrftoken"].value
213
+ submit = secure_client.post(
214
+ login_url,
215
+ {
216
+ "username": "staff",
217
+ "password": "pwd",
218
+ "csrfmiddlewaretoken": csrf_cookie,
219
+ },
220
+ HTTP_HOST="gway-qk32000",
221
+ HTTP_ORIGIN="https://gway-qk32000",
222
+ HTTP_X_FORWARDED_PROTO="https",
223
+ HTTP_REFERER="https://gway-qk32000/login/",
224
+ )
225
+ self.assertRedirects(submit, reverse("admin:index"))
226
+
227
+ @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "gway-qk32000"])
228
+ def test_login_allows_forwarded_origin_with_private_host_header(self):
229
+ secure_client = Client(enforce_csrf_checks=True)
230
+ login_url = reverse("pages:login")
231
+ response = secure_client.get(login_url, HTTP_HOST="10.42.0.2")
232
+ csrf_cookie = response.cookies["csrftoken"].value
233
+ submit = secure_client.post(
234
+ login_url,
235
+ {
236
+ "username": "staff",
237
+ "password": "pwd",
238
+ "csrfmiddlewaretoken": csrf_cookie,
239
+ },
240
+ HTTP_HOST="10.42.0.2",
241
+ HTTP_ORIGIN="https://gway-qk32000",
242
+ HTTP_X_FORWARDED_PROTO="https",
243
+ HTTP_X_FORWARDED_HOST="gway-qk32000",
244
+ HTTP_REFERER="https://gway-qk32000/login/",
245
+ )
246
+ self.assertRedirects(submit, reverse("admin:index"))
247
+
248
+ @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "gway-qk32000"])
249
+ def test_login_allows_forwarded_header_host_and_proto(self):
250
+ secure_client = Client(enforce_csrf_checks=True)
251
+ login_url = reverse("pages:login")
252
+ response = secure_client.get(login_url, HTTP_HOST="10.42.0.2")
253
+ csrf_cookie = response.cookies["csrftoken"].value
254
+ submit = secure_client.post(
255
+ login_url,
256
+ {
257
+ "username": "staff",
258
+ "password": "pwd",
259
+ "csrfmiddlewaretoken": csrf_cookie,
260
+ },
261
+ HTTP_HOST="10.42.0.2",
262
+ HTTP_ORIGIN="https://gway-qk32000",
263
+ HTTP_FORWARDED="proto=https;host=gway-qk32000",
264
+ HTTP_REFERER="https://gway-qk32000/login/",
265
+ )
266
+ self.assertRedirects(submit, reverse("admin:index"))
267
+
268
+ @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "gway-qk32000"])
269
+ def test_login_allows_forwarded_referer_without_origin(self):
270
+ secure_client = Client(enforce_csrf_checks=True)
271
+ login_url = reverse("pages:login")
272
+ response = secure_client.get(login_url, HTTP_HOST="10.42.0.2")
273
+ csrf_cookie = response.cookies["csrftoken"].value
274
+ submit = secure_client.post(
275
+ login_url,
276
+ {
277
+ "username": "staff",
278
+ "password": "pwd",
279
+ "csrfmiddlewaretoken": csrf_cookie,
280
+ },
281
+ HTTP_HOST="10.42.0.2",
282
+ HTTP_X_FORWARDED_PROTO="https",
283
+ HTTP_X_FORWARDED_HOST="gway-qk32000",
284
+ HTTP_REFERER="https://gway-qk32000/login/",
285
+ )
286
+ self.assertRedirects(submit, reverse("admin:index"))
287
+
288
+ @override_settings(ALLOWED_HOSTS=["gway-qk32000"])
289
+ def test_login_allows_forwarded_origin_with_explicit_port(self):
290
+ secure_client = Client(enforce_csrf_checks=True)
291
+ login_url = reverse("pages:login")
292
+ response = secure_client.get(login_url, HTTP_HOST="gway-qk32000")
293
+ csrf_cookie = response.cookies["csrftoken"].value
294
+ submit = secure_client.post(
295
+ login_url,
296
+ {
297
+ "username": "staff",
298
+ "password": "pwd",
299
+ "csrfmiddlewaretoken": csrf_cookie,
300
+ },
301
+ HTTP_HOST="gway-qk32000",
302
+ HTTP_ORIGIN="https://gway-qk32000:4443",
303
+ HTTP_X_FORWARDED_PROTO="https",
304
+ HTTP_X_FORWARDED_HOST="gway-qk32000:4443",
305
+ HTTP_REFERER="https://gway-qk32000:4443/login/",
306
+ )
307
+ self.assertRedirects(submit, reverse("admin:index"))
308
+
309
+
310
+ class AuthenticatorSetupTests(TestCase):
311
+ def setUp(self):
312
+ self.client = Client()
313
+ User = get_user_model()
314
+ self.staff = User.objects.create_user(
315
+ username="staffer", password="pwd", is_staff=True
316
+ )
317
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
318
+ self.client.force_login(self.staff)
319
+
320
+ def _current_token(self, device):
321
+ totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift)
322
+ totp.time = time.time()
323
+ return f"{totp.token():0{device.digits}d}"
324
+
325
+ def test_generate_creates_pending_device(self):
326
+ resp = self.client.post(
327
+ reverse("pages:authenticator-setup"), {"action": "generate"}
328
+ )
329
+ self.assertRedirects(resp, reverse("pages:authenticator-setup"))
330
+ device = TOTPDevice.objects.get(user=self.staff)
331
+ self.assertFalse(device.confirmed)
332
+ self.assertEqual(device.name, TOTP_DEVICE_NAME)
333
+
334
+ def test_device_config_url_includes_issuer_prefix(self):
335
+ self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
336
+ device = TOTPDevice.objects.get(user=self.staff)
337
+ config_url = device.config_url
338
+ label = quote(f"{settings.OTP_TOTP_ISSUER}:{self.staff.username}")
339
+ self.assertIn(label, config_url)
340
+ self.assertIn(f"issuer={quote(settings.OTP_TOTP_ISSUER)}", config_url)
341
+
342
+ def test_device_config_url_uses_custom_issuer_when_available(self):
343
+ self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
344
+ device = TOTPDevice.objects.get(user=self.staff)
345
+ TOTPDeviceSettings.objects.create(device=device, issuer="Custom Co")
346
+ config_url = device.config_url
347
+ quoted_issuer = quote("Custom Co")
348
+ self.assertIn(quoted_issuer, config_url)
349
+ self.assertIn(f"issuer={quoted_issuer}", config_url)
350
+
351
+ def test_pending_device_context_includes_qr(self):
352
+ self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
353
+ resp = self.client.get(reverse("pages:authenticator-setup"))
354
+ self.assertEqual(resp.status_code, 200)
355
+ self.assertTrue(resp.context["qr_data_uri"].startswith("data:image/png;base64,"))
356
+ self.assertTrue(resp.context["manual_key"])
357
+
358
+ def test_confirm_pending_device(self):
359
+ self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
360
+ device = TOTPDevice.objects.get(user=self.staff)
361
+ token = self._current_token(device)
362
+ resp = self.client.post(
363
+ reverse("pages:authenticator-setup"),
364
+ {"action": "confirm", "token": token},
365
+ )
366
+ self.assertRedirects(resp, reverse("pages:authenticator-setup"))
367
+ device.refresh_from_db()
368
+ self.assertTrue(device.confirmed)
369
+
370
+ def test_remove_device(self):
371
+ self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
372
+ device = TOTPDevice.objects.get(user=self.staff)
373
+ token = self._current_token(device)
374
+ self.client.post(
375
+ reverse("pages:authenticator-setup"),
376
+ {"action": "confirm", "token": token},
377
+ )
378
+ resp = self.client.post(
379
+ reverse("pages:authenticator-setup"), {"action": "remove"}
380
+ )
381
+ self.assertRedirects(resp, reverse("pages:authenticator-setup"))
382
+ self.assertFalse(TOTPDevice.objects.filter(user=self.staff).exists())
383
+
384
+ @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
385
+ class InvitationTests(TestCase):
386
+ def setUp(self):
387
+ self.client = Client()
388
+ User = get_user_model()
389
+ self.user = User.objects.create_user(
390
+ username="invited",
391
+ email="invite@example.com",
392
+ is_active=False,
393
+ )
394
+ self.user.set_unusable_password()
395
+ self.user.save()
396
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
397
+
398
+ def test_login_page_has_request_link(self):
399
+ resp = self.client.get(reverse("pages:login"))
400
+ self.assertContains(resp, reverse("pages:request-invite"))
401
+
402
+ def test_request_invite_sets_csrf_cookie(self):
403
+ resp = self.client.get(reverse("pages:request-invite"))
404
+ self.assertIn("csrftoken", resp.cookies)
405
+
406
+ def test_request_invite_allows_post_without_csrf(self):
407
+ client = Client(enforce_csrf_checks=True)
408
+ resp = client.post(
409
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
410
+ )
411
+ self.assertEqual(resp.status_code, 200)
412
+
413
+ def test_invitation_flow(self):
414
+ resp = self.client.post(
415
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
416
+ )
417
+ self.assertEqual(resp.status_code, 200)
418
+ self.assertEqual(len(mail.outbox), 1)
419
+ link = re.search(r"http://testserver[\S]+", mail.outbox[0].body).group(0)
420
+ resp = self.client.get(link)
421
+ self.assertEqual(resp.status_code, 200)
422
+ resp = self.client.post(link)
423
+ self.user.refresh_from_db()
424
+ self.assertTrue(self.user.is_active)
425
+ self.assertIn("_auth_user_id", self.client.session)
426
+
427
+ def test_request_invite_handles_email_errors(self):
428
+ with patch("pages.views.mailer.send", side_effect=Exception("fail")):
429
+ resp = self.client.post(
430
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
431
+ )
432
+ self.assertEqual(resp.status_code, 200)
433
+ self.assertContains(resp, "If the email exists, an invitation has been sent.")
434
+ lead = InviteLead.objects.get()
435
+ self.assertIsNone(lead.sent_on)
436
+ self.assertIn("fail", lead.error)
437
+ self.assertIn("email service", lead.error)
438
+ self.assertEqual(len(mail.outbox), 0)
439
+
440
+ def test_request_invite_records_send_time(self):
441
+ resp = self.client.post(
442
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
443
+ )
444
+ self.assertEqual(resp.status_code, 200)
445
+ lead = InviteLead.objects.get()
446
+ self.assertIsNotNone(lead.sent_on)
447
+ self.assertEqual(lead.error, "")
448
+ self.assertEqual(len(mail.outbox), 1)
449
+
450
+ def test_request_invite_creates_lead_with_comment(self):
451
+ resp = self.client.post(
452
+ reverse("pages:request-invite"),
453
+ {"email": "new@example.com", "comment": "Hello"},
454
+ )
455
+ self.assertEqual(resp.status_code, 200)
456
+ lead = InviteLead.objects.get()
457
+ self.assertEqual(lead.email, "new@example.com")
458
+ self.assertEqual(lead.comment, "Hello")
459
+ self.assertIsNone(lead.sent_on)
460
+ self.assertEqual(lead.error, "")
461
+ self.assertEqual(lead.mac_address, "")
462
+ self.assertEqual(len(mail.outbox), 0)
463
+
464
+ def test_request_invite_falls_back_to_send_mail(self):
465
+ node = Node.objects.create(
466
+ hostname="local", address="127.0.0.1", mac_address="00:11:22:33:44:55"
467
+ )
468
+ with (
469
+ patch("pages.views.Node.get_local", return_value=node),
470
+ patch.object(
471
+ node, "send_mail", side_effect=Exception("node fail")
472
+ ) as node_send,
473
+ patch("pages.views.mailer.send", wraps=mailer.send) as fallback,
474
+ ):
475
+ resp = self.client.post(
476
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
477
+ )
478
+ self.assertEqual(resp.status_code, 200)
479
+ lead = InviteLead.objects.get()
480
+ self.assertIsNotNone(lead.sent_on)
481
+ self.assertIn("node fail", lead.error)
482
+ self.assertIn("default mail backend", lead.error)
483
+ self.assertTrue(node_send.called)
484
+ self.assertTrue(fallback.called)
485
+ self.assertEqual(len(mail.outbox), 1)
486
+
487
+ @patch(
488
+ "pages.views.public_wifi.resolve_mac_address",
489
+ return_value="aa:bb:cc:dd:ee:ff",
490
+ )
491
+ def test_request_invite_records_mac_address(self, mock_resolve):
492
+ resp = self.client.post(
493
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
494
+ )
495
+ self.assertEqual(resp.status_code, 200)
496
+ lead = InviteLead.objects.get()
497
+ self.assertEqual(lead.mac_address, "aa:bb:cc:dd:ee:ff")
498
+
499
+ @patch("pages.views.public_wifi.grant_public_access")
500
+ @patch(
501
+ "pages.views.public_wifi.resolve_mac_address",
502
+ return_value="aa:bb:cc:dd:ee:ff",
503
+ )
504
+ def test_invitation_login_grants_public_wifi_access(self, mock_resolve, mock_grant):
505
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
506
+ feature = NodeFeature.objects.create(slug="ap-router", display="AP Router")
507
+ feature.roles.add(control_role)
508
+ node = Node.objects.create(
509
+ hostname="control",
510
+ address="127.0.0.1",
511
+ mac_address=Node.get_current_mac(),
512
+ role=control_role,
513
+ )
514
+ NodeFeatureAssignment.objects.create(node=node, feature=feature)
515
+ with patch("pages.views.Node.get_local", return_value=node):
516
+ resp = self.client.post(
517
+ reverse("pages:request-invite"), {"email": "invite@example.com"}
518
+ )
519
+ self.assertEqual(resp.status_code, 200)
520
+ link = re.search(r"http://testserver[\S]+", mail.outbox[0].body).group(0)
521
+ with patch("pages.views.Node.get_local", return_value=node):
522
+ resp = self.client.post(link)
523
+ self.assertEqual(resp.status_code, 302)
524
+ self.user.refresh_from_db()
525
+ self.assertTrue(self.user.is_active)
526
+ mock_grant.assert_called_once_with(self.user, "aa:bb:cc:dd:ee:ff")
527
+
528
+
529
+ class NavbarBrandTests(TestCase):
530
+ def setUp(self):
531
+ self.client = Client()
532
+ Site.objects.update_or_create(
533
+ id=1, defaults={"name": "Terminal", "domain": "testserver"}
534
+ )
535
+
536
+ def test_site_name_displayed_when_known(self):
537
+ resp = self.client.get(reverse("pages:index"))
538
+ self.assertContains(resp, '<a class="navbar-brand" href="/">Terminal</a>')
539
+
540
+ def test_default_brand_when_unknown(self):
541
+ Site.objects.filter(id=1).update(domain="example.com")
542
+ resp = self.client.get(reverse("pages:index"))
543
+ self.assertContains(resp, '<a class="navbar-brand" href="/">Arthexis</a>')
544
+
545
+ @override_settings(ALLOWED_HOSTS=["127.0.0.1", "testserver"])
546
+ def test_brand_uses_role_name_when_site_name_blank(self):
547
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
548
+ Node.objects.update_or_create(
549
+ mac_address=Node.get_current_mac(),
550
+ defaults={
551
+ "hostname": "localhost",
552
+ "address": "127.0.0.1",
553
+ "role": role,
554
+ },
555
+ )
556
+ Site.objects.filter(id=1).update(name="", domain="127.0.0.1")
557
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1")
558
+ self.assertEqual(resp.context["badge_site_name"], "Terminal")
559
+ self.assertContains(resp, '<a class="navbar-brand" href="/">Terminal</a>')
560
+
561
+
562
+ class AdminBadgesTests(TestCase):
563
+ def setUp(self):
564
+ self.client = Client()
565
+ User = get_user_model()
566
+ self.admin = User.objects.create_superuser(
567
+ username="badge-admin", password="pwd", email="admin@example.com"
568
+ )
569
+ self.client.force_login(self.admin)
570
+ Site.objects.update_or_create(
571
+ id=1, defaults={"name": "test", "domain": "testserver"}
572
+ )
573
+ from nodes.models import Node
574
+
575
+ self.node_hostname = "otherhost"
576
+ self.node = Node.objects.create(
577
+ hostname=self.node_hostname,
578
+ address=socket.gethostbyname(socket.gethostname()),
579
+ )
580
+
581
+ def test_badges_show_site_and_node(self):
582
+ resp = self.client.get(reverse("admin:index"))
583
+ self.assertContains(resp, "SITE: test")
584
+ self.assertContains(resp, f"NODE: {self.node_hostname}")
585
+
586
+ def test_badges_show_node_role(self):
587
+ from nodes.models import NodeRole
588
+
589
+ role = NodeRole.objects.create(name="Dev")
590
+ self.node.role = role
591
+ self.node.save()
592
+ resp = self.client.get(reverse("admin:index"))
593
+ role_list = reverse("admin:nodes_noderole_changelist")
594
+ role_change = reverse("admin:nodes_noderole_change", args=[role.pk])
595
+ self.assertContains(resp, "ROLE: Dev")
596
+ self.assertContains(resp, f'href="{role_list}"')
597
+ self.assertContains(resp, f'href="{role_change}"')
598
+
599
+ def test_badges_warn_when_node_missing(self):
600
+ from nodes.models import Node
601
+
602
+ Node.objects.all().delete()
603
+ resp = self.client.get(reverse("admin:index"))
604
+ self.assertContains(resp, "NODE: Unknown")
605
+ self.assertContains(resp, "badge-unknown")
606
+ self.assertContains(resp, "#6c757d")
607
+
608
+ def test_badges_link_to_admin(self):
609
+ resp = self.client.get(reverse("admin:index"))
610
+ site_list = reverse("admin:pages_siteproxy_changelist")
611
+ site_change = reverse("admin:pages_siteproxy_change", args=[1])
612
+ node_list = reverse("admin:nodes_node_changelist")
613
+ node_change = reverse("admin:nodes_node_change", args=[self.node.pk])
614
+ self.assertContains(resp, f'href="{site_list}"')
615
+ self.assertContains(resp, f'href="{site_change}"')
616
+ self.assertContains(resp, f'href="{node_list}"')
617
+ self.assertContains(resp, f'href="{node_change}"')
618
+
619
+ def test_badge_colors_use_standard_palette(self):
620
+ site = Site.objects.get(pk=1)
621
+ badge, _ = SiteBadge.objects.get_or_create(site=site)
622
+ badge.badge_color = "#ff0000"
623
+ badge.save(update_fields=["badge_color"])
624
+ self.node.badge_color = "#123456"
625
+ self.node.save(update_fields=["badge_color"])
626
+
627
+ resp = self.client.get(reverse("admin:index"))
628
+
629
+ self.assertNotContains(resp, "#ff0000")
630
+ self.assertNotContains(resp, "#123456")
631
+ self.assertContains(resp, 'style="background-color: #28a745;"', 2)
632
+
633
+
634
+ class AdminDashboardAppListTests(TestCase):
635
+ def setUp(self):
636
+ self.client = Client()
637
+ User = get_user_model()
638
+ self.admin = User.objects.create_superuser(
639
+ username="dashboard_admin", password="pwd", email="admin@example.com"
640
+ )
641
+ self.client.force_login(self.admin)
642
+ Site.objects.update_or_create(
643
+ id=1, defaults={"name": "test", "domain": "testserver"}
644
+ )
645
+ self.locks_dir = Path(settings.BASE_DIR) / "locks"
646
+ self.locks_dir.mkdir(parents=True, exist_ok=True)
647
+ self.celery_lock = self.locks_dir / "celery.lck"
648
+ if self.celery_lock.exists():
649
+ self.celery_lock.unlink()
650
+ self.addCleanup(self._remove_celery_lock)
651
+ self.node, _ = Node.objects.update_or_create(
652
+ mac_address=Node.get_current_mac(),
653
+ defaults={
654
+ "hostname": socket.gethostname(),
655
+ "address": socket.gethostbyname(socket.gethostname()),
656
+ "base_path": settings.BASE_DIR,
657
+ "port": 8000,
658
+ },
659
+ )
660
+ self.node.features.clear()
661
+
662
+ def _remove_celery_lock(self):
663
+ try:
664
+ self.celery_lock.unlink()
665
+ except FileNotFoundError:
666
+ pass
667
+
668
+ def test_horologia_hidden_without_celery_feature(self):
669
+ resp = self.client.get(reverse("admin:index"))
670
+ self.assertNotContains(resp, "5. Horologia MODELS")
671
+
672
+ def test_horologia_visible_with_celery_feature(self):
673
+ feature = NodeFeature.objects.create(slug="celery-queue", display="Celery Queue")
674
+ NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
675
+ resp = self.client.get(reverse("admin:index"))
676
+ self.assertContains(resp, "5. Horologia MODELS")
677
+
678
+ def test_horologia_visible_with_celery_lock(self):
679
+ self.celery_lock.write_text("")
680
+ resp = self.client.get(reverse("admin:index"))
681
+ self.assertContains(resp, "5. Horologia MODELS")
682
+
683
+
684
+ class AdminSidebarTests(TestCase):
685
+ def setUp(self):
686
+ self.client = Client()
687
+ User = get_user_model()
688
+ self.admin = User.objects.create_superuser(
689
+ username="sidebar_admin", password="pwd", email="admin@example.com"
690
+ )
691
+ self.client.force_login(self.admin)
692
+ Site.objects.update_or_create(
693
+ id=1, defaults={"name": "test", "domain": "testserver"}
694
+ )
695
+ from nodes.models import Node
696
+
697
+ Node.objects.create(hostname="testserver", address="127.0.0.1")
698
+
699
+ def test_sidebar_app_groups_collapsible_script_present(self):
700
+ url = reverse("admin:nodes_node_changelist")
701
+ resp = self.client.get(url)
702
+ self.assertContains(resp, 'id="admin-collapsible-apps"')
703
+
704
+
705
+ class ViewHistoryLoggingTests(TestCase):
706
+ def setUp(self):
707
+ self.client = Client()
708
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
709
+
710
+ def test_successful_visit_creates_entry(self):
711
+ resp = self.client.get(reverse("pages:index"))
712
+ self.assertEqual(resp.status_code, 200)
713
+ entry = ViewHistory.objects.order_by("-visited_at").first()
714
+ self.assertIsNotNone(entry)
715
+ self.assertEqual(entry.path, "/")
716
+ self.assertEqual(entry.status_code, 200)
717
+ self.assertEqual(entry.error_message, "")
718
+
719
+ def test_error_visit_records_message(self):
720
+ resp = self.client.get("/missing-page/")
721
+ self.assertEqual(resp.status_code, 404)
722
+ entry = (
723
+ ViewHistory.objects.filter(path="/missing-page/")
724
+ .order_by("-visited_at")
725
+ .first()
726
+ )
727
+ self.assertIsNotNone(entry)
728
+ self.assertEqual(entry.status_code, 404)
729
+ self.assertNotEqual(entry.error_message, "")
730
+
731
+ def test_debug_toolbar_requests_not_tracked(self):
732
+ resp = self.client.get(reverse("pages:index"), {"djdt": "toolbar"})
733
+ self.assertEqual(resp.status_code, 200)
734
+ self.assertFalse(ViewHistory.objects.exists())
735
+
736
+ def test_authenticated_user_last_visit_ip_updated(self):
737
+ User = get_user_model()
738
+ user = User.objects.create_user(
739
+ username="history_user", password="pwd", email="history@example.com"
740
+ )
741
+ self.assertTrue(self.client.login(username="history_user", password="pwd"))
742
+
743
+ resp = self.client.get(
744
+ reverse("pages:index"),
745
+ HTTP_X_FORWARDED_FOR="203.0.113.5",
746
+ )
747
+
748
+ self.assertEqual(resp.status_code, 200)
749
+ user.refresh_from_db()
750
+ self.assertEqual(user.last_visit_ip_address, "203.0.113.5")
751
+
752
+ def test_landing_visit_records_lead(self):
753
+ role = NodeRole.objects.create(name="landing-role")
754
+ application = Application.objects.create(
755
+ name="landing-tests-app", description=""
756
+ )
757
+ module = Module.objects.create(
758
+ node_role=role,
759
+ application=application,
760
+ path="/",
761
+ menu="Landing",
762
+ )
763
+ landing = module.landings.get(path="/")
764
+ landing.label = "Home Landing"
765
+ landing.save(update_fields=["label"])
766
+
767
+ resp = self.client.get(
768
+ reverse("pages:index"), HTTP_REFERER="https://example.com/ref"
769
+ )
770
+
771
+ self.assertEqual(resp.status_code, 200)
772
+ lead = LandingLead.objects.latest("created_on")
773
+ self.assertEqual(lead.landing, landing)
774
+ self.assertEqual(lead.path, "/")
775
+ self.assertEqual(lead.referer, "https://example.com/ref")
776
+
777
+ def test_disabled_landing_does_not_record_lead(self):
778
+ role = NodeRole.objects.create(name="landing-role-disabled")
779
+ application = Application.objects.create(
780
+ name="landing-disabled-app", description=""
781
+ )
782
+ module = Module.objects.create(
783
+ node_role=role,
784
+ application=application,
785
+ path="/",
786
+ menu="Landing",
787
+ )
788
+ landing = module.landings.get(path="/")
789
+ landing.enabled = False
790
+ landing.save(update_fields=["enabled"])
791
+
792
+ resp = self.client.get(reverse("pages:index"))
793
+
794
+ self.assertEqual(resp.status_code, 200)
795
+ self.assertFalse(LandingLead.objects.exists())
796
+
797
+
798
+ class ViewHistoryAdminTests(TestCase):
799
+ def setUp(self):
800
+ self.client = Client()
801
+ User = get_user_model()
802
+ self.admin = User.objects.create_superuser(
803
+ username="history_admin", password="pwd", email="admin@example.com"
804
+ )
805
+ self.client.force_login(self.admin)
806
+ Site.objects.update_or_create(
807
+ id=1, defaults={"name": "test", "domain": "testserver"}
808
+ )
809
+
810
+ def _create_history(self, path: str, days_offset: int = 0, count: int = 1):
811
+ for _ in range(count):
812
+ entry = ViewHistory.objects.create(
813
+ path=path,
814
+ method="GET",
815
+ status_code=200,
816
+ status_text="OK",
817
+ error_message="",
818
+ view_name="pages:index",
819
+ )
820
+ if days_offset:
821
+ entry.visited_at = timezone.now() - timedelta(days=days_offset)
822
+ entry.save(update_fields=["visited_at"])
823
+
824
+ def test_change_list_includes_graph_link(self):
825
+ resp = self.client.get(reverse("admin:pages_viewhistory_changelist"))
826
+ self.assertContains(resp, reverse("admin:pages_viewhistory_traffic_graph"))
827
+ self.assertContains(resp, "Traffic graph")
828
+
829
+ def test_graph_view_renders_canvas(self):
830
+ resp = self.client.get(reverse("admin:pages_viewhistory_traffic_graph"))
831
+ self.assertContains(resp, "viewhistory-chart")
832
+ self.assertContains(resp, reverse("admin:pages_viewhistory_changelist"))
833
+ self.assertContains(resp, static("core/vendor/chart.umd.min.js"))
834
+
835
+ def test_graph_data_endpoint(self):
836
+ ViewHistory.all_objects.all().delete()
837
+ self._create_history("/", count=2)
838
+ self._create_history("/about/", days_offset=1)
839
+ url = reverse("admin:pages_viewhistory_traffic_data")
840
+ resp = self.client.get(url)
841
+ self.assertEqual(resp.status_code, 200)
842
+ data = resp.json()
843
+ self.assertIn("labels", data)
844
+ self.assertIn("datasets", data)
845
+ self.assertGreater(len(data["labels"]), 0)
846
+ totals = {
847
+ dataset["label"]: sum(dataset["data"]) for dataset in data["datasets"]
848
+ }
849
+ self.assertEqual(totals.get("/"), 2)
850
+ self.assertEqual(totals.get("/about/"), 1)
851
+
852
+ def test_graph_data_includes_late_evening_visits(self):
853
+ target_date = date(2025, 9, 27)
854
+ entry = ViewHistory.objects.create(
855
+ path="/late/",
856
+ method="GET",
857
+ status_code=200,
858
+ status_text="OK",
859
+ error_message="",
860
+ view_name="pages:index",
861
+ )
862
+ local_evening = datetime.combine(target_date, datetime_time(21, 30))
863
+ aware_evening = timezone.make_aware(
864
+ local_evening, timezone.get_current_timezone()
865
+ )
866
+ entry.visited_at = aware_evening.astimezone(datetime_timezone.utc)
867
+ entry.save(update_fields=["visited_at"])
868
+
869
+ url = reverse("admin:pages_viewhistory_traffic_data")
870
+ with patch("pages.admin.timezone.localdate", return_value=target_date):
871
+ resp = self.client.get(url)
872
+ self.assertEqual(resp.status_code, 200)
873
+ data = resp.json()
874
+ totals = {
875
+ dataset["label"]: sum(dataset["data"]) for dataset in data["datasets"]
876
+ }
877
+ self.assertEqual(totals.get("/late/"), 1)
878
+
879
+ def test_graph_data_filters_using_datetime_range(self):
880
+ admin_view = ViewHistoryAdmin(ViewHistory, admin.site)
881
+ with patch.object(ViewHistory.objects, "filter") as mock_filter:
882
+ mock_queryset = mock_filter.return_value
883
+ mock_queryset.exists.return_value = False
884
+ admin_view._build_chart_data()
885
+
886
+ kwargs = mock_filter.call_args.kwargs
887
+ self.assertIn("visited_at__gte", kwargs)
888
+ self.assertIn("visited_at__lt", kwargs)
889
+
890
+ def test_admin_index_displays_widget(self):
891
+ resp = self.client.get(reverse("admin:index"))
892
+ self.assertContains(resp, "viewhistory-mini-module")
893
+ self.assertContains(resp, reverse("admin:pages_viewhistory_traffic_graph"))
894
+ self.assertContains(resp, static("core/vendor/chart.umd.min.js"))
895
+
896
+
897
+ class LogViewerAdminTests(SimpleTestCase):
898
+ def setUp(self):
899
+ self.factory = RequestFactory()
900
+ self.logs_dir = Path(settings.BASE_DIR) / "logs"
901
+ self.logs_dir.mkdir(exist_ok=True)
902
+
903
+ def tearDown(self):
904
+ for path in list(self.logs_dir.iterdir()):
905
+ if path.name == ".gitkeep":
906
+ continue
907
+ if path.is_file():
908
+ path.unlink()
909
+ elif path.is_dir():
910
+ shutil.rmtree(path, ignore_errors=True)
911
+
912
+ def _create_log(self, name: str, content: str = "") -> Path:
913
+ path = self.logs_dir / name
914
+ path.write_text(content, encoding="utf-8")
915
+ return path
916
+
917
+ def _build_request(self, params: dict | None = None):
918
+ request = self.factory.get("/admin/logs/viewer/", params or {})
919
+
920
+ class DummyUser:
921
+ is_active = True
922
+ is_staff = True
923
+ is_superuser = True
924
+
925
+ @property
926
+ def is_authenticated(self):
927
+ return True
928
+
929
+ def has_perm(self, perm):
930
+ return True
931
+
932
+ def has_perms(self, perms):
933
+ return True
934
+
935
+ def has_module_perms(self, app_label):
936
+ return True
937
+
938
+ def get_username(self):
939
+ return "tester"
940
+
941
+ request.user = DummyUser()
942
+ request.session = {}
943
+ request.current_app = admin.site.name
944
+ return request
945
+
946
+ def _render(self, params: dict | None = None):
947
+ request = self._build_request(params)
948
+ context = {
949
+ "site_title": "Constellation",
950
+ "site_header": "Constellation",
951
+ "site_url": "/",
952
+ "available_apps": [],
953
+ }
954
+ with patch("pages.admin.admin.site.each_context", return_value=context), patch(
955
+ "pages.context_processors.get_site", return_value=None
956
+ ):
957
+ response = log_viewer(request)
958
+ return response
959
+
960
+ def test_log_viewer_lists_available_logs(self):
961
+ self._create_log("example.log", "example content")
962
+ response = self._render()
963
+ self.assertIn("example.log", response.context_data["available_logs"])
964
+
965
+ def test_log_viewer_displays_selected_log(self):
966
+ self._create_log("selected.log", "hello world")
967
+ response = self._render({"log": "selected.log"})
968
+ context = response.context_data
969
+ self.assertEqual(context["selected_log"], "selected.log")
970
+ self.assertIn("hello world", context["log_content"])
971
+
972
+ def test_log_viewer_reports_missing_log(self):
973
+ response = self._render({"log": "missing.log"})
974
+ self.assertIn("requested log could not be found", response.context_data["log_error"])
975
+
976
+ def test_log_viewer_ignores_nested_files(self):
977
+ nested = self.logs_dir / "nested"
978
+ nested.mkdir(exist_ok=True)
979
+ (nested / "hidden.log").write_text("hidden", encoding="utf-8")
980
+ self._create_log("root.log", "root")
981
+ response = self._render()
982
+ self.assertIn("root.log", response.context_data["available_logs"])
983
+ self.assertNotIn("hidden.log", response.context_data["available_logs"])
984
+
985
+ def test_log_viewer_ignores_hidden_files(self):
986
+ hidden_log = self.logs_dir / ".hidden.log"
987
+ hidden_log.write_text("secret", encoding="utf-8")
988
+ self._create_log("visible.log", "visible")
989
+ response = self._render()
990
+ self.assertIn("visible.log", response.context_data["available_logs"])
991
+ self.assertNotIn(".hidden.log", response.context_data["available_logs"])
992
+
993
+ class AdminModelStatusTests(TestCase):
994
+ def setUp(self):
995
+ self.client = Client()
996
+ User = get_user_model()
997
+ self.admin = User.objects.create_superuser(
998
+ username="status_admin", password="pwd", email="admin@example.com"
999
+ )
1000
+ self.client.force_login(self.admin)
1001
+ Site.objects.update_or_create(
1002
+ id=1, defaults={"name": "test", "domain": "testserver"}
1003
+ )
1004
+ from nodes.models import Node
1005
+
1006
+ Node.objects.create(hostname="testserver", address="127.0.0.1")
1007
+
1008
+ @patch("pages.templatetags.admin_extras.connection.introspection.table_names")
1009
+ def test_status_dots_render(self, mock_tables):
1010
+ from django.db import connection
1011
+
1012
+ tables = type(connection.introspection).table_names(connection.introspection)
1013
+ mock_tables.return_value = [t for t in tables if t != "pages_module"]
1014
+ resp = self.client.get(reverse("admin:index"))
1015
+ self.assertContains(resp, 'class="model-status ok"')
1016
+ self.assertContains(resp, 'class="model-status missing"', count=1)
1017
+
1018
+
1019
+ class SiteAdminRegisterCurrentTests(TestCase):
1020
+ def setUp(self):
1021
+ self.client = Client()
1022
+ User = get_user_model()
1023
+ self.admin = User.objects.create_superuser(
1024
+ username="site-admin", password="pwd", email="admin@example.com"
1025
+ )
1026
+ self.client.force_login(self.admin)
1027
+ Site.objects.update_or_create(
1028
+ id=1, defaults={"name": "Constellation", "domain": "arthexis.com"}
1029
+ )
1030
+
1031
+ def test_register_current_creates_site(self):
1032
+ resp = self.client.get(reverse("admin:pages_siteproxy_changelist"))
1033
+ self.assertContains(resp, "Register Current")
1034
+
1035
+ resp = self.client.get(reverse("admin:pages_siteproxy_register_current"))
1036
+ self.assertRedirects(resp, reverse("admin:pages_siteproxy_changelist"))
1037
+ self.assertTrue(Site.objects.filter(domain="testserver").exists())
1038
+ site = Site.objects.get(domain="testserver")
1039
+ self.assertEqual(site.name, "testserver")
1040
+
1041
+ @override_settings(ALLOWED_HOSTS=["127.0.0.1", "testserver"])
1042
+ def test_register_current_ip_sets_pages_name(self):
1043
+ resp = self.client.get(
1044
+ reverse("admin:pages_siteproxy_register_current"), HTTP_HOST="127.0.0.1"
1045
+ )
1046
+ self.assertRedirects(resp, reverse("admin:pages_siteproxy_changelist"))
1047
+ site = Site.objects.get(domain="127.0.0.1")
1048
+ self.assertEqual(site.name, "")
1049
+
1050
+
1051
+ class SiteAdminScreenshotTests(TestCase):
1052
+ def setUp(self):
1053
+ self.client = Client()
1054
+ User = get_user_model()
1055
+ self.admin = User.objects.create_superuser(
1056
+ username="screenshot-admin", password="pwd", email="admin@example.com"
1057
+ )
1058
+ self.client.force_login(self.admin)
1059
+ Site.objects.update_or_create(
1060
+ id=1, defaults={"name": "Terminal", "domain": "testserver"}
1061
+ )
1062
+ self.node = Node.objects.create(
1063
+ hostname="localhost",
1064
+ address="127.0.0.1",
1065
+ port=80,
1066
+ mac_address=Node.get_current_mac(),
1067
+ )
1068
+
1069
+ @patch("pages.admin.capture_screenshot")
1070
+ def test_capture_screenshot_action(self, mock_capture):
1071
+ screenshot_dir = settings.LOG_DIR / "screenshots"
1072
+ screenshot_dir.mkdir(parents=True, exist_ok=True)
1073
+ file_path = screenshot_dir / "test.png"
1074
+ file_path.write_bytes(b"frontpage")
1075
+ mock_capture.return_value = Path("screenshots/test.png")
1076
+ url = reverse("admin:pages_siteproxy_changelist")
1077
+ response = self.client.post(
1078
+ url,
1079
+ {"action": "capture_screenshot", "_selected_action": [1]},
1080
+ follow=True,
1081
+ )
1082
+ self.assertEqual(response.status_code, 200)
1083
+ self.assertEqual(
1084
+ ContentSample.objects.filter(kind=ContentSample.IMAGE).count(), 1
1085
+ )
1086
+ screenshot = ContentSample.objects.filter(kind=ContentSample.IMAGE).first()
1087
+ self.assertEqual(screenshot.node, self.node)
1088
+ self.assertEqual(screenshot.path, "screenshots/test.png")
1089
+ self.assertEqual(screenshot.method, "ADMIN")
1090
+ link = reverse("admin:nodes_contentsample_change", args=[screenshot.pk])
1091
+ self.assertContains(response, link)
1092
+ mock_capture.assert_called_once_with("http://testserver/")
1093
+
1094
+
1095
+ class AdminBadgesWebsiteTests(TestCase):
1096
+ def setUp(self):
1097
+ self.client = Client()
1098
+ User = get_user_model()
1099
+ self.admin = User.objects.create_superuser(
1100
+ username="badge-admin2", password="pwd", email="admin@example.com"
1101
+ )
1102
+ self.client.force_login(self.admin)
1103
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1104
+ Node.objects.update_or_create(
1105
+ mac_address=Node.get_current_mac(),
1106
+ defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1107
+ )
1108
+ Site.objects.update_or_create(
1109
+ id=1, defaults={"name": "", "domain": "127.0.0.1"}
1110
+ )
1111
+
1112
+ @override_settings(ALLOWED_HOSTS=["127.0.0.1", "testserver"])
1113
+ def test_badge_shows_domain_when_site_name_blank(self):
1114
+ resp = self.client.get(reverse("admin:index"), HTTP_HOST="127.0.0.1")
1115
+ self.assertContains(resp, "SITE: 127.0.0.1")
1116
+
1117
+
1118
+ class NavAppsTests(TestCase):
1119
+ def setUp(self):
1120
+ self.client = Client()
1121
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1122
+ Node.objects.update_or_create(
1123
+ mac_address=Node.get_current_mac(),
1124
+ defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1125
+ )
1126
+ Site.objects.update_or_create(
1127
+ id=1, defaults={"domain": "127.0.0.1", "name": ""}
1128
+ )
1129
+ app = Application.objects.create(name="Readme")
1130
+ Module.objects.create(
1131
+ node_role=role, application=app, path="/", is_default=True
1132
+ )
1133
+
1134
+ def test_nav_pill_renders(self):
1135
+ resp = self.client.get(reverse("pages:index"))
1136
+ self.assertContains(resp, "README")
1137
+ self.assertContains(resp, "badge rounded-pill")
1138
+
1139
+ def test_nav_pill_renders_with_port(self):
1140
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8000")
1141
+ self.assertContains(resp, "README")
1142
+
1143
+ def test_nav_pill_uses_menu_field(self):
1144
+ site_app = Module.objects.get()
1145
+ site_app.menu = "Docs"
1146
+ site_app.save()
1147
+ resp = self.client.get(reverse("pages:index"))
1148
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">DOCS')
1149
+ self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">README')
1150
+
1151
+ def test_app_without_root_url_excluded(self):
1152
+ role = NodeRole.objects.get(name="Terminal")
1153
+ app = Application.objects.create(name="core")
1154
+ Module.objects.create(node_role=role, application=app, path="/core/")
1155
+ resp = self.client.get(reverse("pages:index"))
1156
+ self.assertNotContains(resp, 'href="/core/"')
1157
+
1158
+
1159
+ class RoleLandingRedirectTests(TestCase):
1160
+ def setUp(self):
1161
+ self.client = Client()
1162
+ Site.objects.update_or_create(
1163
+ id=1, defaults={"domain": "testserver", "name": ""}
1164
+ )
1165
+ self.node, _ = Node.objects.update_or_create(
1166
+ mac_address=Node.get_current_mac(),
1167
+ defaults={"hostname": "localhost", "address": "127.0.0.1"},
1168
+ )
1169
+ self.ocpp_app, _ = Application.objects.get_or_create(name="ocpp")
1170
+ self.user_model = get_user_model()
1171
+
1172
+ def _ensure_landing(
1173
+ self, role: NodeRole, landing_path: str, label: str
1174
+ ) -> Landing:
1175
+ module, _ = Module.objects.get_or_create(
1176
+ node_role=role,
1177
+ application=self.ocpp_app,
1178
+ defaults={"path": "/ocpp/", "menu": "Chargers"},
1179
+ )
1180
+ if module.path != "/ocpp/":
1181
+ module.path = "/ocpp/"
1182
+ module.save(update_fields=["path"])
1183
+ landing, _ = Landing.objects.get_or_create(
1184
+ module=module,
1185
+ path=landing_path,
1186
+ defaults={
1187
+ "label": label,
1188
+ "enabled": True,
1189
+ "description": "",
1190
+ },
1191
+ )
1192
+ if landing.label != label or not landing.enabled or landing.description:
1193
+ landing.label = label
1194
+ landing.enabled = True
1195
+ landing.description = ""
1196
+ landing.save(update_fields=["label", "enabled", "description"])
1197
+ return landing
1198
+
1199
+ def _configure_role_landing(
1200
+ self, role_name: str, landing_path: str, label: str, priority: int = 0
1201
+ ) -> str:
1202
+ role, _ = NodeRole.objects.get_or_create(name=role_name)
1203
+ self.node.role = role
1204
+ self.node.save(update_fields=["role"])
1205
+ landing = self._ensure_landing(role, landing_path, label)
1206
+ RoleLanding.objects.update_or_create(
1207
+ node_role=role,
1208
+ defaults={"landing": landing, "is_deleted": False, "priority": priority},
1209
+ )
1210
+ return landing_path
1211
+
1212
+ def test_satellite_redirects_to_dashboard(self):
1213
+ target = self._configure_role_landing(
1214
+ "Satellite", "/ocpp/", "CPMS Online Dashboard"
1215
+ )
1216
+ resp = self.client.get(reverse("pages:index"))
1217
+ self.assertRedirects(resp, target, fetch_redirect_response=False)
1218
+
1219
+ def test_control_redirects_to_rfid(self):
1220
+ target = self._configure_role_landing(
1221
+ "Control", "/ocpp/rfid/", "RFID Tag Validator"
1222
+ )
1223
+ resp = self.client.get(reverse("pages:index"))
1224
+ self.assertRedirects(resp, target, fetch_redirect_response=False)
1225
+
1226
+ def test_security_group_redirect_takes_priority(self):
1227
+ self._configure_role_landing("Control", "/ocpp/rfid/", "RFID Tag Validator")
1228
+ role = self.node.role
1229
+ group = SecurityGroup.objects.create(name="Operators")
1230
+ group_landing = self._ensure_landing(role, "/ocpp/group/", "Group Landing")
1231
+ RoleLanding.objects.update_or_create(
1232
+ security_group=group,
1233
+ defaults={"landing": group_landing, "priority": 5, "is_deleted": False},
1234
+ )
1235
+ user = self.user_model.objects.create_user("group-user")
1236
+ user.groups.add(group)
1237
+ self.client.force_login(user)
1238
+ resp = self.client.get(reverse("pages:index"))
1239
+ self.assertRedirects(
1240
+ resp, group_landing.path, fetch_redirect_response=False
1241
+ )
1242
+
1243
+ def test_user_redirect_overrides_group_with_higher_priority(self):
1244
+ self._configure_role_landing("Control", "/ocpp/rfid/", "RFID Tag Validator")
1245
+ role = self.node.role
1246
+ group = SecurityGroup.objects.create(name="Operators")
1247
+ group_landing = self._ensure_landing(role, "/ocpp/group/", "Group Landing")
1248
+ RoleLanding.objects.update_or_create(
1249
+ security_group=group,
1250
+ defaults={"landing": group_landing, "priority": 3, "is_deleted": False},
1251
+ )
1252
+ user = self.user_model.objects.create_user("priority-user")
1253
+ user.groups.add(group)
1254
+ user_landing = self._ensure_landing(role, "/ocpp/user/", "User Landing")
1255
+ RoleLanding.objects.update_or_create(
1256
+ user=user,
1257
+ defaults={"landing": user_landing, "priority": 10, "is_deleted": False},
1258
+ )
1259
+ self.client.force_login(user)
1260
+ resp = self.client.get(reverse("pages:index"))
1261
+ self.assertRedirects(
1262
+ resp, user_landing.path, fetch_redirect_response=False
1263
+ )
1264
+
1265
+
1266
+ class ConstellationNavTests(TestCase):
1267
+ def setUp(self):
1268
+ self.client = Client()
1269
+ role, _ = NodeRole.objects.get_or_create(name="Constellation")
1270
+ Node.objects.update_or_create(
1271
+ mac_address=Node.get_current_mac(),
1272
+ defaults={
1273
+ "hostname": "localhost",
1274
+ "address": "127.0.0.1",
1275
+ "role": role,
1276
+ },
1277
+ )
1278
+ Site.objects.update_or_create(
1279
+ id=1, defaults={"domain": "testserver", "name": ""}
1280
+ )
1281
+ fixtures = [
1282
+ Path(
1283
+ settings.BASE_DIR,
1284
+ "pages",
1285
+ "fixtures",
1286
+ "constellation__application_ocpp.json",
1287
+ ),
1288
+ Path(
1289
+ settings.BASE_DIR,
1290
+ "pages",
1291
+ "fixtures",
1292
+ "constellation__module_ocpp.json",
1293
+ ),
1294
+ Path(
1295
+ settings.BASE_DIR,
1296
+ "pages",
1297
+ "fixtures",
1298
+ "constellation__landing_ocpp_dashboard.json",
1299
+ ),
1300
+ Path(
1301
+ settings.BASE_DIR,
1302
+ "pages",
1303
+ "fixtures",
1304
+ "constellation__landing_ocpp_cp_simulator.json",
1305
+ ),
1306
+ Path(
1307
+ settings.BASE_DIR,
1308
+ "pages",
1309
+ "fixtures",
1310
+ "constellation__landing_ocpp_rfid.json",
1311
+ ),
1312
+ ]
1313
+ call_command("loaddata", *map(str, fixtures))
1314
+
1315
+ def test_rfid_pill_hidden(self):
1316
+ resp = self.client.get(reverse("pages:index"))
1317
+ nav_labels = [
1318
+ module.menu_label.upper() for module in resp.context["nav_modules"]
1319
+ ]
1320
+ self.assertNotIn("RFID", nav_labels)
1321
+ self.assertTrue(
1322
+ Module.objects.filter(
1323
+ path="/ocpp/", node_role__name="Constellation"
1324
+ ).exists()
1325
+ )
1326
+ self.assertFalse(
1327
+ Module.objects.filter(
1328
+ path="/ocpp/rfid/",
1329
+ node_role__name="Constellation",
1330
+ is_deleted=False,
1331
+ ).exists()
1332
+ )
1333
+ ocpp_module = next(
1334
+ module
1335
+ for module in resp.context["nav_modules"]
1336
+ if module.menu_label.upper() == "CHARGERS"
1337
+ )
1338
+ landing_labels = [landing.label for landing in ocpp_module.enabled_landings]
1339
+ self.assertIn("RFID Tag Validator", landing_labels)
1340
+
1341
+ def test_ocpp_dashboard_visible(self):
1342
+ resp = self.client.get(reverse("pages:index"))
1343
+ self.assertContains(resp, 'href="/ocpp/"')
1344
+
1345
+ class ControlNavTests(TestCase):
1346
+ def setUp(self):
1347
+ self.client = Client()
1348
+ role, _ = NodeRole.objects.get_or_create(name="Control")
1349
+ Node.objects.update_or_create(
1350
+ mac_address=Node.get_current_mac(),
1351
+ defaults={
1352
+ "hostname": "localhost",
1353
+ "address": "127.0.0.1",
1354
+ "role": role,
1355
+ },
1356
+ )
1357
+ Site.objects.update_or_create(
1358
+ id=1, defaults={"domain": "testserver", "name": ""}
1359
+ )
1360
+ fixtures = [
1361
+ Path(
1362
+ settings.BASE_DIR,
1363
+ "pages",
1364
+ "fixtures",
1365
+ "control__application_ocpp.json",
1366
+ ),
1367
+ Path(
1368
+ settings.BASE_DIR,
1369
+ "pages",
1370
+ "fixtures",
1371
+ "control__module_ocpp.json",
1372
+ ),
1373
+ Path(
1374
+ settings.BASE_DIR,
1375
+ "pages",
1376
+ "fixtures",
1377
+ "control__landing_ocpp_dashboard.json",
1378
+ ),
1379
+ Path(
1380
+ settings.BASE_DIR,
1381
+ "pages",
1382
+ "fixtures",
1383
+ "control__landing_ocpp_cp_simulator.json",
1384
+ ),
1385
+ Path(
1386
+ settings.BASE_DIR,
1387
+ "pages",
1388
+ "fixtures",
1389
+ "control__landing_ocpp_rfid.json",
1390
+ ),
1391
+ ]
1392
+ call_command("loaddata", *map(str, fixtures))
1393
+
1394
+ def test_ocpp_dashboard_visible(self):
1395
+ user = get_user_model().objects.create_user("control", password="pw")
1396
+ self.client.force_login(user)
1397
+ resp = self.client.get(reverse("pages:index"))
1398
+ self.assertEqual(resp.status_code, 200)
1399
+ self.assertContains(resp, 'href="/ocpp/"')
1400
+ self.assertContains(
1401
+ resp, 'badge rounded-pill text-bg-secondary">CHARGERS'
1402
+ )
1403
+
1404
+ def test_header_links_visible_when_defined(self):
1405
+ Reference.objects.create(
1406
+ alt_text="Console",
1407
+ value="https://example.com/console",
1408
+ show_in_header=True,
1409
+ )
1410
+
1411
+ resp = self.client.get(reverse("pages:index"))
1412
+
1413
+ self.assertIn("header_references", resp.context)
1414
+ self.assertTrue(resp.context["header_references"])
1415
+ self.assertContains(resp, "LINKS")
1416
+ self.assertContains(resp, 'href="https://example.com/console"')
1417
+
1418
+ def test_header_links_hidden_when_flag_false(self):
1419
+ Reference.objects.create(
1420
+ alt_text="Hidden",
1421
+ value="https://example.com/hidden",
1422
+ show_in_header=False,
1423
+ )
1424
+
1425
+ resp = self.client.get(reverse("pages:index"))
1426
+
1427
+ self.assertIn("header_references", resp.context)
1428
+ self.assertFalse(resp.context["header_references"])
1429
+ self.assertNotContains(resp, "https://example.com/hidden")
1430
+
1431
+
1432
+ class PowerNavTests(TestCase):
1433
+ def setUp(self):
1434
+ self.client = Client()
1435
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1436
+ Node.objects.update_or_create(
1437
+ mac_address=Node.get_current_mac(),
1438
+ defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1439
+ )
1440
+ Site.objects.update_or_create(
1441
+ id=1, defaults={"domain": "testserver", "name": ""}
1442
+ )
1443
+ awg_app, _ = Application.objects.get_or_create(name="awg")
1444
+ awg_module, _ = Module.objects.get_or_create(
1445
+ node_role=role, application=awg_app, path="/awg/"
1446
+ )
1447
+ awg_module.create_landings()
1448
+ manuals_app, _ = Application.objects.get_or_create(name="pages")
1449
+ man_module, _ = Module.objects.get_or_create(
1450
+ node_role=role, application=manuals_app, path="/man/"
1451
+ )
1452
+ man_module.create_landings()
1453
+ User = get_user_model()
1454
+ self.user = User.objects.create_user("user", password="pw")
1455
+
1456
+ def test_power_pill_lists_calculators(self):
1457
+ resp = self.client.get(reverse("pages:index"))
1458
+ power_module = None
1459
+ for module in resp.context["nav_modules"]:
1460
+ if module.path == "/awg/":
1461
+ power_module = module
1462
+ break
1463
+ self.assertIsNotNone(power_module)
1464
+ self.assertEqual(power_module.menu_label.upper(), "CALCULATE")
1465
+ landing_labels = {landing.label for landing in power_module.enabled_landings}
1466
+ self.assertIn("AWG Calculator", landing_labels)
1467
+
1468
+ def test_manual_pill_label(self):
1469
+ resp = self.client.get(reverse("pages:index"))
1470
+ manuals_module = None
1471
+ for module in resp.context["nav_modules"]:
1472
+ if module.path == "/man/":
1473
+ manuals_module = module
1474
+ break
1475
+ self.assertIsNotNone(manuals_module)
1476
+ self.assertEqual(manuals_module.menu_label.upper(), "MANUAL")
1477
+ landing_labels = {landing.label for landing in manuals_module.enabled_landings}
1478
+ self.assertIn("Manuals", landing_labels)
1479
+
1480
+ def test_energy_tariff_visible_when_logged_in(self):
1481
+ self.client.force_login(self.user)
1482
+ resp = self.client.get(reverse("pages:index"))
1483
+ power_module = None
1484
+ for module in resp.context["nav_modules"]:
1485
+ if module.path == "/awg/":
1486
+ power_module = module
1487
+ break
1488
+ self.assertIsNotNone(power_module)
1489
+ landing_labels = {landing.label for landing in power_module.enabled_landings}
1490
+ self.assertIn("AWG Calculator", landing_labels)
1491
+ self.assertIn("Energy Tariff Calculator", landing_labels)
1492
+
1493
+
1494
+ class StaffNavVisibilityTests(TestCase):
1495
+ def setUp(self):
1496
+ self.client = Client()
1497
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1498
+ Node.objects.update_or_create(
1499
+ mac_address=Node.get_current_mac(),
1500
+ defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1501
+ )
1502
+ Site.objects.update_or_create(
1503
+ id=1, defaults={"domain": "testserver", "name": ""}
1504
+ )
1505
+ app = Application.objects.create(name="ocpp")
1506
+ Module.objects.create(node_role=role, application=app, path="/ocpp/")
1507
+ User = get_user_model()
1508
+ self.user = User.objects.create_user("user", password="pw")
1509
+ self.staff = User.objects.create_user("staff", password="pw", is_staff=True)
1510
+
1511
+ def test_nonstaff_pill_hidden(self):
1512
+ self.client.login(username="user", password="pw")
1513
+ resp = self.client.get(reverse("pages:index"))
1514
+ self.assertContains(resp, 'href="/ocpp/"')
1515
+
1516
+ def test_staff_sees_pill(self):
1517
+ self.client.login(username="staff", password="pw")
1518
+ resp = self.client.get(reverse("pages:index"))
1519
+ self.assertContains(resp, 'href="/ocpp/"')
1520
+
1521
+
1522
+ class ApplicationModelTests(TestCase):
1523
+ def test_path_defaults_to_slugified_name(self):
1524
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1525
+ Node.objects.update_or_create(
1526
+ mac_address=Node.get_current_mac(),
1527
+ defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1528
+ )
1529
+ Site.objects.update_or_create(
1530
+ id=1, defaults={"domain": "testserver", "name": ""}
1531
+ )
1532
+ app = Application.objects.create(name="core")
1533
+ site_app = Module.objects.create(node_role=role, application=app)
1534
+ self.assertEqual(site_app.path, "/core/")
1535
+
1536
+ def test_installed_flag_false_when_missing(self):
1537
+ app = Application.objects.create(name="missing")
1538
+ self.assertFalse(app.installed)
1539
+
1540
+ def test_verbose_name_property(self):
1541
+ app = Application.objects.create(name="ocpp")
1542
+ config = django_apps.get_app_config("ocpp")
1543
+ self.assertEqual(app.verbose_name, config.verbose_name)
1544
+
1545
+
1546
+ class ApplicationAdminFormTests(TestCase):
1547
+ def test_name_field_uses_local_apps(self):
1548
+ admin_instance = ApplicationAdmin(Application, admin.site)
1549
+ form = admin_instance.get_form(request=None)()
1550
+ choices = [choice[0] for choice in form.fields["name"].choices]
1551
+ self.assertIn("core", choices)
1552
+
1553
+
1554
+ class ApplicationAdminDisplayTests(TestCase):
1555
+ def setUp(self):
1556
+ User = get_user_model()
1557
+ self.admin = User.objects.create_superuser(
1558
+ username="app-admin", password="pwd", email="admin@example.com"
1559
+ )
1560
+ self.client = Client()
1561
+ self.client.force_login(self.admin)
1562
+
1563
+ def test_changelist_shows_verbose_name(self):
1564
+ Application.objects.create(name="ocpp")
1565
+ resp = self.client.get(reverse("admin:pages_application_changelist"))
1566
+ config = django_apps.get_app_config("ocpp")
1567
+ self.assertContains(resp, config.verbose_name)
1568
+
1569
+ def test_changelist_shows_description(self):
1570
+ Application.objects.create(
1571
+ name="awg", description="Power, Energy and Cost calculations."
1572
+ )
1573
+ resp = self.client.get(reverse("admin:pages_application_changelist"))
1574
+ self.assertContains(resp, "Power, Energy and Cost calculations.")
1575
+
1576
+
1577
+ class UserManualAdminFormTests(TestCase):
1578
+ def setUp(self):
1579
+ self.manual = UserManual.objects.create(
1580
+ slug="manual-one",
1581
+ title="Manual One",
1582
+ description="Test manual",
1583
+ languages="en",
1584
+ content_html="<p>Manual</p>",
1585
+ content_pdf=base64.b64encode(b"initial").decode("ascii"),
1586
+ )
1587
+
1588
+ def test_widget_uses_slug_for_download(self):
1589
+ admin_instance = UserManualAdmin(UserManual, admin.site)
1590
+ form_class = admin_instance.get_form(request=None, obj=self.manual)
1591
+ form = form_class(instance=self.manual)
1592
+ field = form.fields["content_pdf"]
1593
+ self.assertEqual(field.widget.download_name, f"{self.manual.slug}.pdf")
1594
+ self.assertEqual(field.widget.content_type, "application/pdf")
1595
+
1596
+ def test_upload_encodes_content_pdf(self):
1597
+ admin_instance = UserManualAdmin(UserManual, admin.site)
1598
+ form_class = admin_instance.get_form(request=None, obj=self.manual)
1599
+ payload = {
1600
+ "slug": self.manual.slug,
1601
+ "title": self.manual.title,
1602
+ "description": self.manual.description,
1603
+ "languages": self.manual.languages,
1604
+ "content_html": self.manual.content_html,
1605
+ "pdf_orientation": self.manual.pdf_orientation,
1606
+ }
1607
+ upload = SimpleUploadedFile("manual.pdf", b"PDF data")
1608
+ form = form_class(data=payload, files={"content_pdf": upload}, instance=self.manual)
1609
+ self.assertTrue(form.is_valid(), form.errors.as_json())
1610
+ self.assertEqual(
1611
+ form.cleaned_data["content_pdf"],
1612
+ base64.b64encode(b"PDF data").decode("ascii"),
1613
+ )
1614
+
1615
+ def test_initial_base64_preserved_without_upload(self):
1616
+ admin_instance = UserManualAdmin(UserManual, admin.site)
1617
+ form_class = admin_instance.get_form(request=None, obj=self.manual)
1618
+ payload = {
1619
+ "slug": self.manual.slug,
1620
+ "title": self.manual.title,
1621
+ "description": self.manual.description,
1622
+ "languages": self.manual.languages,
1623
+ "content_html": self.manual.content_html,
1624
+ "pdf_orientation": self.manual.pdf_orientation,
1625
+ }
1626
+ form = form_class(data=payload, files={}, instance=self.manual)
1627
+ self.assertTrue(form.is_valid(), form.errors.as_json())
1628
+ self.assertEqual(form.cleaned_data["content_pdf"], self.manual.content_pdf)
1629
+
1630
+
1631
+ class LandingCreationTests(TestCase):
1632
+ def setUp(self):
1633
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1634
+ Node.objects.update_or_create(
1635
+ mac_address=Node.get_current_mac(),
1636
+ defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
1637
+ )
1638
+ self.app, _ = Application.objects.get_or_create(name="pages")
1639
+ Site.objects.update_or_create(
1640
+ id=1, defaults={"domain": "testserver", "name": ""}
1641
+ )
1642
+ self.role = role
1643
+
1644
+ def test_landings_created_on_module_creation(self):
1645
+ module = Module.objects.create(
1646
+ node_role=self.role, application=self.app, path="/"
1647
+ )
1648
+ self.assertTrue(module.landings.filter(path="/").exists())
1649
+
1650
+
1651
+ class LandingFixtureTests(TestCase):
1652
+ def test_constellation_fixture_loads_without_duplicates(self):
1653
+ from glob import glob
1654
+
1655
+ NodeRole.objects.get_or_create(name="Constellation")
1656
+ fixtures = glob(
1657
+ str(Path(settings.BASE_DIR, "pages", "fixtures", "constellation__*.json"))
1658
+ )
1659
+ fixtures = sorted(
1660
+ fixtures,
1661
+ key=lambda path: (
1662
+ 0 if "__application_" in path else 1 if "__module_" in path else 2
1663
+ ),
1664
+ )
1665
+ call_command("loaddata", *fixtures)
1666
+ call_command("loaddata", *fixtures)
1667
+ module = Module.objects.get(path="/ocpp/", node_role__name="Constellation")
1668
+ module.create_landings()
1669
+ self.assertEqual(module.landings.filter(path="/ocpp/rfid/").count(), 1)
1670
+
1671
+
1672
+ class AllowedHostSubnetTests(TestCase):
1673
+ def setUp(self):
1674
+ self.client = Client()
1675
+ Site.objects.update_or_create(
1676
+ id=1, defaults={"domain": "testserver", "name": "pages"}
1677
+ )
1678
+
1679
+ @override_settings(ALLOWED_HOSTS=["10.42.0.0/16", "192.168.0.0/16"])
1680
+ def test_private_network_hosts_allowed(self):
1681
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="10.42.1.5")
1682
+ self.assertEqual(resp.status_code, 200)
1683
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="192.168.2.3")
1684
+ self.assertEqual(resp.status_code, 200)
1685
+
1686
+ @override_settings(ALLOWED_HOSTS=["10.42.0.0/16"])
1687
+ def test_host_outside_subnets_disallowed(self):
1688
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="11.0.0.1")
1689
+ self.assertEqual(resp.status_code, 400)
1690
+
1691
+
1692
+ class RFIDPageTests(TestCase):
1693
+ def setUp(self):
1694
+ self.client = Client()
1695
+ Site.objects.update_or_create(
1696
+ id=1, defaults={"domain": "testserver", "name": "pages"}
1697
+ )
1698
+ User = get_user_model()
1699
+ self.user = User.objects.create_user("rfid-user", password="pwd")
1700
+
1701
+ def test_page_redirects_when_anonymous(self):
1702
+ resp = self.client.get(reverse("rfid-reader"))
1703
+ self.assertEqual(resp.status_code, 302)
1704
+ self.assertIn(reverse("pages:login"), resp.url)
1705
+
1706
+ def test_page_renders_for_authenticated_user(self):
1707
+ self.client.force_login(self.user)
1708
+ resp = self.client.get(reverse("rfid-reader"))
1709
+ self.assertContains(resp, "Scanner ready")
1710
+
1711
+
1712
+ class FaviconTests(TestCase):
1713
+ def setUp(self):
1714
+ self.client = Client()
1715
+ self.tmpdir = tempfile.mkdtemp()
1716
+ self.addCleanup(shutil.rmtree, self.tmpdir)
1717
+
1718
+ def _png(self, name):
1719
+ data = base64.b64decode(
1720
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="
1721
+ )
1722
+ return SimpleUploadedFile(name, data, content_type="image/png")
1723
+
1724
+ def test_site_app_favicon_preferred_over_site(self):
1725
+ with override_settings(MEDIA_ROOT=self.tmpdir):
1726
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1727
+ Node.objects.update_or_create(
1728
+ mac_address=Node.get_current_mac(),
1729
+ defaults={
1730
+ "hostname": "localhost",
1731
+ "address": "127.0.0.1",
1732
+ "role": role,
1733
+ },
1734
+ )
1735
+ site, _ = Site.objects.update_or_create(
1736
+ id=1, defaults={"domain": "testserver", "name": ""}
1737
+ )
1738
+ SiteBadge.objects.create(
1739
+ site=site, badge_color="#28a745", favicon=self._png("site.png")
1740
+ )
1741
+ app = Application.objects.create(name="readme")
1742
+ Module.objects.create(
1743
+ node_role=role,
1744
+ application=app,
1745
+ path="/",
1746
+ is_default=True,
1747
+ favicon=self._png("app.png"),
1748
+ )
1749
+ resp = self.client.get(reverse("pages:index"))
1750
+ self.assertContains(resp, "app.png")
1751
+
1752
+ def test_site_favicon_used_when_app_missing(self):
1753
+ with override_settings(MEDIA_ROOT=self.tmpdir):
1754
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1755
+ Node.objects.update_or_create(
1756
+ mac_address=Node.get_current_mac(),
1757
+ defaults={
1758
+ "hostname": "localhost",
1759
+ "address": "127.0.0.1",
1760
+ "role": role,
1761
+ },
1762
+ )
1763
+ site, _ = Site.objects.update_or_create(
1764
+ id=1, defaults={"domain": "testserver", "name": ""}
1765
+ )
1766
+ SiteBadge.objects.create(
1767
+ site=site, badge_color="#28a745", favicon=self._png("site.png")
1768
+ )
1769
+ app = Application.objects.create(name="readme")
1770
+ Module.objects.create(
1771
+ node_role=role, application=app, path="/", is_default=True
1772
+ )
1773
+ resp = self.client.get(reverse("pages:index"))
1774
+ self.assertContains(resp, "site.png")
1775
+
1776
+ def test_default_favicon_used_when_none_defined(self):
1777
+ with override_settings(MEDIA_ROOT=self.tmpdir):
1778
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
1779
+ Node.objects.update_or_create(
1780
+ mac_address=Node.get_current_mac(),
1781
+ defaults={
1782
+ "hostname": "localhost",
1783
+ "address": "127.0.0.1",
1784
+ "role": role,
1785
+ },
1786
+ )
1787
+ Site.objects.update_or_create(
1788
+ id=1, defaults={"domain": "testserver", "name": ""}
1789
+ )
1790
+ resp = self.client.get(reverse("pages:index"))
1791
+ b64 = (
1792
+ Path(settings.BASE_DIR)
1793
+ .joinpath("pages", "fixtures", "data", "favicon.txt")
1794
+ .read_text()
1795
+ .strip()
1796
+ )
1797
+ self.assertContains(resp, b64)
1798
+
1799
+ def test_control_nodes_use_silver_favicon(self):
1800
+ with override_settings(MEDIA_ROOT=self.tmpdir):
1801
+ role, _ = NodeRole.objects.get_or_create(name="Control")
1802
+ Node.objects.update_or_create(
1803
+ mac_address=Node.get_current_mac(),
1804
+ defaults={
1805
+ "hostname": "localhost",
1806
+ "address": "127.0.0.1",
1807
+ "role": role,
1808
+ },
1809
+ )
1810
+ Site.objects.update_or_create(
1811
+ id=1, defaults={"domain": "testserver", "name": ""}
1812
+ )
1813
+ resp = self.client.get(reverse("pages:index"))
1814
+ b64 = (
1815
+ Path(settings.BASE_DIR)
1816
+ .joinpath("pages", "fixtures", "data", "favicon_control.txt")
1817
+ .read_text()
1818
+ .strip()
1819
+ )
1820
+ self.assertContains(resp, b64)
1821
+
1822
+ def test_constellation_nodes_use_goldenrod_favicon(self):
1823
+ with override_settings(MEDIA_ROOT=self.tmpdir):
1824
+ role, _ = NodeRole.objects.get_or_create(name="Constellation")
1825
+ Node.objects.update_or_create(
1826
+ mac_address=Node.get_current_mac(),
1827
+ defaults={
1828
+ "hostname": "localhost",
1829
+ "address": "127.0.0.1",
1830
+ "role": role,
1831
+ },
1832
+ )
1833
+ Site.objects.update_or_create(
1834
+ id=1, defaults={"domain": "testserver", "name": ""}
1835
+ )
1836
+ resp = self.client.get(reverse("pages:index"))
1837
+ b64 = (
1838
+ Path(settings.BASE_DIR)
1839
+ .joinpath("pages", "fixtures", "data", "favicon_constellation.txt")
1840
+ .read_text()
1841
+ .strip()
1842
+ )
1843
+ self.assertContains(resp, b64)
1844
+
1845
+ def test_satellite_nodes_use_silver_favicon(self):
1846
+ with override_settings(MEDIA_ROOT=self.tmpdir):
1847
+ role, _ = NodeRole.objects.get_or_create(name="Satellite")
1848
+ Node.objects.update_or_create(
1849
+ mac_address=Node.get_current_mac(),
1850
+ defaults={
1851
+ "hostname": "localhost",
1852
+ "address": "127.0.0.1",
1853
+ "role": role,
1854
+ },
1855
+ )
1856
+ Site.objects.update_or_create(
1857
+ id=1, defaults={"domain": "testserver", "name": ""}
1858
+ )
1859
+ resp = self.client.get(reverse("pages:index"))
1860
+ b64 = (
1861
+ Path(settings.BASE_DIR)
1862
+ .joinpath("pages", "fixtures", "data", "favicon_satellite.txt")
1863
+ .read_text()
1864
+ .strip()
1865
+ )
1866
+ self.assertContains(resp, b64)
1867
+
1868
+
1869
+ class FavoriteTests(TestCase):
1870
+ def setUp(self):
1871
+ self.client = Client()
1872
+ User = get_user_model()
1873
+ self.user = User.objects.create_superuser(
1874
+ username="favadmin", password="pwd", email="fav@example.com"
1875
+ )
1876
+ ReleaseManager.objects.create(user=self.user)
1877
+ self.client.force_login(self.user)
1878
+ Site.objects.update_or_create(
1879
+ id=1, defaults={"name": "test", "domain": "testserver"}
1880
+ )
1881
+ from nodes.models import Node, NodeRole
1882
+
1883
+ terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
1884
+ self.node, _ = Node.objects.update_or_create(
1885
+ mac_address=Node.get_current_mac(),
1886
+ defaults={
1887
+ "hostname": "localhost",
1888
+ "address": "127.0.0.1",
1889
+ "role": terminal_role,
1890
+ },
1891
+ )
1892
+ ContentType.objects.clear_cache()
1893
+
1894
+ def test_add_favorite(self):
1895
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
1896
+ next_url = reverse("admin:pages_application_changelist")
1897
+ url = (
1898
+ reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
1899
+ )
1900
+ resp = self.client.post(url, {"custom_label": "Apps", "user_data": "on"})
1901
+ self.assertRedirects(resp, next_url)
1902
+ fav = Favorite.objects.get(user=self.user, content_type=ct)
1903
+ self.assertEqual(fav.custom_label, "Apps")
1904
+ self.assertTrue(fav.user_data)
1905
+
1906
+ def test_cancel_link_uses_next(self):
1907
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
1908
+ next_url = reverse("admin:pages_application_changelist")
1909
+ url = (
1910
+ reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
1911
+ )
1912
+ resp = self.client.get(url)
1913
+ self.assertContains(resp, f'href="{next_url}"')
1914
+
1915
+ def test_existing_favorite_redirects_to_list(self):
1916
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
1917
+ Favorite.objects.create(user=self.user, content_type=ct)
1918
+ url = reverse("admin:favorite_toggle", args=[ct.id])
1919
+ resp = self.client.get(url)
1920
+ self.assertRedirects(resp, reverse("admin:favorite_list"))
1921
+ resp = self.client.get(reverse("admin:favorite_list"))
1922
+ self.assertContains(resp, ct.name)
1923
+
1924
+ def test_update_user_data_from_list(self):
1925
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
1926
+ fav = Favorite.objects.create(user=self.user, content_type=ct)
1927
+ url = reverse("admin:favorite_list")
1928
+ resp = self.client.post(url, {"user_data": [str(fav.pk)]})
1929
+ self.assertRedirects(resp, url)
1930
+ fav.refresh_from_db()
1931
+ self.assertTrue(fav.user_data)
1932
+
1933
+ def test_dashboard_includes_favorites_and_user_data(self):
1934
+ fav_ct = ContentType.objects.get_by_natural_key("pages", "application")
1935
+ Favorite.objects.create(
1936
+ user=self.user, content_type=fav_ct, custom_label="Apps"
1937
+ )
1938
+ NodeRole.objects.create(name="DataRole", is_user_data=True)
1939
+ resp = self.client.get(reverse("admin:index"))
1940
+ self.assertContains(resp, reverse("admin:pages_application_changelist"))
1941
+ self.assertContains(resp, reverse("admin:nodes_noderole_changelist"))
1942
+
1943
+ def test_dashboard_merges_duplicate_future_actions(self):
1944
+ ct = ContentType.objects.get_for_model(NodeRole)
1945
+ Favorite.objects.create(user=self.user, content_type=ct)
1946
+ NodeRole.objects.create(name="DataRole2", is_user_data=True)
1947
+ AdminHistory.objects.create(
1948
+ user=self.user,
1949
+ content_type=ct,
1950
+ url=reverse("admin:nodes_noderole_changelist"),
1951
+ )
1952
+ resp = self.client.get(reverse("admin:index"))
1953
+ url = reverse("admin:nodes_noderole_changelist")
1954
+ self.assertGreaterEqual(resp.content.decode().count(url), 1)
1955
+ self.assertContains(resp, NodeRole._meta.verbose_name_plural)
1956
+
1957
+ def test_dashboard_shows_open_lead_badge(self):
1958
+ InviteLead.objects.create(email="open1@example.com")
1959
+ InviteLead.objects.create(email="open2@example.com")
1960
+ closed = InviteLead.objects.create(email="closed@example.com")
1961
+ closed.status = InviteLead.Status.CLOSED
1962
+ closed.save(update_fields=["status"])
1963
+ assigned = InviteLead.objects.create(email="assigned@example.com")
1964
+ assigned.status = InviteLead.Status.ASSIGNED
1965
+ assigned.save(update_fields=["status"])
1966
+
1967
+ resp = self.client.get(reverse("admin:index"))
1968
+ content = resp.content.decode()
1969
+
1970
+ self.assertIn('class="lead-open-badge"', content)
1971
+ self.assertIn('title="2 open leads"', content)
1972
+ self.assertIn('aria-label="2 open leads"', content)
1973
+
1974
+ def test_dashboard_shows_rfid_release_badge(self):
1975
+ RFID.objects.create(rfid="RFID0001", released=True, allowed=True)
1976
+ RFID.objects.create(rfid="RFID0002", released=True, allowed=False)
1977
+
1978
+ resp = self.client.get(reverse("admin:index"))
1979
+
1980
+ expected = "1 / 2"
1981
+ badge_label = gettext(
1982
+ "%(released_allowed)s released and allowed RFIDs out of %(registered)s registered RFIDs"
1983
+ ) % {"released_allowed": 1, "registered": 2}
1984
+
1985
+ self.assertContains(resp, expected)
1986
+ self.assertContains(resp, f'title="{badge_label}"')
1987
+ self.assertContains(resp, f'aria-label="{badge_label}"')
1988
+
1989
+ def test_nav_sidebar_hides_dashboard_badges(self):
1990
+ InviteLead.objects.create(email="open@example.com")
1991
+ RFID.objects.create(rfid="RFID0003", released=True, allowed=True)
1992
+
1993
+ resp = self.client.get(reverse("admin:teams_invitelead_changelist"))
1994
+
1995
+ self.assertNotContains(resp, "lead-open-badge")
1996
+ self.assertNotContains(resp, "rfid-release-badge")
1997
+
1998
+ def test_dashboard_limits_future_actions_to_top_four(self):
1999
+ from pages.templatetags.admin_extras import future_action_items
2000
+
2001
+ role_ct = ContentType.objects.get_for_model(NodeRole)
2002
+ role_url = reverse("admin:nodes_noderole_changelist")
2003
+ AdminHistory.objects.create(
2004
+ user=self.user,
2005
+ content_type=role_ct,
2006
+ url=role_url,
2007
+ )
2008
+ AdminHistory.objects.create(
2009
+ user=self.user,
2010
+ content_type=role_ct,
2011
+ url=f"{role_url}?page=2",
2012
+ )
2013
+ AdminHistory.objects.create(
2014
+ user=self.user,
2015
+ content_type=role_ct,
2016
+ url=f"{role_url}?page=3",
2017
+ )
2018
+
2019
+ app_ct = ContentType.objects.get_for_model(Application)
2020
+ app_url = reverse("admin:pages_application_changelist")
2021
+ AdminHistory.objects.create(
2022
+ user=self.user,
2023
+ content_type=app_ct,
2024
+ url=app_url,
2025
+ )
2026
+ AdminHistory.objects.create(
2027
+ user=self.user,
2028
+ content_type=app_ct,
2029
+ url=f"{app_url}?page=2",
2030
+ )
2031
+
2032
+ module_ct = ContentType.objects.get_for_model(Module)
2033
+ module_url = reverse("admin:pages_module_changelist")
2034
+ AdminHistory.objects.create(
2035
+ user=self.user,
2036
+ content_type=module_ct,
2037
+ url=module_url,
2038
+ )
2039
+ AdminHistory.objects.create(
2040
+ user=self.user,
2041
+ content_type=module_ct,
2042
+ url=f"{module_url}?page=2",
2043
+ )
2044
+
2045
+ package_ct = ContentType.objects.get_for_model(Package)
2046
+ package_url = reverse("admin:core_package_changelist")
2047
+ AdminHistory.objects.create(
2048
+ user=self.user,
2049
+ content_type=package_ct,
2050
+ url=package_url,
2051
+ )
2052
+
2053
+ view_history_ct = ContentType.objects.get_for_model(ViewHistory)
2054
+ view_history_url = reverse("admin:pages_viewhistory_changelist")
2055
+ AdminHistory.objects.create(
2056
+ user=self.user,
2057
+ content_type=view_history_ct,
2058
+ url=view_history_url,
2059
+ )
2060
+
2061
+ resp = self.client.get(reverse("admin:index"))
2062
+ items = future_action_items({"request": resp.wsgi_request})["models"]
2063
+ labels = {item["label"] for item in items}
2064
+ self.assertEqual(len(items), 4)
2065
+ self.assertIn("Node Roles", labels)
2066
+ self.assertIn("Modules", labels)
2067
+ self.assertIn("applications", labels)
2068
+ self.assertIn("View Histories", labels)
2069
+ self.assertNotIn("Packages", labels)
2070
+ ContentType.objects.clear_cache()
2071
+
2072
+ def test_favorite_ct_id_recreates_missing_content_type(self):
2073
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2074
+ ct.delete()
2075
+ from pages.templatetags.favorites import favorite_ct_id
2076
+
2077
+ new_id = favorite_ct_id("pages", "Application")
2078
+ self.assertIsNotNone(new_id)
2079
+ self.assertTrue(
2080
+ ContentType.objects.filter(
2081
+ pk=new_id, app_label="pages", model="application"
2082
+ ).exists()
2083
+ )
2084
+
2085
+ def test_dashboard_uses_change_label(self):
2086
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2087
+ Favorite.objects.create(user=self.user, content_type=ct)
2088
+ resp = self.client.get(reverse("admin:index"))
2089
+ self.assertContains(resp, "Change Applications")
2090
+ self.assertContains(resp, 'target="_blank" rel="noopener noreferrer"')
2091
+
2092
+ def test_dashboard_links_to_focus_view(self):
2093
+ todo = Todo.objects.create(request="Check docs", url="/docs/")
2094
+ resp = self.client.get(reverse("admin:index"))
2095
+ focus_url = reverse("todo-focus", args=[todo.pk])
2096
+ expected_next = quote(reverse("admin:index"))
2097
+ self.assertContains(
2098
+ resp,
2099
+ f'href="{focus_url}?next={expected_next}"',
2100
+ )
2101
+
2102
+ def test_dashboard_shows_todo_with_done_button(self):
2103
+ todo = Todo.objects.create(request="Do thing")
2104
+ resp = self.client.get(reverse("admin:index"))
2105
+ done_url = reverse("todo-done", args=[todo.pk])
2106
+ self.assertContains(resp, todo.request)
2107
+ self.assertContains(resp, f'action="{done_url}"')
2108
+ self.assertContains(resp, "DONE")
2109
+
2110
+ def test_dashboard_shows_request_details(self):
2111
+ Todo.objects.create(request="Do thing", request_details="More info")
2112
+ resp = self.client.get(reverse("admin:index"))
2113
+ self.assertContains(
2114
+ resp, '<div class="todo-details">More info</div>', html=True
2115
+ )
2116
+
2117
+ def test_dashboard_excludes_todo_changelist_link(self):
2118
+ ct = ContentType.objects.get_for_model(Todo)
2119
+ Favorite.objects.create(user=self.user, content_type=ct)
2120
+ AdminHistory.objects.create(
2121
+ user=self.user,
2122
+ content_type=ct,
2123
+ url=reverse("admin:core_todo_changelist"),
2124
+ )
2125
+ Todo.objects.create(request="Task", is_user_data=True)
2126
+ resp = self.client.get(reverse("admin:index"))
2127
+ changelist = reverse("admin:core_todo_changelist")
2128
+ self.assertNotContains(resp, f'href="{changelist}"')
2129
+
2130
+ def test_dashboard_hides_todos_without_release_manager(self):
2131
+ todo = Todo.objects.create(request="Only Release Manager")
2132
+ User = get_user_model()
2133
+ other_user = User.objects.create_superuser(
2134
+ username="norole", password="pwd", email="norole@example.com"
2135
+ )
2136
+ self.client.force_login(other_user)
2137
+ resp = self.client.get(reverse("admin:index"))
2138
+ self.assertNotContains(resp, "Release manager tasks")
2139
+ self.assertNotContains(resp, todo.request)
2140
+
2141
+ def test_dashboard_hides_todos_for_non_terminal_node(self):
2142
+ todo = Todo.objects.create(request="Terminal Tasks")
2143
+ from nodes.models import NodeRole
2144
+
2145
+ control_role, _ = NodeRole.objects.get_or_create(name="Control")
2146
+ self.node.role = control_role
2147
+ self.node.save(update_fields=["role"])
2148
+ resp = self.client.get(reverse("admin:index"))
2149
+ self.assertNotContains(resp, "Release manager tasks")
2150
+ self.assertNotContains(resp, todo.request)
2151
+
2152
+ def test_dashboard_shows_todos_for_delegate_release_manager(self):
2153
+ todo = Todo.objects.create(request="Delegate Task")
2154
+ User = get_user_model()
2155
+ delegate = User.objects.create_superuser(
2156
+ username="delegate",
2157
+ password="pwd",
2158
+ email="delegate@example.com",
2159
+ )
2160
+ ReleaseManager.objects.create(user=delegate)
2161
+ operator = User.objects.create_superuser(
2162
+ username="operator",
2163
+ password="pwd",
2164
+ email="operator@example.com",
2165
+ )
2166
+ operator.operate_as = delegate
2167
+ operator.full_clean()
2168
+ operator.save()
2169
+ self.client.force_login(operator)
2170
+ resp = self.client.get(reverse("admin:index"))
2171
+ self.assertContains(resp, "Release manager tasks")
2172
+ self.assertContains(resp, todo.request)
2173
+
2174
+
2175
+ class AdminActionListTests(TestCase):
2176
+ def setUp(self):
2177
+ User = get_user_model()
2178
+ User.objects.filter(username="action-admin").delete()
2179
+ self.user = User.objects.create_superuser(
2180
+ username="action-admin",
2181
+ password="pwd",
2182
+ email="action@example.com",
2183
+ )
2184
+ self.factory = RequestFactory()
2185
+
2186
+ def test_profile_actions_available_without_selection(self):
2187
+ from pages.templatetags.admin_extras import model_admin_actions
2188
+
2189
+ request = self.factory.get("/")
2190
+ request.user = self.user
2191
+ context = {"request": request}
2192
+
2193
+ registered = [
2194
+ (model._meta.app_label, model._meta.object_name)
2195
+ for model, admin_instance in admin.site._registry.items()
2196
+ if isinstance(admin_instance, ProfileAdminMixin)
2197
+ ]
2198
+
2199
+ for app_label, object_name in registered:
2200
+ with self.subTest(model=f"{app_label}.{object_name}"):
2201
+ actions = model_admin_actions(context, app_label, object_name)
2202
+ labels = {action["label"] for action in actions}
2203
+ self.assertIn("Active Profile", labels)
2204
+
2205
+ def test_quote_report_link_available(self):
2206
+ from pages.templatetags.admin_extras import model_admin_actions
2207
+
2208
+ request = self.factory.get("/")
2209
+ request.user = self.user
2210
+ context = {"request": request}
2211
+
2212
+ actions = model_admin_actions(context, "core", "OdooProfile")
2213
+ labels = {action["label"] for action in actions}
2214
+ self.assertIn("Quote Report", labels)
2215
+ url = next(
2216
+ action["url"]
2217
+ for action in actions
2218
+ if action["label"] == "Quote Report"
2219
+ )
2220
+ self.assertEqual(
2221
+ url,
2222
+ reverse(
2223
+ "admin:core_odooprofile_actions",
2224
+ kwargs={"tool": "generate_quote_report"},
2225
+ ),
2226
+ )
2227
+
2228
+ def test_send_net_message_link_available(self):
2229
+ from pages.templatetags.admin_extras import model_admin_actions
2230
+
2231
+ request = self.factory.get("/")
2232
+ request.user = self.user
2233
+ context = {"request": request}
2234
+
2235
+ actions = model_admin_actions(context, "nodes", "NetMessage")
2236
+ labels = {action["label"] for action in actions}
2237
+ self.assertIn("Send Net Message", labels)
2238
+ url = next(
2239
+ action["url"]
2240
+ for action in actions
2241
+ if action["label"] == "Send Net Message"
2242
+ )
2243
+ self.assertEqual(url, reverse("admin:nodes_netmessage_send"))
2244
+
2245
+
2246
+ class AdminModelGraphViewTests(TestCase):
2247
+ def setUp(self):
2248
+ self.client = Client()
2249
+ User = get_user_model()
2250
+ self.user = User.objects.create_user(
2251
+ username="graph-staff", password="pwd", is_staff=True
2252
+ )
2253
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
2254
+ self.client.force_login(self.user)
2255
+
2256
+ def _mock_graph(self):
2257
+ fake_graph = Mock()
2258
+ fake_graph.source = "digraph {}"
2259
+ fake_graph.engine = "dot"
2260
+
2261
+ def pipe_side_effect(*args, **kwargs):
2262
+ fmt = kwargs.get("format") or (args[0] if args else None)
2263
+ if fmt == "svg":
2264
+ return '<svg xmlns="http://www.w3.org/2000/svg"></svg>'
2265
+ if fmt == "pdf":
2266
+ return b"%PDF-1.4 mock"
2267
+ raise AssertionError(f"Unexpected format: {fmt}")
2268
+
2269
+ fake_graph.pipe.side_effect = pipe_side_effect
2270
+ return fake_graph
2271
+
2272
+ def test_model_graph_renders_controls_and_download_link(self):
2273
+ url = reverse("admin-model-graph", args=["pages"])
2274
+ graph = self._mock_graph()
2275
+ with (
2276
+ patch("pages.views._build_model_graph", return_value=graph),
2277
+ patch("pages.views.shutil.which", return_value="/usr/bin/dot"),
2278
+ ):
2279
+ response = self.client.get(url)
2280
+
2281
+ self.assertEqual(response.status_code, 200)
2282
+ self.assertContains(response, "data-model-graph")
2283
+ self.assertContains(response, 'data-graph-action="zoom-in"')
2284
+ self.assertContains(response, "Download PDF")
2285
+ self.assertIn("?format=pdf", response.context_data["download_url"])
2286
+ args, kwargs = graph.pipe.call_args
2287
+ self.assertEqual(kwargs.get("format"), "svg")
2288
+ self.assertEqual(kwargs.get("encoding"), "utf-8")
2289
+
2290
+ def test_model_graph_pdf_download(self):
2291
+ url = reverse("admin-model-graph", args=["pages"])
2292
+ graph = self._mock_graph()
2293
+ with (
2294
+ patch("pages.views._build_model_graph", return_value=graph),
2295
+ patch("pages.views.shutil.which", return_value="/usr/bin/dot"),
2296
+ ):
2297
+ response = self.client.get(url, {"format": "pdf"})
2298
+
2299
+ self.assertEqual(response.status_code, 200)
2300
+ self.assertEqual(response["Content-Type"], "application/pdf")
2301
+ app_config = django_apps.get_app_config("pages")
2302
+ expected_slug = slugify(app_config.verbose_name) or app_config.label
2303
+ self.assertIn(
2304
+ f"{expected_slug}-model-graph.pdf", response["Content-Disposition"]
2305
+ )
2306
+ self.assertEqual(response.content, b"%PDF-1.4 mock")
2307
+ args, kwargs = graph.pipe.call_args
2308
+ self.assertEqual(kwargs.get("format"), "pdf")
2309
+
2310
+
2311
+ class DatasetteTests(TestCase):
2312
+ def setUp(self):
2313
+ self.client = Client()
2314
+ User = get_user_model()
2315
+ self.user = User.objects.create_user(username="ds", password="pwd")
2316
+ Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
2317
+
2318
+ def test_datasette_auth_endpoint(self):
2319
+ resp = self.client.get(reverse("pages:datasette-auth"))
2320
+ self.assertEqual(resp.status_code, 401)
2321
+ self.client.force_login(self.user)
2322
+ resp = self.client.get(reverse("pages:datasette-auth"))
2323
+ self.assertEqual(resp.status_code, 200)
2324
+
2325
+ def test_navbar_includes_datasette_when_enabled(self):
2326
+ lock_dir = Path(settings.BASE_DIR) / "locks"
2327
+ lock_dir.mkdir(exist_ok=True)
2328
+ lock_file = lock_dir / "datasette.lck"
2329
+ try:
2330
+ lock_file.touch()
2331
+ resp = self.client.get(reverse("pages:index"))
2332
+ self.assertContains(resp, 'href="/data/"')
2333
+ finally:
2334
+ lock_file.unlink(missing_ok=True)
2335
+
2336
+ def test_admin_home_includes_datasette_button_when_enabled(self):
2337
+ lock_dir = Path(settings.BASE_DIR) / "locks"
2338
+ lock_dir.mkdir(exist_ok=True)
2339
+ lock_file = lock_dir / "datasette.lck"
2340
+ try:
2341
+ lock_file.touch()
2342
+ self.user.is_staff = True
2343
+ self.user.is_superuser = True
2344
+ self.user.save()
2345
+ self.client.force_login(self.user)
2346
+ resp = self.client.get(reverse("admin:index"))
2347
+ self.assertContains(resp, 'href="/data/"')
2348
+ self.assertContains(resp, ">Datasette<")
2349
+ finally:
2350
+ lock_file.unlink(missing_ok=True)
2351
+
2352
+
2353
+ class UserStorySubmissionTests(TestCase):
2354
+ def setUp(self):
2355
+ self.client = Client()
2356
+ self.url = reverse("pages:user-story-submit")
2357
+ User = get_user_model()
2358
+ self.user = User.objects.create_user(username="feedbacker", password="pwd")
2359
+
2360
+ def test_authenticated_submission_defaults_to_username(self):
2361
+ self.client.force_login(self.user)
2362
+ response = self.client.post(
2363
+ self.url,
2364
+ {
2365
+ "rating": 5,
2366
+ "comments": "Loved the experience!",
2367
+ "path": "/wizard/step-1/",
2368
+ "take_screenshot": "1",
2369
+ },
2370
+ )
2371
+ self.assertEqual(response.status_code, 200)
2372
+ self.assertEqual(response.json(), {"success": True})
2373
+ story = UserStory.objects.get()
2374
+ self.assertEqual(story.name, "feedbacker")
2375
+ self.assertEqual(story.rating, 5)
2376
+ self.assertEqual(story.path, "/wizard/step-1/")
2377
+ self.assertEqual(story.user, self.user)
2378
+ self.assertEqual(story.owner, self.user)
2379
+ self.assertTrue(story.is_user_data)
2380
+ self.assertTrue(story.take_screenshot)
2381
+
2382
+ def test_anonymous_submission_uses_provided_name(self):
2383
+ response = self.client.post(
2384
+ self.url,
2385
+ {
2386
+ "name": "Guest Reviewer",
2387
+ "rating": 3,
2388
+ "comments": "It was fine.",
2389
+ "path": "/status/",
2390
+ "take_screenshot": "on",
2391
+ },
2392
+ )
2393
+ self.assertEqual(response.status_code, 200)
2394
+ self.assertEqual(UserStory.objects.count(), 1)
2395
+ story = UserStory.objects.get()
2396
+ self.assertEqual(story.name, "Guest Reviewer")
2397
+ self.assertIsNone(story.user)
2398
+ self.assertIsNone(story.owner)
2399
+ self.assertEqual(story.comments, "It was fine.")
2400
+ self.assertTrue(story.take_screenshot)
2401
+
2402
+ def test_invalid_rating_returns_errors(self):
2403
+ response = self.client.post(
2404
+ self.url,
2405
+ {
2406
+ "rating": 7,
2407
+ "comments": "Way off the scale",
2408
+ "path": "/feedback/",
2409
+ "take_screenshot": "1",
2410
+ },
2411
+ )
2412
+ self.assertEqual(response.status_code, 400)
2413
+ data = response.json()
2414
+ self.assertFalse(UserStory.objects.exists())
2415
+ self.assertIn("rating", data.get("errors", {}))
2416
+
2417
+ def test_anonymous_submission_without_name_uses_fallback(self):
2418
+ response = self.client.post(
2419
+ self.url,
2420
+ {
2421
+ "rating": 2,
2422
+ "comments": "Could be better.",
2423
+ "path": "/feedback/",
2424
+ "take_screenshot": "1",
2425
+ },
2426
+ )
2427
+ self.assertEqual(response.status_code, 200)
2428
+ story = UserStory.objects.get()
2429
+ self.assertEqual(story.name, "Anonymous")
2430
+ self.assertIsNone(story.user)
2431
+ self.assertIsNone(story.owner)
2432
+ self.assertTrue(story.take_screenshot)
2433
+
2434
+ def test_submission_without_screenshot_request(self):
2435
+ response = self.client.post(
2436
+ self.url,
2437
+ {
2438
+ "rating": 4,
2439
+ "comments": "Skip the screenshot, please.",
2440
+ "path": "/feedback/",
2441
+ },
2442
+ )
2443
+ self.assertEqual(response.status_code, 200)
2444
+ story = UserStory.objects.get()
2445
+ self.assertFalse(story.take_screenshot)
2446
+ self.assertIsNone(story.owner)
2447
+
2448
+
2449
+ class UserStoryAdminActionTests(TestCase):
2450
+ def setUp(self):
2451
+ self.client = Client()
2452
+ self.factory = RequestFactory()
2453
+ User = get_user_model()
2454
+ self.admin_user = User.objects.create_superuser(
2455
+ username="admin",
2456
+ email="admin@example.com",
2457
+ password="pwd",
2458
+ )
2459
+ self.story = UserStory.objects.create(
2460
+ path="/",
2461
+ name="Feedback",
2462
+ rating=4,
2463
+ comments="Helpful notes",
2464
+ take_screenshot=True,
2465
+ )
2466
+ self.admin = UserStoryAdmin(UserStory, admin.site)
2467
+
2468
+ def _build_request(self):
2469
+ request = self.factory.post("/admin/pages/userstory/")
2470
+ request.user = self.admin_user
2471
+ request.session = self.client.session
2472
+ setattr(request, "_messages", FallbackStorage(request))
2473
+ return request
2474
+
2475
+ @patch("pages.models.github_issues.create_issue")
2476
+ def test_create_github_issues_action_updates_issue_fields(self, mock_create_issue):
2477
+ response = MagicMock()
2478
+ response.json.return_value = {
2479
+ "html_url": "https://github.com/example/repo/issues/123",
2480
+ "number": 123,
2481
+ }
2482
+ mock_create_issue.return_value = response
2483
+
2484
+ request = self._build_request()
2485
+ queryset = UserStory.objects.filter(pk=self.story.pk)
2486
+ self.admin.create_github_issues(request, queryset)
2487
+
2488
+ self.story.refresh_from_db()
2489
+ self.assertEqual(self.story.github_issue_number, 123)
2490
+ self.assertEqual(
2491
+ self.story.github_issue_url,
2492
+ "https://github.com/example/repo/issues/123",
2493
+ )
2494
+
2495
+ mock_create_issue.assert_called_once()
2496
+ args, kwargs = mock_create_issue.call_args
2497
+ self.assertIn("Feedback for", args[0])
2498
+ self.assertIn("**Rating:**", args[1])
2499
+ self.assertEqual(kwargs.get("labels"), ["feedback"])
2500
+ self.assertEqual(
2501
+ kwargs.get("fingerprint"), f"user-story:{self.story.pk}"
2502
+ )
2503
+
2504
+ @patch("pages.models.github_issues.create_issue")
2505
+ def test_create_github_issues_action_skips_existing_issue(self, mock_create_issue):
2506
+ self.story.github_issue_url = "https://github.com/example/repo/issues/5"
2507
+ self.story.github_issue_number = 5
2508
+ self.story.save(update_fields=["github_issue_url", "github_issue_number"])
2509
+
2510
+ request = self._build_request()
2511
+ queryset = UserStory.objects.filter(pk=self.story.pk)
2512
+ self.admin.create_github_issues(request, queryset)
2513
+
2514
+ mock_create_issue.assert_not_called()
2515
+
2516
+
2517
+ class ClientReportLiveUpdateTests(TestCase):
2518
+ def setUp(self):
2519
+ self.client = Client()
2520
+
2521
+ def test_client_report_includes_interval(self):
2522
+ resp = self.client.get(reverse("pages:client-report"))
2523
+ self.assertEqual(resp.context["request"].live_update_interval, 5)
2524
+ self.assertContains(resp, "setInterval(() => location.reload()")
2525
+
2526
+
2527
+ class ScreenshotSpecInfrastructureTests(TestCase):
2528
+ def test_runner_creates_outputs_and_cleans_old_samples(self):
2529
+ spec = ScreenshotSpec(slug="spec-test", url="/")
2530
+ with tempfile.TemporaryDirectory() as tmp:
2531
+ temp_dir = Path(tmp)
2532
+ screenshot_path = temp_dir / "source.png"
2533
+ screenshot_path.write_bytes(b"fake")
2534
+ ContentSample.objects.create(
2535
+ kind=ContentSample.IMAGE,
2536
+ path="old.png",
2537
+ method="spec:old",
2538
+ hash="old-hash",
2539
+ )
2540
+ ContentSample.objects.filter(hash="old-hash").update(
2541
+ created_at=timezone.now() - timedelta(days=8)
2542
+ )
2543
+ with (
2544
+ patch(
2545
+ "pages.screenshot_specs.base.capture_screenshot",
2546
+ return_value=screenshot_path,
2547
+ ) as capture_mock,
2548
+ patch(
2549
+ "pages.screenshot_specs.base.save_screenshot", return_value=None
2550
+ ) as save_mock,
2551
+ ):
2552
+ with ScreenshotSpecRunner(temp_dir) as runner:
2553
+ result = runner.run(spec)
2554
+ self.assertTrue(result.image_path.exists())
2555
+ self.assertTrue(result.base64_path.exists())
2556
+ self.assertEqual(ContentSample.objects.filter(hash="old-hash").count(), 0)
2557
+ capture_mock.assert_called_once()
2558
+ save_mock.assert_called_once_with(screenshot_path, method="spec:spec-test")
2559
+
2560
+ def test_runner_respects_manual_reason(self):
2561
+ spec = ScreenshotSpec(slug="manual-spec", url="/", manual_reason="hardware")
2562
+ with tempfile.TemporaryDirectory() as tmp:
2563
+ with ScreenshotSpecRunner(Path(tmp)) as runner:
2564
+ with self.assertRaises(ScreenshotUnavailable):
2565
+ runner.run(spec)
2566
+
2567
+
2568
+ class CaptureUIScreenshotsCommandTests(TestCase):
2569
+ def tearDown(self):
2570
+ registry.unregister("manual-cmd")
2571
+ registry.unregister("auto-cmd")
2572
+
2573
+ def test_manual_spec_emits_warning(self):
2574
+ spec = ScreenshotSpec(slug="manual-cmd", url="/", manual_reason="manual")
2575
+ registry.register(spec)
2576
+ out = StringIO()
2577
+ call_command("capture_ui_screenshots", "--spec", spec.slug, stdout=out)
2578
+ self.assertIn("Skipping manual screenshot", out.getvalue())
2579
+
2580
+ def test_command_invokes_runner(self):
2581
+ spec = ScreenshotSpec(slug="auto-cmd", url="/")
2582
+ registry.register(spec)
2583
+ with tempfile.TemporaryDirectory() as tmp:
2584
+ tmp_path = Path(tmp)
2585
+ image_path = tmp_path / "auto-cmd.png"
2586
+ base64_path = tmp_path / "auto-cmd.base64"
2587
+ image_path.write_bytes(b"fake")
2588
+ base64_path.write_text("Zg==", encoding="utf-8")
2589
+ runner = Mock()
2590
+ runner.__enter__ = Mock(return_value=runner)
2591
+ runner.__exit__ = Mock(return_value=None)
2592
+ runner.run.return_value = SimpleNamespace(
2593
+ image_path=image_path,
2594
+ base64_path=base64_path,
2595
+ sample=None,
2596
+ )
2597
+ with patch(
2598
+ "pages.management.commands.capture_ui_screenshots.ScreenshotSpecRunner",
2599
+ return_value=runner,
2600
+ ) as runner_cls:
2601
+ out = StringIO()
2602
+ call_command(
2603
+ "capture_ui_screenshots",
2604
+ "--spec",
2605
+ spec.slug,
2606
+ "--output-dir",
2607
+ tmp_path,
2608
+ stdout=out,
2609
+ )
2610
+ runner_cls.assert_called_once()
2611
+ runner.run.assert_called_once_with(spec)
2612
+ self.assertIn("Captured 'auto-cmd'", out.getvalue())