arthexis 0.1.7__py3-none-any.whl → 0.1.9__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 (82) hide show
  1. arthexis-0.1.9.dist-info/METADATA +168 -0
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.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 +134 -16
  10. config/urls.py +71 -3
  11. core/admin.py +1331 -165
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +151 -0
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1136 -259
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +445 -58
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +17 -0
  42. core/workgroup_views.py +94 -0
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +4 -3
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.7.dist-info/METADATA +0 -126
  77. arthexis-0.1.7.dist-info/RECORD +0 -77
  78. arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
  79. config/workgroup_app.py +0 -7
  80. core/checks.py +0 -29
  81. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  82. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
core/views.py CHANGED
@@ -1,6 +1,6 @@
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
6
  from django.contrib.admin.views.decorators import staff_member_required
@@ -8,16 +8,46 @@ from django.contrib.auth import authenticate, login
8
8
  from django.http import Http404, JsonResponse
9
9
  from django.shortcuts import get_object_or_404, render, redirect
10
10
  from django.views.decorators.csrf import csrf_exempt
11
+ from django.views.decorators.http import require_POST
12
+ from django.utils.translation import gettext as _
13
+ from django.utils import timezone
14
+ from django.urls import NoReverseMatch, reverse
11
15
  from pathlib import Path
12
16
  import subprocess
17
+ import json
13
18
 
14
19
  from utils.api import api_login_required
15
20
 
16
- from .models import Product, Subscription, EnergyAccount, PackageRelease
21
+ from .models import Product, EnergyAccount, PackageRelease, Todo
17
22
  from .models import RFID
23
+
24
+
25
+ @staff_member_required
26
+ def odoo_products(request):
27
+ """Return available products from the user's Odoo instance."""
28
+
29
+ profile = getattr(request.user, "odoo_profile", None)
30
+ if not profile or not profile.is_verified:
31
+ raise Http404
32
+ try:
33
+ products = profile.execute(
34
+ "product.product",
35
+ "search_read",
36
+ [],
37
+ {"fields": ["name"], "limit": 50},
38
+ )
39
+ except Exception:
40
+ return JsonResponse({"detail": "Unable to fetch products"}, status=502)
41
+ items = [{"id": p.get("id"), "name": p.get("name", "")} for p in products]
42
+ return JsonResponse(items, safe=False)
43
+
44
+
18
45
  from . import release as release_utils
19
46
 
20
47
 
48
+ TODO_FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures"
49
+
50
+
21
51
  def _append_log(path: Path, message: str) -> None:
22
52
  path.parent.mkdir(parents=True, exist_ok=True)
23
53
  with path.open("a", encoding="utf-8") as fh:
@@ -47,7 +77,40 @@ def _changelog_notes(version: str) -> str:
47
77
  return ""
48
78
 
49
79
 
50
- def _step_check_pypi(release, ctx, log_path: Path) -> None:
80
+ class PendingTodos(Exception):
81
+ """Raised when TODO items require acknowledgment before proceeding."""
82
+
83
+
84
+ class ApprovalRequired(Exception):
85
+ """Raised when release manager approval is required before continuing."""
86
+
87
+
88
+ def _step_check_todos(release, ctx, log_path: Path) -> None:
89
+ pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
90
+ if pending_qs.exists():
91
+ ctx["todos"] = list(
92
+ pending_qs.values("id", "request", "url", "request_details")
93
+ )
94
+ if not ctx.get("todos_ack"):
95
+ raise PendingTodos()
96
+ todos = list(Todo.objects.filter(is_deleted=False))
97
+ for todo in todos:
98
+ todo.delete()
99
+ removed = []
100
+ for path in TODO_FIXTURE_DIR.glob("todos__*.json"):
101
+ removed.append(str(path))
102
+ path.unlink()
103
+ if removed:
104
+ subprocess.run(["git", "add", *removed], check=False)
105
+ subprocess.run(
106
+ ["git", "commit", "-m", "chore: remove TODO fixtures"],
107
+ check=False,
108
+ )
109
+ ctx.pop("todos", None)
110
+ ctx.pop("todos_ack", None)
111
+
112
+
113
+ def _step_check_version(release, ctx, log_path: Path) -> None:
51
114
  from . import release as release_utils
52
115
  from packaging.version import Version
53
116
 
@@ -107,13 +170,9 @@ def _step_check_pypi(release, ctx, log_path: Path) -> None:
107
170
  _append_log(log_path, f"Checking if version {release.version} exists on PyPI")
108
171
  if release_utils.network_available():
109
172
  try:
110
- resp = requests.get(
111
- f"https://pypi.org/pypi/{release.package.name}/json"
112
- )
173
+ resp = requests.get(f"https://pypi.org/pypi/{release.package.name}/json")
113
174
  if resp.ok and release.version in resp.json().get("releases", {}):
114
- raise Exception(
115
- f"Version {release.version} already on PyPI"
116
- )
175
+ raise Exception(f"Version {release.version} already on PyPI")
117
176
  except Exception as exc:
118
177
  # network errors should be logged but not crash
119
178
  if "already on PyPI" in str(exc):
@@ -123,8 +182,40 @@ def _step_check_pypi(release, ctx, log_path: Path) -> None:
123
182
  _append_log(log_path, "Network unavailable, skipping PyPI check")
124
183
 
125
184
 
185
+ def _step_handle_migrations(release, ctx, log_path: Path) -> None:
186
+ _append_log(log_path, "Freeze, squash and approve migrations")
187
+
188
+
189
+ def _step_changelog_docs(release, ctx, log_path: Path) -> None:
190
+ _append_log(log_path, "Compose CHANGELOG and documentation")
191
+
192
+
193
+ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
194
+ _append_log(log_path, "Execute pre-release actions")
195
+ version_path = Path("VERSION")
196
+ version_path.write_text(f"{release.version}\n", encoding="utf-8")
197
+ subprocess.run(["git", "add", "VERSION"], check=True)
198
+ diff = subprocess.run(
199
+ ["git", "diff", "--cached", "--quiet", "--", "VERSION"],
200
+ check=False,
201
+ )
202
+ if diff.returncode != 0:
203
+ subprocess.run(
204
+ ["git", "commit", "-m", f"pre-release commit {release.version}"],
205
+ check=True,
206
+ )
207
+ else:
208
+ _append_log(log_path, "No changes detected for VERSION; skipping commit")
209
+ subprocess.run(["git", "reset", "HEAD", "VERSION"], check=False)
210
+
211
+
212
+ def _step_run_tests(release, ctx, log_path: Path) -> None:
213
+ _append_log(log_path, "Complete test suite with --all flag")
214
+
215
+
126
216
  def _step_promote_build(release, ctx, log_path: Path) -> None:
127
217
  from . import release as release_utils
218
+
128
219
  _append_log(log_path, "Generating build files")
129
220
  try:
130
221
  try:
@@ -138,22 +229,16 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
138
229
  version=release.version,
139
230
  creds=release.to_credentials(),
140
231
  )
232
+ from glob import glob
233
+
234
+ paths = ["VERSION", *glob("core/fixtures/releases__*.json")]
141
235
  diff = subprocess.run(
142
- [
143
- "git",
144
- "status",
145
- "--porcelain",
146
- "VERSION",
147
- "core/fixtures/releases.json",
148
- ],
236
+ ["git", "status", "--porcelain", *paths],
149
237
  capture_output=True,
150
238
  text=True,
151
239
  )
152
240
  if diff.stdout.strip():
153
- subprocess.run(
154
- ["git", "add", "VERSION", "core/fixtures/releases.json"],
155
- check=True,
156
- )
241
+ subprocess.run(["git", "add", *paths], check=True)
157
242
  subprocess.run(
158
243
  [
159
244
  "git",
@@ -175,6 +260,41 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
175
260
  _append_log(new_log, "Build complete")
176
261
 
177
262
 
263
+ def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
264
+ if release.to_credentials() is None:
265
+ ctx.pop("release_approval", None)
266
+ if not ctx.get("approval_credentials_missing"):
267
+ _append_log(log_path, "Release manager publishing credentials missing")
268
+ ctx["approval_credentials_missing"] = True
269
+ ctx["awaiting_approval"] = True
270
+ raise ApprovalRequired()
271
+
272
+ missing_before = ctx.pop("approval_credentials_missing", None)
273
+ if missing_before:
274
+ ctx.pop("awaiting_approval", None)
275
+ decision = ctx.get("release_approval")
276
+ if decision == "approved":
277
+ ctx.pop("release_approval", None)
278
+ ctx.pop("awaiting_approval", None)
279
+ ctx.pop("approval_credentials_missing", None)
280
+ _append_log(log_path, "Release manager approved release")
281
+ return
282
+ if decision == "rejected":
283
+ ctx.pop("release_approval", None)
284
+ ctx.pop("awaiting_approval", None)
285
+ ctx.pop("approval_credentials_missing", None)
286
+ _append_log(log_path, "Release manager rejected release")
287
+ raise RuntimeError(
288
+ _("Release manager rejected the release. Restart required."),
289
+ )
290
+ if not ctx.get("awaiting_approval"):
291
+ ctx["awaiting_approval"] = True
292
+ _append_log(log_path, "Awaiting release manager approval")
293
+ else:
294
+ ctx["awaiting_approval"] = True
295
+ raise ApprovalRequired()
296
+
297
+
178
298
  def _step_publish(release, ctx, log_path: Path) -> None:
179
299
  from . import release as release_utils
180
300
 
@@ -184,16 +304,24 @@ def _step_publish(release, ctx, log_path: Path) -> None:
184
304
  version=release.version,
185
305
  creds=release.to_credentials(),
186
306
  )
187
- release.pypi_url = f"https://pypi.org/project/{release.package.name}/{release.version}/"
307
+ release.pypi_url = (
308
+ f"https://pypi.org/project/{release.package.name}/{release.version}/"
309
+ )
188
310
  release.save(update_fields=["pypi_url"])
189
311
  PackageRelease.dump_fixture()
190
312
  _append_log(log_path, "Upload complete")
191
313
 
192
314
 
193
315
  PUBLISH_STEPS = [
194
- ("Check version availability", _step_check_pypi),
195
- ("Generate build", _step_promote_build),
196
- ("Publish", _step_publish),
316
+ ("Check version number availability", _step_check_version),
317
+ ("Confirm release TODO completion", _step_check_todos),
318
+ ("Freeze, squash and approve migrations", _step_handle_migrations),
319
+ ("Compose CHANGELOG and documentation", _step_changelog_docs),
320
+ ("Execute pre-release actions", _step_pre_release_actions),
321
+ ("Build release artifacts", _step_promote_build),
322
+ ("Complete test suite with --all flag", _step_run_tests),
323
+ ("Get Release Manager Approval", _step_release_manager_approval),
324
+ ("Upload final build to PyPI", _step_publish),
197
325
  ]
198
326
 
199
327
 
@@ -233,8 +361,8 @@ def product_list(request):
233
361
 
234
362
  @csrf_exempt
235
363
  @api_login_required
236
- def add_subscription(request):
237
- """Create a subscription for an energy account from POSTed JSON."""
364
+ def add_live_subscription(request):
365
+ """Create a live subscription for an energy account from POSTed JSON."""
238
366
 
239
367
  if request.method != "POST":
240
368
  return JsonResponse({"detail": "POST required"}, status=400)
@@ -257,32 +385,55 @@ def add_subscription(request):
257
385
  except Product.DoesNotExist:
258
386
  return JsonResponse({"detail": "invalid product"}, status=404)
259
387
 
260
- sub = Subscription.objects.create(
261
- account_id=account_id,
262
- product=product,
263
- next_renewal=date.today() + timedelta(days=product.renewal_period),
388
+ try:
389
+ account = EnergyAccount.objects.get(id=account_id)
390
+ except EnergyAccount.DoesNotExist:
391
+ return JsonResponse({"detail": "invalid account"}, status=404)
392
+
393
+ start_date = timezone.now().date()
394
+ account.live_subscription_product = product
395
+ account.live_subscription_start_date = start_date
396
+ account.live_subscription_next_renewal = start_date + timedelta(
397
+ days=product.renewal_period
264
398
  )
265
- return JsonResponse({"id": sub.id})
399
+ account.save()
400
+
401
+ return JsonResponse({"id": account.id})
266
402
 
267
403
 
268
404
  @api_login_required
269
- def subscription_list(request):
270
- """Return subscriptions for the given account_id."""
405
+ def live_subscription_list(request):
406
+ """Return live subscriptions for the given account_id."""
271
407
 
272
408
  account_id = request.GET.get("account_id")
273
409
  if not account_id:
274
410
  return JsonResponse({"detail": "account_id required"}, status=400)
275
411
 
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",
412
+ try:
413
+ account = EnergyAccount.objects.select_related("live_subscription_product").get(
414
+ id=account_id
415
+ )
416
+ except EnergyAccount.DoesNotExist:
417
+ return JsonResponse({"detail": "invalid account"}, status=404)
418
+
419
+ subs = []
420
+ product = account.live_subscription_product
421
+ if product:
422
+ next_renewal = account.live_subscription_next_renewal
423
+ if not next_renewal and account.live_subscription_start_date:
424
+ next_renewal = account.live_subscription_start_date + timedelta(
425
+ days=product.renewal_period
426
+ )
427
+
428
+ subs.append(
429
+ {
430
+ "id": account.id,
431
+ "product__name": product.name,
432
+ "next_renewal": next_renewal,
433
+ }
283
434
  )
284
- )
285
- return JsonResponse({"subscriptions": subs})
435
+
436
+ return JsonResponse({"live_subscriptions": subs})
286
437
 
287
438
 
288
439
  @csrf_exempt
@@ -303,6 +454,7 @@ def rfid_batch(request):
303
454
  tags = [
304
455
  {
305
456
  "rfid": t.rfid,
457
+ "custom_label": t.custom_label,
306
458
  "energy_accounts": list(t.energy_accounts.values_list("id", flat=True)),
307
459
  "allowed": t.allowed,
308
460
  "color": t.color,
@@ -329,12 +481,11 @@ def rfid_batch(request):
329
481
  continue
330
482
  allowed = row.get("allowed", True)
331
483
  energy_accounts = row.get("energy_accounts") or []
332
- color = (
333
- (row.get("color") or RFID.BLACK).strip().upper() or RFID.BLACK
334
- )
484
+ color = (row.get("color") or RFID.BLACK).strip().upper() or RFID.BLACK
335
485
  released = row.get("released", False)
336
486
  if isinstance(released, str):
337
487
  released = released.lower() == "true"
488
+ custom_label = (row.get("custom_label") or "").strip()
338
489
 
339
490
  tag, _ = RFID.objects.update_or_create(
340
491
  rfid=rfid.upper(),
@@ -342,10 +493,13 @@ def rfid_batch(request):
342
493
  "allowed": allowed,
343
494
  "color": color,
344
495
  "released": released,
496
+ "custom_label": custom_label,
345
497
  },
346
498
  )
347
499
  if energy_accounts:
348
- tag.energy_accounts.set(EnergyAccount.objects.filter(id__in=energy_accounts))
500
+ tag.energy_accounts.set(
501
+ EnergyAccount.objects.filter(id__in=energy_accounts)
502
+ )
349
503
  else:
350
504
  tag.energy_accounts.clear()
351
505
  count += 1
@@ -360,6 +514,8 @@ def release_progress(request, pk: int, action: str):
360
514
  release = get_object_or_404(PackageRelease, pk=pk)
361
515
  if action != "publish":
362
516
  raise Http404("Unknown action")
517
+ if not release.is_current:
518
+ raise Http404("Release is not current")
363
519
  session_key = f"release_publish_{pk}"
364
520
  lock_path = Path("locks") / f"release_publish_{pk}.json"
365
521
  restart_path = Path("locks") / f"release_publish_{pk}.restarts"
@@ -393,6 +549,28 @@ def release_progress(request, pk: int, action: str):
393
549
  ctx = {"step": 0}
394
550
  if restart_path.exists():
395
551
  restart_path.unlink()
552
+
553
+ manager = release.release_manager or release.package.release_manager
554
+ credentials_ready = bool(release.to_credentials())
555
+ if credentials_ready and ctx.get("approval_credentials_missing"):
556
+ ctx.pop("approval_credentials_missing", None)
557
+
558
+ if request.GET.get("start"):
559
+ ctx["started"] = True
560
+ ctx["paused"] = False
561
+ if request.GET.get("ack_todos"):
562
+ ctx["todos_ack"] = True
563
+ if (
564
+ ctx.get("awaiting_approval")
565
+ and not ctx.get("approval_credentials_missing")
566
+ and credentials_ready
567
+ ):
568
+ if request.GET.get("approve"):
569
+ ctx["release_approval"] = "approved"
570
+ if request.GET.get("reject"):
571
+ ctx["release_approval"] = "rejected"
572
+ if request.GET.get("pause") and ctx.get("started"):
573
+ ctx["paused"] = True
396
574
  restart_count = 0
397
575
  if restart_path.exists():
398
576
  try:
@@ -402,47 +580,172 @@ def release_progress(request, pk: int, action: str):
402
580
  step_count = ctx.get("step", 0)
403
581
  step_param = request.GET.get("step")
404
582
 
583
+ pending = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
584
+ if pending.exists() and not ctx.get("todos_ack"):
585
+ ctx["todos"] = list(pending.values("id", "request", "url", "request_details"))
586
+ else:
587
+ ctx.pop("todos", None)
588
+
405
589
  identifier = f"{release.package.name}-{release.version}"
406
590
  log_name = f"{identifier}.log"
407
591
  if ctx.get("log") != log_name:
408
- ctx = {"step": 0, "log": log_name}
592
+ ctx = {
593
+ "step": 0,
594
+ "log": log_name,
595
+ "started": ctx.get("started", False),
596
+ }
409
597
  step_count = 0
410
598
  log_path = Path("logs") / log_name
411
599
  ctx.setdefault("log", log_name)
600
+ ctx.setdefault("paused", False)
412
601
 
413
- if step_count == 0 and (step_param is None or step_param == "0"):
602
+ if (
603
+ ctx.get("started")
604
+ and step_count == 0
605
+ and (step_param is None or step_param == "0")
606
+ ):
414
607
  if log_path.exists():
415
608
  log_path.unlink()
416
609
 
417
610
  steps = PUBLISH_STEPS
418
611
  error = ctx.get("error")
419
612
 
420
- if step_param is not None and not error and step_count < len(steps):
613
+ if (
614
+ ctx.get("started")
615
+ and not ctx.get("paused")
616
+ and step_param is not None
617
+ and not error
618
+ and step_count < len(steps)
619
+ ):
421
620
  to_run = int(step_param)
422
621
  if to_run == step_count:
423
622
  name, func = steps[to_run]
424
623
  try:
425
624
  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")
625
+ except PendingTodos:
626
+ pass
627
+ except ApprovalRequired:
628
+ pass
431
629
  except Exception as exc: # pragma: no cover - best effort logging
432
630
  _append_log(log_path, f"{name} failed: {exc}")
433
631
  ctx["error"] = str(exc)
434
632
  request.session[session_key] = ctx
435
633
  lock_path.parent.mkdir(parents=True, exist_ok=True)
436
634
  lock_path.write_text(json.dumps(ctx), encoding="utf-8")
635
+ else:
636
+ step_count += 1
637
+ ctx["step"] = step_count
638
+ request.session[session_key] = ctx
639
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
640
+ lock_path.write_text(json.dumps(ctx), encoding="utf-8")
437
641
 
438
642
  done = step_count >= len(steps) and not ctx.get("error")
439
643
 
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
644
+ show_log = ctx.get("started") or step_count > 0 or done or ctx.get("error")
645
+ if show_log and log_path.exists():
646
+ log_content = log_path.read_text(encoding="utf-8")
647
+ else:
648
+ log_content = ""
649
+ next_step = (
650
+ step_count
651
+ if ctx.get("started")
652
+ and not ctx.get("paused")
653
+ and not done
654
+ and not ctx.get("error")
655
+ else None
656
+ )
657
+ has_pending_todos = bool(ctx.get("todos") and not ctx.get("todos_ack"))
658
+ if has_pending_todos:
659
+ next_step = None
660
+ awaiting_approval = bool(ctx.get("awaiting_approval"))
661
+ approval_credentials_missing = bool(ctx.get("approval_credentials_missing"))
662
+ if awaiting_approval:
663
+ next_step = None
664
+ if approval_credentials_missing:
665
+ next_step = None
666
+ paused = ctx.get("paused", False)
667
+
668
+ step_names = [s[0] for s in steps]
669
+ approval_credentials_ready = credentials_ready
670
+ credentials_blocking = approval_credentials_missing or (
671
+ awaiting_approval and not approval_credentials_ready
672
+ )
673
+ step_states = []
674
+ for index, name in enumerate(step_names):
675
+ if index < step_count:
676
+ status = "complete"
677
+ icon = "✅"
678
+ label = _("Completed")
679
+ elif error and index == step_count:
680
+ status = "error"
681
+ icon = "❌"
682
+ label = _("Failed")
683
+ elif paused and ctx.get("started") and index == step_count and not done:
684
+ status = "paused"
685
+ icon = "⏸️"
686
+ label = _("Paused")
687
+ elif (
688
+ has_pending_todos
689
+ and ctx.get("started")
690
+ and index == step_count
691
+ and not done
692
+ ):
693
+ status = "blocked"
694
+ icon = "📝"
695
+ label = _("Awaiting checklist")
696
+ elif (
697
+ credentials_blocking
698
+ and ctx.get("started")
699
+ and index == step_count
700
+ and not done
701
+ ):
702
+ status = "missing-credentials"
703
+ icon = "🔐"
704
+ label = _("Credentials required")
705
+ elif (
706
+ awaiting_approval
707
+ and approval_credentials_ready
708
+ and ctx.get("started")
709
+ and index == step_count
710
+ and not done
711
+ ):
712
+ status = "awaiting-approval"
713
+ icon = "🤝"
714
+ label = _("Awaiting approval")
715
+ elif ctx.get("started") and index == step_count and not done:
716
+ status = "active"
717
+ icon = "⏳"
718
+ label = _("In progress")
719
+ else:
720
+ status = "pending"
721
+ icon = "⬜"
722
+ label = _("Pending")
723
+ step_states.append(
724
+ {
725
+ "index": index + 1,
726
+ "name": name,
727
+ "status": status,
728
+ "icon": icon,
729
+ "label": label,
730
+ }
731
+ )
732
+
733
+ is_running = ctx.get("started") and not paused and not done and not ctx.get("error")
734
+ can_resume = ctx.get("started") and paused and not done and not ctx.get("error")
735
+ release_manager_owner = manager.owner_display() if manager else ""
736
+ try:
737
+ current_user_admin_url = reverse(
738
+ "admin:teams_user_change", args=[request.user.pk]
739
+ )
740
+ except NoReverseMatch:
741
+ current_user_admin_url = reverse(
742
+ "admin:core_user_change", args=[request.user.pk]
743
+ )
744
+
442
745
  context = {
443
746
  "release": release,
444
747
  "action": "publish",
445
- "steps": [s[0] for s in steps],
748
+ "steps": step_names,
446
749
  "current_step": step_count,
447
750
  "next_step": next_step,
448
751
  "done": done,
@@ -451,7 +754,21 @@ def release_progress(request, pk: int, action: str):
451
754
  "log_path": str(log_path),
452
755
  "cert_log": ctx.get("cert_log"),
453
756
  "fixtures": ctx.get("fixtures"),
757
+ "todos": ctx.get("todos"),
454
758
  "restart_count": restart_count,
759
+ "started": ctx.get("started", False),
760
+ "paused": paused,
761
+ "show_log": show_log,
762
+ "step_states": step_states,
763
+ "has_pending_todos": has_pending_todos,
764
+ "awaiting_approval": awaiting_approval,
765
+ "approval_credentials_missing": approval_credentials_missing,
766
+ "approval_credentials_ready": approval_credentials_ready,
767
+ "release_manager_owner": release_manager_owner,
768
+ "has_release_manager": bool(manager),
769
+ "current_user_admin_url": current_user_admin_url,
770
+ "is_running": is_running,
771
+ "can_resume": can_resume,
455
772
  }
456
773
  request.session[session_key] = ctx
457
774
  if done or ctx.get("error"):
@@ -461,3 +778,12 @@ def release_progress(request, pk: int, action: str):
461
778
  lock_path.parent.mkdir(parents=True, exist_ok=True)
462
779
  lock_path.write_text(json.dumps(ctx), encoding="utf-8")
463
780
  return render(request, "core/release_progress.html", context)
781
+
782
+
783
+ @staff_member_required
784
+ @require_POST
785
+ def todo_done(request, pk: int):
786
+ todo = get_object_or_404(Todo, pk=pk, is_deleted=False, done_on__isnull=True)
787
+ todo.done_on = timezone.now()
788
+ todo.save(update_fields=["done_on"])
789
+ return redirect("admin:index")
core/widgets.py ADDED
@@ -0,0 +1,51 @@
1
+ from django import forms
2
+ import json
3
+
4
+
5
+ class CopyColorWidget(forms.TextInput):
6
+ input_type = "color"
7
+ template_name = "widgets/copy_color.html"
8
+
9
+ class Media:
10
+ js = ["core/copy_color.js"]
11
+
12
+
13
+ class CodeEditorWidget(forms.Textarea):
14
+ """Simple code editor widget for editing recipes."""
15
+
16
+ def __init__(self, attrs=None):
17
+ default_attrs = {"class": "code-editor"}
18
+ if attrs:
19
+ default_attrs.update(attrs)
20
+ super().__init__(attrs=default_attrs)
21
+
22
+ class Media:
23
+ css = {"all": ["core/code_editor.css"]}
24
+ js = ["core/code_editor.js"]
25
+
26
+
27
+ class OdooProductWidget(forms.Select):
28
+ """Widget for selecting an Odoo product."""
29
+
30
+ template_name = "widgets/odoo_product.html"
31
+
32
+ class Media:
33
+ js = ["core/odoo_product.js"]
34
+
35
+ def get_context(self, name, value, attrs):
36
+ attrs = attrs or {}
37
+ if isinstance(value, dict):
38
+ attrs["data-current-id"] = str(value.get("id", ""))
39
+ value = json.dumps(value)
40
+ elif not value:
41
+ value = ""
42
+ return super().get_context(name, value, attrs)
43
+
44
+ def value_from_datadict(self, data, files, name):
45
+ raw = data.get(name)
46
+ if not raw:
47
+ return {}
48
+ try:
49
+ return json.loads(raw)
50
+ except Exception:
51
+ return {}