arthexis 0.1.16__py3-none-any.whl → 0.1.26__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 (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
pages/tests.py CHANGED
@@ -3,6 +3,7 @@ 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
 
@@ -13,13 +14,14 @@ from django.templatetags.static import static
13
14
  from urllib.parse import quote
14
15
  from django.contrib.auth import get_user_model
15
16
  from django.contrib.sites.models import Site
16
- from django.contrib import admin
17
+ from django.contrib import admin, messages
17
18
  from django.contrib.messages.storage.fallback import FallbackStorage
18
19
  from django.core.exceptions import DisallowedHost
19
20
  from django.core.cache import cache
20
21
  from django.db import connection
21
22
  import socket
22
23
  from django.db import connection
24
+ from pages import site_config
23
25
  from pages.models import (
24
26
  Application,
25
27
  Landing,
@@ -32,6 +34,8 @@ from pages.models import (
32
34
  UserManual,
33
35
  UserStory,
34
36
  )
37
+ from django.http import FileResponse
38
+
35
39
  from pages.admin import (
36
40
  ApplicationAdmin,
37
41
  UserManualAdmin,
@@ -47,6 +51,7 @@ from pages.screenshot_specs import (
47
51
  )
48
52
  from pages.context_processors import nav_links
49
53
  from django.apps import apps as django_apps
54
+ from config.middleware import SiteHttpsRedirectMiddleware
50
55
  from core import mailer
51
56
  from core.admin import ProfileAdminMixin
52
57
  from core.models import (
@@ -57,21 +62,28 @@ from core.models import (
57
62
  RFID,
58
63
  ReleaseManager,
59
64
  SecurityGroup,
65
+ GoogleCalendarProfile,
60
66
  Todo,
61
67
  TOTPDeviceSettings,
62
68
  )
69
+ from ocpp.models import Charger
63
70
  from django.core.files.uploadedfile import SimpleUploadedFile
64
71
  import base64
72
+ import json
65
73
  import tempfile
66
74
  import shutil
75
+ from datetime import timedelta
67
76
  from io import StringIO
68
77
  from django.conf import settings
78
+ from django.utils import timezone
79
+ from django.utils.html import escape
69
80
  from pathlib import Path
70
81
  from unittest.mock import MagicMock, Mock, call, patch
71
82
  from types import SimpleNamespace
72
83
  from django.core.management import call_command
73
84
  import re
74
85
  from django.contrib.contenttypes.models import ContentType
86
+ from django.http import HttpResponse
75
87
  from datetime import (
76
88
  date,
77
89
  datetime,
@@ -82,6 +94,7 @@ from datetime import (
82
94
  from django.core import mail
83
95
  from django.utils import timezone
84
96
  from django.utils.text import slugify
97
+ from django.utils import translation
85
98
  from django.utils.translation import gettext
86
99
  from django_otp import DEVICE_ID_SESSION_KEY
87
100
  from django_otp.oath import TOTP
@@ -96,6 +109,7 @@ from nodes.models import (
96
109
  NodeRole,
97
110
  NodeFeature,
98
111
  NodeFeatureAssignment,
112
+ NetMessage,
99
113
  )
100
114
  from django.contrib.auth.models import AnonymousUser
101
115
 
@@ -187,6 +201,41 @@ class LoginViewTests(TestCase):
187
201
  )
188
202
  self.assertRedirects(resp, "/nodes/list/")
189
203
 
204
+ def test_homepage_excludes_version_banner_for_anonymous(self):
205
+ response = self.client.get(reverse("pages:index"))
206
+
207
+ self.assertEqual(response.status_code, 200)
208
+ self.assertNotContains(response, "__versionCheckInitialized")
209
+
210
+ def test_homepage_includes_version_banner_for_staff(self):
211
+ self.client.force_login(self.staff)
212
+ response = self.client.get(reverse("pages:index"))
213
+
214
+ self.assertEqual(response.status_code, 200)
215
+ self.assertContains(response, "__versionCheckInitialized")
216
+
217
+
218
+ class AdminTemplateVersionBannerTests(TestCase):
219
+ def setUp(self):
220
+ self.client = Client()
221
+ User = get_user_model()
222
+ self.staff = User.objects.create_user(
223
+ username="admin-staff", password="pwd", is_staff=True
224
+ )
225
+
226
+ def test_admin_login_excludes_version_banner_for_anonymous(self):
227
+ response = self.client.get(reverse("admin:login"))
228
+
229
+ self.assertEqual(response.status_code, 200)
230
+ self.assertNotContains(response, "__versionCheckInitialized")
231
+
232
+ def test_admin_dashboard_includes_version_banner_for_staff(self):
233
+ self.client.force_login(self.staff)
234
+ response = self.client.get(reverse("admin:index"))
235
+
236
+ self.assertEqual(response.status_code, 200)
237
+ self.assertContains(response, "__versionCheckInitialized")
238
+
190
239
  def test_staff_redirects_next_when_specified(self):
191
240
  resp = self.client.post(
192
241
  reverse("pages:login") + "?next=/nodes/list/",
@@ -466,6 +515,23 @@ class InvitationTests(TestCase):
466
515
  self.assertEqual(lead.mac_address, "")
467
516
  self.assertEqual(len(mail.outbox), 0)
468
517
 
518
+ def test_request_invite_uses_original_referer(self):
519
+ InviteLead.objects.all().delete()
520
+ self.client.get(
521
+ reverse("pages:index"),
522
+ HTTP_REFERER="https://campaign.example/landing",
523
+ )
524
+
525
+ resp = self.client.post(
526
+ reverse("pages:request-invite"),
527
+ {"email": "origin@example.com"},
528
+ HTTP_REFERER="http://testserver/pages/request-invite/",
529
+ )
530
+
531
+ self.assertEqual(resp.status_code, 200)
532
+ lead = InviteLead.objects.get()
533
+ self.assertEqual(lead.referer, "https://campaign.example/landing")
534
+
469
535
  def test_request_invite_falls_back_to_send_mail(self):
470
536
  node = Node.objects.create(
471
537
  hostname="local", address="127.0.0.1", mac_address="00:11:22:33:44:55"
@@ -501,6 +567,7 @@ class InvitationTests(TestCase):
501
567
  lead = InviteLead.objects.get()
502
568
  self.assertEqual(lead.mac_address, "aa:bb:cc:dd:ee:ff")
503
569
 
570
+ @pytest.mark.feature("ap-router")
504
571
  @patch("pages.views.public_wifi.grant_public_access")
505
572
  @patch(
506
573
  "pages.views.public_wifi.resolve_mac_address",
@@ -672,19 +739,36 @@ class AdminDashboardAppListTests(TestCase):
672
739
 
673
740
  def test_horologia_hidden_without_celery_feature(self):
674
741
  resp = self.client.get(reverse("admin:index"))
675
- self.assertNotContains(resp, "5. Horologia MODELS")
742
+ self.assertNotContains(resp, "5. Horologia</a>")
676
743
 
677
744
  def test_horologia_visible_with_celery_feature(self):
678
745
  feature = NodeFeature.objects.create(slug="celery-queue", display="Celery Queue")
679
746
  NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
680
747
  resp = self.client.get(reverse("admin:index"))
681
- self.assertContains(resp, "5. Horologia MODELS")
748
+ self.assertContains(resp, "5. Horologia</a>")
682
749
 
683
750
  def test_horologia_visible_with_celery_lock(self):
684
751
  self.celery_lock.write_text("")
685
752
  resp = self.client.get(reverse("admin:index"))
686
- self.assertContains(resp, "5. Horologia MODELS")
753
+ self.assertContains(resp, "5. Horologia</a>")
687
754
 
755
+ def test_dashboard_shows_last_net_message(self):
756
+ NetMessage.objects.all().delete()
757
+ NetMessage.objects.create(subject="Older", body="First body")
758
+ NetMessage.objects.create(subject="Latest", body="Signal ready")
759
+
760
+ resp = self.client.get(reverse("admin:index"))
761
+
762
+ self.assertContains(resp, gettext("Net message"))
763
+ self.assertContains(resp, "Latest — Signal ready")
764
+ self.assertNotContains(resp, gettext("No net messages available"))
765
+
766
+ def test_dashboard_shows_placeholder_without_net_message(self):
767
+ NetMessage.objects.all().delete()
768
+
769
+ resp = self.client.get(reverse("admin:index"))
770
+
771
+ self.assertContains(resp, gettext("No net messages available"))
688
772
 
689
773
  class AdminSidebarTests(TestCase):
690
774
  def setUp(self):
@@ -707,6 +791,66 @@ class AdminSidebarTests(TestCase):
707
791
  self.assertContains(resp, 'id="admin-collapsible-apps"')
708
792
 
709
793
 
794
+ class AdminGoogleCalendarSidebarTests(TestCase):
795
+ def setUp(self):
796
+ self.client = Client()
797
+ User = get_user_model()
798
+ self.admin = User.objects.create_superuser(
799
+ username="calendar_admin", password="pwd", email="admin@example.com"
800
+ )
801
+ self.client.force_login(self.admin)
802
+ Site.objects.update_or_create(
803
+ id=1, defaults={"name": "test", "domain": "testserver"}
804
+ )
805
+ Node.objects.create(hostname="testserver", address="127.0.0.1")
806
+
807
+ def test_calendar_module_hidden_without_profile(self):
808
+ resp = self.client.get(reverse("admin:index"))
809
+ self.assertNotContains(resp, 'id="google-calendar-module"', html=False)
810
+
811
+ @patch("core.models.GoogleCalendarProfile.fetch_events")
812
+ def test_calendar_module_shows_events_for_user(self, fetch_events):
813
+ fetch_events.return_value = [
814
+ {
815
+ "summary": "Standup",
816
+ "start": timezone.now(),
817
+ "end": None,
818
+ "all_day": False,
819
+ "html_link": "https://calendar.google.com/event",
820
+ "location": "HQ",
821
+ }
822
+ ]
823
+ GoogleCalendarProfile.objects.create(
824
+ user=self.admin,
825
+ calendar_id="example@group.calendar.google.com",
826
+ api_key="secret",
827
+ display_name="Team Calendar",
828
+ )
829
+
830
+ resp = self.client.get(reverse("admin:index"))
831
+
832
+ self.assertContains(resp, 'id="google-calendar-module"', html=False)
833
+ self.assertContains(resp, "Standup")
834
+ self.assertContains(resp, "Open full calendar")
835
+ fetch_events.assert_called_once()
836
+
837
+ @patch("core.models.GoogleCalendarProfile.fetch_events")
838
+ def test_calendar_module_uses_group_profile(self, fetch_events):
839
+ fetch_events.return_value = []
840
+ group = SecurityGroup.objects.create(name="Calendar Group")
841
+ self.admin.groups.add(group)
842
+ GoogleCalendarProfile.objects.create(
843
+ group=group,
844
+ calendar_id="group@calendar.google.com",
845
+ api_key="secret",
846
+ )
847
+
848
+ resp = self.client.get(reverse("admin:index"))
849
+
850
+ self.assertContains(resp, 'id="google-calendar-module"', html=False)
851
+ fetch_events.assert_called_once()
852
+
853
+
710
854
  class ViewHistoryLoggingTests(TestCase):
711
855
  def setUp(self):
712
856
  self.client = Client()
@@ -795,7 +939,8 @@ class ViewHistoryLoggingTests(TestCase):
795
939
  )
796
940
  landing = module.landings.get(path="/")
797
941
  landing.label = "Home Landing"
798
- landing.save(update_fields=["label"])
942
+ landing.track_leads = True
943
+ landing.save(update_fields=["label", "track_leads"])
799
944
 
800
945
  resp = self.client.get(
801
946
  reverse("pages:index"), HTTP_REFERER="https://example.com/ref"
@@ -807,6 +952,35 @@ class ViewHistoryLoggingTests(TestCase):
807
952
  self.assertEqual(lead.path, "/")
808
953
  self.assertEqual(lead.referer, "https://example.com/ref")
809
954
 
955
+ def test_pages_config_purges_old_view_history(self):
956
+ ViewHistory.objects.all().delete()
957
+
958
+ old_entry = ViewHistory.objects.create(
959
+ path="/old/",
960
+ method="GET",
961
+ status_code=200,
962
+ status_text="OK",
963
+ )
964
+ new_entry = ViewHistory.objects.create(
965
+ path="/recent/",
966
+ method="GET",
967
+ status_code=200,
968
+ status_text="OK",
969
+ )
970
+
971
+ ViewHistory.objects.filter(pk=old_entry.pk).update(
972
+ visited_at=timezone.now() - timedelta(days=20)
973
+ )
974
+ ViewHistory.objects.filter(pk=new_entry.pk).update(
975
+ visited_at=timezone.now() - timedelta(days=10)
976
+ )
977
+
978
+ config = django_apps.get_app_config("pages")
979
+ config._purge_view_history()
980
+
981
+ self.assertFalse(ViewHistory.objects.filter(pk=old_entry.pk).exists())
982
+ self.assertTrue(ViewHistory.objects.filter(pk=new_entry.pk).exists())
983
+
810
984
  def test_landing_visit_does_not_record_lead_without_celery(self):
811
985
  role = NodeRole.objects.create(name="no-celery-role")
812
986
  application = Application.objects.create(
@@ -820,7 +994,8 @@ class ViewHistoryLoggingTests(TestCase):
820
994
  )
821
995
  landing = module.landings.get(path="/")
822
996
  landing.label = "No Celery"
823
- landing.save(update_fields=["label"])
997
+ landing.track_leads = True
998
+ landing.save(update_fields=["label", "track_leads"])
824
999
 
825
1000
  resp = self.client.get(reverse("pages:index"))
826
1001
 
@@ -840,7 +1015,8 @@ class ViewHistoryLoggingTests(TestCase):
840
1015
  )
841
1016
  landing = module.landings.get(path="/")
842
1017
  landing.enabled = False
843
- landing.save(update_fields=["enabled"])
1018
+ landing.track_leads = True
1019
+ landing.save(update_fields=["enabled", "track_leads"])
844
1020
 
845
1021
  resp = self.client.get(reverse("pages:index"))
846
1022
 
@@ -902,6 +1078,44 @@ class ViewHistoryAdminTests(TestCase):
902
1078
  self.assertEqual(totals.get("/"), 2)
903
1079
  self.assertEqual(totals.get("/about/"), 1)
904
1080
 
1081
+ def test_graph_data_endpoint_respects_days_parameter(self):
1082
+ ViewHistory.all_objects.all().delete()
1083
+ reference_date = date(2025, 5, 1)
1084
+ tz = timezone.get_current_timezone()
1085
+ path = "/range/"
1086
+
1087
+ for offset in range(10):
1088
+ entry = ViewHistory.objects.create(
1089
+ path=path,
1090
+ method="GET",
1091
+ status_code=200,
1092
+ status_text="OK",
1093
+ error_message="",
1094
+ view_name="pages:index",
1095
+ )
1096
+ visited_date = reference_date - timedelta(days=offset)
1097
+ visited_at = timezone.make_aware(
1098
+ datetime.combine(visited_date, datetime_time(12, 0)), tz
1099
+ )
1100
+ entry.visited_at = visited_at
1101
+ entry.save(update_fields=["visited_at"])
1102
+
1103
+ url = reverse("admin:pages_viewhistory_traffic_data")
1104
+ with patch("pages.admin.timezone.localdate", return_value=reference_date):
1105
+ resp = self.client.get(url, {"days": 7})
1106
+
1107
+ self.assertEqual(resp.status_code, 200)
1108
+ data = resp.json()
1109
+
1110
+ self.assertEqual(len(data.get("labels", [])), 7)
1111
+ self.assertEqual(data.get("meta", {}).get("start"), (reference_date - timedelta(days=6)).isoformat())
1112
+ self.assertEqual(data.get("meta", {}).get("end"), reference_date.isoformat())
1113
+
1114
+ totals = {
1115
+ dataset["label"]: sum(dataset["data"]) for dataset in data.get("datasets", [])
1116
+ }
1117
+ self.assertEqual(totals.get(path), 7)
1118
+
905
1119
  def test_graph_data_includes_late_evening_visits(self):
906
1120
  target_date = date(2025, 9, 27)
907
1121
  entry = ViewHistory.objects.create(
@@ -1109,6 +1323,50 @@ class LogViewerAdminTests(SimpleTestCase):
1109
1323
  self.assertEqual(context["selected_log"], "selected.log")
1110
1324
  self.assertIn("hello world", context["log_content"])
1111
1325
 
1326
+ def test_log_viewer_applies_line_limit(self):
1327
+ content = "\n".join(f"line {i}" for i in range(50))
1328
+ self._create_log("limited.log", content)
1329
+ response = self._render({"log": "limited.log", "limit": "20"})
1330
+ context = response.context_data
1331
+ self.assertEqual(context["log_limit_choice"], "20")
1332
+ self.assertIn("line 49", context["log_content"])
1333
+ self.assertIn("line 30", context["log_content"])
1334
+ self.assertNotIn("line 29", context["log_content"])
1335
+
1336
+ def test_log_viewer_all_limit_returns_full_log(self):
1337
+ content = "first\nsecond\nthird"
1338
+ self._create_log("all.log", content)
1339
+ response = self._render({"log": "all.log", "limit": "all"})
1340
+ context = response.context_data
1341
+ self.assertEqual(context["log_limit_choice"], "all")
1342
+ self.assertIn("first", context["log_content"])
1343
+ self.assertIn("second", context["log_content"])
1344
+
1345
+ def test_log_viewer_invalid_limit_defaults_to_20(self):
1346
+ content = "\n".join(f"item {i}" for i in range(5))
1347
+ self._create_log("invalid-limit.log", content)
1348
+ response = self._render({"log": "invalid-limit.log", "limit": "oops"})
1349
+ context = response.context_data
1350
+ self.assertEqual(context["log_limit_choice"], "20")
1351
+
1352
+ def test_log_viewer_downloads_selected_log(self):
1353
+ self._create_log("download.log", "downloadable content")
1354
+ request = self._build_request({"log": "download.log", "download": "1"})
1355
+ context = {
1356
+ "site_title": "Constellation",
1357
+ "site_header": "Constellation",
1358
+ "site_url": "/",
1359
+ "available_apps": [],
1360
+ }
1361
+ with patch("pages.admin.admin.site.each_context", return_value=context), patch(
1362
+ "pages.context_processors.get_site", return_value=None
1363
+ ):
1364
+ response = log_viewer(request)
1365
+ self.assertIsInstance(response, FileResponse)
1366
+ self.assertIn("attachment", response["Content-Disposition"])
1367
+ content = b"".join(response.streaming_content).decode()
1368
+ self.assertIn("downloadable content", content)
1369
+
1112
1370
  def test_log_viewer_reports_missing_log(self):
1113
1371
  response = self._render({"log": "missing.log"})
1114
1372
  self.assertIn("requested log could not be found", response.context_data["log_error"])
@@ -1156,6 +1414,125 @@ class AdminModelStatusTests(TestCase):
1156
1414
  self.assertContains(resp, 'class="model-status missing"', count=1)
1157
1415
 
1158
1416
 
1417
+ class _FakeQuerySet(list):
1418
+ def only(self, *args, **kwargs):
1419
+ return self
1420
+
1421
+ def order_by(self, *args, **kwargs):
1422
+ return self
1423
+
1424
+
1425
+ class SiteConfigurationStagingTests(SimpleTestCase):
1426
+ def setUp(self):
1427
+ self.tmpdir = tempfile.mkdtemp()
1428
+ self.addCleanup(shutil.rmtree, self.tmpdir)
1429
+ self.config_path = Path(self.tmpdir) / "nginx-sites.json"
1430
+ self._path_patcher = patch(
1431
+ "pages.site_config._sites_config_path", side_effect=lambda: self.config_path
1432
+ )
1433
+ self._path_patcher.start()
1434
+ self.addCleanup(self._path_patcher.stop)
1435
+ self._model_patcher = patch("pages.site_config.apps.get_model")
1436
+ self.mock_get_model = self._model_patcher.start()
1437
+ self.addCleanup(self._model_patcher.stop)
1438
+
1439
+ def _read_config(self):
1440
+ if not self.config_path.exists():
1441
+ return None
1442
+ return json.loads(self.config_path.read_text(encoding="utf-8"))
1443
+
1444
+ def _set_sites(self, sites):
1445
+ queryset = _FakeQuerySet(sites)
1446
+
1447
+ class _Manager:
1448
+ @staticmethod
1449
+ def filter(**kwargs):
1450
+ return queryset
1451
+
1452
+ self.mock_get_model.return_value = SimpleNamespace(objects=_Manager())
1453
+
1454
+ def test_managed_site_persists_configuration(self):
1455
+ self._set_sites([SimpleNamespace(domain="example.com", require_https=True)])
1456
+ site_config.update_local_nginx_scripts()
1457
+ config = self._read_config()
1458
+ self.assertEqual(
1459
+ config,
1460
+ [
1461
+ {
1462
+ "domain": "example.com",
1463
+ "require_https": True,
1464
+ }
1465
+ ],
1466
+ )
1467
+
1468
+ def test_disabling_managed_site_removes_entry(self):
1469
+ primary = SimpleNamespace(domain="primary.test", require_https=False)
1470
+ secondary = SimpleNamespace(domain="secondary.test", require_https=False)
1471
+ self._set_sites([primary, secondary])
1472
+ site_config.update_local_nginx_scripts()
1473
+ config = self._read_config()
1474
+ self.assertEqual(
1475
+ [entry["domain"] for entry in config],
1476
+ ["primary.test", "secondary.test"],
1477
+ )
1478
+
1479
+ self._set_sites([secondary])
1480
+ site_config.update_local_nginx_scripts()
1481
+ config = self._read_config()
1482
+ self.assertEqual(config, [{"domain": "secondary.test", "require_https": False}])
1483
+
1484
+ self._set_sites([])
1485
+ site_config.update_local_nginx_scripts()
1486
+ self.assertIsNone(self._read_config())
1487
+
1488
+ def test_require_https_toggle_updates_configuration(self):
1489
+ site = SimpleNamespace(domain="secure.example", require_https=False)
1490
+ self._set_sites([site])
1491
+ site_config.update_local_nginx_scripts()
1492
+ config = self._read_config()
1493
+ self.assertEqual(config, [{"domain": "secure.example", "require_https": False}])
1494
+
1495
+ site.require_https = True
1496
+ self._set_sites([site])
1497
+ site_config.update_local_nginx_scripts()
1498
+ config = self._read_config()
1499
+ self.assertEqual(config, [{"domain": "secure.example", "require_https": True}])
1500
+
1501
+
1502
+ class SiteRequireHttpsMiddlewareTests(SimpleTestCase):
1503
+ def setUp(self):
1504
+ self.factory = RequestFactory()
1505
+ self.middleware = SiteHttpsRedirectMiddleware(lambda request: HttpResponse("ok"))
1506
+ self.secure_site = SimpleNamespace(domain="secure.test", require_https=True)
1507
+
1508
+ def test_http_request_redirects_to_https(self):
1509
+ request = self.factory.get("/", HTTP_HOST="secure.test")
1510
+ request.site = self.secure_site
1511
+ response = self.middleware(request)
1512
+ self.assertEqual(response.status_code, 301)
1513
+ self.assertTrue(response["Location"].startswith("https://secure.test"))
1514
+
1515
+ def test_secure_request_not_redirected(self):
1516
+ request = self.factory.get("/", HTTP_HOST="secure.test", secure=True)
1517
+ request.site = self.secure_site
1518
+ response = self.middleware(request)
1519
+ self.assertEqual(response.status_code, 200)
1520
+
1521
+ def test_forwarded_proto_respected(self):
1522
+ request = self.factory.get(
1523
+ "/", HTTP_HOST="secure.test", HTTP_X_FORWARDED_PROTO="https"
1524
+ )
1525
+ request.site = self.secure_site
1526
+ response = self.middleware(request)
1527
+ self.assertEqual(response.status_code, 200)
1528
+
1529
+ self.secure_site.require_https = False
1530
+ request = self.factory.get("/", HTTP_HOST="secure.test")
1531
+ request.site = self.secure_site
1532
+ response = self.middleware(request)
1533
+ self.assertEqual(response.status_code, 200)
1534
+
1535
+
1159
1536
  class SiteAdminRegisterCurrentTests(TestCase):
1160
1537
  def setUp(self):
1161
1538
  self.client = Client()
@@ -1188,6 +1565,7 @@ class SiteAdminRegisterCurrentTests(TestCase):
1188
1565
  self.assertEqual(site.name, "")
1189
1566
 
1190
1567
 
1568
+ @pytest.mark.feature("screenshot-poll")
1191
1569
  class SiteAdminScreenshotTests(TestCase):
1192
1570
  def setUp(self):
1193
1571
  self.client = Client()
@@ -1307,17 +1685,17 @@ class NavAppsTests(TestCase):
1307
1685
  )
1308
1686
  app = Application.objects.create(name="Readme")
1309
1687
  Module.objects.create(
1310
- node_role=role, application=app, path="/", is_default=True
1688
+ node_role=role, application=app, path="/", is_default=True, menu="Cookbooks"
1311
1689
  )
1312
1690
 
1313
1691
  def test_nav_pill_renders(self):
1314
1692
  resp = self.client.get(reverse("pages:index"))
1315
- self.assertContains(resp, "README")
1693
+ self.assertContains(resp, "COOKBOOKS")
1316
1694
  self.assertContains(resp, "badge rounded-pill")
1317
1695
 
1318
1696
  def test_nav_pill_renders_with_port(self):
1319
1697
  resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8000")
1320
- self.assertContains(resp, "README")
1698
+ self.assertContains(resp, "COOKBOOKS")
1321
1699
 
1322
1700
  def test_nav_pill_uses_menu_field(self):
1323
1701
  site_app = Module.objects.get()
@@ -1325,7 +1703,7 @@ class NavAppsTests(TestCase):
1325
1703
  site_app.save()
1326
1704
  resp = self.client.get(reverse("pages:index"))
1327
1705
  self.assertContains(resp, 'badge rounded-pill text-bg-secondary">DOCS')
1328
- self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">README')
1706
+ self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1329
1707
 
1330
1708
  def test_app_without_root_url_excluded(self):
1331
1709
  role = NodeRole.objects.get(name="Terminal")
@@ -1390,20 +1768,22 @@ class RoleLandingRedirectTests(TestCase):
1390
1768
 
1391
1769
  def test_satellite_redirects_to_dashboard(self):
1392
1770
  target = self._configure_role_landing(
1393
- "Satellite", "/ocpp/", "CPMS Online Dashboard"
1771
+ "Satellite", "/ocpp/cpms/dashboard/", "CPMS Online Dashboard"
1394
1772
  )
1395
1773
  resp = self.client.get(reverse("pages:index"))
1396
1774
  self.assertRedirects(resp, target, fetch_redirect_response=False)
1397
1775
 
1398
1776
  def test_control_redirects_to_rfid(self):
1399
1777
  target = self._configure_role_landing(
1400
- "Control", "/ocpp/rfid/", "RFID Tag Validator"
1778
+ "Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
1401
1779
  )
1402
1780
  resp = self.client.get(reverse("pages:index"))
1403
1781
  self.assertRedirects(resp, target, fetch_redirect_response=False)
1404
1782
 
1405
1783
  def test_security_group_redirect_takes_priority(self):
1406
- self._configure_role_landing("Control", "/ocpp/rfid/", "RFID Tag Validator")
1784
+ self._configure_role_landing(
1785
+ "Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
1786
+ )
1407
1787
  role = self.node.role
1408
1788
  group = SecurityGroup.objects.create(name="Operators")
1409
1789
  group_landing = self._ensure_landing(role, "/ocpp/group/", "Group Landing")
@@ -1420,7 +1800,9 @@ class RoleLandingRedirectTests(TestCase):
1420
1800
  )
1421
1801
 
1422
1802
  def test_user_redirect_overrides_group_with_higher_priority(self):
1423
- self._configure_role_landing("Control", "/ocpp/rfid/", "RFID Tag Validator")
1803
+ self._configure_role_landing(
1804
+ "Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
1805
+ )
1424
1806
  role = self.node.role
1425
1807
  group = SecurityGroup.objects.create(name="Operators")
1426
1808
  group_landing = self._ensure_landing(role, "/ocpp/group/", "Group Landing")
@@ -1442,10 +1824,10 @@ class RoleLandingRedirectTests(TestCase):
1442
1824
  )
1443
1825
 
1444
1826
 
1445
- class ConstellationNavTests(TestCase):
1827
+ class WatchtowerNavTests(TestCase):
1446
1828
  def setUp(self):
1447
1829
  self.client = Client()
1448
- role, _ = NodeRole.objects.get_or_create(name="Constellation")
1830
+ role, _ = NodeRole.objects.get_or_create(name="Watchtower")
1449
1831
  Node.objects.update_or_create(
1450
1832
  mac_address=Node.get_current_mac(),
1451
1833
  defaults={
@@ -1455,38 +1837,56 @@ class ConstellationNavTests(TestCase):
1455
1837
  },
1456
1838
  )
1457
1839
  Site.objects.update_or_create(
1458
- id=1, defaults={"domain": "testserver", "name": ""}
1840
+ id=1, defaults={"domain": "arthexis.com", "name": "Arthexis"}
1459
1841
  )
1460
1842
  fixtures = [
1461
1843
  Path(
1462
1844
  settings.BASE_DIR,
1463
1845
  "pages",
1464
1846
  "fixtures",
1465
- "constellation__application_ocpp.json",
1847
+ "default__application_pages.json",
1848
+ ),
1849
+ Path(
1850
+ settings.BASE_DIR,
1851
+ "pages",
1852
+ "fixtures",
1853
+ "watchtower__application_ocpp.json",
1854
+ ),
1855
+ Path(
1856
+ settings.BASE_DIR,
1857
+ "pages",
1858
+ "fixtures",
1859
+ "watchtower__module_ocpp.json",
1860
+ ),
1861
+ Path(
1862
+ settings.BASE_DIR,
1863
+ "pages",
1864
+ "fixtures",
1865
+ "watchtower__landing_ocpp_dashboard.json",
1466
1866
  ),
1467
1867
  Path(
1468
1868
  settings.BASE_DIR,
1469
1869
  "pages",
1470
1870
  "fixtures",
1471
- "constellation__module_ocpp.json",
1871
+ "watchtower__landing_ocpp_cp_simulator.json",
1472
1872
  ),
1473
1873
  Path(
1474
1874
  settings.BASE_DIR,
1475
1875
  "pages",
1476
1876
  "fixtures",
1477
- "constellation__landing_ocpp_dashboard.json",
1877
+ "watchtower__landing_ocpp_rfid.json",
1478
1878
  ),
1479
1879
  Path(
1480
1880
  settings.BASE_DIR,
1481
1881
  "pages",
1482
1882
  "fixtures",
1483
- "constellation__landing_ocpp_cp_simulator.json",
1883
+ "watchtower__module_readme.json",
1484
1884
  ),
1485
1885
  Path(
1486
1886
  settings.BASE_DIR,
1487
1887
  "pages",
1488
1888
  "fixtures",
1489
- "constellation__landing_ocpp_rfid.json",
1889
+ "watchtower__landing_readme.json",
1490
1890
  ),
1491
1891
  ]
1492
1892
  call_command("loaddata", *map(str, fixtures))
@@ -1499,13 +1899,13 @@ class ConstellationNavTests(TestCase):
1499
1899
  self.assertNotIn("RFID", nav_labels)
1500
1900
  self.assertTrue(
1501
1901
  Module.objects.filter(
1502
- path="/ocpp/", node_role__name="Constellation"
1902
+ path="/ocpp/", node_role__name="Watchtower"
1503
1903
  ).exists()
1504
1904
  )
1505
1905
  self.assertFalse(
1506
1906
  Module.objects.filter(
1507
1907
  path="/ocpp/rfid/",
1508
- node_role__name="Constellation",
1908
+ node_role__name="Watchtower",
1509
1909
  is_deleted=False,
1510
1910
  ).exists()
1511
1911
  )
@@ -1517,9 +1917,16 @@ class ConstellationNavTests(TestCase):
1517
1917
  landing_labels = [landing.label for landing in ocpp_module.enabled_landings]
1518
1918
  self.assertIn("RFID Tag Validator", landing_labels)
1519
1919
 
1920
+ @override_settings(ALLOWED_HOSTS=["testserver", "arthexis.com"])
1921
+ def test_cookbooks_pill_visible_for_arthexis(self):
1922
+ resp = self.client.get(
1923
+ reverse("pages:index"), HTTP_HOST="arthexis.com"
1924
+ )
1925
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1926
+
1520
1927
  def test_ocpp_dashboard_visible(self):
1521
1928
  resp = self.client.get(reverse("pages:index"))
1522
- self.assertContains(resp, 'href="/ocpp/"')
1929
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
1523
1930
 
1524
1931
 
1525
1932
  class ReleaseModuleNavTests(TestCase):
@@ -1661,7 +2068,7 @@ class ControlNavTests(TestCase):
1661
2068
  self.client.force_login(user)
1662
2069
  resp = self.client.get(reverse("pages:index"))
1663
2070
  self.assertEqual(resp.status_code, 200)
1664
- self.assertContains(resp, 'href="/ocpp/"')
2071
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
1665
2072
  self.assertContains(
1666
2073
  resp, 'badge rounded-pill text-bg-secondary">CHARGERS'
1667
2074
  )
@@ -1693,10 +2100,76 @@ class ControlNavTests(TestCase):
1693
2100
  self.assertFalse(resp.context["header_references"])
1694
2101
  self.assertNotContains(resp, "https://example.com/hidden")
1695
2102
 
2103
+ def test_header_link_hidden_when_only_site_matches(self):
2104
+ terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
2105
+ site = Site.objects.get(domain="testserver")
2106
+ reference = Reference.objects.create(
2107
+ alt_text="Restricted",
2108
+ value="https://example.com/restricted",
2109
+ show_in_header=True,
2110
+ )
2111
+ reference.roles.add(terminal_role)
2112
+ reference.sites.add(site)
2113
+
2114
+ resp = self.client.get(reverse("pages:index"))
2115
+
2116
+ self.assertIn("header_references", resp.context)
2117
+ self.assertFalse(resp.context["header_references"])
2118
+ self.assertNotContains(resp, "https://example.com/restricted")
2119
+
1696
2120
  def test_readme_pill_visible(self):
1697
2121
  resp = self.client.get(reverse("pages:readme"))
1698
- self.assertContains(resp, 'href="/readme/"')
1699
- self.assertContains(resp, 'badge rounded-pill text-bg-secondary">README')
2122
+ self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
2123
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
2124
+
2125
+ def test_cookbook_pill_has_no_dropdown(self):
2126
+ module = Module.objects.get(node_role__name="Control", path="/read/")
2127
+ Landing.objects.create(
2128
+ module=module,
2129
+ path="/man/",
2130
+ label="Manuals",
2131
+ enabled=True,
2132
+ )
2133
+
2134
+ resp = self.client.get(reverse("pages:readme"))
2135
+
2136
+ self.assertContains(
2137
+ resp,
2138
+ '<a class="nav-link" href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"><span class="badge rounded-pill text-bg-secondary">COOKBOOKS</span></a>',
2139
+ html=True,
2140
+ )
2141
+ self.assertNotContains(resp, 'dropdown-item" href="/man/"')
2142
+
2143
+ def test_readme_page_includes_qr_share(self):
2144
+ resp = self.client.get(reverse("pages:readme"), {"section": "intro"})
2145
+ self.assertContains(resp, 'id="reader-qr"')
2146
+ self.assertContains(
2147
+ resp,
2148
+ 'data-url="http://testserver/read/?section=intro"',
2149
+ )
2150
+ self.assertNotContains(resp, "Scan this page")
2151
+ self.assertNotContains(
2152
+ resp, 'class="small text-break text-muted mt-3 mb-0"'
2153
+ )
2154
+
2155
+ def test_readme_document_by_name(self):
2156
+ resp = self.client.get(reverse("pages:readme-document", args=["AGENTS.md"]))
2157
+ self.assertEqual(resp.status_code, 200)
2158
+ self.assertContains(resp, "Agent Guidelines")
2159
+
2160
+ def test_readme_document_by_relative_path(self):
2161
+ resp = self.client.get(
2162
+ reverse(
2163
+ "pages:readme-document",
2164
+ args=["docs/development/maintenance-roadmap.md"],
2165
+ )
2166
+ )
2167
+ self.assertEqual(resp.status_code, 200)
2168
+ self.assertContains(resp, "Maintenance Improvement Proposals")
2169
+
2170
+ def test_readme_document_rejects_traversal(self):
2171
+ resp = self.client.get("/read/../../SECRET.md")
2172
+ self.assertEqual(resp.status_code, 404)
1700
2173
 
1701
2174
 
1702
2175
  class SatelliteNavTests(TestCase):
@@ -1768,8 +2241,8 @@ class SatelliteNavTests(TestCase):
1768
2241
 
1769
2242
  def test_readme_pill_visible(self):
1770
2243
  resp = self.client.get(reverse("pages:readme"))
1771
- self.assertContains(resp, 'href="/readme/"')
1772
- self.assertContains(resp, 'badge rounded-pill text-bg-secondary">README')
2244
+ self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
2245
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1773
2246
 
1774
2247
 
1775
2248
  class PowerNavTests(TestCase):
@@ -1804,9 +2277,9 @@ class PowerNavTests(TestCase):
1804
2277
  power_module = module
1805
2278
  break
1806
2279
  self.assertIsNotNone(power_module)
1807
- self.assertEqual(power_module.menu_label.upper(), "CALCULATE")
2280
+ self.assertEqual(power_module.menu_label.upper(), "CALCULATORS")
1808
2281
  landing_labels = {landing.label for landing in power_module.enabled_landings}
1809
- self.assertIn("AWG Calculator", landing_labels)
2282
+ self.assertIn("AWG Cable Calculator", landing_labels)
1810
2283
 
1811
2284
  def test_manual_pill_label(self):
1812
2285
  resp = self.client.get(reverse("pages:index"))
@@ -1830,9 +2303,26 @@ class PowerNavTests(TestCase):
1830
2303
  break
1831
2304
  self.assertIsNotNone(power_module)
1832
2305
  landing_labels = {landing.label for landing in power_module.enabled_landings}
1833
- self.assertIn("AWG Calculator", landing_labels)
2306
+ self.assertIn("AWG Cable Calculator", landing_labels)
1834
2307
  self.assertIn("Energy Tariff Calculator", landing_labels)
1835
2308
 
2309
+ def test_locked_landing_shows_lock_icon(self):
2310
+ resp = self.client.get(reverse("pages:index"))
2311
+ html = resp.content.decode()
2312
+ energy_index = html.find("Energy Tariff Calculator")
2313
+ self.assertGreaterEqual(energy_index, 0)
2314
+ icon_index = html.find("dropdown-lock-icon", energy_index, energy_index + 300)
2315
+ self.assertGreaterEqual(icon_index, 0)
2316
+
2317
+ def test_lock_icon_disappears_after_login(self):
2318
+ self.client.force_login(self.user)
2319
+ resp = self.client.get(reverse("pages:index"))
2320
+ html = resp.content.decode()
2321
+ energy_index = html.find("Energy Tariff Calculator")
2322
+ self.assertGreaterEqual(energy_index, 0)
2323
+ icon_index = html.find("dropdown-lock-icon", energy_index, energy_index + 300)
2324
+ self.assertEqual(icon_index, -1)
2325
+
1836
2326
 
1837
2327
  class StaffNavVisibilityTests(TestCase):
1838
2328
  def setUp(self):
@@ -1854,12 +2344,12 @@ class StaffNavVisibilityTests(TestCase):
1854
2344
  def test_nonstaff_pill_hidden(self):
1855
2345
  self.client.login(username="user", password="pw")
1856
2346
  resp = self.client.get(reverse("pages:index"))
1857
- self.assertContains(resp, 'href="/ocpp/"')
2347
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
1858
2348
 
1859
2349
  def test_staff_sees_pill(self):
1860
2350
  self.client.login(username="staff", password="pw")
1861
2351
  resp = self.client.get(reverse("pages:index"))
1862
- self.assertContains(resp, 'href="/ocpp/"')
2352
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
1863
2353
 
1864
2354
 
1865
2355
  class ModuleAdminReloadActionTests(TestCase):
@@ -1872,7 +2362,7 @@ class ModuleAdminReloadActionTests(TestCase):
1872
2362
  password="pw",
1873
2363
  )
1874
2364
  self.client.force_login(self.superuser)
1875
- self.role, _ = NodeRole.objects.get_or_create(name="Constellation")
2365
+ self.role, _ = NodeRole.objects.get_or_create(name="Watchtower")
1876
2366
  Application.objects.get_or_create(name="ocpp")
1877
2367
  Application.objects.get_or_create(name="awg")
1878
2368
  Site.objects.update_or_create(
@@ -1910,7 +2400,11 @@ class ModuleAdminReloadActionTests(TestCase):
1910
2400
  )
1911
2401
  self.assertSetEqual(
1912
2402
  charger_landings,
1913
- {"/ocpp/", "/ocpp/simulator/", "/ocpp/rfid/"},
2403
+ {
2404
+ "/ocpp/cpms/dashboard/",
2405
+ "/ocpp/evcs/simulator/",
2406
+ "/ocpp/rfid/validator/",
2407
+ },
1914
2408
  )
1915
2409
 
1916
2410
  calculator_landings = set(
@@ -2048,6 +2542,47 @@ class UserManualAdminFormTests(TestCase):
2048
2542
  self.assertEqual(form.cleaned_data["content_pdf"], self.manual.content_pdf)
2049
2543
 
2050
2544
 
2545
+ class UserManualModelTests(TestCase):
2546
+ def _build_manual(self, **overrides):
2547
+ defaults = {
2548
+ "slug": "manual-model-test",
2549
+ "title": "Manual Model",
2550
+ "description": "Manual description",
2551
+ "languages": "en",
2552
+ "content_html": "<p>Manual</p>",
2553
+ "content_pdf": base64.b64encode(b"initial").decode("ascii"),
2554
+ }
2555
+ defaults.update(overrides)
2556
+ return UserManual(**defaults)
2557
+
2558
+ def test_save_encodes_uploaded_file(self):
2559
+ upload = SimpleUploadedFile("manual.pdf", b"PDF data")
2560
+ manual = self._build_manual(slug="manual-upload", content_pdf=upload)
2561
+ manual.save()
2562
+ manual.refresh_from_db()
2563
+ self.assertEqual(
2564
+ manual.content_pdf,
2565
+ base64.b64encode(b"PDF data").decode("ascii"),
2566
+ )
2567
+
2568
+ def test_save_encodes_raw_bytes(self):
2569
+ manual = self._build_manual(slug="manual-bytes", content_pdf=b"PDF raw")
2570
+ manual.save()
2571
+ manual.refresh_from_db()
2572
+ self.assertEqual(
2573
+ manual.content_pdf,
2574
+ base64.b64encode(b"PDF raw").decode("ascii"),
2575
+ )
2576
+
2577
+ def test_save_strips_data_uri_prefix(self):
2578
+ encoded = base64.b64encode(b"PDF data").decode("ascii")
2579
+ data_uri = f"data:application/pdf;base64,{encoded}"
2580
+ manual = self._build_manual(slug="manual-data-uri", content_pdf=data_uri)
2581
+ manual.save()
2582
+ manual.refresh_from_db()
2583
+ self.assertEqual(manual.content_pdf, encoded)
2584
+
2585
+
2051
2586
  class LandingCreationTests(TestCase):
2052
2587
  def setUp(self):
2053
2588
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
@@ -2069,12 +2604,12 @@ class LandingCreationTests(TestCase):
2069
2604
 
2070
2605
 
2071
2606
  class LandingFixtureTests(TestCase):
2072
- def test_constellation_fixture_loads_without_duplicates(self):
2607
+ def test_watchtower_fixture_loads_without_duplicates(self):
2073
2608
  from glob import glob
2074
2609
 
2075
- NodeRole.objects.get_or_create(name="Constellation")
2610
+ NodeRole.objects.get_or_create(name="Watchtower")
2076
2611
  fixtures = glob(
2077
- str(Path(settings.BASE_DIR, "pages", "fixtures", "constellation__*.json"))
2612
+ str(Path(settings.BASE_DIR, "pages", "fixtures", "watchtower__*.json"))
2078
2613
  )
2079
2614
  fixtures = sorted(
2080
2615
  fixtures,
@@ -2084,9 +2619,11 @@ class LandingFixtureTests(TestCase):
2084
2619
  )
2085
2620
  call_command("loaddata", *fixtures)
2086
2621
  call_command("loaddata", *fixtures)
2087
- module = Module.objects.get(path="/ocpp/", node_role__name="Constellation")
2622
+ module = Module.objects.get(path="/ocpp/", node_role__name="Watchtower")
2088
2623
  module.create_landings()
2089
- self.assertEqual(module.landings.filter(path="/ocpp/rfid/").count(), 1)
2624
+ self.assertEqual(
2625
+ module.landings.filter(path="/ocpp/rfid/validator/").count(), 1
2626
+ )
2090
2627
 
2091
2628
 
2092
2629
  class AllowedHostSubnetTests(TestCase):
@@ -2239,9 +2776,9 @@ class FaviconTests(TestCase):
2239
2776
  )
2240
2777
  self.assertContains(resp, b64)
2241
2778
 
2242
- def test_constellation_nodes_use_goldenrod_favicon(self):
2779
+ def test_watchtower_nodes_use_goldenrod_favicon(self):
2243
2780
  with override_settings(MEDIA_ROOT=self.tmpdir):
2244
- role, _ = NodeRole.objects.get_or_create(name="Constellation")
2781
+ role, _ = NodeRole.objects.get_or_create(name="Watchtower")
2245
2782
  Node.objects.update_or_create(
2246
2783
  mac_address=Node.get_current_mac(),
2247
2784
  defaults={
@@ -2256,7 +2793,7 @@ class FaviconTests(TestCase):
2256
2793
  resp = self.client.get(reverse("pages:index"))
2257
2794
  b64 = (
2258
2795
  Path(settings.BASE_DIR)
2259
- .joinpath("pages", "fixtures", "data", "favicon_constellation.txt")
2796
+ .joinpath("pages", "fixtures", "data", "favicon_watchtower.txt")
2260
2797
  .read_text()
2261
2798
  .strip()
2262
2799
  )
@@ -2323,6 +2860,20 @@ class FavoriteTests(TestCase):
2323
2860
  self.assertEqual(fav.custom_label, "Apps")
2324
2861
  self.assertTrue(fav.user_data)
2325
2862
 
2863
+ def test_add_favorite_defaults_user_data_checked(self):
2864
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2865
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2866
+ resp = self.client.get(url)
2867
+ self.assertContains(resp, 'name="user_data" checked')
2868
+
2869
+ def test_add_favorite_with_priority(self):
2870
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2871
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2872
+ resp = self.client.post(url, {"priority": "7"})
2873
+ self.assertRedirects(resp, reverse("admin:index"))
2874
+ fav = Favorite.objects.get(user=self.user, content_type=ct)
2875
+ self.assertEqual(fav.priority, 7)
2876
+
2326
2877
  def test_cancel_link_uses_next(self):
2327
2878
  ct = ContentType.objects.get_by_natural_key("pages", "application")
2328
2879
  next_url = reverse("admin:pages_application_changelist")
@@ -2332,14 +2883,31 @@ class FavoriteTests(TestCase):
2332
2883
  resp = self.client.get(url)
2333
2884
  self.assertContains(resp, f'href="{next_url}"')
2334
2885
 
2335
- def test_existing_favorite_redirects_to_list(self):
2886
+ def test_existing_favorite_shows_update_form(self):
2336
2887
  ct = ContentType.objects.get_by_natural_key("pages", "application")
2337
- Favorite.objects.create(user=self.user, content_type=ct)
2888
+ favorite = Favorite.objects.create(
2889
+ user=self.user, content_type=ct, custom_label="Apps", user_data=True
2890
+ )
2338
2891
  url = reverse("admin:favorite_toggle", args=[ct.id])
2339
2892
  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)
2893
+ self.assertContains(resp, "Update Favorite")
2894
+ self.assertContains(resp, "value=\"Apps\"")
2895
+ self.assertContains(resp, "checked")
2896
+ self.assertContains(resp, "name=\"remove\"")
2897
+
2898
+ resp = self.client.post(url, {"custom_label": "Apps Updated"})
2899
+ self.assertRedirects(resp, reverse("admin:index"))
2900
+ favorite.refresh_from_db()
2901
+ self.assertEqual(favorite.custom_label, "Apps Updated")
2902
+ self.assertFalse(favorite.user_data)
2903
+
2904
+ def test_remove_existing_favorite_from_toggle(self):
2905
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2906
+ Favorite.objects.create(user=self.user, content_type=ct)
2907
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2908
+ resp = self.client.post(url, {"remove": "1"})
2909
+ self.assertRedirects(resp, reverse("admin:index"))
2910
+ self.assertFalse(Favorite.objects.filter(user=self.user, content_type=ct).exists())
2343
2911
 
2344
2912
  def test_update_user_data_from_list(self):
2345
2913
  ct = ContentType.objects.get_by_natural_key("pages", "application")
@@ -2350,6 +2918,15 @@ class FavoriteTests(TestCase):
2350
2918
  fav.refresh_from_db()
2351
2919
  self.assertTrue(fav.user_data)
2352
2920
 
2921
+ def test_update_priority_from_list(self):
2922
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2923
+ fav = Favorite.objects.create(user=self.user, content_type=ct, priority=3)
2924
+ url = reverse("admin:favorite_list")
2925
+ resp = self.client.post(url, {f"priority_{fav.pk}": "12"})
2926
+ self.assertRedirects(resp, url)
2927
+ fav.refresh_from_db()
2928
+ self.assertEqual(fav.priority, 12)
2929
+
2353
2930
  def test_dashboard_includes_favorites_and_user_data(self):
2354
2931
  fav_ct = ContentType.objects.get_by_natural_key("pages", "application")
2355
2932
  Favorite.objects.create(
@@ -2360,6 +2937,12 @@ class FavoriteTests(TestCase):
2360
2937
  self.assertContains(resp, reverse("admin:pages_application_changelist"))
2361
2938
  self.assertContains(resp, reverse("admin:nodes_noderole_changelist"))
2362
2939
 
2940
+ def test_dashboard_shows_empty_todo_state(self):
2941
+ Todo.objects.all().delete()
2942
+ resp = self.client.get(reverse("admin:index"))
2943
+ self.assertContains(resp, "Release manager tasks")
2944
+ self.assertContains(resp, "No pending TODOs")
2945
+
2363
2946
  def test_dashboard_merges_duplicate_future_actions(self):
2364
2947
  ct = ContentType.objects.get_for_model(NodeRole)
2365
2948
  Favorite.objects.create(user=self.user, content_type=ct)
@@ -2406,6 +2989,48 @@ class FavoriteTests(TestCase):
2406
2989
  self.assertContains(resp, f'title="{badge_label}"')
2407
2990
  self.assertContains(resp, f'aria-label="{badge_label}"')
2408
2991
 
2992
+ def test_dashboard_shows_charge_point_availability_badge(self):
2993
+ Charger.objects.create(
2994
+ charger_id="CP-001", connector_id=1, last_status="Available"
2995
+ )
2996
+ Charger.objects.create(charger_id="CP-002", last_status="Available")
2997
+ Charger.objects.create(
2998
+ charger_id="CP-003", connector_id=1, last_status="Unavailable"
2999
+ )
3000
+
3001
+ resp = self.client.get(reverse("admin:index"))
3002
+
3003
+ expected = "1 / 2"
3004
+ badge_label = gettext(
3005
+ "%(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."
3006
+ ) % {"available": 1, "total": 2, "missing": 1}
3007
+
3008
+ self.assertContains(resp, expected)
3009
+ self.assertContains(resp, 'class="charger-availability-badge"')
3010
+ self.assertContains(resp, f'title="{badge_label}"')
3011
+ self.assertContains(resp, f'aria-label="{badge_label}"')
3012
+
3013
+ def test_dashboard_charge_point_badge_ignores_aggregator(self):
3014
+ Charger.objects.create(charger_id="CP-AGG", last_status="Available")
3015
+ Charger.objects.create(
3016
+ charger_id="CP-AGG", connector_id=1, last_status="Available"
3017
+ )
3018
+ Charger.objects.create(
3019
+ charger_id="CP-AGG", connector_id=2, last_status="Available"
3020
+ )
3021
+
3022
+ resp = self.client.get(reverse("admin:index"))
3023
+
3024
+ expected = "2 / 2"
3025
+ badge_label = gettext(
3026
+ "%(available)s chargers reporting Available status with a CP number."
3027
+ ) % {"available": 2}
3028
+
3029
+ self.assertContains(resp, expected)
3030
+ self.assertContains(resp, 'class="charger-availability-badge"')
3031
+ self.assertContains(resp, f'title="{badge_label}"')
3032
+ self.assertContains(resp, f'aria-label="{badge_label}"')
3033
+
2409
3034
  def test_nav_sidebar_hides_dashboard_badges(self):
2410
3035
  InviteLead.objects.create(email="open@example.com")
2411
3036
  RFID.objects.create(rfid="RFID0003", released=True, allowed=True)
@@ -2557,7 +3182,11 @@ class FavoriteTests(TestCase):
2557
3182
  todo = Todo.objects.create(request="Do thing")
2558
3183
  resp = self.client.get(reverse("admin:index"))
2559
3184
  done_url = reverse("todo-done", args=[todo.pk])
2560
- self.assertContains(resp, todo.request)
3185
+ tooltip = escape(todo.request)
3186
+ self.assertContains(resp, f'title="{tooltip}"')
3187
+ self.assertContains(resp, f'aria-label="{tooltip}"')
3188
+ task_label = gettext("Task %(counter)s") % {"counter": 1}
3189
+ self.assertContains(resp, task_label)
2561
3190
  self.assertContains(resp, f'action="{done_url}"')
2562
3191
  self.assertContains(resp, "DONE")
2563
3192
 
@@ -2568,6 +3197,15 @@ class FavoriteTests(TestCase):
2568
3197
  resp, '<div class="todo-details">More info</div>', html=True
2569
3198
  )
2570
3199
 
3200
+ def test_dashboard_hides_completed_todos(self):
3201
+ todo = Todo.objects.create(request="Completed task")
3202
+ Todo.objects.filter(pk=todo.pk).update(done_on=timezone.now())
3203
+
3204
+ resp = self.client.get(reverse("admin:index"))
3205
+
3206
+ self.assertNotContains(resp, todo.request)
3207
+ self.assertNotContains(resp, "Completed")
3208
+
2571
3209
  def test_dashboard_shows_todos_when_node_unknown(self):
2572
3210
  Todo.objects.create(request="Check fallback")
2573
3211
  from nodes.models import Node
@@ -2576,7 +3214,8 @@ class FavoriteTests(TestCase):
2576
3214
 
2577
3215
  resp = self.client.get(reverse("admin:index"))
2578
3216
  self.assertContains(resp, "Release manager tasks")
2579
- self.assertContains(resp, "Check fallback")
3217
+ tooltip = escape("Check fallback")
3218
+ self.assertContains(resp, f'title="{tooltip}"')
2580
3219
 
2581
3220
  def test_dashboard_shows_todos_without_release_manager_profile(self):
2582
3221
  Todo.objects.create(request="Unrestricted task")
@@ -2584,7 +3223,8 @@ class FavoriteTests(TestCase):
2584
3223
 
2585
3224
  resp = self.client.get(reverse("admin:index"))
2586
3225
  self.assertContains(resp, "Release manager tasks")
2587
- self.assertContains(resp, "Unrestricted task")
3226
+ tooltip = escape("Unrestricted task")
3227
+ self.assertContains(resp, f'title="{tooltip}"')
2588
3228
 
2589
3229
  def test_dashboard_excludes_todo_changelist_link(self):
2590
3230
  ct = ContentType.objects.get_for_model(Todo)
@@ -2608,7 +3248,8 @@ class FavoriteTests(TestCase):
2608
3248
  self.client.force_login(other_user)
2609
3249
  resp = self.client.get(reverse("admin:index"))
2610
3250
  self.assertContains(resp, "Release manager tasks")
2611
- self.assertContains(resp, todo.request)
3251
+ tooltip = escape(todo.request)
3252
+ self.assertContains(resp, f'title="{tooltip}"')
2612
3253
 
2613
3254
  def test_dashboard_shows_todos_for_non_terminal_node(self):
2614
3255
  todo = Todo.objects.create(request="Terminal Tasks")
@@ -2619,7 +3260,8 @@ class FavoriteTests(TestCase):
2619
3260
  self.node.save(update_fields=["role"])
2620
3261
  resp = self.client.get(reverse("admin:index"))
2621
3262
  self.assertContains(resp, "Release manager tasks")
2622
- self.assertContains(resp, todo.request)
3263
+ tooltip = escape(todo.request)
3264
+ self.assertContains(resp, f'title="{tooltip}"')
2623
3265
 
2624
3266
  def test_dashboard_shows_todos_for_delegate_release_manager(self):
2625
3267
  todo = Todo.objects.create(request="Delegate Task")
@@ -2641,7 +3283,8 @@ class FavoriteTests(TestCase):
2641
3283
  self.client.force_login(operator)
2642
3284
  resp = self.client.get(reverse("admin:index"))
2643
3285
  self.assertContains(resp, "Release manager tasks")
2644
- self.assertContains(resp, todo.request)
3286
+ tooltip = escape(todo.request)
3287
+ self.assertContains(resp, f'title="{tooltip}"')
2645
3288
 
2646
3289
 
2647
3290
  class AdminIndexQueryRegressionTests(TestCase):
@@ -2836,47 +3479,6 @@ class AdminModelGraphViewTests(TestCase):
2836
3479
  self.assertEqual(kwargs.get("format"), "pdf")
2837
3480
 
2838
3481
 
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
3482
 
2881
3483
  class UserStorySubmissionTests(TestCase):
2882
3484
  def setUp(self):
@@ -2885,6 +3487,14 @@ class UserStorySubmissionTests(TestCase):
2885
3487
  self.url = reverse("pages:user-story-submit")
2886
3488
  User = get_user_model()
2887
3489
  self.user = User.objects.create_user(username="feedbacker", password="pwd")
3490
+ self.capture_patcher = patch("pages.views.capture_screenshot", autospec=True)
3491
+ self.save_patcher = patch("pages.views.save_screenshot", autospec=True)
3492
+ self.mock_capture = self.capture_patcher.start()
3493
+ self.mock_save = self.save_patcher.start()
3494
+ self.mock_capture.return_value = Path("/tmp/fake.png")
3495
+ self.mock_save.return_value = None
3496
+ self.addCleanup(self.capture_patcher.stop)
3497
+ self.addCleanup(self.save_patcher.stop)
2888
3498
 
2889
3499
  def test_authenticated_submission_defaults_to_username(self):
2890
3500
  self.client.force_login(self.user)
@@ -2913,12 +3523,121 @@ class UserStorySubmissionTests(TestCase):
2913
3523
  self.assertEqual(story.referer, "https://example.test/wizard/step-1/")
2914
3524
  self.assertEqual(story.user_agent, "FeedbackBot/1.0")
2915
3525
  self.assertEqual(story.ip_address, "127.0.0.1")
3526
+ expected_language = (translation.get_language() or "").split("-")[0]
3527
+ self.assertTrue(story.language_code)
3528
+ self.assertTrue(
3529
+ story.language_code.startswith(expected_language),
3530
+ story.language_code,
3531
+ )
3532
+
3533
+ def test_submission_records_request_language(self):
3534
+ self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = "es"
3535
+ with translation.override("es"):
3536
+ response = self.client.post(
3537
+ self.url,
3538
+ {
3539
+ "rating": 4,
3540
+ "comments": "Buena experiencia",
3541
+ "path": "/es/soporte/",
3542
+ "take_screenshot": "1",
3543
+ },
3544
+ HTTP_ACCEPT_LANGUAGE="es",
3545
+ )
3546
+
3547
+ self.assertEqual(response.status_code, 200)
3548
+ story = UserStory.objects.get()
3549
+ self.assertEqual(story.language_code, "es")
2916
3550
 
2917
- def test_anonymous_submission_uses_provided_name(self):
3551
+ def test_submission_prefers_original_referer(self):
3552
+ self.client.get(
3553
+ reverse("pages:index"),
3554
+ HTTP_REFERER="https://ads.example/original",
3555
+ )
2918
3556
  response = self.client.post(
2919
3557
  self.url,
2920
3558
  {
2921
- "name": "Guest Reviewer",
3559
+ "rating": 3,
3560
+ "comments": "Works well",
3561
+ "path": "/wizard/step-2/",
3562
+ "name": "visitor@example.com",
3563
+ "take_screenshot": "0",
3564
+ },
3565
+ HTTP_REFERER="http://testserver/wizard/step-2/",
3566
+ HTTP_USER_AGENT="FeedbackBot/2.0",
3567
+ )
3568
+
3569
+ self.assertEqual(response.status_code, 200)
3570
+ story = UserStory.objects.get()
3571
+ self.assertEqual(story.referer, "https://ads.example/original")
3572
+
3573
+ def test_superuser_submission_creates_triage_todo(self):
3574
+ Todo.objects.all().delete()
3575
+ superuser = get_user_model().objects.create_superuser(
3576
+ username="overseer", email="overseer@example.com", password="pwd"
3577
+ )
3578
+ Node.objects.update_or_create(
3579
+ mac_address=Node.get_current_mac(),
3580
+ defaults={
3581
+ "hostname": "local-node",
3582
+ "address": "127.0.0.1",
3583
+ "port": 8000,
3584
+ "public_endpoint": "local-node",
3585
+ },
3586
+ )
3587
+ self.client.force_login(superuser)
3588
+ comments = "Review analytics dashboard flow"
3589
+ response = self.client.post(
3590
+ self.url,
3591
+ {
3592
+ "rating": 5,
3593
+ "comments": comments,
3594
+ "path": "/reports/analytics/",
3595
+ "take_screenshot": "0",
3596
+ },
3597
+ )
3598
+ self.assertEqual(response.status_code, 200)
3599
+ self.assertEqual(Todo.objects.count(), 1)
3600
+ todo = Todo.objects.get()
3601
+ self.assertEqual(todo.request, f"Triage {comments}")
3602
+ self.assertTrue(todo.is_user_data)
3603
+ self.assertEqual(todo.original_user, superuser)
3604
+ self.assertTrue(todo.original_user_is_authenticated)
3605
+ self.assertEqual(todo.origin_node, Node.get_local())
3606
+
3607
+ def test_screenshot_request_links_saved_sample(self):
3608
+ self.client.force_login(self.user)
3609
+ screenshot_file = Path("/tmp/fake.png")
3610
+ self.mock_capture.return_value = screenshot_file
3611
+ sample = ContentSample.objects.create(kind=ContentSample.IMAGE)
3612
+ self.mock_save.return_value = sample
3613
+
3614
+ response = self.client.post(
3615
+ self.url,
3616
+ {
3617
+ "rating": 5,
3618
+ "comments": "Loved the experience!",
3619
+ "path": "/wizard/step-1/",
3620
+ "take_screenshot": "1",
3621
+ },
3622
+ HTTP_REFERER="https://example.test/wizard/step-1/",
3623
+ )
3624
+
3625
+ self.assertEqual(response.status_code, 200)
3626
+ story = UserStory.objects.get()
3627
+ self.assertEqual(story.screenshot, sample)
3628
+ self.mock_capture.assert_called_once_with("https://example.test/wizard/step-1/")
3629
+ self.mock_save.assert_called_once_with(
3630
+ screenshot_file,
3631
+ method="USER_STORY",
3632
+ user=self.user,
3633
+ link_duplicates=True,
3634
+ )
3635
+
3636
+ def test_anonymous_submission_uses_provided_email(self):
3637
+ response = self.client.post(
3638
+ self.url,
3639
+ {
3640
+ "name": "guest@example.com",
2922
3641
  "rating": 3,
2923
3642
  "comments": "It was fine.",
2924
3643
  "path": "/status/",
@@ -2928,7 +3647,7 @@ class UserStorySubmissionTests(TestCase):
2928
3647
  self.assertEqual(response.status_code, 200)
2929
3648
  self.assertEqual(UserStory.objects.count(), 1)
2930
3649
  story = UserStory.objects.get()
2931
- self.assertEqual(story.name, "Guest Reviewer")
3650
+ self.assertEqual(story.name, "guest@example.com")
2932
3651
  self.assertIsNone(story.user)
2933
3652
  self.assertIsNone(story.owner)
2934
3653
  self.assertEqual(story.comments, "It was fine.")
@@ -2950,7 +3669,7 @@ class UserStorySubmissionTests(TestCase):
2950
3669
  self.assertFalse(UserStory.objects.exists())
2951
3670
  self.assertIn("rating", data.get("errors", {}))
2952
3671
 
2953
- def test_anonymous_submission_without_name_uses_fallback(self):
3672
+ def test_anonymous_submission_without_email_returns_errors(self):
2954
3673
  response = self.client.post(
2955
3674
  self.url,
2956
3675
  {
@@ -2960,18 +3679,32 @@ class UserStorySubmissionTests(TestCase):
2960
3679
  "take_screenshot": "1",
2961
3680
  },
2962
3681
  )
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)
3682
+ self.assertEqual(response.status_code, 400)
3683
+ self.assertFalse(UserStory.objects.exists())
3684
+ data = response.json()
3685
+ self.assertIn("name", data.get("errors", {}))
3686
+
3687
+ def test_anonymous_submission_with_invalid_email_returns_errors(self):
3688
+ response = self.client.post(
3689
+ self.url,
3690
+ {
3691
+ "name": "Guest Reviewer",
3692
+ "rating": 3,
3693
+ "comments": "Needs improvement.",
3694
+ "path": "/feedback/",
3695
+ "take_screenshot": "1",
3696
+ },
3697
+ )
3698
+ self.assertEqual(response.status_code, 400)
3699
+ self.assertFalse(UserStory.objects.exists())
3700
+ data = response.json()
3701
+ self.assertIn("name", data.get("errors", {}))
2970
3702
 
2971
3703
  def test_submission_without_screenshot_request(self):
2972
3704
  response = self.client.post(
2973
3705
  self.url,
2974
3706
  {
3707
+ "name": "guest@example.com",
2975
3708
  "rating": 4,
2976
3709
  "comments": "Skip the screenshot, please.",
2977
3710
  "path": "/feedback/",
@@ -2981,9 +3714,14 @@ class UserStorySubmissionTests(TestCase):
2981
3714
  story = UserStory.objects.get()
2982
3715
  self.assertFalse(story.take_screenshot)
2983
3716
  self.assertIsNone(story.owner)
3717
+ self.assertIsNone(story.screenshot)
3718
+ self.assertEqual(story.status, UserStory.Status.OPEN)
3719
+ self.mock_capture.assert_not_called()
3720
+ self.mock_save.assert_not_called()
2984
3721
 
2985
3722
  def test_rate_limit_blocks_repeated_submissions(self):
2986
3723
  payload = {
3724
+ "name": "guest@example.com",
2987
3725
  "rating": 4,
2988
3726
  "comments": "Pretty good",
2989
3727
  "path": "/feedback/",
@@ -3084,6 +3822,8 @@ class UserStoryAdminActionTests(TestCase):
3084
3822
  comments="Helpful notes",
3085
3823
  take_screenshot=True,
3086
3824
  )
3825
+ self.story.language_code = "es"
3826
+ self.story.save(update_fields=["language_code"])
3087
3827
  self.admin = UserStoryAdmin(UserStory, admin.site)
3088
3828
 
3089
3829
  def _build_request(self):
@@ -3117,6 +3857,8 @@ class UserStoryAdminActionTests(TestCase):
3117
3857
  args, kwargs = mock_create_issue.call_args
3118
3858
  self.assertIn("Feedback for", args[0])
3119
3859
  self.assertIn("**Rating:**", args[1])
3860
+ self.assertIn("**Language:**", args[1])
3861
+ self.assertIn("(es)", args[1])
3120
3862
  self.assertEqual(kwargs.get("labels"), ["feedback"])
3121
3863
  self.assertEqual(
3122
3864
  kwargs.get("fingerprint"), f"user-story:{self.story.pk}"
@@ -3134,6 +3876,40 @@ class UserStoryAdminActionTests(TestCase):
3134
3876
 
3135
3877
  mock_create_issue.assert_not_called()
3136
3878
 
3879
+ def test_create_github_issues_action_links_to_credentials_when_missing(self):
3880
+ request = self._build_request()
3881
+ queryset = UserStory.objects.filter(pk=self.story.pk)
3882
+
3883
+ mock_url = "/admin/core/releasemanager/"
3884
+ with (
3885
+ patch(
3886
+ "pages.admin.reverse", return_value=mock_url
3887
+ ) as mock_reverse,
3888
+ patch.object(
3889
+ UserStory,
3890
+ "create_github_issue",
3891
+ side_effect=RuntimeError("GitHub token is not configured"),
3892
+ ),
3893
+ ):
3894
+ self.admin.create_github_issues(request, queryset)
3895
+
3896
+ messages_list = list(request._messages)
3897
+ self.assertTrue(messages_list)
3898
+
3899
+ opts = ReleaseManager._meta
3900
+ mock_reverse.assert_called_once_with(
3901
+ f"{self.admin.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
3902
+ )
3903
+ self.assertTrue(
3904
+ any(mock_url in message.message for message in messages_list),
3905
+ )
3906
+ self.assertTrue(
3907
+ any("Configure GitHub credentials" in message.message for message in messages_list),
3908
+ )
3909
+ self.assertTrue(
3910
+ any(message.level == messages.ERROR for message in messages_list),
3911
+ )
3912
+
3137
3913
 
3138
3914
  class ClientReportLiveUpdateTests(TestCase):
3139
3915
  def setUp(self):