arthexis 0.1.21__py3-none-any.whl → 0.1.23__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.

pages/tests.py CHANGED
@@ -62,6 +62,7 @@ from core.models import (
62
62
  RFID,
63
63
  ReleaseManager,
64
64
  SecurityGroup,
65
+ GoogleCalendarProfile,
65
66
  Todo,
66
67
  TOTPDeviceSettings,
67
68
  )
@@ -71,8 +72,11 @@ import base64
71
72
  import json
72
73
  import tempfile
73
74
  import shutil
75
+ from datetime import timedelta
74
76
  from io import StringIO
75
77
  from django.conf import settings
78
+ from django.utils import timezone
79
+ from django.utils.html import escape
76
80
  from pathlib import Path
77
81
  from unittest.mock import MagicMock, Mock, call, patch
78
82
  from types import SimpleNamespace
@@ -90,6 +94,7 @@ from datetime import (
90
94
  from django.core import mail
91
95
  from django.utils import timezone
92
96
  from django.utils.text import slugify
97
+ from django.utils import translation
93
98
  from django.utils.translation import gettext
94
99
  from django_otp import DEVICE_ID_SESSION_KEY
95
100
  from django_otp.oath import TOTP
@@ -104,6 +109,7 @@ from nodes.models import (
104
109
  NodeRole,
105
110
  NodeFeature,
106
111
  NodeFeatureAssignment,
112
+ NetMessage,
107
113
  )
108
114
  from django.contrib.auth.models import AnonymousUser
109
115
 
@@ -195,6 +201,41 @@ class LoginViewTests(TestCase):
195
201
  )
196
202
  self.assertRedirects(resp, "/nodes/list/")
197
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
+
198
239
  def test_staff_redirects_next_when_specified(self):
199
240
  resp = self.client.post(
200
241
  reverse("pages:login") + "?next=/nodes/list/",
@@ -681,18 +722,36 @@ class AdminDashboardAppListTests(TestCase):
681
722
 
682
723
  def test_horologia_hidden_without_celery_feature(self):
683
724
  resp = self.client.get(reverse("admin:index"))
684
- self.assertNotContains(resp, "5. Horologia MODELS")
725
+ self.assertNotContains(resp, "5. Horologia</a>")
685
726
 
686
727
  def test_horologia_visible_with_celery_feature(self):
687
728
  feature = NodeFeature.objects.create(slug="celery-queue", display="Celery Queue")
688
729
  NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
689
730
  resp = self.client.get(reverse("admin:index"))
690
- self.assertContains(resp, "5. Horologia MODELS")
731
+ self.assertContains(resp, "5. Horologia</a>")
691
732
 
692
733
  def test_horologia_visible_with_celery_lock(self):
693
734
  self.celery_lock.write_text("")
694
735
  resp = self.client.get(reverse("admin:index"))
695
- self.assertContains(resp, "5. Horologia MODELS")
736
+ self.assertContains(resp, "5. Horologia</a>")
737
+
738
+ def test_dashboard_shows_last_net_message(self):
739
+ NetMessage.objects.all().delete()
740
+ NetMessage.objects.create(subject="Older", body="First body")
741
+ NetMessage.objects.create(subject="Latest", body="Signal ready")
742
+
743
+ resp = self.client.get(reverse("admin:index"))
744
+
745
+ self.assertContains(resp, gettext("Net message"))
746
+ self.assertContains(resp, "Latest — Signal ready")
747
+ self.assertNotContains(resp, gettext("No net messages available"))
748
+
749
+ def test_dashboard_shows_placeholder_without_net_message(self):
750
+ NetMessage.objects.all().delete()
751
+
752
+ resp = self.client.get(reverse("admin:index"))
753
+
754
+ self.assertContains(resp, gettext("No net messages available"))
696
755
 
697
756
  class AdminSidebarTests(TestCase):
698
757
  def setUp(self):
@@ -715,6 +774,66 @@ class AdminSidebarTests(TestCase):
715
774
  self.assertContains(resp, 'id="admin-collapsible-apps"')
716
775
 
717
776
 
777
+ class AdminGoogleCalendarSidebarTests(TestCase):
778
+ def setUp(self):
779
+ self.client = Client()
780
+ User = get_user_model()
781
+ self.admin = User.objects.create_superuser(
782
+ username="calendar_admin", password="pwd", email="admin@example.com"
783
+ )
784
+ self.client.force_login(self.admin)
785
+ Site.objects.update_or_create(
786
+ id=1, defaults={"name": "test", "domain": "testserver"}
787
+ )
788
+ Node.objects.create(hostname="testserver", address="127.0.0.1")
789
+
790
+ def test_calendar_module_hidden_without_profile(self):
791
+ resp = self.client.get(reverse("admin:index"))
792
+ self.assertNotContains(resp, 'id="google-calendar-module"', html=False)
793
+
794
+ @patch("core.models.GoogleCalendarProfile.fetch_events")
795
+ def test_calendar_module_shows_events_for_user(self, fetch_events):
796
+ fetch_events.return_value = [
797
+ {
798
+ "summary": "Standup",
799
+ "start": timezone.now(),
800
+ "end": None,
801
+ "all_day": False,
802
+ "html_link": "https://calendar.google.com/event",
803
+ "location": "HQ",
804
+ }
805
+ ]
806
+ GoogleCalendarProfile.objects.create(
807
+ user=self.admin,
808
+ calendar_id="example@group.calendar.google.com",
809
+ api_key="secret",
810
+ display_name="Team Calendar",
811
+ )
812
+
813
+ resp = self.client.get(reverse("admin:index"))
814
+
815
+ self.assertContains(resp, 'id="google-calendar-module"', html=False)
816
+ self.assertContains(resp, "Standup")
817
+ self.assertContains(resp, "Open full calendar")
818
+ fetch_events.assert_called_once()
819
+
820
+ @patch("core.models.GoogleCalendarProfile.fetch_events")
821
+ def test_calendar_module_uses_group_profile(self, fetch_events):
822
+ fetch_events.return_value = []
823
+ group = SecurityGroup.objects.create(name="Calendar Group")
824
+ self.admin.groups.add(group)
825
+ GoogleCalendarProfile.objects.create(
826
+ group=group,
827
+ calendar_id="group@calendar.google.com",
828
+ api_key="secret",
829
+ )
830
+
831
+ resp = self.client.get(reverse("admin:index"))
832
+
833
+ self.assertContains(resp, 'id="google-calendar-module"', html=False)
834
+ fetch_events.assert_called_once()
835
+
836
+
718
837
  class ViewHistoryLoggingTests(TestCase):
719
838
  def setUp(self):
720
839
  self.client = Client()
@@ -816,6 +935,35 @@ class ViewHistoryLoggingTests(TestCase):
816
935
  self.assertEqual(lead.path, "/")
817
936
  self.assertEqual(lead.referer, "https://example.com/ref")
818
937
 
938
+ def test_pages_config_purges_old_view_history(self):
939
+ ViewHistory.objects.all().delete()
940
+
941
+ old_entry = ViewHistory.objects.create(
942
+ path="/old/",
943
+ method="GET",
944
+ status_code=200,
945
+ status_text="OK",
946
+ )
947
+ new_entry = ViewHistory.objects.create(
948
+ path="/recent/",
949
+ method="GET",
950
+ status_code=200,
951
+ status_text="OK",
952
+ )
953
+
954
+ ViewHistory.objects.filter(pk=old_entry.pk).update(
955
+ visited_at=timezone.now() - timedelta(days=20)
956
+ )
957
+ ViewHistory.objects.filter(pk=new_entry.pk).update(
958
+ visited_at=timezone.now() - timedelta(days=10)
959
+ )
960
+
961
+ config = django_apps.get_app_config("pages")
962
+ config._purge_view_history()
963
+
964
+ self.assertFalse(ViewHistory.objects.filter(pk=old_entry.pk).exists())
965
+ self.assertTrue(ViewHistory.objects.filter(pk=new_entry.pk).exists())
966
+
819
967
  def test_landing_visit_does_not_record_lead_without_celery(self):
820
968
  role = NodeRole.objects.create(name="no-celery-role")
821
969
  application = Application.objects.create(
@@ -913,6 +1061,44 @@ class ViewHistoryAdminTests(TestCase):
913
1061
  self.assertEqual(totals.get("/"), 2)
914
1062
  self.assertEqual(totals.get("/about/"), 1)
915
1063
 
1064
+ def test_graph_data_endpoint_respects_days_parameter(self):
1065
+ ViewHistory.all_objects.all().delete()
1066
+ reference_date = date(2025, 5, 1)
1067
+ tz = timezone.get_current_timezone()
1068
+ path = "/range/"
1069
+
1070
+ for offset in range(10):
1071
+ entry = ViewHistory.objects.create(
1072
+ path=path,
1073
+ method="GET",
1074
+ status_code=200,
1075
+ status_text="OK",
1076
+ error_message="",
1077
+ view_name="pages:index",
1078
+ )
1079
+ visited_date = reference_date - timedelta(days=offset)
1080
+ visited_at = timezone.make_aware(
1081
+ datetime.combine(visited_date, datetime_time(12, 0)), tz
1082
+ )
1083
+ entry.visited_at = visited_at
1084
+ entry.save(update_fields=["visited_at"])
1085
+
1086
+ url = reverse("admin:pages_viewhistory_traffic_data")
1087
+ with patch("pages.admin.timezone.localdate", return_value=reference_date):
1088
+ resp = self.client.get(url, {"days": 7})
1089
+
1090
+ self.assertEqual(resp.status_code, 200)
1091
+ data = resp.json()
1092
+
1093
+ self.assertEqual(len(data.get("labels", [])), 7)
1094
+ self.assertEqual(data.get("meta", {}).get("start"), (reference_date - timedelta(days=6)).isoformat())
1095
+ self.assertEqual(data.get("meta", {}).get("end"), reference_date.isoformat())
1096
+
1097
+ totals = {
1098
+ dataset["label"]: sum(dataset["data"]) for dataset in data.get("datasets", [])
1099
+ }
1100
+ self.assertEqual(totals.get(path), 7)
1101
+
916
1102
  def test_graph_data_includes_late_evening_visits(self):
917
1103
  target_date = date(2025, 9, 27)
918
1104
  entry = ViewHistory.objects.create(
@@ -1916,7 +2102,7 @@ class ControlNavTests(TestCase):
1916
2102
 
1917
2103
  def test_readme_pill_visible(self):
1918
2104
  resp = self.client.get(reverse("pages:readme"))
1919
- self.assertContains(resp, 'href="/read/"')
2105
+ self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
1920
2106
  self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1921
2107
 
1922
2108
  def test_cookbook_pill_has_no_dropdown(self):
@@ -1932,7 +2118,7 @@ class ControlNavTests(TestCase):
1932
2118
 
1933
2119
  self.assertContains(
1934
2120
  resp,
1935
- '<a class="nav-link" href="/read/"><span class="badge rounded-pill text-bg-secondary">COOKBOOKS</span></a>',
2121
+ '<a class="nav-link" href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"><span class="badge rounded-pill text-bg-secondary">COOKBOOKS</span></a>',
1936
2122
  html=True,
1937
2123
  )
1938
2124
  self.assertNotContains(resp, 'dropdown-item" href="/man/"')
@@ -2038,7 +2224,7 @@ class SatelliteNavTests(TestCase):
2038
2224
 
2039
2225
  def test_readme_pill_visible(self):
2040
2226
  resp = self.client.get(reverse("pages:readme"))
2041
- self.assertContains(resp, 'href="/read/"')
2227
+ self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
2042
2228
  self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
2043
2229
 
2044
2230
 
@@ -2657,6 +2843,20 @@ class FavoriteTests(TestCase):
2657
2843
  self.assertEqual(fav.custom_label, "Apps")
2658
2844
  self.assertTrue(fav.user_data)
2659
2845
 
2846
+ def test_add_favorite_defaults_user_data_checked(self):
2847
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2848
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2849
+ resp = self.client.get(url)
2850
+ self.assertContains(resp, 'name="user_data" checked')
2851
+
2852
+ def test_add_favorite_with_priority(self):
2853
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2854
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2855
+ resp = self.client.post(url, {"priority": "7"})
2856
+ self.assertRedirects(resp, reverse("admin:index"))
2857
+ fav = Favorite.objects.get(user=self.user, content_type=ct)
2858
+ self.assertEqual(fav.priority, 7)
2859
+
2660
2860
  def test_cancel_link_uses_next(self):
2661
2861
  ct = ContentType.objects.get_by_natural_key("pages", "application")
2662
2862
  next_url = reverse("admin:pages_application_changelist")
@@ -2666,14 +2866,31 @@ class FavoriteTests(TestCase):
2666
2866
  resp = self.client.get(url)
2667
2867
  self.assertContains(resp, f'href="{next_url}"')
2668
2868
 
2669
- def test_existing_favorite_redirects_to_list(self):
2869
+ def test_existing_favorite_shows_update_form(self):
2670
2870
  ct = ContentType.objects.get_by_natural_key("pages", "application")
2671
- Favorite.objects.create(user=self.user, content_type=ct)
2871
+ favorite = Favorite.objects.create(
2872
+ user=self.user, content_type=ct, custom_label="Apps", user_data=True
2873
+ )
2672
2874
  url = reverse("admin:favorite_toggle", args=[ct.id])
2673
2875
  resp = self.client.get(url)
2674
- self.assertRedirects(resp, reverse("admin:favorite_list"))
2675
- resp = self.client.get(reverse("admin:favorite_list"))
2676
- self.assertContains(resp, ct.name)
2876
+ self.assertContains(resp, "Update Favorite")
2877
+ self.assertContains(resp, "value=\"Apps\"")
2878
+ self.assertContains(resp, "checked")
2879
+ self.assertContains(resp, "name=\"remove\"")
2880
+
2881
+ resp = self.client.post(url, {"custom_label": "Apps Updated"})
2882
+ self.assertRedirects(resp, reverse("admin:index"))
2883
+ favorite.refresh_from_db()
2884
+ self.assertEqual(favorite.custom_label, "Apps Updated")
2885
+ self.assertFalse(favorite.user_data)
2886
+
2887
+ def test_remove_existing_favorite_from_toggle(self):
2888
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2889
+ Favorite.objects.create(user=self.user, content_type=ct)
2890
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2891
+ resp = self.client.post(url, {"remove": "1"})
2892
+ self.assertRedirects(resp, reverse("admin:index"))
2893
+ self.assertFalse(Favorite.objects.filter(user=self.user, content_type=ct).exists())
2677
2894
 
2678
2895
  def test_update_user_data_from_list(self):
2679
2896
  ct = ContentType.objects.get_by_natural_key("pages", "application")
@@ -2684,6 +2901,15 @@ class FavoriteTests(TestCase):
2684
2901
  fav.refresh_from_db()
2685
2902
  self.assertTrue(fav.user_data)
2686
2903
 
2904
+ def test_update_priority_from_list(self):
2905
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2906
+ fav = Favorite.objects.create(user=self.user, content_type=ct, priority=3)
2907
+ url = reverse("admin:favorite_list")
2908
+ resp = self.client.post(url, {f"priority_{fav.pk}": "12"})
2909
+ self.assertRedirects(resp, url)
2910
+ fav.refresh_from_db()
2911
+ self.assertEqual(fav.priority, 12)
2912
+
2687
2913
  def test_dashboard_includes_favorites_and_user_data(self):
2688
2914
  fav_ct = ContentType.objects.get_by_natural_key("pages", "application")
2689
2915
  Favorite.objects.create(
@@ -2694,6 +2920,12 @@ class FavoriteTests(TestCase):
2694
2920
  self.assertContains(resp, reverse("admin:pages_application_changelist"))
2695
2921
  self.assertContains(resp, reverse("admin:nodes_noderole_changelist"))
2696
2922
 
2923
+ def test_dashboard_shows_empty_todo_state(self):
2924
+ Todo.objects.all().delete()
2925
+ resp = self.client.get(reverse("admin:index"))
2926
+ self.assertContains(resp, "Release manager tasks")
2927
+ self.assertContains(resp, "No pending TODOs")
2928
+
2697
2929
  def test_dashboard_merges_duplicate_future_actions(self):
2698
2930
  ct = ContentType.objects.get_for_model(NodeRole)
2699
2931
  Favorite.objects.create(user=self.user, content_type=ct)
@@ -2933,7 +3165,11 @@ class FavoriteTests(TestCase):
2933
3165
  todo = Todo.objects.create(request="Do thing")
2934
3166
  resp = self.client.get(reverse("admin:index"))
2935
3167
  done_url = reverse("todo-done", args=[todo.pk])
2936
- self.assertContains(resp, todo.request)
3168
+ tooltip = escape(todo.request)
3169
+ self.assertContains(resp, f'title="{tooltip}"')
3170
+ self.assertContains(resp, f'aria-label="{tooltip}"')
3171
+ task_label = gettext("Task %(counter)s") % {"counter": 1}
3172
+ self.assertContains(resp, task_label)
2937
3173
  self.assertContains(resp, f'action="{done_url}"')
2938
3174
  self.assertContains(resp, "DONE")
2939
3175
 
@@ -2961,7 +3197,8 @@ class FavoriteTests(TestCase):
2961
3197
 
2962
3198
  resp = self.client.get(reverse("admin:index"))
2963
3199
  self.assertContains(resp, "Release manager tasks")
2964
- self.assertContains(resp, "Check fallback")
3200
+ tooltip = escape("Check fallback")
3201
+ self.assertContains(resp, f'title="{tooltip}"')
2965
3202
 
2966
3203
  def test_dashboard_shows_todos_without_release_manager_profile(self):
2967
3204
  Todo.objects.create(request="Unrestricted task")
@@ -2969,7 +3206,8 @@ class FavoriteTests(TestCase):
2969
3206
 
2970
3207
  resp = self.client.get(reverse("admin:index"))
2971
3208
  self.assertContains(resp, "Release manager tasks")
2972
- self.assertContains(resp, "Unrestricted task")
3209
+ tooltip = escape("Unrestricted task")
3210
+ self.assertContains(resp, f'title="{tooltip}"')
2973
3211
 
2974
3212
  def test_dashboard_excludes_todo_changelist_link(self):
2975
3213
  ct = ContentType.objects.get_for_model(Todo)
@@ -2993,7 +3231,8 @@ class FavoriteTests(TestCase):
2993
3231
  self.client.force_login(other_user)
2994
3232
  resp = self.client.get(reverse("admin:index"))
2995
3233
  self.assertContains(resp, "Release manager tasks")
2996
- self.assertContains(resp, todo.request)
3234
+ tooltip = escape(todo.request)
3235
+ self.assertContains(resp, f'title="{tooltip}"')
2997
3236
 
2998
3237
  def test_dashboard_shows_todos_for_non_terminal_node(self):
2999
3238
  todo = Todo.objects.create(request="Terminal Tasks")
@@ -3004,7 +3243,8 @@ class FavoriteTests(TestCase):
3004
3243
  self.node.save(update_fields=["role"])
3005
3244
  resp = self.client.get(reverse("admin:index"))
3006
3245
  self.assertContains(resp, "Release manager tasks")
3007
- self.assertContains(resp, todo.request)
3246
+ tooltip = escape(todo.request)
3247
+ self.assertContains(resp, f'title="{tooltip}"')
3008
3248
 
3009
3249
  def test_dashboard_shows_todos_for_delegate_release_manager(self):
3010
3250
  todo = Todo.objects.create(request="Delegate Task")
@@ -3026,7 +3266,8 @@ class FavoriteTests(TestCase):
3026
3266
  self.client.force_login(operator)
3027
3267
  resp = self.client.get(reverse("admin:index"))
3028
3268
  self.assertContains(resp, "Release manager tasks")
3029
- self.assertContains(resp, todo.request)
3269
+ tooltip = escape(todo.request)
3270
+ self.assertContains(resp, f'title="{tooltip}"')
3030
3271
 
3031
3272
 
3032
3273
  class AdminIndexQueryRegressionTests(TestCase):
@@ -3229,6 +3470,14 @@ class UserStorySubmissionTests(TestCase):
3229
3470
  self.url = reverse("pages:user-story-submit")
3230
3471
  User = get_user_model()
3231
3472
  self.user = User.objects.create_user(username="feedbacker", password="pwd")
3473
+ self.capture_patcher = patch("pages.views.capture_screenshot", autospec=True)
3474
+ self.save_patcher = patch("pages.views.save_screenshot", autospec=True)
3475
+ self.mock_capture = self.capture_patcher.start()
3476
+ self.mock_save = self.save_patcher.start()
3477
+ self.mock_capture.return_value = Path("/tmp/fake.png")
3478
+ self.mock_save.return_value = None
3479
+ self.addCleanup(self.capture_patcher.stop)
3480
+ self.addCleanup(self.save_patcher.stop)
3232
3481
 
3233
3482
  def test_authenticated_submission_defaults_to_username(self):
3234
3483
  self.client.force_login(self.user)
@@ -3257,12 +3506,98 @@ class UserStorySubmissionTests(TestCase):
3257
3506
  self.assertEqual(story.referer, "https://example.test/wizard/step-1/")
3258
3507
  self.assertEqual(story.user_agent, "FeedbackBot/1.0")
3259
3508
  self.assertEqual(story.ip_address, "127.0.0.1")
3509
+ expected_language = (translation.get_language() or "").split("-")[0]
3510
+ self.assertTrue(story.language_code)
3511
+ self.assertTrue(
3512
+ story.language_code.startswith(expected_language),
3513
+ story.language_code,
3514
+ )
3515
+
3516
+ def test_submission_records_request_language(self):
3517
+ self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = "es"
3518
+ with translation.override("es"):
3519
+ response = self.client.post(
3520
+ self.url,
3521
+ {
3522
+ "rating": 4,
3523
+ "comments": "Buena experiencia",
3524
+ "path": "/es/soporte/",
3525
+ "take_screenshot": "1",
3526
+ },
3527
+ HTTP_ACCEPT_LANGUAGE="es",
3528
+ )
3529
+
3530
+ self.assertEqual(response.status_code, 200)
3531
+ story = UserStory.objects.get()
3532
+ self.assertEqual(story.language_code, "es")
3260
3533
 
3261
- def test_anonymous_submission_uses_provided_name(self):
3534
+ def test_superuser_submission_creates_triage_todo(self):
3535
+ Todo.objects.all().delete()
3536
+ superuser = get_user_model().objects.create_superuser(
3537
+ username="overseer", email="overseer@example.com", password="pwd"
3538
+ )
3539
+ Node.objects.update_or_create(
3540
+ mac_address=Node.get_current_mac(),
3541
+ defaults={
3542
+ "hostname": "local-node",
3543
+ "address": "127.0.0.1",
3544
+ "port": 8000,
3545
+ "public_endpoint": "local-node",
3546
+ },
3547
+ )
3548
+ self.client.force_login(superuser)
3549
+ comments = "Review analytics dashboard flow"
3262
3550
  response = self.client.post(
3263
3551
  self.url,
3264
3552
  {
3265
- "name": "Guest Reviewer",
3553
+ "rating": 5,
3554
+ "comments": comments,
3555
+ "path": "/reports/analytics/",
3556
+ "take_screenshot": "0",
3557
+ },
3558
+ )
3559
+ self.assertEqual(response.status_code, 200)
3560
+ self.assertEqual(Todo.objects.count(), 1)
3561
+ todo = Todo.objects.get()
3562
+ self.assertEqual(todo.request, f"Triage {comments}")
3563
+ self.assertTrue(todo.is_user_data)
3564
+ self.assertEqual(todo.original_user, superuser)
3565
+ self.assertTrue(todo.original_user_is_authenticated)
3566
+ self.assertEqual(todo.origin_node, Node.get_local())
3567
+
3568
+ def test_screenshot_request_links_saved_sample(self):
3569
+ self.client.force_login(self.user)
3570
+ screenshot_file = Path("/tmp/fake.png")
3571
+ self.mock_capture.return_value = screenshot_file
3572
+ sample = ContentSample.objects.create(kind=ContentSample.IMAGE)
3573
+ self.mock_save.return_value = sample
3574
+
3575
+ response = self.client.post(
3576
+ self.url,
3577
+ {
3578
+ "rating": 5,
3579
+ "comments": "Loved the experience!",
3580
+ "path": "/wizard/step-1/",
3581
+ "take_screenshot": "1",
3582
+ },
3583
+ HTTP_REFERER="https://example.test/wizard/step-1/",
3584
+ )
3585
+
3586
+ self.assertEqual(response.status_code, 200)
3587
+ story = UserStory.objects.get()
3588
+ self.assertEqual(story.screenshot, sample)
3589
+ self.mock_capture.assert_called_once_with("https://example.test/wizard/step-1/")
3590
+ self.mock_save.assert_called_once_with(
3591
+ screenshot_file,
3592
+ method="USER_STORY",
3593
+ user=self.user,
3594
+ )
3595
+
3596
+ def test_anonymous_submission_uses_provided_email(self):
3597
+ response = self.client.post(
3598
+ self.url,
3599
+ {
3600
+ "name": "guest@example.com",
3266
3601
  "rating": 3,
3267
3602
  "comments": "It was fine.",
3268
3603
  "path": "/status/",
@@ -3272,7 +3607,7 @@ class UserStorySubmissionTests(TestCase):
3272
3607
  self.assertEqual(response.status_code, 200)
3273
3608
  self.assertEqual(UserStory.objects.count(), 1)
3274
3609
  story = UserStory.objects.get()
3275
- self.assertEqual(story.name, "Guest Reviewer")
3610
+ self.assertEqual(story.name, "guest@example.com")
3276
3611
  self.assertIsNone(story.user)
3277
3612
  self.assertIsNone(story.owner)
3278
3613
  self.assertEqual(story.comments, "It was fine.")
@@ -3294,7 +3629,7 @@ class UserStorySubmissionTests(TestCase):
3294
3629
  self.assertFalse(UserStory.objects.exists())
3295
3630
  self.assertIn("rating", data.get("errors", {}))
3296
3631
 
3297
- def test_anonymous_submission_without_name_uses_fallback(self):
3632
+ def test_anonymous_submission_without_email_returns_errors(self):
3298
3633
  response = self.client.post(
3299
3634
  self.url,
3300
3635
  {
@@ -3304,18 +3639,32 @@ class UserStorySubmissionTests(TestCase):
3304
3639
  "take_screenshot": "1",
3305
3640
  },
3306
3641
  )
3307
- self.assertEqual(response.status_code, 200)
3308
- story = UserStory.objects.get()
3309
- self.assertEqual(story.name, "Anonymous")
3310
- self.assertIsNone(story.user)
3311
- self.assertIsNone(story.owner)
3312
- self.assertTrue(story.take_screenshot)
3313
- self.assertEqual(story.status, UserStory.Status.OPEN)
3642
+ self.assertEqual(response.status_code, 400)
3643
+ self.assertFalse(UserStory.objects.exists())
3644
+ data = response.json()
3645
+ self.assertIn("name", data.get("errors", {}))
3646
+
3647
+ def test_anonymous_submission_with_invalid_email_returns_errors(self):
3648
+ response = self.client.post(
3649
+ self.url,
3650
+ {
3651
+ "name": "Guest Reviewer",
3652
+ "rating": 3,
3653
+ "comments": "Needs improvement.",
3654
+ "path": "/feedback/",
3655
+ "take_screenshot": "1",
3656
+ },
3657
+ )
3658
+ self.assertEqual(response.status_code, 400)
3659
+ self.assertFalse(UserStory.objects.exists())
3660
+ data = response.json()
3661
+ self.assertIn("name", data.get("errors", {}))
3314
3662
 
3315
3663
  def test_submission_without_screenshot_request(self):
3316
3664
  response = self.client.post(
3317
3665
  self.url,
3318
3666
  {
3667
+ "name": "guest@example.com",
3319
3668
  "rating": 4,
3320
3669
  "comments": "Skip the screenshot, please.",
3321
3670
  "path": "/feedback/",
@@ -3325,9 +3674,14 @@ class UserStorySubmissionTests(TestCase):
3325
3674
  story = UserStory.objects.get()
3326
3675
  self.assertFalse(story.take_screenshot)
3327
3676
  self.assertIsNone(story.owner)
3677
+ self.assertIsNone(story.screenshot)
3678
+ self.assertEqual(story.status, UserStory.Status.OPEN)
3679
+ self.mock_capture.assert_not_called()
3680
+ self.mock_save.assert_not_called()
3328
3681
 
3329
3682
  def test_rate_limit_blocks_repeated_submissions(self):
3330
3683
  payload = {
3684
+ "name": "guest@example.com",
3331
3685
  "rating": 4,
3332
3686
  "comments": "Pretty good",
3333
3687
  "path": "/feedback/",
@@ -3428,6 +3782,8 @@ class UserStoryAdminActionTests(TestCase):
3428
3782
  comments="Helpful notes",
3429
3783
  take_screenshot=True,
3430
3784
  )
3785
+ self.story.language_code = "es"
3786
+ self.story.save(update_fields=["language_code"])
3431
3787
  self.admin = UserStoryAdmin(UserStory, admin.site)
3432
3788
 
3433
3789
  def _build_request(self):
@@ -3461,6 +3817,8 @@ class UserStoryAdminActionTests(TestCase):
3461
3817
  args, kwargs = mock_create_issue.call_args
3462
3818
  self.assertIn("Feedback for", args[0])
3463
3819
  self.assertIn("**Rating:**", args[1])
3820
+ self.assertIn("**Language:**", args[1])
3821
+ self.assertIn("(es)", args[1])
3464
3822
  self.assertEqual(kwargs.get("labels"), ["feedback"])
3465
3823
  self.assertEqual(
3466
3824
  kwargs.get("fingerprint"), f"user-story:{self.story.pk}"
pages/urls.py CHANGED
@@ -6,12 +6,22 @@ app_name = "pages"
6
6
 
7
7
  urlpatterns = [
8
8
  path("", views.index, name="index"),
9
+ path(
10
+ "read/assets/<str:source>/<path:asset>",
11
+ views.readme_asset,
12
+ name="readme-asset",
13
+ ),
9
14
  path("read/<path:doc>/edit/", views.readme_edit, name="readme-edit"),
10
15
  path("read/", views.readme, name="readme"),
11
16
  path("read/<path:doc>", views.readme, name="readme-document"),
12
17
  path("sitemap.xml", views.sitemap, name="pages-sitemap"),
13
18
  path("release/", views.release_admin_redirect, name="release-admin"),
14
19
  path("client-report/", views.client_report, name="client-report"),
20
+ path(
21
+ "client-report/download/<int:report_id>/",
22
+ views.client_report_download,
23
+ name="client-report-download",
24
+ ),
15
25
  path("release-checklist", views.release_checklist, name="release-checklist"),
16
26
  path("login/", views.login_view, name="login"),
17
27
  path("authenticator/setup/", views.authenticator_setup, name="authenticator-setup"),