arthexis 0.1.8__py3-none-any.whl → 0.1.10__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 (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/views.py CHANGED
@@ -1,23 +1,74 @@
1
1
  import json
2
2
  import shutil
3
- from datetime import date, timedelta
3
+ from datetime import timedelta
4
4
 
5
5
  import requests
6
+ from django.conf import settings
6
7
  from django.contrib.admin.views.decorators import staff_member_required
7
8
  from django.contrib.auth import authenticate, login
9
+ from django.contrib import messages
10
+ from django.contrib.sites.models import Site
8
11
  from django.http import Http404, JsonResponse
9
- from django.shortcuts import get_object_or_404, render, redirect
12
+ from django.shortcuts import get_object_or_404, redirect, render, resolve_url
13
+ from django.utils import timezone
14
+ from django.utils.translation import gettext as _
15
+ from django.urls import NoReverseMatch, reverse
10
16
  from django.views.decorators.csrf import csrf_exempt
17
+ from django.views.decorators.http import require_GET, require_POST
18
+ from django.utils.http import url_has_allowed_host_and_scheme
11
19
  from pathlib import Path
20
+ from urllib.parse import urlsplit, urlunsplit
12
21
  import subprocess
13
22
 
23
+ from utils import revision
14
24
  from utils.api import api_login_required
15
25
 
16
- from .models import Product, Subscription, EnergyAccount, PackageRelease
26
+ from .models import Product, EnergyAccount, PackageRelease, Todo
17
27
  from .models import RFID
28
+
29
+
30
+ @staff_member_required
31
+ def odoo_products(request):
32
+ """Return available products from the user's Odoo instance."""
33
+
34
+ profile = getattr(request.user, "odoo_profile", None)
35
+ if not profile or not profile.is_verified:
36
+ raise Http404
37
+ try:
38
+ products = profile.execute(
39
+ "product.product",
40
+ "search_read",
41
+ [],
42
+ {"fields": ["name"], "limit": 50},
43
+ )
44
+ except Exception:
45
+ return JsonResponse({"detail": "Unable to fetch products"}, status=502)
46
+ items = [{"id": p.get("id"), "name": p.get("name", "")} for p in products]
47
+ return JsonResponse(items, safe=False)
48
+
49
+
50
+ @require_GET
51
+ def version_info(request):
52
+ """Return the running application version and Git revision."""
53
+
54
+ version = ""
55
+ version_path = Path(settings.BASE_DIR) / "VERSION"
56
+ if version_path.exists():
57
+ version = version_path.read_text(encoding="utf-8").strip()
58
+ return JsonResponse(
59
+ {
60
+ "version": version,
61
+ "revision": revision.get_revision(),
62
+ }
63
+ )
64
+
65
+
18
66
  from . import release as release_utils
19
67
 
20
68
 
69
+ TODO_FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures"
70
+
71
+
21
72
  def _append_log(path: Path, message: str) -> None:
22
73
  path.parent.mkdir(parents=True, exist_ok=True)
23
74
  with path.open("a", encoding="utf-8") as fh:
@@ -30,6 +81,53 @@ def _clean_repo() -> None:
30
81
  subprocess.run(["git", "clean", "-fd"], check=False)
31
82
 
32
83
 
84
+ def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
85
+ """Ensure ``release`` matches the repository revision and version.
86
+
87
+ Returns a tuple ``(updated, previous_version)`` where ``updated`` is
88
+ ``True`` when any field changed and ``previous_version`` is the version
89
+ before synchronization.
90
+ """
91
+
92
+ from packaging.version import InvalidVersion, Version
93
+
94
+ previous_version = release.version
95
+ updated_fields: set[str] = set()
96
+
97
+ repo_version: Version | None = None
98
+ version_path = Path("VERSION")
99
+ if version_path.exists():
100
+ try:
101
+ repo_version = Version(version_path.read_text(encoding="utf-8").strip())
102
+ except InvalidVersion:
103
+ repo_version = None
104
+
105
+ try:
106
+ release_version = Version(release.version)
107
+ except InvalidVersion:
108
+ release_version = None
109
+
110
+ if repo_version is not None:
111
+ bumped_repo_version = Version(
112
+ f"{repo_version.major}.{repo_version.minor}.{repo_version.micro + 1}"
113
+ )
114
+ if release_version is None or release_version < bumped_repo_version:
115
+ release.version = str(bumped_repo_version)
116
+ release_version = bumped_repo_version
117
+ updated_fields.add("version")
118
+
119
+ current_revision = revision.get_revision()
120
+ if current_revision and current_revision != release.revision:
121
+ release.revision = current_revision
122
+ updated_fields.add("revision")
123
+
124
+ if updated_fields:
125
+ release.save(update_fields=list(updated_fields))
126
+ PackageRelease.dump_fixture()
127
+
128
+ return bool(updated_fields), previous_version
129
+
130
+
33
131
  def _changelog_notes(version: str) -> str:
34
132
  path = Path("CHANGELOG.rst")
35
133
  if not path.exists():
@@ -47,7 +145,80 @@ def _changelog_notes(version: str) -> str:
47
145
  return ""
48
146
 
49
147
 
50
- def _step_check_pypi(release, ctx, log_path: Path) -> None:
148
+ class PendingTodos(Exception):
149
+ """Raised when TODO items require acknowledgment before proceeding."""
150
+
151
+
152
+ class ApprovalRequired(Exception):
153
+ """Raised when release manager approval is required before continuing."""
154
+
155
+
156
+ def _format_condition_failure(todo: Todo, result) -> str:
157
+ """Return a localized error message for a failed TODO condition."""
158
+
159
+ if result.error and result.resolved:
160
+ detail = _("%(condition)s (error: %(error)s)") % {
161
+ "condition": result.resolved,
162
+ "error": result.error,
163
+ }
164
+ elif result.error:
165
+ detail = _("Error: %(error)s") % {"error": result.error}
166
+ elif result.resolved:
167
+ detail = result.resolved
168
+ else:
169
+ detail = _("Condition evaluated to False")
170
+ return _("Condition failed for %(todo)s: %(detail)s") % {
171
+ "todo": todo.request,
172
+ "detail": detail,
173
+ }
174
+
175
+
176
+ def _get_return_url(request) -> str:
177
+ """Return a safe URL to redirect back to after completing a TODO."""
178
+
179
+ candidates = [request.GET.get("next"), request.POST.get("next")]
180
+ referer = request.META.get("HTTP_REFERER")
181
+ if referer:
182
+ candidates.append(referer)
183
+
184
+ for candidate in candidates:
185
+ if not candidate:
186
+ continue
187
+ if url_has_allowed_host_and_scheme(
188
+ candidate,
189
+ allowed_hosts={request.get_host()},
190
+ require_https=request.is_secure(),
191
+ ):
192
+ return candidate
193
+ return resolve_url("admin:index")
194
+
195
+
196
+ def _step_check_todos(release, ctx, log_path: Path) -> None:
197
+ pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
198
+ if pending_qs.exists():
199
+ ctx["todos"] = list(
200
+ pending_qs.values("id", "request", "url", "request_details")
201
+ )
202
+ if not ctx.get("todos_ack"):
203
+ raise PendingTodos()
204
+ todos = list(Todo.objects.filter(is_deleted=False))
205
+ for todo in todos:
206
+ todo.delete()
207
+ removed = []
208
+ for path in TODO_FIXTURE_DIR.glob("todos__*.json"):
209
+ removed.append(str(path))
210
+ path.unlink()
211
+ if removed:
212
+ subprocess.run(["git", "add", *removed], check=False)
213
+ subprocess.run(
214
+ ["git", "commit", "-m", "chore: remove TODO fixtures"],
215
+ check=False,
216
+ )
217
+ ctx.pop("todos", None)
218
+ ctx.pop("todos_ack", None)
219
+
220
+
221
+ def _step_check_version(release, ctx, log_path: Path) -> None:
51
222
  from . import release as release_utils
52
223
  from packaging.version import Version
53
224
 
@@ -107,13 +278,9 @@ def _step_check_pypi(release, ctx, log_path: Path) -> None:
107
278
  _append_log(log_path, f"Checking if version {release.version} exists on PyPI")
108
279
  if release_utils.network_available():
109
280
  try:
110
- resp = requests.get(
111
- f"https://pypi.org/pypi/{release.package.name}/json"
112
- )
281
+ resp = requests.get(f"https://pypi.org/pypi/{release.package.name}/json")
113
282
  if resp.ok and release.version in resp.json().get("releases", {}):
114
- raise Exception(
115
- f"Version {release.version} already on PyPI"
116
- )
283
+ raise Exception(f"Version {release.version} already on PyPI")
117
284
  except Exception as exc:
118
285
  # network errors should be logged but not crash
119
286
  if "already on PyPI" in str(exc):
@@ -123,8 +290,40 @@ def _step_check_pypi(release, ctx, log_path: Path) -> None:
123
290
  _append_log(log_path, "Network unavailable, skipping PyPI check")
124
291
 
125
292
 
293
+ def _step_handle_migrations(release, ctx, log_path: Path) -> None:
294
+ _append_log(log_path, "Freeze, squash and approve migrations")
295
+
296
+
297
+ def _step_changelog_docs(release, ctx, log_path: Path) -> None:
298
+ _append_log(log_path, "Compose CHANGELOG and documentation")
299
+
300
+
301
+ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
302
+ _append_log(log_path, "Execute pre-release actions")
303
+ version_path = Path("VERSION")
304
+ version_path.write_text(f"{release.version}\n", encoding="utf-8")
305
+ subprocess.run(["git", "add", "VERSION"], check=True)
306
+ diff = subprocess.run(
307
+ ["git", "diff", "--cached", "--quiet", "--", "VERSION"],
308
+ check=False,
309
+ )
310
+ if diff.returncode != 0:
311
+ subprocess.run(
312
+ ["git", "commit", "-m", f"pre-release commit {release.version}"],
313
+ check=True,
314
+ )
315
+ else:
316
+ _append_log(log_path, "No changes detected for VERSION; skipping commit")
317
+ subprocess.run(["git", "reset", "HEAD", "VERSION"], check=False)
318
+
319
+
320
+ def _step_run_tests(release, ctx, log_path: Path) -> None:
321
+ _append_log(log_path, "Complete test suite with --all flag")
322
+
323
+
126
324
  def _step_promote_build(release, ctx, log_path: Path) -> None:
127
325
  from . import release as release_utils
326
+
128
327
  _append_log(log_path, "Generating build files")
129
328
  try:
130
329
  try:
@@ -138,22 +337,16 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
138
337
  version=release.version,
139
338
  creds=release.to_credentials(),
140
339
  )
340
+ from glob import glob
341
+
342
+ paths = ["VERSION", *glob("core/fixtures/releases__*.json")]
141
343
  diff = subprocess.run(
142
- [
143
- "git",
144
- "status",
145
- "--porcelain",
146
- "VERSION",
147
- "core/fixtures/releases.json",
148
- ],
344
+ ["git", "status", "--porcelain", *paths],
149
345
  capture_output=True,
150
346
  text=True,
151
347
  )
152
348
  if diff.stdout.strip():
153
- subprocess.run(
154
- ["git", "add", "VERSION", "core/fixtures/releases.json"],
155
- check=True,
156
- )
349
+ subprocess.run(["git", "add", *paths], check=True)
157
350
  subprocess.run(
158
351
  [
159
352
  "git",
@@ -175,6 +368,41 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
175
368
  _append_log(new_log, "Build complete")
176
369
 
177
370
 
371
+ def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
372
+ if release.to_credentials() is None:
373
+ ctx.pop("release_approval", None)
374
+ if not ctx.get("approval_credentials_missing"):
375
+ _append_log(log_path, "Release manager publishing credentials missing")
376
+ ctx["approval_credentials_missing"] = True
377
+ ctx["awaiting_approval"] = True
378
+ raise ApprovalRequired()
379
+
380
+ missing_before = ctx.pop("approval_credentials_missing", None)
381
+ if missing_before:
382
+ ctx.pop("awaiting_approval", None)
383
+ decision = ctx.get("release_approval")
384
+ if decision == "approved":
385
+ ctx.pop("release_approval", None)
386
+ ctx.pop("awaiting_approval", None)
387
+ ctx.pop("approval_credentials_missing", None)
388
+ _append_log(log_path, "Release manager approved release")
389
+ return
390
+ if decision == "rejected":
391
+ ctx.pop("release_approval", None)
392
+ ctx.pop("awaiting_approval", None)
393
+ ctx.pop("approval_credentials_missing", None)
394
+ _append_log(log_path, "Release manager rejected release")
395
+ raise RuntimeError(
396
+ _("Release manager rejected the release. Restart required."),
397
+ )
398
+ if not ctx.get("awaiting_approval"):
399
+ ctx["awaiting_approval"] = True
400
+ _append_log(log_path, "Awaiting release manager approval")
401
+ else:
402
+ ctx["awaiting_approval"] = True
403
+ raise ApprovalRequired()
404
+
405
+
178
406
  def _step_publish(release, ctx, log_path: Path) -> None:
179
407
  from . import release as release_utils
180
408
 
@@ -184,16 +412,27 @@ def _step_publish(release, ctx, log_path: Path) -> None:
184
412
  version=release.version,
185
413
  creds=release.to_credentials(),
186
414
  )
187
- release.pypi_url = f"https://pypi.org/project/{release.package.name}/{release.version}/"
415
+ release.pypi_url = (
416
+ f"https://pypi.org/project/{release.package.name}/{release.version}/"
417
+ )
188
418
  release.save(update_fields=["pypi_url"])
189
419
  PackageRelease.dump_fixture()
190
420
  _append_log(log_path, "Upload complete")
191
421
 
192
422
 
423
+ FIXTURE_REVIEW_STEP_NAME = "Freeze, squash and approve migrations"
424
+
425
+
193
426
  PUBLISH_STEPS = [
194
- ("Check version availability", _step_check_pypi),
195
- ("Generate build", _step_promote_build),
196
- ("Publish", _step_publish),
427
+ ("Check version number availability", _step_check_version),
428
+ ("Confirm release TODO completion", _step_check_todos),
429
+ (FIXTURE_REVIEW_STEP_NAME, _step_handle_migrations),
430
+ ("Compose CHANGELOG and documentation", _step_changelog_docs),
431
+ ("Execute pre-release actions", _step_pre_release_actions),
432
+ ("Build release artifacts", _step_promote_build),
433
+ ("Complete test suite with --all flag", _step_run_tests),
434
+ ("Get Release Manager Approval", _step_release_manager_approval),
435
+ ("Upload final build to PyPI", _step_publish),
197
436
  ]
198
437
 
199
438
 
@@ -233,8 +472,8 @@ def product_list(request):
233
472
 
234
473
  @csrf_exempt
235
474
  @api_login_required
236
- def add_subscription(request):
237
- """Create a subscription for an energy account from POSTed JSON."""
475
+ def add_live_subscription(request):
476
+ """Create a live subscription for an energy account from POSTed JSON."""
238
477
 
239
478
  if request.method != "POST":
240
479
  return JsonResponse({"detail": "POST required"}, status=400)
@@ -257,32 +496,55 @@ def add_subscription(request):
257
496
  except Product.DoesNotExist:
258
497
  return JsonResponse({"detail": "invalid product"}, status=404)
259
498
 
260
- sub = Subscription.objects.create(
261
- account_id=account_id,
262
- product=product,
263
- next_renewal=date.today() + timedelta(days=product.renewal_period),
499
+ try:
500
+ account = EnergyAccount.objects.get(id=account_id)
501
+ except EnergyAccount.DoesNotExist:
502
+ return JsonResponse({"detail": "invalid account"}, status=404)
503
+
504
+ start_date = timezone.now().date()
505
+ account.live_subscription_product = product
506
+ account.live_subscription_start_date = start_date
507
+ account.live_subscription_next_renewal = start_date + timedelta(
508
+ days=product.renewal_period
264
509
  )
265
- return JsonResponse({"id": sub.id})
510
+ account.save()
511
+
512
+ return JsonResponse({"id": account.id})
266
513
 
267
514
 
268
515
  @api_login_required
269
- def subscription_list(request):
270
- """Return subscriptions for the given account_id."""
516
+ def live_subscription_list(request):
517
+ """Return live subscriptions for the given account_id."""
271
518
 
272
519
  account_id = request.GET.get("account_id")
273
520
  if not account_id:
274
521
  return JsonResponse({"detail": "account_id required"}, status=400)
275
522
 
276
- subs = list(
277
- Subscription.objects.filter(account_id=account_id)
278
- .select_related("product")
279
- .values(
280
- "id",
281
- "product__name",
282
- "next_renewal",
523
+ try:
524
+ account = EnergyAccount.objects.select_related("live_subscription_product").get(
525
+ id=account_id
283
526
  )
284
- )
285
- return JsonResponse({"subscriptions": subs})
527
+ except EnergyAccount.DoesNotExist:
528
+ return JsonResponse({"detail": "invalid account"}, status=404)
529
+
530
+ subs = []
531
+ product = account.live_subscription_product
532
+ if product:
533
+ next_renewal = account.live_subscription_next_renewal
534
+ if not next_renewal and account.live_subscription_start_date:
535
+ next_renewal = account.live_subscription_start_date + timedelta(
536
+ days=product.renewal_period
537
+ )
538
+
539
+ subs.append(
540
+ {
541
+ "id": account.id,
542
+ "product__name": product.name,
543
+ "next_renewal": next_renewal,
544
+ }
545
+ )
546
+
547
+ return JsonResponse({"live_subscriptions": subs})
286
548
 
287
549
 
288
550
  @csrf_exempt
@@ -303,6 +565,7 @@ def rfid_batch(request):
303
565
  tags = [
304
566
  {
305
567
  "rfid": t.rfid,
568
+ "custom_label": t.custom_label,
306
569
  "energy_accounts": list(t.energy_accounts.values_list("id", flat=True)),
307
570
  "allowed": t.allowed,
308
571
  "color": t.color,
@@ -329,12 +592,11 @@ def rfid_batch(request):
329
592
  continue
330
593
  allowed = row.get("allowed", True)
331
594
  energy_accounts = row.get("energy_accounts") or []
332
- color = (
333
- (row.get("color") or RFID.BLACK).strip().upper() or RFID.BLACK
334
- )
595
+ color = (row.get("color") or RFID.BLACK).strip().upper() or RFID.BLACK
335
596
  released = row.get("released", False)
336
597
  if isinstance(released, str):
337
598
  released = released.lower() == "true"
599
+ custom_label = (row.get("custom_label") or "").strip()
338
600
 
339
601
  tag, _ = RFID.objects.update_or_create(
340
602
  rfid=rfid.upper(),
@@ -342,10 +604,13 @@ def rfid_batch(request):
342
604
  "allowed": allowed,
343
605
  "color": color,
344
606
  "released": released,
607
+ "custom_label": custom_label,
345
608
  },
346
609
  )
347
610
  if energy_accounts:
348
- tag.energy_accounts.set(EnergyAccount.objects.filter(id__in=energy_accounts))
611
+ tag.energy_accounts.set(
612
+ EnergyAccount.objects.filter(id__in=energy_accounts)
613
+ )
349
614
  else:
350
615
  tag.energy_accounts.clear()
351
616
  count += 1
@@ -364,6 +629,22 @@ def release_progress(request, pk: int, action: str):
364
629
  lock_path = Path("locks") / f"release_publish_{pk}.json"
365
630
  restart_path = Path("locks") / f"release_publish_{pk}.restarts"
366
631
 
632
+ if not release.is_current:
633
+ if release.is_published:
634
+ raise Http404("Release is not current")
635
+ updated, previous_version = _sync_release_with_revision(release)
636
+ if updated:
637
+ request.session.pop(session_key, None)
638
+ if lock_path.exists():
639
+ lock_path.unlink()
640
+ if restart_path.exists():
641
+ restart_path.unlink()
642
+ log_dir = Path("logs")
643
+ for log_file in log_dir.glob(
644
+ f"{release.package.name}-{previous_version}*.log"
645
+ ):
646
+ log_file.unlink()
647
+
367
648
  if request.GET.get("restart"):
368
649
  count = 0
369
650
  if restart_path.exists():
@@ -393,6 +674,28 @@ def release_progress(request, pk: int, action: str):
393
674
  ctx = {"step": 0}
394
675
  if restart_path.exists():
395
676
  restart_path.unlink()
677
+
678
+ manager = release.release_manager or release.package.release_manager
679
+ credentials_ready = bool(release.to_credentials())
680
+ if credentials_ready and ctx.get("approval_credentials_missing"):
681
+ ctx.pop("approval_credentials_missing", None)
682
+
683
+ ack_todos_requested = bool(request.GET.get("ack_todos"))
684
+
685
+ if request.GET.get("start"):
686
+ ctx["started"] = True
687
+ ctx["paused"] = False
688
+ if (
689
+ ctx.get("awaiting_approval")
690
+ and not ctx.get("approval_credentials_missing")
691
+ and credentials_ready
692
+ ):
693
+ if request.GET.get("approve"):
694
+ ctx["release_approval"] = "approved"
695
+ if request.GET.get("reject"):
696
+ ctx["release_approval"] = "rejected"
697
+ if request.GET.get("pause") and ctx.get("started"):
698
+ ctx["paused"] = True
396
699
  restart_count = 0
397
700
  if restart_path.exists():
398
701
  try:
@@ -402,47 +705,213 @@ def release_progress(request, pk: int, action: str):
402
705
  step_count = ctx.get("step", 0)
403
706
  step_param = request.GET.get("step")
404
707
 
708
+ pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
709
+ pending_items = list(pending_qs)
710
+ if ack_todos_requested:
711
+ if pending_items:
712
+ failures = []
713
+ for todo in pending_items:
714
+ result = todo.check_on_done_condition()
715
+ if not result.passed:
716
+ failures.append((todo, result))
717
+ if failures:
718
+ ctx.pop("todos_ack", None)
719
+ for todo, result in failures:
720
+ messages.error(request, _format_condition_failure(todo, result))
721
+ else:
722
+ ctx["todos_ack"] = True
723
+ else:
724
+ ctx["todos_ack"] = True
725
+
726
+ if pending_items and not ctx.get("todos_ack"):
727
+ ctx["todos"] = [
728
+ {
729
+ "id": todo.pk,
730
+ "request": todo.request,
731
+ "url": todo.url,
732
+ "request_details": todo.request_details,
733
+ }
734
+ for todo in pending_items
735
+ ]
736
+ else:
737
+ ctx.pop("todos", None)
738
+
405
739
  identifier = f"{release.package.name}-{release.version}"
406
740
  log_name = f"{identifier}.log"
407
741
  if ctx.get("log") != log_name:
408
- ctx = {"step": 0, "log": log_name}
742
+ ctx = {
743
+ "step": 0,
744
+ "log": log_name,
745
+ "started": ctx.get("started", False),
746
+ }
409
747
  step_count = 0
410
748
  log_path = Path("logs") / log_name
411
749
  ctx.setdefault("log", log_name)
750
+ ctx.setdefault("paused", False)
412
751
 
413
- if step_count == 0 and (step_param is None or step_param == "0"):
752
+ if (
753
+ ctx.get("started")
754
+ and step_count == 0
755
+ and (step_param is None or step_param == "0")
756
+ ):
414
757
  if log_path.exists():
415
758
  log_path.unlink()
416
759
 
417
760
  steps = PUBLISH_STEPS
761
+ fixtures_step_index = next(
762
+ (
763
+ index
764
+ for index, (name, _) in enumerate(steps)
765
+ if name == FIXTURE_REVIEW_STEP_NAME
766
+ ),
767
+ None,
768
+ )
418
769
  error = ctx.get("error")
419
770
 
420
- if step_param is not None and not error and step_count < len(steps):
771
+ if (
772
+ ctx.get("started")
773
+ and not ctx.get("paused")
774
+ and step_param is not None
775
+ and not error
776
+ and step_count < len(steps)
777
+ ):
421
778
  to_run = int(step_param)
422
779
  if to_run == step_count:
423
780
  name, func = steps[to_run]
424
781
  try:
425
782
  func(release, ctx, log_path)
426
- step_count += 1
427
- ctx["step"] = step_count
428
- request.session[session_key] = ctx
429
- lock_path.parent.mkdir(parents=True, exist_ok=True)
430
- lock_path.write_text(json.dumps(ctx), encoding="utf-8")
783
+ except PendingTodos:
784
+ pass
785
+ except ApprovalRequired:
786
+ pass
431
787
  except Exception as exc: # pragma: no cover - best effort logging
432
788
  _append_log(log_path, f"{name} failed: {exc}")
433
789
  ctx["error"] = str(exc)
434
790
  request.session[session_key] = ctx
435
791
  lock_path.parent.mkdir(parents=True, exist_ok=True)
436
792
  lock_path.write_text(json.dumps(ctx), encoding="utf-8")
793
+ else:
794
+ step_count += 1
795
+ ctx["step"] = step_count
796
+ request.session[session_key] = ctx
797
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
798
+ lock_path.write_text(json.dumps(ctx), encoding="utf-8")
437
799
 
438
800
  done = step_count >= len(steps) and not ctx.get("error")
439
801
 
440
- log_content = log_path.read_text(encoding="utf-8") if log_path.exists() else ""
441
- next_step = step_count if not done and not ctx.get("error") else None
802
+ show_log = ctx.get("started") or step_count > 0 or done or ctx.get("error")
803
+ if show_log and log_path.exists():
804
+ log_content = log_path.read_text(encoding="utf-8")
805
+ else:
806
+ log_content = ""
807
+ next_step = (
808
+ step_count
809
+ if ctx.get("started")
810
+ and not ctx.get("paused")
811
+ and not done
812
+ and not ctx.get("error")
813
+ else None
814
+ )
815
+ has_pending_todos = bool(ctx.get("todos") and not ctx.get("todos_ack"))
816
+ if has_pending_todos:
817
+ next_step = None
818
+ awaiting_approval = bool(ctx.get("awaiting_approval"))
819
+ approval_credentials_missing = bool(ctx.get("approval_credentials_missing"))
820
+ if awaiting_approval:
821
+ next_step = None
822
+ if approval_credentials_missing:
823
+ next_step = None
824
+ paused = ctx.get("paused", False)
825
+
826
+ step_names = [s[0] for s in steps]
827
+ approval_credentials_ready = credentials_ready
828
+ credentials_blocking = approval_credentials_missing or (
829
+ awaiting_approval and not approval_credentials_ready
830
+ )
831
+ step_states = []
832
+ for index, name in enumerate(step_names):
833
+ if index < step_count:
834
+ status = "complete"
835
+ icon = "✅"
836
+ label = _("Completed")
837
+ elif error and index == step_count:
838
+ status = "error"
839
+ icon = "❌"
840
+ label = _("Failed")
841
+ elif paused and ctx.get("started") and index == step_count and not done:
842
+ status = "paused"
843
+ icon = "⏸️"
844
+ label = _("Paused")
845
+ elif (
846
+ has_pending_todos
847
+ and ctx.get("started")
848
+ and index == step_count
849
+ and not done
850
+ ):
851
+ status = "blocked"
852
+ icon = "📝"
853
+ label = _("Awaiting checklist")
854
+ elif (
855
+ credentials_blocking
856
+ and ctx.get("started")
857
+ and index == step_count
858
+ and not done
859
+ ):
860
+ status = "missing-credentials"
861
+ icon = "🔐"
862
+ label = _("Credentials required")
863
+ elif (
864
+ awaiting_approval
865
+ and approval_credentials_ready
866
+ and ctx.get("started")
867
+ and index == step_count
868
+ and not done
869
+ ):
870
+ status = "awaiting-approval"
871
+ icon = "🤝"
872
+ label = _("Awaiting approval")
873
+ elif ctx.get("started") and index == step_count and not done:
874
+ status = "active"
875
+ icon = "⏳"
876
+ label = _("In progress")
877
+ else:
878
+ status = "pending"
879
+ icon = "⬜"
880
+ label = _("Pending")
881
+ step_states.append(
882
+ {
883
+ "index": index + 1,
884
+ "name": name,
885
+ "status": status,
886
+ "icon": icon,
887
+ "label": label,
888
+ }
889
+ )
890
+
891
+ is_running = ctx.get("started") and not paused and not done and not ctx.get("error")
892
+ can_resume = ctx.get("started") and paused and not done and not ctx.get("error")
893
+ release_manager_owner = manager.owner_display() if manager else ""
894
+ try:
895
+ current_user_admin_url = reverse(
896
+ "admin:teams_user_change", args=[request.user.pk]
897
+ )
898
+ except NoReverseMatch:
899
+ current_user_admin_url = reverse(
900
+ "admin:core_user_change", args=[request.user.pk]
901
+ )
902
+
903
+ fixtures_summary = ctx.get("fixtures")
904
+ if (
905
+ fixtures_summary
906
+ and fixtures_step_index is not None
907
+ and step_count > fixtures_step_index
908
+ ):
909
+ fixtures_summary = None
910
+
442
911
  context = {
443
912
  "release": release,
444
913
  "action": "publish",
445
- "steps": [s[0] for s in steps],
914
+ "steps": step_names,
446
915
  "current_step": step_count,
447
916
  "next_step": next_step,
448
917
  "done": done,
@@ -450,8 +919,22 @@ def release_progress(request, pk: int, action: str):
450
919
  "log_content": log_content,
451
920
  "log_path": str(log_path),
452
921
  "cert_log": ctx.get("cert_log"),
453
- "fixtures": ctx.get("fixtures"),
922
+ "fixtures": fixtures_summary,
923
+ "todos": ctx.get("todos"),
454
924
  "restart_count": restart_count,
925
+ "started": ctx.get("started", False),
926
+ "paused": paused,
927
+ "show_log": show_log,
928
+ "step_states": step_states,
929
+ "has_pending_todos": has_pending_todos,
930
+ "awaiting_approval": awaiting_approval,
931
+ "approval_credentials_missing": approval_credentials_missing,
932
+ "approval_credentials_ready": approval_credentials_ready,
933
+ "release_manager_owner": release_manager_owner,
934
+ "has_release_manager": bool(manager),
935
+ "current_user_admin_url": current_user_admin_url,
936
+ "is_running": is_running,
937
+ "can_resume": can_resume,
455
938
  }
456
939
  request.session[session_key] = ctx
457
940
  if done or ctx.get("error"):
@@ -461,3 +944,88 @@ def release_progress(request, pk: int, action: str):
461
944
  lock_path.parent.mkdir(parents=True, exist_ok=True)
462
945
  lock_path.write_text(json.dumps(ctx), encoding="utf-8")
463
946
  return render(request, "core/release_progress.html", context)
947
+
948
+
949
+ def _todo_iframe_url(request, todo: Todo) -> str:
950
+ """Return a safe iframe URL for ``todo`` scoped to the current host."""
951
+
952
+ fallback = reverse("admin:core_todo_change", args=[todo.pk])
953
+ raw_url = (todo.url or "").strip()
954
+ if not raw_url:
955
+ return fallback
956
+
957
+ parsed = urlsplit(raw_url)
958
+ if not parsed.scheme and not parsed.netloc:
959
+ return raw_url
960
+
961
+ if parsed.scheme and parsed.scheme.lower() not in {"http", "https"}:
962
+ return fallback
963
+
964
+ request_host = request.get_host().strip().lower()
965
+ host_without_port = request_host.split(":", 1)[0]
966
+ allowed_hosts = {
967
+ request_host,
968
+ host_without_port,
969
+ "localhost",
970
+ "127.0.0.1",
971
+ "0.0.0.0",
972
+ "::1",
973
+ }
974
+
975
+ site_domain = ""
976
+ try:
977
+ site_domain = Site.objects.get_current().domain.strip().lower()
978
+ except Site.DoesNotExist:
979
+ site_domain = ""
980
+ if site_domain:
981
+ allowed_hosts.add(site_domain)
982
+ allowed_hosts.add(site_domain.split(":", 1)[0])
983
+
984
+ for host in getattr(settings, "ALLOWED_HOSTS", []):
985
+ if not isinstance(host, str):
986
+ continue
987
+ normalized = host.strip().lower()
988
+ if not normalized or normalized.startswith("*"):
989
+ continue
990
+ allowed_hosts.add(normalized)
991
+ allowed_hosts.add(normalized.split(":", 1)[0])
992
+
993
+ hostname = (parsed.hostname or "").strip().lower()
994
+ netloc = parsed.netloc.strip().lower()
995
+ if hostname in allowed_hosts or netloc in allowed_hosts:
996
+ path = parsed.path or "/"
997
+ if not path.startswith("/"):
998
+ path = f"/{path}"
999
+ return urlunsplit(("", "", path, parsed.query, parsed.fragment)) or fallback
1000
+
1001
+ return fallback
1002
+
1003
+
1004
+ @staff_member_required
1005
+ def todo_focus(request, pk: int):
1006
+ todo = get_object_or_404(Todo, pk=pk, is_deleted=False)
1007
+ if todo.done_on:
1008
+ return redirect(_get_return_url(request))
1009
+
1010
+ iframe_url = _todo_iframe_url(request, todo)
1011
+ context = {
1012
+ "todo": todo,
1013
+ "iframe_url": iframe_url,
1014
+ "next_url": _get_return_url(request),
1015
+ "done_url": reverse("todo-done", args=[todo.pk]),
1016
+ }
1017
+ return render(request, "core/todo_focus.html", context)
1018
+
1019
+
1020
+ @staff_member_required
1021
+ @require_POST
1022
+ def todo_done(request, pk: int):
1023
+ todo = get_object_or_404(Todo, pk=pk, is_deleted=False, done_on__isnull=True)
1024
+ redirect_to = _get_return_url(request)
1025
+ result = todo.check_on_done_condition()
1026
+ if not result.passed:
1027
+ messages.error(request, _format_condition_failure(todo, result))
1028
+ return redirect(redirect_to)
1029
+ todo.done_on = timezone.now()
1030
+ todo.save(update_fields=["done_on"])
1031
+ return redirect(redirect_to)