arthexis 0.1.16__py3-none-any.whl → 0.1.28__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 (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
pages/tests.py CHANGED
@@ -3,23 +3,26 @@ import os
3
3
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
4
 
5
5
  import django
6
+ import pytest
6
7
 
7
8
  django.setup()
8
9
 
9
10
  from django.test import Client, RequestFactory, TestCase, SimpleTestCase, override_settings
10
11
  from django.test.utils import CaptureQueriesContext
11
12
  from django.urls import reverse
13
+ from django.shortcuts import resolve_url
12
14
  from django.templatetags.static import static
13
15
  from urllib.parse import quote
14
16
  from django.contrib.auth import get_user_model
15
17
  from django.contrib.sites.models import Site
16
- from django.contrib import admin
18
+ from django.contrib import admin, messages
17
19
  from django.contrib.messages.storage.fallback import FallbackStorage
18
20
  from django.core.exceptions import DisallowedHost
19
21
  from django.core.cache import cache
20
22
  from django.db import connection
21
23
  import socket
22
24
  from django.db import connection
25
+ from pages import site_config
23
26
  from pages.models import (
24
27
  Application,
25
28
  Landing,
@@ -32,6 +35,8 @@ from pages.models import (
32
35
  UserManual,
33
36
  UserStory,
34
37
  )
38
+ from django.http import FileResponse
39
+
35
40
  from pages.admin import (
36
41
  ApplicationAdmin,
37
42
  UserManualAdmin,
@@ -47,31 +52,40 @@ from pages.screenshot_specs import (
47
52
  )
48
53
  from pages.context_processors import nav_links
49
54
  from django.apps import apps as django_apps
55
+ from config.middleware import SiteHttpsRedirectMiddleware
50
56
  from core import mailer
51
57
  from core.admin import ProfileAdminMixin
52
58
  from core.models import (
53
59
  AdminHistory,
60
+ ClientReport,
54
61
  InviteLead,
55
62
  Package,
56
63
  Reference,
57
64
  RFID,
58
65
  ReleaseManager,
59
66
  SecurityGroup,
67
+ GoogleCalendarProfile,
60
68
  Todo,
61
69
  TOTPDeviceSettings,
62
70
  )
71
+ from ocpp.models import Charger
63
72
  from django.core.files.uploadedfile import SimpleUploadedFile
64
73
  import base64
74
+ import json
65
75
  import tempfile
66
76
  import shutil
77
+ from datetime import timedelta
67
78
  from io import StringIO
68
79
  from django.conf import settings
80
+ from django.utils import timezone
81
+ from django.utils.html import escape
69
82
  from pathlib import Path
70
83
  from unittest.mock import MagicMock, Mock, call, patch
71
84
  from types import SimpleNamespace
72
85
  from django.core.management import call_command
73
86
  import re
74
87
  from django.contrib.contenttypes.models import ContentType
88
+ from django.http import HttpResponse
75
89
  from datetime import (
76
90
  date,
77
91
  datetime,
@@ -82,6 +96,7 @@ from datetime import (
82
96
  from django.core import mail
83
97
  from django.utils import timezone
84
98
  from django.utils.text import slugify
99
+ from django.utils import translation
85
100
  from django.utils.translation import gettext
86
101
  from django_otp import DEVICE_ID_SESSION_KEY
87
102
  from django_otp.oath import TOTP
@@ -96,6 +111,7 @@ from nodes.models import (
96
111
  NodeRole,
97
112
  NodeFeature,
98
113
  NodeFeatureAssignment,
114
+ NetMessage,
99
115
  )
100
116
  from django.contrib.auth.models import AnonymousUser
101
117
 
@@ -109,9 +125,26 @@ class LoginViewTests(TestCase):
109
125
  self.user = User.objects.create_user(username="user", password="pwd")
110
126
  Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
111
127
 
128
+ def _enable_rfid_scanner(self):
129
+ node, _ = Node.objects.get_or_create(
130
+ mac_address=Node.get_current_mac(),
131
+ defaults={"hostname": "local-node"},
132
+ )
133
+ feature, _ = NodeFeature.objects.get_or_create(
134
+ slug="rfid-scanner", defaults={"display": "RFID Scanner"}
135
+ )
136
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
137
+ return node
138
+
112
139
  def test_login_link_in_navbar(self):
113
140
  resp = self.client.get(reverse("pages:index"))
114
- self.assertContains(resp, 'href="/login/"')
141
+ login_url = resolve_url(settings.LOGIN_URL)
142
+ self.assertContains(resp, f'href="{login_url}"')
143
+
144
+ @override_settings(LOGIN_URL="/staff/login/")
145
+ def test_login_link_uses_configured_login_url(self):
146
+ resp = self.client.get(reverse("pages:index"))
147
+ self.assertContains(resp, 'href="/staff/login/"')
115
148
 
116
149
  def test_login_page_shows_authenticator_toggle(self):
117
150
  resp = self.client.get(reverse("pages:login"))
@@ -187,6 +220,83 @@ class LoginViewTests(TestCase):
187
220
  )
188
221
  self.assertRedirects(resp, "/nodes/list/")
189
222
 
223
+ def test_login_page_shows_rfid_link_when_feature_enabled(self):
224
+ self._enable_rfid_scanner()
225
+ resp = self.client.get(reverse("pages:login"))
226
+ self.assertContains(resp, reverse("pages:rfid-login"))
227
+
228
+ def test_login_page_detects_rfid_lock_without_mac_address(self):
229
+ Node.objects.all().delete()
230
+ NodeFeature.objects.get_or_create(
231
+ slug="rfid-scanner", defaults={"display": "RFID Scanner"}
232
+ )
233
+ with tempfile.TemporaryDirectory() as tempdir:
234
+ locks_dir = Path(tempdir) / "locks"
235
+ locks_dir.mkdir()
236
+ (locks_dir / "rfid.lck").touch()
237
+ Node.objects.create(
238
+ hostname="local-node",
239
+ base_path=tempdir,
240
+ current_relation=Node.Relation.SELF,
241
+ mac_address=None,
242
+ )
243
+
244
+ resp = self.client.get(reverse("pages:login"))
245
+
246
+ self.assertContains(resp, reverse("pages:rfid-login"))
247
+
248
+ def test_rfid_login_page_requires_feature(self):
249
+ resp = self.client.get(reverse("pages:rfid-login"))
250
+ self.assertEqual(resp.status_code, 404)
251
+
252
+ def test_rfid_login_page_redirects_authenticated_user(self):
253
+ self._enable_rfid_scanner()
254
+ self.client.force_login(self.user)
255
+ resp = self.client.get(reverse("pages:rfid-login"))
256
+ self.assertRedirects(resp, "/")
257
+
258
+ def test_rfid_login_page_includes_scan_url(self):
259
+ self._enable_rfid_scanner()
260
+ resp = self.client.get(reverse("pages:rfid-login"))
261
+ self.assertEqual(resp.status_code, 200)
262
+ self.assertEqual(resp.context["login_api_url"], reverse("rfid-login"))
263
+ self.assertEqual(resp.context["scan_api_url"], reverse("rfid-scan-next"))
264
+
265
+ def test_homepage_excludes_version_banner_for_anonymous(self):
266
+ response = self.client.get(reverse("pages:index"))
267
+
268
+ self.assertEqual(response.status_code, 200)
269
+ self.assertNotContains(response, "__versionCheckInitialized")
270
+
271
+ def test_homepage_includes_version_banner_for_staff(self):
272
+ self.client.force_login(self.staff)
273
+ response = self.client.get(reverse("pages:index"))
274
+
275
+ self.assertEqual(response.status_code, 200)
276
+ self.assertContains(response, "__versionCheckInitialized")
277
+
278
+
279
+ class AdminTemplateVersionBannerTests(TestCase):
280
+ def setUp(self):
281
+ self.client = Client()
282
+ User = get_user_model()
283
+ self.staff = User.objects.create_user(
284
+ username="admin-staff", password="pwd", is_staff=True
285
+ )
286
+
287
+ def test_admin_login_excludes_version_banner_for_anonymous(self):
288
+ response = self.client.get(reverse("admin:login"))
289
+
290
+ self.assertEqual(response.status_code, 200)
291
+ self.assertNotContains(response, "__versionCheckInitialized")
292
+
293
+ def test_admin_dashboard_includes_version_banner_for_staff(self):
294
+ self.client.force_login(self.staff)
295
+ response = self.client.get(reverse("admin:index"))
296
+
297
+ self.assertEqual(response.status_code, 200)
298
+ self.assertContains(response, "__versionCheckInitialized")
299
+
190
300
  def test_staff_redirects_next_when_specified(self):
191
301
  resp = self.client.post(
192
302
  reverse("pages:login") + "?next=/nodes/list/",
@@ -466,6 +576,23 @@ class InvitationTests(TestCase):
466
576
  self.assertEqual(lead.mac_address, "")
467
577
  self.assertEqual(len(mail.outbox), 0)
468
578
 
579
+ def test_request_invite_uses_original_referer(self):
580
+ InviteLead.objects.all().delete()
581
+ self.client.get(
582
+ reverse("pages:index"),
583
+ HTTP_REFERER="https://campaign.example/landing",
584
+ )
585
+
586
+ resp = self.client.post(
587
+ reverse("pages:request-invite"),
588
+ {"email": "origin@example.com"},
589
+ HTTP_REFERER="http://testserver/pages/request-invite/",
590
+ )
591
+
592
+ self.assertEqual(resp.status_code, 200)
593
+ lead = InviteLead.objects.get()
594
+ self.assertEqual(lead.referer, "https://campaign.example/landing")
595
+
469
596
  def test_request_invite_falls_back_to_send_mail(self):
470
597
  node = Node.objects.create(
471
598
  hostname="local", address="127.0.0.1", mac_address="00:11:22:33:44:55"
@@ -501,6 +628,7 @@ class InvitationTests(TestCase):
501
628
  lead = InviteLead.objects.get()
502
629
  self.assertEqual(lead.mac_address, "aa:bb:cc:dd:ee:ff")
503
630
 
631
+ @pytest.mark.feature("ap-router")
504
632
  @patch("pages.views.public_wifi.grant_public_access")
505
633
  @patch(
506
634
  "pages.views.public_wifi.resolve_mac_address",
@@ -659,7 +787,7 @@ class AdminDashboardAppListTests(TestCase):
659
787
  "hostname": socket.gethostname(),
660
788
  "address": socket.gethostbyname(socket.gethostname()),
661
789
  "base_path": settings.BASE_DIR,
662
- "port": 8000,
790
+ "port": 8888,
663
791
  },
664
792
  )
665
793
  self.node.features.clear()
@@ -672,19 +800,36 @@ class AdminDashboardAppListTests(TestCase):
672
800
 
673
801
  def test_horologia_hidden_without_celery_feature(self):
674
802
  resp = self.client.get(reverse("admin:index"))
675
- self.assertNotContains(resp, "5. Horologia MODELS")
803
+ self.assertNotContains(resp, "5. Horologia</a>")
676
804
 
677
805
  def test_horologia_visible_with_celery_feature(self):
678
806
  feature = NodeFeature.objects.create(slug="celery-queue", display="Celery Queue")
679
807
  NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
680
808
  resp = self.client.get(reverse("admin:index"))
681
- self.assertContains(resp, "5. Horologia MODELS")
809
+ self.assertContains(resp, "5. Horologia</a>")
682
810
 
683
811
  def test_horologia_visible_with_celery_lock(self):
684
812
  self.celery_lock.write_text("")
685
813
  resp = self.client.get(reverse("admin:index"))
686
- self.assertContains(resp, "5. Horologia MODELS")
814
+ self.assertContains(resp, "5. Horologia</a>")
815
+
816
+ def test_dashboard_shows_last_net_message(self):
817
+ NetMessage.objects.all().delete()
818
+ NetMessage.objects.create(subject="Older", body="First body")
819
+ NetMessage.objects.create(subject="Latest", body="Signal ready")
820
+
821
+ resp = self.client.get(reverse("admin:index"))
822
+
823
+ self.assertContains(resp, gettext("Net message"))
824
+ self.assertContains(resp, "Latest — Signal ready")
825
+ self.assertNotContains(resp, gettext("No net messages available"))
826
+
827
+ def test_dashboard_shows_placeholder_without_net_message(self):
828
+ NetMessage.objects.all().delete()
829
+
830
+ resp = self.client.get(reverse("admin:index"))
687
831
 
832
+ self.assertContains(resp, gettext("No net messages available"))
688
833
 
689
834
  class AdminSidebarTests(TestCase):
690
835
  def setUp(self):
@@ -707,6 +852,66 @@ class AdminSidebarTests(TestCase):
707
852
  self.assertContains(resp, 'id="admin-collapsible-apps"')
708
853
 
709
854
 
855
+ class AdminGoogleCalendarSidebarTests(TestCase):
856
+ def setUp(self):
857
+ self.client = Client()
858
+ User = get_user_model()
859
+ self.admin = User.objects.create_superuser(
860
+ username="calendar_admin", password="pwd", email="admin@example.com"
861
+ )
862
+ self.client.force_login(self.admin)
863
+ Site.objects.update_or_create(
864
+ id=1, defaults={"name": "test", "domain": "testserver"}
865
+ )
866
+ Node.objects.create(hostname="testserver", address="127.0.0.1")
867
+
868
+ def test_calendar_module_hidden_without_profile(self):
869
+ resp = self.client.get(reverse("admin:index"))
870
+ self.assertNotContains(resp, 'id="google-calendar-module"', html=False)
871
+
872
+ @patch("core.models.GoogleCalendarProfile.fetch_events")
873
+ def test_calendar_module_shows_events_for_user(self, fetch_events):
874
+ fetch_events.return_value = [
875
+ {
876
+ "summary": "Standup",
877
+ "start": timezone.now(),
878
+ "end": None,
879
+ "all_day": False,
880
+ "html_link": "https://calendar.google.com/event",
881
+ "location": "HQ",
882
+ }
883
+ ]
884
+ GoogleCalendarProfile.objects.create(
885
+ user=self.admin,
886
+ calendar_id="example@group.calendar.google.com",
887
+ api_key="secret",
888
+ display_name="Team Calendar",
889
+ )
890
+
891
+ resp = self.client.get(reverse("admin:index"))
892
+
893
+ self.assertContains(resp, 'id="google-calendar-module"', html=False)
894
+ self.assertContains(resp, "Standup")
895
+ self.assertContains(resp, "Open full calendar")
896
+ fetch_events.assert_called_once()
897
+
898
+ @patch("core.models.GoogleCalendarProfile.fetch_events")
899
+ def test_calendar_module_uses_group_profile(self, fetch_events):
900
+ fetch_events.return_value = []
901
+ group = SecurityGroup.objects.create(name="Calendar Group")
902
+ self.admin.groups.add(group)
903
+ GoogleCalendarProfile.objects.create(
904
+ group=group,
905
+ calendar_id="group@calendar.google.com",
906
+ api_key="secret",
907
+ )
908
+
909
+ resp = self.client.get(reverse("admin:index"))
910
+
911
+ self.assertContains(resp, 'id="google-calendar-module"', html=False)
912
+ fetch_events.assert_called_once()
913
+
914
+
710
915
  class ViewHistoryLoggingTests(TestCase):
711
916
  def setUp(self):
712
917
  self.client = Client()
@@ -715,8 +920,11 @@ class ViewHistoryLoggingTests(TestCase):
715
920
 
716
921
  def _reset_purge_task(self):
717
922
  from django_celery_beat.models import PeriodicTask
923
+ from core.celery_utils import periodic_task_name_variants
718
924
 
719
- PeriodicTask.objects.filter(name="pages_purge_landing_leads").delete()
925
+ PeriodicTask.objects.filter(
926
+ name__in=periodic_task_name_variants("pages_purge_landing_leads")
927
+ ).delete()
720
928
 
721
929
  def _create_local_node(self):
722
930
  node, _ = Node.objects.update_or_create(
@@ -725,7 +933,7 @@ class ViewHistoryLoggingTests(TestCase):
725
933
  "hostname": socket.gethostname(),
726
934
  "address": "127.0.0.1",
727
935
  "base_path": settings.BASE_DIR,
728
- "port": 8000,
936
+ "port": 8888,
729
937
  },
730
938
  )
731
939
  return node
@@ -795,7 +1003,8 @@ class ViewHistoryLoggingTests(TestCase):
795
1003
  )
796
1004
  landing = module.landings.get(path="/")
797
1005
  landing.label = "Home Landing"
798
- landing.save(update_fields=["label"])
1006
+ landing.track_leads = True
1007
+ landing.save(update_fields=["label", "track_leads"])
799
1008
 
800
1009
  resp = self.client.get(
801
1010
  reverse("pages:index"), HTTP_REFERER="https://example.com/ref"
@@ -807,6 +1016,35 @@ class ViewHistoryLoggingTests(TestCase):
807
1016
  self.assertEqual(lead.path, "/")
808
1017
  self.assertEqual(lead.referer, "https://example.com/ref")
809
1018
 
1019
+ def test_pages_config_purges_old_view_history(self):
1020
+ ViewHistory.objects.all().delete()
1021
+
1022
+ old_entry = ViewHistory.objects.create(
1023
+ path="/old/",
1024
+ method="GET",
1025
+ status_code=200,
1026
+ status_text="OK",
1027
+ )
1028
+ new_entry = ViewHistory.objects.create(
1029
+ path="/recent/",
1030
+ method="GET",
1031
+ status_code=200,
1032
+ status_text="OK",
1033
+ )
1034
+
1035
+ ViewHistory.objects.filter(pk=old_entry.pk).update(
1036
+ visited_at=timezone.now() - timedelta(days=20)
1037
+ )
1038
+ ViewHistory.objects.filter(pk=new_entry.pk).update(
1039
+ visited_at=timezone.now() - timedelta(days=10)
1040
+ )
1041
+
1042
+ config = django_apps.get_app_config("pages")
1043
+ config._purge_view_history()
1044
+
1045
+ self.assertFalse(ViewHistory.objects.filter(pk=old_entry.pk).exists())
1046
+ self.assertTrue(ViewHistory.objects.filter(pk=new_entry.pk).exists())
1047
+
810
1048
  def test_landing_visit_does_not_record_lead_without_celery(self):
811
1049
  role = NodeRole.objects.create(name="no-celery-role")
812
1050
  application = Application.objects.create(
@@ -820,7 +1058,8 @@ class ViewHistoryLoggingTests(TestCase):
820
1058
  )
821
1059
  landing = module.landings.get(path="/")
822
1060
  landing.label = "No Celery"
823
- landing.save(update_fields=["label"])
1061
+ landing.track_leads = True
1062
+ landing.save(update_fields=["label", "track_leads"])
824
1063
 
825
1064
  resp = self.client.get(reverse("pages:index"))
826
1065
 
@@ -840,7 +1079,8 @@ class ViewHistoryLoggingTests(TestCase):
840
1079
  )
841
1080
  landing = module.landings.get(path="/")
842
1081
  landing.enabled = False
843
- landing.save(update_fields=["enabled"])
1082
+ landing.track_leads = True
1083
+ landing.save(update_fields=["enabled", "track_leads"])
844
1084
 
845
1085
  resp = self.client.get(reverse("pages:index"))
846
1086
 
@@ -902,6 +1142,44 @@ class ViewHistoryAdminTests(TestCase):
902
1142
  self.assertEqual(totals.get("/"), 2)
903
1143
  self.assertEqual(totals.get("/about/"), 1)
904
1144
 
1145
+ def test_graph_data_endpoint_respects_days_parameter(self):
1146
+ ViewHistory.all_objects.all().delete()
1147
+ reference_date = date(2025, 5, 1)
1148
+ tz = timezone.get_current_timezone()
1149
+ path = "/range/"
1150
+
1151
+ for offset in range(10):
1152
+ entry = ViewHistory.objects.create(
1153
+ path=path,
1154
+ method="GET",
1155
+ status_code=200,
1156
+ status_text="OK",
1157
+ error_message="",
1158
+ view_name="pages:index",
1159
+ )
1160
+ visited_date = reference_date - timedelta(days=offset)
1161
+ visited_at = timezone.make_aware(
1162
+ datetime.combine(visited_date, datetime_time(12, 0)), tz
1163
+ )
1164
+ entry.visited_at = visited_at
1165
+ entry.save(update_fields=["visited_at"])
1166
+
1167
+ url = reverse("admin:pages_viewhistory_traffic_data")
1168
+ with patch("pages.admin.timezone.localdate", return_value=reference_date):
1169
+ resp = self.client.get(url, {"days": 7})
1170
+
1171
+ self.assertEqual(resp.status_code, 200)
1172
+ data = resp.json()
1173
+
1174
+ self.assertEqual(len(data.get("labels", [])), 7)
1175
+ self.assertEqual(data.get("meta", {}).get("start"), (reference_date - timedelta(days=6)).isoformat())
1176
+ self.assertEqual(data.get("meta", {}).get("end"), reference_date.isoformat())
1177
+
1178
+ totals = {
1179
+ dataset["label"]: sum(dataset["data"]) for dataset in data.get("datasets", [])
1180
+ }
1181
+ self.assertEqual(totals.get(path), 7)
1182
+
905
1183
  def test_graph_data_includes_late_evening_visits(self):
906
1184
  target_date = date(2025, 9, 27)
907
1185
  entry = ViewHistory.objects.create(
@@ -964,7 +1242,7 @@ class LandingLeadAdminTests(TestCase):
964
1242
  "hostname": socket.gethostname(),
965
1243
  "address": "127.0.0.1",
966
1244
  "base_path": settings.BASE_DIR,
967
- "port": 8000,
1245
+ "port": 8888,
968
1246
  },
969
1247
  )
970
1248
  self.node.features.clear()
@@ -972,8 +1250,11 @@ class LandingLeadAdminTests(TestCase):
972
1250
 
973
1251
  def _reset_purge_task(self):
974
1252
  from django_celery_beat.models import PeriodicTask
1253
+ from core.celery_utils import periodic_task_name_variants
975
1254
 
976
- PeriodicTask.objects.filter(name="pages_purge_landing_leads").delete()
1255
+ PeriodicTask.objects.filter(
1256
+ name__in=periodic_task_name_variants("pages_purge_landing_leads")
1257
+ ).delete()
977
1258
 
978
1259
  def test_changelist_warns_without_celery(self):
979
1260
  url = reverse("admin:pages_landinglead_changelist")
@@ -1109,6 +1390,50 @@ class LogViewerAdminTests(SimpleTestCase):
1109
1390
  self.assertEqual(context["selected_log"], "selected.log")
1110
1391
  self.assertIn("hello world", context["log_content"])
1111
1392
 
1393
+ def test_log_viewer_applies_line_limit(self):
1394
+ content = "\n".join(f"line {i}" for i in range(50))
1395
+ self._create_log("limited.log", content)
1396
+ response = self._render({"log": "limited.log", "limit": "20"})
1397
+ context = response.context_data
1398
+ self.assertEqual(context["log_limit_choice"], "20")
1399
+ self.assertIn("line 49", context["log_content"])
1400
+ self.assertIn("line 30", context["log_content"])
1401
+ self.assertNotIn("line 29", context["log_content"])
1402
+
1403
+ def test_log_viewer_all_limit_returns_full_log(self):
1404
+ content = "first\nsecond\nthird"
1405
+ self._create_log("all.log", content)
1406
+ response = self._render({"log": "all.log", "limit": "all"})
1407
+ context = response.context_data
1408
+ self.assertEqual(context["log_limit_choice"], "all")
1409
+ self.assertIn("first", context["log_content"])
1410
+ self.assertIn("second", context["log_content"])
1411
+
1412
+ def test_log_viewer_invalid_limit_defaults_to_20(self):
1413
+ content = "\n".join(f"item {i}" for i in range(5))
1414
+ self._create_log("invalid-limit.log", content)
1415
+ response = self._render({"log": "invalid-limit.log", "limit": "oops"})
1416
+ context = response.context_data
1417
+ self.assertEqual(context["log_limit_choice"], "20")
1418
+
1419
+ def test_log_viewer_downloads_selected_log(self):
1420
+ self._create_log("download.log", "downloadable content")
1421
+ request = self._build_request({"log": "download.log", "download": "1"})
1422
+ context = {
1423
+ "site_title": "Constellation",
1424
+ "site_header": "Constellation",
1425
+ "site_url": "/",
1426
+ "available_apps": [],
1427
+ }
1428
+ with patch("pages.admin.admin.site.each_context", return_value=context), patch(
1429
+ "pages.context_processors.get_site", return_value=None
1430
+ ):
1431
+ response = log_viewer(request)
1432
+ self.assertIsInstance(response, FileResponse)
1433
+ self.assertIn("attachment", response["Content-Disposition"])
1434
+ content = b"".join(response.streaming_content).decode()
1435
+ self.assertIn("downloadable content", content)
1436
+
1112
1437
  def test_log_viewer_reports_missing_log(self):
1113
1438
  response = self._render({"log": "missing.log"})
1114
1439
  self.assertIn("requested log could not be found", response.context_data["log_error"])
@@ -1145,15 +1470,131 @@ class AdminModelStatusTests(TestCase):
1145
1470
 
1146
1471
  Node.objects.create(hostname="testserver", address="127.0.0.1")
1147
1472
 
1148
- @patch("pages.templatetags.admin_extras.connection.introspection.table_names")
1149
- def test_status_dots_render(self, mock_tables):
1150
- from django.db import connection
1151
-
1152
- tables = type(connection.introspection).table_names(connection.introspection)
1153
- mock_tables.return_value = [t for t in tables if t != "pages_module"]
1473
+ def test_status_indicator_removed(self):
1154
1474
  resp = self.client.get(reverse("admin:index"))
1155
- self.assertContains(resp, 'class="model-status ok"')
1156
- self.assertContains(resp, 'class="model-status missing"', count=1)
1475
+ self.assertNotContains(resp, "class=\"model-status")
1476
+
1477
+ changelist = self.client.get(reverse("admin:pages_application_changelist"))
1478
+ self.assertNotContains(changelist, "class=\"model-status")
1479
+
1480
+
1481
+ class _FakeQuerySet(list):
1482
+ def only(self, *args, **kwargs):
1483
+ return self
1484
+
1485
+ def order_by(self, *args, **kwargs):
1486
+ return self
1487
+
1488
+
1489
+ class SiteConfigurationStagingTests(SimpleTestCase):
1490
+ def setUp(self):
1491
+ self.tmpdir = tempfile.mkdtemp()
1492
+ self.addCleanup(shutil.rmtree, self.tmpdir)
1493
+ self.config_path = Path(self.tmpdir) / "nginx-sites.json"
1494
+ self._path_patcher = patch(
1495
+ "pages.site_config._sites_config_path", side_effect=lambda: self.config_path
1496
+ )
1497
+ self._path_patcher.start()
1498
+ self.addCleanup(self._path_patcher.stop)
1499
+ self._model_patcher = patch("pages.site_config.apps.get_model")
1500
+ self.mock_get_model = self._model_patcher.start()
1501
+ self.addCleanup(self._model_patcher.stop)
1502
+
1503
+ def _read_config(self):
1504
+ if not self.config_path.exists():
1505
+ return None
1506
+ return json.loads(self.config_path.read_text(encoding="utf-8"))
1507
+
1508
+ def _set_sites(self, sites):
1509
+ queryset = _FakeQuerySet(sites)
1510
+
1511
+ class _Manager:
1512
+ @staticmethod
1513
+ def filter(**kwargs):
1514
+ return queryset
1515
+
1516
+ self.mock_get_model.return_value = SimpleNamespace(objects=_Manager())
1517
+
1518
+ def test_managed_site_persists_configuration(self):
1519
+ self._set_sites([SimpleNamespace(domain="example.com", require_https=True)])
1520
+ site_config.update_local_nginx_scripts()
1521
+ config = self._read_config()
1522
+ self.assertEqual(
1523
+ config,
1524
+ [
1525
+ {
1526
+ "domain": "example.com",
1527
+ "require_https": True,
1528
+ }
1529
+ ],
1530
+ )
1531
+
1532
+ def test_disabling_managed_site_removes_entry(self):
1533
+ primary = SimpleNamespace(domain="primary.test", require_https=False)
1534
+ secondary = SimpleNamespace(domain="secondary.test", require_https=False)
1535
+ self._set_sites([primary, secondary])
1536
+ site_config.update_local_nginx_scripts()
1537
+ config = self._read_config()
1538
+ self.assertEqual(
1539
+ [entry["domain"] for entry in config],
1540
+ ["primary.test", "secondary.test"],
1541
+ )
1542
+
1543
+ self._set_sites([secondary])
1544
+ site_config.update_local_nginx_scripts()
1545
+ config = self._read_config()
1546
+ self.assertEqual(config, [{"domain": "secondary.test", "require_https": False}])
1547
+
1548
+ self._set_sites([])
1549
+ site_config.update_local_nginx_scripts()
1550
+ self.assertIsNone(self._read_config())
1551
+
1552
+ def test_require_https_toggle_updates_configuration(self):
1553
+ site = SimpleNamespace(domain="secure.example", require_https=False)
1554
+ self._set_sites([site])
1555
+ site_config.update_local_nginx_scripts()
1556
+ config = self._read_config()
1557
+ self.assertEqual(config, [{"domain": "secure.example", "require_https": False}])
1558
+
1559
+ site.require_https = True
1560
+ self._set_sites([site])
1561
+ site_config.update_local_nginx_scripts()
1562
+ config = self._read_config()
1563
+ self.assertEqual(config, [{"domain": "secure.example", "require_https": True}])
1564
+
1565
+
1566
+ class SiteRequireHttpsMiddlewareTests(SimpleTestCase):
1567
+ def setUp(self):
1568
+ self.factory = RequestFactory()
1569
+ self.middleware = SiteHttpsRedirectMiddleware(lambda request: HttpResponse("ok"))
1570
+ self.secure_site = SimpleNamespace(domain="secure.test", require_https=True)
1571
+
1572
+ def test_http_request_redirects_to_https(self):
1573
+ request = self.factory.get("/", HTTP_HOST="secure.test")
1574
+ request.site = self.secure_site
1575
+ response = self.middleware(request)
1576
+ self.assertEqual(response.status_code, 301)
1577
+ self.assertTrue(response["Location"].startswith("https://secure.test"))
1578
+
1579
+ def test_secure_request_not_redirected(self):
1580
+ request = self.factory.get("/", HTTP_HOST="secure.test", secure=True)
1581
+ request.site = self.secure_site
1582
+ response = self.middleware(request)
1583
+ self.assertEqual(response.status_code, 200)
1584
+
1585
+ def test_forwarded_proto_respected(self):
1586
+ request = self.factory.get(
1587
+ "/", HTTP_HOST="secure.test", HTTP_X_FORWARDED_PROTO="https"
1588
+ )
1589
+ request.site = self.secure_site
1590
+ response = self.middleware(request)
1591
+ self.assertEqual(response.status_code, 200)
1592
+
1593
+ self.secure_site.require_https = False
1594
+ request = self.factory.get("/", HTTP_HOST="secure.test")
1595
+ request.site = self.secure_site
1596
+ response = self.middleware(request)
1597
+ self.assertEqual(response.status_code, 200)
1157
1598
 
1158
1599
 
1159
1600
  class SiteAdminRegisterCurrentTests(TestCase):
@@ -1188,6 +1629,7 @@ class SiteAdminRegisterCurrentTests(TestCase):
1188
1629
  self.assertEqual(site.name, "")
1189
1630
 
1190
1631
 
1632
+ @pytest.mark.feature("screenshot-poll")
1191
1633
  class SiteAdminScreenshotTests(TestCase):
1192
1634
  def setUp(self):
1193
1635
  self.client = Client()
@@ -1307,17 +1749,17 @@ class NavAppsTests(TestCase):
1307
1749
  )
1308
1750
  app = Application.objects.create(name="Readme")
1309
1751
  Module.objects.create(
1310
- node_role=role, application=app, path="/", is_default=True
1752
+ node_role=role, application=app, path="/", is_default=True, menu="Cookbooks"
1311
1753
  )
1312
1754
 
1313
1755
  def test_nav_pill_renders(self):
1314
1756
  resp = self.client.get(reverse("pages:index"))
1315
- self.assertContains(resp, "README")
1757
+ self.assertContains(resp, "COOKBOOKS")
1316
1758
  self.assertContains(resp, "badge rounded-pill")
1317
1759
 
1318
1760
  def test_nav_pill_renders_with_port(self):
1319
- resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8000")
1320
- self.assertContains(resp, "README")
1761
+ resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8888")
1762
+ self.assertContains(resp, "COOKBOOKS")
1321
1763
 
1322
1764
  def test_nav_pill_uses_menu_field(self):
1323
1765
  site_app = Module.objects.get()
@@ -1325,7 +1767,7 @@ class NavAppsTests(TestCase):
1325
1767
  site_app.save()
1326
1768
  resp = self.client.get(reverse("pages:index"))
1327
1769
  self.assertContains(resp, 'badge rounded-pill text-bg-secondary">DOCS')
1328
- self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">README')
1770
+ self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1329
1771
 
1330
1772
  def test_app_without_root_url_excluded(self):
1331
1773
  role = NodeRole.objects.get(name="Terminal")
@@ -1390,20 +1832,22 @@ class RoleLandingRedirectTests(TestCase):
1390
1832
 
1391
1833
  def test_satellite_redirects_to_dashboard(self):
1392
1834
  target = self._configure_role_landing(
1393
- "Satellite", "/ocpp/", "CPMS Online Dashboard"
1835
+ "Satellite", "/ocpp/cpms/dashboard/", "CPMS Online Dashboard"
1394
1836
  )
1395
1837
  resp = self.client.get(reverse("pages:index"))
1396
1838
  self.assertRedirects(resp, target, fetch_redirect_response=False)
1397
1839
 
1398
1840
  def test_control_redirects_to_rfid(self):
1399
1841
  target = self._configure_role_landing(
1400
- "Control", "/ocpp/rfid/", "RFID Tag Validator"
1842
+ "Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
1401
1843
  )
1402
1844
  resp = self.client.get(reverse("pages:index"))
1403
1845
  self.assertRedirects(resp, target, fetch_redirect_response=False)
1404
1846
 
1405
1847
  def test_security_group_redirect_takes_priority(self):
1406
- self._configure_role_landing("Control", "/ocpp/rfid/", "RFID Tag Validator")
1848
+ self._configure_role_landing(
1849
+ "Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
1850
+ )
1407
1851
  role = self.node.role
1408
1852
  group = SecurityGroup.objects.create(name="Operators")
1409
1853
  group_landing = self._ensure_landing(role, "/ocpp/group/", "Group Landing")
@@ -1420,7 +1864,9 @@ class RoleLandingRedirectTests(TestCase):
1420
1864
  )
1421
1865
 
1422
1866
  def test_user_redirect_overrides_group_with_higher_priority(self):
1423
- self._configure_role_landing("Control", "/ocpp/rfid/", "RFID Tag Validator")
1867
+ self._configure_role_landing(
1868
+ "Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
1869
+ )
1424
1870
  role = self.node.role
1425
1871
  group = SecurityGroup.objects.create(name="Operators")
1426
1872
  group_landing = self._ensure_landing(role, "/ocpp/group/", "Group Landing")
@@ -1442,10 +1888,10 @@ class RoleLandingRedirectTests(TestCase):
1442
1888
  )
1443
1889
 
1444
1890
 
1445
- class ConstellationNavTests(TestCase):
1891
+ class WatchtowerNavTests(TestCase):
1446
1892
  def setUp(self):
1447
1893
  self.client = Client()
1448
- role, _ = NodeRole.objects.get_or_create(name="Constellation")
1894
+ role, _ = NodeRole.objects.get_or_create(name="Watchtower")
1449
1895
  Node.objects.update_or_create(
1450
1896
  mac_address=Node.get_current_mac(),
1451
1897
  defaults={
@@ -1455,38 +1901,56 @@ class ConstellationNavTests(TestCase):
1455
1901
  },
1456
1902
  )
1457
1903
  Site.objects.update_or_create(
1458
- id=1, defaults={"domain": "testserver", "name": ""}
1904
+ id=1, defaults={"domain": "arthexis.com", "name": "Arthexis"}
1459
1905
  )
1460
1906
  fixtures = [
1461
1907
  Path(
1462
1908
  settings.BASE_DIR,
1463
1909
  "pages",
1464
1910
  "fixtures",
1465
- "constellation__application_ocpp.json",
1911
+ "default__application_pages.json",
1912
+ ),
1913
+ Path(
1914
+ settings.BASE_DIR,
1915
+ "pages",
1916
+ "fixtures",
1917
+ "watchtower__application_ocpp.json",
1918
+ ),
1919
+ Path(
1920
+ settings.BASE_DIR,
1921
+ "pages",
1922
+ "fixtures",
1923
+ "watchtower__module_ocpp.json",
1924
+ ),
1925
+ Path(
1926
+ settings.BASE_DIR,
1927
+ "pages",
1928
+ "fixtures",
1929
+ "watchtower__landing_ocpp_dashboard.json",
1466
1930
  ),
1467
1931
  Path(
1468
1932
  settings.BASE_DIR,
1469
1933
  "pages",
1470
1934
  "fixtures",
1471
- "constellation__module_ocpp.json",
1935
+ "watchtower__landing_ocpp_cp_simulator.json",
1472
1936
  ),
1473
1937
  Path(
1474
1938
  settings.BASE_DIR,
1475
1939
  "pages",
1476
1940
  "fixtures",
1477
- "constellation__landing_ocpp_dashboard.json",
1941
+ "watchtower__landing_ocpp_rfid.json",
1478
1942
  ),
1479
1943
  Path(
1480
1944
  settings.BASE_DIR,
1481
1945
  "pages",
1482
1946
  "fixtures",
1483
- "constellation__landing_ocpp_cp_simulator.json",
1947
+ "watchtower__module_readme.json",
1484
1948
  ),
1485
1949
  Path(
1486
1950
  settings.BASE_DIR,
1487
1951
  "pages",
1488
1952
  "fixtures",
1489
- "constellation__landing_ocpp_rfid.json",
1953
+ "watchtower__landing_readme.json",
1490
1954
  ),
1491
1955
  ]
1492
1956
  call_command("loaddata", *map(str, fixtures))
@@ -1499,13 +1963,13 @@ class ConstellationNavTests(TestCase):
1499
1963
  self.assertNotIn("RFID", nav_labels)
1500
1964
  self.assertTrue(
1501
1965
  Module.objects.filter(
1502
- path="/ocpp/", node_role__name="Constellation"
1966
+ path="/ocpp/", node_role__name="Watchtower"
1503
1967
  ).exists()
1504
1968
  )
1505
1969
  self.assertFalse(
1506
1970
  Module.objects.filter(
1507
1971
  path="/ocpp/rfid/",
1508
- node_role__name="Constellation",
1972
+ node_role__name="Watchtower",
1509
1973
  is_deleted=False,
1510
1974
  ).exists()
1511
1975
  )
@@ -1517,9 +1981,16 @@ class ConstellationNavTests(TestCase):
1517
1981
  landing_labels = [landing.label for landing in ocpp_module.enabled_landings]
1518
1982
  self.assertIn("RFID Tag Validator", landing_labels)
1519
1983
 
1984
+ @override_settings(ALLOWED_HOSTS=["testserver", "arthexis.com"])
1985
+ def test_cookbooks_pill_visible_for_arthexis(self):
1986
+ resp = self.client.get(
1987
+ reverse("pages:index"), HTTP_HOST="arthexis.com"
1988
+ )
1989
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1990
+
1520
1991
  def test_ocpp_dashboard_visible(self):
1521
1992
  resp = self.client.get(reverse("pages:index"))
1522
- self.assertContains(resp, 'href="/ocpp/"')
1993
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
1523
1994
 
1524
1995
 
1525
1996
  class ReleaseModuleNavTests(TestCase):
@@ -1661,7 +2132,7 @@ class ControlNavTests(TestCase):
1661
2132
  self.client.force_login(user)
1662
2133
  resp = self.client.get(reverse("pages:index"))
1663
2134
  self.assertEqual(resp.status_code, 200)
1664
- self.assertContains(resp, 'href="/ocpp/"')
2135
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
1665
2136
  self.assertContains(
1666
2137
  resp, 'badge rounded-pill text-bg-secondary">CHARGERS'
1667
2138
  )
@@ -1693,10 +2164,76 @@ class ControlNavTests(TestCase):
1693
2164
  self.assertFalse(resp.context["header_references"])
1694
2165
  self.assertNotContains(resp, "https://example.com/hidden")
1695
2166
 
2167
+ def test_header_link_hidden_when_only_site_matches(self):
2168
+ terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
2169
+ site = Site.objects.get(domain="testserver")
2170
+ reference = Reference.objects.create(
2171
+ alt_text="Restricted",
2172
+ value="https://example.com/restricted",
2173
+ show_in_header=True,
2174
+ )
2175
+ reference.roles.add(terminal_role)
2176
+ reference.sites.add(site)
2177
+
2178
+ resp = self.client.get(reverse("pages:index"))
2179
+
2180
+ self.assertIn("header_references", resp.context)
2181
+ self.assertFalse(resp.context["header_references"])
2182
+ self.assertNotContains(resp, "https://example.com/restricted")
2183
+
1696
2184
  def test_readme_pill_visible(self):
1697
2185
  resp = self.client.get(reverse("pages:readme"))
1698
- self.assertContains(resp, 'href="/readme/"')
1699
- self.assertContains(resp, 'badge rounded-pill text-bg-secondary">README')
2186
+ self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
2187
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
2188
+
2189
+ def test_cookbook_pill_has_no_dropdown(self):
2190
+ module = Module.objects.get(node_role__name="Control", path="/read/")
2191
+ Landing.objects.create(
2192
+ module=module,
2193
+ path="/man/",
2194
+ label="Manuals",
2195
+ enabled=True,
2196
+ )
2197
+
2198
+ resp = self.client.get(reverse("pages:readme"))
2199
+
2200
+ self.assertContains(
2201
+ resp,
2202
+ '<a class="nav-link" href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"><span class="badge rounded-pill text-bg-secondary">COOKBOOKS</span></a>',
2203
+ html=True,
2204
+ )
2205
+ self.assertNotContains(resp, 'dropdown-item" href="/man/"')
2206
+
2207
+ def test_readme_page_includes_qr_share(self):
2208
+ resp = self.client.get(reverse("pages:readme"), {"section": "intro"})
2209
+ self.assertContains(resp, 'id="reader-qr"')
2210
+ self.assertContains(
2211
+ resp,
2212
+ 'data-url="http://testserver/read/?section=intro"',
2213
+ )
2214
+ self.assertNotContains(resp, "Scan this page")
2215
+ self.assertNotContains(
2216
+ resp, 'class="small text-break text-muted mt-3 mb-0"'
2217
+ )
2218
+
2219
+ def test_readme_document_by_name(self):
2220
+ resp = self.client.get(reverse("pages:readme-document", args=["AGENTS.md"]))
2221
+ self.assertEqual(resp.status_code, 200)
2222
+ self.assertContains(resp, "Agent Guidelines")
2223
+
2224
+ def test_readme_document_by_relative_path(self):
2225
+ resp = self.client.get(
2226
+ reverse(
2227
+ "pages:readme-document",
2228
+ args=["docs/development/maintenance-roadmap.md"],
2229
+ )
2230
+ )
2231
+ self.assertEqual(resp.status_code, 200)
2232
+ self.assertContains(resp, "Maintenance Improvement Proposals")
2233
+
2234
+ def test_readme_document_rejects_traversal(self):
2235
+ resp = self.client.get("/read/../../SECRET.md")
2236
+ self.assertEqual(resp.status_code, 404)
1700
2237
 
1701
2238
 
1702
2239
  class SatelliteNavTests(TestCase):
@@ -1768,8 +2305,8 @@ class SatelliteNavTests(TestCase):
1768
2305
 
1769
2306
  def test_readme_pill_visible(self):
1770
2307
  resp = self.client.get(reverse("pages:readme"))
1771
- self.assertContains(resp, 'href="/readme/"')
1772
- self.assertContains(resp, 'badge rounded-pill text-bg-secondary">README')
2308
+ self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
2309
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1773
2310
 
1774
2311
 
1775
2312
  class PowerNavTests(TestCase):
@@ -1804,9 +2341,9 @@ class PowerNavTests(TestCase):
1804
2341
  power_module = module
1805
2342
  break
1806
2343
  self.assertIsNotNone(power_module)
1807
- self.assertEqual(power_module.menu_label.upper(), "CALCULATE")
2344
+ self.assertEqual(power_module.menu_label.upper(), "CALCULATORS")
1808
2345
  landing_labels = {landing.label for landing in power_module.enabled_landings}
1809
- self.assertIn("AWG Calculator", landing_labels)
2346
+ self.assertIn("AWG Cable Calculator", landing_labels)
1810
2347
 
1811
2348
  def test_manual_pill_label(self):
1812
2349
  resp = self.client.get(reverse("pages:index"))
@@ -1830,9 +2367,88 @@ class PowerNavTests(TestCase):
1830
2367
  break
1831
2368
  self.assertIsNotNone(power_module)
1832
2369
  landing_labels = {landing.label for landing in power_module.enabled_landings}
1833
- self.assertIn("AWG Calculator", landing_labels)
2370
+ self.assertIn("AWG Cable Calculator", landing_labels)
1834
2371
  self.assertIn("Energy Tariff Calculator", landing_labels)
1835
2372
 
2373
+ def test_locked_landing_shows_lock_icon(self):
2374
+ resp = self.client.get(reverse("pages:index"))
2375
+ html = resp.content.decode()
2376
+ energy_index = html.find("Energy Tariff Calculator")
2377
+ self.assertGreaterEqual(energy_index, 0)
2378
+ icon_index = html.find("dropdown-lock-icon", energy_index, energy_index + 300)
2379
+ self.assertGreaterEqual(icon_index, 0)
2380
+
2381
+ def test_lock_icon_disappears_after_login(self):
2382
+ self.client.force_login(self.user)
2383
+ resp = self.client.get(reverse("pages:index"))
2384
+ html = resp.content.decode()
2385
+ energy_index = html.find("Energy Tariff Calculator")
2386
+ self.assertGreaterEqual(energy_index, 0)
2387
+ icon_index = html.find("dropdown-lock-icon", energy_index, energy_index + 300)
2388
+ self.assertEqual(icon_index, -1)
2389
+
2390
+
2391
+ class WatchtowerLandingLinkTests(TestCase):
2392
+ def setUp(self):
2393
+ self.client = Client()
2394
+ self.role, _ = NodeRole.objects.get_or_create(name="Watchtower")
2395
+ Node.objects.update_or_create(
2396
+ mac_address=Node.get_current_mac(),
2397
+ defaults={
2398
+ "hostname": "localhost",
2399
+ "address": "127.0.0.1",
2400
+ "role": self.role,
2401
+ },
2402
+ )
2403
+ Site.objects.update_or_create(
2404
+ id=1, defaults={"domain": "testserver", "name": ""}
2405
+ )
2406
+ self.ocpp_app, _ = Application.objects.get_or_create(name="ocpp")
2407
+ self.ocpp_module, _ = Module.objects.get_or_create(
2408
+ node_role=self.role,
2409
+ application=self.ocpp_app,
2410
+ path="/ocpp/",
2411
+ )
2412
+ self.ocpp_module.create_landings()
2413
+
2414
+ def _get_ocpp_module(self, response):
2415
+ for module in response.context["nav_modules"]:
2416
+ if module.path == "/ocpp/":
2417
+ return module
2418
+ return None
2419
+
2420
+ def test_ocpp_landings_present_for_anonymous_users(self):
2421
+ response = self.client.get(reverse("pages:index"))
2422
+ ocpp_module = self._get_ocpp_module(response)
2423
+ self.assertIsNotNone(ocpp_module)
2424
+ landing_by_label = {
2425
+ landing.label: landing for landing in ocpp_module.enabled_landings
2426
+ }
2427
+ expected_landings = {
2428
+ "CPMS Online Dashboard": "/ocpp/cpms/dashboard/",
2429
+ "Charge Point Simulator": "/ocpp/evcs/simulator/",
2430
+ "RFID Tag Validator": "/ocpp/rfid/validator/",
2431
+ }
2432
+ for label, path in expected_landings.items():
2433
+ with self.subTest(label=label):
2434
+ landing = landing_by_label.get(label)
2435
+ self.assertIsNotNone(landing)
2436
+ self.assertEqual(landing.path, path)
2437
+ self.assertTrue(path.startswith("/"))
2438
+ resolve(path)
2439
+
2440
+ def test_simulator_requires_login(self):
2441
+ response = self.client.get(reverse("pages:index"))
2442
+ ocpp_module = self._get_ocpp_module(response)
2443
+ self.assertIsNotNone(ocpp_module)
2444
+ locked_landings = {
2445
+ landing.label: landing
2446
+ for landing in ocpp_module.enabled_landings
2447
+ if getattr(landing, "nav_is_locked", False)
2448
+ }
2449
+ simulator = locked_landings.get("Charge Point Simulator")
2450
+ self.assertIsNotNone(simulator)
2451
+ self.assertTrue(simulator.nav_is_locked)
1836
2452
 
1837
2453
  class StaffNavVisibilityTests(TestCase):
1838
2454
  def setUp(self):
@@ -1854,12 +2470,12 @@ class StaffNavVisibilityTests(TestCase):
1854
2470
  def test_nonstaff_pill_hidden(self):
1855
2471
  self.client.login(username="user", password="pw")
1856
2472
  resp = self.client.get(reverse("pages:index"))
1857
- self.assertContains(resp, 'href="/ocpp/"')
2473
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
1858
2474
 
1859
2475
  def test_staff_sees_pill(self):
1860
2476
  self.client.login(username="staff", password="pw")
1861
2477
  resp = self.client.get(reverse("pages:index"))
1862
- self.assertContains(resp, 'href="/ocpp/"')
2478
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
1863
2479
 
1864
2480
 
1865
2481
  class ModuleAdminReloadActionTests(TestCase):
@@ -1872,7 +2488,7 @@ class ModuleAdminReloadActionTests(TestCase):
1872
2488
  password="pw",
1873
2489
  )
1874
2490
  self.client.force_login(self.superuser)
1875
- self.role, _ = NodeRole.objects.get_or_create(name="Constellation")
2491
+ self.role, _ = NodeRole.objects.get_or_create(name="Watchtower")
1876
2492
  Application.objects.get_or_create(name="ocpp")
1877
2493
  Application.objects.get_or_create(name="awg")
1878
2494
  Site.objects.update_or_create(
@@ -1910,7 +2526,11 @@ class ModuleAdminReloadActionTests(TestCase):
1910
2526
  )
1911
2527
  self.assertSetEqual(
1912
2528
  charger_landings,
1913
- {"/ocpp/", "/ocpp/simulator/", "/ocpp/rfid/"},
2529
+ {
2530
+ "/ocpp/cpms/dashboard/",
2531
+ "/ocpp/evcs/simulator/",
2532
+ "/ocpp/rfid/validator/",
2533
+ },
1914
2534
  )
1915
2535
 
1916
2536
  calculator_landings = set(
@@ -2048,6 +2668,47 @@ class UserManualAdminFormTests(TestCase):
2048
2668
  self.assertEqual(form.cleaned_data["content_pdf"], self.manual.content_pdf)
2049
2669
 
2050
2670
 
2671
+ class UserManualModelTests(TestCase):
2672
+ def _build_manual(self, **overrides):
2673
+ defaults = {
2674
+ "slug": "manual-model-test",
2675
+ "title": "Manual Model",
2676
+ "description": "Manual description",
2677
+ "languages": "en",
2678
+ "content_html": "<p>Manual</p>",
2679
+ "content_pdf": base64.b64encode(b"initial").decode("ascii"),
2680
+ }
2681
+ defaults.update(overrides)
2682
+ return UserManual(**defaults)
2683
+
2684
+ def test_save_encodes_uploaded_file(self):
2685
+ upload = SimpleUploadedFile("manual.pdf", b"PDF data")
2686
+ manual = self._build_manual(slug="manual-upload", content_pdf=upload)
2687
+ manual.save()
2688
+ manual.refresh_from_db()
2689
+ self.assertEqual(
2690
+ manual.content_pdf,
2691
+ base64.b64encode(b"PDF data").decode("ascii"),
2692
+ )
2693
+
2694
+ def test_save_encodes_raw_bytes(self):
2695
+ manual = self._build_manual(slug="manual-bytes", content_pdf=b"PDF raw")
2696
+ manual.save()
2697
+ manual.refresh_from_db()
2698
+ self.assertEqual(
2699
+ manual.content_pdf,
2700
+ base64.b64encode(b"PDF raw").decode("ascii"),
2701
+ )
2702
+
2703
+ def test_save_strips_data_uri_prefix(self):
2704
+ encoded = base64.b64encode(b"PDF data").decode("ascii")
2705
+ data_uri = f"data:application/pdf;base64,{encoded}"
2706
+ manual = self._build_manual(slug="manual-data-uri", content_pdf=data_uri)
2707
+ manual.save()
2708
+ manual.refresh_from_db()
2709
+ self.assertEqual(manual.content_pdf, encoded)
2710
+
2711
+
2051
2712
  class LandingCreationTests(TestCase):
2052
2713
  def setUp(self):
2053
2714
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
@@ -2069,12 +2730,12 @@ class LandingCreationTests(TestCase):
2069
2730
 
2070
2731
 
2071
2732
  class LandingFixtureTests(TestCase):
2072
- def test_constellation_fixture_loads_without_duplicates(self):
2733
+ def test_watchtower_fixture_loads_without_duplicates(self):
2073
2734
  from glob import glob
2074
2735
 
2075
- NodeRole.objects.get_or_create(name="Constellation")
2736
+ NodeRole.objects.get_or_create(name="Watchtower")
2076
2737
  fixtures = glob(
2077
- str(Path(settings.BASE_DIR, "pages", "fixtures", "constellation__*.json"))
2738
+ str(Path(settings.BASE_DIR, "pages", "fixtures", "watchtower__*.json"))
2078
2739
  )
2079
2740
  fixtures = sorted(
2080
2741
  fixtures,
@@ -2084,9 +2745,11 @@ class LandingFixtureTests(TestCase):
2084
2745
  )
2085
2746
  call_command("loaddata", *fixtures)
2086
2747
  call_command("loaddata", *fixtures)
2087
- module = Module.objects.get(path="/ocpp/", node_role__name="Constellation")
2748
+ module = Module.objects.get(path="/ocpp/", node_role__name="Watchtower")
2088
2749
  module.create_landings()
2089
- self.assertEqual(module.landings.filter(path="/ocpp/rfid/").count(), 1)
2750
+ self.assertEqual(
2751
+ module.landings.filter(path="/ocpp/rfid/validator/").count(), 1
2752
+ )
2090
2753
 
2091
2754
 
2092
2755
  class AllowedHostSubnetTests(TestCase):
@@ -2239,9 +2902,9 @@ class FaviconTests(TestCase):
2239
2902
  )
2240
2903
  self.assertContains(resp, b64)
2241
2904
 
2242
- def test_constellation_nodes_use_goldenrod_favicon(self):
2905
+ def test_watchtower_nodes_use_goldenrod_favicon(self):
2243
2906
  with override_settings(MEDIA_ROOT=self.tmpdir):
2244
- role, _ = NodeRole.objects.get_or_create(name="Constellation")
2907
+ role, _ = NodeRole.objects.get_or_create(name="Watchtower")
2245
2908
  Node.objects.update_or_create(
2246
2909
  mac_address=Node.get_current_mac(),
2247
2910
  defaults={
@@ -2256,7 +2919,7 @@ class FaviconTests(TestCase):
2256
2919
  resp = self.client.get(reverse("pages:index"))
2257
2920
  b64 = (
2258
2921
  Path(settings.BASE_DIR)
2259
- .joinpath("pages", "fixtures", "data", "favicon_constellation.txt")
2922
+ .joinpath("pages", "fixtures", "data", "favicon_watchtower.txt")
2260
2923
  .read_text()
2261
2924
  .strip()
2262
2925
  )
@@ -2323,6 +2986,20 @@ class FavoriteTests(TestCase):
2323
2986
  self.assertEqual(fav.custom_label, "Apps")
2324
2987
  self.assertTrue(fav.user_data)
2325
2988
 
2989
+ def test_add_favorite_defaults_user_data_checked(self):
2990
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2991
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2992
+ resp = self.client.get(url)
2993
+ self.assertContains(resp, 'name="user_data" checked')
2994
+
2995
+ def test_add_favorite_with_priority(self):
2996
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2997
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2998
+ resp = self.client.post(url, {"priority": "7"})
2999
+ self.assertRedirects(resp, reverse("admin:index"))
3000
+ fav = Favorite.objects.get(user=self.user, content_type=ct)
3001
+ self.assertEqual(fav.priority, 7)
3002
+
2326
3003
  def test_cancel_link_uses_next(self):
2327
3004
  ct = ContentType.objects.get_by_natural_key("pages", "application")
2328
3005
  next_url = reverse("admin:pages_application_changelist")
@@ -2332,14 +3009,31 @@ class FavoriteTests(TestCase):
2332
3009
  resp = self.client.get(url)
2333
3010
  self.assertContains(resp, f'href="{next_url}"')
2334
3011
 
2335
- def test_existing_favorite_redirects_to_list(self):
3012
+ def test_existing_favorite_shows_update_form(self):
2336
3013
  ct = ContentType.objects.get_by_natural_key("pages", "application")
2337
- Favorite.objects.create(user=self.user, content_type=ct)
3014
+ favorite = Favorite.objects.create(
3015
+ user=self.user, content_type=ct, custom_label="Apps", user_data=True
3016
+ )
2338
3017
  url = reverse("admin:favorite_toggle", args=[ct.id])
2339
3018
  resp = self.client.get(url)
2340
- self.assertRedirects(resp, reverse("admin:favorite_list"))
2341
- resp = self.client.get(reverse("admin:favorite_list"))
2342
- self.assertContains(resp, ct.name)
3019
+ self.assertContains(resp, "Update Favorite")
3020
+ self.assertContains(resp, "value=\"Apps\"")
3021
+ self.assertContains(resp, "checked")
3022
+ self.assertContains(resp, "name=\"remove\"")
3023
+
3024
+ resp = self.client.post(url, {"custom_label": "Apps Updated"})
3025
+ self.assertRedirects(resp, reverse("admin:index"))
3026
+ favorite.refresh_from_db()
3027
+ self.assertEqual(favorite.custom_label, "Apps Updated")
3028
+ self.assertFalse(favorite.user_data)
3029
+
3030
+ def test_remove_existing_favorite_from_toggle(self):
3031
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
3032
+ Favorite.objects.create(user=self.user, content_type=ct)
3033
+ url = reverse("admin:favorite_toggle", args=[ct.id])
3034
+ resp = self.client.post(url, {"remove": "1"})
3035
+ self.assertRedirects(resp, reverse("admin:index"))
3036
+ self.assertFalse(Favorite.objects.filter(user=self.user, content_type=ct).exists())
2343
3037
 
2344
3038
  def test_update_user_data_from_list(self):
2345
3039
  ct = ContentType.objects.get_by_natural_key("pages", "application")
@@ -2350,6 +3044,15 @@ class FavoriteTests(TestCase):
2350
3044
  fav.refresh_from_db()
2351
3045
  self.assertTrue(fav.user_data)
2352
3046
 
3047
+ def test_update_priority_from_list(self):
3048
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
3049
+ fav = Favorite.objects.create(user=self.user, content_type=ct, priority=3)
3050
+ url = reverse("admin:favorite_list")
3051
+ resp = self.client.post(url, {f"priority_{fav.pk}": "12"})
3052
+ self.assertRedirects(resp, url)
3053
+ fav.refresh_from_db()
3054
+ self.assertEqual(fav.priority, 12)
3055
+
2353
3056
  def test_dashboard_includes_favorites_and_user_data(self):
2354
3057
  fav_ct = ContentType.objects.get_by_natural_key("pages", "application")
2355
3058
  Favorite.objects.create(
@@ -2360,6 +3063,12 @@ class FavoriteTests(TestCase):
2360
3063
  self.assertContains(resp, reverse("admin:pages_application_changelist"))
2361
3064
  self.assertContains(resp, reverse("admin:nodes_noderole_changelist"))
2362
3065
 
3066
+ def test_dashboard_shows_empty_todo_state(self):
3067
+ Todo.objects.all().delete()
3068
+ resp = self.client.get(reverse("admin:index"))
3069
+ self.assertContains(resp, "Release manager tasks")
3070
+ self.assertContains(resp, "No pending TODOs")
3071
+
2363
3072
  def test_dashboard_merges_duplicate_future_actions(self):
2364
3073
  ct = ContentType.objects.get_for_model(NodeRole)
2365
3074
  Favorite.objects.create(user=self.user, content_type=ct)
@@ -2406,6 +3115,48 @@ class FavoriteTests(TestCase):
2406
3115
  self.assertContains(resp, f'title="{badge_label}"')
2407
3116
  self.assertContains(resp, f'aria-label="{badge_label}"')
2408
3117
 
3118
+ def test_dashboard_shows_charge_point_availability_badge(self):
3119
+ Charger.objects.create(
3120
+ charger_id="CP-001", connector_id=1, last_status="Available"
3121
+ )
3122
+ Charger.objects.create(charger_id="CP-002", last_status="Available")
3123
+ Charger.objects.create(
3124
+ charger_id="CP-003", connector_id=1, last_status="Unavailable"
3125
+ )
3126
+
3127
+ resp = self.client.get(reverse("admin:index"))
3128
+
3129
+ expected = "1 / 2"
3130
+ badge_label = gettext(
3131
+ "%(available)s chargers reporting Available status with a CP number, out of %(total)s total Available chargers. %(missing)s Available chargers are missing a connector number."
3132
+ ) % {"available": 1, "total": 2, "missing": 1}
3133
+
3134
+ self.assertContains(resp, expected)
3135
+ self.assertContains(resp, 'class="charger-availability-badge"')
3136
+ self.assertContains(resp, f'title="{badge_label}"')
3137
+ self.assertContains(resp, f'aria-label="{badge_label}"')
3138
+
3139
+ def test_dashboard_charge_point_badge_ignores_aggregator(self):
3140
+ Charger.objects.create(charger_id="CP-AGG", last_status="Available")
3141
+ Charger.objects.create(
3142
+ charger_id="CP-AGG", connector_id=1, last_status="Available"
3143
+ )
3144
+ Charger.objects.create(
3145
+ charger_id="CP-AGG", connector_id=2, last_status="Available"
3146
+ )
3147
+
3148
+ resp = self.client.get(reverse("admin:index"))
3149
+
3150
+ expected = "2 / 2"
3151
+ badge_label = gettext(
3152
+ "%(available)s chargers reporting Available status with a CP number."
3153
+ ) % {"available": 2}
3154
+
3155
+ self.assertContains(resp, expected)
3156
+ self.assertContains(resp, 'class="charger-availability-badge"')
3157
+ self.assertContains(resp, f'title="{badge_label}"')
3158
+ self.assertContains(resp, f'aria-label="{badge_label}"')
3159
+
2409
3160
  def test_nav_sidebar_hides_dashboard_badges(self):
2410
3161
  InviteLead.objects.create(email="open@example.com")
2411
3162
  RFID.objects.create(rfid="RFID0003", released=True, allowed=True)
@@ -2508,7 +3259,7 @@ class FavoriteTests(TestCase):
2508
3259
  hostname="cached-node",
2509
3260
  address="127.0.0.1",
2510
3261
  mac_address="AA:BB:CC:DD:EE:FF",
2511
- port=8000,
3262
+ port=8888,
2512
3263
  is_user_data=True,
2513
3264
  )
2514
3265
 
@@ -2557,7 +3308,11 @@ class FavoriteTests(TestCase):
2557
3308
  todo = Todo.objects.create(request="Do thing")
2558
3309
  resp = self.client.get(reverse("admin:index"))
2559
3310
  done_url = reverse("todo-done", args=[todo.pk])
2560
- self.assertContains(resp, todo.request)
3311
+ tooltip = escape(todo.request)
3312
+ self.assertContains(resp, f'title="{tooltip}"')
3313
+ self.assertContains(resp, f'aria-label="{tooltip}"')
3314
+ task_label = gettext("Task %(counter)s") % {"counter": 1}
3315
+ self.assertContains(resp, task_label)
2561
3316
  self.assertContains(resp, f'action="{done_url}"')
2562
3317
  self.assertContains(resp, "DONE")
2563
3318
 
@@ -2568,6 +3323,15 @@ class FavoriteTests(TestCase):
2568
3323
  resp, '<div class="todo-details">More info</div>', html=True
2569
3324
  )
2570
3325
 
3326
+ def test_dashboard_hides_completed_todos(self):
3327
+ todo = Todo.objects.create(request="Completed task")
3328
+ Todo.objects.filter(pk=todo.pk).update(done_on=timezone.now())
3329
+
3330
+ resp = self.client.get(reverse("admin:index"))
3331
+
3332
+ self.assertNotContains(resp, todo.request)
3333
+ self.assertNotContains(resp, "Completed")
3334
+
2571
3335
  def test_dashboard_shows_todos_when_node_unknown(self):
2572
3336
  Todo.objects.create(request="Check fallback")
2573
3337
  from nodes.models import Node
@@ -2576,7 +3340,8 @@ class FavoriteTests(TestCase):
2576
3340
 
2577
3341
  resp = self.client.get(reverse("admin:index"))
2578
3342
  self.assertContains(resp, "Release manager tasks")
2579
- self.assertContains(resp, "Check fallback")
3343
+ tooltip = escape("Check fallback")
3344
+ self.assertContains(resp, f'title="{tooltip}"')
2580
3345
 
2581
3346
  def test_dashboard_shows_todos_without_release_manager_profile(self):
2582
3347
  Todo.objects.create(request="Unrestricted task")
@@ -2584,7 +3349,8 @@ class FavoriteTests(TestCase):
2584
3349
 
2585
3350
  resp = self.client.get(reverse("admin:index"))
2586
3351
  self.assertContains(resp, "Release manager tasks")
2587
- self.assertContains(resp, "Unrestricted task")
3352
+ tooltip = escape("Unrestricted task")
3353
+ self.assertContains(resp, f'title="{tooltip}"')
2588
3354
 
2589
3355
  def test_dashboard_excludes_todo_changelist_link(self):
2590
3356
  ct = ContentType.objects.get_for_model(Todo)
@@ -2608,7 +3374,8 @@ class FavoriteTests(TestCase):
2608
3374
  self.client.force_login(other_user)
2609
3375
  resp = self.client.get(reverse("admin:index"))
2610
3376
  self.assertContains(resp, "Release manager tasks")
2611
- self.assertContains(resp, todo.request)
3377
+ tooltip = escape(todo.request)
3378
+ self.assertContains(resp, f'title="{tooltip}"')
2612
3379
 
2613
3380
  def test_dashboard_shows_todos_for_non_terminal_node(self):
2614
3381
  todo = Todo.objects.create(request="Terminal Tasks")
@@ -2619,7 +3386,8 @@ class FavoriteTests(TestCase):
2619
3386
  self.node.save(update_fields=["role"])
2620
3387
  resp = self.client.get(reverse("admin:index"))
2621
3388
  self.assertContains(resp, "Release manager tasks")
2622
- self.assertContains(resp, todo.request)
3389
+ tooltip = escape(todo.request)
3390
+ self.assertContains(resp, f'title="{tooltip}"')
2623
3391
 
2624
3392
  def test_dashboard_shows_todos_for_delegate_release_manager(self):
2625
3393
  todo = Todo.objects.create(request="Delegate Task")
@@ -2641,7 +3409,8 @@ class FavoriteTests(TestCase):
2641
3409
  self.client.force_login(operator)
2642
3410
  resp = self.client.get(reverse("admin:index"))
2643
3411
  self.assertContains(resp, "Release manager tasks")
2644
- self.assertContains(resp, todo.request)
3412
+ tooltip = escape(todo.request)
3413
+ self.assertContains(resp, f'title="{tooltip}"')
2645
3414
 
2646
3415
 
2647
3416
  class AdminIndexQueryRegressionTests(TestCase):
@@ -2836,47 +3605,6 @@ class AdminModelGraphViewTests(TestCase):
2836
3605
  self.assertEqual(kwargs.get("format"), "pdf")
2837
3606
 
2838
3607
 
2839
- class DatasetteTests(TestCase):
2840
- def setUp(self):
2841
- self.client = Client()
2842
- User = get_user_model()
2843
- self.user = User.objects.create_user(username="ds", password="pwd")
2844
- Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
2845
-
2846
- def test_datasette_auth_endpoint(self):
2847
- resp = self.client.get(reverse("pages:datasette-auth"))
2848
- self.assertEqual(resp.status_code, 401)
2849
- self.client.force_login(self.user)
2850
- resp = self.client.get(reverse("pages:datasette-auth"))
2851
- self.assertEqual(resp.status_code, 200)
2852
-
2853
- def test_navbar_includes_datasette_when_enabled(self):
2854
- lock_dir = Path(settings.BASE_DIR) / "locks"
2855
- lock_dir.mkdir(exist_ok=True)
2856
- lock_file = lock_dir / "datasette.lck"
2857
- try:
2858
- lock_file.touch()
2859
- resp = self.client.get(reverse("pages:index"))
2860
- self.assertContains(resp, 'href="/data/"')
2861
- finally:
2862
- lock_file.unlink(missing_ok=True)
2863
-
2864
- def test_admin_home_includes_datasette_button_when_enabled(self):
2865
- lock_dir = Path(settings.BASE_DIR) / "locks"
2866
- lock_dir.mkdir(exist_ok=True)
2867
- lock_file = lock_dir / "datasette.lck"
2868
- try:
2869
- lock_file.touch()
2870
- self.user.is_staff = True
2871
- self.user.is_superuser = True
2872
- self.user.save()
2873
- self.client.force_login(self.user)
2874
- resp = self.client.get(reverse("admin:index"))
2875
- self.assertContains(resp, 'href="/data/"')
2876
- self.assertContains(resp, ">Datasette<")
2877
- finally:
2878
- lock_file.unlink(missing_ok=True)
2879
-
2880
3608
 
2881
3609
  class UserStorySubmissionTests(TestCase):
2882
3610
  def setUp(self):
@@ -2885,6 +3613,14 @@ class UserStorySubmissionTests(TestCase):
2885
3613
  self.url = reverse("pages:user-story-submit")
2886
3614
  User = get_user_model()
2887
3615
  self.user = User.objects.create_user(username="feedbacker", password="pwd")
3616
+ self.capture_patcher = patch("pages.views.capture_screenshot", autospec=True)
3617
+ self.save_patcher = patch("pages.views.save_screenshot", autospec=True)
3618
+ self.mock_capture = self.capture_patcher.start()
3619
+ self.mock_save = self.save_patcher.start()
3620
+ self.mock_capture.return_value = Path("/tmp/fake.png")
3621
+ self.mock_save.return_value = None
3622
+ self.addCleanup(self.capture_patcher.stop)
3623
+ self.addCleanup(self.save_patcher.stop)
2888
3624
 
2889
3625
  def test_authenticated_submission_defaults_to_username(self):
2890
3626
  self.client.force_login(self.user)
@@ -2913,12 +3649,121 @@ class UserStorySubmissionTests(TestCase):
2913
3649
  self.assertEqual(story.referer, "https://example.test/wizard/step-1/")
2914
3650
  self.assertEqual(story.user_agent, "FeedbackBot/1.0")
2915
3651
  self.assertEqual(story.ip_address, "127.0.0.1")
3652
+ expected_language = (translation.get_language() or "").split("-")[0]
3653
+ self.assertTrue(story.language_code)
3654
+ self.assertTrue(
3655
+ story.language_code.startswith(expected_language),
3656
+ story.language_code,
3657
+ )
3658
+
3659
+ def test_submission_records_request_language(self):
3660
+ self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = "es"
3661
+ with translation.override("es"):
3662
+ response = self.client.post(
3663
+ self.url,
3664
+ {
3665
+ "rating": 4,
3666
+ "comments": "Buena experiencia",
3667
+ "path": "/es/soporte/",
3668
+ "take_screenshot": "1",
3669
+ },
3670
+ HTTP_ACCEPT_LANGUAGE="es",
3671
+ )
3672
+
3673
+ self.assertEqual(response.status_code, 200)
3674
+ story = UserStory.objects.get()
3675
+ self.assertEqual(story.language_code, "es")
2916
3676
 
2917
- def test_anonymous_submission_uses_provided_name(self):
3677
+ def test_submission_prefers_original_referer(self):
3678
+ self.client.get(
3679
+ reverse("pages:index"),
3680
+ HTTP_REFERER="https://ads.example/original",
3681
+ )
2918
3682
  response = self.client.post(
2919
3683
  self.url,
2920
3684
  {
2921
- "name": "Guest Reviewer",
3685
+ "rating": 3,
3686
+ "comments": "Works well",
3687
+ "path": "/wizard/step-2/",
3688
+ "name": "visitor@example.com",
3689
+ "take_screenshot": "0",
3690
+ },
3691
+ HTTP_REFERER="http://testserver/wizard/step-2/",
3692
+ HTTP_USER_AGENT="FeedbackBot/2.0",
3693
+ )
3694
+
3695
+ self.assertEqual(response.status_code, 200)
3696
+ story = UserStory.objects.get()
3697
+ self.assertEqual(story.referer, "https://ads.example/original")
3698
+
3699
+ def test_superuser_submission_creates_triage_todo(self):
3700
+ Todo.objects.all().delete()
3701
+ superuser = get_user_model().objects.create_superuser(
3702
+ username="overseer", email="overseer@example.com", password="pwd"
3703
+ )
3704
+ Node.objects.update_or_create(
3705
+ mac_address=Node.get_current_mac(),
3706
+ defaults={
3707
+ "hostname": "local-node",
3708
+ "address": "127.0.0.1",
3709
+ "port": 8888,
3710
+ "public_endpoint": "local-node",
3711
+ },
3712
+ )
3713
+ self.client.force_login(superuser)
3714
+ comments = "Review analytics dashboard flow"
3715
+ response = self.client.post(
3716
+ self.url,
3717
+ {
3718
+ "rating": 5,
3719
+ "comments": comments,
3720
+ "path": "/reports/analytics/",
3721
+ "take_screenshot": "0",
3722
+ },
3723
+ )
3724
+ self.assertEqual(response.status_code, 200)
3725
+ self.assertEqual(Todo.objects.count(), 1)
3726
+ todo = Todo.objects.get()
3727
+ self.assertEqual(todo.request, f"Triage {comments}")
3728
+ self.assertTrue(todo.is_user_data)
3729
+ self.assertEqual(todo.original_user, superuser)
3730
+ self.assertTrue(todo.original_user_is_authenticated)
3731
+ self.assertEqual(todo.origin_node, Node.get_local())
3732
+
3733
+ def test_screenshot_request_links_saved_sample(self):
3734
+ self.client.force_login(self.user)
3735
+ screenshot_file = Path("/tmp/fake.png")
3736
+ self.mock_capture.return_value = screenshot_file
3737
+ sample = ContentSample.objects.create(kind=ContentSample.IMAGE)
3738
+ self.mock_save.return_value = sample
3739
+
3740
+ response = self.client.post(
3741
+ self.url,
3742
+ {
3743
+ "rating": 5,
3744
+ "comments": "Loved the experience!",
3745
+ "path": "/wizard/step-1/",
3746
+ "take_screenshot": "1",
3747
+ },
3748
+ HTTP_REFERER="https://example.test/wizard/step-1/",
3749
+ )
3750
+
3751
+ self.assertEqual(response.status_code, 200)
3752
+ story = UserStory.objects.get()
3753
+ self.assertEqual(story.screenshot, sample)
3754
+ self.mock_capture.assert_called_once_with("https://example.test/wizard/step-1/")
3755
+ self.mock_save.assert_called_once_with(
3756
+ screenshot_file,
3757
+ method="USER_STORY",
3758
+ user=self.user,
3759
+ link_duplicates=True,
3760
+ )
3761
+
3762
+ def test_anonymous_submission_uses_provided_email(self):
3763
+ response = self.client.post(
3764
+ self.url,
3765
+ {
3766
+ "name": "guest@example.com",
2922
3767
  "rating": 3,
2923
3768
  "comments": "It was fine.",
2924
3769
  "path": "/status/",
@@ -2928,7 +3773,7 @@ class UserStorySubmissionTests(TestCase):
2928
3773
  self.assertEqual(response.status_code, 200)
2929
3774
  self.assertEqual(UserStory.objects.count(), 1)
2930
3775
  story = UserStory.objects.get()
2931
- self.assertEqual(story.name, "Guest Reviewer")
3776
+ self.assertEqual(story.name, "guest@example.com")
2932
3777
  self.assertIsNone(story.user)
2933
3778
  self.assertIsNone(story.owner)
2934
3779
  self.assertEqual(story.comments, "It was fine.")
@@ -2950,7 +3795,7 @@ class UserStorySubmissionTests(TestCase):
2950
3795
  self.assertFalse(UserStory.objects.exists())
2951
3796
  self.assertIn("rating", data.get("errors", {}))
2952
3797
 
2953
- def test_anonymous_submission_without_name_uses_fallback(self):
3798
+ def test_anonymous_submission_without_email_returns_errors(self):
2954
3799
  response = self.client.post(
2955
3800
  self.url,
2956
3801
  {
@@ -2960,18 +3805,32 @@ class UserStorySubmissionTests(TestCase):
2960
3805
  "take_screenshot": "1",
2961
3806
  },
2962
3807
  )
2963
- self.assertEqual(response.status_code, 200)
2964
- story = UserStory.objects.get()
2965
- self.assertEqual(story.name, "Anonymous")
2966
- self.assertIsNone(story.user)
2967
- self.assertIsNone(story.owner)
2968
- self.assertTrue(story.take_screenshot)
2969
- self.assertEqual(story.status, UserStory.Status.OPEN)
3808
+ self.assertEqual(response.status_code, 400)
3809
+ self.assertFalse(UserStory.objects.exists())
3810
+ data = response.json()
3811
+ self.assertIn("name", data.get("errors", {}))
3812
+
3813
+ def test_anonymous_submission_with_invalid_email_returns_errors(self):
3814
+ response = self.client.post(
3815
+ self.url,
3816
+ {
3817
+ "name": "Guest Reviewer",
3818
+ "rating": 3,
3819
+ "comments": "Needs improvement.",
3820
+ "path": "/feedback/",
3821
+ "take_screenshot": "1",
3822
+ },
3823
+ )
3824
+ self.assertEqual(response.status_code, 400)
3825
+ self.assertFalse(UserStory.objects.exists())
3826
+ data = response.json()
3827
+ self.assertIn("name", data.get("errors", {}))
2970
3828
 
2971
3829
  def test_submission_without_screenshot_request(self):
2972
3830
  response = self.client.post(
2973
3831
  self.url,
2974
3832
  {
3833
+ "name": "guest@example.com",
2975
3834
  "rating": 4,
2976
3835
  "comments": "Skip the screenshot, please.",
2977
3836
  "path": "/feedback/",
@@ -2981,9 +3840,14 @@ class UserStorySubmissionTests(TestCase):
2981
3840
  story = UserStory.objects.get()
2982
3841
  self.assertFalse(story.take_screenshot)
2983
3842
  self.assertIsNone(story.owner)
3843
+ self.assertIsNone(story.screenshot)
3844
+ self.assertEqual(story.status, UserStory.Status.OPEN)
3845
+ self.mock_capture.assert_not_called()
3846
+ self.mock_save.assert_not_called()
2984
3847
 
2985
3848
  def test_rate_limit_blocks_repeated_submissions(self):
2986
3849
  payload = {
3850
+ "name": "guest@example.com",
2987
3851
  "rating": 4,
2988
3852
  "comments": "Pretty good",
2989
3853
  "path": "/feedback/",
@@ -3084,6 +3948,8 @@ class UserStoryAdminActionTests(TestCase):
3084
3948
  comments="Helpful notes",
3085
3949
  take_screenshot=True,
3086
3950
  )
3951
+ self.story.language_code = "es"
3952
+ self.story.save(update_fields=["language_code"])
3087
3953
  self.admin = UserStoryAdmin(UserStory, admin.site)
3088
3954
 
3089
3955
  def _build_request(self):
@@ -3117,6 +3983,8 @@ class UserStoryAdminActionTests(TestCase):
3117
3983
  args, kwargs = mock_create_issue.call_args
3118
3984
  self.assertIn("Feedback for", args[0])
3119
3985
  self.assertIn("**Rating:**", args[1])
3986
+ self.assertIn("**Language:**", args[1])
3987
+ self.assertIn("(es)", args[1])
3120
3988
  self.assertEqual(kwargs.get("labels"), ["feedback"])
3121
3989
  self.assertEqual(
3122
3990
  kwargs.get("fingerprint"), f"user-story:{self.story.pk}"
@@ -3134,6 +4002,40 @@ class UserStoryAdminActionTests(TestCase):
3134
4002
 
3135
4003
  mock_create_issue.assert_not_called()
3136
4004
 
4005
+ def test_create_github_issues_action_links_to_credentials_when_missing(self):
4006
+ request = self._build_request()
4007
+ queryset = UserStory.objects.filter(pk=self.story.pk)
4008
+
4009
+ mock_url = "/admin/core/releasemanager/"
4010
+ with (
4011
+ patch(
4012
+ "pages.admin.reverse", return_value=mock_url
4013
+ ) as mock_reverse,
4014
+ patch.object(
4015
+ UserStory,
4016
+ "create_github_issue",
4017
+ side_effect=RuntimeError("GitHub token is not configured"),
4018
+ ),
4019
+ ):
4020
+ self.admin.create_github_issues(request, queryset)
4021
+
4022
+ messages_list = list(request._messages)
4023
+ self.assertTrue(messages_list)
4024
+
4025
+ opts = ReleaseManager._meta
4026
+ mock_reverse.assert_called_once_with(
4027
+ f"{self.admin.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
4028
+ )
4029
+ self.assertTrue(
4030
+ any(mock_url in message.message for message in messages_list),
4031
+ )
4032
+ self.assertTrue(
4033
+ any("Configure GitHub credentials" in message.message for message in messages_list),
4034
+ )
4035
+ self.assertTrue(
4036
+ any(message.level == messages.ERROR for message in messages_list),
4037
+ )
4038
+
3137
4039
 
3138
4040
  class ClientReportLiveUpdateTests(TestCase):
3139
4041
  def setUp(self):
@@ -3141,9 +4043,31 @@ class ClientReportLiveUpdateTests(TestCase):
3141
4043
 
3142
4044
  def test_client_report_includes_interval(self):
3143
4045
  resp = self.client.get(reverse("pages:client-report"))
3144
- self.assertEqual(resp.context["request"].live_update_interval, 5)
4046
+ self.assertEqual(resp.wsgi_request.live_update_interval, 5)
3145
4047
  self.assertContains(resp, "setInterval(() => location.reload()")
3146
4048
 
4049
+ def test_client_report_download_disables_refresh(self):
4050
+ User = get_user_model()
4051
+ user = User.objects.create_user(username="download-user", password="pwd")
4052
+ report = ClientReport.objects.create(
4053
+ start_date=date(2024, 1, 1),
4054
+ end_date=date(2024, 1, 2),
4055
+ data={},
4056
+ owner=user,
4057
+ disable_emails=True,
4058
+ language="en",
4059
+ title="",
4060
+ )
4061
+
4062
+ self.client.force_login(user)
4063
+ resp = self.client.get(
4064
+ reverse("pages:client-report"), {"download": report.pk}
4065
+ )
4066
+
4067
+ self.assertIsNone(getattr(resp.wsgi_request, "live_update_interval", None))
4068
+ self.assertContains(resp, "report-download-frame")
4069
+ self.assertNotContains(resp, "setInterval(() => location.reload()")
4070
+
3147
4071
 
3148
4072
  class ScreenshotSpecInfrastructureTests(TestCase):
3149
4073
  def test_runner_creates_outputs_and_cleans_old_samples(self):