arthexis 0.1.17__py3-none-any.whl → 0.1.19__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
@@ -8,18 +8,19 @@ django.setup()
8
8
 
9
9
  from django.test import Client, RequestFactory, TestCase, SimpleTestCase, override_settings
10
10
  from django.test.utils import CaptureQueriesContext
11
- from django.urls import NoReverseMatch, reverse
11
+ from django.urls import reverse
12
12
  from django.templatetags.static import static
13
13
  from urllib.parse import quote
14
14
  from django.contrib.auth import get_user_model
15
15
  from django.contrib.sites.models import Site
16
- from django.contrib import admin
16
+ from django.contrib import admin, messages
17
17
  from django.contrib.messages.storage.fallback import FallbackStorage
18
18
  from django.core.exceptions import DisallowedHost
19
19
  from django.core.cache import cache
20
20
  from django.db import connection
21
21
  import socket
22
22
  from django.db import connection
23
+ from pages import site_config
23
24
  from pages.models import (
24
25
  Application,
25
26
  Landing,
@@ -32,6 +33,8 @@ from pages.models import (
32
33
  UserManual,
33
34
  UserStory,
34
35
  )
36
+ from django.http import FileResponse
37
+
35
38
  from pages.admin import (
36
39
  ApplicationAdmin,
37
40
  UserManualAdmin,
@@ -47,6 +50,7 @@ from pages.screenshot_specs import (
47
50
  )
48
51
  from pages.context_processors import nav_links
49
52
  from django.apps import apps as django_apps
53
+ from config.middleware import SiteHttpsRedirectMiddleware
50
54
  from core import mailer
51
55
  from core.admin import ProfileAdminMixin
52
56
  from core.models import (
@@ -60,8 +64,10 @@ from core.models import (
60
64
  Todo,
61
65
  TOTPDeviceSettings,
62
66
  )
67
+ from ocpp.models import Charger
63
68
  from django.core.files.uploadedfile import SimpleUploadedFile
64
69
  import base64
70
+ import json
65
71
  import tempfile
66
72
  import shutil
67
73
  from io import StringIO
@@ -72,6 +78,7 @@ from types import SimpleNamespace
72
78
  from django.core.management import call_command
73
79
  import re
74
80
  from django.contrib.contenttypes.models import ContentType
81
+ from django.http import HttpResponse
75
82
  from datetime import (
76
83
  date,
77
84
  datetime,
@@ -685,23 +692,6 @@ class AdminDashboardAppListTests(TestCase):
685
692
  resp = self.client.get(reverse("admin:index"))
686
693
  self.assertContains(resp, "5. Horologia MODELS")
687
694
 
688
- def test_dashboard_handles_missing_last_net_message_url(self):
689
- from pages.templatetags import admin_extras
690
-
691
- real_reverse = admin_extras.reverse
692
-
693
- def fake_reverse(name, *args, **kwargs):
694
- if name == "last-net-message":
695
- raise NoReverseMatch("missing")
696
- return real_reverse(name, *args, **kwargs)
697
-
698
- with patch("pages.templatetags.admin_extras.reverse", side_effect=fake_reverse):
699
- resp = self.client.get(reverse("admin:index"))
700
-
701
- self.assertEqual(resp.status_code, 200)
702
- self.assertNotIn(b"last-net-message", resp.content)
703
-
704
-
705
695
  class AdminSidebarTests(TestCase):
706
696
  def setUp(self):
707
697
  self.client = Client()
@@ -811,7 +801,8 @@ class ViewHistoryLoggingTests(TestCase):
811
801
  )
812
802
  landing = module.landings.get(path="/")
813
803
  landing.label = "Home Landing"
814
- landing.save(update_fields=["label"])
804
+ landing.track_leads = True
805
+ landing.save(update_fields=["label", "track_leads"])
815
806
 
816
807
  resp = self.client.get(
817
808
  reverse("pages:index"), HTTP_REFERER="https://example.com/ref"
@@ -836,7 +827,8 @@ class ViewHistoryLoggingTests(TestCase):
836
827
  )
837
828
  landing = module.landings.get(path="/")
838
829
  landing.label = "No Celery"
839
- landing.save(update_fields=["label"])
830
+ landing.track_leads = True
831
+ landing.save(update_fields=["label", "track_leads"])
840
832
 
841
833
  resp = self.client.get(reverse("pages:index"))
842
834
 
@@ -856,7 +848,8 @@ class ViewHistoryLoggingTests(TestCase):
856
848
  )
857
849
  landing = module.landings.get(path="/")
858
850
  landing.enabled = False
859
- landing.save(update_fields=["enabled"])
851
+ landing.track_leads = True
852
+ landing.save(update_fields=["enabled", "track_leads"])
860
853
 
861
854
  resp = self.client.get(reverse("pages:index"))
862
855
 
@@ -1125,6 +1118,50 @@ class LogViewerAdminTests(SimpleTestCase):
1125
1118
  self.assertEqual(context["selected_log"], "selected.log")
1126
1119
  self.assertIn("hello world", context["log_content"])
1127
1120
 
1121
+ def test_log_viewer_applies_line_limit(self):
1122
+ content = "\n".join(f"line {i}" for i in range(50))
1123
+ self._create_log("limited.log", content)
1124
+ response = self._render({"log": "limited.log", "limit": "20"})
1125
+ context = response.context_data
1126
+ self.assertEqual(context["log_limit_choice"], "20")
1127
+ self.assertIn("line 49", context["log_content"])
1128
+ self.assertIn("line 30", context["log_content"])
1129
+ self.assertNotIn("line 29", context["log_content"])
1130
+
1131
+ def test_log_viewer_all_limit_returns_full_log(self):
1132
+ content = "first\nsecond\nthird"
1133
+ self._create_log("all.log", content)
1134
+ response = self._render({"log": "all.log", "limit": "all"})
1135
+ context = response.context_data
1136
+ self.assertEqual(context["log_limit_choice"], "all")
1137
+ self.assertIn("first", context["log_content"])
1138
+ self.assertIn("second", context["log_content"])
1139
+
1140
+ def test_log_viewer_invalid_limit_defaults_to_20(self):
1141
+ content = "\n".join(f"item {i}" for i in range(5))
1142
+ self._create_log("invalid-limit.log", content)
1143
+ response = self._render({"log": "invalid-limit.log", "limit": "oops"})
1144
+ context = response.context_data
1145
+ self.assertEqual(context["log_limit_choice"], "20")
1146
+
1147
+ def test_log_viewer_downloads_selected_log(self):
1148
+ self._create_log("download.log", "downloadable content")
1149
+ request = self._build_request({"log": "download.log", "download": "1"})
1150
+ context = {
1151
+ "site_title": "Constellation",
1152
+ "site_header": "Constellation",
1153
+ "site_url": "/",
1154
+ "available_apps": [],
1155
+ }
1156
+ with patch("pages.admin.admin.site.each_context", return_value=context), patch(
1157
+ "pages.context_processors.get_site", return_value=None
1158
+ ):
1159
+ response = log_viewer(request)
1160
+ self.assertIsInstance(response, FileResponse)
1161
+ self.assertIn("attachment", response["Content-Disposition"])
1162
+ content = b"".join(response.streaming_content).decode()
1163
+ self.assertIn("downloadable content", content)
1164
+
1128
1165
  def test_log_viewer_reports_missing_log(self):
1129
1166
  response = self._render({"log": "missing.log"})
1130
1167
  self.assertIn("requested log could not be found", response.context_data["log_error"])
@@ -1172,6 +1209,125 @@ class AdminModelStatusTests(TestCase):
1172
1209
  self.assertContains(resp, 'class="model-status missing"', count=1)
1173
1210
 
1174
1211
 
1212
+ class _FakeQuerySet(list):
1213
+ def only(self, *args, **kwargs):
1214
+ return self
1215
+
1216
+ def order_by(self, *args, **kwargs):
1217
+ return self
1218
+
1219
+
1220
+ class SiteConfigurationStagingTests(SimpleTestCase):
1221
+ def setUp(self):
1222
+ self.tmpdir = tempfile.mkdtemp()
1223
+ self.addCleanup(shutil.rmtree, self.tmpdir)
1224
+ self.config_path = Path(self.tmpdir) / "nginx-sites.json"
1225
+ self._path_patcher = patch(
1226
+ "pages.site_config._sites_config_path", side_effect=lambda: self.config_path
1227
+ )
1228
+ self._path_patcher.start()
1229
+ self.addCleanup(self._path_patcher.stop)
1230
+ self._model_patcher = patch("pages.site_config.apps.get_model")
1231
+ self.mock_get_model = self._model_patcher.start()
1232
+ self.addCleanup(self._model_patcher.stop)
1233
+
1234
+ def _read_config(self):
1235
+ if not self.config_path.exists():
1236
+ return None
1237
+ return json.loads(self.config_path.read_text(encoding="utf-8"))
1238
+
1239
+ def _set_sites(self, sites):
1240
+ queryset = _FakeQuerySet(sites)
1241
+
1242
+ class _Manager:
1243
+ @staticmethod
1244
+ def filter(**kwargs):
1245
+ return queryset
1246
+
1247
+ self.mock_get_model.return_value = SimpleNamespace(objects=_Manager())
1248
+
1249
+ def test_managed_site_persists_configuration(self):
1250
+ self._set_sites([SimpleNamespace(domain="example.com", require_https=True)])
1251
+ site_config.update_local_nginx_scripts()
1252
+ config = self._read_config()
1253
+ self.assertEqual(
1254
+ config,
1255
+ [
1256
+ {
1257
+ "domain": "example.com",
1258
+ "require_https": True,
1259
+ }
1260
+ ],
1261
+ )
1262
+
1263
+ def test_disabling_managed_site_removes_entry(self):
1264
+ primary = SimpleNamespace(domain="primary.test", require_https=False)
1265
+ secondary = SimpleNamespace(domain="secondary.test", require_https=False)
1266
+ self._set_sites([primary, secondary])
1267
+ site_config.update_local_nginx_scripts()
1268
+ config = self._read_config()
1269
+ self.assertEqual(
1270
+ [entry["domain"] for entry in config],
1271
+ ["primary.test", "secondary.test"],
1272
+ )
1273
+
1274
+ self._set_sites([secondary])
1275
+ site_config.update_local_nginx_scripts()
1276
+ config = self._read_config()
1277
+ self.assertEqual(config, [{"domain": "secondary.test", "require_https": False}])
1278
+
1279
+ self._set_sites([])
1280
+ site_config.update_local_nginx_scripts()
1281
+ self.assertIsNone(self._read_config())
1282
+
1283
+ def test_require_https_toggle_updates_configuration(self):
1284
+ site = SimpleNamespace(domain="secure.example", require_https=False)
1285
+ self._set_sites([site])
1286
+ site_config.update_local_nginx_scripts()
1287
+ config = self._read_config()
1288
+ self.assertEqual(config, [{"domain": "secure.example", "require_https": False}])
1289
+
1290
+ site.require_https = True
1291
+ self._set_sites([site])
1292
+ site_config.update_local_nginx_scripts()
1293
+ config = self._read_config()
1294
+ self.assertEqual(config, [{"domain": "secure.example", "require_https": True}])
1295
+
1296
+
1297
+ class SiteRequireHttpsMiddlewareTests(SimpleTestCase):
1298
+ def setUp(self):
1299
+ self.factory = RequestFactory()
1300
+ self.middleware = SiteHttpsRedirectMiddleware(lambda request: HttpResponse("ok"))
1301
+ self.secure_site = SimpleNamespace(domain="secure.test", require_https=True)
1302
+
1303
+ def test_http_request_redirects_to_https(self):
1304
+ request = self.factory.get("/", HTTP_HOST="secure.test")
1305
+ request.site = self.secure_site
1306
+ response = self.middleware(request)
1307
+ self.assertEqual(response.status_code, 301)
1308
+ self.assertTrue(response["Location"].startswith("https://secure.test"))
1309
+
1310
+ def test_secure_request_not_redirected(self):
1311
+ request = self.factory.get("/", HTTP_HOST="secure.test", secure=True)
1312
+ request.site = self.secure_site
1313
+ response = self.middleware(request)
1314
+ self.assertEqual(response.status_code, 200)
1315
+
1316
+ def test_forwarded_proto_respected(self):
1317
+ request = self.factory.get(
1318
+ "/", HTTP_HOST="secure.test", HTTP_X_FORWARDED_PROTO="https"
1319
+ )
1320
+ request.site = self.secure_site
1321
+ response = self.middleware(request)
1322
+ self.assertEqual(response.status_code, 200)
1323
+
1324
+ self.secure_site.require_https = False
1325
+ request = self.factory.get("/", HTTP_HOST="secure.test")
1326
+ request.site = self.secure_site
1327
+ response = self.middleware(request)
1328
+ self.assertEqual(response.status_code, 200)
1329
+
1330
+
1175
1331
  class SiteAdminRegisterCurrentTests(TestCase):
1176
1332
  def setUp(self):
1177
1333
  self.client = Client()
@@ -1323,17 +1479,17 @@ class NavAppsTests(TestCase):
1323
1479
  )
1324
1480
  app = Application.objects.create(name="Readme")
1325
1481
  Module.objects.create(
1326
- node_role=role, application=app, path="/", is_default=True
1482
+ node_role=role, application=app, path="/", is_default=True, menu="Cookbook"
1327
1483
  )
1328
1484
 
1329
1485
  def test_nav_pill_renders(self):
1330
1486
  resp = self.client.get(reverse("pages:index"))
1331
- self.assertContains(resp, "README")
1487
+ self.assertContains(resp, "COOKBOOK")
1332
1488
  self.assertContains(resp, "badge rounded-pill")
1333
1489
 
1334
1490
  def test_nav_pill_renders_with_port(self):
1335
1491
  resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8000")
1336
- self.assertContains(resp, "README")
1492
+ self.assertContains(resp, "COOKBOOK")
1337
1493
 
1338
1494
  def test_nav_pill_uses_menu_field(self):
1339
1495
  site_app = Module.objects.get()
@@ -1341,7 +1497,7 @@ class NavAppsTests(TestCase):
1341
1497
  site_app.save()
1342
1498
  resp = self.client.get(reverse("pages:index"))
1343
1499
  self.assertContains(resp, 'badge rounded-pill text-bg-secondary">DOCS')
1344
- self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">README')
1500
+ self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOK')
1345
1501
 
1346
1502
  def test_app_without_root_url_excluded(self):
1347
1503
  role = NodeRole.objects.get(name="Terminal")
@@ -1711,8 +1867,57 @@ class ControlNavTests(TestCase):
1711
1867
 
1712
1868
  def test_readme_pill_visible(self):
1713
1869
  resp = self.client.get(reverse("pages:readme"))
1714
- self.assertContains(resp, 'href="/readme/"')
1715
- self.assertContains(resp, 'badge rounded-pill text-bg-secondary">README')
1870
+ self.assertContains(resp, 'href="/read/"')
1871
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOK')
1872
+
1873
+ def test_cookbook_pill_has_no_dropdown(self):
1874
+ module = Module.objects.get(node_role__name="Control", path="/read/")
1875
+ Landing.objects.create(
1876
+ module=module,
1877
+ path="/man/",
1878
+ label="Manuals",
1879
+ enabled=True,
1880
+ )
1881
+
1882
+ resp = self.client.get(reverse("pages:readme"))
1883
+
1884
+ self.assertContains(
1885
+ resp,
1886
+ '<a class="nav-link" href="/read/"><span class="badge rounded-pill text-bg-secondary">COOKBOOK</span></a>',
1887
+ html=True,
1888
+ )
1889
+ self.assertNotContains(resp, 'dropdown-item" href="/man/"')
1890
+
1891
+ def test_readme_page_includes_qr_share(self):
1892
+ resp = self.client.get(reverse("pages:readme"), {"section": "intro"})
1893
+ self.assertContains(resp, 'id="reader-qr"')
1894
+ self.assertContains(
1895
+ resp,
1896
+ 'data-url="http://testserver/read/?section=intro"',
1897
+ )
1898
+ self.assertNotContains(resp, "Scan this page")
1899
+ self.assertNotContains(
1900
+ resp, 'class="small text-break text-muted mt-3 mb-0"'
1901
+ )
1902
+
1903
+ def test_readme_document_by_name(self):
1904
+ resp = self.client.get(reverse("pages:readme-document", args=["AGENTS.md"]))
1905
+ self.assertEqual(resp.status_code, 200)
1906
+ self.assertContains(resp, "Agent Guidelines")
1907
+
1908
+ def test_readme_document_by_relative_path(self):
1909
+ resp = self.client.get(
1910
+ reverse(
1911
+ "pages:readme-document",
1912
+ args=["docs/development/maintenance-roadmap.md"],
1913
+ )
1914
+ )
1915
+ self.assertEqual(resp.status_code, 200)
1916
+ self.assertContains(resp, "Maintenance Improvement Proposals")
1917
+
1918
+ def test_readme_document_rejects_traversal(self):
1919
+ resp = self.client.get("/read/../../SECRET.md")
1920
+ self.assertEqual(resp.status_code, 404)
1716
1921
 
1717
1922
 
1718
1923
  class SatelliteNavTests(TestCase):
@@ -1784,8 +1989,8 @@ class SatelliteNavTests(TestCase):
1784
1989
 
1785
1990
  def test_readme_pill_visible(self):
1786
1991
  resp = self.client.get(reverse("pages:readme"))
1787
- self.assertContains(resp, 'href="/readme/"')
1788
- self.assertContains(resp, 'badge rounded-pill text-bg-secondary">README')
1992
+ self.assertContains(resp, 'href="/read/"')
1993
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOK')
1789
1994
 
1790
1995
 
1791
1996
  class PowerNavTests(TestCase):
@@ -2064,6 +2269,47 @@ class UserManualAdminFormTests(TestCase):
2064
2269
  self.assertEqual(form.cleaned_data["content_pdf"], self.manual.content_pdf)
2065
2270
 
2066
2271
 
2272
+ class UserManualModelTests(TestCase):
2273
+ def _build_manual(self, **overrides):
2274
+ defaults = {
2275
+ "slug": "manual-model-test",
2276
+ "title": "Manual Model",
2277
+ "description": "Manual description",
2278
+ "languages": "en",
2279
+ "content_html": "<p>Manual</p>",
2280
+ "content_pdf": base64.b64encode(b"initial").decode("ascii"),
2281
+ }
2282
+ defaults.update(overrides)
2283
+ return UserManual(**defaults)
2284
+
2285
+ def test_save_encodes_uploaded_file(self):
2286
+ upload = SimpleUploadedFile("manual.pdf", b"PDF data")
2287
+ manual = self._build_manual(slug="manual-upload", content_pdf=upload)
2288
+ manual.save()
2289
+ manual.refresh_from_db()
2290
+ self.assertEqual(
2291
+ manual.content_pdf,
2292
+ base64.b64encode(b"PDF data").decode("ascii"),
2293
+ )
2294
+
2295
+ def test_save_encodes_raw_bytes(self):
2296
+ manual = self._build_manual(slug="manual-bytes", content_pdf=b"PDF raw")
2297
+ manual.save()
2298
+ manual.refresh_from_db()
2299
+ self.assertEqual(
2300
+ manual.content_pdf,
2301
+ base64.b64encode(b"PDF raw").decode("ascii"),
2302
+ )
2303
+
2304
+ def test_save_strips_data_uri_prefix(self):
2305
+ encoded = base64.b64encode(b"PDF data").decode("ascii")
2306
+ data_uri = f"data:application/pdf;base64,{encoded}"
2307
+ manual = self._build_manual(slug="manual-data-uri", content_pdf=data_uri)
2308
+ manual.save()
2309
+ manual.refresh_from_db()
2310
+ self.assertEqual(manual.content_pdf, encoded)
2311
+
2312
+
2067
2313
  class LandingCreationTests(TestCase):
2068
2314
  def setUp(self):
2069
2315
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
@@ -2422,6 +2668,48 @@ class FavoriteTests(TestCase):
2422
2668
  self.assertContains(resp, f'title="{badge_label}"')
2423
2669
  self.assertContains(resp, f'aria-label="{badge_label}"')
2424
2670
 
2671
+ def test_dashboard_shows_charge_point_availability_badge(self):
2672
+ Charger.objects.create(
2673
+ charger_id="CP-001", connector_id=1, last_status="Available"
2674
+ )
2675
+ Charger.objects.create(charger_id="CP-002", last_status="Available")
2676
+ Charger.objects.create(
2677
+ charger_id="CP-003", connector_id=1, last_status="Unavailable"
2678
+ )
2679
+
2680
+ resp = self.client.get(reverse("admin:index"))
2681
+
2682
+ expected = "1 / 2"
2683
+ badge_label = gettext(
2684
+ "%(available)s chargers reporting Available status with a CP number, out of %(total)s total Available chargers. %(missing)s Available chargers are missing a connector number."
2685
+ ) % {"available": 1, "total": 2, "missing": 1}
2686
+
2687
+ self.assertContains(resp, expected)
2688
+ self.assertContains(resp, 'class="charger-availability-badge"')
2689
+ self.assertContains(resp, f'title="{badge_label}"')
2690
+ self.assertContains(resp, f'aria-label="{badge_label}"')
2691
+
2692
+ def test_dashboard_charge_point_badge_ignores_aggregator(self):
2693
+ Charger.objects.create(charger_id="CP-AGG", last_status="Available")
2694
+ Charger.objects.create(
2695
+ charger_id="CP-AGG", connector_id=1, last_status="Available"
2696
+ )
2697
+ Charger.objects.create(
2698
+ charger_id="CP-AGG", connector_id=2, last_status="Available"
2699
+ )
2700
+
2701
+ resp = self.client.get(reverse("admin:index"))
2702
+
2703
+ expected = "2 / 2"
2704
+ badge_label = gettext(
2705
+ "%(available)s chargers reporting Available status with a CP number."
2706
+ ) % {"available": 2}
2707
+
2708
+ self.assertContains(resp, expected)
2709
+ self.assertContains(resp, 'class="charger-availability-badge"')
2710
+ self.assertContains(resp, f'title="{badge_label}"')
2711
+ self.assertContains(resp, f'aria-label="{badge_label}"')
2712
+
2425
2713
  def test_nav_sidebar_hides_dashboard_badges(self):
2426
2714
  InviteLead.objects.create(email="open@example.com")
2427
2715
  RFID.objects.create(rfid="RFID0003", released=True, allowed=True)
@@ -3159,6 +3447,40 @@ class UserStoryAdminActionTests(TestCase):
3159
3447
 
3160
3448
  mock_create_issue.assert_not_called()
3161
3449
 
3450
+ def test_create_github_issues_action_links_to_credentials_when_missing(self):
3451
+ request = self._build_request()
3452
+ queryset = UserStory.objects.filter(pk=self.story.pk)
3453
+
3454
+ mock_url = "/admin/core/releasemanager/"
3455
+ with (
3456
+ patch(
3457
+ "pages.admin.reverse", return_value=mock_url
3458
+ ) as mock_reverse,
3459
+ patch.object(
3460
+ UserStory,
3461
+ "create_github_issue",
3462
+ side_effect=RuntimeError("GitHub token is not configured"),
3463
+ ),
3464
+ ):
3465
+ self.admin.create_github_issues(request, queryset)
3466
+
3467
+ messages_list = list(request._messages)
3468
+ self.assertTrue(messages_list)
3469
+
3470
+ opts = ReleaseManager._meta
3471
+ mock_reverse.assert_called_once_with(
3472
+ f"{self.admin.admin_site.name}:{opts.app_label}_{opts.model_name}_changelist"
3473
+ )
3474
+ self.assertTrue(
3475
+ any(mock_url in message.message for message in messages_list),
3476
+ )
3477
+ self.assertTrue(
3478
+ any("Configure GitHub credentials" in message.message for message in messages_list),
3479
+ )
3480
+ self.assertTrue(
3481
+ any(message.level == messages.ERROR for message in messages_list),
3482
+ )
3483
+
3162
3484
 
3163
3485
  class ClientReportLiveUpdateTests(TestCase):
3164
3486
  def setUp(self):
pages/urls.py CHANGED
@@ -6,7 +6,8 @@ app_name = "pages"
6
6
 
7
7
  urlpatterns = [
8
8
  path("", views.index, name="index"),
9
- path("readme/", views.readme, name="readme"),
9
+ path("read/", views.readme, name="readme"),
10
+ path("read/<path:doc>", views.readme, name="readme-document"),
10
11
  path("sitemap.xml", views.sitemap, name="pages-sitemap"),
11
12
  path("release/", views.release_admin_redirect, name="release-admin"),
12
13
  path("client-report/", views.client_report, name="client-report"),
pages/views.py CHANGED
@@ -439,38 +439,80 @@ def admin_model_graph(request, app_label: str):
439
439
  return response
440
440
 
441
441
 
442
- def _render_readme(request, role):
442
+ def _render_readme(request, role, doc: str | None = None):
443
443
  app = (
444
444
  Module.objects.filter(node_role=role, is_default=True)
445
445
  .select_related("application")
446
446
  .first()
447
447
  )
448
448
  app_slug = app.path.strip("/") if app else ""
449
- readme_base = (
450
- Path(settings.BASE_DIR) / app_slug if app_slug else Path(settings.BASE_DIR)
451
- )
449
+ root_base = Path(settings.BASE_DIR).resolve()
450
+ readme_base = (root_base / app_slug).resolve() if app_slug else root_base
452
451
  lang = getattr(request, "LANGUAGE_CODE", "")
453
452
  lang = lang.replace("_", "-").lower()
454
- root_base = Path(settings.BASE_DIR)
455
453
  candidates = []
456
- if lang:
457
- candidates.append(readme_base / f"README.{lang}.md")
458
- short = lang.split("-")[0]
459
- if short != lang:
460
- candidates.append(readme_base / f"README.{short}.md")
461
- candidates.append(readme_base / "README.md")
462
- if readme_base != root_base:
454
+ if doc:
455
+ normalized = doc.strip().replace("\\", "/")
456
+ while normalized.startswith("./"):
457
+ normalized = normalized[2:]
458
+ normalized = normalized.lstrip("/")
459
+ if not normalized:
460
+ raise Http404("Document not found")
461
+ doc_path = Path(normalized)
462
+ if doc_path.is_absolute() or any(part == ".." for part in doc_path.parts):
463
+ raise Http404("Document not found")
464
+
465
+ relative_candidates: list[Path] = []
466
+
467
+ def add_candidate(path: Path) -> None:
468
+ if path not in relative_candidates:
469
+ relative_candidates.append(path)
470
+
471
+ add_candidate(doc_path)
472
+ if doc_path.suffix.lower() != ".md" or doc_path.suffix != ".md":
473
+ add_candidate(doc_path.with_suffix(".md"))
474
+ if doc_path.suffix.lower() != ".md":
475
+ add_candidate(doc_path / "README.md")
476
+
477
+ search_roots = [readme_base]
478
+ if readme_base != root_base:
479
+ search_roots.append(root_base)
480
+
481
+ for relative in relative_candidates:
482
+ for base in search_roots:
483
+ base_resolved = base.resolve()
484
+ candidate = (base_resolved / relative).resolve(strict=False)
485
+ try:
486
+ candidate.relative_to(base_resolved)
487
+ except ValueError:
488
+ continue
489
+ candidates.append(candidate)
490
+ else:
463
491
  if lang:
464
- candidates.append(root_base / f"README.{lang}.md")
492
+ candidates.append(readme_base / f"README.{lang}.md")
465
493
  short = lang.split("-")[0]
466
494
  if short != lang:
467
- candidates.append(root_base / f"README.{short}.md")
468
- candidates.append(root_base / "README.md")
469
- readme_file = next((p for p in candidates if p.exists()), root_base / "README.md")
495
+ candidates.append(readme_base / f"README.{short}.md")
496
+ candidates.append(readme_base / "README.md")
497
+ if readme_base != root_base:
498
+ if lang:
499
+ candidates.append(root_base / f"README.{lang}.md")
500
+ short = lang.split("-")[0]
501
+ if short != lang:
502
+ candidates.append(root_base / f"README.{short}.md")
503
+ candidates.append(root_base / "README.md")
504
+ readme_file = next((p for p in candidates if p.exists()), None)
505
+ if readme_file is None:
506
+ raise Http404("Document not found")
470
507
  text = readme_file.read_text(encoding="utf-8")
471
508
  html, toc_html = _render_markdown_with_toc(text)
472
509
  title = "README" if readme_file.name.startswith("README") else readme_file.stem
473
- context = {"content": html, "title": title, "toc": toc_html}
510
+ context = {
511
+ "content": html,
512
+ "title": title,
513
+ "toc": toc_html,
514
+ "page_url": request.build_absolute_uri(),
515
+ }
474
516
  response = render(request, "pages/readme.html", context)
475
517
  patch_vary_headers(response, ["Accept-Language", "Cookie"])
476
518
  return response
@@ -525,10 +567,10 @@ def index(request):
525
567
 
526
568
 
527
569
  @never_cache
528
- def readme(request):
570
+ def readme(request, doc=None):
529
571
  node = Node.get_local()
530
572
  role = node.role if node else None
531
- return _render_readme(request, role)
573
+ return _render_readme(request, role, doc)
532
574
 
533
575
 
534
576
  def sitemap(request):
@@ -976,10 +1018,10 @@ class ClientReportForm(forms.Form):
976
1018
  label=_("Email destinations"),
977
1019
  required=False,
978
1020
  widget=forms.Textarea(attrs={"rows": 2}),
979
- help_text=_("Separate addresses with commas or new lines."),
1021
+ help_text=_("Separate addresses with commas, whitespace, or new lines."),
980
1022
  )
981
1023
  recurrence = forms.ChoiceField(
982
- label=_("Recurrency"),
1024
+ label=_("Recurrence"),
983
1025
  choices=RECURRENCE_CHOICES,
984
1026
  initial=ClientReportSchedule.PERIODICITY_NONE,
985
1027
  help_text=_("Defines how often the report should be generated automatically."),
@@ -1006,8 +1048,13 @@ class ClientReportForm(forms.Form):
1006
1048
  week_str = cleaned.get("week")
1007
1049
  if not week_str:
1008
1050
  raise forms.ValidationError(_("Please select a week."))
1009
- year, week_num = week_str.split("-W")
1010
- start = datetime.date.fromisocalendar(int(year), int(week_num), 1)
1051
+ try:
1052
+ year_str, week_num_str = week_str.split("-W", 1)
1053
+ start = datetime.date.fromisocalendar(
1054
+ int(year_str), int(week_num_str), 1
1055
+ )
1056
+ except (TypeError, ValueError):
1057
+ raise forms.ValidationError(_("Please select a week."))
1011
1058
  cleaned["start"] = start
1012
1059
  cleaned["end"] = start + datetime.timedelta(days=6)
1013
1060
  elif period == "month":