arthexis 0.1.21__py3-none-any.whl → 0.1.23__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/release.py CHANGED
@@ -520,11 +520,6 @@ def build(
520
520
  if not version_path.exists():
521
521
  raise ReleaseError("VERSION file not found")
522
522
  version = version_path.read_text().strip()
523
- if bump:
524
- major, minor, patch = map(int, version.split("."))
525
- patch += 1
526
- version = f"{major}.{minor}.{patch}"
527
- version_path.write_text(version + "\n")
528
523
  else:
529
524
  # Ensure the VERSION file reflects the provided release version
530
525
  if version_path.parent != Path("."):
core/system.py CHANGED
@@ -101,6 +101,72 @@ def _open_changelog_entries() -> list[dict[str, str]]:
101
101
  return entries
102
102
 
103
103
 
104
+ def _latest_release_changelog() -> dict[str, object]:
105
+ """Return the most recent tagged release entries for display."""
106
+
107
+ changelog_path = Path("CHANGELOG.rst")
108
+ try:
109
+ text = changelog_path.read_text(encoding="utf-8")
110
+ except (FileNotFoundError, OSError):
111
+ return {"title": "", "entries": []}
112
+
113
+ lines = text.splitlines()
114
+ state = "before"
115
+ release_title = ""
116
+ entries: list[dict[str, str]] = []
117
+
118
+ for raw_line in lines:
119
+ stripped = raw_line.strip()
120
+
121
+ if state == "before":
122
+ if stripped == "Unreleased":
123
+ state = "unreleased-heading"
124
+ continue
125
+
126
+ if state == "unreleased-heading":
127
+ if set(stripped) == {"-"}:
128
+ state = "unreleased-body"
129
+ else:
130
+ state = "unreleased-body"
131
+ continue
132
+
133
+ if state == "unreleased-body":
134
+ if not stripped:
135
+ state = "after-unreleased"
136
+ continue
137
+
138
+ if state == "after-unreleased":
139
+ if not stripped:
140
+ continue
141
+ release_title = stripped
142
+ state = "release-heading"
143
+ continue
144
+
145
+ if state == "release-heading":
146
+ if set(stripped) == {"-"}:
147
+ state = "release-body"
148
+ else:
149
+ state = "release-body"
150
+ continue
151
+
152
+ if state == "release-body":
153
+ if not stripped:
154
+ if entries:
155
+ break
156
+ continue
157
+ if not stripped.startswith("- "):
158
+ break
159
+ trimmed = stripped[2:].strip()
160
+ if not trimmed:
161
+ continue
162
+ parts = trimmed.split(" ", 1)
163
+ sha = parts[0]
164
+ message = parts[1] if len(parts) > 1 else ""
165
+ entries.append({"sha": sha, "message": message})
166
+
167
+ return {"title": release_title, "entries": entries}
168
+
169
+
104
170
  def _exclude_changelog_entries(shas: Iterable[str]) -> int:
105
171
  """Remove entries matching ``shas`` from the changelog.
106
172
 
@@ -1073,6 +1139,7 @@ def _system_changelog_report_view(request):
1073
1139
  {
1074
1140
  "title": _("Changelog Report"),
1075
1141
  "open_changelog_entries": _open_changelog_entries(),
1142
+ "latest_release_changelog": _latest_release_changelog(),
1076
1143
  }
1077
1144
  )
1078
1145
  return TemplateResponse(request, "admin/system_changelog_report.html", context)
@@ -1119,6 +1186,14 @@ class PendingTodoForm(forms.ModelForm):
1119
1186
  for name in ["request_details", "on_done_condition"]:
1120
1187
  self.fields[name].widget.attrs.setdefault("class", "vLargeTextField")
1121
1188
 
1189
+ mark_done_widget = self.fields["mark_done"].widget
1190
+ existing_classes = mark_done_widget.attrs.get("class", "").split()
1191
+ if "approve-checkbox" not in existing_classes:
1192
+ existing_classes.append("approve-checkbox")
1193
+ mark_done_widget.attrs["class"] = " ".join(
1194
+ class_name for class_name in existing_classes if class_name
1195
+ )
1196
+
1122
1197
 
1123
1198
  PendingTodoFormSet = modelformset_factory(Todo, form=PendingTodoForm, extra=0)
1124
1199
 
@@ -1144,6 +1219,7 @@ def _system_pending_todos_report_view(request):
1144
1219
  has_changes = form.has_changed()
1145
1220
  if mark_done and todo.done_on is None:
1146
1221
  todo.done_on = timezone.now()
1222
+ todo.populate_done_metadata(request.user)
1147
1223
  approved_count += 1
1148
1224
  has_changes = True
1149
1225
  if has_changes:
core/tests.py CHANGED
@@ -1969,7 +1969,7 @@ class PackageReleaseAdminActionTests(TestCase):
1969
1969
 
1970
1970
  @mock.patch("core.admin.PackageRelease.dump_fixture")
1971
1971
  @mock.patch("core.admin.requests.get")
1972
- def test_refresh_from_pypi_creates_releases(self, mock_get, dump):
1972
+ def test_refresh_from_pypi_reports_missing_releases(self, mock_get, dump):
1973
1973
  mock_get.return_value.raise_for_status.return_value = None
1974
1974
  mock_get.return_value.json.return_value = {
1975
1975
  "releases": {
@@ -1982,13 +1982,17 @@ class PackageReleaseAdminActionTests(TestCase):
1982
1982
  }
1983
1983
  }
1984
1984
  self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1985
- new_release = PackageRelease.objects.get(version="1.1.0")
1986
- self.assertEqual(new_release.revision, "")
1987
- self.assertEqual(
1988
- new_release.release_on,
1989
- datetime(2024, 2, 2, 15, 45, tzinfo=datetime_timezone.utc),
1985
+ self.assertFalse(
1986
+ PackageRelease.objects.filter(version="1.1.0").exists()
1987
+ )
1988
+ dump.assert_not_called()
1989
+ self.assertIn(
1990
+ (
1991
+ "Manual creation required for 1 release: 1.1.0",
1992
+ messages.WARNING,
1993
+ ),
1994
+ self.messages,
1990
1995
  )
1991
- dump.assert_called_once()
1992
1996
 
1993
1997
  @mock.patch("core.admin.PackageRelease.dump_fixture")
1994
1998
  @mock.patch("core.admin.requests.get")
@@ -2134,6 +2138,25 @@ class PackageReleaseCurrentTests(TestCase):
2134
2138
  self.package.save()
2135
2139
  self.assertFalse(self.release.is_current)
2136
2140
 
2141
+ def test_is_current_false_when_version_has_plus(self):
2142
+ self.version_path.write_text("1.0.0+")
2143
+ self.assertFalse(self.release.is_current)
2144
+
2145
+
2146
+ class PackageReleaseRevisionTests(TestCase):
2147
+ def setUp(self):
2148
+ self.package = Package.objects.get(name="arthexis")
2149
+ self.release = PackageRelease.objects.create(
2150
+ package=self.package,
2151
+ version="1.0.0",
2152
+ revision="abcdef123456",
2153
+ )
2154
+
2155
+ def test_matches_revision_ignores_plus_suffix(self):
2156
+ self.assertTrue(
2157
+ PackageRelease.matches_revision("1.0.0+", "abcdef123456")
2158
+ )
2159
+
2137
2160
  def test_is_current_false_when_version_differs(self):
2138
2161
  self.release.version = "2.0.0"
2139
2162
  self.release.save()
@@ -2184,13 +2207,22 @@ class PackageAdminPrepareNextReleaseTests(TestCase):
2184
2207
 
2185
2208
  def test_prepare_next_release_active_creates_release(self):
2186
2209
  PackageRelease.all_objects.filter(package=self.package).delete()
2187
- request = self.factory.get("/admin/core/package/prepare-next-release/")
2210
+ request = self.factory.post("/admin/core/package/prepare-next-release/")
2188
2211
  response = self.admin.prepare_next_release_active(request)
2189
2212
  self.assertEqual(response.status_code, 302)
2190
2213
  self.assertEqual(
2191
2214
  PackageRelease.all_objects.filter(package=self.package).count(), 1
2192
2215
  )
2193
2216
 
2217
+ def test_prepare_next_release_active_get_creates_release(self):
2218
+ PackageRelease.all_objects.filter(package=self.package).delete()
2219
+ request = self.factory.get("/admin/core/package/prepare-next-release/")
2220
+ response = self.admin.prepare_next_release_active(request)
2221
+ self.assertEqual(response.status_code, 302)
2222
+ self.assertTrue(
2223
+ PackageRelease.all_objects.filter(package=self.package).exists()
2224
+ )
2225
+
2194
2226
 
2195
2227
  class PackageAdminChangeViewTests(TestCase):
2196
2228
  def setUp(self):
@@ -2212,13 +2244,97 @@ class TodoDoneTests(TestCase):
2212
2244
  User.objects.create_superuser("admin", "admin@example.com", "pw")
2213
2245
  self.client.force_login(User.objects.get(username="admin"))
2214
2246
 
2215
- def test_mark_done_sets_timestamp(self):
2247
+ @mock.patch("core.models.revision_utils.get_revision", return_value="rev123")
2248
+ def test_mark_done_sets_timestamp(self, _get_revision):
2216
2249
  todo = Todo.objects.create(request="Task", is_seed_data=True)
2217
2250
  resp = self.client.post(reverse("todo-done", args=[todo.pk]))
2218
2251
  self.assertRedirects(resp, reverse("admin:index"))
2219
2252
  todo.refresh_from_db()
2220
2253
  self.assertIsNotNone(todo.done_on)
2221
2254
  self.assertFalse(todo.is_deleted)
2255
+ self.assertIsNone(todo.done_node)
2256
+ version_path = Path(settings.BASE_DIR) / "VERSION"
2257
+ expected_version = ""
2258
+ if version_path.exists():
2259
+ expected_version = version_path.read_text(encoding="utf-8").strip()
2260
+ self.assertEqual(todo.done_version, expected_version)
2261
+ self.assertEqual(todo.done_revision, "rev123")
2262
+ self.assertEqual(todo.done_username, "admin")
2263
+
2264
+ def test_mark_done_updates_seed_fixture(self):
2265
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2266
+ with tempfile.TemporaryDirectory() as tmp:
2267
+ base = Path(tmp)
2268
+ fixture_dir = base / "core" / "fixtures"
2269
+ fixture_dir.mkdir(parents=True)
2270
+ fixture_path = fixture_dir / "todo__task.json"
2271
+ fixture_path.write_text(
2272
+ json.dumps(
2273
+ [
2274
+ {
2275
+ "model": "core.todo",
2276
+ "fields": {
2277
+ "request": "Task",
2278
+ "url": "",
2279
+ "request_details": "",
2280
+ },
2281
+ }
2282
+ ],
2283
+ indent=2,
2284
+ )
2285
+ + "\n",
2286
+ encoding="utf-8",
2287
+ )
2288
+
2289
+ with override_settings(BASE_DIR=base):
2290
+ with mock.patch(
2291
+ "core.models.revision_utils.get_revision", return_value="rev456"
2292
+ ):
2293
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
2294
+
2295
+ self.assertRedirects(resp, reverse("admin:index"))
2296
+ data = json.loads(fixture_path.read_text(encoding="utf-8"))
2297
+ self.assertEqual(len(data), 1)
2298
+ fields = data[0]["fields"]
2299
+ self.assertIn("done_on", fields)
2300
+ self.assertTrue(fields["done_on"])
2301
+ self.assertFalse(fields.get("is_deleted", False))
2302
+ self.assertIn("done_version", fields)
2303
+ self.assertEqual(fields.get("done_revision"), "rev456")
2304
+ self.assertEqual(fields.get("done_username"), "admin")
2305
+
2306
+ def test_soft_delete_updates_seed_fixture(self):
2307
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2308
+ with tempfile.TemporaryDirectory() as tmp:
2309
+ base = Path(tmp)
2310
+ fixture_dir = base / "core" / "fixtures"
2311
+ fixture_dir.mkdir(parents=True)
2312
+ fixture_path = fixture_dir / "todo__task.json"
2313
+ fixture_path.write_text(
2314
+ json.dumps(
2315
+ [
2316
+ {
2317
+ "model": "core.todo",
2318
+ "fields": {
2319
+ "request": "Task",
2320
+ "url": "",
2321
+ "request_details": "",
2322
+ },
2323
+ }
2324
+ ],
2325
+ indent=2,
2326
+ )
2327
+ + "\n",
2328
+ encoding="utf-8",
2329
+ )
2330
+
2331
+ with override_settings(BASE_DIR=base):
2332
+ todo.delete()
2333
+
2334
+ data = json.loads(fixture_path.read_text(encoding="utf-8"))
2335
+ self.assertEqual(len(data), 1)
2336
+ fields = data[0]["fields"]
2337
+ self.assertTrue(fields.get("is_deleted"))
2222
2338
 
2223
2339
  def test_mark_done_missing_task_refreshes(self):
2224
2340
  todo = Todo.objects.create(request="Task", is_seed_data=True)
@@ -2324,6 +2440,61 @@ class TodoDoneTests(TestCase):
2324
2440
  self.assertTrue(todo.is_seed_data)
2325
2441
 
2326
2442
 
2443
+ class TodoDeleteTests(TestCase):
2444
+ def setUp(self):
2445
+ self.client = Client()
2446
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
2447
+ self.client.force_login(User.objects.get(username="admin"))
2448
+
2449
+ def test_delete_marks_task_deleted(self):
2450
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2451
+ resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
2452
+ self.assertRedirects(resp, reverse("admin:index"))
2453
+ todo.refresh_from_db()
2454
+ self.assertTrue(todo.is_deleted)
2455
+ self.assertIsNone(todo.done_on)
2456
+
2457
+ def test_delete_updates_seed_fixture(self):
2458
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2459
+ with tempfile.TemporaryDirectory() as tmp:
2460
+ base = Path(tmp)
2461
+ fixture_dir = base / "core" / "fixtures"
2462
+ fixture_dir.mkdir(parents=True)
2463
+ fixture_path = fixture_dir / "todo__task.json"
2464
+ fixture_path.write_text(
2465
+ json.dumps(
2466
+ [
2467
+ {
2468
+ "model": "core.todo",
2469
+ "fields": {
2470
+ "request": "Task",
2471
+ "url": "",
2472
+ "request_details": "",
2473
+ },
2474
+ }
2475
+ ],
2476
+ indent=2,
2477
+ )
2478
+ + "\n",
2479
+ encoding="utf-8",
2480
+ )
2481
+
2482
+ with override_settings(BASE_DIR=base):
2483
+ resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
2484
+ self.assertRedirects(resp, reverse("admin:index"))
2485
+ data = json.loads(fixture_path.read_text(encoding="utf-8"))
2486
+ self.assertEqual(len(data), 1)
2487
+ fields = data[0]["fields"]
2488
+ self.assertTrue(fields.get("is_deleted"))
2489
+
2490
+ def test_delete_missing_task_redirects(self):
2491
+ todo = Todo.objects.create(request="Task")
2492
+ todo.is_deleted = True
2493
+ todo.save(update_fields=["is_deleted"])
2494
+ resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
2495
+ self.assertRedirects(resp, reverse("admin:index"))
2496
+
2497
+
2327
2498
  class TodoFocusViewTests(TestCase):
2328
2499
  def setUp(self):
2329
2500
  self.client = Client()
@@ -2341,6 +2512,7 @@ class TodoFocusViewTests(TestCase):
2341
2512
  self.assertEqual(resp["X-Frame-Options"], "SAMEORIGIN")
2342
2513
  self.assertContains(resp, f'src="{todo.url}"')
2343
2514
  self.assertContains(resp, "Done")
2515
+ self.assertContains(resp, "Delete")
2344
2516
  self.assertContains(resp, "Back")
2345
2517
  self.assertContains(resp, "Take Snapshot")
2346
2518
  snapshot_url = reverse("todo-snapshot", args=[todo.pk])
core/user_data.py CHANGED
@@ -201,9 +201,49 @@ def dump_user_fixture(instance, user=None) -> None:
201
201
 
202
202
  def delete_user_fixture(instance, user=None) -> None:
203
203
  target_user = user or _resolve_fixture_user(instance)
204
- if target_user is None:
204
+ filename = (
205
+ f"{instance._meta.app_label}_{instance._meta.model_name}_{instance.pk}.json"
206
+ )
207
+
208
+ def _remove_for_user(candidate) -> None:
209
+ if candidate is None:
210
+ return
211
+ base_path = Path(
212
+ getattr(candidate, "data_path", "") or Path(settings.BASE_DIR) / "data"
213
+ )
214
+ username = _username_for(candidate)
215
+ if not username:
216
+ return
217
+ user_dir = base_path / username
218
+ if user_dir.exists():
219
+ (user_dir / filename).unlink(missing_ok=True)
220
+
221
+ if target_user is not None:
222
+ _remove_for_user(target_user)
205
223
  return
206
- _fixture_path(target_user, instance).unlink(missing_ok=True)
224
+
225
+ root = Path(settings.BASE_DIR) / "data"
226
+ if root.exists():
227
+ (root / filename).unlink(missing_ok=True)
228
+ for path in root.iterdir():
229
+ if path.is_dir():
230
+ (path / filename).unlink(missing_ok=True)
231
+
232
+ UserModel = get_user_model()
233
+ manager = getattr(UserModel, "all_objects", UserModel._default_manager)
234
+ for candidate in manager.all():
235
+ data_path = getattr(candidate, "data_path", "")
236
+ if not data_path:
237
+ continue
238
+ base_path = Path(data_path)
239
+ if not base_path.exists():
240
+ continue
241
+ username = _username_for(candidate)
242
+ if not username:
243
+ continue
244
+ user_dir = base_path / username
245
+ if user_dir.exists():
246
+ (user_dir / filename).unlink(missing_ok=True)
207
247
 
208
248
 
209
249
  def _mark_fixture_user_data(path: Path) -> None:
core/views.py CHANGED
@@ -365,6 +365,7 @@ def request_temp_password(request):
365
365
  )
366
366
 
367
367
 
368
+ @staff_member_required
368
369
  @require_GET
369
370
  def version_info(request):
370
371
  """Return the running application version and Git revision."""
@@ -495,6 +496,16 @@ def _sync_with_origin_main(log_path: Path) -> None:
495
496
  if stderr:
496
497
  _append_log(log_path, "git errors:\n" + stderr)
497
498
 
499
+ status = subprocess.run(
500
+ ["git", "status"], capture_output=True, text=True, check=False
501
+ )
502
+ status_output = (status.stdout or "").strip()
503
+ status_errors = (status.stderr or "").strip()
504
+ if status_output:
505
+ _append_log(log_path, "git status:\n" + status_output)
506
+ if status_errors:
507
+ _append_log(log_path, "git status errors:\n" + status_errors)
508
+
498
509
  branch = _current_branch() or "(detached HEAD)"
499
510
  instructions = [
500
511
  "Manual intervention required to finish syncing with origin/main.",
@@ -679,16 +690,17 @@ def _ensure_origin_main_unchanged(log_path: Path) -> None:
679
690
  def _next_patch_version(version: str) -> str:
680
691
  from packaging.version import InvalidVersion, Version
681
692
 
693
+ cleaned = version.rstrip("+")
682
694
  try:
683
- parsed = Version(version)
695
+ parsed = Version(cleaned)
684
696
  except InvalidVersion:
685
- parts = version.split(".")
697
+ parts = cleaned.split(".") if cleaned else []
686
698
  for index in range(len(parts) - 1, -1, -1):
687
699
  segment = parts[index]
688
700
  if segment.isdigit():
689
701
  parts[index] = str(int(segment) + 1)
690
702
  return ".".join(parts)
691
- return version
703
+ return cleaned or version
692
704
  return f"{parsed.major}.{parsed.minor}.{parsed.micro + 1}"
693
705
 
694
706
 
@@ -757,7 +769,9 @@ def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
757
769
  version_path = Path("VERSION")
758
770
  if version_path.exists():
759
771
  try:
760
- repo_version = Version(version_path.read_text(encoding="utf-8").strip())
772
+ raw_version = version_path.read_text(encoding="utf-8").strip()
773
+ cleaned_version = raw_version.rstrip("+") or "0.0.0"
774
+ repo_version = Version(cleaned_version)
761
775
  except InvalidVersion:
762
776
  repo_version = None
763
777
 
@@ -924,7 +938,7 @@ def _refresh_changelog_once(ctx, log_path: Path) -> None:
924
938
  ctx["changelog_refreshed"] = True
925
939
 
926
940
 
927
- def _step_check_todos(release, ctx, log_path: Path) -> None:
941
+ def _step_check_todos(release, ctx, log_path: Path, *, user=None) -> None:
928
942
  _refresh_changelog_once(ctx, log_path)
929
943
 
930
944
  pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
@@ -964,7 +978,7 @@ def _step_check_todos(release, ctx, log_path: Path) -> None:
964
978
  ctx["todos_ack"] = True
965
979
 
966
980
 
967
- def _step_check_version(release, ctx, log_path: Path) -> None:
981
+ def _step_check_version(release, ctx, log_path: Path, *, user=None) -> None:
968
982
  from . import release as release_utils
969
983
  from packaging.version import InvalidVersion, Version
970
984
 
@@ -1099,10 +1113,12 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
1099
1113
  version_path = Path("VERSION")
1100
1114
  if version_path.exists():
1101
1115
  current = version_path.read_text(encoding="utf-8").strip()
1102
- if current and Version(release.version) < Version(current):
1103
- raise Exception(
1104
- f"Version {release.version} is older than existing {current}"
1105
- )
1116
+ if current:
1117
+ current_clean = current.rstrip("+") or "0.0.0"
1118
+ if Version(release.version) < Version(current_clean):
1119
+ raise Exception(
1120
+ f"Version {release.version} is older than existing {current}"
1121
+ )
1106
1122
 
1107
1123
  _append_log(log_path, f"Checking if version {release.version} exists on PyPI")
1108
1124
  if release_utils.network_available():
@@ -1152,17 +1168,17 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
1152
1168
  _append_log(log_path, "Network unavailable, skipping PyPI check")
1153
1169
 
1154
1170
 
1155
- def _step_handle_migrations(release, ctx, log_path: Path) -> None:
1171
+ def _step_handle_migrations(release, ctx, log_path: Path, *, user=None) -> None:
1156
1172
  _append_log(log_path, "Freeze, squash and approve migrations")
1157
1173
  _append_log(log_path, "Migration review acknowledged (manual step)")
1158
1174
 
1159
1175
 
1160
- def _step_changelog_docs(release, ctx, log_path: Path) -> None:
1176
+ def _step_changelog_docs(release, ctx, log_path: Path, *, user=None) -> None:
1161
1177
  _append_log(log_path, "Compose CHANGELOG and documentation")
1162
1178
  _append_log(log_path, "CHANGELOG and documentation review recorded")
1163
1179
 
1164
1180
 
1165
- def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1181
+ def _step_pre_release_actions(release, ctx, log_path: Path, *, user=None) -> None:
1166
1182
  _append_log(log_path, "Execute pre-release actions")
1167
1183
  if ctx.get("dry_run"):
1168
1184
  _append_log(log_path, "Dry run: skipping pre-release actions")
@@ -1238,12 +1254,12 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1238
1254
  _append_log(log_path, "Pre-release actions complete")
1239
1255
 
1240
1256
 
1241
- def _step_run_tests(release, ctx, log_path: Path) -> None:
1257
+ def _step_run_tests(release, ctx, log_path: Path, *, user=None) -> None:
1242
1258
  _append_log(log_path, "Complete test suite with --all flag")
1243
1259
  _append_log(log_path, "Test suite completion acknowledged")
1244
1260
 
1245
1261
 
1246
- def _step_promote_build(release, ctx, log_path: Path) -> None:
1262
+ def _step_promote_build(release, ctx, log_path: Path, *, user=None) -> None:
1247
1263
  from . import release as release_utils
1248
1264
 
1249
1265
  _append_log(log_path, "Generating build files")
@@ -1255,7 +1271,7 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1255
1271
  release_utils.promote(
1256
1272
  package=release.to_package(),
1257
1273
  version=release.version,
1258
- creds=release.to_credentials(),
1274
+ creds=release.to_credentials(user=user),
1259
1275
  )
1260
1276
  _append_log(
1261
1277
  log_path,
@@ -1303,8 +1319,10 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1303
1319
  _append_log(new_log, "Build complete")
1304
1320
 
1305
1321
 
1306
- def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
1307
- if release.to_credentials() is None:
1322
+ def _step_release_manager_approval(
1323
+ release, ctx, log_path: Path, *, user=None
1324
+ ) -> None:
1325
+ if release.to_credentials(user=user) is None:
1308
1326
  ctx.pop("release_approval", None)
1309
1327
  if not ctx.get("approval_credentials_missing"):
1310
1328
  _append_log(log_path, "Release manager publishing credentials missing")
@@ -1338,14 +1356,14 @@ def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
1338
1356
  raise ApprovalRequired()
1339
1357
 
1340
1358
 
1341
- def _step_publish(release, ctx, log_path: Path) -> None:
1359
+ def _step_publish(release, ctx, log_path: Path, *, user=None) -> None:
1342
1360
  from . import release as release_utils
1343
1361
 
1344
1362
  if ctx.get("dry_run"):
1345
1363
  test_repository_url = os.environ.get(
1346
1364
  "PYPI_TEST_REPOSITORY_URL", "https://test.pypi.org/legacy/"
1347
1365
  )
1348
- test_creds = release.to_credentials()
1366
+ test_creds = release.to_credentials(user=user)
1349
1367
  if not (test_creds and test_creds.has_auth()):
1350
1368
  test_creds = release_utils.Credentials(
1351
1369
  token=os.environ.get("PYPI_TEST_API_TOKEN"),
@@ -1385,7 +1403,7 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1385
1403
  release_utils.build(
1386
1404
  package=package,
1387
1405
  version=release.version,
1388
- creds=release.to_credentials(),
1406
+ creds=release.to_credentials(user=user),
1389
1407
  dist=True,
1390
1408
  tests=False,
1391
1409
  twine=False,
@@ -1414,13 +1432,13 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1414
1432
  release_utils.publish(
1415
1433
  package=release.to_package(),
1416
1434
  version=release.version,
1417
- creds=target.credentials or release.to_credentials(),
1435
+ creds=target.credentials or release.to_credentials(user=user),
1418
1436
  repositories=[target],
1419
1437
  )
1420
1438
  _append_log(log_path, "Dry run: skipped release metadata updates")
1421
1439
  return
1422
1440
 
1423
- targets = release.build_publish_targets()
1441
+ targets = release.build_publish_targets(user=user)
1424
1442
  repo_labels = []
1425
1443
  for target in targets:
1426
1444
  label = target.name
@@ -1439,7 +1457,7 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1439
1457
  release_utils.publish(
1440
1458
  package=release.to_package(),
1441
1459
  version=release.version,
1442
- creds=release.to_credentials(),
1460
+ creds=release.to_credentials(user=user),
1443
1461
  repositories=targets,
1444
1462
  )
1445
1463
  except release_utils.PostPublishWarning as warning:
@@ -1808,7 +1826,7 @@ def release_progress(request, pk: int, action: str):
1808
1826
  return redirect(request.path)
1809
1827
 
1810
1828
  manager = release.release_manager or release.package.release_manager
1811
- credentials_ready = bool(release.to_credentials())
1829
+ credentials_ready = bool(release.to_credentials(user=request.user))
1812
1830
  if credentials_ready and ctx.get("approval_credentials_missing"):
1813
1831
  ctx.pop("approval_credentials_missing", None)
1814
1832
 
@@ -1979,7 +1997,7 @@ def release_progress(request, pk: int, action: str):
1979
1997
  if to_run == step_count:
1980
1998
  name, func = steps[to_run]
1981
1999
  try:
1982
- func(release, ctx, log_path)
2000
+ func(release, ctx, log_path, user=request.user)
1983
2001
  except PendingTodos:
1984
2002
  pass
1985
2003
  except ApprovalRequired:
@@ -2360,6 +2378,7 @@ def todo_focus(request, pk: int):
2360
2378
  "focus_auth": focus_auth,
2361
2379
  "next_url": _get_return_url(request),
2362
2380
  "done_url": reverse("todo-done", args=[todo.pk]),
2381
+ "delete_url": reverse("todo-delete", args=[todo.pk]),
2363
2382
  "snapshot_url": reverse("todo-snapshot", args=[todo.pk]),
2364
2383
  }
2365
2384
  return render(request, "core/todo_focus.html", context)
@@ -2378,7 +2397,29 @@ def todo_done(request, pk: int):
2378
2397
  messages.error(request, _format_condition_failure(todo, result))
2379
2398
  return redirect(redirect_to)
2380
2399
  todo.done_on = timezone.now()
2381
- todo.save(update_fields=["done_on"])
2400
+ todo.populate_done_metadata(request.user)
2401
+ todo.save(
2402
+ update_fields=[
2403
+ "done_on",
2404
+ "done_node",
2405
+ "done_version",
2406
+ "done_revision",
2407
+ "done_username",
2408
+ ]
2409
+ )
2410
+ return redirect(redirect_to)
2411
+
2412
+
2413
+ @staff_member_required
2414
+ @require_POST
2415
+ def todo_delete(request, pk: int):
2416
+ redirect_to = reverse("admin:index")
2417
+ try:
2418
+ todo = Todo.objects.get(pk=pk, is_deleted=False)
2419
+ except Todo.DoesNotExist:
2420
+ return redirect(redirect_to)
2421
+ todo.is_deleted = True
2422
+ todo.save(update_fields=["is_deleted"])
2382
2423
  return redirect(redirect_to)
2383
2424
 
2384
2425