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.

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
- incremented_previous = _next_patch_version(previous_version)
670
- if incremented_previous == release.version:
671
- target_version = release.version
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
- if pending_qs.exists():
821
- ctx["todos"] = list(
822
- pending_qs.values("id", "request", "url", "request_details")
823
- )
824
- if not ctx.get("todos_ack"):
825
- raise PendingTodos()
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
- todo, fixture_path = _ensure_release_todo(
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.publish(
1350
- package=release.to_package(),
1351
- version=release.version,
1352
- creds=release.to_credentials(),
1353
- repositories=targets,
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 pending_items and not ctx.get("todos_ack"):
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(ctx.get("todos") and not ctx.get("todos_ack"))
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
  },