arthexis 0.1.16__py3-none-any.whl → 0.1.28__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.

Files changed (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
core/views.py CHANGED
@@ -11,7 +11,7 @@ import requests
11
11
  from django.conf import settings
12
12
  from django.contrib.admin.sites import site as admin_site
13
13
  from django.contrib.admin.views.decorators import staff_member_required
14
- from django.contrib.auth import authenticate, login
14
+ from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login
15
15
  from django.contrib import messages
16
16
  from django.contrib.sites.models import Site
17
17
  from django.http import Http404, JsonResponse, HttpResponse
@@ -19,7 +19,6 @@ from django.shortcuts import get_object_or_404, redirect, render, resolve_url
19
19
  from django.template.response import TemplateResponse
20
20
  from django.utils import timezone
21
21
  from django.utils.html import strip_tags
22
- from django.utils.text import slugify
23
22
  from django.utils.translation import gettext as _
24
23
  from django.urls import NoReverseMatch, reverse
25
24
  from django.views.decorators.csrf import csrf_exempt
@@ -43,6 +42,7 @@ logger = logging.getLogger(__name__)
43
42
  PYPI_REQUEST_TIMEOUT = 10
44
43
 
45
44
  from . import changelog as changelog_utils
45
+ from . import temp_passwords
46
46
  from .models import OdooProfile, Product, EnergyAccount, PackageRelease, Todo
47
47
  from .models import RFID
48
48
 
@@ -58,7 +58,6 @@ def odoo_products(request):
58
58
  products = profile.execute(
59
59
  "product.product",
60
60
  "search_read",
61
- [[]],
62
61
  fields=["name"],
63
62
  limit=50,
64
63
  )
@@ -100,7 +99,7 @@ def odoo_quote_report(request):
100
99
 
101
100
  if not profile or not profile.is_verified:
102
101
  context["error"] = _(
103
- "Configure and verify your Odoo employee credentials before generating the report."
102
+ "Configure and verify your CRM employee credentials before generating the report."
104
103
  )
105
104
  return TemplateResponse(
106
105
  request, "admin/core/odoo_quote_report.html", context
@@ -137,7 +136,6 @@ def odoo_quote_report(request):
137
136
  templates = profile.execute(
138
137
  "sale.order.template",
139
138
  "search_read",
140
- [[]],
141
139
  fields=["name"],
142
140
  order="name asc",
143
141
  )
@@ -287,7 +285,6 @@ def odoo_quote_report(request):
287
285
  products = profile.execute(
288
286
  "product.product",
289
287
  "search_read",
290
- [[]],
291
288
  fields=["name", "default_code", "write_date", "create_date"],
292
289
  limit=10,
293
290
  order="write_date desc, create_date desc",
@@ -336,6 +333,36 @@ def odoo_quote_report(request):
336
333
  return TemplateResponse(request, "admin/core/odoo_quote_report.html", context)
337
334
 
338
335
 
336
+ @staff_member_required
337
+ @require_GET
338
+ def request_temp_password(request):
339
+ """Generate a temporary password for the authenticated staff member."""
340
+
341
+ user = request.user
342
+ username = user.get_username()
343
+ password = temp_passwords.generate_password()
344
+ entry = temp_passwords.store_temp_password(
345
+ username,
346
+ password,
347
+ allow_change=True,
348
+ )
349
+ context = {
350
+ **admin_site.each_context(request),
351
+ "title": _("Temporary password"),
352
+ "username": username,
353
+ "password": password,
354
+ "expires_at": timezone.localtime(entry.expires_at),
355
+ "allow_change": entry.allow_change,
356
+ "return_url": reverse("admin:password_change"),
357
+ }
358
+ return TemplateResponse(
359
+ request,
360
+ "admin/core/request_temp_password.html",
361
+ context,
362
+ )
363
+
364
+
365
+ @staff_member_required
339
366
  @require_GET
340
367
  def version_info(request):
341
368
  """Return the running application version and Git revision."""
@@ -418,8 +445,11 @@ def _resolve_release_log_dir(preferred: Path) -> tuple[Path, str | None]:
418
445
 
419
446
  env_override = os.environ.pop("ARTHEXIS_LOG_DIR", None)
420
447
  fallback = select_log_dir(Path(settings.BASE_DIR))
421
- if env_override and Path(env_override) != fallback:
422
- os.environ["ARTHEXIS_LOG_DIR"] = str(fallback)
448
+ if env_override is not None:
449
+ if Path(env_override) == fallback:
450
+ os.environ["ARTHEXIS_LOG_DIR"] = env_override
451
+ else:
452
+ os.environ["ARTHEXIS_LOG_DIR"] = str(fallback)
423
453
 
424
454
  if fallback == preferred:
425
455
  if error:
@@ -440,9 +470,60 @@ def _resolve_release_log_dir(preferred: Path) -> tuple[Path, str | None]:
440
470
  return fallback, warning
441
471
 
442
472
 
473
+ def _commit_todo_fixtures_if_needed(log_path: Path) -> None:
474
+ """Stage and commit modified TODO fixtures before syncing with origin/main."""
475
+
476
+ try:
477
+ status = subprocess.run(
478
+ ["git", "status", "--porcelain"],
479
+ check=True,
480
+ capture_output=True,
481
+ text=True,
482
+ )
483
+ except subprocess.CalledProcessError:
484
+ return
485
+
486
+ todo_paths: set[Path] = set()
487
+ for line in (status.stdout or "").splitlines():
488
+ if not line:
489
+ continue
490
+ path_fragment = line[3:].strip()
491
+ if "->" in path_fragment:
492
+ path_fragment = path_fragment.split("->", 1)[1].strip()
493
+ path = Path(path_fragment)
494
+ if path.suffix == ".json" and path.parent == Path("core/fixtures"):
495
+ if path.name.startswith("todo__"):
496
+ todo_paths.add(path)
497
+
498
+ if not todo_paths:
499
+ return
500
+
501
+ sorted_paths = sorted(todo_paths)
502
+ subprocess.run(
503
+ ["git", "add", *[str(path) for path in sorted_paths]],
504
+ check=True,
505
+ )
506
+ formatted = ", ".join(_format_path(path) for path in sorted_paths)
507
+ _append_log(log_path, f"Staged TODO fixtures {formatted}")
508
+
509
+ diff = subprocess.run(
510
+ ["git", "diff", "--cached", "--name-only", "--", *[str(path) for path in sorted_paths]],
511
+ check=True,
512
+ capture_output=True,
513
+ text=True,
514
+ )
515
+
516
+ if (diff.stdout or "").strip():
517
+ message = "chore: update TODO fixtures"
518
+ subprocess.run(["git", "commit", "-m", message], check=True)
519
+ _append_log(log_path, f"Committed TODO fixtures ({message})")
520
+
521
+
443
522
  def _sync_with_origin_main(log_path: Path) -> None:
444
523
  """Ensure the current branch is rebased onto ``origin/main``."""
445
524
 
525
+ _commit_todo_fixtures_if_needed(log_path)
526
+
446
527
  if not _has_remote("origin"):
447
528
  _append_log(log_path, "No git remote configured; skipping sync with origin/main")
448
529
  return
@@ -463,6 +544,16 @@ def _sync_with_origin_main(log_path: Path) -> None:
463
544
  if stderr:
464
545
  _append_log(log_path, "git errors:\n" + stderr)
465
546
 
547
+ status = subprocess.run(
548
+ ["git", "status"], capture_output=True, text=True, check=False
549
+ )
550
+ status_output = (status.stdout or "").strip()
551
+ status_errors = (status.stderr or "").strip()
552
+ if status_output:
553
+ _append_log(log_path, "git status:\n" + status_output)
554
+ if status_errors:
555
+ _append_log(log_path, "git status errors:\n" + status_errors)
556
+
466
557
  branch = _current_branch() or "(detached HEAD)"
467
558
  instructions = [
468
559
  "Manual intervention required to finish syncing with origin/main.",
@@ -578,6 +669,43 @@ def _git_authentication_missing(exc: subprocess.CalledProcessError) -> bool:
578
669
  return any(marker in message for marker in auth_markers)
579
670
 
580
671
 
672
+ def _push_release_changes(log_path: Path) -> bool:
673
+ """Push release commits to ``origin`` and log the outcome."""
674
+
675
+ if not _has_remote("origin"):
676
+ _append_log(
677
+ log_path, "No git remote configured; skipping push of release changes"
678
+ )
679
+ return False
680
+
681
+ try:
682
+ branch = _current_branch()
683
+ if branch is None:
684
+ push_cmd = ["git", "push", "origin", "HEAD"]
685
+ elif _has_upstream(branch):
686
+ push_cmd = ["git", "push"]
687
+ else:
688
+ push_cmd = ["git", "push", "--set-upstream", "origin", branch]
689
+ subprocess.run(push_cmd, check=True, capture_output=True, text=True)
690
+ except subprocess.CalledProcessError as exc:
691
+ details = _format_subprocess_error(exc)
692
+ if _git_authentication_missing(exc):
693
+ _append_log(
694
+ log_path,
695
+ "Authentication is required to push release changes to origin; skipping push",
696
+ )
697
+ if details:
698
+ _append_log(log_path, details)
699
+ return False
700
+ _append_log(
701
+ log_path, f"Failed to push release changes to origin: {details}"
702
+ )
703
+ raise Exception("Failed to push release changes") from exc
704
+
705
+ _append_log(log_path, "Pushed release changes to origin")
706
+ return True
707
+
708
+
581
709
  def _ensure_origin_main_unchanged(log_path: Path) -> None:
582
710
  """Verify that ``origin/main`` has not advanced during the release."""
583
711
 
@@ -610,42 +738,20 @@ def _ensure_origin_main_unchanged(log_path: Path) -> None:
610
738
  def _next_patch_version(version: str) -> str:
611
739
  from packaging.version import InvalidVersion, Version
612
740
 
741
+ cleaned = version.rstrip("+")
613
742
  try:
614
- parsed = Version(version)
743
+ parsed = Version(cleaned)
615
744
  except InvalidVersion:
616
- parts = version.split(".")
745
+ parts = cleaned.split(".") if cleaned else []
617
746
  for index in range(len(parts) - 1, -1, -1):
618
747
  segment = parts[index]
619
748
  if segment.isdigit():
620
749
  parts[index] = str(int(segment) + 1)
621
750
  return ".".join(parts)
622
- return version
751
+ return cleaned or version
623
752
  return f"{parsed.major}.{parsed.minor}.{parsed.micro + 1}"
624
753
 
625
754
 
626
- def _write_todo_fixture(todo: Todo) -> Path:
627
- safe_request = todo.request.replace(".", " ")
628
- slug = slugify(safe_request).replace("-", "_")
629
- if not slug:
630
- slug = "todo"
631
- path = TODO_FIXTURE_DIR / f"todos__{slug}.json"
632
- path.parent.mkdir(parents=True, exist_ok=True)
633
- data = [
634
- {
635
- "model": "core.todo",
636
- "fields": {
637
- "request": todo.request,
638
- "url": todo.url,
639
- "request_details": todo.request_details,
640
- "generated_for_version": todo.generated_for_version,
641
- "generated_for_revision": todo.generated_for_revision,
642
- },
643
- }
644
- ]
645
- path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
646
- return path
647
-
648
-
649
755
  def _should_use_python_changelog(exc: OSError) -> bool:
650
756
  winerror = getattr(exc, "winerror", None)
651
757
  if winerror in {193}:
@@ -656,8 +762,8 @@ def _should_use_python_changelog(exc: OSError) -> bool:
656
762
  def _generate_changelog_with_python(log_path: Path) -> None:
657
763
  _append_log(log_path, "Falling back to Python changelog generator")
658
764
  changelog_path = Path("CHANGELOG.rst")
659
- range_spec = changelog_utils.determine_range_spec()
660
765
  previous = changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else None
766
+ range_spec = changelog_utils.determine_range_spec(previous_text=previous)
661
767
  sections = changelog_utils.collect_sections(range_spec=range_spec, previous_text=previous)
662
768
  content = changelog_utils.render_changelog(sections)
663
769
  if not content.endswith("\n"):
@@ -666,44 +772,32 @@ def _generate_changelog_with_python(log_path: Path) -> None:
666
772
  _append_log(log_path, "Regenerated CHANGELOG.rst using Python fallback")
667
773
 
668
774
 
669
- def _ensure_release_todo(
670
- release, *, previous_version: str | None = None
671
- ) -> tuple[Todo, Path]:
672
- previous_version = (previous_version or "").strip()
673
- target_version = _next_patch_version(release.version)
674
- if previous_version:
675
- try:
676
- from packaging.version import InvalidVersion, Version
775
+ def _todo_blocks_publish(todo: Todo, release: PackageRelease) -> bool:
776
+ """Return ``True`` when ``todo`` should block the release workflow."""
677
777
 
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)
685
- request = f"Create release {release.package.name} {target_version}"
686
- try:
687
- url = reverse("admin:core_packagerelease_changelist")
688
- except NoReverseMatch:
689
- url = ""
690
- todo, _ = Todo.all_objects.update_or_create(
691
- request__iexact=request,
692
- defaults={
693
- "request": request,
694
- "url": url,
695
- "request_details": "",
696
- "generated_for_version": release.version or "",
697
- "generated_for_revision": release.revision or "",
698
- "is_seed_data": True,
699
- "is_deleted": False,
700
- "is_user_data": False,
701
- "done_on": None,
702
- "on_done_condition": "",
703
- },
704
- )
705
- fixture_path = _write_todo_fixture(todo)
706
- return todo, fixture_path
778
+ request = (todo.request or "").strip()
779
+ release_name = (release.package.name or "").strip()
780
+ if not request or not release_name:
781
+ return True
782
+
783
+ prefix = f"create release {release_name.lower()} "
784
+ if not request.lower().startswith(prefix):
785
+ return True
786
+
787
+ release_version = (release.version or "").strip()
788
+ generated_version = (todo.generated_for_version or "").strip()
789
+ if not release_version or release_version != generated_version:
790
+ return True
791
+
792
+ generated_revision = (todo.generated_for_revision or "").strip()
793
+ release_revision = (release.revision or "").strip()
794
+ if generated_revision and release_revision and generated_revision != release_revision:
795
+ return True
796
+
797
+ if not todo.is_seed_data:
798
+ return True
799
+
800
+ return False
707
801
 
708
802
 
709
803
  def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
@@ -723,7 +817,9 @@ def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
723
817
  version_path = Path("VERSION")
724
818
  if version_path.exists():
725
819
  try:
726
- repo_version = Version(version_path.read_text(encoding="utf-8").strip())
820
+ raw_version = version_path.read_text(encoding="utf-8").strip()
821
+ cleaned_version = raw_version.rstrip("+") or "0.0.0"
822
+ repo_version = Version(cleaned_version)
727
823
  except InvalidVersion:
728
824
  repo_version = None
729
825
 
@@ -890,14 +986,25 @@ def _refresh_changelog_once(ctx, log_path: Path) -> None:
890
986
  ctx["changelog_refreshed"] = True
891
987
 
892
988
 
893
- def _step_check_todos(release, ctx, log_path: Path) -> None:
989
+ def _step_check_todos(release, ctx, log_path: Path, *, user=None) -> None:
894
990
  _refresh_changelog_once(ctx, log_path)
895
991
 
896
992
  pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
897
993
  pending_values = list(
898
994
  pending_qs.values("id", "request", "url", "request_details")
899
995
  )
996
+ if not pending_values:
997
+ ctx["todos_ack"] = True
998
+
900
999
  if not ctx.get("todos_ack"):
1000
+ if not ctx.get("todos_block_logged"):
1001
+ _append_log(
1002
+ log_path,
1003
+ "Release checklist requires acknowledgment before continuing. "
1004
+ "Review outstanding TODO items and confirm the checklist; "
1005
+ "publishing will resume automatically afterward.",
1006
+ )
1007
+ ctx["todos_block_logged"] = True
901
1008
  ctx["todos"] = pending_values
902
1009
  ctx["todos_required"] = True
903
1010
  raise PendingTodos()
@@ -919,7 +1026,7 @@ def _step_check_todos(release, ctx, log_path: Path) -> None:
919
1026
  ctx["todos_ack"] = True
920
1027
 
921
1028
 
922
- def _step_check_version(release, ctx, log_path: Path) -> None:
1029
+ def _step_check_version(release, ctx, log_path: Path, *, user=None) -> None:
923
1030
  from . import release as release_utils
924
1031
  from packaging.version import InvalidVersion, Version
925
1032
 
@@ -1054,10 +1161,12 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
1054
1161
  version_path = Path("VERSION")
1055
1162
  if version_path.exists():
1056
1163
  current = version_path.read_text(encoding="utf-8").strip()
1057
- if current and Version(release.version) < Version(current):
1058
- raise Exception(
1059
- f"Version {release.version} is older than existing {current}"
1060
- )
1164
+ if current:
1165
+ current_clean = current.rstrip("+") or "0.0.0"
1166
+ if Version(release.version) < Version(current_clean):
1167
+ raise Exception(
1168
+ f"Version {release.version} is older than existing {current}"
1169
+ )
1061
1170
 
1062
1171
  _append_log(log_path, f"Checking if version {release.version} exists on PyPI")
1063
1172
  if release_utils.network_available():
@@ -1107,47 +1216,17 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
1107
1216
  _append_log(log_path, "Network unavailable, skipping PyPI check")
1108
1217
 
1109
1218
 
1110
- def _step_handle_migrations(release, ctx, log_path: Path) -> None:
1219
+ def _step_handle_migrations(release, ctx, log_path: Path, *, user=None) -> None:
1111
1220
  _append_log(log_path, "Freeze, squash and approve migrations")
1112
1221
  _append_log(log_path, "Migration review acknowledged (manual step)")
1113
1222
 
1114
1223
 
1115
- def _step_changelog_docs(release, ctx, log_path: Path) -> None:
1224
+ def _step_changelog_docs(release, ctx, log_path: Path, *, user=None) -> None:
1116
1225
  _append_log(log_path, "Compose CHANGELOG and documentation")
1117
1226
  _append_log(log_path, "CHANGELOG and documentation review recorded")
1118
1227
 
1119
1228
 
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
-
1150
- def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1229
+ def _step_pre_release_actions(release, ctx, log_path: Path, *, user=None) -> None:
1151
1230
  _append_log(log_path, "Execute pre-release actions")
1152
1231
  if ctx.get("dry_run"):
1153
1232
  _append_log(log_path, "Dry run: skipping pre-release actions")
@@ -1220,16 +1299,15 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1220
1299
  for path in staged_release_fixtures:
1221
1300
  subprocess.run(["git", "reset", "HEAD", str(path)], check=False)
1222
1301
  _append_log(log_path, f"Unstaged release fixture {_format_path(path)}")
1223
- ctx["release_todo_previous_version"] = repo_version_before_sync
1224
1302
  _append_log(log_path, "Pre-release actions complete")
1225
1303
 
1226
1304
 
1227
- def _step_run_tests(release, ctx, log_path: Path) -> None:
1305
+ def _step_run_tests(release, ctx, log_path: Path, *, user=None) -> None:
1228
1306
  _append_log(log_path, "Complete test suite with --all flag")
1229
1307
  _append_log(log_path, "Test suite completion acknowledged")
1230
1308
 
1231
1309
 
1232
- def _step_promote_build(release, ctx, log_path: Path) -> None:
1310
+ def _step_promote_build(release, ctx, log_path: Path, *, user=None) -> None:
1233
1311
  from . import release as release_utils
1234
1312
 
1235
1313
  _append_log(log_path, "Generating build files")
@@ -1241,7 +1319,7 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1241
1319
  release_utils.promote(
1242
1320
  package=release.to_package(),
1243
1321
  version=release.version,
1244
- creds=release.to_credentials(),
1322
+ creds=release.to_credentials(user=user),
1245
1323
  )
1246
1324
  _append_log(
1247
1325
  log_path,
@@ -1271,40 +1349,9 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1271
1349
  log_path,
1272
1350
  f"Committed release metadata for v{release.version}",
1273
1351
  )
1274
- if _has_remote("origin"):
1275
- try:
1276
- branch = _current_branch()
1277
- if branch is None:
1278
- push_cmd = ["git", "push", "origin", "HEAD"]
1279
- elif _has_upstream(branch):
1280
- push_cmd = ["git", "push"]
1281
- else:
1282
- push_cmd = ["git", "push", "--set-upstream", "origin", branch]
1283
- subprocess.run(push_cmd, check=True, capture_output=True, text=True)
1284
- except subprocess.CalledProcessError as exc:
1285
- details = _format_subprocess_error(exc)
1286
- if _git_authentication_missing(exc):
1287
- _append_log(
1288
- log_path,
1289
- "Authentication is required to push release changes to origin; skipping push",
1290
- )
1291
- if details:
1292
- _append_log(log_path, details)
1293
- else:
1294
- _append_log(
1295
- log_path, f"Failed to push release changes to origin: {details}"
1296
- )
1297
- raise Exception("Failed to push release changes") from exc
1298
- else:
1299
- _append_log(log_path, "Pushed release changes to origin")
1300
- else:
1301
- _append_log(
1302
- log_path,
1303
- "No git remote configured; skipping push of release changes",
1304
- )
1352
+ _push_release_changes(log_path)
1305
1353
  PackageRelease.dump_fixture()
1306
1354
  _append_log(log_path, "Updated release fixtures")
1307
- _record_release_todo(release, ctx, log_path)
1308
1355
  except Exception:
1309
1356
  _clean_repo()
1310
1357
  raise
@@ -1320,8 +1367,10 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1320
1367
  _append_log(new_log, "Build complete")
1321
1368
 
1322
1369
 
1323
- def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
1324
- if release.to_credentials() is None:
1370
+ def _step_release_manager_approval(
1371
+ release, ctx, log_path: Path, *, user=None
1372
+ ) -> None:
1373
+ if release.to_credentials(user=user) is None:
1325
1374
  ctx.pop("release_approval", None)
1326
1375
  if not ctx.get("approval_credentials_missing"):
1327
1376
  _append_log(log_path, "Release manager publishing credentials missing")
@@ -1355,14 +1404,14 @@ def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
1355
1404
  raise ApprovalRequired()
1356
1405
 
1357
1406
 
1358
- def _step_publish(release, ctx, log_path: Path) -> None:
1407
+ def _step_publish(release, ctx, log_path: Path, *, user=None) -> None:
1359
1408
  from . import release as release_utils
1360
1409
 
1361
1410
  if ctx.get("dry_run"):
1362
1411
  test_repository_url = os.environ.get(
1363
1412
  "PYPI_TEST_REPOSITORY_URL", "https://test.pypi.org/legacy/"
1364
1413
  )
1365
- test_creds = release.to_credentials()
1414
+ test_creds = release.to_credentials(user=user)
1366
1415
  if not (test_creds and test_creds.has_auth()):
1367
1416
  test_creds = release_utils.Credentials(
1368
1417
  token=os.environ.get("PYPI_TEST_API_TOKEN"),
@@ -1402,7 +1451,7 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1402
1451
  release_utils.build(
1403
1452
  package=package,
1404
1453
  version=release.version,
1405
- creds=release.to_credentials(),
1454
+ creds=release.to_credentials(user=user),
1406
1455
  dist=True,
1407
1456
  tests=False,
1408
1457
  twine=False,
@@ -1431,13 +1480,13 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1431
1480
  release_utils.publish(
1432
1481
  package=release.to_package(),
1433
1482
  version=release.version,
1434
- creds=target.credentials or release.to_credentials(),
1483
+ creds=target.credentials or release.to_credentials(user=user),
1435
1484
  repositories=[target],
1436
1485
  )
1437
1486
  _append_log(log_path, "Dry run: skipped release metadata updates")
1438
1487
  return
1439
1488
 
1440
- targets = release.build_publish_targets()
1489
+ targets = release.build_publish_targets(user=user)
1441
1490
  repo_labels = []
1442
1491
  for target in targets:
1443
1492
  label = target.name
@@ -1451,12 +1500,29 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1451
1500
  )
1452
1501
  else:
1453
1502
  _append_log(log_path, "Uploading distribution")
1454
- release_utils.publish(
1455
- package=release.to_package(),
1456
- version=release.version,
1457
- creds=release.to_credentials(),
1458
- repositories=targets,
1459
- )
1503
+ publish_warning: release_utils.PostPublishWarning | None = None
1504
+ try:
1505
+ release_utils.publish(
1506
+ package=release.to_package(),
1507
+ version=release.version,
1508
+ creds=release.to_credentials(user=user),
1509
+ repositories=targets,
1510
+ )
1511
+ except release_utils.PostPublishWarning as warning:
1512
+ publish_warning = warning
1513
+
1514
+ if publish_warning is not None:
1515
+ message = str(publish_warning)
1516
+ followups = _dedupe_preserve_order(publish_warning.followups)
1517
+ warning_entries = ctx.setdefault("warnings", [])
1518
+ if not any(entry.get("message") == message for entry in warning_entries):
1519
+ entry: dict[str, object] = {"message": message}
1520
+ if followups:
1521
+ entry["followups"] = followups
1522
+ warning_entries.append(entry)
1523
+ _append_log(log_path, message)
1524
+ for note in followups:
1525
+ _append_log(log_path, f"Follow-up: {note}")
1460
1526
  release.pypi_url = (
1461
1527
  f"https://pypi.org/project/{release.package.name}/{release.version}/"
1462
1528
  )
@@ -1475,6 +1541,30 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1475
1541
  _append_log(log_path, f"Recorded PyPI URL: {release.pypi_url}")
1476
1542
  if release.github_url:
1477
1543
  _append_log(log_path, f"Recorded GitHub URL: {release.github_url}")
1544
+ fixture_paths = [
1545
+ str(path) for path in Path("core/fixtures").glob("releases__*.json")
1546
+ ]
1547
+ if fixture_paths:
1548
+ status = subprocess.run(
1549
+ ["git", "status", "--porcelain", "--", *fixture_paths],
1550
+ capture_output=True,
1551
+ text=True,
1552
+ check=True,
1553
+ )
1554
+ if status.stdout.strip():
1555
+ subprocess.run(["git", "add", *fixture_paths], check=True)
1556
+ _append_log(log_path, "Staged publish metadata updates")
1557
+ commit_message = f"chore: record publish metadata for v{release.version}"
1558
+ subprocess.run(["git", "commit", "-m", commit_message], check=True)
1559
+ _append_log(
1560
+ log_path, f"Committed publish metadata for v{release.version}"
1561
+ )
1562
+ _push_release_changes(log_path)
1563
+ else:
1564
+ _append_log(
1565
+ log_path,
1566
+ "No release metadata updates detected after publish; skipping commit",
1567
+ )
1478
1568
  _append_log(log_path, "Upload complete")
1479
1569
 
1480
1570
 
@@ -1510,12 +1600,28 @@ def rfid_login(request):
1510
1600
  if not rfid:
1511
1601
  return JsonResponse({"detail": "rfid required"}, status=400)
1512
1602
 
1603
+ redirect_to = data.get(REDIRECT_FIELD_NAME) or data.get("next")
1604
+ if redirect_to and not url_has_allowed_host_and_scheme(
1605
+ redirect_to,
1606
+ allowed_hosts={request.get_host()},
1607
+ require_https=request.is_secure(),
1608
+ ):
1609
+ redirect_to = ""
1610
+
1513
1611
  user = authenticate(request, rfid=rfid)
1514
1612
  if user is None:
1515
1613
  return JsonResponse({"detail": "invalid RFID"}, status=401)
1516
1614
 
1517
1615
  login(request, user)
1518
- return JsonResponse({"id": user.id, "username": user.username})
1616
+ if redirect_to:
1617
+ target = redirect_to
1618
+ elif user.is_staff:
1619
+ target = reverse("admin:index")
1620
+ else:
1621
+ target = "/"
1622
+ return JsonResponse(
1623
+ {"id": user.id, "username": user.username, "redirect": target}
1624
+ )
1519
1625
 
1520
1626
 
1521
1627
  @api_login_required
@@ -1668,9 +1774,9 @@ def rfid_batch(request):
1668
1774
  else:
1669
1775
  post_auth_command = post_auth_command.strip()
1670
1776
 
1671
- tag, _ = RFID.objects.update_or_create(
1672
- rfid=rfid.upper(),
1673
- defaults={
1777
+ tag, _ = RFID.update_or_create_from_code(
1778
+ rfid,
1779
+ {
1674
1780
  "allowed": allowed,
1675
1781
  "color": color,
1676
1782
  "released": released,
@@ -1784,7 +1890,7 @@ def release_progress(request, pk: int, action: str):
1784
1890
  return redirect(request.path)
1785
1891
 
1786
1892
  manager = release.release_manager or release.package.release_manager
1787
- credentials_ready = bool(release.to_credentials())
1893
+ credentials_ready = bool(release.to_credentials(user=request.user))
1788
1894
  if credentials_ready and ctx.get("approval_credentials_missing"):
1789
1895
  ctx.pop("approval_credentials_missing", None)
1790
1896
 
@@ -1804,8 +1910,16 @@ def release_progress(request, pk: int, action: str):
1804
1910
  ctx["release_approval"] = "approved"
1805
1911
  if request.GET.get("reject"):
1806
1912
  ctx["release_approval"] = "rejected"
1913
+ resume_requested = bool(request.GET.get("resume"))
1914
+
1807
1915
  if request.GET.get("pause") and ctx.get("started"):
1808
1916
  ctx["paused"] = True
1917
+
1918
+ if resume_requested:
1919
+ if not ctx.get("started"):
1920
+ ctx["started"] = True
1921
+ if ctx.get("paused"):
1922
+ ctx["paused"] = False
1809
1923
  restart_count = 0
1810
1924
  if restart_path.exists():
1811
1925
  try:
@@ -1814,26 +1928,42 @@ def release_progress(request, pk: int, action: str):
1814
1928
  restart_count = 0
1815
1929
  step_count = ctx.get("step", 0)
1816
1930
  step_param = request.GET.get("step")
1931
+ if resume_requested and step_param is None:
1932
+ step_param = str(step_count)
1817
1933
 
1818
1934
  pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
1819
1935
  pending_items = list(pending_qs)
1820
- if ack_todos_requested:
1821
- if pending_items:
1822
- failures = []
1823
- for todo in pending_items:
1824
- result = todo.check_on_done_condition()
1825
- if not result.passed:
1826
- failures.append((todo, result))
1827
- if failures:
1828
- ctx.pop("todos_ack", None)
1829
- for todo, result in failures:
1830
- messages.error(request, _format_condition_failure(todo, result))
1831
- else:
1832
- ctx["todos_ack"] = True
1936
+ blocking_todos = [
1937
+ todo for todo in pending_items if _todo_blocks_publish(todo, release)
1938
+ ]
1939
+ if not blocking_todos:
1940
+ ctx["todos_ack"] = True
1941
+ ctx["todos_ack_auto"] = True
1942
+ elif ack_todos_requested:
1943
+ failures = []
1944
+ for todo in blocking_todos:
1945
+ result = todo.check_on_done_condition()
1946
+ if not result.passed:
1947
+ failures.append((todo, result))
1948
+ if failures:
1949
+ ctx["todos_ack"] = False
1950
+ ctx.pop("todos_ack_auto", None)
1951
+ for todo, result in failures:
1952
+ messages.error(request, _format_condition_failure(todo, result))
1833
1953
  else:
1834
1954
  ctx["todos_ack"] = True
1955
+ ctx.pop("todos_ack_auto", None)
1956
+ else:
1957
+ if ctx.pop("todos_ack_auto", None):
1958
+ ctx["todos_ack"] = False
1959
+ else:
1960
+ ctx.setdefault("todos_ack", False)
1835
1961
 
1836
- if not ctx.get("todos_ack"):
1962
+ if ctx.get("todos_ack"):
1963
+ ctx.pop("todos_block_logged", None)
1964
+ ctx.pop("todos", None)
1965
+ ctx.pop("todos_required", None)
1966
+ else:
1837
1967
  ctx["todos"] = [
1838
1968
  {
1839
1969
  "id": todo.pk,
@@ -1841,12 +1971,9 @@ def release_progress(request, pk: int, action: str):
1841
1971
  "url": todo.url,
1842
1972
  "request_details": todo.request_details,
1843
1973
  }
1844
- for todo in pending_items
1974
+ for todo in blocking_todos
1845
1975
  ]
1846
1976
  ctx["todos_required"] = True
1847
- else:
1848
- ctx.pop("todos", None)
1849
- ctx.pop("todos_required", None)
1850
1977
 
1851
1978
  log_name = _release_log_name(release.package.name, release.version)
1852
1979
  if ctx.get("log") != log_name:
@@ -1856,6 +1983,8 @@ def release_progress(request, pk: int, action: str):
1856
1983
  "started": ctx.get("started", False),
1857
1984
  }
1858
1985
  step_count = 0
1986
+ if not blocking_todos:
1987
+ ctx["todos_ack"] = True
1859
1988
  log_path = log_dir / log_name
1860
1989
  ctx.setdefault("log", log_name)
1861
1990
  ctx.setdefault("paused", False)
@@ -1932,7 +2061,7 @@ def release_progress(request, pk: int, action: str):
1932
2061
  if to_run == step_count:
1933
2062
  name, func = steps[to_run]
1934
2063
  try:
1935
- func(release, ctx, log_path)
2064
+ func(release, ctx, log_path, user=request.user)
1936
2065
  except PendingTodos:
1937
2066
  pass
1938
2067
  except ApprovalRequired:
@@ -2049,6 +2178,14 @@ def release_progress(request, pk: int, action: str):
2049
2178
  )
2050
2179
 
2051
2180
  is_running = ctx.get("started") and not paused and not done and not ctx.get("error")
2181
+ resume_available = (
2182
+ ctx.get("started")
2183
+ and not paused
2184
+ and not done
2185
+ and not ctx.get("error")
2186
+ and step_count < len(steps)
2187
+ and next_step is None
2188
+ )
2052
2189
  can_resume = ctx.get("started") and paused and not done and not ctx.get("error")
2053
2190
  release_manager_owner = manager.owner_display() if manager else ""
2054
2191
  try:
@@ -2103,9 +2240,11 @@ def release_progress(request, pk: int, action: str):
2103
2240
  "has_release_manager": bool(manager),
2104
2241
  "current_user_admin_url": current_user_admin_url,
2105
2242
  "is_running": is_running,
2243
+ "resume_available": resume_available,
2106
2244
  "can_resume": can_resume,
2107
2245
  "dry_run": dry_run_active,
2108
2246
  "dry_run_toggle_enabled": dry_run_toggle_enabled,
2247
+ "warnings": ctx.get("warnings", []),
2109
2248
  }
2110
2249
  request.session[session_key] = ctx
2111
2250
  if done or ctx.get("error"):
@@ -2303,6 +2442,7 @@ def todo_focus(request, pk: int):
2303
2442
  "focus_auth": focus_auth,
2304
2443
  "next_url": _get_return_url(request),
2305
2444
  "done_url": reverse("todo-done", args=[todo.pk]),
2445
+ "delete_url": reverse("todo-delete", args=[todo.pk]),
2306
2446
  "snapshot_url": reverse("todo-snapshot", args=[todo.pk]),
2307
2447
  }
2308
2448
  return render(request, "core/todo_focus.html", context)
@@ -2321,7 +2461,29 @@ def todo_done(request, pk: int):
2321
2461
  messages.error(request, _format_condition_failure(todo, result))
2322
2462
  return redirect(redirect_to)
2323
2463
  todo.done_on = timezone.now()
2324
- todo.save(update_fields=["done_on"])
2464
+ todo.populate_done_metadata(request.user)
2465
+ todo.save(
2466
+ update_fields=[
2467
+ "done_on",
2468
+ "done_node",
2469
+ "done_version",
2470
+ "done_revision",
2471
+ "done_username",
2472
+ ]
2473
+ )
2474
+ return redirect(redirect_to)
2475
+
2476
+
2477
+ @staff_member_required
2478
+ @require_POST
2479
+ def todo_delete(request, pk: int):
2480
+ redirect_to = reverse("admin:index")
2481
+ try:
2482
+ todo = Todo.objects.get(pk=pk, is_deleted=False)
2483
+ except Todo.DoesNotExist:
2484
+ return redirect(redirect_to)
2485
+ todo.is_deleted = True
2486
+ todo.save(update_fields=["is_deleted"])
2325
2487
  return redirect(redirect_to)
2326
2488
 
2327
2489