arthexis 0.1.14__py3-none-any.whl → 0.1.15__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/tasks.py ADDED
@@ -0,0 +1,74 @@
1
+ """Celery tasks for the pages application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import timedelta
7
+
8
+ from celery import shared_task
9
+
10
+ from django.utils import timezone
11
+
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @shared_task
17
+ def create_user_story_github_issue(user_story_id: int) -> str | None:
18
+ """Create a GitHub issue for the provided ``UserStory`` instance."""
19
+
20
+ from .models import UserStory
21
+
22
+ try:
23
+ story = UserStory.objects.get(pk=user_story_id)
24
+ except UserStory.DoesNotExist: # pragma: no cover - defensive guard
25
+ logger.warning(
26
+ "User story %s no longer exists; skipping GitHub issue creation",
27
+ user_story_id,
28
+ )
29
+ return None
30
+
31
+ if story.rating >= 5:
32
+ logger.info(
33
+ "Skipping GitHub issue creation for user story %s with rating %s",
34
+ story.pk,
35
+ story.rating,
36
+ )
37
+ return None
38
+
39
+ if story.github_issue_url:
40
+ logger.info(
41
+ "GitHub issue already recorded for user story %s: %s",
42
+ story.pk,
43
+ story.github_issue_url,
44
+ )
45
+ return story.github_issue_url
46
+
47
+ issue_url = story.create_github_issue()
48
+
49
+ if issue_url:
50
+ logger.info(
51
+ "Created GitHub issue %s for user story %s", issue_url, story.pk
52
+ )
53
+ else:
54
+ logger.info(
55
+ "No GitHub issue created for user story %s", story.pk
56
+ )
57
+
58
+ return issue_url
59
+
60
+
61
+ @shared_task
62
+ def purge_expired_landing_leads(days: int = 30) -> int:
63
+ """Remove landing leads older than ``days`` days."""
64
+
65
+ from .models import LandingLead
66
+
67
+ cutoff = timezone.now() - timedelta(days=days)
68
+ queryset = LandingLead.objects.filter(created_on__lt=cutoff)
69
+ deleted, _ = queryset.delete()
70
+ if deleted:
71
+ logger.info(
72
+ "Purged %s landing leads older than %s days", deleted, days
73
+ )
74
+ return deleted
pages/tests.py CHANGED
@@ -7,6 +7,7 @@ import django
7
7
  django.setup()
8
8
 
9
9
  from django.test import Client, RequestFactory, TestCase, SimpleTestCase, override_settings
10
+ from django.test.utils import CaptureQueriesContext
10
11
  from django.urls import reverse
11
12
  from django.templatetags.static import static
12
13
  from urllib.parse import quote
@@ -15,7 +16,10 @@ from django.contrib.sites.models import Site
15
16
  from django.contrib import admin
16
17
  from django.contrib.messages.storage.fallback import FallbackStorage
17
18
  from django.core.exceptions import DisallowedHost
19
+ from django.core.cache import cache
20
+ from django.db import connection
18
21
  import socket
22
+ from django.db import connection
19
23
  from pages.models import (
20
24
  Application,
21
25
  Landing,
@@ -62,7 +66,7 @@ import shutil
62
66
  from io import StringIO
63
67
  from django.conf import settings
64
68
  from pathlib import Path
65
- from unittest.mock import MagicMock, Mock, patch
69
+ from unittest.mock import MagicMock, Mock, call, patch
66
70
  from types import SimpleNamespace
67
71
  from django.core.management import call_command
68
72
  import re
@@ -706,6 +710,32 @@ class ViewHistoryLoggingTests(TestCase):
706
710
  def setUp(self):
707
711
  self.client = Client()
708
712
  Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
713
+ self.addCleanup(self._reset_purge_task)
714
+
715
+ def _reset_purge_task(self):
716
+ from django_celery_beat.models import PeriodicTask
717
+
718
+ PeriodicTask.objects.filter(name="pages_purge_landing_leads").delete()
719
+
720
+ def _create_local_node(self):
721
+ node, _ = Node.objects.update_or_create(
722
+ mac_address=Node.get_current_mac(),
723
+ defaults={
724
+ "hostname": socket.gethostname(),
725
+ "address": "127.0.0.1",
726
+ "base_path": settings.BASE_DIR,
727
+ "port": 8000,
728
+ },
729
+ )
730
+ return node
731
+
732
+ def _enable_celery_feature(self):
733
+ node = self._create_local_node()
734
+ feature, _ = NodeFeature.objects.get_or_create(
735
+ slug="celery-queue", defaults={"display": "Celery Queue"}
736
+ )
737
+ NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
738
+ return node
709
739
 
710
740
  def test_successful_visit_creates_entry(self):
711
741
  resp = self.client.get(reverse("pages:index"))
@@ -750,6 +780,8 @@ class ViewHistoryLoggingTests(TestCase):
750
780
  self.assertEqual(user.last_visit_ip_address, "203.0.113.5")
751
781
 
752
782
  def test_landing_visit_records_lead(self):
783
+ self._enable_celery_feature()
784
+
753
785
  role = NodeRole.objects.create(name="landing-role")
754
786
  application = Application.objects.create(
755
787
  name="landing-tests-app", description=""
@@ -774,6 +806,26 @@ class ViewHistoryLoggingTests(TestCase):
774
806
  self.assertEqual(lead.path, "/")
775
807
  self.assertEqual(lead.referer, "https://example.com/ref")
776
808
 
809
+ def test_landing_visit_does_not_record_lead_without_celery(self):
810
+ role = NodeRole.objects.create(name="no-celery-role")
811
+ application = Application.objects.create(
812
+ name="no-celery-app", description=""
813
+ )
814
+ module = Module.objects.create(
815
+ node_role=role,
816
+ application=application,
817
+ path="/",
818
+ menu="Landing",
819
+ )
820
+ landing = module.landings.get(path="/")
821
+ landing.label = "No Celery"
822
+ landing.save(update_fields=["label"])
823
+
824
+ resp = self.client.get(reverse("pages:index"))
825
+
826
+ self.assertEqual(resp.status_code, 200)
827
+ self.assertFalse(LandingLead.objects.exists())
828
+
777
829
  def test_disabled_landing_does_not_record_lead(self):
778
830
  role = NodeRole.objects.create(name="landing-role-disabled")
779
831
  application = Application.objects.create(
@@ -894,6 +946,93 @@ class ViewHistoryAdminTests(TestCase):
894
946
  self.assertContains(resp, static("core/vendor/chart.umd.min.js"))
895
947
 
896
948
 
949
+ class LandingLeadAdminTests(TestCase):
950
+ def setUp(self):
951
+ self.client = Client()
952
+ User = get_user_model()
953
+ self.admin = User.objects.create_superuser(
954
+ username="lead_admin", password="pwd", email="lead@example.com"
955
+ )
956
+ self.client.force_login(self.admin)
957
+ Site.objects.update_or_create(
958
+ id=1, defaults={"name": "test", "domain": "testserver"}
959
+ )
960
+ self.node, _ = Node.objects.update_or_create(
961
+ mac_address=Node.get_current_mac(),
962
+ defaults={
963
+ "hostname": socket.gethostname(),
964
+ "address": "127.0.0.1",
965
+ "base_path": settings.BASE_DIR,
966
+ "port": 8000,
967
+ },
968
+ )
969
+ self.node.features.clear()
970
+ self.addCleanup(self._reset_purge_task)
971
+
972
+ def _reset_purge_task(self):
973
+ from django_celery_beat.models import PeriodicTask
974
+
975
+ PeriodicTask.objects.filter(name="pages_purge_landing_leads").delete()
976
+
977
+ def test_changelist_warns_without_celery(self):
978
+ url = reverse("admin:pages_landinglead_changelist")
979
+ response = self.client.get(url)
980
+ self.assertContains(
981
+ response,
982
+ "Landing leads are not being recorded because Celery is not running on this node.",
983
+ )
984
+
985
+ def test_changelist_no_warning_with_celery(self):
986
+ feature, _ = NodeFeature.objects.get_or_create(
987
+ slug="celery-queue", defaults={"display": "Celery Queue"}
988
+ )
989
+ NodeFeatureAssignment.objects.get_or_create(node=self.node, feature=feature)
990
+ url = reverse("admin:pages_landinglead_changelist")
991
+ response = self.client.get(url)
992
+ self.assertNotContains(
993
+ response,
994
+ "Landing leads are not being recorded because Celery is not running on this node.",
995
+ )
996
+
997
+
998
+ class LandingLeadTaskTests(TestCase):
999
+ def setUp(self):
1000
+ self.role = NodeRole.objects.create(name="lead-task-role")
1001
+ self.application = Application.objects.create(
1002
+ name="lead-task-app", description=""
1003
+ )
1004
+ self.module = Module.objects.create(
1005
+ node_role=self.role,
1006
+ application=self.application,
1007
+ path="/tasks",
1008
+ menu="Landing",
1009
+ )
1010
+ self.landing = Landing.objects.create(
1011
+ module=self.module,
1012
+ path="/tasks/",
1013
+ label="Tasks Landing",
1014
+ enabled=True,
1015
+ )
1016
+
1017
+ def test_purge_expired_landing_leads_removes_old_records(self):
1018
+ from pages.tasks import purge_expired_landing_leads
1019
+
1020
+ stale = LandingLead.objects.create(landing=self.landing, path="/tasks/")
1021
+ recent = LandingLead.objects.create(landing=self.landing, path="/tasks/")
1022
+ LandingLead.objects.filter(pk=stale.pk).update(
1023
+ created_on=timezone.now() - timedelta(days=31)
1024
+ )
1025
+ LandingLead.objects.filter(pk=recent.pk).update(
1026
+ created_on=timezone.now() - timedelta(days=5)
1027
+ )
1028
+
1029
+ deleted = purge_expired_landing_leads()
1030
+
1031
+ self.assertEqual(deleted, 1)
1032
+ self.assertFalse(LandingLead.objects.filter(pk=stale.pk).exists())
1033
+ self.assertTrue(LandingLead.objects.filter(pk=recent.pk).exists())
1034
+
1035
+
897
1036
  class LogViewerAdminTests(SimpleTestCase):
898
1037
  def setUp(self):
899
1038
  self.factory = RequestFactory()
@@ -1092,6 +1231,45 @@ class SiteAdminScreenshotTests(TestCase):
1092
1231
  mock_capture.assert_called_once_with("http://testserver/")
1093
1232
 
1094
1233
 
1234
+ class SiteAdminReloadFixturesTests(TestCase):
1235
+ def setUp(self):
1236
+ self.client = Client()
1237
+ User = get_user_model()
1238
+ self.admin = User.objects.create_superuser(
1239
+ username="fixture-admin", password="pwd", email="admin@example.com"
1240
+ )
1241
+ self.client.force_login(self.admin)
1242
+ Site.objects.update_or_create(
1243
+ id=1, defaults={"name": "Terminal", "domain": "testserver"}
1244
+ )
1245
+
1246
+ @patch("pages.admin.call_command")
1247
+ def test_reload_site_fixtures_action(self, mock_call_command):
1248
+ response = self.client.post(
1249
+ reverse("admin:pages_siteproxy_changelist"),
1250
+ {"action": "reload_site_fixtures", "_selected_action": [1]},
1251
+ follow=True,
1252
+ )
1253
+ self.assertEqual(response.status_code, 200)
1254
+
1255
+ fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
1256
+ expected = sorted(fixtures_dir.glob("references__00_site_*.json"))
1257
+ sigil_fixture = fixtures_dir / "sigil_roots__site.json"
1258
+ if sigil_fixture.exists():
1259
+ expected.append(sigil_fixture)
1260
+
1261
+ expected_calls = [
1262
+ call("loaddata", str(path), verbosity=0) for path in expected
1263
+ ]
1264
+ self.assertEqual(mock_call_command.call_args_list, expected_calls)
1265
+
1266
+ if expected_calls:
1267
+ self.assertContains(
1268
+ response,
1269
+ f"Reloaded {len(expected_calls)} site fixtures.",
1270
+ )
1271
+
1272
+
1095
1273
  class AdminBadgesWebsiteTests(TestCase):
1096
1274
  def setUp(self):
1097
1275
  self.client = Client()
@@ -1358,6 +1536,12 @@ class ControlNavTests(TestCase):
1358
1536
  id=1, defaults={"domain": "testserver", "name": ""}
1359
1537
  )
1360
1538
  fixtures = [
1539
+ Path(
1540
+ settings.BASE_DIR,
1541
+ "pages",
1542
+ "fixtures",
1543
+ "default__application_pages.json",
1544
+ ),
1361
1545
  Path(
1362
1546
  settings.BASE_DIR,
1363
1547
  "pages",
@@ -1388,6 +1572,18 @@ class ControlNavTests(TestCase):
1388
1572
  "fixtures",
1389
1573
  "control__landing_ocpp_rfid.json",
1390
1574
  ),
1575
+ Path(
1576
+ settings.BASE_DIR,
1577
+ "pages",
1578
+ "fixtures",
1579
+ "control__module_readme.json",
1580
+ ),
1581
+ Path(
1582
+ settings.BASE_DIR,
1583
+ "pages",
1584
+ "fixtures",
1585
+ "control__landing_readme.json",
1586
+ ),
1391
1587
  ]
1392
1588
  call_command("loaddata", *map(str, fixtures))
1393
1589
 
@@ -1428,6 +1624,84 @@ class ControlNavTests(TestCase):
1428
1624
  self.assertFalse(resp.context["header_references"])
1429
1625
  self.assertNotContains(resp, "https://example.com/hidden")
1430
1626
 
1627
+ def test_readme_pill_visible(self):
1628
+ resp = self.client.get(reverse("pages:readme"))
1629
+ self.assertContains(resp, 'href="/readme/"')
1630
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">README')
1631
+
1632
+
1633
+ class SatelliteNavTests(TestCase):
1634
+ def setUp(self):
1635
+ self.client = Client()
1636
+ role, _ = NodeRole.objects.get_or_create(name="Satellite")
1637
+ Node.objects.update_or_create(
1638
+ mac_address=Node.get_current_mac(),
1639
+ defaults={
1640
+ "hostname": "localhost",
1641
+ "address": "127.0.0.1",
1642
+ "role": role,
1643
+ },
1644
+ )
1645
+ Site.objects.update_or_create(
1646
+ id=1, defaults={"domain": "testserver", "name": ""}
1647
+ )
1648
+ fixtures = [
1649
+ Path(
1650
+ settings.BASE_DIR,
1651
+ "pages",
1652
+ "fixtures",
1653
+ "default__application_pages.json",
1654
+ ),
1655
+ Path(
1656
+ settings.BASE_DIR,
1657
+ "pages",
1658
+ "fixtures",
1659
+ "satellite_box__application_ocpp.json",
1660
+ ),
1661
+ Path(
1662
+ settings.BASE_DIR,
1663
+ "pages",
1664
+ "fixtures",
1665
+ "satellite_box__module_ocpp.json",
1666
+ ),
1667
+ Path(
1668
+ settings.BASE_DIR,
1669
+ "pages",
1670
+ "fixtures",
1671
+ "satellite_box__landing_ocpp_dashboard.json",
1672
+ ),
1673
+ Path(
1674
+ settings.BASE_DIR,
1675
+ "pages",
1676
+ "fixtures",
1677
+ "satellite_box__landing_ocpp_cp_simulator.json",
1678
+ ),
1679
+ Path(
1680
+ settings.BASE_DIR,
1681
+ "pages",
1682
+ "fixtures",
1683
+ "satellite_box__landing_ocpp_rfid.json",
1684
+ ),
1685
+ Path(
1686
+ settings.BASE_DIR,
1687
+ "pages",
1688
+ "fixtures",
1689
+ "satellite_box__module_readme.json",
1690
+ ),
1691
+ Path(
1692
+ settings.BASE_DIR,
1693
+ "pages",
1694
+ "fixtures",
1695
+ "satellite_box__landing_readme.json",
1696
+ ),
1697
+ ]
1698
+ call_command("loaddata", *map(str, fixtures))
1699
+
1700
+ def test_readme_pill_visible(self):
1701
+ resp = self.client.get(reverse("pages:readme"))
1702
+ self.assertContains(resp, 'href="/readme/"')
1703
+ self.assertContains(resp, 'badge rounded-pill text-bg-secondary">README')
1704
+
1431
1705
 
1432
1706
  class PowerNavTests(TestCase):
1433
1707
  def setUp(self):
@@ -2069,6 +2343,40 @@ class FavoriteTests(TestCase):
2069
2343
  self.assertNotIn("Packages", labels)
2070
2344
  ContentType.objects.clear_cache()
2071
2345
 
2346
+ def test_future_action_items_limits_user_data_queries(self):
2347
+ from pages.templatetags import admin_extras
2348
+
2349
+ cache.delete(admin_extras.USER_DATA_MODELS_CACHE_KEY)
2350
+ self.addCleanup(cache.delete, admin_extras.USER_DATA_MODELS_CACHE_KEY)
2351
+
2352
+ for index in range(3):
2353
+ NodeRole.objects.create(name=f"CachedRole{index}", is_user_data=True)
2354
+ for index in range(2):
2355
+ NodeFeature.objects.create(
2356
+ slug=f"cached-feature-{index}",
2357
+ display=f"Feature {index}",
2358
+ is_user_data=True,
2359
+ )
2360
+
2361
+ Node.objects.create(
2362
+ hostname="cached-node",
2363
+ address="127.0.0.1",
2364
+ mac_address="AA:BB:CC:DD:EE:FF",
2365
+ port=8000,
2366
+ is_user_data=True,
2367
+ )
2368
+
2369
+ response = self.client.get(reverse("admin:index"))
2370
+ request = response.wsgi_request
2371
+
2372
+ admin_extras.future_action_items({"request": request})
2373
+ with CaptureQueriesContext(connection) as ctx:
2374
+ admin_extras.future_action_items({"request": request})
2375
+
2376
+ # History and favorites queries should remain bounded regardless of the
2377
+ # number of Entity subclasses with user data.
2378
+ self.assertLessEqual(len(ctx.captured_queries), 4)
2379
+
2072
2380
  def test_favorite_ct_id_recreates_missing_content_type(self):
2073
2381
  ct = ContentType.objects.get_by_natural_key("pages", "application")
2074
2382
  ct.delete()
@@ -2172,6 +2480,62 @@ class FavoriteTests(TestCase):
2172
2480
  self.assertContains(resp, todo.request)
2173
2481
 
2174
2482
 
2483
+ class AdminIndexQueryRegressionTests(TestCase):
2484
+ def setUp(self):
2485
+ User = get_user_model()
2486
+ self.client = Client()
2487
+ self.user = User.objects.create_superuser(
2488
+ username="queryadmin", password="pwd", email="query@example.com"
2489
+ )
2490
+ self.client.force_login(self.user)
2491
+ Site.objects.update_or_create(
2492
+ id=1, defaults={"name": "test", "domain": "testserver"}
2493
+ )
2494
+ favorite_cts = [
2495
+ ContentType.objects.get_for_model(Application),
2496
+ ContentType.objects.get_for_model(Landing),
2497
+ ]
2498
+ for ct in favorite_cts:
2499
+ Favorite.objects.create(user=self.user, content_type=ct)
2500
+
2501
+ def _render_admin_and_count_queries(self):
2502
+ url = reverse("admin:index")
2503
+ with CaptureQueriesContext(connection) as ctx:
2504
+ response = self.client.get(url)
2505
+ self.assertEqual(response.status_code, 200)
2506
+ sql_statements = [query["sql"].lower() for query in ctx.captured_queries]
2507
+ ct_queries = [sql for sql in sql_statements if '"django_content_type"' in sql]
2508
+ favorite_queries = [sql for sql in sql_statements if '"pages_favorite"' in sql]
2509
+ return len(ct_queries), len(favorite_queries)
2510
+
2511
+ def test_admin_index_queries_constant_with_more_models(self):
2512
+ site = admin.site
2513
+ original_registry = site._registry.copy()
2514
+ registry_items = list(original_registry.items())
2515
+ if len(registry_items) < 2:
2516
+ self.skipTest("Not enough registered admin models for regression test")
2517
+
2518
+ for model in original_registry.keys():
2519
+ ContentType.objects.get_for_model(model)
2520
+
2521
+ try:
2522
+ site._registry = dict(registry_items[:1])
2523
+ baseline_ct_queries, baseline_favorite_queries = (
2524
+ self._render_admin_and_count_queries()
2525
+ )
2526
+
2527
+ expanded_limit = min(len(registry_items), 5)
2528
+ site._registry = dict(registry_items[:expanded_limit])
2529
+ expanded_ct_queries, expanded_favorite_queries = (
2530
+ self._render_admin_and_count_queries()
2531
+ )
2532
+ finally:
2533
+ site._registry = original_registry
2534
+
2535
+ self.assertEqual(expanded_ct_queries, baseline_ct_queries)
2536
+ self.assertEqual(expanded_favorite_queries, baseline_favorite_queries)
2537
+
2538
+
2175
2539
  class AdminActionListTests(TestCase):
2176
2540
  def setUp(self):
2177
2541
  User = get_user_model()
@@ -2446,6 +2810,55 @@ class UserStorySubmissionTests(TestCase):
2446
2810
  self.assertIsNone(story.owner)
2447
2811
 
2448
2812
 
2813
+ class UserStoryIssueAutomationTests(TestCase):
2814
+ def setUp(self):
2815
+ self.lock_dir = Path(settings.BASE_DIR) / "locks"
2816
+ self.lock_dir.mkdir(parents=True, exist_ok=True)
2817
+ self.lock_file = self.lock_dir / "celery.lck"
2818
+
2819
+ def tearDown(self):
2820
+ self.lock_file.unlink(missing_ok=True)
2821
+
2822
+ def test_low_rating_story_enqueues_issue_creation_when_celery_enabled(self):
2823
+ self.lock_file.write_text("")
2824
+
2825
+ with patch("pages.models.create_user_story_github_issue.delay") as mock_delay:
2826
+ story = UserStory.objects.create(
2827
+ path="/feedback/",
2828
+ rating=2,
2829
+ comments="Needs work",
2830
+ take_screenshot=False,
2831
+ )
2832
+
2833
+ mock_delay.assert_called_once_with(story.pk)
2834
+
2835
+ def test_five_star_story_does_not_enqueue_issue(self):
2836
+ self.lock_file.write_text("")
2837
+
2838
+ with patch("pages.models.create_user_story_github_issue.delay") as mock_delay:
2839
+ UserStory.objects.create(
2840
+ path="/feedback/",
2841
+ rating=5,
2842
+ comments="Great!",
2843
+ take_screenshot=True,
2844
+ )
2845
+
2846
+ mock_delay.assert_not_called()
2847
+
2848
+ def test_low_rating_story_skips_when_celery_disabled(self):
2849
+ self.lock_file.unlink(missing_ok=True)
2850
+
2851
+ with patch("pages.models.create_user_story_github_issue.delay") as mock_delay:
2852
+ UserStory.objects.create(
2853
+ path="/feedback/",
2854
+ rating=1,
2855
+ comments="Not good",
2856
+ take_screenshot=False,
2857
+ )
2858
+
2859
+ mock_delay.assert_not_called()
2860
+
2861
+
2449
2862
  class UserStoryAdminActionTests(TestCase):
2450
2863
  def setUp(self):
2451
2864
  self.client = Client()
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("readme/", views.readme, name="readme"),
9
10
  path("sitemap.xml", views.sitemap, name="pages-sitemap"),
10
11
  path("client-report/", views.client_report, name="client-report"),
11
12
  path("release-checklist", views.release_checklist, name="release-checklist"),
pages/utils.py CHANGED
@@ -10,3 +10,14 @@ def landing(label=None):
10
10
  return view
11
11
 
12
12
  return decorator
13
+
14
+
15
+ def landing_leads_supported() -> bool:
16
+ """Return ``True`` when the local node supports landing lead tracking."""
17
+
18
+ from nodes.models import Node
19
+
20
+ node = Node.get_local()
21
+ if not node:
22
+ return False
23
+ return node.has_feature("celery-queue")
pages/views.py CHANGED
@@ -411,6 +411,43 @@ def admin_model_graph(request, app_label: str):
411
411
  return TemplateResponse(request, "admin/model_graph.html", context)
412
412
 
413
413
 
414
+ def _render_readme(request, role):
415
+ app = (
416
+ Module.objects.filter(node_role=role, is_default=True)
417
+ .select_related("application")
418
+ .first()
419
+ )
420
+ app_slug = app.path.strip("/") if app else ""
421
+ readme_base = (
422
+ Path(settings.BASE_DIR) / app_slug if app_slug else Path(settings.BASE_DIR)
423
+ )
424
+ lang = getattr(request, "LANGUAGE_CODE", "")
425
+ lang = lang.replace("_", "-").lower()
426
+ root_base = Path(settings.BASE_DIR)
427
+ candidates = []
428
+ if lang:
429
+ candidates.append(readme_base / f"README.{lang}.md")
430
+ short = lang.split("-")[0]
431
+ if short != lang:
432
+ candidates.append(readme_base / f"README.{short}.md")
433
+ candidates.append(readme_base / "README.md")
434
+ if readme_base != root_base:
435
+ if lang:
436
+ candidates.append(root_base / f"README.{lang}.md")
437
+ short = lang.split("-")[0]
438
+ if short != lang:
439
+ candidates.append(root_base / f"README.{short}.md")
440
+ candidates.append(root_base / "README.md")
441
+ readme_file = next((p for p in candidates if p.exists()), root_base / "README.md")
442
+ text = readme_file.read_text(encoding="utf-8")
443
+ html, toc_html = _render_markdown_with_toc(text)
444
+ title = "README" if readme_file.name.startswith("README") else readme_file.stem
445
+ context = {"content": html, "title": title, "toc": toc_html}
446
+ response = render(request, "pages/readme.html", context)
447
+ patch_vary_headers(response, ["Accept-Language", "Cookie"])
448
+ return response
449
+
450
+
414
451
  @landing("Home")
415
452
  @never_cache
416
453
  def index(request):
@@ -456,40 +493,14 @@ def index(request):
456
493
  target_path = landing_obj.path
457
494
  if target_path and target_path != request.path:
458
495
  return redirect(target_path)
459
- app = (
460
- Module.objects.filter(node_role=role, is_default=True)
461
- .select_related("application")
462
- .first()
463
- )
464
- app_slug = app.path.strip("/") if app else ""
465
- readme_base = (
466
- Path(settings.BASE_DIR) / app_slug if app_slug else Path(settings.BASE_DIR)
467
- )
468
- lang = getattr(request, "LANGUAGE_CODE", "")
469
- lang = lang.replace("_", "-").lower()
470
- root_base = Path(settings.BASE_DIR)
471
- candidates = []
472
- if lang:
473
- candidates.append(readme_base / f"README.{lang}.md")
474
- short = lang.split("-")[0]
475
- if short != lang:
476
- candidates.append(readme_base / f"README.{short}.md")
477
- candidates.append(readme_base / "README.md")
478
- if readme_base != root_base:
479
- if lang:
480
- candidates.append(root_base / f"README.{lang}.md")
481
- short = lang.split("-")[0]
482
- if short != lang:
483
- candidates.append(root_base / f"README.{short}.md")
484
- candidates.append(root_base / "README.md")
485
- readme_file = next((p for p in candidates if p.exists()), root_base / "README.md")
486
- text = readme_file.read_text(encoding="utf-8")
487
- html, toc_html = _render_markdown_with_toc(text)
488
- title = "README" if readme_file.name.startswith("README") else readme_file.stem
489
- context = {"content": html, "title": title, "toc": toc_html}
490
- response = render(request, "pages/readme.html", context)
491
- patch_vary_headers(response, ["Accept-Language", "Cookie"])
492
- return response
496
+ return _render_readme(request, role)
497
+
498
+
499
+ @never_cache
500
+ def readme(request):
501
+ node = Node.get_local()
502
+ role = node.role if node else None
503
+ return _render_readme(request, role)
493
504
 
494
505
 
495
506
  def sitemap(request):