arthexis 0.1.8__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.
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
- arthexis-0.1.9.dist-info/RECORD +92 -0
- arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +133 -16
- config/urls.py +65 -6
- core/admin.py +1226 -191
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +158 -3
- core/backends.py +46 -4
- core/entity.py +62 -48
- core/fields.py +6 -1
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1071 -264
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/release.py +27 -20
- core/sigil_builder.py +131 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +129 -10
- core/tasks.py +118 -19
- core/test_system_info.py +22 -0
- core/tests.py +358 -63
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +329 -167
- core/views.py +383 -57
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +159 -284
- nodes/apps.py +9 -15
- nodes/backends.py +53 -0
- nodes/lcd.py +24 -10
- nodes/models.py +375 -178
- nodes/tasks.py +1 -5
- nodes/tests.py +524 -129
- nodes/utils.py +13 -2
- nodes/views.py +66 -23
- ocpp/admin.py +150 -61
- ocpp/apps.py +1 -1
- ocpp/consumers.py +432 -69
- ocpp/evcs.py +25 -8
- ocpp/models.py +408 -68
- ocpp/simulator.py +13 -6
- ocpp/store.py +258 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1198 -135
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +654 -101
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +19 -6
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +759 -40
- pages/urls.py +3 -0
- pages/utils.py +0 -1
- pages/views.py +576 -25
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.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
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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",
|
|
195
|
-
("
|
|
196
|
-
("
|
|
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
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
399
|
+
account.save()
|
|
400
|
+
|
|
401
|
+
return JsonResponse({"id": account.id})
|
|
266
402
|
|
|
267
403
|
|
|
268
404
|
@api_login_required
|
|
269
|
-
def
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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({"
|
|
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(
|
|
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 = {
|
|
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
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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":
|
|
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 {}
|