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.

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
- 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,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
- 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 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
- if files and len(fixture_files) == len(files):
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 fixture changes: " + ", ".join(fixture_files),
995
+ f"Committing release prep changes: {details}",
893
996
  )
894
- subprocess.run(["git", "add", *fixture_files], check=True)
895
- subprocess.run(
896
- ["git", "commit", "-m", "chore: update fixtures"], check=True
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
- todo, fixture_path = _ensure_release_todo(
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 pending_items and not ctx.get("todos_ack"):
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(ctx.get("todos") and not ctx.get("todos_ack"))
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
  },