arthexis 0.1.10__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.

Files changed (54) hide show
  1. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
  2. arthexis-0.1.12.dist-info/RECORD +102 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +31 -5
  5. config/urls.py +5 -4
  6. core/admin.py +430 -90
  7. core/apps.py +48 -2
  8. core/backends.py +38 -0
  9. core/environment.py +23 -5
  10. core/mailer.py +3 -1
  11. core/models.py +303 -31
  12. core/reference_utils.py +20 -9
  13. core/release.py +4 -0
  14. core/sigil_builder.py +7 -2
  15. core/sigil_resolver.py +35 -4
  16. core/system.py +250 -1
  17. core/tasks.py +92 -40
  18. core/temp_passwords.py +181 -0
  19. core/test_system_info.py +62 -2
  20. core/tests.py +169 -3
  21. core/user_data.py +51 -8
  22. core/views.py +371 -20
  23. nodes/admin.py +453 -8
  24. nodes/backends.py +21 -6
  25. nodes/dns.py +203 -0
  26. nodes/feature_checks.py +133 -0
  27. nodes/models.py +374 -31
  28. nodes/reports.py +411 -0
  29. nodes/tests.py +677 -38
  30. nodes/utils.py +32 -0
  31. nodes/views.py +14 -0
  32. ocpp/admin.py +278 -15
  33. ocpp/consumers.py +517 -16
  34. ocpp/evcs_discovery.py +158 -0
  35. ocpp/models.py +237 -4
  36. ocpp/reference_utils.py +42 -0
  37. ocpp/simulator.py +321 -22
  38. ocpp/store.py +110 -2
  39. ocpp/test_rfid.py +169 -7
  40. ocpp/tests.py +819 -6
  41. ocpp/transactions_io.py +17 -3
  42. ocpp/views.py +233 -19
  43. pages/admin.py +144 -4
  44. pages/context_processors.py +21 -7
  45. pages/defaults.py +13 -0
  46. pages/forms.py +38 -0
  47. pages/models.py +189 -15
  48. pages/tests.py +281 -8
  49. pages/urls.py +4 -0
  50. pages/views.py +137 -21
  51. arthexis-0.1.10.dist-info/RECORD +0 -95
  52. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
  53. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
  54. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
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")
@@ -1042,9 +1113,9 @@ class PowerNavTests(TestCase):
1042
1113
  node_role=role, application=awg_app, path="/awg/"
1043
1114
  )
1044
1115
  awg_module.create_landings()
1045
- man_app, _ = Application.objects.get_or_create(name="man")
1116
+ manuals_app, _ = Application.objects.get_or_create(name="pages")
1046
1117
  man_module, _ = Module.objects.get_or_create(
1047
- node_role=role, application=man_app, path="/man/"
1118
+ node_role=role, application=manuals_app, path="/man/"
1048
1119
  )
1049
1120
  man_module.create_landings()
1050
1121
  User = get_user_model()
@@ -1070,7 +1141,7 @@ class PowerNavTests(TestCase):
1070
1141
  manuals_module = module
1071
1142
  break
1072
1143
  self.assertIsNotNone(manuals_module)
1073
- self.assertEqual(manuals_module.menu_label.upper(), "MANUALS")
1144
+ self.assertEqual(manuals_module.menu_label.upper(), "MANUAL")
1074
1145
  landing_labels = {landing.label for landing in manuals_module.enabled_landings}
1075
1146
  self.assertIn("Manuals", landing_labels)
1076
1147
 
@@ -1163,6 +1234,13 @@ class ApplicationAdminDisplayTests(TestCase):
1163
1234
  config = django_apps.get_app_config("ocpp")
1164
1235
  self.assertContains(resp, config.verbose_name)
1165
1236
 
1237
+ def test_changelist_shows_description(self):
1238
+ Application.objects.create(
1239
+ name="awg", description="Power, Energy and Cost calculations."
1240
+ )
1241
+ resp = self.client.get(reverse("admin:pages_application_changelist"))
1242
+ self.assertContains(resp, "Power, Energy and Cost calculations.")
1243
+
1166
1244
 
1167
1245
  class LandingCreationTests(TestCase):
1168
1246
  def setUp(self):
@@ -1231,8 +1309,16 @@ class RFIDPageTests(TestCase):
1231
1309
  Site.objects.update_or_create(
1232
1310
  id=1, defaults={"domain": "testserver", "name": "pages"}
1233
1311
  )
1312
+ User = get_user_model()
1313
+ self.user = User.objects.create_user("rfid-user", password="pwd")
1234
1314
 
1235
- def test_page_renders(self):
1315
+ def test_page_redirects_when_anonymous(self):
1316
+ resp = self.client.get(reverse("rfid-reader"))
1317
+ self.assertEqual(resp.status_code, 302)
1318
+ self.assertIn(reverse("pages:login"), resp.url)
1319
+
1320
+ def test_page_renders_for_authenticated_user(self):
1321
+ self.client.force_login(self.user)
1236
1322
  resp = self.client.get(reverse("rfid-reader"))
1237
1323
  self.assertContains(resp, "Scanner ready")
1238
1324
 
@@ -1324,6 +1410,29 @@ class FaviconTests(TestCase):
1324
1410
  )
1325
1411
  self.assertContains(resp, b64)
1326
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
+
1327
1436
 
1328
1437
  class FavoriteTests(TestCase):
1329
1438
  def setUp(self):
@@ -1712,6 +1821,170 @@ class DatasetteTests(TestCase):
1712
1821
  lock_file.unlink(missing_ok=True)
1713
1822
 
1714
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
+
1715
1988
  class ClientReportLiveUpdateTests(TestCase):
1716
1989
  def setUp(self):
1717
1990
  self.client = Client()
pages/urls.py CHANGED
@@ -18,4 +18,8 @@ urlpatterns = [
18
18
  name="invitation-login",
19
19
  ),
20
20
  path("datasette-auth/", views.datasette_auth, name="datasette-auth"),
21
+ path("man/", views.manual_list, name="manual-list"),
22
+ path("man/<slug:slug>/", views.manual_detail, name="manual-detail"),
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"),
21
25
  ]
pages/views.py CHANGED
@@ -1,12 +1,14 @@
1
1
  import base64
2
2
  import logging
3
3
  from pathlib import Path
4
+ from types import SimpleNamespace
4
5
  import datetime
5
6
  import calendar
6
7
  import io
7
8
  import shutil
8
9
  import re
9
10
  from html import escape
11
+ from urllib.parse import urlparse
10
12
 
11
13
  from django.conf import settings
12
14
  from django.contrib import admin
@@ -18,10 +20,11 @@ from django.contrib.auth.views import LoginView
18
20
  from django import forms
19
21
  from django.apps import apps as django_apps
20
22
  from utils.sites import get_site
21
- from django.http import Http404, HttpResponse
22
- from django.shortcuts import redirect, render
23
+ from django.http import Http404, HttpResponse, JsonResponse
24
+ from django.shortcuts import get_object_or_404, redirect, render
23
25
  from nodes.models import Node
24
26
  from django.template.response import TemplateResponse
27
+ from django.test import RequestFactory
25
28
  from django.urls import NoReverseMatch, reverse
26
29
  from django.utils import timezone
27
30
  from django.utils.encoding import force_bytes, force_str
@@ -30,6 +33,7 @@ from core import mailer, public_wifi
30
33
  from core.backends import TOTP_DEVICE_NAME
31
34
  from django.utils.translation import gettext as _
32
35
  from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
36
+ from django.views.decorators.http import require_POST
33
37
  from django.core.cache import cache
34
38
  from django.views.decorators.cache import never_cache
35
39
  from django.utils.cache import patch_vary_headers
@@ -47,13 +51,41 @@ except ImportError: # pragma: no cover - handled gracefully in views
47
51
  CalledProcessError = ExecutableNotFound = None
48
52
 
49
53
  import markdown
54
+
55
+
56
+ MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
57
+
58
+
59
+ def _render_markdown_with_toc(text: str) -> tuple[str, str]:
60
+ """Render ``text`` to HTML and return the HTML and stripped TOC."""
61
+
62
+ md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
63
+ html = md.convert(text)
64
+ toc_html = md.toc
65
+ toc_html = _strip_toc_wrapper(toc_html)
66
+ return html, toc_html
67
+
68
+
69
+ def _strip_toc_wrapper(toc_html: str) -> str:
70
+ """Normalize ``markdown``'s TOC output by removing the wrapper ``div``."""
71
+
72
+ toc_html = toc_html.strip()
73
+ if toc_html.startswith('<div class="toc">'):
74
+ toc_html = toc_html[len('<div class="toc">') :]
75
+ if toc_html.endswith("</div>"):
76
+ toc_html = toc_html[: -len("</div>")]
77
+ return toc_html.strip()
50
78
  from pages.utils import landing
51
79
  from core.liveupdate import live_update
52
80
  from django_otp import login as otp_login
53
81
  from django_otp.plugins.otp_totp.models import TOTPDevice
54
82
  import qrcode
55
- from .forms import AuthenticatorEnrollmentForm, AuthenticatorLoginForm
56
- from .models import Module
83
+ from .forms import (
84
+ AuthenticatorEnrollmentForm,
85
+ AuthenticatorLoginForm,
86
+ UserStoryForm,
87
+ )
88
+ from .models import Module, UserManual, UserStory
57
89
 
58
90
 
59
91
  logger = logging.getLogger(__name__)
@@ -407,14 +439,7 @@ def index(request):
407
439
  candidates.append(root_base / "README.md")
408
440
  readme_file = next((p for p in candidates if p.exists()), root_base / "README.md")
409
441
  text = readme_file.read_text(encoding="utf-8")
410
- md = markdown.Markdown(extensions=["toc", "tables"])
411
- html = md.convert(text)
412
- toc_html = md.toc
413
- if toc_html.strip().startswith('<div class="toc">'):
414
- toc_html = toc_html.strip()[len('<div class="toc">') :]
415
- if toc_html.endswith("</div>"):
416
- toc_html = toc_html[: -len("</div>")]
417
- toc_html = toc_html.strip()
442
+ html, toc_html = _render_markdown_with_toc(text)
418
443
  title = "README" if readme_file.name.startswith("README") else readme_file.stem
419
444
  context = {"content": html, "title": title, "toc": toc_html}
420
445
  response = render(request, "pages/readme.html", context)
@@ -447,14 +472,7 @@ def release_checklist(request):
447
472
  if not file_path.exists():
448
473
  raise Http404("Release checklist not found")
449
474
  text = file_path.read_text(encoding="utf-8")
450
- md = markdown.Markdown(extensions=["toc", "tables"])
451
- html = md.convert(text)
452
- toc_html = md.toc
453
- if toc_html.strip().startswith('<div class="toc">'):
454
- toc_html = toc_html.strip()[len('<div class="toc">') :]
455
- if toc_html.endswith("</div>"):
456
- toc_html = toc_html[: -len("</div>")]
457
- toc_html = toc_html.strip()
475
+ html, toc_html = _render_markdown_with_toc(text)
458
476
  context = {"content": html, "title": "Release Checklist", "toc": toc_html}
459
477
  response = render(request, "pages/readme.html", context)
460
478
  patch_vary_headers(response, ["Accept-Language", "Cookie"])
@@ -482,12 +500,26 @@ class CustomLoginView(LoginView):
482
500
  def get_context_data(self, **kwargs):
483
501
  context = super(LoginView, self).get_context_data(**kwargs)
484
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
+ )
485
516
  context.update(
486
517
  {
487
518
  "site": current_site,
488
519
  "site_name": getattr(current_site, "name", ""),
489
520
  "next": self.get_success_url(),
490
521
  "can_request_invite": mailer.can_send_email(),
522
+ "restricted_notice": restricted_notice,
491
523
  }
492
524
  )
493
525
  return context
@@ -709,11 +741,14 @@ def request_invite(request):
709
741
  try:
710
742
  node_error = None
711
743
  node = Node.get_local()
744
+ outbox = getattr(node, "email_outbox", None) if node else None
712
745
  if node:
713
746
  try:
714
747
  result = node.send_mail(subject, body, [email])
748
+ lead.sent_via_outbox = outbox
715
749
  except Exception as exc:
716
750
  node_error = exc
751
+ lead.sent_via_outbox = None
717
752
  logger.exception(
718
753
  "Node send_mail failed, falling back to default backend"
719
754
  )
@@ -724,6 +759,7 @@ def request_invite(request):
724
759
  result = mailer.send(
725
760
  subject, body, [email], settings.DEFAULT_FROM_EMAIL
726
761
  )
762
+ lead.sent_via_outbox = None
727
763
  lead.sent_on = timezone.now()
728
764
  if node_error:
729
765
  lead.error = (
@@ -738,9 +774,10 @@ def request_invite(request):
738
774
  )
739
775
  except Exception as exc:
740
776
  lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
777
+ lead.sent_via_outbox = None
741
778
  logger.exception("Failed to send invitation email to %s", email)
742
779
  if lead.sent_on or lead.error:
743
- lead.save(update_fields=["sent_on", "error"])
780
+ lead.save(update_fields=["sent_on", "error", "sent_via_outbox"])
744
781
  sent = True
745
782
  return render(request, "pages/request_invite.html", {"form": form, "sent": sent})
746
783
 
@@ -998,7 +1035,86 @@ def client_report(request):
998
1035
  return render(request, "pages/client_report.html", context)
999
1036
 
1000
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
+
1001
1067
  def csrf_failure(request, reason=""):
1002
1068
  """Custom CSRF failure view with a friendly message."""
1003
1069
  logger.warning("CSRF failure on %s: %s", request.path, reason)
1004
1070
  return render(request, "pages/csrf_failure.html", status=403)
1071
+
1072
+
1073
+ def _admin_context(request):
1074
+ context = admin.site.each_context(request)
1075
+ if not context.get("has_permission"):
1076
+ rf = RequestFactory()
1077
+ mock_request = rf.get(request.path)
1078
+ mock_request.user = SimpleNamespace(
1079
+ is_active=True,
1080
+ is_staff=True,
1081
+ is_superuser=True,
1082
+ has_perm=lambda perm, obj=None: True,
1083
+ has_module_perms=lambda app_label: True,
1084
+ )
1085
+ context["available_apps"] = admin.site.get_app_list(mock_request)
1086
+ context["has_permission"] = True
1087
+ return context
1088
+
1089
+
1090
+ def admin_manual_list(request):
1091
+ manuals = UserManual.objects.order_by("title")
1092
+ context = _admin_context(request)
1093
+ context["manuals"] = manuals
1094
+ return render(request, "admin_doc/manuals.html", context)
1095
+
1096
+
1097
+ def admin_manual_detail(request, slug):
1098
+ manual = get_object_or_404(UserManual, slug=slug)
1099
+ context = _admin_context(request)
1100
+ context["manual"] = manual
1101
+ return render(request, "admin_doc/manual_detail.html", context)
1102
+
1103
+
1104
+ def manual_pdf(request, slug):
1105
+ manual = get_object_or_404(UserManual, slug=slug)
1106
+ pdf_data = base64.b64decode(manual.content_pdf)
1107
+ response = HttpResponse(pdf_data, content_type="application/pdf")
1108
+ response["Content-Disposition"] = f'attachment; filename="{manual.slug}.pdf"'
1109
+ return response
1110
+
1111
+
1112
+ @landing(_("Manuals"))
1113
+ def manual_list(request):
1114
+ manuals = UserManual.objects.order_by("title")
1115
+ return render(request, "pages/manual_list.html", {"manuals": manuals})
1116
+
1117
+
1118
+ def manual_detail(request, slug):
1119
+ manual = get_object_or_404(UserManual, slug=slug)
1120
+ return render(request, "pages/manual_detail.html", {"manual": manual})