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.
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.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 +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- 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 +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- 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 +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- 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.10.dist-info}/WHEEL +0 -0
- {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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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",
|
|
195
|
-
("
|
|
196
|
-
(
|
|
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
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
510
|
+
account.save()
|
|
511
|
+
|
|
512
|
+
return JsonResponse({"id": account.id})
|
|
266
513
|
|
|
267
514
|
|
|
268
515
|
@api_login_required
|
|
269
|
-
def
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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(
|
|
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 = {
|
|
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
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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":
|
|
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":
|
|
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)
|