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
core/system.py
CHANGED
|
@@ -10,17 +10,20 @@ import re
|
|
|
10
10
|
import socket
|
|
11
11
|
import subprocess
|
|
12
12
|
import shutil
|
|
13
|
+
import logging
|
|
13
14
|
from typing import Callable, Iterable, Optional
|
|
14
15
|
|
|
15
16
|
from django.conf import settings
|
|
16
|
-
from django.contrib import admin
|
|
17
|
+
from django.contrib import admin, messages
|
|
17
18
|
from django.template.response import TemplateResponse
|
|
18
|
-
from django.
|
|
19
|
+
from django.http import HttpResponseRedirect
|
|
20
|
+
from django.urls import path, reverse
|
|
19
21
|
from django.utils import timezone
|
|
20
22
|
from django.utils.formats import date_format
|
|
21
|
-
from django.utils.translation import gettext_lazy as _
|
|
23
|
+
from django.utils.translation import gettext_lazy as _, ngettext
|
|
22
24
|
|
|
23
25
|
from core.auto_upgrade import AUTO_UPGRADE_TASK_NAME, AUTO_UPGRADE_TASK_PATH
|
|
26
|
+
from core import changelog as changelog_utils
|
|
24
27
|
from utils import revision
|
|
25
28
|
|
|
26
29
|
|
|
@@ -29,6 +32,9 @@ AUTO_UPGRADE_SKIP_LOCK_NAME = "auto_upgrade_skip_revisions.lck"
|
|
|
29
32
|
AUTO_UPGRADE_LOG_NAME = "auto-upgrade.log"
|
|
30
33
|
|
|
31
34
|
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
32
38
|
def _auto_upgrade_mode_file(base_dir: Path) -> Path:
|
|
33
39
|
return base_dir / "locks" / AUTO_UPGRADE_LOCK_NAME
|
|
34
40
|
|
|
@@ -41,6 +47,132 @@ def _auto_upgrade_log_file(base_dir: Path) -> Path:
|
|
|
41
47
|
return base_dir / "logs" / AUTO_UPGRADE_LOG_NAME
|
|
42
48
|
|
|
43
49
|
|
|
50
|
+
def _open_changelog_entries() -> list[dict[str, str]]:
|
|
51
|
+
"""Return changelog entries that are not yet part of a tagged release."""
|
|
52
|
+
|
|
53
|
+
changelog_path = Path("CHANGELOG.rst")
|
|
54
|
+
try:
|
|
55
|
+
text = changelog_path.read_text(encoding="utf-8")
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
return []
|
|
58
|
+
except OSError:
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
collecting = False
|
|
62
|
+
entries: list[dict[str, str]] = []
|
|
63
|
+
for raw_line in text.splitlines():
|
|
64
|
+
line = raw_line.strip()
|
|
65
|
+
if not collecting:
|
|
66
|
+
if line == "Unreleased":
|
|
67
|
+
collecting = True
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if not line:
|
|
71
|
+
if entries:
|
|
72
|
+
break
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if set(line) == {"-"}:
|
|
76
|
+
# Underline immediately following the section heading.
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if not line.startswith("- "):
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
trimmed = line[2:].strip()
|
|
83
|
+
if not trimmed:
|
|
84
|
+
continue
|
|
85
|
+
parts = trimmed.split(" ", 1)
|
|
86
|
+
sha = parts[0]
|
|
87
|
+
message = parts[1] if len(parts) > 1 else ""
|
|
88
|
+
entries.append({"sha": sha, "message": message})
|
|
89
|
+
|
|
90
|
+
return entries
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _exclude_changelog_entries(shas: Iterable[str]) -> int:
|
|
94
|
+
"""Remove entries matching ``shas`` from the changelog.
|
|
95
|
+
|
|
96
|
+
Returns the number of entries removed. Only entries within the
|
|
97
|
+
``Unreleased`` section are considered.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
normalized_shas = {sha.strip() for sha in shas if sha and sha.strip()}
|
|
101
|
+
if not normalized_shas:
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
changelog_path = Path("CHANGELOG.rst")
|
|
105
|
+
try:
|
|
106
|
+
text = changelog_path.read_text(encoding="utf-8")
|
|
107
|
+
except (FileNotFoundError, OSError):
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
lines = text.splitlines(keepends=True)
|
|
111
|
+
new_lines: list[str] = []
|
|
112
|
+
collecting = False
|
|
113
|
+
removed = 0
|
|
114
|
+
|
|
115
|
+
for raw_line in lines:
|
|
116
|
+
stripped = raw_line.strip()
|
|
117
|
+
|
|
118
|
+
if not collecting:
|
|
119
|
+
new_lines.append(raw_line)
|
|
120
|
+
if stripped == "Unreleased":
|
|
121
|
+
collecting = True
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
if not stripped:
|
|
125
|
+
new_lines.append(raw_line)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
if set(stripped) == {"-"}:
|
|
129
|
+
new_lines.append(raw_line)
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if not stripped.startswith("- "):
|
|
133
|
+
new_lines.append(raw_line)
|
|
134
|
+
collecting = False
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
trimmed = stripped[2:].strip()
|
|
138
|
+
if not trimmed:
|
|
139
|
+
new_lines.append(raw_line)
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
sha = trimmed.split(" ", 1)[0]
|
|
143
|
+
if sha in normalized_shas:
|
|
144
|
+
removed += 1
|
|
145
|
+
normalized_shas.remove(sha)
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
new_lines.append(raw_line)
|
|
149
|
+
|
|
150
|
+
if removed:
|
|
151
|
+
new_text = "".join(new_lines)
|
|
152
|
+
if not new_text.endswith("\n"):
|
|
153
|
+
new_text += "\n"
|
|
154
|
+
changelog_path.write_text(new_text, encoding="utf-8")
|
|
155
|
+
|
|
156
|
+
return removed
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _regenerate_changelog() -> None:
|
|
160
|
+
"""Rebuild the changelog file using recent git commits."""
|
|
161
|
+
|
|
162
|
+
changelog_path = Path("CHANGELOG.rst")
|
|
163
|
+
previous_text = (
|
|
164
|
+
changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else None
|
|
165
|
+
)
|
|
166
|
+
range_spec = changelog_utils.determine_range_spec()
|
|
167
|
+
sections = changelog_utils.collect_sections(
|
|
168
|
+
range_spec=range_spec, previous_text=previous_text
|
|
169
|
+
)
|
|
170
|
+
content = changelog_utils.render_changelog(sections)
|
|
171
|
+
if not content.endswith("\n"):
|
|
172
|
+
content += "\n"
|
|
173
|
+
changelog_path.write_text(content, encoding="utf-8")
|
|
174
|
+
|
|
175
|
+
|
|
44
176
|
@dataclass(frozen=True)
|
|
45
177
|
class SystemField:
|
|
46
178
|
"""Metadata describing a single entry on the system admin page."""
|
|
@@ -721,6 +853,69 @@ def _system_view(request):
|
|
|
721
853
|
return TemplateResponse(request, "admin/system.html", context)
|
|
722
854
|
|
|
723
855
|
|
|
856
|
+
def _system_changelog_report_view(request):
|
|
857
|
+
if request.method == "POST":
|
|
858
|
+
action = request.POST.get("action")
|
|
859
|
+
if action == "exclude":
|
|
860
|
+
selected_shas = request.POST.getlist("selected_shas")
|
|
861
|
+
removed = _exclude_changelog_entries(selected_shas)
|
|
862
|
+
if removed:
|
|
863
|
+
messages.success(
|
|
864
|
+
request,
|
|
865
|
+
ngettext(
|
|
866
|
+
"Excluded %(count)d changelog entry.",
|
|
867
|
+
"Excluded %(count)d changelog entries.",
|
|
868
|
+
removed,
|
|
869
|
+
)
|
|
870
|
+
% {"count": removed},
|
|
871
|
+
)
|
|
872
|
+
else:
|
|
873
|
+
if selected_shas:
|
|
874
|
+
messages.info(
|
|
875
|
+
request,
|
|
876
|
+
_(
|
|
877
|
+
"The selected changelog entries were not found or have already been excluded."
|
|
878
|
+
),
|
|
879
|
+
)
|
|
880
|
+
else:
|
|
881
|
+
messages.info(
|
|
882
|
+
request,
|
|
883
|
+
_("Select at least one changelog entry to exclude."),
|
|
884
|
+
)
|
|
885
|
+
else:
|
|
886
|
+
try:
|
|
887
|
+
_regenerate_changelog()
|
|
888
|
+
except subprocess.CalledProcessError as exc:
|
|
889
|
+
logger.exception("Changelog regeneration failed")
|
|
890
|
+
messages.error(
|
|
891
|
+
request,
|
|
892
|
+
_("Unable to recalculate the changelog: %(error)s")
|
|
893
|
+
% {"error": exc.stderr.strip() if exc.stderr else str(exc)},
|
|
894
|
+
)
|
|
895
|
+
except Exception as exc: # pragma: no cover - unexpected failure
|
|
896
|
+
logger.exception("Unexpected error while regenerating changelog")
|
|
897
|
+
messages.error(
|
|
898
|
+
request,
|
|
899
|
+
_("Unable to recalculate the changelog: %(error)s")
|
|
900
|
+
% {"error": str(exc)},
|
|
901
|
+
)
|
|
902
|
+
else:
|
|
903
|
+
messages.success(
|
|
904
|
+
request,
|
|
905
|
+
_("Successfully recalculated the changelog from recent commits."),
|
|
906
|
+
)
|
|
907
|
+
return HttpResponseRedirect(reverse("admin:system-changelog-report"))
|
|
908
|
+
|
|
909
|
+
context = admin.site.each_context(request)
|
|
910
|
+
context.update(
|
|
911
|
+
{
|
|
912
|
+
"title": _("Changelog Report"),
|
|
913
|
+
"open_changelog_entries": _open_changelog_entries(),
|
|
914
|
+
}
|
|
915
|
+
)
|
|
916
|
+
return TemplateResponse(request, "admin/system_changelog_report.html", context)
|
|
917
|
+
|
|
918
|
+
|
|
724
919
|
def _system_upgrade_report_view(request):
|
|
725
920
|
context = admin.site.each_context(request)
|
|
726
921
|
context.update(
|
|
@@ -740,6 +935,11 @@ def patch_admin_system_view() -> None:
|
|
|
740
935
|
urls = original_get_urls()
|
|
741
936
|
custom = [
|
|
742
937
|
path("system/", admin.site.admin_view(_system_view), name="system"),
|
|
938
|
+
path(
|
|
939
|
+
"system/changelog-report/",
|
|
940
|
+
admin.site.admin_view(_system_changelog_report_view),
|
|
941
|
+
name="system-changelog-report",
|
|
942
|
+
),
|
|
743
943
|
path(
|
|
744
944
|
"system/upgrade-report/",
|
|
745
945
|
admin.site.admin_view(_system_upgrade_report_view),
|
core/tests.py
CHANGED
|
@@ -1051,6 +1051,79 @@ class ReleaseProcessTests(TestCase):
|
|
|
1051
1051
|
requests_get.assert_called_once()
|
|
1052
1052
|
sync_main.assert_called_once_with(Path("rel.log"))
|
|
1053
1053
|
|
|
1054
|
+
@mock.patch("core.views.release_utils.network_available", return_value=False)
|
|
1055
|
+
@mock.patch("core.views._collect_dirty_files")
|
|
1056
|
+
@mock.patch("core.views._sync_with_origin_main")
|
|
1057
|
+
@mock.patch("core.views.subprocess.run")
|
|
1058
|
+
@mock.patch("core.views.release_utils._git_clean", return_value=False)
|
|
1059
|
+
def test_step_check_commits_release_prep_changes(
|
|
1060
|
+
self,
|
|
1061
|
+
git_clean,
|
|
1062
|
+
subprocess_run,
|
|
1063
|
+
sync_main,
|
|
1064
|
+
collect_dirty,
|
|
1065
|
+
network_available,
|
|
1066
|
+
):
|
|
1067
|
+
fixture_path = next(Path("core/fixtures").glob("releases__*.json"))
|
|
1068
|
+
collect_dirty.return_value = [
|
|
1069
|
+
{
|
|
1070
|
+
"path": str(fixture_path),
|
|
1071
|
+
"status": "M",
|
|
1072
|
+
"status_label": "Modified",
|
|
1073
|
+
},
|
|
1074
|
+
{"path": "CHANGELOG.rst", "status": "M", "status_label": "Modified"},
|
|
1075
|
+
]
|
|
1076
|
+
subprocess_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
|
|
1077
|
+
|
|
1078
|
+
ctx: dict[str, object] = {}
|
|
1079
|
+
_step_check_version(self.release, ctx, Path("rel.log"))
|
|
1080
|
+
|
|
1081
|
+
add_call = mock.call(
|
|
1082
|
+
["git", "add", str(fixture_path), "CHANGELOG.rst"],
|
|
1083
|
+
check=True,
|
|
1084
|
+
)
|
|
1085
|
+
commit_call = mock.call(
|
|
1086
|
+
[
|
|
1087
|
+
"git",
|
|
1088
|
+
"commit",
|
|
1089
|
+
"-m",
|
|
1090
|
+
"chore: sync release fixtures and changelog",
|
|
1091
|
+
],
|
|
1092
|
+
check=True,
|
|
1093
|
+
)
|
|
1094
|
+
self.assertIn(add_call, subprocess_run.call_args_list)
|
|
1095
|
+
self.assertIn(commit_call, subprocess_run.call_args_list)
|
|
1096
|
+
self.assertNotIn("dirty_files", ctx)
|
|
1097
|
+
|
|
1098
|
+
@mock.patch("core.views.release_utils.network_available", return_value=False)
|
|
1099
|
+
@mock.patch("core.views._collect_dirty_files")
|
|
1100
|
+
@mock.patch("core.views._sync_with_origin_main")
|
|
1101
|
+
@mock.patch("core.views.subprocess.run")
|
|
1102
|
+
@mock.patch("core.views.release_utils._git_clean", return_value=False)
|
|
1103
|
+
def test_step_check_commits_changelog_only(
|
|
1104
|
+
self,
|
|
1105
|
+
git_clean,
|
|
1106
|
+
subprocess_run,
|
|
1107
|
+
sync_main,
|
|
1108
|
+
collect_dirty,
|
|
1109
|
+
network_available,
|
|
1110
|
+
):
|
|
1111
|
+
collect_dirty.return_value = [
|
|
1112
|
+
{"path": "CHANGELOG.rst", "status": "M", "status_label": "Modified"}
|
|
1113
|
+
]
|
|
1114
|
+
subprocess_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
|
|
1115
|
+
|
|
1116
|
+
ctx: dict[str, object] = {}
|
|
1117
|
+
_step_check_version(self.release, ctx, Path("rel.log"))
|
|
1118
|
+
|
|
1119
|
+
subprocess_run.assert_any_call(
|
|
1120
|
+
["git", "add", "CHANGELOG.rst"], check=True
|
|
1121
|
+
)
|
|
1122
|
+
subprocess_run.assert_any_call(
|
|
1123
|
+
["git", "commit", "-m", "docs: refresh changelog"], check=True
|
|
1124
|
+
)
|
|
1125
|
+
self.assertNotIn("dirty_files", ctx)
|
|
1126
|
+
|
|
1054
1127
|
@mock.patch("core.models.PackageRelease.dump_fixture")
|
|
1055
1128
|
def test_save_does_not_dump_fixture(self, dump):
|
|
1056
1129
|
self.release.pypi_url = "https://example.com"
|
core/views.py
CHANGED
|
@@ -859,7 +859,12 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
|
859
859
|
for f in files
|
|
860
860
|
if "fixtures" in Path(f).parts and Path(f).suffix == ".json"
|
|
861
861
|
]
|
|
862
|
-
|
|
862
|
+
changelog_dirty = "CHANGELOG.rst" in files
|
|
863
|
+
allowed_dirty_files = set(fixture_files)
|
|
864
|
+
if changelog_dirty:
|
|
865
|
+
allowed_dirty_files.add("CHANGELOG.rst")
|
|
866
|
+
|
|
867
|
+
if files and len(allowed_dirty_files) == len(files):
|
|
863
868
|
summary = []
|
|
864
869
|
for f in fixture_files:
|
|
865
870
|
path = Path(f)
|
|
@@ -887,15 +892,36 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
|
887
892
|
summary.append({"path": f, "count": count, "models": models})
|
|
888
893
|
|
|
889
894
|
ctx["fixtures"] = summary
|
|
895
|
+
commit_paths = [*fixture_files]
|
|
896
|
+
if changelog_dirty:
|
|
897
|
+
commit_paths.append("CHANGELOG.rst")
|
|
898
|
+
|
|
899
|
+
log_fragments = []
|
|
900
|
+
if fixture_files:
|
|
901
|
+
log_fragments.append(
|
|
902
|
+
"fixtures " + ", ".join(fixture_files)
|
|
903
|
+
)
|
|
904
|
+
if changelog_dirty:
|
|
905
|
+
log_fragments.append("CHANGELOG.rst")
|
|
906
|
+
details = ", ".join(log_fragments) if log_fragments else "changes"
|
|
890
907
|
_append_log(
|
|
891
908
|
log_path,
|
|
892
|
-
"Committing
|
|
909
|
+
f"Committing release prep changes: {details}",
|
|
893
910
|
)
|
|
894
|
-
subprocess.run(["git", "add", *
|
|
895
|
-
|
|
896
|
-
|
|
911
|
+
subprocess.run(["git", "add", *commit_paths], check=True)
|
|
912
|
+
|
|
913
|
+
if changelog_dirty and fixture_files:
|
|
914
|
+
commit_message = "chore: sync release fixtures and changelog"
|
|
915
|
+
elif changelog_dirty:
|
|
916
|
+
commit_message = "docs: refresh changelog"
|
|
917
|
+
else:
|
|
918
|
+
commit_message = "chore: update fixtures"
|
|
919
|
+
|
|
920
|
+
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
|
921
|
+
_append_log(
|
|
922
|
+
log_path,
|
|
923
|
+
f"Release prep changes committed ({commit_message})",
|
|
897
924
|
)
|
|
898
|
-
_append_log(log_path, "Fixture changes committed")
|
|
899
925
|
ctx.pop("dirty_files", None)
|
|
900
926
|
ctx.pop("dirty_commit_error", None)
|
|
901
927
|
retry_sync = True
|
nodes/models.py
CHANGED
|
@@ -16,7 +16,6 @@ import base64
|
|
|
16
16
|
from django.utils import timezone
|
|
17
17
|
from django.utils.text import slugify
|
|
18
18
|
from django.conf import settings
|
|
19
|
-
from django.contrib.sites.models import Site
|
|
20
19
|
from datetime import timedelta
|
|
21
20
|
import uuid
|
|
22
21
|
import os
|
|
@@ -344,7 +343,6 @@ class Node(Entity):
|
|
|
344
343
|
if terminal:
|
|
345
344
|
node.role = terminal
|
|
346
345
|
node.save(update_fields=["role"])
|
|
347
|
-
Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
|
|
348
346
|
node.ensure_keys()
|
|
349
347
|
node.notify_peers_of_update()
|
|
350
348
|
return node, created
|
|
@@ -745,8 +743,10 @@ class Node(Entity):
|
|
|
745
743
|
def sync_feature_tasks(self):
|
|
746
744
|
clipboard_enabled = self.has_feature("clipboard-poll")
|
|
747
745
|
screenshot_enabled = self.has_feature("screenshot-poll")
|
|
746
|
+
celery_enabled = self.is_local and self.has_feature("celery-queue")
|
|
748
747
|
self._sync_clipboard_task(clipboard_enabled)
|
|
749
748
|
self._sync_screenshot_task(screenshot_enabled)
|
|
749
|
+
self._sync_landing_lead_task(celery_enabled)
|
|
750
750
|
|
|
751
751
|
def _sync_clipboard_task(self, enabled: bool):
|
|
752
752
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
@@ -792,6 +792,33 @@ class Node(Entity):
|
|
|
792
792
|
else:
|
|
793
793
|
PeriodicTask.objects.filter(name=task_name).delete()
|
|
794
794
|
|
|
795
|
+
def _sync_landing_lead_task(self, enabled: bool):
|
|
796
|
+
if not self.is_local:
|
|
797
|
+
return
|
|
798
|
+
|
|
799
|
+
from django_celery_beat.models import CrontabSchedule, PeriodicTask
|
|
800
|
+
|
|
801
|
+
task_name = "pages_purge_landing_leads"
|
|
802
|
+
if enabled:
|
|
803
|
+
schedule, _ = CrontabSchedule.objects.get_or_create(
|
|
804
|
+
minute="0",
|
|
805
|
+
hour="3",
|
|
806
|
+
day_of_week="*",
|
|
807
|
+
day_of_month="*",
|
|
808
|
+
month_of_year="*",
|
|
809
|
+
)
|
|
810
|
+
PeriodicTask.objects.update_or_create(
|
|
811
|
+
name=task_name,
|
|
812
|
+
defaults={
|
|
813
|
+
"crontab": schedule,
|
|
814
|
+
"interval": None,
|
|
815
|
+
"task": "pages.tasks.purge_expired_landing_leads",
|
|
816
|
+
"enabled": True,
|
|
817
|
+
},
|
|
818
|
+
)
|
|
819
|
+
else:
|
|
820
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
821
|
+
|
|
795
822
|
def send_mail(
|
|
796
823
|
self,
|
|
797
824
|
subject: str,
|
nodes/tests.py
CHANGED
|
@@ -35,7 +35,6 @@ from django.urls import reverse
|
|
|
35
35
|
from django.contrib.auth import get_user_model
|
|
36
36
|
from django.contrib import admin
|
|
37
37
|
from django.contrib.auth.models import Permission
|
|
38
|
-
from django.contrib.sites.models import Site
|
|
39
38
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
40
39
|
from django.conf import settings
|
|
41
40
|
from django.utils import timezone
|
|
@@ -1271,6 +1270,29 @@ class NodeRegisterCurrentTests(TestCase):
|
|
|
1271
1270
|
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1272
1271
|
self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists())
|
|
1273
1272
|
|
|
1273
|
+
def test_landing_lead_purge_task_syncs_with_celery_feature(self):
|
|
1274
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
1275
|
+
slug="celery-queue", defaults={"display": "Celery Queue"}
|
|
1276
|
+
)
|
|
1277
|
+
node, _ = Node.objects.update_or_create(
|
|
1278
|
+
mac_address=Node.get_current_mac(),
|
|
1279
|
+
defaults={
|
|
1280
|
+
"hostname": socket.gethostname(),
|
|
1281
|
+
"address": "127.0.0.1",
|
|
1282
|
+
"port": 9300,
|
|
1283
|
+
"base_path": settings.BASE_DIR,
|
|
1284
|
+
},
|
|
1285
|
+
)
|
|
1286
|
+
PeriodicTask.objects.filter(name="pages_purge_landing_leads").delete()
|
|
1287
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
1288
|
+
self.assertTrue(
|
|
1289
|
+
PeriodicTask.objects.filter(name="pages_purge_landing_leads").exists()
|
|
1290
|
+
)
|
|
1291
|
+
NodeFeatureAssignment.objects.filter(node=node, feature=feature).delete()
|
|
1292
|
+
self.assertFalse(
|
|
1293
|
+
PeriodicTask.objects.filter(name="pages_purge_landing_leads").exists()
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1274
1296
|
|
|
1275
1297
|
class CheckRegistrationReadyCommandTests(TestCase):
|
|
1276
1298
|
def test_command_completes_successfully(self):
|
|
@@ -1357,8 +1379,6 @@ class NodeAdminTests(TestCase):
|
|
|
1357
1379
|
self.assertTrue(priv.exists())
|
|
1358
1380
|
self.assertTrue(pub.exists())
|
|
1359
1381
|
self.assertTrue(node.public_key)
|
|
1360
|
-
self.assertTrue(Site.objects.filter(domain=hostname, name="host").exists())
|
|
1361
|
-
|
|
1362
1382
|
def test_register_current_updates_existing_node(self):
|
|
1363
1383
|
hostname = socket.gethostname()
|
|
1364
1384
|
Node.objects.create(
|
pages/admin.py
CHANGED
|
@@ -18,11 +18,13 @@ import ipaddress
|
|
|
18
18
|
from django.apps import apps as django_apps
|
|
19
19
|
from django.conf import settings
|
|
20
20
|
from django.utils.translation import gettext_lazy as _, ngettext
|
|
21
|
+
from django.core.management import CommandError, call_command
|
|
21
22
|
|
|
22
23
|
from nodes.models import Node
|
|
23
24
|
from nodes.utils import capture_screenshot, save_screenshot
|
|
24
25
|
|
|
25
26
|
from .forms import UserManualAdminForm
|
|
27
|
+
from .utils import landing_leads_supported
|
|
26
28
|
|
|
27
29
|
from .models import (
|
|
28
30
|
SiteBadge,
|
|
@@ -107,6 +109,49 @@ class SiteAdmin(DjangoSiteAdmin):
|
|
|
107
109
|
messages.INFO,
|
|
108
110
|
)
|
|
109
111
|
|
|
112
|
+
def _reload_site_fixtures(self, request):
|
|
113
|
+
fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
|
|
114
|
+
fixture_paths = sorted(fixtures_dir.glob("references__00_site_*.json"))
|
|
115
|
+
sigil_fixture = fixtures_dir / "sigil_roots__site.json"
|
|
116
|
+
if sigil_fixture.exists():
|
|
117
|
+
fixture_paths.append(sigil_fixture)
|
|
118
|
+
|
|
119
|
+
if not fixture_paths:
|
|
120
|
+
self.message_user(request, _("No site fixtures found."), messages.WARNING)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
loaded = 0
|
|
124
|
+
for path in fixture_paths:
|
|
125
|
+
try:
|
|
126
|
+
call_command("loaddata", str(path), verbosity=0)
|
|
127
|
+
except CommandError as exc:
|
|
128
|
+
self.message_user(
|
|
129
|
+
request,
|
|
130
|
+
_("%(fixture)s: %(error)s")
|
|
131
|
+
% {"fixture": path.name, "error": exc},
|
|
132
|
+
messages.ERROR,
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
loaded += 1
|
|
136
|
+
|
|
137
|
+
if loaded:
|
|
138
|
+
message = ngettext(
|
|
139
|
+
"Reloaded %(count)d site fixture.",
|
|
140
|
+
"Reloaded %(count)d site fixtures.",
|
|
141
|
+
loaded,
|
|
142
|
+
) % {"count": loaded}
|
|
143
|
+
self.message_user(request, message, messages.SUCCESS)
|
|
144
|
+
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def reload_site_fixtures(self, request):
|
|
148
|
+
if request.method != "POST":
|
|
149
|
+
return redirect("..")
|
|
150
|
+
|
|
151
|
+
self._reload_site_fixtures(request)
|
|
152
|
+
|
|
153
|
+
return redirect("..")
|
|
154
|
+
|
|
110
155
|
def get_urls(self):
|
|
111
156
|
urls = super().get_urls()
|
|
112
157
|
custom = [
|
|
@@ -114,7 +159,12 @@ class SiteAdmin(DjangoSiteAdmin):
|
|
|
114
159
|
"register-current/",
|
|
115
160
|
self.admin_site.admin_view(self.register_current),
|
|
116
161
|
name="pages_siteproxy_register_current",
|
|
117
|
-
)
|
|
162
|
+
),
|
|
163
|
+
path(
|
|
164
|
+
"reload-site-fixtures/",
|
|
165
|
+
self.admin_site.admin_view(self.reload_site_fixtures),
|
|
166
|
+
name="pages_siteproxy_reload_site_fixtures",
|
|
167
|
+
),
|
|
118
168
|
]
|
|
119
169
|
return custom + urls
|
|
120
170
|
|
|
@@ -237,6 +287,17 @@ class LandingLeadAdmin(EntityModelAdmin):
|
|
|
237
287
|
ordering = ("-created_on",)
|
|
238
288
|
date_hierarchy = "created_on"
|
|
239
289
|
|
|
290
|
+
def changelist_view(self, request, extra_context=None):
|
|
291
|
+
if not landing_leads_supported():
|
|
292
|
+
self.message_user(
|
|
293
|
+
request,
|
|
294
|
+
_(
|
|
295
|
+
"Landing leads are not being recorded because Celery is not running on this node."
|
|
296
|
+
),
|
|
297
|
+
messages.WARNING,
|
|
298
|
+
)
|
|
299
|
+
return super().changelist_view(request, extra_context=extra_context)
|
|
300
|
+
|
|
240
301
|
@admin.display(description=_("Landing"), ordering="landing__label")
|
|
241
302
|
def landing_label(self, obj):
|
|
242
303
|
return obj.landing.label
|
pages/middleware.py
CHANGED
|
@@ -10,6 +10,7 @@ from django.conf import settings
|
|
|
10
10
|
from django.urls import Resolver404, resolve
|
|
11
11
|
|
|
12
12
|
from .models import Landing, LandingLead, ViewHistory
|
|
13
|
+
from .utils import landing_leads_supported
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
logger = logging.getLogger(__name__)
|
|
@@ -125,6 +126,9 @@ class ViewHistoryMiddleware:
|
|
|
125
126
|
if request.method.upper() != "GET":
|
|
126
127
|
return
|
|
127
128
|
|
|
129
|
+
if not landing_leads_supported():
|
|
130
|
+
return
|
|
131
|
+
|
|
128
132
|
referer = request.META.get("HTTP_REFERER", "") or ""
|
|
129
133
|
user_agent = request.META.get("HTTP_USER_AGENT", "") or ""
|
|
130
134
|
ip_address = self._extract_client_ip(request) or None
|
pages/models.py
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
1
4
|
from django.db import models
|
|
2
5
|
from django.db.models import Q
|
|
3
6
|
from core.entity import Entity
|
|
@@ -15,6 +18,10 @@ from django.core.validators import MaxLengthValidator, MaxValueValidator, MinVal
|
|
|
15
18
|
from django.core.exceptions import ValidationError
|
|
16
19
|
|
|
17
20
|
from core import github_issues
|
|
21
|
+
from .tasks import create_user_story_github_issue
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
18
25
|
|
|
19
26
|
|
|
20
27
|
class ApplicationManager(models.Manager):
|
|
@@ -599,6 +606,35 @@ from django.db.models.signals import post_save
|
|
|
599
606
|
from django.dispatch import receiver
|
|
600
607
|
|
|
601
608
|
|
|
609
|
+
def _celery_lock_path() -> Path:
|
|
610
|
+
return Path(settings.BASE_DIR) / "locks" / "celery.lck"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _is_celery_enabled() -> bool:
|
|
614
|
+
return _celery_lock_path().exists()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@receiver(post_save, sender=UserStory)
|
|
618
|
+
def _queue_low_rating_user_story_issue(
|
|
619
|
+
sender, instance: UserStory, created: bool, raw: bool, **kwargs
|
|
620
|
+
) -> None:
|
|
621
|
+
if raw or not created:
|
|
622
|
+
return
|
|
623
|
+
if instance.rating >= 5:
|
|
624
|
+
return
|
|
625
|
+
if instance.github_issue_url:
|
|
626
|
+
return
|
|
627
|
+
if not _is_celery_enabled():
|
|
628
|
+
return
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
create_user_story_github_issue.delay(instance.pk)
|
|
632
|
+
except Exception: # pragma: no cover - logging only
|
|
633
|
+
logger.exception(
|
|
634
|
+
"Failed to enqueue GitHub issue creation for user story %s", instance.pk
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
602
638
|
@receiver(post_save, sender=Module)
|
|
603
639
|
def _create_landings(
|
|
604
640
|
sender, instance, created, raw, **kwargs
|