arthexis 0.1.14__py3-none-any.whl → 0.1.16__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.14.dist-info → arthexis-0.1.16.dist-info}/METADATA +3 -2
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/RECORD +41 -39
- config/urls.py +5 -0
- core/admin.py +200 -9
- core/admindocs.py +44 -3
- core/apps.py +1 -1
- core/backends.py +44 -8
- core/entity.py +17 -1
- core/github_issues.py +12 -7
- core/log_paths.py +24 -10
- core/mailer.py +9 -5
- core/models.py +92 -23
- core/release.py +173 -2
- core/system.py +411 -4
- core/tasks.py +5 -1
- core/test_system_info.py +16 -0
- core/tests.py +280 -0
- core/views.py +252 -38
- nodes/admin.py +25 -1
- nodes/models.py +99 -6
- nodes/rfid_sync.py +15 -0
- nodes/tests.py +142 -3
- nodes/utils.py +3 -0
- ocpp/consumers.py +38 -0
- ocpp/models.py +19 -4
- ocpp/tasks.py +156 -2
- ocpp/test_rfid.py +44 -2
- ocpp/tests.py +111 -1
- pages/admin.py +188 -5
- pages/context_processors.py +20 -1
- pages/middleware.py +4 -0
- pages/models.py +39 -1
- pages/module_defaults.py +156 -0
- pages/tasks.py +74 -0
- pages/tests.py +629 -8
- pages/urls.py +2 -0
- pages/utils.py +11 -0
- pages/views.py +106 -38
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/WHEEL +0 -0
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.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
|
]
|
|
@@ -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,77 @@ 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 ctx.get("todos_ack"):
|
|
901
|
+
ctx["todos"] = pending_values
|
|
902
|
+
ctx["todos_required"] = True
|
|
903
|
+
raise PendingTodos()
|
|
826
904
|
todos = list(Todo.objects.filter(is_deleted=False))
|
|
827
905
|
for todo in todos:
|
|
828
906
|
todo.delete()
|
|
@@ -837,6 +915,7 @@ def _step_check_todos(release, ctx, log_path: Path) -> None:
|
|
|
837
915
|
check=False,
|
|
838
916
|
)
|
|
839
917
|
ctx.pop("todos", None)
|
|
918
|
+
ctx.pop("todos_required", None)
|
|
840
919
|
ctx["todos_ack"] = True
|
|
841
920
|
|
|
842
921
|
|
|
@@ -859,7 +938,15 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
|
859
938
|
for f in files
|
|
860
939
|
if "fixtures" in Path(f).parts and Path(f).suffix == ".json"
|
|
861
940
|
]
|
|
862
|
-
|
|
941
|
+
changelog_dirty = "CHANGELOG.rst" in files
|
|
942
|
+
version_dirty = "VERSION" in files
|
|
943
|
+
allowed_dirty_files = set(fixture_files)
|
|
944
|
+
if changelog_dirty:
|
|
945
|
+
allowed_dirty_files.add("CHANGELOG.rst")
|
|
946
|
+
if version_dirty:
|
|
947
|
+
allowed_dirty_files.add("VERSION")
|
|
948
|
+
|
|
949
|
+
if files and len(allowed_dirty_files) == len(files):
|
|
863
950
|
summary = []
|
|
864
951
|
for f in fixture_files:
|
|
865
952
|
path = Path(f)
|
|
@@ -887,15 +974,48 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
|
|
|
887
974
|
summary.append({"path": f, "count": count, "models": models})
|
|
888
975
|
|
|
889
976
|
ctx["fixtures"] = summary
|
|
977
|
+
commit_paths = [*fixture_files]
|
|
978
|
+
if changelog_dirty:
|
|
979
|
+
commit_paths.append("CHANGELOG.rst")
|
|
980
|
+
if version_dirty:
|
|
981
|
+
commit_paths.append("VERSION")
|
|
982
|
+
|
|
983
|
+
log_fragments = []
|
|
984
|
+
if fixture_files:
|
|
985
|
+
log_fragments.append(
|
|
986
|
+
"fixtures " + ", ".join(fixture_files)
|
|
987
|
+
)
|
|
988
|
+
if changelog_dirty:
|
|
989
|
+
log_fragments.append("CHANGELOG.rst")
|
|
990
|
+
if version_dirty:
|
|
991
|
+
log_fragments.append("VERSION")
|
|
992
|
+
details = ", ".join(log_fragments) if log_fragments else "changes"
|
|
890
993
|
_append_log(
|
|
891
994
|
log_path,
|
|
892
|
-
"Committing
|
|
995
|
+
f"Committing release prep changes: {details}",
|
|
893
996
|
)
|
|
894
|
-
subprocess.run(["git", "add", *
|
|
895
|
-
|
|
896
|
-
|
|
997
|
+
subprocess.run(["git", "add", *commit_paths], check=True)
|
|
998
|
+
|
|
999
|
+
if changelog_dirty and version_dirty and fixture_files:
|
|
1000
|
+
commit_message = "chore: sync release metadata"
|
|
1001
|
+
elif changelog_dirty and version_dirty:
|
|
1002
|
+
commit_message = "chore: update version and changelog"
|
|
1003
|
+
elif version_dirty and fixture_files:
|
|
1004
|
+
commit_message = "chore: update version and fixtures"
|
|
1005
|
+
elif changelog_dirty and fixture_files:
|
|
1006
|
+
commit_message = "chore: sync release fixtures and changelog"
|
|
1007
|
+
elif version_dirty:
|
|
1008
|
+
commit_message = "chore: update version"
|
|
1009
|
+
elif changelog_dirty:
|
|
1010
|
+
commit_message = "docs: refresh changelog"
|
|
1011
|
+
else:
|
|
1012
|
+
commit_message = "chore: update fixtures"
|
|
1013
|
+
|
|
1014
|
+
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
|
1015
|
+
_append_log(
|
|
1016
|
+
log_path,
|
|
1017
|
+
f"Release prep changes committed ({commit_message})",
|
|
897
1018
|
)
|
|
898
|
-
_append_log(log_path, "Fixture changes committed")
|
|
899
1019
|
ctx.pop("dirty_files", None)
|
|
900
1020
|
ctx.pop("dirty_commit_error", None)
|
|
901
1021
|
retry_sync = True
|
|
@@ -997,6 +1117,36 @@ def _step_changelog_docs(release, ctx, log_path: Path) -> None:
|
|
|
997
1117
|
_append_log(log_path, "CHANGELOG and documentation review recorded")
|
|
998
1118
|
|
|
999
1119
|
|
|
1120
|
+
def _record_release_todo(
|
|
1121
|
+
release, ctx, log_path: Path, *, previous_version: str | None = None
|
|
1122
|
+
) -> None:
|
|
1123
|
+
previous_version = previous_version or ctx.pop(
|
|
1124
|
+
"release_todo_previous_version",
|
|
1125
|
+
getattr(release, "_repo_version_before_sync", ""),
|
|
1126
|
+
)
|
|
1127
|
+
todo, fixture_path = _ensure_release_todo(
|
|
1128
|
+
release, previous_version=previous_version
|
|
1129
|
+
)
|
|
1130
|
+
fixture_display = _format_path(fixture_path)
|
|
1131
|
+
_append_log(log_path, f"Added TODO: {todo.request}")
|
|
1132
|
+
_append_log(log_path, f"Wrote TODO fixture {fixture_display}")
|
|
1133
|
+
subprocess.run(["git", "add", str(fixture_path)], check=True)
|
|
1134
|
+
_append_log(log_path, f"Staged TODO fixture {fixture_display}")
|
|
1135
|
+
fixture_diff = subprocess.run(
|
|
1136
|
+
["git", "diff", "--cached", "--quiet", "--", str(fixture_path)],
|
|
1137
|
+
check=False,
|
|
1138
|
+
)
|
|
1139
|
+
if fixture_diff.returncode != 0:
|
|
1140
|
+
commit_message = f"chore: add release TODO for {release.package.name}"
|
|
1141
|
+
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
|
1142
|
+
_append_log(log_path, f"Committed TODO fixture {fixture_display}")
|
|
1143
|
+
else:
|
|
1144
|
+
_append_log(
|
|
1145
|
+
log_path,
|
|
1146
|
+
f"No changes detected for TODO fixture {fixture_display}; skipping commit",
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
|
|
1000
1150
|
def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
|
|
1001
1151
|
_append_log(log_path, "Execute pre-release actions")
|
|
1002
1152
|
if ctx.get("dry_run"):
|
|
@@ -1070,27 +1220,7 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
|
|
|
1070
1220
|
for path in staged_release_fixtures:
|
|
1071
1221
|
subprocess.run(["git", "reset", "HEAD", str(path)], check=False)
|
|
1072
1222
|
_append_log(log_path, f"Unstaged release fixture {_format_path(path)}")
|
|
1073
|
-
|
|
1074
|
-
release, previous_version=repo_version_before_sync
|
|
1075
|
-
)
|
|
1076
|
-
fixture_display = _format_path(fixture_path)
|
|
1077
|
-
_append_log(log_path, f"Added TODO: {todo.request}")
|
|
1078
|
-
_append_log(log_path, f"Wrote TODO fixture {fixture_display}")
|
|
1079
|
-
subprocess.run(["git", "add", str(fixture_path)], check=True)
|
|
1080
|
-
_append_log(log_path, f"Staged TODO fixture {fixture_display}")
|
|
1081
|
-
fixture_diff = subprocess.run(
|
|
1082
|
-
["git", "diff", "--cached", "--quiet", "--", str(fixture_path)],
|
|
1083
|
-
check=False,
|
|
1084
|
-
)
|
|
1085
|
-
if fixture_diff.returncode != 0:
|
|
1086
|
-
commit_message = f"chore: add release TODO for {release.package.name}"
|
|
1087
|
-
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
|
1088
|
-
_append_log(log_path, f"Committed TODO fixture {fixture_display}")
|
|
1089
|
-
else:
|
|
1090
|
-
_append_log(
|
|
1091
|
-
log_path,
|
|
1092
|
-
f"No changes detected for TODO fixture {fixture_display}; skipping commit",
|
|
1093
|
-
)
|
|
1223
|
+
ctx["release_todo_previous_version"] = repo_version_before_sync
|
|
1094
1224
|
_append_log(log_path, "Pre-release actions complete")
|
|
1095
1225
|
|
|
1096
1226
|
|
|
@@ -1174,6 +1304,7 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
|
|
|
1174
1304
|
)
|
|
1175
1305
|
PackageRelease.dump_fixture()
|
|
1176
1306
|
_append_log(log_path, "Updated release fixtures")
|
|
1307
|
+
_record_release_todo(release, ctx, log_path)
|
|
1177
1308
|
except Exception:
|
|
1178
1309
|
_clean_repo()
|
|
1179
1310
|
raise
|
|
@@ -1495,6 +1626,7 @@ def rfid_batch(request):
|
|
|
1495
1626
|
"custom_label": t.custom_label,
|
|
1496
1627
|
"energy_accounts": list(t.energy_accounts.values_list("id", flat=True)),
|
|
1497
1628
|
"external_command": t.external_command,
|
|
1629
|
+
"post_auth_command": t.post_auth_command,
|
|
1498
1630
|
"allowed": t.allowed,
|
|
1499
1631
|
"color": t.color,
|
|
1500
1632
|
"released": t.released,
|
|
@@ -1530,6 +1662,11 @@ def rfid_batch(request):
|
|
|
1530
1662
|
external_command = ""
|
|
1531
1663
|
else:
|
|
1532
1664
|
external_command = external_command.strip()
|
|
1665
|
+
post_auth_command = row.get("post_auth_command")
|
|
1666
|
+
if not isinstance(post_auth_command, str):
|
|
1667
|
+
post_auth_command = ""
|
|
1668
|
+
else:
|
|
1669
|
+
post_auth_command = post_auth_command.strip()
|
|
1533
1670
|
|
|
1534
1671
|
tag, _ = RFID.objects.update_or_create(
|
|
1535
1672
|
rfid=rfid.upper(),
|
|
@@ -1539,6 +1676,7 @@ def rfid_batch(request):
|
|
|
1539
1676
|
"released": released,
|
|
1540
1677
|
"custom_label": custom_label,
|
|
1541
1678
|
"external_command": external_command,
|
|
1679
|
+
"post_auth_command": post_auth_command,
|
|
1542
1680
|
},
|
|
1543
1681
|
)
|
|
1544
1682
|
if energy_accounts:
|
|
@@ -1622,6 +1760,12 @@ def release_progress(request, pk: int, action: str):
|
|
|
1622
1760
|
else:
|
|
1623
1761
|
log_dir_warning_message = ctx.get("log_dir_warning_message")
|
|
1624
1762
|
|
|
1763
|
+
if "changelog_report_url" not in ctx:
|
|
1764
|
+
try:
|
|
1765
|
+
ctx["changelog_report_url"] = reverse("admin:system-changelog-report")
|
|
1766
|
+
except NoReverseMatch:
|
|
1767
|
+
ctx["changelog_report_url"] = ""
|
|
1768
|
+
|
|
1625
1769
|
steps = PUBLISH_STEPS
|
|
1626
1770
|
total_steps = len(steps)
|
|
1627
1771
|
step_count = ctx.get("step", 0)
|
|
@@ -1689,7 +1833,7 @@ def release_progress(request, pk: int, action: str):
|
|
|
1689
1833
|
else:
|
|
1690
1834
|
ctx["todos_ack"] = True
|
|
1691
1835
|
|
|
1692
|
-
if
|
|
1836
|
+
if not ctx.get("todos_ack"):
|
|
1693
1837
|
ctx["todos"] = [
|
|
1694
1838
|
{
|
|
1695
1839
|
"id": todo.pk,
|
|
@@ -1699,8 +1843,10 @@ def release_progress(request, pk: int, action: str):
|
|
|
1699
1843
|
}
|
|
1700
1844
|
for todo in pending_items
|
|
1701
1845
|
]
|
|
1846
|
+
ctx["todos_required"] = True
|
|
1702
1847
|
else:
|
|
1703
1848
|
ctx.pop("todos", None)
|
|
1849
|
+
ctx.pop("todos_required", None)
|
|
1704
1850
|
|
|
1705
1851
|
log_name = _release_log_name(release.package.name, release.version)
|
|
1706
1852
|
if ctx.get("log") != log_name:
|
|
@@ -1821,7 +1967,9 @@ def release_progress(request, pk: int, action: str):
|
|
|
1821
1967
|
and not ctx.get("error")
|
|
1822
1968
|
else None
|
|
1823
1969
|
)
|
|
1824
|
-
has_pending_todos = bool(
|
|
1970
|
+
has_pending_todos = bool(
|
|
1971
|
+
ctx.get("todos_required") and not ctx.get("todos_ack")
|
|
1972
|
+
)
|
|
1825
1973
|
if has_pending_todos:
|
|
1826
1974
|
next_step = None
|
|
1827
1975
|
dirty_files = ctx.get("dirty_files")
|
|
@@ -1938,6 +2086,7 @@ def release_progress(request, pk: int, action: str):
|
|
|
1938
2086
|
"cert_log": ctx.get("cert_log"),
|
|
1939
2087
|
"fixtures": fixtures_summary,
|
|
1940
2088
|
"todos": todos_display,
|
|
2089
|
+
"changelog_report_url": ctx.get("changelog_report_url", ""),
|
|
1941
2090
|
"dirty_files": dirty_files,
|
|
1942
2091
|
"dirty_commit_message": ctx.get("dirty_commit_message", DIRTY_COMMIT_DEFAULT_MESSAGE),
|
|
1943
2092
|
"dirty_commit_error": ctx.get("dirty_commit_error"),
|
|
@@ -2154,6 +2303,7 @@ def todo_focus(request, pk: int):
|
|
|
2154
2303
|
"focus_auth": focus_auth,
|
|
2155
2304
|
"next_url": _get_return_url(request),
|
|
2156
2305
|
"done_url": reverse("todo-done", args=[todo.pk]),
|
|
2306
|
+
"snapshot_url": reverse("todo-snapshot", args=[todo.pk]),
|
|
2157
2307
|
}
|
|
2158
2308
|
return render(request, "core/todo_focus.html", context)
|
|
2159
2309
|
|
|
@@ -2173,3 +2323,67 @@ def todo_done(request, pk: int):
|
|
|
2173
2323
|
todo.done_on = timezone.now()
|
|
2174
2324
|
todo.save(update_fields=["done_on"])
|
|
2175
2325
|
return redirect(redirect_to)
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
@staff_member_required
|
|
2329
|
+
@require_POST
|
|
2330
|
+
def todo_snapshot(request, pk: int):
|
|
2331
|
+
todo = get_object_or_404(Todo, pk=pk, is_deleted=False)
|
|
2332
|
+
if todo.done_on:
|
|
2333
|
+
return JsonResponse({"detail": _("This TODO has already been completed.")}, status=400)
|
|
2334
|
+
|
|
2335
|
+
try:
|
|
2336
|
+
payload = json.loads(request.body.decode("utf-8") or "{}")
|
|
2337
|
+
except json.JSONDecodeError:
|
|
2338
|
+
return JsonResponse({"detail": _("Invalid JSON payload.")}, status=400)
|
|
2339
|
+
|
|
2340
|
+
image_data = payload.get("image", "") if isinstance(payload, dict) else ""
|
|
2341
|
+
if not isinstance(image_data, str) or not image_data.startswith("data:image/png;base64,"):
|
|
2342
|
+
return JsonResponse({"detail": _("A PNG data URL is required.")}, status=400)
|
|
2343
|
+
|
|
2344
|
+
try:
|
|
2345
|
+
encoded = image_data.split(",", 1)[1]
|
|
2346
|
+
except IndexError:
|
|
2347
|
+
return JsonResponse({"detail": _("Screenshot data is incomplete.")}, status=400)
|
|
2348
|
+
|
|
2349
|
+
try:
|
|
2350
|
+
image_bytes = base64.b64decode(encoded, validate=True)
|
|
2351
|
+
except (ValueError, binascii.Error):
|
|
2352
|
+
return JsonResponse({"detail": _("Unable to decode screenshot data.")}, status=400)
|
|
2353
|
+
|
|
2354
|
+
if not image_bytes:
|
|
2355
|
+
return JsonResponse({"detail": _("Screenshot data is empty.")}, status=400)
|
|
2356
|
+
|
|
2357
|
+
max_size = 5 * 1024 * 1024
|
|
2358
|
+
if len(image_bytes) > max_size:
|
|
2359
|
+
return JsonResponse({"detail": _("Screenshot is too large to store.")}, status=400)
|
|
2360
|
+
|
|
2361
|
+
relative_path = Path("screenshots") / f"todo-{todo.pk}-{uuid.uuid4().hex}.png"
|
|
2362
|
+
full_path = settings.LOG_DIR / relative_path
|
|
2363
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2364
|
+
with full_path.open("wb") as fh:
|
|
2365
|
+
fh.write(image_bytes)
|
|
2366
|
+
|
|
2367
|
+
primary_text = strip_tags(todo.request or "").strip()
|
|
2368
|
+
details_text = strip_tags(todo.request_details or "").strip()
|
|
2369
|
+
alt_parts = [part for part in (primary_text, details_text) if part]
|
|
2370
|
+
if alt_parts:
|
|
2371
|
+
alt_text = " — ".join(alt_parts)
|
|
2372
|
+
else:
|
|
2373
|
+
alt_text = _("TODO %(id)s snapshot") % {"id": todo.pk}
|
|
2374
|
+
|
|
2375
|
+
sample = save_screenshot(
|
|
2376
|
+
relative_path,
|
|
2377
|
+
method="TODO_QA",
|
|
2378
|
+
content=alt_text,
|
|
2379
|
+
user=request.user if request.user.is_authenticated else None,
|
|
2380
|
+
)
|
|
2381
|
+
|
|
2382
|
+
if sample is None:
|
|
2383
|
+
try:
|
|
2384
|
+
full_path.unlink()
|
|
2385
|
+
except FileNotFoundError:
|
|
2386
|
+
pass
|
|
2387
|
+
return JsonResponse({"detail": _("Duplicate snapshot ignored.")})
|
|
2388
|
+
|
|
2389
|
+
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
|
},
|