arthexis 0.1.19__py3-none-any.whl → 0.1.20__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.
pages/tests.py CHANGED
@@ -3,6 +3,7 @@ import os
3
3
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
4
 
5
5
  import django
6
+ import pytest
6
7
 
7
8
  django.setup()
8
9
 
@@ -508,6 +509,7 @@ class InvitationTests(TestCase):
508
509
  lead = InviteLead.objects.get()
509
510
  self.assertEqual(lead.mac_address, "aa:bb:cc:dd:ee:ff")
510
511
 
512
+ @pytest.mark.feature("ap-router")
511
513
  @patch("pages.views.public_wifi.grant_public_access")
512
514
  @patch(
513
515
  "pages.views.public_wifi.resolve_mac_address",
@@ -1360,6 +1362,7 @@ class SiteAdminRegisterCurrentTests(TestCase):
1360
1362
  self.assertEqual(site.name, "")
1361
1363
 
1362
1364
 
1365
+ @pytest.mark.feature("screenshot-poll")
1363
1366
  class SiteAdminScreenshotTests(TestCase):
1364
1367
  def setUp(self):
1365
1368
  self.client = Client()
@@ -1479,17 +1482,17 @@ class NavAppsTests(TestCase):
1479
1482
  )
1480
1483
  app = Application.objects.create(name="Readme")
1481
1484
  Module.objects.create(
1482
- node_role=role, application=app, path="/", is_default=True, menu="Cookbook"
1485
+ node_role=role, application=app, path="/", is_default=True, menu="Cookbooks"
1483
1486
  )
1484
1487
 
1485
1488
  def test_nav_pill_renders(self):
1486
1489
  resp = self.client.get(reverse("pages:index"))
1487
- self.assertContains(resp, "COOKBOOK")
1490
+ self.assertContains(resp, "COOKBOOKS")
1488
1491
  self.assertContains(resp, "badge rounded-pill")
1489
1492
 
1490
1493
  def test_nav_pill_renders_with_port(self):
1491
1494
  resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8000")
1492
- self.assertContains(resp, "COOKBOOK")
1495
+ self.assertContains(resp, "COOKBOOKS")
1493
1496
 
1494
1497
  def test_nav_pill_uses_menu_field(self):
1495
1498
  site_app = Module.objects.get()
@@ -1497,7 +1500,7 @@ class NavAppsTests(TestCase):
1497
1500
  site_app.save()
1498
1501
  resp = self.client.get(reverse("pages:index"))
1499
1502
  self.assertContains(resp, 'badge rounded-pill text-bg-secondary">DOCS')
1500
- self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOK')
1503
+ self.assertNotContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1501
1504
 
1502
1505
  def test_app_without_root_url_excluded(self):
1503
1506
  role = NodeRole.objects.get(name="Terminal")
@@ -1562,20 +1565,22 @@ class RoleLandingRedirectTests(TestCase):
1562
1565
 
1563
1566
  def test_satellite_redirects_to_dashboard(self):
1564
1567
  target = self._configure_role_landing(
1565
- "Satellite", "/ocpp/", "CPMS Online Dashboard"
1568
+ "Satellite", "/ocpp/cpms/dashboard/", "CPMS Online Dashboard"
1566
1569
  )
1567
1570
  resp = self.client.get(reverse("pages:index"))
1568
1571
  self.assertRedirects(resp, target, fetch_redirect_response=False)
1569
1572
 
1570
1573
  def test_control_redirects_to_rfid(self):
1571
1574
  target = self._configure_role_landing(
1572
- "Control", "/ocpp/rfid/", "RFID Tag Validator"
1575
+ "Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
1573
1576
  )
1574
1577
  resp = self.client.get(reverse("pages:index"))
1575
1578
  self.assertRedirects(resp, target, fetch_redirect_response=False)
1576
1579
 
1577
1580
  def test_security_group_redirect_takes_priority(self):
1578
- self._configure_role_landing("Control", "/ocpp/rfid/", "RFID Tag Validator")
1581
+ self._configure_role_landing(
1582
+ "Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
1583
+ )
1579
1584
  role = self.node.role
1580
1585
  group = SecurityGroup.objects.create(name="Operators")
1581
1586
  group_landing = self._ensure_landing(role, "/ocpp/group/", "Group Landing")
@@ -1592,7 +1597,9 @@ class RoleLandingRedirectTests(TestCase):
1592
1597
  )
1593
1598
 
1594
1599
  def test_user_redirect_overrides_group_with_higher_priority(self):
1595
- self._configure_role_landing("Control", "/ocpp/rfid/", "RFID Tag Validator")
1600
+ self._configure_role_landing(
1601
+ "Control", "/ocpp/rfid/validator/", "RFID Tag Validator"
1602
+ )
1596
1603
  role = self.node.role
1597
1604
  group = SecurityGroup.objects.create(name="Operators")
1598
1605
  group_landing = self._ensure_landing(role, "/ocpp/group/", "Group Landing")
@@ -1614,10 +1621,10 @@ class RoleLandingRedirectTests(TestCase):
1614
1621
  )
1615
1622
 
1616
1623
 
1617
- class ConstellationNavTests(TestCase):
1624
+ class WatchtowerNavTests(TestCase):
1618
1625
  def setUp(self):
1619
1626
  self.client = Client()
1620
- role, _ = NodeRole.objects.get_or_create(name="Constellation")
1627
+ role, _ = NodeRole.objects.get_or_create(name="Watchtower")
1621
1628
  Node.objects.update_or_create(
1622
1629
  mac_address=Node.get_current_mac(),
1623
1630
  defaults={
@@ -1627,38 +1634,56 @@ class ConstellationNavTests(TestCase):
1627
1634
  },
1628
1635
  )
1629
1636
  Site.objects.update_or_create(
1630
- id=1, defaults={"domain": "testserver", "name": ""}
1637
+ id=1, defaults={"domain": "arthexis.com", "name": "Arthexis"}
1631
1638
  )
1632
1639
  fixtures = [
1633
1640
  Path(
1634
1641
  settings.BASE_DIR,
1635
1642
  "pages",
1636
1643
  "fixtures",
1637
- "constellation__application_ocpp.json",
1644
+ "default__application_pages.json",
1638
1645
  ),
1639
1646
  Path(
1640
1647
  settings.BASE_DIR,
1641
1648
  "pages",
1642
1649
  "fixtures",
1643
- "constellation__module_ocpp.json",
1650
+ "watchtower__application_ocpp.json",
1644
1651
  ),
1645
1652
  Path(
1646
1653
  settings.BASE_DIR,
1647
1654
  "pages",
1648
1655
  "fixtures",
1649
- "constellation__landing_ocpp_dashboard.json",
1656
+ "watchtower__module_ocpp.json",
1650
1657
  ),
1651
1658
  Path(
1652
1659
  settings.BASE_DIR,
1653
1660
  "pages",
1654
1661
  "fixtures",
1655
- "constellation__landing_ocpp_cp_simulator.json",
1662
+ "watchtower__landing_ocpp_dashboard.json",
1656
1663
  ),
1657
1664
  Path(
1658
1665
  settings.BASE_DIR,
1659
1666
  "pages",
1660
1667
  "fixtures",
1661
- "constellation__landing_ocpp_rfid.json",
1668
+ "watchtower__landing_ocpp_cp_simulator.json",
1669
+ ),
1670
+ Path(
1671
+ settings.BASE_DIR,
1672
+ "pages",
1673
+ "fixtures",
1674
+ "watchtower__landing_ocpp_rfid.json",
1675
+ ),
1676
+ Path(
1677
+ settings.BASE_DIR,
1678
+ "pages",
1679
+ "fixtures",
1680
+ "watchtower__module_readme.json",
1681
+ ),
1682
+ Path(
1683
+ settings.BASE_DIR,
1684
+ "pages",
1685
+ "fixtures",
1686
+ "watchtower__landing_readme.json",
1662
1687
  ),
1663
1688
  ]
1664
1689
  call_command("loaddata", *map(str, fixtures))
@@ -1671,13 +1696,13 @@ class ConstellationNavTests(TestCase):
1671
1696
  self.assertNotIn("RFID", nav_labels)
1672
1697
  self.assertTrue(
1673
1698
  Module.objects.filter(
1674
- path="/ocpp/", node_role__name="Constellation"
1699
+ path="/ocpp/", node_role__name="Watchtower"
1675
1700
  ).exists()
1676
1701
  )
1677
1702
  self.assertFalse(
1678
1703
  Module.objects.filter(
1679
1704
  path="/ocpp/rfid/",
1680
- node_role__name="Constellation",
1705
+ node_role__name="Watchtower",
1681
1706
  is_deleted=False,
1682
1707
  ).exists()
1683
1708
  )
@@ -1689,9 +1714,16 @@ class ConstellationNavTests(TestCase):
1689
1714
  landing_labels = [landing.label for landing in ocpp_module.enabled_landings]
1690
1715
  self.assertIn("RFID Tag Validator", landing_labels)
1691
1716
 
1717
+ @override_settings(ALLOWED_HOSTS=["testserver", "arthexis.com"])
1718
+ def test_cookbooks_pill_visible_for_arthexis(self):
1719
+ resp = self.client.get(
1720
+ reverse("pages:index"), HTTP_HOST="arthexis.com"
1721
+ )
1722
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1723
+
1692
1724
  def test_ocpp_dashboard_visible(self):
1693
1725
  resp = self.client.get(reverse("pages:index"))
1694
- self.assertContains(resp, 'href="/ocpp/"')
1726
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
1695
1727
 
1696
1728
 
1697
1729
  class ReleaseModuleNavTests(TestCase):
@@ -1833,7 +1865,7 @@ class ControlNavTests(TestCase):
1833
1865
  self.client.force_login(user)
1834
1866
  resp = self.client.get(reverse("pages:index"))
1835
1867
  self.assertEqual(resp.status_code, 200)
1836
- self.assertContains(resp, 'href="/ocpp/"')
1868
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
1837
1869
  self.assertContains(
1838
1870
  resp, 'badge rounded-pill text-bg-secondary">CHARGERS'
1839
1871
  )
@@ -1865,10 +1897,27 @@ class ControlNavTests(TestCase):
1865
1897
  self.assertFalse(resp.context["header_references"])
1866
1898
  self.assertNotContains(resp, "https://example.com/hidden")
1867
1899
 
1900
+ def test_header_link_hidden_when_only_site_matches(self):
1901
+ terminal_role, _ = NodeRole.objects.get_or_create(name="Terminal")
1902
+ site = Site.objects.get(domain="testserver")
1903
+ reference = Reference.objects.create(
1904
+ alt_text="Restricted",
1905
+ value="https://example.com/restricted",
1906
+ show_in_header=True,
1907
+ )
1908
+ reference.roles.add(terminal_role)
1909
+ reference.sites.add(site)
1910
+
1911
+ resp = self.client.get(reverse("pages:index"))
1912
+
1913
+ self.assertIn("header_references", resp.context)
1914
+ self.assertFalse(resp.context["header_references"])
1915
+ self.assertNotContains(resp, "https://example.com/restricted")
1916
+
1868
1917
  def test_readme_pill_visible(self):
1869
1918
  resp = self.client.get(reverse("pages:readme"))
1870
1919
  self.assertContains(resp, 'href="/read/"')
1871
- self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOK')
1920
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1872
1921
 
1873
1922
  def test_cookbook_pill_has_no_dropdown(self):
1874
1923
  module = Module.objects.get(node_role__name="Control", path="/read/")
@@ -1883,7 +1932,7 @@ class ControlNavTests(TestCase):
1883
1932
 
1884
1933
  self.assertContains(
1885
1934
  resp,
1886
- '<a class="nav-link" href="/read/"><span class="badge rounded-pill text-bg-secondary">COOKBOOK</span></a>',
1935
+ '<a class="nav-link" href="/read/"><span class="badge rounded-pill text-bg-secondary">COOKBOOKS</span></a>',
1887
1936
  html=True,
1888
1937
  )
1889
1938
  self.assertNotContains(resp, 'dropdown-item" href="/man/"')
@@ -1990,7 +2039,7 @@ class SatelliteNavTests(TestCase):
1990
2039
  def test_readme_pill_visible(self):
1991
2040
  resp = self.client.get(reverse("pages:readme"))
1992
2041
  self.assertContains(resp, 'href="/read/"')
1993
- self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOK')
2042
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
1994
2043
 
1995
2044
 
1996
2045
  class PowerNavTests(TestCase):
@@ -2025,9 +2074,9 @@ class PowerNavTests(TestCase):
2025
2074
  power_module = module
2026
2075
  break
2027
2076
  self.assertIsNotNone(power_module)
2028
- self.assertEqual(power_module.menu_label.upper(), "CALCULATE")
2077
+ self.assertEqual(power_module.menu_label.upper(), "CALCULATORS")
2029
2078
  landing_labels = {landing.label for landing in power_module.enabled_landings}
2030
- self.assertIn("AWG Calculator", landing_labels)
2079
+ self.assertIn("AWG Cable Calculator", landing_labels)
2031
2080
 
2032
2081
  def test_manual_pill_label(self):
2033
2082
  resp = self.client.get(reverse("pages:index"))
@@ -2051,9 +2100,26 @@ class PowerNavTests(TestCase):
2051
2100
  break
2052
2101
  self.assertIsNotNone(power_module)
2053
2102
  landing_labels = {landing.label for landing in power_module.enabled_landings}
2054
- self.assertIn("AWG Calculator", landing_labels)
2103
+ self.assertIn("AWG Cable Calculator", landing_labels)
2055
2104
  self.assertIn("Energy Tariff Calculator", landing_labels)
2056
2105
 
2106
+ def test_locked_landing_shows_lock_icon(self):
2107
+ resp = self.client.get(reverse("pages:index"))
2108
+ html = resp.content.decode()
2109
+ energy_index = html.find("Energy Tariff Calculator")
2110
+ self.assertGreaterEqual(energy_index, 0)
2111
+ icon_index = html.find("dropdown-lock-icon", energy_index, energy_index + 300)
2112
+ self.assertGreaterEqual(icon_index, 0)
2113
+
2114
+ def test_lock_icon_disappears_after_login(self):
2115
+ self.client.force_login(self.user)
2116
+ resp = self.client.get(reverse("pages:index"))
2117
+ html = resp.content.decode()
2118
+ energy_index = html.find("Energy Tariff Calculator")
2119
+ self.assertGreaterEqual(energy_index, 0)
2120
+ icon_index = html.find("dropdown-lock-icon", energy_index, energy_index + 300)
2121
+ self.assertEqual(icon_index, -1)
2122
+
2057
2123
 
2058
2124
  class StaffNavVisibilityTests(TestCase):
2059
2125
  def setUp(self):
@@ -2075,12 +2141,12 @@ class StaffNavVisibilityTests(TestCase):
2075
2141
  def test_nonstaff_pill_hidden(self):
2076
2142
  self.client.login(username="user", password="pw")
2077
2143
  resp = self.client.get(reverse("pages:index"))
2078
- self.assertContains(resp, 'href="/ocpp/"')
2144
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
2079
2145
 
2080
2146
  def test_staff_sees_pill(self):
2081
2147
  self.client.login(username="staff", password="pw")
2082
2148
  resp = self.client.get(reverse("pages:index"))
2083
- self.assertContains(resp, 'href="/ocpp/"')
2149
+ self.assertContains(resp, 'href="/ocpp/cpms/dashboard/"')
2084
2150
 
2085
2151
 
2086
2152
  class ModuleAdminReloadActionTests(TestCase):
@@ -2093,7 +2159,7 @@ class ModuleAdminReloadActionTests(TestCase):
2093
2159
  password="pw",
2094
2160
  )
2095
2161
  self.client.force_login(self.superuser)
2096
- self.role, _ = NodeRole.objects.get_or_create(name="Constellation")
2162
+ self.role, _ = NodeRole.objects.get_or_create(name="Watchtower")
2097
2163
  Application.objects.get_or_create(name="ocpp")
2098
2164
  Application.objects.get_or_create(name="awg")
2099
2165
  Site.objects.update_or_create(
@@ -2131,7 +2197,11 @@ class ModuleAdminReloadActionTests(TestCase):
2131
2197
  )
2132
2198
  self.assertSetEqual(
2133
2199
  charger_landings,
2134
- {"/ocpp/", "/ocpp/simulator/", "/ocpp/rfid/"},
2200
+ {
2201
+ "/ocpp/cpms/dashboard/",
2202
+ "/ocpp/evcs/simulator/",
2203
+ "/ocpp/rfid/validator/",
2204
+ },
2135
2205
  )
2136
2206
 
2137
2207
  calculator_landings = set(
@@ -2331,12 +2401,12 @@ class LandingCreationTests(TestCase):
2331
2401
 
2332
2402
 
2333
2403
  class LandingFixtureTests(TestCase):
2334
- def test_constellation_fixture_loads_without_duplicates(self):
2404
+ def test_watchtower_fixture_loads_without_duplicates(self):
2335
2405
  from glob import glob
2336
2406
 
2337
- NodeRole.objects.get_or_create(name="Constellation")
2407
+ NodeRole.objects.get_or_create(name="Watchtower")
2338
2408
  fixtures = glob(
2339
- str(Path(settings.BASE_DIR, "pages", "fixtures", "constellation__*.json"))
2409
+ str(Path(settings.BASE_DIR, "pages", "fixtures", "watchtower__*.json"))
2340
2410
  )
2341
2411
  fixtures = sorted(
2342
2412
  fixtures,
@@ -2346,9 +2416,11 @@ class LandingFixtureTests(TestCase):
2346
2416
  )
2347
2417
  call_command("loaddata", *fixtures)
2348
2418
  call_command("loaddata", *fixtures)
2349
- module = Module.objects.get(path="/ocpp/", node_role__name="Constellation")
2419
+ module = Module.objects.get(path="/ocpp/", node_role__name="Watchtower")
2350
2420
  module.create_landings()
2351
- self.assertEqual(module.landings.filter(path="/ocpp/rfid/").count(), 1)
2421
+ self.assertEqual(
2422
+ module.landings.filter(path="/ocpp/rfid/validator/").count(), 1
2423
+ )
2352
2424
 
2353
2425
 
2354
2426
  class AllowedHostSubnetTests(TestCase):
@@ -2501,9 +2573,9 @@ class FaviconTests(TestCase):
2501
2573
  )
2502
2574
  self.assertContains(resp, b64)
2503
2575
 
2504
- def test_constellation_nodes_use_goldenrod_favicon(self):
2576
+ def test_watchtower_nodes_use_goldenrod_favicon(self):
2505
2577
  with override_settings(MEDIA_ROOT=self.tmpdir):
2506
- role, _ = NodeRole.objects.get_or_create(name="Constellation")
2578
+ role, _ = NodeRole.objects.get_or_create(name="Watchtower")
2507
2579
  Node.objects.update_or_create(
2508
2580
  mac_address=Node.get_current_mac(),
2509
2581
  defaults={
@@ -2518,7 +2590,7 @@ class FaviconTests(TestCase):
2518
2590
  resp = self.client.get(reverse("pages:index"))
2519
2591
  b64 = (
2520
2592
  Path(settings.BASE_DIR)
2521
- .joinpath("pages", "fixtures", "data", "favicon_constellation.txt")
2593
+ .joinpath("pages", "fixtures", "data", "favicon_watchtower.txt")
2522
2594
  .read_text()
2523
2595
  .strip()
2524
2596
  )
pages/urls.py CHANGED
@@ -6,6 +6,7 @@ app_name = "pages"
6
6
 
7
7
  urlpatterns = [
8
8
  path("", views.index, name="index"),
9
+ path("read/<path:doc>/edit/", views.readme_edit, name="readme-edit"),
9
10
  path("read/", views.readme, name="readme"),
10
11
  path("read/<path:doc>", views.readme, name="readme-document"),
11
12
  path("sitemap.xml", views.sitemap, name="pages-sitemap"),
pages/views.py CHANGED
@@ -439,7 +439,7 @@ def admin_model_graph(request, app_label: str):
439
439
  return response
440
440
 
441
441
 
442
- def _render_readme(request, role, doc: str | None = None):
442
+ def _locate_readme_document(role, doc: str | None, lang: str) -> SimpleNamespace:
443
443
  app = (
444
444
  Module.objects.filter(node_role=role, is_default=True)
445
445
  .select_related("application")
@@ -448,9 +448,8 @@ def _render_readme(request, role, doc: str | None = None):
448
448
  app_slug = app.path.strip("/") if app else ""
449
449
  root_base = Path(settings.BASE_DIR).resolve()
450
450
  readme_base = (root_base / app_slug).resolve() if app_slug else root_base
451
- lang = getattr(request, "LANGUAGE_CODE", "")
452
- lang = lang.replace("_", "-").lower()
453
- candidates = []
451
+ candidates: list[Path] = []
452
+
454
453
  if doc:
455
454
  normalized = doc.strip().replace("\\", "/")
456
455
  while normalized.startswith("./"):
@@ -501,23 +500,72 @@ def _render_readme(request, role, doc: str | None = None):
501
500
  if short != lang:
502
501
  candidates.append(root_base / f"README.{short}.md")
503
502
  candidates.append(root_base / "README.md")
503
+
504
504
  readme_file = next((p for p in candidates if p.exists()), None)
505
505
  if readme_file is None:
506
506
  raise Http404("Document not found")
507
- text = readme_file.read_text(encoding="utf-8")
508
- html, toc_html = _render_markdown_with_toc(text)
507
+
509
508
  title = "README" if readme_file.name.startswith("README") else readme_file.stem
509
+ return SimpleNamespace(
510
+ file=readme_file,
511
+ title=title,
512
+ root_base=root_base,
513
+ )
514
+
515
+
516
+ def _relative_readme_path(readme_file: Path, root_base: Path) -> str | None:
517
+ try:
518
+ return readme_file.relative_to(root_base).as_posix()
519
+ except ValueError:
520
+ return None
521
+
522
+
523
+ def _render_readme(request, role, doc: str | None = None):
524
+ lang = getattr(request, "LANGUAGE_CODE", "")
525
+ lang = lang.replace("_", "-").lower()
526
+ document = _locate_readme_document(role, doc, lang)
527
+ text = document.file.read_text(encoding="utf-8")
528
+ html, toc_html = _render_markdown_with_toc(text)
529
+ relative_path = _relative_readme_path(document.file, document.root_base)
530
+ user = getattr(request, "user", None)
531
+ can_edit = bool(
532
+ relative_path
533
+ and user
534
+ and user.is_authenticated
535
+ and user.is_superuser
536
+ )
537
+ edit_url = None
538
+ if can_edit:
539
+ try:
540
+ edit_url = reverse("pages:readme-edit", kwargs={"doc": relative_path})
541
+ except NoReverseMatch:
542
+ edit_url = None
510
543
  context = {
511
544
  "content": html,
512
- "title": title,
545
+ "title": document.title,
513
546
  "toc": toc_html,
514
547
  "page_url": request.build_absolute_uri(),
548
+ "edit_url": edit_url,
515
549
  }
516
550
  response = render(request, "pages/readme.html", context)
517
551
  patch_vary_headers(response, ["Accept-Language", "Cookie"])
518
552
  return response
519
553
 
520
554
 
555
+ class MarkdownDocumentForm(forms.Form):
556
+ content = forms.CharField(
557
+ widget=forms.Textarea(
558
+ attrs={
559
+ "class": "form-control",
560
+ "rows": 24,
561
+ "spellcheck": "false",
562
+ }
563
+ ),
564
+ required=False,
565
+ strip=False,
566
+ )
567
+
568
+
521
569
  @landing("Home")
522
570
  @never_cache
523
571
  def index(request):
@@ -573,6 +621,57 @@ def readme(request, doc=None):
573
621
  return _render_readme(request, role, doc)
574
622
 
575
623
 
624
+ def readme_edit(request, doc):
625
+ user = getattr(request, "user", None)
626
+ if not (user and user.is_authenticated and user.is_superuser):
627
+ raise PermissionDenied
628
+
629
+ node = Node.get_local()
630
+ role = node.role if node else None
631
+ lang = getattr(request, "LANGUAGE_CODE", "")
632
+ lang = lang.replace("_", "-").lower()
633
+ document = _locate_readme_document(role, doc, lang)
634
+ relative_path = _relative_readme_path(document.file, document.root_base)
635
+ if relative_path:
636
+ read_url = reverse("pages:readme-document", kwargs={"doc": relative_path})
637
+ else:
638
+ read_url = reverse("pages:readme")
639
+
640
+ if request.method == "POST":
641
+ form = MarkdownDocumentForm(request.POST)
642
+ if form.is_valid():
643
+ content = form.cleaned_data["content"]
644
+ try:
645
+ document.file.write_text(content, encoding="utf-8")
646
+ except OSError:
647
+ logger.exception("Failed to update markdown document %s", document.file)
648
+ messages.error(
649
+ request,
650
+ _("Unable to save changes. Please try again."),
651
+ )
652
+ else:
653
+ messages.success(request, _("Document saved successfully."))
654
+ if relative_path:
655
+ return redirect("pages:readme-edit", doc=relative_path)
656
+ return redirect("pages:readme")
657
+ else:
658
+ try:
659
+ initial_text = document.file.read_text(encoding="utf-8")
660
+ except OSError:
661
+ logger.exception("Failed to read markdown document %s", document.file)
662
+ messages.error(request, _("Unable to load the document for editing."))
663
+ return redirect("pages:readme")
664
+ form = MarkdownDocumentForm(initial={"content": initial_text})
665
+
666
+ context = {
667
+ "form": form,
668
+ "title": document.title,
669
+ "relative_path": relative_path,
670
+ "read_url": read_url,
671
+ }
672
+ return render(request, "pages/readme_edit.html", context)
673
+
674
+
576
675
  def sitemap(request):
577
676
  site = get_site(request)
578
677
  node = Node.get_local()
@@ -1005,13 +1104,14 @@ class ClientReportForm(forms.Form):
1005
1104
  label=_("Month"),
1006
1105
  required=False,
1007
1106
  widget=forms.DateInput(attrs={"type": "month"}),
1107
+ input_formats=["%Y-%m"],
1008
1108
  help_text=_("Generates the report for the calendar month that you select."),
1009
1109
  )
1010
1110
  owner = forms.ModelChoiceField(
1011
1111
  queryset=get_user_model().objects.all(),
1012
1112
  required=False,
1013
1113
  help_text=_(
1014
- "Sets who owns the report schedule and is listed as the requestor."
1114
+ "Sets who owns the report schedule and is listed as the requester."
1015
1115
  ),
1016
1116
  )
1017
1117
  destinations = forms.CharField(