arthexis 0.1.15__py3-none-any.whl → 0.1.17__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/METADATA +1 -2
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/RECORD +40 -39
- config/settings.py +3 -0
- config/urls.py +5 -0
- core/admin.py +242 -15
- core/admindocs.py +44 -3
- core/apps.py +1 -1
- core/backends.py +46 -8
- core/changelog.py +66 -5
- core/github_issues.py +12 -7
- core/mailer.py +9 -5
- core/models.py +121 -29
- core/release.py +107 -2
- core/system.py +209 -2
- core/tasks.py +5 -7
- core/test_system_info.py +16 -0
- core/tests.py +329 -0
- core/views.py +279 -40
- nodes/admin.py +25 -1
- nodes/models.py +70 -4
- nodes/rfid_sync.py +15 -0
- nodes/tests.py +119 -0
- nodes/utils.py +3 -0
- ocpp/admin.py +92 -10
- ocpp/consumers.py +38 -0
- ocpp/models.py +19 -4
- ocpp/tasks.py +156 -2
- ocpp/test_rfid.py +92 -5
- ocpp/tests.py +243 -1
- ocpp/views.py +23 -5
- pages/admin.py +126 -4
- pages/context_processors.py +20 -1
- pages/models.py +3 -1
- pages/module_defaults.py +156 -0
- pages/tests.py +241 -8
- pages/urls.py +1 -0
- pages/views.py +61 -4
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/WHEEL +0 -0
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.15.dist-info → arthexis-0.1.17.dist-info}/top_level.txt +0 -0
core/views.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import binascii
|
|
1
3
|
import json
|
|
2
4
|
import logging
|
|
3
5
|
import os
|
|
@@ -16,6 +18,7 @@ from django.http import Http404, JsonResponse, HttpResponse
|
|
|
16
18
|
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
|
|
17
19
|
from django.template.response import TemplateResponse
|
|
18
20
|
from django.utils import timezone
|
|
21
|
+
from django.utils.html import strip_tags
|
|
19
22
|
from django.utils.text import slugify
|
|
20
23
|
from django.utils.translation import gettext as _
|
|
21
24
|
from django.urls import NoReverseMatch, reverse
|
|
@@ -32,6 +35,7 @@ from django.template.loader import get_template
|
|
|
32
35
|
from django.test import signals
|
|
33
36
|
|
|
34
37
|
from utils import revision
|
|
38
|
+
from nodes.utils import save_screenshot
|
|
35
39
|
from utils.api import api_login_required
|
|
36
40
|
|
|
37
41
|
logger = logging.getLogger(__name__)
|
|
@@ -633,6 +637,8 @@ def _write_todo_fixture(todo: Todo) -> Path:
|
|
|
633
637
|
"request": todo.request,
|
|
634
638
|
"url": todo.url,
|
|
635
639
|
"request_details": todo.request_details,
|
|
640
|
+
"generated_for_version": todo.generated_for_version,
|
|
641
|
+
"generated_for_revision": todo.generated_for_revision,
|
|
636
642
|
},
|
|
637
643
|
}
|
|
638
644
|
]
|
|
@@ -650,8 +656,8 @@ def _should_use_python_changelog(exc: OSError) -> bool:
|
|
|
650
656
|
def _generate_changelog_with_python(log_path: Path) -> None:
|
|
651
657
|
_append_log(log_path, "Falling back to Python changelog generator")
|
|
652
658
|
changelog_path = Path("CHANGELOG.rst")
|
|
653
|
-
range_spec = changelog_utils.determine_range_spec()
|
|
654
659
|
previous = changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else None
|
|
660
|
+
range_spec = changelog_utils.determine_range_spec(previous_text=previous)
|
|
655
661
|
sections = changelog_utils.collect_sections(range_spec=range_spec, previous_text=previous)
|
|
656
662
|
content = changelog_utils.render_changelog(sections)
|
|
657
663
|
if not content.endswith("\n"):
|
|
@@ -666,9 +672,16 @@ def _ensure_release_todo(
|
|
|
666
672
|
previous_version = (previous_version or "").strip()
|
|
667
673
|
target_version = _next_patch_version(release.version)
|
|
668
674
|
if previous_version:
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
675
|
+
try:
|
|
676
|
+
from packaging.version import InvalidVersion, Version
|
|
677
|
+
|
|
678
|
+
parsed_previous = Version(previous_version)
|
|
679
|
+
parsed_target = Version(target_version)
|
|
680
|
+
except InvalidVersion:
|
|
681
|
+
pass
|
|
682
|
+
else:
|
|
683
|
+
if parsed_target <= parsed_previous:
|
|
684
|
+
target_version = _next_patch_version(previous_version)
|
|
672
685
|
request = f"Create release {release.package.name} {target_version}"
|
|
673
686
|
try:
|
|
674
687
|
url = reverse("admin:core_packagerelease_changelist")
|
|
@@ -680,6 +693,8 @@ def _ensure_release_todo(
|
|
|
680
693
|
"request": request,
|
|
681
694
|
"url": url,
|
|
682
695
|
"request_details": "",
|
|
696
|
+
"generated_for_version": release.version or "",
|
|
697
|
+
"generated_for_revision": release.revision or "",
|
|
683
698
|
"is_seed_data": True,
|
|
684
699
|
"is_deleted": False,
|
|
685
700
|
"is_user_data": False,
|
|
@@ -815,14 +830,88 @@ def _get_return_url(request) -> str:
|
|
|
815
830
|
return resolve_url("admin:index")
|
|
816
831
|
|
|
817
832
|
|
|
833
|
+
def _refresh_changelog_once(ctx, log_path: Path) -> None:
|
|
834
|
+
"""Regenerate the changelog a single time per release run."""
|
|
835
|
+
|
|
836
|
+
if ctx.get("changelog_refreshed"):
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
_append_log(log_path, "Refreshing changelog before TODO review")
|
|
840
|
+
try:
|
|
841
|
+
subprocess.run(["scripts/generate-changelog.sh"], check=True)
|
|
842
|
+
except OSError as exc:
|
|
843
|
+
if _should_use_python_changelog(exc):
|
|
844
|
+
_append_log(
|
|
845
|
+
log_path,
|
|
846
|
+
f"scripts/generate-changelog.sh failed: {exc}",
|
|
847
|
+
)
|
|
848
|
+
_generate_changelog_with_python(log_path)
|
|
849
|
+
else: # pragma: no cover - unexpected OSError
|
|
850
|
+
raise
|
|
851
|
+
else:
|
|
852
|
+
_append_log(
|
|
853
|
+
log_path,
|
|
854
|
+
"Regenerated CHANGELOG.rst using scripts/generate-changelog.sh",
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
staged_paths: list[str] = []
|
|
858
|
+
changelog_path = Path("CHANGELOG.rst")
|
|
859
|
+
if changelog_path.exists():
|
|
860
|
+
staged_paths.append(str(changelog_path))
|
|
861
|
+
|
|
862
|
+
release_fixtures = sorted(Path("core/fixtures").glob("releases__*.json"))
|
|
863
|
+
staged_paths.extend(str(path) for path in release_fixtures)
|
|
864
|
+
|
|
865
|
+
if staged_paths:
|
|
866
|
+
subprocess.run(["git", "add", *staged_paths], check=True)
|
|
867
|
+
|
|
868
|
+
diff = subprocess.run(
|
|
869
|
+
["git", "diff", "--cached", "--name-only"],
|
|
870
|
+
check=True,
|
|
871
|
+
capture_output=True,
|
|
872
|
+
text=True,
|
|
873
|
+
)
|
|
874
|
+
changed_paths = [line.strip() for line in diff.stdout.splitlines() if line.strip()]
|
|
875
|
+
|
|
876
|
+
if changed_paths:
|
|
877
|
+
changelog_dirty = "CHANGELOG.rst" in changed_paths
|
|
878
|
+
fixtures_dirty = any(path.startswith("core/fixtures/") for path in changed_paths)
|
|
879
|
+
if changelog_dirty and fixtures_dirty:
|
|
880
|
+
message = "chore: sync release fixtures and changelog"
|
|
881
|
+
elif changelog_dirty:
|
|
882
|
+
message = "docs: refresh changelog"
|
|
883
|
+
else:
|
|
884
|
+
message = "chore: update release fixtures"
|
|
885
|
+
subprocess.run(["git", "commit", "-m", message], check=True)
|
|
886
|
+
_append_log(log_path, f"Committed changelog refresh ({message})")
|
|
887
|
+
else:
|
|
888
|
+
_append_log(log_path, "Changelog already up to date")
|
|
889
|
+
|
|
890
|
+
ctx["changelog_refreshed"] = True
|
|
891
|
+
|
|
892
|
+
|
|
818
893
|
def _step_check_todos(release, ctx, log_path: Path) -> None:
|
|
894
|
+
_refresh_changelog_once(ctx, log_path)
|
|
895
|
+
|
|
819
896
|
pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
897
|
+
pending_values = list(
|
|
898
|
+
pending_qs.values("id", "request", "url", "request_details")
|
|
899
|
+
)
|
|
900
|
+
if not pending_values:
|
|
901
|
+
ctx["todos_ack"] = True
|
|
902
|
+
|
|
903
|
+
if not ctx.get("todos_ack"):
|
|
904
|
+
if not ctx.get("todos_block_logged"):
|
|
905
|
+
_append_log(
|
|
906
|
+
log_path,
|
|
907
|
+
"Release checklist requires acknowledgment before continuing. "
|
|
908
|
+
"Review outstanding TODO items and confirm the checklist; "
|
|
909
|
+
"publishing will resume automatically afterward.",
|
|
910
|
+
)
|
|
911
|
+
ctx["todos_block_logged"] = True
|
|
912
|
+
ctx["todos"] = pending_values
|
|
913
|
+
ctx["todos_required"] = True
|
|
914
|
+
raise PendingTodos()
|
|
826
915
|
todos = list(Todo.objects.filter(is_deleted=False))
|
|
827
916
|
for todo in todos:
|
|
828
917
|
todo.delete()
|
|
@@ -837,6 +926,7 @@ def _step_check_todos(release, ctx, log_path: Path) -> None:
|
|
|
837
926
|
check=False,
|
|
838
927
|
)
|
|
839
928
|
ctx.pop("todos", None)
|
|
929
|
+
ctx.pop("todos_required", None)
|
|
840
930
|
ctx["todos_ack"] = True
|
|
841
931
|
|
|
842
932
|
|
|
@@ -860,9 +950,12 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
|
860
950
|
if "fixtures" in Path(f).parts and Path(f).suffix == ".json"
|
|
861
951
|
]
|
|
862
952
|
changelog_dirty = "CHANGELOG.rst" in files
|
|
953
|
+
version_dirty = "VERSION" in files
|
|
863
954
|
allowed_dirty_files = set(fixture_files)
|
|
864
955
|
if changelog_dirty:
|
|
865
956
|
allowed_dirty_files.add("CHANGELOG.rst")
|
|
957
|
+
if version_dirty:
|
|
958
|
+
allowed_dirty_files.add("VERSION")
|
|
866
959
|
|
|
867
960
|
if files and len(allowed_dirty_files) == len(files):
|
|
868
961
|
summary = []
|
|
@@ -895,6 +988,8 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
|
895
988
|
commit_paths = [*fixture_files]
|
|
896
989
|
if changelog_dirty:
|
|
897
990
|
commit_paths.append("CHANGELOG.rst")
|
|
991
|
+
if version_dirty:
|
|
992
|
+
commit_paths.append("VERSION")
|
|
898
993
|
|
|
899
994
|
log_fragments = []
|
|
900
995
|
if fixture_files:
|
|
@@ -903,6 +998,8 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
|
903
998
|
)
|
|
904
999
|
if changelog_dirty:
|
|
905
1000
|
log_fragments.append("CHANGELOG.rst")
|
|
1001
|
+
if version_dirty:
|
|
1002
|
+
log_fragments.append("VERSION")
|
|
906
1003
|
details = ", ".join(log_fragments) if log_fragments else "changes"
|
|
907
1004
|
_append_log(
|
|
908
1005
|
log_path,
|
|
@@ -910,8 +1007,16 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
|
910
1007
|
)
|
|
911
1008
|
subprocess.run(["git", "add", *commit_paths], check=True)
|
|
912
1009
|
|
|
913
|
-
if changelog_dirty and fixture_files:
|
|
1010
|
+
if changelog_dirty and version_dirty and fixture_files:
|
|
1011
|
+
commit_message = "chore: sync release metadata"
|
|
1012
|
+
elif changelog_dirty and version_dirty:
|
|
1013
|
+
commit_message = "chore: update version and changelog"
|
|
1014
|
+
elif version_dirty and fixture_files:
|
|
1015
|
+
commit_message = "chore: update version and fixtures"
|
|
1016
|
+
elif changelog_dirty and fixture_files:
|
|
914
1017
|
commit_message = "chore: sync release fixtures and changelog"
|
|
1018
|
+
elif version_dirty:
|
|
1019
|
+
commit_message = "chore: update version"
|
|
915
1020
|
elif changelog_dirty:
|
|
916
1021
|
commit_message = "docs: refresh changelog"
|
|
917
1022
|
else:
|
|
@@ -1023,6 +1128,36 @@ def _step_changelog_docs(release, ctx, log_path: Path) -> None:
|
|
|
1023
1128
|
_append_log(log_path, "CHANGELOG and documentation review recorded")
|
|
1024
1129
|
|
|
1025
1130
|
|
|
1131
|
+
def _record_release_todo(
|
|
1132
|
+
release, ctx, log_path: Path, *, previous_version: str | None = None
|
|
1133
|
+
) -> None:
|
|
1134
|
+
previous_version = previous_version or ctx.pop(
|
|
1135
|
+
"release_todo_previous_version",
|
|
1136
|
+
getattr(release, "_repo_version_before_sync", ""),
|
|
1137
|
+
)
|
|
1138
|
+
todo, fixture_path = _ensure_release_todo(
|
|
1139
|
+
release, previous_version=previous_version
|
|
1140
|
+
)
|
|
1141
|
+
fixture_display = _format_path(fixture_path)
|
|
1142
|
+
_append_log(log_path, f"Added TODO: {todo.request}")
|
|
1143
|
+
_append_log(log_path, f"Wrote TODO fixture {fixture_display}")
|
|
1144
|
+
subprocess.run(["git", "add", str(fixture_path)], check=True)
|
|
1145
|
+
_append_log(log_path, f"Staged TODO fixture {fixture_display}")
|
|
1146
|
+
fixture_diff = subprocess.run(
|
|
1147
|
+
["git", "diff", "--cached", "--quiet", "--", str(fixture_path)],
|
|
1148
|
+
check=False,
|
|
1149
|
+
)
|
|
1150
|
+
if fixture_diff.returncode != 0:
|
|
1151
|
+
commit_message = f"chore: add release TODO for {release.package.name}"
|
|
1152
|
+
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
|
1153
|
+
_append_log(log_path, f"Committed TODO fixture {fixture_display}")
|
|
1154
|
+
else:
|
|
1155
|
+
_append_log(
|
|
1156
|
+
log_path,
|
|
1157
|
+
f"No changes detected for TODO fixture {fixture_display}; skipping commit",
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
|
|
1026
1161
|
def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
|
|
1027
1162
|
_append_log(log_path, "Execute pre-release actions")
|
|
1028
1163
|
if ctx.get("dry_run"):
|
|
@@ -1096,27 +1231,7 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
|
|
|
1096
1231
|
for path in staged_release_fixtures:
|
|
1097
1232
|
subprocess.run(["git", "reset", "HEAD", str(path)], check=False)
|
|
1098
1233
|
_append_log(log_path, f"Unstaged release fixture {_format_path(path)}")
|
|
1099
|
-
|
|
1100
|
-
release, previous_version=repo_version_before_sync
|
|
1101
|
-
)
|
|
1102
|
-
fixture_display = _format_path(fixture_path)
|
|
1103
|
-
_append_log(log_path, f"Added TODO: {todo.request}")
|
|
1104
|
-
_append_log(log_path, f"Wrote TODO fixture {fixture_display}")
|
|
1105
|
-
subprocess.run(["git", "add", str(fixture_path)], check=True)
|
|
1106
|
-
_append_log(log_path, f"Staged TODO fixture {fixture_display}")
|
|
1107
|
-
fixture_diff = subprocess.run(
|
|
1108
|
-
["git", "diff", "--cached", "--quiet", "--", str(fixture_path)],
|
|
1109
|
-
check=False,
|
|
1110
|
-
)
|
|
1111
|
-
if fixture_diff.returncode != 0:
|
|
1112
|
-
commit_message = f"chore: add release TODO for {release.package.name}"
|
|
1113
|
-
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
|
1114
|
-
_append_log(log_path, f"Committed TODO fixture {fixture_display}")
|
|
1115
|
-
else:
|
|
1116
|
-
_append_log(
|
|
1117
|
-
log_path,
|
|
1118
|
-
f"No changes detected for TODO fixture {fixture_display}; skipping commit",
|
|
1119
|
-
)
|
|
1234
|
+
ctx["release_todo_previous_version"] = repo_version_before_sync
|
|
1120
1235
|
_append_log(log_path, "Pre-release actions complete")
|
|
1121
1236
|
|
|
1122
1237
|
|
|
@@ -1200,6 +1315,7 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
|
|
|
1200
1315
|
)
|
|
1201
1316
|
PackageRelease.dump_fixture()
|
|
1202
1317
|
_append_log(log_path, "Updated release fixtures")
|
|
1318
|
+
_record_release_todo(release, ctx, log_path)
|
|
1203
1319
|
except Exception:
|
|
1204
1320
|
_clean_repo()
|
|
1205
1321
|
raise
|
|
@@ -1346,12 +1462,29 @@ def _step_publish(release, ctx, log_path: Path) -> None:
|
|
|
1346
1462
|
)
|
|
1347
1463
|
else:
|
|
1348
1464
|
_append_log(log_path, "Uploading distribution")
|
|
1349
|
-
release_utils.
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1465
|
+
publish_warning: release_utils.PostPublishWarning | None = None
|
|
1466
|
+
try:
|
|
1467
|
+
release_utils.publish(
|
|
1468
|
+
package=release.to_package(),
|
|
1469
|
+
version=release.version,
|
|
1470
|
+
creds=release.to_credentials(),
|
|
1471
|
+
repositories=targets,
|
|
1472
|
+
)
|
|
1473
|
+
except release_utils.PostPublishWarning as warning:
|
|
1474
|
+
publish_warning = warning
|
|
1475
|
+
|
|
1476
|
+
if publish_warning is not None:
|
|
1477
|
+
message = str(publish_warning)
|
|
1478
|
+
followups = _dedupe_preserve_order(publish_warning.followups)
|
|
1479
|
+
warning_entries = ctx.setdefault("warnings", [])
|
|
1480
|
+
if not any(entry.get("message") == message for entry in warning_entries):
|
|
1481
|
+
entry: dict[str, object] = {"message": message}
|
|
1482
|
+
if followups:
|
|
1483
|
+
entry["followups"] = followups
|
|
1484
|
+
warning_entries.append(entry)
|
|
1485
|
+
_append_log(log_path, message)
|
|
1486
|
+
for note in followups:
|
|
1487
|
+
_append_log(log_path, f"Follow-up: {note}")
|
|
1355
1488
|
release.pypi_url = (
|
|
1356
1489
|
f"https://pypi.org/project/{release.package.name}/{release.version}/"
|
|
1357
1490
|
)
|
|
@@ -1521,6 +1654,7 @@ def rfid_batch(request):
|
|
|
1521
1654
|
"custom_label": t.custom_label,
|
|
1522
1655
|
"energy_accounts": list(t.energy_accounts.values_list("id", flat=True)),
|
|
1523
1656
|
"external_command": t.external_command,
|
|
1657
|
+
"post_auth_command": t.post_auth_command,
|
|
1524
1658
|
"allowed": t.allowed,
|
|
1525
1659
|
"color": t.color,
|
|
1526
1660
|
"released": t.released,
|
|
@@ -1556,6 +1690,11 @@ def rfid_batch(request):
|
|
|
1556
1690
|
external_command = ""
|
|
1557
1691
|
else:
|
|
1558
1692
|
external_command = external_command.strip()
|
|
1693
|
+
post_auth_command = row.get("post_auth_command")
|
|
1694
|
+
if not isinstance(post_auth_command, str):
|
|
1695
|
+
post_auth_command = ""
|
|
1696
|
+
else:
|
|
1697
|
+
post_auth_command = post_auth_command.strip()
|
|
1559
1698
|
|
|
1560
1699
|
tag, _ = RFID.objects.update_or_create(
|
|
1561
1700
|
rfid=rfid.upper(),
|
|
@@ -1565,6 +1704,7 @@ def rfid_batch(request):
|
|
|
1565
1704
|
"released": released,
|
|
1566
1705
|
"custom_label": custom_label,
|
|
1567
1706
|
"external_command": external_command,
|
|
1707
|
+
"post_auth_command": post_auth_command,
|
|
1568
1708
|
},
|
|
1569
1709
|
)
|
|
1570
1710
|
if energy_accounts:
|
|
@@ -1648,6 +1788,12 @@ def release_progress(request, pk: int, action: str):
|
|
|
1648
1788
|
else:
|
|
1649
1789
|
log_dir_warning_message = ctx.get("log_dir_warning_message")
|
|
1650
1790
|
|
|
1791
|
+
if "changelog_report_url" not in ctx:
|
|
1792
|
+
try:
|
|
1793
|
+
ctx["changelog_report_url"] = reverse("admin:system-changelog-report")
|
|
1794
|
+
except NoReverseMatch:
|
|
1795
|
+
ctx["changelog_report_url"] = ""
|
|
1796
|
+
|
|
1651
1797
|
steps = PUBLISH_STEPS
|
|
1652
1798
|
total_steps = len(steps)
|
|
1653
1799
|
step_count = ctx.get("step", 0)
|
|
@@ -1686,8 +1832,16 @@ def release_progress(request, pk: int, action: str):
|
|
|
1686
1832
|
ctx["release_approval"] = "approved"
|
|
1687
1833
|
if request.GET.get("reject"):
|
|
1688
1834
|
ctx["release_approval"] = "rejected"
|
|
1835
|
+
resume_requested = bool(request.GET.get("resume"))
|
|
1836
|
+
|
|
1689
1837
|
if request.GET.get("pause") and ctx.get("started"):
|
|
1690
1838
|
ctx["paused"] = True
|
|
1839
|
+
|
|
1840
|
+
if resume_requested:
|
|
1841
|
+
if not ctx.get("started"):
|
|
1842
|
+
ctx["started"] = True
|
|
1843
|
+
if ctx.get("paused"):
|
|
1844
|
+
ctx["paused"] = False
|
|
1691
1845
|
restart_count = 0
|
|
1692
1846
|
if restart_path.exists():
|
|
1693
1847
|
try:
|
|
@@ -1696,6 +1850,8 @@ def release_progress(request, pk: int, action: str):
|
|
|
1696
1850
|
restart_count = 0
|
|
1697
1851
|
step_count = ctx.get("step", 0)
|
|
1698
1852
|
step_param = request.GET.get("step")
|
|
1853
|
+
if resume_requested and step_param is None:
|
|
1854
|
+
step_param = str(step_count)
|
|
1699
1855
|
|
|
1700
1856
|
pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
|
|
1701
1857
|
pending_items = list(pending_qs)
|
|
@@ -1715,7 +1871,10 @@ def release_progress(request, pk: int, action: str):
|
|
|
1715
1871
|
else:
|
|
1716
1872
|
ctx["todos_ack"] = True
|
|
1717
1873
|
|
|
1718
|
-
if
|
|
1874
|
+
if ctx.get("todos_ack"):
|
|
1875
|
+
ctx.pop("todos_block_logged", None)
|
|
1876
|
+
|
|
1877
|
+
if not ctx.get("todos_ack"):
|
|
1719
1878
|
ctx["todos"] = [
|
|
1720
1879
|
{
|
|
1721
1880
|
"id": todo.pk,
|
|
@@ -1725,8 +1884,10 @@ def release_progress(request, pk: int, action: str):
|
|
|
1725
1884
|
}
|
|
1726
1885
|
for todo in pending_items
|
|
1727
1886
|
]
|
|
1887
|
+
ctx["todos_required"] = True
|
|
1728
1888
|
else:
|
|
1729
1889
|
ctx.pop("todos", None)
|
|
1890
|
+
ctx.pop("todos_required", None)
|
|
1730
1891
|
|
|
1731
1892
|
log_name = _release_log_name(release.package.name, release.version)
|
|
1732
1893
|
if ctx.get("log") != log_name:
|
|
@@ -1847,7 +2008,9 @@ def release_progress(request, pk: int, action: str):
|
|
|
1847
2008
|
and not ctx.get("error")
|
|
1848
2009
|
else None
|
|
1849
2010
|
)
|
|
1850
|
-
has_pending_todos = bool(
|
|
2011
|
+
has_pending_todos = bool(
|
|
2012
|
+
ctx.get("todos_required") and not ctx.get("todos_ack")
|
|
2013
|
+
)
|
|
1851
2014
|
if has_pending_todos:
|
|
1852
2015
|
next_step = None
|
|
1853
2016
|
dirty_files = ctx.get("dirty_files")
|
|
@@ -1927,6 +2090,14 @@ def release_progress(request, pk: int, action: str):
|
|
|
1927
2090
|
)
|
|
1928
2091
|
|
|
1929
2092
|
is_running = ctx.get("started") and not paused and not done and not ctx.get("error")
|
|
2093
|
+
resume_available = (
|
|
2094
|
+
ctx.get("started")
|
|
2095
|
+
and not paused
|
|
2096
|
+
and not done
|
|
2097
|
+
and not ctx.get("error")
|
|
2098
|
+
and step_count < len(steps)
|
|
2099
|
+
and next_step is None
|
|
2100
|
+
)
|
|
1930
2101
|
can_resume = ctx.get("started") and paused and not done and not ctx.get("error")
|
|
1931
2102
|
release_manager_owner = manager.owner_display() if manager else ""
|
|
1932
2103
|
try:
|
|
@@ -1964,6 +2135,7 @@ def release_progress(request, pk: int, action: str):
|
|
|
1964
2135
|
"cert_log": ctx.get("cert_log"),
|
|
1965
2136
|
"fixtures": fixtures_summary,
|
|
1966
2137
|
"todos": todos_display,
|
|
2138
|
+
"changelog_report_url": ctx.get("changelog_report_url", ""),
|
|
1967
2139
|
"dirty_files": dirty_files,
|
|
1968
2140
|
"dirty_commit_message": ctx.get("dirty_commit_message", DIRTY_COMMIT_DEFAULT_MESSAGE),
|
|
1969
2141
|
"dirty_commit_error": ctx.get("dirty_commit_error"),
|
|
@@ -1980,9 +2152,11 @@ def release_progress(request, pk: int, action: str):
|
|
|
1980
2152
|
"has_release_manager": bool(manager),
|
|
1981
2153
|
"current_user_admin_url": current_user_admin_url,
|
|
1982
2154
|
"is_running": is_running,
|
|
2155
|
+
"resume_available": resume_available,
|
|
1983
2156
|
"can_resume": can_resume,
|
|
1984
2157
|
"dry_run": dry_run_active,
|
|
1985
2158
|
"dry_run_toggle_enabled": dry_run_toggle_enabled,
|
|
2159
|
+
"warnings": ctx.get("warnings", []),
|
|
1986
2160
|
}
|
|
1987
2161
|
request.session[session_key] = ctx
|
|
1988
2162
|
if done or ctx.get("error"):
|
|
@@ -2180,6 +2354,7 @@ def todo_focus(request, pk: int):
|
|
|
2180
2354
|
"focus_auth": focus_auth,
|
|
2181
2355
|
"next_url": _get_return_url(request),
|
|
2182
2356
|
"done_url": reverse("todo-done", args=[todo.pk]),
|
|
2357
|
+
"snapshot_url": reverse("todo-snapshot", args=[todo.pk]),
|
|
2183
2358
|
}
|
|
2184
2359
|
return render(request, "core/todo_focus.html", context)
|
|
2185
2360
|
|
|
@@ -2199,3 +2374,67 @@ def todo_done(request, pk: int):
|
|
|
2199
2374
|
todo.done_on = timezone.now()
|
|
2200
2375
|
todo.save(update_fields=["done_on"])
|
|
2201
2376
|
return redirect(redirect_to)
|
|
2377
|
+
|
|
2378
|
+
|
|
2379
|
+
@staff_member_required
|
|
2380
|
+
@require_POST
|
|
2381
|
+
def todo_snapshot(request, pk: int):
|
|
2382
|
+
todo = get_object_or_404(Todo, pk=pk, is_deleted=False)
|
|
2383
|
+
if todo.done_on:
|
|
2384
|
+
return JsonResponse({"detail": _("This TODO has already been completed.")}, status=400)
|
|
2385
|
+
|
|
2386
|
+
try:
|
|
2387
|
+
payload = json.loads(request.body.decode("utf-8") or "{}")
|
|
2388
|
+
except json.JSONDecodeError:
|
|
2389
|
+
return JsonResponse({"detail": _("Invalid JSON payload.")}, status=400)
|
|
2390
|
+
|
|
2391
|
+
image_data = payload.get("image", "") if isinstance(payload, dict) else ""
|
|
2392
|
+
if not isinstance(image_data, str) or not image_data.startswith("data:image/png;base64,"):
|
|
2393
|
+
return JsonResponse({"detail": _("A PNG data URL is required.")}, status=400)
|
|
2394
|
+
|
|
2395
|
+
try:
|
|
2396
|
+
encoded = image_data.split(",", 1)[1]
|
|
2397
|
+
except IndexError:
|
|
2398
|
+
return JsonResponse({"detail": _("Screenshot data is incomplete.")}, status=400)
|
|
2399
|
+
|
|
2400
|
+
try:
|
|
2401
|
+
image_bytes = base64.b64decode(encoded, validate=True)
|
|
2402
|
+
except (ValueError, binascii.Error):
|
|
2403
|
+
return JsonResponse({"detail": _("Unable to decode screenshot data.")}, status=400)
|
|
2404
|
+
|
|
2405
|
+
if not image_bytes:
|
|
2406
|
+
return JsonResponse({"detail": _("Screenshot data is empty.")}, status=400)
|
|
2407
|
+
|
|
2408
|
+
max_size = 5 * 1024 * 1024
|
|
2409
|
+
if len(image_bytes) > max_size:
|
|
2410
|
+
return JsonResponse({"detail": _("Screenshot is too large to store.")}, status=400)
|
|
2411
|
+
|
|
2412
|
+
relative_path = Path("screenshots") / f"todo-{todo.pk}-{uuid.uuid4().hex}.png"
|
|
2413
|
+
full_path = settings.LOG_DIR / relative_path
|
|
2414
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2415
|
+
with full_path.open("wb") as fh:
|
|
2416
|
+
fh.write(image_bytes)
|
|
2417
|
+
|
|
2418
|
+
primary_text = strip_tags(todo.request or "").strip()
|
|
2419
|
+
details_text = strip_tags(todo.request_details or "").strip()
|
|
2420
|
+
alt_parts = [part for part in (primary_text, details_text) if part]
|
|
2421
|
+
if alt_parts:
|
|
2422
|
+
alt_text = " — ".join(alt_parts)
|
|
2423
|
+
else:
|
|
2424
|
+
alt_text = _("TODO %(id)s snapshot") % {"id": todo.pk}
|
|
2425
|
+
|
|
2426
|
+
sample = save_screenshot(
|
|
2427
|
+
relative_path,
|
|
2428
|
+
method="TODO_QA",
|
|
2429
|
+
content=alt_text,
|
|
2430
|
+
user=request.user if request.user.is_authenticated else None,
|
|
2431
|
+
)
|
|
2432
|
+
|
|
2433
|
+
if sample is None:
|
|
2434
|
+
try:
|
|
2435
|
+
full_path.unlink()
|
|
2436
|
+
except FileNotFoundError:
|
|
2437
|
+
pass
|
|
2438
|
+
return JsonResponse({"detail": _("Duplicate snapshot ignored.")})
|
|
2439
|
+
|
|
2440
|
+
return JsonResponse({"detail": _("Snapshot saved."), "sample": str(sample.pk)})
|
nodes/admin.py
CHANGED
|
@@ -1217,6 +1217,11 @@ class NodeFeatureAdmin(EntityModelAdmin):
|
|
|
1217
1217
|
self.admin_site.admin_view(self.celery_report),
|
|
1218
1218
|
name="nodes_nodefeature_celery_report",
|
|
1219
1219
|
),
|
|
1220
|
+
path(
|
|
1221
|
+
"view-waveform/",
|
|
1222
|
+
self.admin_site.admin_view(self.view_waveform),
|
|
1223
|
+
name="nodes_nodefeature_view_waveform",
|
|
1224
|
+
),
|
|
1220
1225
|
path(
|
|
1221
1226
|
"take-screenshot/",
|
|
1222
1227
|
self.admin_site.admin_view(self.take_screenshot),
|
|
@@ -1291,6 +1296,24 @@ class NodeFeatureAdmin(EntityModelAdmin):
|
|
|
1291
1296
|
return None
|
|
1292
1297
|
return feature
|
|
1293
1298
|
|
|
1299
|
+
def view_waveform(self, request):
|
|
1300
|
+
feature = self._ensure_feature_enabled(
|
|
1301
|
+
request, "audio-capture", "View Waveform"
|
|
1302
|
+
)
|
|
1303
|
+
if not feature:
|
|
1304
|
+
return redirect("..")
|
|
1305
|
+
|
|
1306
|
+
context = {
|
|
1307
|
+
**self.admin_site.each_context(request),
|
|
1308
|
+
"title": _("Audio Capture Waveform"),
|
|
1309
|
+
"feature": feature,
|
|
1310
|
+
}
|
|
1311
|
+
return TemplateResponse(
|
|
1312
|
+
request,
|
|
1313
|
+
"admin/nodes/nodefeature/view_waveform.html",
|
|
1314
|
+
context,
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1294
1317
|
def take_screenshot(self, request):
|
|
1295
1318
|
feature = self._ensure_feature_enabled(
|
|
1296
1319
|
request, "screenshot-poll", "Take Screenshot"
|
|
@@ -1542,7 +1565,7 @@ class NetMessageAdmin(EntityModelAdmin):
|
|
|
1542
1565
|
search_fields = ("subject", "body")
|
|
1543
1566
|
list_filter = ("complete", "filter_node_role", "filter_current_relation")
|
|
1544
1567
|
ordering = ("-created",)
|
|
1545
|
-
readonly_fields = ("complete",)
|
|
1568
|
+
readonly_fields = ("complete", "confirmed_peers")
|
|
1546
1569
|
actions = ["send_messages"]
|
|
1547
1570
|
fieldsets = (
|
|
1548
1571
|
(None, {"fields": ("subject", "body")}),
|
|
@@ -1567,6 +1590,7 @@ class NetMessageAdmin(EntityModelAdmin):
|
|
|
1567
1590
|
"node_origin",
|
|
1568
1591
|
"target_limit",
|
|
1569
1592
|
"propagated_to",
|
|
1593
|
+
"confirmed_peers",
|
|
1570
1594
|
"complete",
|
|
1571
1595
|
)
|
|
1572
1596
|
},
|