arthexis 0.1.21__py3-none-any.whl → 0.1.22__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
@@ -195,6 +200,41 @@ class LoginViewTests(TestCase):
195
200
  )
196
201
  self.assertRedirects(resp, "/nodes/list/")
197
202
 
203
+ def test_homepage_excludes_version_banner_for_anonymous(self):
204
+ response = self.client.get(reverse("pages:index"))
205
+
206
+ self.assertEqual(response.status_code, 200)
207
+ self.assertNotContains(response, "__versionCheckInitialized")
208
+
209
+ def test_homepage_includes_version_banner_for_staff(self):
210
+ self.client.force_login(self.staff)
211
+ response = self.client.get(reverse("pages:index"))
212
+
213
+ self.assertEqual(response.status_code, 200)
214
+ self.assertContains(response, "__versionCheckInitialized")
215
+
216
+
217
+ class AdminTemplateVersionBannerTests(TestCase):
218
+ def setUp(self):
219
+ self.client = Client()
220
+ User = get_user_model()
221
+ self.staff = User.objects.create_user(
222
+ username="admin-staff", password="pwd", is_staff=True
223
+ )
224
+
225
+ def test_admin_login_excludes_version_banner_for_anonymous(self):
226
+ response = self.client.get(reverse("admin:login"))
227
+
228
+ self.assertEqual(response.status_code, 200)
229
+ self.assertNotContains(response, "__versionCheckInitialized")
230
+
231
+ def test_admin_dashboard_includes_version_banner_for_staff(self):
232
+ self.client.force_login(self.staff)
233
+ response = self.client.get(reverse("admin:index"))
234
+
235
+ self.assertEqual(response.status_code, 200)
236
+ self.assertContains(response, "__versionCheckInitialized")
237
+
198
238
  def test_staff_redirects_next_when_specified(self):
199
239
  resp = self.client.post(
200
240
  reverse("pages:login") + "?next=/nodes/list/",
@@ -715,6 +755,66 @@ class AdminSidebarTests(TestCase):
715
755
  self.assertContains(resp, 'id="admin-collapsible-apps"')
716
756
 
717
757
 
758
+ class AdminGoogleCalendarSidebarTests(TestCase):
759
+ def setUp(self):
760
+ self.client = Client()
761
+ User = get_user_model()
762
+ self.admin = User.objects.create_superuser(
763
+ username="calendar_admin", password="pwd", email="admin@example.com"
764
+ )
765
+ self.client.force_login(self.admin)
766
+ Site.objects.update_or_create(
767
+ id=1, defaults={"name": "test", "domain": "testserver"}
768
+ )
769
+ Node.objects.create(hostname="testserver", address="127.0.0.1")
770
+
771
+ def test_calendar_module_hidden_without_profile(self):
772
+ resp = self.client.get(reverse("admin:index"))
773
+ self.assertNotContains(resp, 'id="google-calendar-module"', html=False)
774
+
775
+ @patch("core.models.GoogleCalendarProfile.fetch_events")
776
+ def test_calendar_module_shows_events_for_user(self, fetch_events):
777
+ fetch_events.return_value = [
778
+ {
779
+ "summary": "Standup",
780
+ "start": timezone.now(),
781
+ "end": None,
782
+ "all_day": False,
783
+ "html_link": "https://calendar.google.com/event",
784
+ "location": "HQ",
785
+ }
786
+ ]
787
+ GoogleCalendarProfile.objects.create(
788
+ user=self.admin,
789
+ calendar_id="example@group.calendar.google.com",
790
+ api_key="secret",
791
+ display_name="Team Calendar",
792
+ )
793
+
794
+ resp = self.client.get(reverse("admin:index"))
795
+
796
+ self.assertContains(resp, 'id="google-calendar-module"', html=False)
797
+ self.assertContains(resp, "Standup")
798
+ self.assertContains(resp, "Open full calendar")
799
+ fetch_events.assert_called_once()
800
+
801
+ @patch("core.models.GoogleCalendarProfile.fetch_events")
802
+ def test_calendar_module_uses_group_profile(self, fetch_events):
803
+ fetch_events.return_value = []
804
+ group = SecurityGroup.objects.create(name="Calendar Group")
805
+ self.admin.groups.add(group)
806
+ GoogleCalendarProfile.objects.create(
807
+ group=group,
808
+ calendar_id="group@calendar.google.com",
809
+ api_key="secret",
810
+ )
811
+
812
+ resp = self.client.get(reverse("admin:index"))
813
+
814
+ self.assertContains(resp, 'id="google-calendar-module"', html=False)
815
+ fetch_events.assert_called_once()
816
+
817
+
718
818
  class ViewHistoryLoggingTests(TestCase):
719
819
  def setUp(self):
720
820
  self.client = Client()
@@ -816,6 +916,35 @@ class ViewHistoryLoggingTests(TestCase):
816
916
  self.assertEqual(lead.path, "/")
817
917
  self.assertEqual(lead.referer, "https://example.com/ref")
818
918
 
919
+ def test_pages_config_purges_old_view_history(self):
920
+ ViewHistory.objects.all().delete()
921
+
922
+ old_entry = ViewHistory.objects.create(
923
+ path="/old/",
924
+ method="GET",
925
+ status_code=200,
926
+ status_text="OK",
927
+ )
928
+ new_entry = ViewHistory.objects.create(
929
+ path="/recent/",
930
+ method="GET",
931
+ status_code=200,
932
+ status_text="OK",
933
+ )
934
+
935
+ ViewHistory.objects.filter(pk=old_entry.pk).update(
936
+ visited_at=timezone.now() - timedelta(days=20)
937
+ )
938
+ ViewHistory.objects.filter(pk=new_entry.pk).update(
939
+ visited_at=timezone.now() - timedelta(days=10)
940
+ )
941
+
942
+ config = django_apps.get_app_config("pages")
943
+ config._purge_view_history()
944
+
945
+ self.assertFalse(ViewHistory.objects.filter(pk=old_entry.pk).exists())
946
+ self.assertTrue(ViewHistory.objects.filter(pk=new_entry.pk).exists())
947
+
819
948
  def test_landing_visit_does_not_record_lead_without_celery(self):
820
949
  role = NodeRole.objects.create(name="no-celery-role")
821
950
  application = Application.objects.create(
@@ -913,6 +1042,44 @@ class ViewHistoryAdminTests(TestCase):
913
1042
  self.assertEqual(totals.get("/"), 2)
914
1043
  self.assertEqual(totals.get("/about/"), 1)
915
1044
 
1045
+ def test_graph_data_endpoint_respects_days_parameter(self):
1046
+ ViewHistory.all_objects.all().delete()
1047
+ reference_date = date(2025, 5, 1)
1048
+ tz = timezone.get_current_timezone()
1049
+ path = "/range/"
1050
+
1051
+ for offset in range(10):
1052
+ entry = ViewHistory.objects.create(
1053
+ path=path,
1054
+ method="GET",
1055
+ status_code=200,
1056
+ status_text="OK",
1057
+ error_message="",
1058
+ view_name="pages:index",
1059
+ )
1060
+ visited_date = reference_date - timedelta(days=offset)
1061
+ visited_at = timezone.make_aware(
1062
+ datetime.combine(visited_date, datetime_time(12, 0)), tz
1063
+ )
1064
+ entry.visited_at = visited_at
1065
+ entry.save(update_fields=["visited_at"])
1066
+
1067
+ url = reverse("admin:pages_viewhistory_traffic_data")
1068
+ with patch("pages.admin.timezone.localdate", return_value=reference_date):
1069
+ resp = self.client.get(url, {"days": 7})
1070
+
1071
+ self.assertEqual(resp.status_code, 200)
1072
+ data = resp.json()
1073
+
1074
+ self.assertEqual(len(data.get("labels", [])), 7)
1075
+ self.assertEqual(data.get("meta", {}).get("start"), (reference_date - timedelta(days=6)).isoformat())
1076
+ self.assertEqual(data.get("meta", {}).get("end"), reference_date.isoformat())
1077
+
1078
+ totals = {
1079
+ dataset["label"]: sum(dataset["data"]) for dataset in data.get("datasets", [])
1080
+ }
1081
+ self.assertEqual(totals.get(path), 7)
1082
+
916
1083
  def test_graph_data_includes_late_evening_visits(self):
917
1084
  target_date = date(2025, 9, 27)
918
1085
  entry = ViewHistory.objects.create(
@@ -2657,6 +2824,20 @@ class FavoriteTests(TestCase):
2657
2824
  self.assertEqual(fav.custom_label, "Apps")
2658
2825
  self.assertTrue(fav.user_data)
2659
2826
 
2827
+ def test_add_favorite_defaults_user_data_checked(self):
2828
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2829
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2830
+ resp = self.client.get(url)
2831
+ self.assertContains(resp, 'name="user_data" checked')
2832
+
2833
+ def test_add_favorite_with_priority(self):
2834
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2835
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2836
+ resp = self.client.post(url, {"priority": "7"})
2837
+ self.assertRedirects(resp, reverse("admin:index"))
2838
+ fav = Favorite.objects.get(user=self.user, content_type=ct)
2839
+ self.assertEqual(fav.priority, 7)
2840
+
2660
2841
  def test_cancel_link_uses_next(self):
2661
2842
  ct = ContentType.objects.get_by_natural_key("pages", "application")
2662
2843
  next_url = reverse("admin:pages_application_changelist")
@@ -2666,14 +2847,31 @@ class FavoriteTests(TestCase):
2666
2847
  resp = self.client.get(url)
2667
2848
  self.assertContains(resp, f'href="{next_url}"')
2668
2849
 
2669
- def test_existing_favorite_redirects_to_list(self):
2850
+ def test_existing_favorite_shows_update_form(self):
2670
2851
  ct = ContentType.objects.get_by_natural_key("pages", "application")
2671
- Favorite.objects.create(user=self.user, content_type=ct)
2852
+ favorite = Favorite.objects.create(
2853
+ user=self.user, content_type=ct, custom_label="Apps", user_data=True
2854
+ )
2672
2855
  url = reverse("admin:favorite_toggle", args=[ct.id])
2673
2856
  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)
2857
+ self.assertContains(resp, "Update Favorite")
2858
+ self.assertContains(resp, "value=\"Apps\"")
2859
+ self.assertContains(resp, "checked")
2860
+ self.assertContains(resp, "name=\"remove\"")
2861
+
2862
+ resp = self.client.post(url, {"custom_label": "Apps Updated"})
2863
+ self.assertRedirects(resp, reverse("admin:index"))
2864
+ favorite.refresh_from_db()
2865
+ self.assertEqual(favorite.custom_label, "Apps Updated")
2866
+ self.assertFalse(favorite.user_data)
2867
+
2868
+ def test_remove_existing_favorite_from_toggle(self):
2869
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2870
+ Favorite.objects.create(user=self.user, content_type=ct)
2871
+ url = reverse("admin:favorite_toggle", args=[ct.id])
2872
+ resp = self.client.post(url, {"remove": "1"})
2873
+ self.assertRedirects(resp, reverse("admin:index"))
2874
+ self.assertFalse(Favorite.objects.filter(user=self.user, content_type=ct).exists())
2677
2875
 
2678
2876
  def test_update_user_data_from_list(self):
2679
2877
  ct = ContentType.objects.get_by_natural_key("pages", "application")
@@ -2684,6 +2882,15 @@ class FavoriteTests(TestCase):
2684
2882
  fav.refresh_from_db()
2685
2883
  self.assertTrue(fav.user_data)
2686
2884
 
2885
+ def test_update_priority_from_list(self):
2886
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
2887
+ fav = Favorite.objects.create(user=self.user, content_type=ct, priority=3)
2888
+ url = reverse("admin:favorite_list")
2889
+ resp = self.client.post(url, {f"priority_{fav.pk}": "12"})
2890
+ self.assertRedirects(resp, url)
2891
+ fav.refresh_from_db()
2892
+ self.assertEqual(fav.priority, 12)
2893
+
2687
2894
  def test_dashboard_includes_favorites_and_user_data(self):
2688
2895
  fav_ct = ContentType.objects.get_by_natural_key("pages", "application")
2689
2896
  Favorite.objects.create(
@@ -2694,6 +2901,12 @@ class FavoriteTests(TestCase):
2694
2901
  self.assertContains(resp, reverse("admin:pages_application_changelist"))
2695
2902
  self.assertContains(resp, reverse("admin:nodes_noderole_changelist"))
2696
2903
 
2904
+ def test_dashboard_shows_empty_todo_state(self):
2905
+ Todo.objects.all().delete()
2906
+ resp = self.client.get(reverse("admin:index"))
2907
+ self.assertContains(resp, "Release manager tasks")
2908
+ self.assertContains(resp, "No pending TODOs")
2909
+
2697
2910
  def test_dashboard_merges_duplicate_future_actions(self):
2698
2911
  ct = ContentType.objects.get_for_model(NodeRole)
2699
2912
  Favorite.objects.create(user=self.user, content_type=ct)
@@ -2933,7 +3146,11 @@ class FavoriteTests(TestCase):
2933
3146
  todo = Todo.objects.create(request="Do thing")
2934
3147
  resp = self.client.get(reverse("admin:index"))
2935
3148
  done_url = reverse("todo-done", args=[todo.pk])
2936
- self.assertContains(resp, todo.request)
3149
+ tooltip = escape(todo.request)
3150
+ self.assertContains(resp, f'title="{tooltip}"')
3151
+ self.assertContains(resp, f'aria-label="{tooltip}"')
3152
+ task_label = gettext("Task %(counter)s") % {"counter": 1}
3153
+ self.assertContains(resp, task_label)
2937
3154
  self.assertContains(resp, f'action="{done_url}"')
2938
3155
  self.assertContains(resp, "DONE")
2939
3156
 
@@ -2961,7 +3178,8 @@ class FavoriteTests(TestCase):
2961
3178
 
2962
3179
  resp = self.client.get(reverse("admin:index"))
2963
3180
  self.assertContains(resp, "Release manager tasks")
2964
- self.assertContains(resp, "Check fallback")
3181
+ tooltip = escape("Check fallback")
3182
+ self.assertContains(resp, f'title="{tooltip}"')
2965
3183
 
2966
3184
  def test_dashboard_shows_todos_without_release_manager_profile(self):
2967
3185
  Todo.objects.create(request="Unrestricted task")
@@ -2969,7 +3187,8 @@ class FavoriteTests(TestCase):
2969
3187
 
2970
3188
  resp = self.client.get(reverse("admin:index"))
2971
3189
  self.assertContains(resp, "Release manager tasks")
2972
- self.assertContains(resp, "Unrestricted task")
3190
+ tooltip = escape("Unrestricted task")
3191
+ self.assertContains(resp, f'title="{tooltip}"')
2973
3192
 
2974
3193
  def test_dashboard_excludes_todo_changelist_link(self):
2975
3194
  ct = ContentType.objects.get_for_model(Todo)
@@ -2993,7 +3212,8 @@ class FavoriteTests(TestCase):
2993
3212
  self.client.force_login(other_user)
2994
3213
  resp = self.client.get(reverse("admin:index"))
2995
3214
  self.assertContains(resp, "Release manager tasks")
2996
- self.assertContains(resp, todo.request)
3215
+ tooltip = escape(todo.request)
3216
+ self.assertContains(resp, f'title="{tooltip}"')
2997
3217
 
2998
3218
  def test_dashboard_shows_todos_for_non_terminal_node(self):
2999
3219
  todo = Todo.objects.create(request="Terminal Tasks")
@@ -3004,7 +3224,8 @@ class FavoriteTests(TestCase):
3004
3224
  self.node.save(update_fields=["role"])
3005
3225
  resp = self.client.get(reverse("admin:index"))
3006
3226
  self.assertContains(resp, "Release manager tasks")
3007
- self.assertContains(resp, todo.request)
3227
+ tooltip = escape(todo.request)
3228
+ self.assertContains(resp, f'title="{tooltip}"')
3008
3229
 
3009
3230
  def test_dashboard_shows_todos_for_delegate_release_manager(self):
3010
3231
  todo = Todo.objects.create(request="Delegate Task")
@@ -3026,7 +3247,8 @@ class FavoriteTests(TestCase):
3026
3247
  self.client.force_login(operator)
3027
3248
  resp = self.client.get(reverse("admin:index"))
3028
3249
  self.assertContains(resp, "Release manager tasks")
3029
- self.assertContains(resp, todo.request)
3250
+ tooltip = escape(todo.request)
3251
+ self.assertContains(resp, f'title="{tooltip}"')
3030
3252
 
3031
3253
 
3032
3254
  class AdminIndexQueryRegressionTests(TestCase):
@@ -3229,6 +3451,14 @@ class UserStorySubmissionTests(TestCase):
3229
3451
  self.url = reverse("pages:user-story-submit")
3230
3452
  User = get_user_model()
3231
3453
  self.user = User.objects.create_user(username="feedbacker", password="pwd")
3454
+ self.capture_patcher = patch("pages.views.capture_screenshot", autospec=True)
3455
+ self.save_patcher = patch("pages.views.save_screenshot", autospec=True)
3456
+ self.mock_capture = self.capture_patcher.start()
3457
+ self.mock_save = self.save_patcher.start()
3458
+ self.mock_capture.return_value = Path("/tmp/fake.png")
3459
+ self.mock_save.return_value = None
3460
+ self.addCleanup(self.capture_patcher.stop)
3461
+ self.addCleanup(self.save_patcher.stop)
3232
3462
 
3233
3463
  def test_authenticated_submission_defaults_to_username(self):
3234
3464
  self.client.force_login(self.user)
@@ -3257,12 +3487,98 @@ class UserStorySubmissionTests(TestCase):
3257
3487
  self.assertEqual(story.referer, "https://example.test/wizard/step-1/")
3258
3488
  self.assertEqual(story.user_agent, "FeedbackBot/1.0")
3259
3489
  self.assertEqual(story.ip_address, "127.0.0.1")
3490
+ expected_language = (translation.get_language() or "").split("-")[0]
3491
+ self.assertTrue(story.language_code)
3492
+ self.assertTrue(
3493
+ story.language_code.startswith(expected_language),
3494
+ story.language_code,
3495
+ )
3496
+
3497
+ def test_submission_records_request_language(self):
3498
+ self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = "es"
3499
+ with translation.override("es"):
3500
+ response = self.client.post(
3501
+ self.url,
3502
+ {
3503
+ "rating": 4,
3504
+ "comments": "Buena experiencia",
3505
+ "path": "/es/soporte/",
3506
+ "take_screenshot": "1",
3507
+ },
3508
+ HTTP_ACCEPT_LANGUAGE="es",
3509
+ )
3260
3510
 
3261
- def test_anonymous_submission_uses_provided_name(self):
3511
+ self.assertEqual(response.status_code, 200)
3512
+ story = UserStory.objects.get()
3513
+ self.assertEqual(story.language_code, "es")
3514
+
3515
+ def test_superuser_submission_creates_triage_todo(self):
3516
+ Todo.objects.all().delete()
3517
+ superuser = get_user_model().objects.create_superuser(
3518
+ username="overseer", email="overseer@example.com", password="pwd"
3519
+ )
3520
+ Node.objects.update_or_create(
3521
+ mac_address=Node.get_current_mac(),
3522
+ defaults={
3523
+ "hostname": "local-node",
3524
+ "address": "127.0.0.1",
3525
+ "port": 8000,
3526
+ "public_endpoint": "local-node",
3527
+ },
3528
+ )
3529
+ self.client.force_login(superuser)
3530
+ comments = "Review analytics dashboard flow"
3262
3531
  response = self.client.post(
3263
3532
  self.url,
3264
3533
  {
3265
- "name": "Guest Reviewer",
3534
+ "rating": 5,
3535
+ "comments": comments,
3536
+ "path": "/reports/analytics/",
3537
+ "take_screenshot": "0",
3538
+ },
3539
+ )
3540
+ self.assertEqual(response.status_code, 200)
3541
+ self.assertEqual(Todo.objects.count(), 1)
3542
+ todo = Todo.objects.get()
3543
+ self.assertEqual(todo.request, f"Triage {comments}")
3544
+ self.assertTrue(todo.is_user_data)
3545
+ self.assertEqual(todo.original_user, superuser)
3546
+ self.assertTrue(todo.original_user_is_authenticated)
3547
+ self.assertEqual(todo.origin_node, Node.get_local())
3548
+
3549
+ def test_screenshot_request_links_saved_sample(self):
3550
+ self.client.force_login(self.user)
3551
+ screenshot_file = Path("/tmp/fake.png")
3552
+ self.mock_capture.return_value = screenshot_file
3553
+ sample = ContentSample.objects.create(kind=ContentSample.IMAGE)
3554
+ self.mock_save.return_value = sample
3555
+
3556
+ response = self.client.post(
3557
+ self.url,
3558
+ {
3559
+ "rating": 5,
3560
+ "comments": "Loved the experience!",
3561
+ "path": "/wizard/step-1/",
3562
+ "take_screenshot": "1",
3563
+ },
3564
+ HTTP_REFERER="https://example.test/wizard/step-1/",
3565
+ )
3566
+
3567
+ self.assertEqual(response.status_code, 200)
3568
+ story = UserStory.objects.get()
3569
+ self.assertEqual(story.screenshot, sample)
3570
+ self.mock_capture.assert_called_once_with("https://example.test/wizard/step-1/")
3571
+ self.mock_save.assert_called_once_with(
3572
+ screenshot_file,
3573
+ method="USER_STORY",
3574
+ user=self.user,
3575
+ )
3576
+
3577
+ def test_anonymous_submission_uses_provided_email(self):
3578
+ response = self.client.post(
3579
+ self.url,
3580
+ {
3581
+ "name": "guest@example.com",
3266
3582
  "rating": 3,
3267
3583
  "comments": "It was fine.",
3268
3584
  "path": "/status/",
@@ -3272,7 +3588,7 @@ class UserStorySubmissionTests(TestCase):
3272
3588
  self.assertEqual(response.status_code, 200)
3273
3589
  self.assertEqual(UserStory.objects.count(), 1)
3274
3590
  story = UserStory.objects.get()
3275
- self.assertEqual(story.name, "Guest Reviewer")
3591
+ self.assertEqual(story.name, "guest@example.com")
3276
3592
  self.assertIsNone(story.user)
3277
3593
  self.assertIsNone(story.owner)
3278
3594
  self.assertEqual(story.comments, "It was fine.")
@@ -3294,7 +3610,7 @@ class UserStorySubmissionTests(TestCase):
3294
3610
  self.assertFalse(UserStory.objects.exists())
3295
3611
  self.assertIn("rating", data.get("errors", {}))
3296
3612
 
3297
- def test_anonymous_submission_without_name_uses_fallback(self):
3613
+ def test_anonymous_submission_without_email_returns_errors(self):
3298
3614
  response = self.client.post(
3299
3615
  self.url,
3300
3616
  {
@@ -3304,18 +3620,32 @@ class UserStorySubmissionTests(TestCase):
3304
3620
  "take_screenshot": "1",
3305
3621
  },
3306
3622
  )
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)
3623
+ self.assertEqual(response.status_code, 400)
3624
+ self.assertFalse(UserStory.objects.exists())
3625
+ data = response.json()
3626
+ self.assertIn("name", data.get("errors", {}))
3627
+
3628
+ def test_anonymous_submission_with_invalid_email_returns_errors(self):
3629
+ response = self.client.post(
3630
+ self.url,
3631
+ {
3632
+ "name": "Guest Reviewer",
3633
+ "rating": 3,
3634
+ "comments": "Needs improvement.",
3635
+ "path": "/feedback/",
3636
+ "take_screenshot": "1",
3637
+ },
3638
+ )
3639
+ self.assertEqual(response.status_code, 400)
3640
+ self.assertFalse(UserStory.objects.exists())
3641
+ data = response.json()
3642
+ self.assertIn("name", data.get("errors", {}))
3314
3643
 
3315
3644
  def test_submission_without_screenshot_request(self):
3316
3645
  response = self.client.post(
3317
3646
  self.url,
3318
3647
  {
3648
+ "name": "guest@example.com",
3319
3649
  "rating": 4,
3320
3650
  "comments": "Skip the screenshot, please.",
3321
3651
  "path": "/feedback/",
@@ -3325,9 +3655,14 @@ class UserStorySubmissionTests(TestCase):
3325
3655
  story = UserStory.objects.get()
3326
3656
  self.assertFalse(story.take_screenshot)
3327
3657
  self.assertIsNone(story.owner)
3658
+ self.assertIsNone(story.screenshot)
3659
+ self.assertEqual(story.status, UserStory.Status.OPEN)
3660
+ self.mock_capture.assert_not_called()
3661
+ self.mock_save.assert_not_called()
3328
3662
 
3329
3663
  def test_rate_limit_blocks_repeated_submissions(self):
3330
3664
  payload = {
3665
+ "name": "guest@example.com",
3331
3666
  "rating": 4,
3332
3667
  "comments": "Pretty good",
3333
3668
  "path": "/feedback/",
@@ -3428,6 +3763,8 @@ class UserStoryAdminActionTests(TestCase):
3428
3763
  comments="Helpful notes",
3429
3764
  take_screenshot=True,
3430
3765
  )
3766
+ self.story.language_code = "es"
3767
+ self.story.save(update_fields=["language_code"])
3431
3768
  self.admin = UserStoryAdmin(UserStory, admin.site)
3432
3769
 
3433
3770
  def _build_request(self):
@@ -3461,6 +3798,8 @@ class UserStoryAdminActionTests(TestCase):
3461
3798
  args, kwargs = mock_create_issue.call_args
3462
3799
  self.assertIn("Feedback for", args[0])
3463
3800
  self.assertIn("**Rating:**", args[1])
3801
+ self.assertIn("**Language:**", args[1])
3802
+ self.assertIn("(es)", args[1])
3464
3803
  self.assertEqual(kwargs.get("labels"), ["feedback"])
3465
3804
  self.assertEqual(
3466
3805
  kwargs.get("fingerprint"), f"user-story:{self.story.pk}"
pages/urls.py CHANGED
@@ -6,6 +6,11 @@ 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"),