arthexis 0.1.18__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,7 +8,7 @@ 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
@@ -33,6 +33,8 @@ from pages.models import (
33
33
  UserManual,
34
34
  UserStory,
35
35
  )
36
+ from django.http import FileResponse
37
+
36
38
  from pages.admin import (
37
39
  ApplicationAdmin,
38
40
  UserManualAdmin,
@@ -690,23 +692,6 @@ class AdminDashboardAppListTests(TestCase):
690
692
  resp = self.client.get(reverse("admin:index"))
691
693
  self.assertContains(resp, "5. Horologia MODELS")
692
694
 
693
- def test_dashboard_handles_missing_last_net_message_url(self):
694
- from pages.templatetags import admin_extras
695
-
696
- real_reverse = admin_extras.reverse
697
-
698
- def fake_reverse(name, *args, **kwargs):
699
- if name == "last-net-message":
700
- raise NoReverseMatch("missing")
701
- return real_reverse(name, *args, **kwargs)
702
-
703
- with patch("pages.templatetags.admin_extras.reverse", side_effect=fake_reverse):
704
- resp = self.client.get(reverse("admin:index"))
705
-
706
- self.assertEqual(resp.status_code, 200)
707
- self.assertNotIn(b"last-net-message", resp.content)
708
-
709
-
710
695
  class AdminSidebarTests(TestCase):
711
696
  def setUp(self):
712
697
  self.client = Client()
@@ -816,7 +801,8 @@ class ViewHistoryLoggingTests(TestCase):
816
801
  )
817
802
  landing = module.landings.get(path="/")
818
803
  landing.label = "Home Landing"
819
- landing.save(update_fields=["label"])
804
+ landing.track_leads = True
805
+ landing.save(update_fields=["label", "track_leads"])
820
806
 
821
807
  resp = self.client.get(
822
808
  reverse("pages:index"), HTTP_REFERER="https://example.com/ref"
@@ -841,7 +827,8 @@ class ViewHistoryLoggingTests(TestCase):
841
827
  )
842
828
  landing = module.landings.get(path="/")
843
829
  landing.label = "No Celery"
844
- landing.save(update_fields=["label"])
830
+ landing.track_leads = True
831
+ landing.save(update_fields=["label", "track_leads"])
845
832
 
846
833
  resp = self.client.get(reverse("pages:index"))
847
834
 
@@ -861,7 +848,8 @@ class ViewHistoryLoggingTests(TestCase):
861
848
  )
862
849
  landing = module.landings.get(path="/")
863
850
  landing.enabled = False
864
- landing.save(update_fields=["enabled"])
851
+ landing.track_leads = True
852
+ landing.save(update_fields=["enabled", "track_leads"])
865
853
 
866
854
  resp = self.client.get(reverse("pages:index"))
867
855
 
@@ -1130,6 +1118,50 @@ class LogViewerAdminTests(SimpleTestCase):
1130
1118
  self.assertEqual(context["selected_log"], "selected.log")
1131
1119
  self.assertIn("hello world", context["log_content"])
1132
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
+
1133
1165
  def test_log_viewer_reports_missing_log(self):
1134
1166
  response = self._render({"log": "missing.log"})
1135
1167
  self.assertIn("requested log could not be found", response.context_data["log_error"])
@@ -1447,17 +1479,17 @@ class NavAppsTests(TestCase):
1447
1479
  )
1448
1480
  app = Application.objects.create(name="Readme")
1449
1481
  Module.objects.create(
1450
- node_role=role, application=app, path="/", is_default=True
1482
+ node_role=role, application=app, path="/", is_default=True, menu="Cookbook"
1451
1483
  )
1452
1484
 
1453
1485
  def test_nav_pill_renders(self):
1454
1486
  resp = self.client.get(reverse("pages:index"))
1455
- self.assertContains(resp, "README")
1487
+ self.assertContains(resp, "COOKBOOK")
1456
1488
  self.assertContains(resp, "badge rounded-pill")
1457
1489
 
1458
1490
  def test_nav_pill_renders_with_port(self):
1459
1491
  resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8000")
1460
- self.assertContains(resp, "README")
1492
+ self.assertContains(resp, "COOKBOOK")
1461
1493
 
1462
1494
  def test_nav_pill_uses_menu_field(self):
1463
1495
  site_app = Module.objects.get()
@@ -1465,7 +1497,7 @@ class NavAppsTests(TestCase):
1465
1497
  site_app.save()
1466
1498
  resp = self.client.get(reverse("pages:index"))
1467
1499
  self.assertContains(resp, 'badge rounded-pill text-bg-secondary">DOCS')
1468
- self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">README')
1500
+ self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOK')
1469
1501
 
1470
1502
  def test_app_without_root_url_excluded(self):
1471
1503
  role = NodeRole.objects.get(name="Terminal")
@@ -1835,8 +1867,57 @@ class ControlNavTests(TestCase):
1835
1867
 
1836
1868
  def test_readme_pill_visible(self):
1837
1869
  resp = self.client.get(reverse("pages:readme"))
1838
- self.assertContains(resp, 'href="/readme/"')
1839
- 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)
1840
1921
 
1841
1922
 
1842
1923
  class SatelliteNavTests(TestCase):
@@ -1908,8 +1989,8 @@ class SatelliteNavTests(TestCase):
1908
1989
 
1909
1990
  def test_readme_pill_visible(self):
1910
1991
  resp = self.client.get(reverse("pages:readme"))
1911
- self.assertContains(resp, 'href="/readme/"')
1912
- 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')
1913
1994
 
1914
1995
 
1915
1996
  class PowerNavTests(TestCase):
@@ -2188,6 +2269,47 @@ class UserManualAdminFormTests(TestCase):
2188
2269
  self.assertEqual(form.cleaned_data["content_pdf"], self.manual.content_pdf)
2189
2270
 
2190
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
+
2191
2313
  class LandingCreationTests(TestCase):
2192
2314
  def setUp(self):
2193
2315
  role, _ = NodeRole.objects.get_or_create(name="Terminal")
@@ -2547,20 +2669,41 @@ class FavoriteTests(TestCase):
2547
2669
  self.assertContains(resp, f'aria-label="{badge_label}"')
2548
2670
 
2549
2671
  def test_dashboard_shows_charge_point_availability_badge(self):
2550
- Charger.objects.create(charger_id="CP-001", last_status="Available")
2551
2672
  Charger.objects.create(
2552
2673
  charger_id="CP-001", connector_id=1, last_status="Available"
2553
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
+ )
2554
2697
  Charger.objects.create(
2555
- charger_id="CP-002", connector_id=1, last_status="Unavailable"
2698
+ charger_id="CP-AGG", connector_id=2, last_status="Available"
2556
2699
  )
2557
2700
 
2558
2701
  resp = self.client.get(reverse("admin:index"))
2559
2702
 
2560
- expected = "2 / 1"
2703
+ expected = "2 / 2"
2561
2704
  badge_label = gettext(
2562
- "%(total)s chargers reporting Available status, including %(with_cp)s with a CP number"
2563
- ) % {"total": 2, "with_cp": 1}
2705
+ "%(available)s chargers reporting Available status with a CP number."
2706
+ ) % {"available": 2}
2564
2707
 
2565
2708
  self.assertContains(resp, expected)
2566
2709
  self.assertContains(resp, 'class="charger-availability-badge"')
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":