arthexis 0.1.11__py3-none-any.whl → 0.1.12__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/forms.py CHANGED
@@ -9,6 +9,8 @@ from django.core.exceptions import ValidationError
9
9
  from django.utils.translation import gettext_lazy as _
10
10
  from django.views.decorators.debug import sensitive_variables
11
11
 
12
+ from .models import UserStory
13
+
12
14
 
13
15
  class AuthenticatorLoginForm(AuthenticationForm):
14
16
  """Authentication form that supports password or authenticator codes."""
@@ -129,3 +131,39 @@ class AuthenticatorEnrollmentForm(forms.Form):
129
131
 
130
132
  def get_verified_device(self):
131
133
  return self.device
134
+
135
+
136
+ class UserStoryForm(forms.ModelForm):
137
+ class Meta:
138
+ model = UserStory
139
+ fields = ("name", "rating", "comments", "take_screenshot", "path")
140
+ widgets = {
141
+ "path": forms.HiddenInput(),
142
+ "comments": forms.Textarea(attrs={"rows": 4, "maxlength": 400}),
143
+ }
144
+
145
+ def __init__(self, *args, **kwargs):
146
+ super().__init__(*args, **kwargs)
147
+ self.fields["name"].required = False
148
+ self.fields["name"].widget.attrs.update(
149
+ {
150
+ "maxlength": 40,
151
+ "placeholder": _("Name, email or pseudonym"),
152
+ }
153
+ )
154
+ self.fields["take_screenshot"].initial = True
155
+ self.fields["rating"].widget = forms.RadioSelect(
156
+ choices=[(i, str(i)) for i in range(1, 6)]
157
+ )
158
+
159
+ def clean_comments(self):
160
+ comments = (self.cleaned_data.get("comments") or "").strip()
161
+ if len(comments) > 400:
162
+ raise forms.ValidationError(
163
+ _("Feedback must be 400 characters or fewer."), code="max_length"
164
+ )
165
+ return comments
166
+
167
+ def clean_name(self):
168
+ name = (self.cleaned_data.get("name") or "").strip()
169
+ return name[:40]
pages/models.py CHANGED
@@ -4,11 +4,14 @@ from django.contrib.sites.models import Site
4
4
  from nodes.models import NodeRole
5
5
  from django.apps import apps as django_apps
6
6
  from django.utils.text import slugify
7
- from django.utils.translation import gettext_lazy as _
7
+ from django.utils.translation import gettext, gettext_lazy as _
8
8
  from importlib import import_module
9
9
  from django.urls import URLPattern
10
10
  from django.conf import settings
11
11
  from django.contrib.contenttypes.models import ContentType
12
+ from django.core.validators import MaxLengthValidator, MaxValueValidator, MinValueValidator
13
+
14
+ from core import github_issues
12
15
 
13
16
 
14
17
  class ApplicationManager(models.Manager):
@@ -279,6 +282,138 @@ class Favorite(Entity):
279
282
  unique_together = ("user", "content_type")
280
283
 
281
284
 
285
+ class UserStory(Entity):
286
+ path = models.CharField(max_length=500)
287
+ name = models.CharField(max_length=40, blank=True)
288
+ rating = models.PositiveSmallIntegerField(
289
+ validators=[MinValueValidator(1), MaxValueValidator(5)],
290
+ help_text=_("Rate your experience from 1 (lowest) to 5 (highest)."),
291
+ )
292
+ comments = models.TextField(
293
+ validators=[MaxLengthValidator(400)],
294
+ help_text=_("Share more about your experience."),
295
+ )
296
+ take_screenshot = models.BooleanField(
297
+ default=True,
298
+ help_text=_("Request a screenshot capture for this feedback."),
299
+ )
300
+ user = models.ForeignKey(
301
+ settings.AUTH_USER_MODEL,
302
+ on_delete=models.SET_NULL,
303
+ blank=True,
304
+ null=True,
305
+ related_name="user_stories",
306
+ )
307
+ owner = models.ForeignKey(
308
+ settings.AUTH_USER_MODEL,
309
+ on_delete=models.SET_NULL,
310
+ blank=True,
311
+ null=True,
312
+ related_name="owned_user_stories",
313
+ help_text=_("Internal owner for this feedback."),
314
+ )
315
+ submitted_at = models.DateTimeField(auto_now_add=True)
316
+ github_issue_number = models.PositiveIntegerField(
317
+ blank=True,
318
+ null=True,
319
+ help_text=_("Number of the GitHub issue created for this feedback."),
320
+ )
321
+ github_issue_url = models.URLField(
322
+ blank=True,
323
+ help_text=_("Link to the GitHub issue created for this feedback."),
324
+ )
325
+
326
+ class Meta:
327
+ ordering = ["-submitted_at"]
328
+ verbose_name = _("User Story")
329
+ verbose_name_plural = _("User Stories")
330
+
331
+ def __str__(self) -> str: # pragma: no cover - simple representation
332
+ display = self.name or _("Anonymous")
333
+ return f"{display} ({self.rating}/5)"
334
+
335
+ def get_github_issue_labels(self) -> list[str]:
336
+ """Return default labels used when creating GitHub issues."""
337
+
338
+ return ["feedback"]
339
+
340
+ def get_github_issue_fingerprint(self) -> str | None:
341
+ """Return a fingerprint used to avoid duplicate issue submissions."""
342
+
343
+ if self.pk:
344
+ return f"user-story:{self.pk}"
345
+ return None
346
+
347
+ def build_github_issue_title(self) -> str:
348
+ """Return the title used for GitHub issues."""
349
+
350
+ path = self.path or "/"
351
+ return gettext("Feedback for %(path)s (%(rating)s/5)") % {
352
+ "path": path,
353
+ "rating": self.rating,
354
+ }
355
+
356
+ def build_github_issue_body(self) -> str:
357
+ """Return the issue body summarising the feedback details."""
358
+
359
+ name = self.name or gettext("Anonymous")
360
+ path = self.path or "/"
361
+ screenshot_requested = gettext("Yes") if self.take_screenshot else gettext("No")
362
+
363
+ lines = [
364
+ f"**Path:** {path}",
365
+ f"**Rating:** {self.rating}/5",
366
+ f"**Name:** {name}",
367
+ f"**Screenshot requested:** {screenshot_requested}",
368
+ ]
369
+
370
+ if self.submitted_at:
371
+ lines.append(f"**Submitted at:** {self.submitted_at.isoformat()}")
372
+
373
+ comment = (self.comments or "").strip()
374
+ if comment:
375
+ lines.extend(["", comment])
376
+
377
+ return "\n".join(lines).strip()
378
+
379
+ def create_github_issue(self) -> str | None:
380
+ """Create a GitHub issue for this feedback and store the identifiers."""
381
+
382
+ if self.github_issue_url:
383
+ return self.github_issue_url
384
+
385
+ response = github_issues.create_issue(
386
+ self.build_github_issue_title(),
387
+ self.build_github_issue_body(),
388
+ labels=self.get_github_issue_labels(),
389
+ fingerprint=self.get_github_issue_fingerprint(),
390
+ )
391
+
392
+ if response is None:
393
+ return None
394
+
395
+ try:
396
+ payload = response.json()
397
+ except ValueError: # pragma: no cover - defensive guard
398
+ payload = {}
399
+
400
+ issue_url = payload.get("html_url")
401
+ issue_number = payload.get("number")
402
+
403
+ update_fields = []
404
+ if issue_url and issue_url != self.github_issue_url:
405
+ self.github_issue_url = issue_url
406
+ update_fields.append("github_issue_url")
407
+ if issue_number is not None and issue_number != self.github_issue_number:
408
+ self.github_issue_number = issue_number
409
+ update_fields.append("github_issue_number")
410
+
411
+ if update_fields:
412
+ self.save(update_fields=update_fields)
413
+
414
+ return issue_url
415
+
416
+
282
417
  from django.db.models.signals import post_save
283
418
  from django.dispatch import receiver
284
419
 
pages/tests.py CHANGED
@@ -13,10 +13,18 @@ from urllib.parse import quote
13
13
  from django.contrib.auth import get_user_model
14
14
  from django.contrib.sites.models import Site
15
15
  from django.contrib import admin
16
+ from django.contrib.messages.storage.fallback import FallbackStorage
16
17
  from django.core.exceptions import DisallowedHost
17
18
  import socket
18
- from pages.models import Application, Module, SiteBadge, Favorite, ViewHistory
19
- from pages.admin import ApplicationAdmin
19
+ from pages.models import (
20
+ Application,
21
+ Module,
22
+ SiteBadge,
23
+ Favorite,
24
+ ViewHistory,
25
+ UserStory,
26
+ )
27
+ from pages.admin import ApplicationAdmin, UserStoryAdmin, ViewHistoryAdmin
20
28
  from pages.screenshot_specs import (
21
29
  ScreenshotSpec,
22
30
  ScreenshotSpecRunner,
@@ -33,6 +41,7 @@ from core.models import (
33
41
  Reference,
34
42
  ReleaseManager,
35
43
  Todo,
44
+ TOTPDeviceSettings,
36
45
  )
37
46
  from django.core.files.uploadedfile import SimpleUploadedFile
38
47
  import base64
@@ -41,12 +50,18 @@ import shutil
41
50
  from io import StringIO
42
51
  from django.conf import settings
43
52
  from pathlib import Path
44
- from unittest.mock import patch, Mock
53
+ from unittest.mock import MagicMock, Mock, patch
45
54
  from types import SimpleNamespace
46
55
  from django.core.management import call_command
47
56
  import re
48
57
  from django.contrib.contenttypes.models import ContentType
49
- from datetime import date, timedelta
58
+ from datetime import (
59
+ date,
60
+ datetime,
61
+ time as datetime_time,
62
+ timedelta,
63
+ timezone as datetime_timezone,
64
+ )
50
65
  from django.core import mail
51
66
  from django.utils import timezone
52
67
  from django.utils.text import slugify
@@ -84,6 +99,14 @@ class LoginViewTests(TestCase):
84
99
  resp = self.client.get(reverse("pages:login"))
85
100
  self.assertContains(resp, "Use Authenticator app")
86
101
 
102
+ def test_cp_simulator_redirect_shows_restricted_message(self):
103
+ simulator_path = reverse("cp-simulator")
104
+ resp = self.client.get(f"{reverse('pages:login')}?next={simulator_path}")
105
+ self.assertContains(
106
+ resp,
107
+ "This page is reserved for members only. Please log in to continue.",
108
+ )
109
+
87
110
  def test_staff_login_redirects_admin(self):
88
111
  resp = self.client.post(
89
112
  reverse("pages:login"),
@@ -303,6 +326,15 @@ class AuthenticatorSetupTests(TestCase):
303
326
  self.assertIn(label, config_url)
304
327
  self.assertIn(f"issuer={quote(settings.OTP_TOTP_ISSUER)}", config_url)
305
328
 
329
+ def test_device_config_url_uses_custom_issuer_when_available(self):
330
+ self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
331
+ device = TOTPDevice.objects.get(user=self.staff)
332
+ TOTPDeviceSettings.objects.create(device=device, issuer="Custom Co")
333
+ config_url = device.config_url
334
+ quoted_issuer = quote("Custom Co")
335
+ self.assertIn(quoted_issuer, config_url)
336
+ self.assertIn(f"issuer={quoted_issuer}", config_url)
337
+
306
338
  def test_pending_device_context_includes_qr(self):
307
339
  self.client.post(reverse("pages:authenticator-setup"), {"action": "generate"})
308
340
  resp = self.client.get(reverse("pages:authenticator-setup"))
@@ -731,6 +763,7 @@ class ViewHistoryAdminTests(TestCase):
731
763
  self.assertContains(resp, static("core/vendor/chart.umd.min.js"))
732
764
 
733
765
  def test_graph_data_endpoint(self):
766
+ ViewHistory.all_objects.all().delete()
734
767
  self._create_history("/", count=2)
735
768
  self._create_history("/about/", days_offset=1)
736
769
  url = reverse("admin:pages_viewhistory_traffic_data")
@@ -746,6 +779,44 @@ class ViewHistoryAdminTests(TestCase):
746
779
  self.assertEqual(totals.get("/"), 2)
747
780
  self.assertEqual(totals.get("/about/"), 1)
748
781
 
782
+ def test_graph_data_includes_late_evening_visits(self):
783
+ target_date = date(2025, 9, 27)
784
+ entry = ViewHistory.objects.create(
785
+ path="/late/",
786
+ method="GET",
787
+ status_code=200,
788
+ status_text="OK",
789
+ error_message="",
790
+ view_name="pages:index",
791
+ )
792
+ local_evening = datetime.combine(target_date, datetime_time(21, 30))
793
+ aware_evening = timezone.make_aware(
794
+ local_evening, timezone.get_current_timezone()
795
+ )
796
+ entry.visited_at = aware_evening.astimezone(datetime_timezone.utc)
797
+ entry.save(update_fields=["visited_at"])
798
+
799
+ url = reverse("admin:pages_viewhistory_traffic_data")
800
+ with patch("pages.admin.timezone.localdate", return_value=target_date):
801
+ resp = self.client.get(url)
802
+ self.assertEqual(resp.status_code, 200)
803
+ data = resp.json()
804
+ totals = {
805
+ dataset["label"]: sum(dataset["data"]) for dataset in data["datasets"]
806
+ }
807
+ self.assertEqual(totals.get("/late/"), 1)
808
+
809
+ def test_graph_data_filters_using_datetime_range(self):
810
+ admin_view = ViewHistoryAdmin(ViewHistory, admin.site)
811
+ with patch.object(ViewHistory.objects, "filter") as mock_filter:
812
+ mock_queryset = mock_filter.return_value
813
+ mock_queryset.exists.return_value = False
814
+ admin_view._build_chart_data()
815
+
816
+ kwargs = mock_filter.call_args.kwargs
817
+ self.assertIn("visited_at__gte", kwargs)
818
+ self.assertIn("visited_at__lt", kwargs)
819
+
749
820
  def test_admin_index_displays_widget(self):
750
821
  resp = self.client.get(reverse("admin:index"))
751
822
  self.assertContains(resp, "viewhistory-mini-module")
@@ -1339,6 +1410,29 @@ class FaviconTests(TestCase):
1339
1410
  )
1340
1411
  self.assertContains(resp, b64)
1341
1412
 
1413
+ def test_control_nodes_use_purple_favicon(self):
1414
+ with override_settings(MEDIA_ROOT=self.tmpdir):
1415
+ role, _ = NodeRole.objects.get_or_create(name="Control")
1416
+ Node.objects.update_or_create(
1417
+ mac_address=Node.get_current_mac(),
1418
+ defaults={
1419
+ "hostname": "localhost",
1420
+ "address": "127.0.0.1",
1421
+ "role": role,
1422
+ },
1423
+ )
1424
+ Site.objects.update_or_create(
1425
+ id=1, defaults={"domain": "testserver", "name": ""}
1426
+ )
1427
+ resp = self.client.get(reverse("pages:index"))
1428
+ b64 = (
1429
+ Path(settings.BASE_DIR)
1430
+ .joinpath("pages", "fixtures", "data", "favicon_control.txt")
1431
+ .read_text()
1432
+ .strip()
1433
+ )
1434
+ self.assertContains(resp, b64)
1435
+
1342
1436
 
1343
1437
  class FavoriteTests(TestCase):
1344
1438
  def setUp(self):
@@ -1727,6 +1821,170 @@ class DatasetteTests(TestCase):
1727
1821
  lock_file.unlink(missing_ok=True)
1728
1822
 
1729
1823
 
1824
+ class UserStorySubmissionTests(TestCase):
1825
+ def setUp(self):
1826
+ self.client = Client()
1827
+ self.url = reverse("pages:user-story-submit")
1828
+ User = get_user_model()
1829
+ self.user = User.objects.create_user(username="feedbacker", password="pwd")
1830
+
1831
+ def test_authenticated_submission_defaults_to_username(self):
1832
+ self.client.force_login(self.user)
1833
+ response = self.client.post(
1834
+ self.url,
1835
+ {
1836
+ "rating": 5,
1837
+ "comments": "Loved the experience!",
1838
+ "path": "/wizard/step-1/",
1839
+ "take_screenshot": "1",
1840
+ },
1841
+ )
1842
+ self.assertEqual(response.status_code, 200)
1843
+ self.assertEqual(response.json(), {"success": True})
1844
+ story = UserStory.objects.get()
1845
+ self.assertEqual(story.name, "feedbacker")
1846
+ self.assertEqual(story.rating, 5)
1847
+ self.assertEqual(story.path, "/wizard/step-1/")
1848
+ self.assertEqual(story.user, self.user)
1849
+ self.assertEqual(story.owner, self.user)
1850
+ self.assertTrue(story.is_user_data)
1851
+ self.assertTrue(story.take_screenshot)
1852
+
1853
+ def test_anonymous_submission_uses_provided_name(self):
1854
+ response = self.client.post(
1855
+ self.url,
1856
+ {
1857
+ "name": "Guest Reviewer",
1858
+ "rating": 3,
1859
+ "comments": "It was fine.",
1860
+ "path": "/status/",
1861
+ "take_screenshot": "on",
1862
+ },
1863
+ )
1864
+ self.assertEqual(response.status_code, 200)
1865
+ self.assertEqual(UserStory.objects.count(), 1)
1866
+ story = UserStory.objects.get()
1867
+ self.assertEqual(story.name, "Guest Reviewer")
1868
+ self.assertIsNone(story.user)
1869
+ self.assertIsNone(story.owner)
1870
+ self.assertEqual(story.comments, "It was fine.")
1871
+ self.assertTrue(story.take_screenshot)
1872
+
1873
+ def test_invalid_rating_returns_errors(self):
1874
+ response = self.client.post(
1875
+ self.url,
1876
+ {
1877
+ "rating": 7,
1878
+ "comments": "Way off the scale",
1879
+ "path": "/feedback/",
1880
+ "take_screenshot": "1",
1881
+ },
1882
+ )
1883
+ self.assertEqual(response.status_code, 400)
1884
+ data = response.json()
1885
+ self.assertFalse(UserStory.objects.exists())
1886
+ self.assertIn("rating", data.get("errors", {}))
1887
+
1888
+ def test_anonymous_submission_without_name_uses_fallback(self):
1889
+ response = self.client.post(
1890
+ self.url,
1891
+ {
1892
+ "rating": 2,
1893
+ "comments": "Could be better.",
1894
+ "path": "/feedback/",
1895
+ "take_screenshot": "1",
1896
+ },
1897
+ )
1898
+ self.assertEqual(response.status_code, 200)
1899
+ story = UserStory.objects.get()
1900
+ self.assertEqual(story.name, "Anonymous")
1901
+ self.assertIsNone(story.user)
1902
+ self.assertIsNone(story.owner)
1903
+ self.assertTrue(story.take_screenshot)
1904
+
1905
+ def test_submission_without_screenshot_request(self):
1906
+ response = self.client.post(
1907
+ self.url,
1908
+ {
1909
+ "rating": 4,
1910
+ "comments": "Skip the screenshot, please.",
1911
+ "path": "/feedback/",
1912
+ },
1913
+ )
1914
+ self.assertEqual(response.status_code, 200)
1915
+ story = UserStory.objects.get()
1916
+ self.assertFalse(story.take_screenshot)
1917
+ self.assertIsNone(story.owner)
1918
+
1919
+
1920
+ class UserStoryAdminActionTests(TestCase):
1921
+ def setUp(self):
1922
+ self.client = Client()
1923
+ self.factory = RequestFactory()
1924
+ User = get_user_model()
1925
+ self.admin_user = User.objects.create_superuser(
1926
+ username="admin",
1927
+ email="admin@example.com",
1928
+ password="pwd",
1929
+ )
1930
+ self.story = UserStory.objects.create(
1931
+ path="/",
1932
+ name="Feedback",
1933
+ rating=4,
1934
+ comments="Helpful notes",
1935
+ take_screenshot=True,
1936
+ )
1937
+ self.admin = UserStoryAdmin(UserStory, admin.site)
1938
+
1939
+ def _build_request(self):
1940
+ request = self.factory.post("/admin/pages/userstory/")
1941
+ request.user = self.admin_user
1942
+ request.session = self.client.session
1943
+ setattr(request, "_messages", FallbackStorage(request))
1944
+ return request
1945
+
1946
+ @patch("pages.models.github_issues.create_issue")
1947
+ def test_create_github_issues_action_updates_issue_fields(self, mock_create_issue):
1948
+ response = MagicMock()
1949
+ response.json.return_value = {
1950
+ "html_url": "https://github.com/example/repo/issues/123",
1951
+ "number": 123,
1952
+ }
1953
+ mock_create_issue.return_value = response
1954
+
1955
+ request = self._build_request()
1956
+ queryset = UserStory.objects.filter(pk=self.story.pk)
1957
+ self.admin.create_github_issues(request, queryset)
1958
+
1959
+ self.story.refresh_from_db()
1960
+ self.assertEqual(self.story.github_issue_number, 123)
1961
+ self.assertEqual(
1962
+ self.story.github_issue_url,
1963
+ "https://github.com/example/repo/issues/123",
1964
+ )
1965
+
1966
+ mock_create_issue.assert_called_once()
1967
+ args, kwargs = mock_create_issue.call_args
1968
+ self.assertIn("Feedback for", args[0])
1969
+ self.assertIn("**Rating:**", args[1])
1970
+ self.assertEqual(kwargs.get("labels"), ["feedback"])
1971
+ self.assertEqual(
1972
+ kwargs.get("fingerprint"), f"user-story:{self.story.pk}"
1973
+ )
1974
+
1975
+ @patch("pages.models.github_issues.create_issue")
1976
+ def test_create_github_issues_action_skips_existing_issue(self, mock_create_issue):
1977
+ self.story.github_issue_url = "https://github.com/example/repo/issues/5"
1978
+ self.story.github_issue_number = 5
1979
+ self.story.save(update_fields=["github_issue_url", "github_issue_number"])
1980
+
1981
+ request = self._build_request()
1982
+ queryset = UserStory.objects.filter(pk=self.story.pk)
1983
+ self.admin.create_github_issues(request, queryset)
1984
+
1985
+ mock_create_issue.assert_not_called()
1986
+
1987
+
1730
1988
  class ClientReportLiveUpdateTests(TestCase):
1731
1989
  def setUp(self):
1732
1990
  self.client = Client()
pages/urls.py CHANGED
@@ -21,4 +21,5 @@ urlpatterns = [
21
21
  path("man/", views.manual_list, name="manual-list"),
22
22
  path("man/<slug:slug>/", views.manual_detail, name="manual-detail"),
23
23
  path("man/<slug:slug>/pdf/", views.manual_pdf, name="manual-pdf"),
24
+ path("feedback/user-story/", views.submit_user_story, name="user-story-submit"),
24
25
  ]
pages/views.py CHANGED
@@ -8,6 +8,7 @@ import io
8
8
  import shutil
9
9
  import re
10
10
  from html import escape
11
+ from urllib.parse import urlparse
11
12
 
12
13
  from django.conf import settings
13
14
  from django.contrib import admin
@@ -19,7 +20,7 @@ from django.contrib.auth.views import LoginView
19
20
  from django import forms
20
21
  from django.apps import apps as django_apps
21
22
  from utils.sites import get_site
22
- from django.http import Http404, HttpResponse
23
+ from django.http import Http404, HttpResponse, JsonResponse
23
24
  from django.shortcuts import get_object_or_404, redirect, render
24
25
  from nodes.models import Node
25
26
  from django.template.response import TemplateResponse
@@ -32,6 +33,7 @@ from core import mailer, public_wifi
32
33
  from core.backends import TOTP_DEVICE_NAME
33
34
  from django.utils.translation import gettext as _
34
35
  from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
36
+ from django.views.decorators.http import require_POST
35
37
  from django.core.cache import cache
36
38
  from django.views.decorators.cache import never_cache
37
39
  from django.utils.cache import patch_vary_headers
@@ -78,8 +80,12 @@ from core.liveupdate import live_update
78
80
  from django_otp import login as otp_login
79
81
  from django_otp.plugins.otp_totp.models import TOTPDevice
80
82
  import qrcode
81
- from .forms import AuthenticatorEnrollmentForm, AuthenticatorLoginForm
82
- from .models import Module, UserManual
83
+ from .forms import (
84
+ AuthenticatorEnrollmentForm,
85
+ AuthenticatorLoginForm,
86
+ UserStoryForm,
87
+ )
88
+ from .models import Module, UserManual, UserStory
83
89
 
84
90
 
85
91
  logger = logging.getLogger(__name__)
@@ -494,12 +500,26 @@ class CustomLoginView(LoginView):
494
500
  def get_context_data(self, **kwargs):
495
501
  context = super(LoginView, self).get_context_data(**kwargs)
496
502
  current_site = get_site(self.request)
503
+ redirect_target = self.request.GET.get(self.redirect_field_name)
504
+ restricted_notice = None
505
+ if redirect_target:
506
+ parsed_target = urlparse(redirect_target)
507
+ target_path = parsed_target.path or redirect_target
508
+ try:
509
+ simulator_path = reverse("cp-simulator")
510
+ except NoReverseMatch: # pragma: no cover - simulator may be uninstalled
511
+ simulator_path = None
512
+ if simulator_path and target_path.startswith(simulator_path):
513
+ restricted_notice = _(
514
+ "This page is reserved for members only. Please log in to continue."
515
+ )
497
516
  context.update(
498
517
  {
499
518
  "site": current_site,
500
519
  "site_name": getattr(current_site, "name", ""),
501
520
  "next": self.get_success_url(),
502
521
  "can_request_invite": mailer.can_send_email(),
522
+ "restricted_notice": restricted_notice,
503
523
  }
504
524
  )
505
525
  return context
@@ -1015,6 +1035,35 @@ def client_report(request):
1015
1035
  return render(request, "pages/client_report.html", context)
1016
1036
 
1017
1037
 
1038
+ @require_POST
1039
+ def submit_user_story(request):
1040
+ data = request.POST.copy()
1041
+ if request.user.is_authenticated and not data.get("name"):
1042
+ data["name"] = request.user.get_username()[:40]
1043
+ if not data.get("path"):
1044
+ data["path"] = request.get_full_path()
1045
+
1046
+ form = UserStoryForm(data)
1047
+ if request.user.is_authenticated:
1048
+ form.instance.user = request.user
1049
+
1050
+ if form.is_valid():
1051
+ story = form.save(commit=False)
1052
+ if request.user.is_authenticated:
1053
+ story.user = request.user
1054
+ story.owner = request.user
1055
+ if not story.name:
1056
+ story.name = request.user.get_username()[:40]
1057
+ if not story.name:
1058
+ story.name = str(_("Anonymous"))[:40]
1059
+ story.path = (story.path or request.get_full_path())[:500]
1060
+ story.is_user_data = True
1061
+ story.save()
1062
+ return JsonResponse({"success": True})
1063
+
1064
+ return JsonResponse({"success": False, "errors": form.errors}, status=400)
1065
+
1066
+
1018
1067
  def csrf_failure(request, reason=""):
1019
1068
  """Custom CSRF failure view with a friendly message."""
1020
1069
  logger.warning("CSRF failure on %s: %s", request.path, reason)