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.
- {arthexis-0.1.14.dist-info → arthexis-0.1.15.dist-info}/METADATA +4 -2
- {arthexis-0.1.14.dist-info → arthexis-0.1.15.dist-info}/RECORD +23 -22
- core/admin.py +26 -2
- core/entity.py +17 -1
- core/log_paths.py +24 -10
- core/models.py +28 -0
- core/release.py +121 -2
- core/system.py +203 -3
- core/tests.py +73 -0
- core/views.py +32 -6
- nodes/models.py +29 -2
- nodes/tests.py +23 -3
- pages/admin.py +62 -1
- pages/middleware.py +4 -0
- pages/models.py +36 -0
- pages/tasks.py +74 -0
- pages/tests.py +414 -1
- pages/urls.py +1 -0
- pages/utils.py +11 -0
- pages/views.py +45 -34
- {arthexis-0.1.14.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
- {arthexis-0.1.14.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.14.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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):
|