arthexis 0.1.18__py3-none-any.whl → 0.1.20__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.
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/METADATA +39 -12
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/RECORD +44 -44
- config/settings.py +1 -5
- core/admin.py +142 -1
- core/backends.py +8 -2
- core/environment.py +221 -4
- core/models.py +124 -25
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/sigil_builder.py +2 -2
- core/system.py +125 -0
- core/tasks.py +24 -23
- core/tests.py +1 -0
- core/views.py +105 -40
- nodes/admin.py +134 -3
- nodes/models.py +310 -69
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +573 -48
- nodes/urls.py +4 -1
- nodes/views.py +498 -106
- ocpp/admin.py +124 -5
- ocpp/consumers.py +106 -9
- ocpp/models.py +90 -1
- ocpp/store.py +6 -4
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +114 -10
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +166 -40
- pages/admin.py +63 -10
- pages/context_processors.py +26 -9
- pages/defaults.py +1 -1
- pages/middleware.py +3 -0
- pages/models.py +35 -0
- pages/module_defaults.py +5 -5
- pages/tests.py +280 -65
- pages/urls.py +3 -1
- pages/views.py +176 -29
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/WHEEL +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/top_level.txt +0 -0
core/system.py
CHANGED
|
@@ -13,8 +13,10 @@ import shutil
|
|
|
13
13
|
import logging
|
|
14
14
|
from typing import Callable, Iterable, Optional
|
|
15
15
|
|
|
16
|
+
from django import forms
|
|
16
17
|
from django.conf import settings
|
|
17
18
|
from django.contrib import admin, messages
|
|
19
|
+
from django.forms import modelformset_factory
|
|
18
20
|
from django.template.response import TemplateResponse
|
|
19
21
|
from django.http import HttpResponseRedirect
|
|
20
22
|
from django.urls import path, reverse
|
|
@@ -32,6 +34,7 @@ from core.release import (
|
|
|
32
34
|
_remote_with_credentials,
|
|
33
35
|
)
|
|
34
36
|
from core.tasks import check_github_updates
|
|
37
|
+
from core.models import Todo
|
|
35
38
|
from utils import revision
|
|
36
39
|
|
|
37
40
|
|
|
@@ -1086,6 +1089,123 @@ def _system_upgrade_report_view(request):
|
|
|
1086
1089
|
return TemplateResponse(request, "admin/system_upgrade_report.html", context)
|
|
1087
1090
|
|
|
1088
1091
|
|
|
1092
|
+
class PendingTodoForm(forms.ModelForm):
|
|
1093
|
+
mark_done = forms.BooleanField(required=False, label=_("Approve"))
|
|
1094
|
+
|
|
1095
|
+
class Meta:
|
|
1096
|
+
model = Todo
|
|
1097
|
+
fields = [
|
|
1098
|
+
"request",
|
|
1099
|
+
"request_details",
|
|
1100
|
+
"url",
|
|
1101
|
+
"generated_for_version",
|
|
1102
|
+
"generated_for_revision",
|
|
1103
|
+
"on_done_condition",
|
|
1104
|
+
]
|
|
1105
|
+
widgets = {
|
|
1106
|
+
"request_details": forms.Textarea(attrs={"rows": 3}),
|
|
1107
|
+
"on_done_condition": forms.Textarea(attrs={"rows": 2}),
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
def __init__(self, *args, **kwargs):
|
|
1111
|
+
super().__init__(*args, **kwargs)
|
|
1112
|
+
for name in [
|
|
1113
|
+
"request",
|
|
1114
|
+
"url",
|
|
1115
|
+
"generated_for_version",
|
|
1116
|
+
"generated_for_revision",
|
|
1117
|
+
]:
|
|
1118
|
+
self.fields[name].widget.attrs.setdefault("class", "vTextField")
|
|
1119
|
+
for name in ["request_details", "on_done_condition"]:
|
|
1120
|
+
self.fields[name].widget.attrs.setdefault("class", "vLargeTextField")
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
PendingTodoFormSet = modelformset_factory(Todo, form=PendingTodoForm, extra=0)
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _system_pending_todos_report_view(request):
|
|
1127
|
+
queryset = (
|
|
1128
|
+
Todo.objects.filter(is_deleted=False, done_on__isnull=True)
|
|
1129
|
+
.order_by("request")
|
|
1130
|
+
)
|
|
1131
|
+
formset = PendingTodoFormSet(
|
|
1132
|
+
request.POST or None,
|
|
1133
|
+
queryset=queryset,
|
|
1134
|
+
prefix="todos",
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
if request.method == "POST":
|
|
1138
|
+
if formset.is_valid():
|
|
1139
|
+
approved_count = 0
|
|
1140
|
+
edited_count = 0
|
|
1141
|
+
for form in formset.forms:
|
|
1142
|
+
mark_done = form.cleaned_data.get("mark_done")
|
|
1143
|
+
todo = form.save(commit=False)
|
|
1144
|
+
has_changes = form.has_changed()
|
|
1145
|
+
if mark_done and todo.done_on is None:
|
|
1146
|
+
todo.done_on = timezone.now()
|
|
1147
|
+
approved_count += 1
|
|
1148
|
+
has_changes = True
|
|
1149
|
+
if has_changes:
|
|
1150
|
+
todo.save()
|
|
1151
|
+
if form.has_changed():
|
|
1152
|
+
edited_count += 1
|
|
1153
|
+
if has_changes and form.has_changed():
|
|
1154
|
+
form.save_m2m()
|
|
1155
|
+
|
|
1156
|
+
if approved_count or edited_count:
|
|
1157
|
+
message_parts: list[str] = []
|
|
1158
|
+
if edited_count:
|
|
1159
|
+
message_parts.append(
|
|
1160
|
+
ngettext(
|
|
1161
|
+
"%(count)d TODO updated.",
|
|
1162
|
+
"%(count)d TODOs updated.",
|
|
1163
|
+
edited_count,
|
|
1164
|
+
)
|
|
1165
|
+
% {"count": edited_count}
|
|
1166
|
+
)
|
|
1167
|
+
if approved_count:
|
|
1168
|
+
message_parts.append(
|
|
1169
|
+
ngettext(
|
|
1170
|
+
"%(count)d TODO approved.",
|
|
1171
|
+
"%(count)d TODOs approved.",
|
|
1172
|
+
approved_count,
|
|
1173
|
+
)
|
|
1174
|
+
% {"count": approved_count}
|
|
1175
|
+
)
|
|
1176
|
+
messages.success(request, " ".join(message_parts))
|
|
1177
|
+
else:
|
|
1178
|
+
messages.info(
|
|
1179
|
+
request,
|
|
1180
|
+
_("No changes were applied to the pending TODOs."),
|
|
1181
|
+
)
|
|
1182
|
+
return HttpResponseRedirect(reverse("admin:system-pending-todos-report"))
|
|
1183
|
+
else:
|
|
1184
|
+
messages.error(request, _("Please correct the errors below."))
|
|
1185
|
+
|
|
1186
|
+
rows = [
|
|
1187
|
+
{
|
|
1188
|
+
"form": form,
|
|
1189
|
+
"todo": form.instance,
|
|
1190
|
+
}
|
|
1191
|
+
for form in formset.forms
|
|
1192
|
+
]
|
|
1193
|
+
|
|
1194
|
+
context = admin.site.each_context(request)
|
|
1195
|
+
context.update(
|
|
1196
|
+
{
|
|
1197
|
+
"title": _("Pending TODOs Report"),
|
|
1198
|
+
"formset": formset,
|
|
1199
|
+
"rows": rows,
|
|
1200
|
+
}
|
|
1201
|
+
)
|
|
1202
|
+
return TemplateResponse(
|
|
1203
|
+
request,
|
|
1204
|
+
"admin/system_pending_todos_report.html",
|
|
1205
|
+
context,
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
|
|
1089
1209
|
def _trigger_upgrade_check() -> bool:
|
|
1090
1210
|
"""Return ``True`` when the upgrade check was queued asynchronously."""
|
|
1091
1211
|
|
|
@@ -1142,6 +1262,11 @@ def patch_admin_system_view() -> None:
|
|
|
1142
1262
|
admin.site.admin_view(_system_changelog_report_view),
|
|
1143
1263
|
name="system-changelog-report",
|
|
1144
1264
|
),
|
|
1265
|
+
path(
|
|
1266
|
+
"system/pending-todos-report/",
|
|
1267
|
+
admin.site.admin_view(_system_pending_todos_report_view),
|
|
1268
|
+
name="system-pending-todos-report",
|
|
1269
|
+
),
|
|
1145
1270
|
path(
|
|
1146
1271
|
"system/upgrade-report/",
|
|
1147
1272
|
admin.site.admin_view(_system_upgrade_report_view),
|
core/tasks.py
CHANGED
|
@@ -2,20 +2,16 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import shutil
|
|
5
|
+
import re
|
|
5
6
|
import subprocess
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
import urllib.error
|
|
8
9
|
import urllib.request
|
|
9
10
|
|
|
10
11
|
from celery import shared_task
|
|
11
|
-
from django.conf import settings
|
|
12
|
-
from django.contrib.auth import get_user_model
|
|
13
|
-
from core import mailer
|
|
14
12
|
from core import github_issues
|
|
15
13
|
from django.utils import timezone
|
|
16
14
|
|
|
17
|
-
from nodes.models import NetMessage
|
|
18
|
-
|
|
19
15
|
|
|
20
16
|
AUTO_UPGRADE_HEALTH_DELAY_SECONDS = 30
|
|
21
17
|
AUTO_UPGRADE_SKIP_LOCK_NAME = "auto_upgrade_skip_revisions.lck"
|
|
@@ -30,23 +26,6 @@ def heartbeat() -> None:
|
|
|
30
26
|
logger.info("Heartbeat task executed")
|
|
31
27
|
|
|
32
28
|
|
|
33
|
-
@shared_task
|
|
34
|
-
def birthday_greetings() -> None:
|
|
35
|
-
"""Send birthday greetings to users via Net Message and email."""
|
|
36
|
-
User = get_user_model()
|
|
37
|
-
today = timezone.localdate()
|
|
38
|
-
for user in User.objects.filter(birthday=today):
|
|
39
|
-
NetMessage.broadcast("Happy bday!", user.username)
|
|
40
|
-
if user.email:
|
|
41
|
-
mailer.send(
|
|
42
|
-
"Happy bday!",
|
|
43
|
-
f"Happy bday! {user.username}",
|
|
44
|
-
[user.email],
|
|
45
|
-
settings.DEFAULT_FROM_EMAIL,
|
|
46
|
-
fail_silently=True,
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
50
29
|
def _auto_upgrade_log_path(base_dir: Path) -> Path:
|
|
51
30
|
"""Return the log file used for auto-upgrade events."""
|
|
52
31
|
|
|
@@ -124,6 +103,21 @@ def _resolve_service_url(base_dir: Path) -> str:
|
|
|
124
103
|
return f"http://127.0.0.1:{port}/"
|
|
125
104
|
|
|
126
105
|
|
|
106
|
+
def _parse_major_minor(version: str) -> tuple[int, int] | None:
|
|
107
|
+
match = re.match(r"^\s*(\d+)\.(\d+)", version)
|
|
108
|
+
if not match:
|
|
109
|
+
return None
|
|
110
|
+
return int(match.group(1)), int(match.group(2))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _shares_stable_series(local: str, remote: str) -> bool:
|
|
114
|
+
local_parts = _parse_major_minor(local)
|
|
115
|
+
remote_parts = _parse_major_minor(remote)
|
|
116
|
+
if not local_parts or not remote_parts:
|
|
117
|
+
return False
|
|
118
|
+
return local_parts == remote_parts
|
|
119
|
+
|
|
120
|
+
|
|
127
121
|
@shared_task
|
|
128
122
|
def check_github_updates() -> None:
|
|
129
123
|
"""Check the GitHub repo for updates and upgrade if needed."""
|
|
@@ -218,9 +212,16 @@ def check_github_updates() -> None:
|
|
|
218
212
|
if startup:
|
|
219
213
|
startup()
|
|
220
214
|
return
|
|
215
|
+
if mode == "stable" and _shares_stable_series(local, remote):
|
|
216
|
+
if startup:
|
|
217
|
+
startup()
|
|
218
|
+
return
|
|
221
219
|
if notify:
|
|
222
220
|
notify("Upgrading...", upgrade_stamp)
|
|
223
|
-
|
|
221
|
+
if mode == "stable":
|
|
222
|
+
args = ["./upgrade.sh", "--stable", "--no-restart"]
|
|
223
|
+
else:
|
|
224
|
+
args = ["./upgrade.sh", "--no-restart"]
|
|
224
225
|
upgrade_was_applied = True
|
|
225
226
|
|
|
226
227
|
with log_file.open("a") as fh:
|
core/tests.py
CHANGED
core/views.py
CHANGED
|
@@ -448,8 +448,11 @@ def _resolve_release_log_dir(preferred: Path) -> tuple[Path, str | None]:
|
|
|
448
448
|
|
|
449
449
|
env_override = os.environ.pop("ARTHEXIS_LOG_DIR", None)
|
|
450
450
|
fallback = select_log_dir(Path(settings.BASE_DIR))
|
|
451
|
-
if env_override
|
|
452
|
-
|
|
451
|
+
if env_override is not None:
|
|
452
|
+
if Path(env_override) == fallback:
|
|
453
|
+
os.environ["ARTHEXIS_LOG_DIR"] = env_override
|
|
454
|
+
else:
|
|
455
|
+
os.environ["ARTHEXIS_LOG_DIR"] = str(fallback)
|
|
453
456
|
|
|
454
457
|
if fallback == preferred:
|
|
455
458
|
if error:
|
|
@@ -608,6 +611,43 @@ def _git_authentication_missing(exc: subprocess.CalledProcessError) -> bool:
|
|
|
608
611
|
return any(marker in message for marker in auth_markers)
|
|
609
612
|
|
|
610
613
|
|
|
614
|
+
def _push_release_changes(log_path: Path) -> bool:
|
|
615
|
+
"""Push release commits to ``origin`` and log the outcome."""
|
|
616
|
+
|
|
617
|
+
if not _has_remote("origin"):
|
|
618
|
+
_append_log(
|
|
619
|
+
log_path, "No git remote configured; skipping push of release changes"
|
|
620
|
+
)
|
|
621
|
+
return False
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
branch = _current_branch()
|
|
625
|
+
if branch is None:
|
|
626
|
+
push_cmd = ["git", "push", "origin", "HEAD"]
|
|
627
|
+
elif _has_upstream(branch):
|
|
628
|
+
push_cmd = ["git", "push"]
|
|
629
|
+
else:
|
|
630
|
+
push_cmd = ["git", "push", "--set-upstream", "origin", branch]
|
|
631
|
+
subprocess.run(push_cmd, check=True, capture_output=True, text=True)
|
|
632
|
+
except subprocess.CalledProcessError as exc:
|
|
633
|
+
details = _format_subprocess_error(exc)
|
|
634
|
+
if _git_authentication_missing(exc):
|
|
635
|
+
_append_log(
|
|
636
|
+
log_path,
|
|
637
|
+
"Authentication is required to push release changes to origin; skipping push",
|
|
638
|
+
)
|
|
639
|
+
if details:
|
|
640
|
+
_append_log(log_path, details)
|
|
641
|
+
return False
|
|
642
|
+
_append_log(
|
|
643
|
+
log_path, f"Failed to push release changes to origin: {details}"
|
|
644
|
+
)
|
|
645
|
+
raise Exception("Failed to push release changes") from exc
|
|
646
|
+
|
|
647
|
+
_append_log(log_path, "Pushed release changes to origin")
|
|
648
|
+
return True
|
|
649
|
+
|
|
650
|
+
|
|
611
651
|
def _ensure_origin_main_unchanged(log_path: Path) -> None:
|
|
612
652
|
"""Verify that ``origin/main`` has not advanced during the release."""
|
|
613
653
|
|
|
@@ -736,6 +776,34 @@ def _ensure_release_todo(
|
|
|
736
776
|
return todo, fixture_path
|
|
737
777
|
|
|
738
778
|
|
|
779
|
+
def _todo_blocks_publish(todo: Todo, release: PackageRelease) -> bool:
|
|
780
|
+
"""Return ``True`` when ``todo`` should block the release workflow."""
|
|
781
|
+
|
|
782
|
+
request = (todo.request or "").strip()
|
|
783
|
+
release_name = (release.package.name or "").strip()
|
|
784
|
+
if not request or not release_name:
|
|
785
|
+
return True
|
|
786
|
+
|
|
787
|
+
prefix = f"create release {release_name.lower()} "
|
|
788
|
+
if not request.lower().startswith(prefix):
|
|
789
|
+
return True
|
|
790
|
+
|
|
791
|
+
release_version = (release.version or "").strip()
|
|
792
|
+
generated_version = (todo.generated_for_version or "").strip()
|
|
793
|
+
if not release_version or release_version != generated_version:
|
|
794
|
+
return True
|
|
795
|
+
|
|
796
|
+
generated_revision = (todo.generated_for_revision or "").strip()
|
|
797
|
+
release_revision = (release.revision or "").strip()
|
|
798
|
+
if generated_revision and release_revision and generated_revision != release_revision:
|
|
799
|
+
return True
|
|
800
|
+
|
|
801
|
+
if not todo.is_seed_data:
|
|
802
|
+
return True
|
|
803
|
+
|
|
804
|
+
return False
|
|
805
|
+
|
|
806
|
+
|
|
739
807
|
def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
|
|
740
808
|
"""Ensure ``release`` matches the repository revision and version.
|
|
741
809
|
|
|
@@ -1312,37 +1380,7 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
|
|
|
1312
1380
|
log_path,
|
|
1313
1381
|
f"Committed release metadata for v{release.version}",
|
|
1314
1382
|
)
|
|
1315
|
-
|
|
1316
|
-
try:
|
|
1317
|
-
branch = _current_branch()
|
|
1318
|
-
if branch is None:
|
|
1319
|
-
push_cmd = ["git", "push", "origin", "HEAD"]
|
|
1320
|
-
elif _has_upstream(branch):
|
|
1321
|
-
push_cmd = ["git", "push"]
|
|
1322
|
-
else:
|
|
1323
|
-
push_cmd = ["git", "push", "--set-upstream", "origin", branch]
|
|
1324
|
-
subprocess.run(push_cmd, check=True, capture_output=True, text=True)
|
|
1325
|
-
except subprocess.CalledProcessError as exc:
|
|
1326
|
-
details = _format_subprocess_error(exc)
|
|
1327
|
-
if _git_authentication_missing(exc):
|
|
1328
|
-
_append_log(
|
|
1329
|
-
log_path,
|
|
1330
|
-
"Authentication is required to push release changes to origin; skipping push",
|
|
1331
|
-
)
|
|
1332
|
-
if details:
|
|
1333
|
-
_append_log(log_path, details)
|
|
1334
|
-
else:
|
|
1335
|
-
_append_log(
|
|
1336
|
-
log_path, f"Failed to push release changes to origin: {details}"
|
|
1337
|
-
)
|
|
1338
|
-
raise Exception("Failed to push release changes") from exc
|
|
1339
|
-
else:
|
|
1340
|
-
_append_log(log_path, "Pushed release changes to origin")
|
|
1341
|
-
else:
|
|
1342
|
-
_append_log(
|
|
1343
|
-
log_path,
|
|
1344
|
-
"No git remote configured; skipping push of release changes",
|
|
1345
|
-
)
|
|
1383
|
+
_push_release_changes(log_path)
|
|
1346
1384
|
PackageRelease.dump_fixture()
|
|
1347
1385
|
_append_log(log_path, "Updated release fixtures")
|
|
1348
1386
|
_record_release_todo(release, ctx, log_path)
|
|
@@ -1533,6 +1571,30 @@ def _step_publish(release, ctx, log_path: Path) -> None:
|
|
|
1533
1571
|
_append_log(log_path, f"Recorded PyPI URL: {release.pypi_url}")
|
|
1534
1572
|
if release.github_url:
|
|
1535
1573
|
_append_log(log_path, f"Recorded GitHub URL: {release.github_url}")
|
|
1574
|
+
fixture_paths = [
|
|
1575
|
+
str(path) for path in Path("core/fixtures").glob("releases__*.json")
|
|
1576
|
+
]
|
|
1577
|
+
if fixture_paths:
|
|
1578
|
+
status = subprocess.run(
|
|
1579
|
+
["git", "status", "--porcelain", "--", *fixture_paths],
|
|
1580
|
+
capture_output=True,
|
|
1581
|
+
text=True,
|
|
1582
|
+
check=True,
|
|
1583
|
+
)
|
|
1584
|
+
if status.stdout.strip():
|
|
1585
|
+
subprocess.run(["git", "add", *fixture_paths], check=True)
|
|
1586
|
+
_append_log(log_path, "Staged publish metadata updates")
|
|
1587
|
+
commit_message = f"chore: record publish metadata for v{release.version}"
|
|
1588
|
+
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
|
1589
|
+
_append_log(
|
|
1590
|
+
log_path, f"Committed publish metadata for v{release.version}"
|
|
1591
|
+
)
|
|
1592
|
+
_push_release_changes(log_path)
|
|
1593
|
+
else:
|
|
1594
|
+
_append_log(
|
|
1595
|
+
log_path,
|
|
1596
|
+
"No release metadata updates detected after publish; skipping commit",
|
|
1597
|
+
)
|
|
1536
1598
|
_append_log(log_path, "Upload complete")
|
|
1537
1599
|
|
|
1538
1600
|
|
|
@@ -1726,9 +1788,9 @@ def rfid_batch(request):
|
|
|
1726
1788
|
else:
|
|
1727
1789
|
post_auth_command = post_auth_command.strip()
|
|
1728
1790
|
|
|
1729
|
-
tag, _ = RFID.
|
|
1730
|
-
rfid
|
|
1731
|
-
|
|
1791
|
+
tag, _ = RFID.update_or_create_from_code(
|
|
1792
|
+
rfid,
|
|
1793
|
+
{
|
|
1732
1794
|
"allowed": allowed,
|
|
1733
1795
|
"color": color,
|
|
1734
1796
|
"released": released,
|
|
@@ -1885,12 +1947,15 @@ def release_progress(request, pk: int, action: str):
|
|
|
1885
1947
|
|
|
1886
1948
|
pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
|
|
1887
1949
|
pending_items = list(pending_qs)
|
|
1888
|
-
|
|
1950
|
+
blocking_todos = [
|
|
1951
|
+
todo for todo in pending_items if _todo_blocks_publish(todo, release)
|
|
1952
|
+
]
|
|
1953
|
+
if not blocking_todos:
|
|
1889
1954
|
ctx["todos_ack"] = True
|
|
1890
1955
|
ctx["todos_ack_auto"] = True
|
|
1891
1956
|
elif ack_todos_requested:
|
|
1892
1957
|
failures = []
|
|
1893
|
-
for todo in
|
|
1958
|
+
for todo in blocking_todos:
|
|
1894
1959
|
result = todo.check_on_done_condition()
|
|
1895
1960
|
if not result.passed:
|
|
1896
1961
|
failures.append((todo, result))
|
|
@@ -1920,7 +1985,7 @@ def release_progress(request, pk: int, action: str):
|
|
|
1920
1985
|
"url": todo.url,
|
|
1921
1986
|
"request_details": todo.request_details,
|
|
1922
1987
|
}
|
|
1923
|
-
for todo in
|
|
1988
|
+
for todo in blocking_todos
|
|
1924
1989
|
]
|
|
1925
1990
|
ctx["todos_required"] = True
|
|
1926
1991
|
|
|
@@ -1932,7 +1997,7 @@ def release_progress(request, pk: int, action: str):
|
|
|
1932
1997
|
"started": ctx.get("started", False),
|
|
1933
1998
|
}
|
|
1934
1999
|
step_count = 0
|
|
1935
|
-
if not
|
|
2000
|
+
if not blocking_todos:
|
|
1936
2001
|
ctx["todos_ack"] = True
|
|
1937
2002
|
log_path = log_dir / log_name
|
|
1938
2003
|
ctx.setdefault("log", log_name)
|
nodes/admin.py
CHANGED
|
@@ -8,7 +8,7 @@ from django.contrib.admin import helpers
|
|
|
8
8
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
|
9
9
|
from django.core.exceptions import PermissionDenied
|
|
10
10
|
from django.db.models import Count
|
|
11
|
-
from django.http import HttpResponse, JsonResponse
|
|
11
|
+
from django.http import Http404, HttpResponse, JsonResponse
|
|
12
12
|
from django.shortcuts import redirect, render
|
|
13
13
|
from django.template.response import TemplateResponse
|
|
14
14
|
from django.urls import NoReverseMatch, path, reverse
|
|
@@ -233,6 +233,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
233
233
|
"role",
|
|
234
234
|
"relation",
|
|
235
235
|
"last_seen",
|
|
236
|
+
"proxy_link",
|
|
236
237
|
)
|
|
237
238
|
search_fields = ("hostname", "address", "mac_address")
|
|
238
239
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
@@ -247,6 +248,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
247
248
|
"address",
|
|
248
249
|
"mac_address",
|
|
249
250
|
"port",
|
|
251
|
+
"message_queue_length",
|
|
250
252
|
"role",
|
|
251
253
|
"current_relation",
|
|
252
254
|
)
|
|
@@ -290,6 +292,16 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
290
292
|
def relation(self, obj):
|
|
291
293
|
return obj.get_current_relation_display()
|
|
292
294
|
|
|
295
|
+
@admin.display(description=_("Proxy"))
|
|
296
|
+
def proxy_link(self, obj):
|
|
297
|
+
if not obj or obj.is_local:
|
|
298
|
+
return ""
|
|
299
|
+
try:
|
|
300
|
+
url = reverse("admin:nodes_node_proxy", args=[obj.pk])
|
|
301
|
+
except NoReverseMatch:
|
|
302
|
+
return ""
|
|
303
|
+
return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
|
|
304
|
+
|
|
293
305
|
def get_urls(self):
|
|
294
306
|
urls = super().get_urls()
|
|
295
307
|
custom = [
|
|
@@ -313,6 +325,11 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
313
325
|
self.admin_site.admin_view(self.update_selected_progress),
|
|
314
326
|
name="nodes_node_update_selected_progress",
|
|
315
327
|
),
|
|
328
|
+
path(
|
|
329
|
+
"<int:node_id>/proxy/",
|
|
330
|
+
self.admin_site.admin_view(self.proxy_node),
|
|
331
|
+
name="nodes_node_proxy",
|
|
332
|
+
),
|
|
316
333
|
]
|
|
317
334
|
return custom + urls
|
|
318
335
|
|
|
@@ -332,6 +349,121 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
332
349
|
}
|
|
333
350
|
return render(request, "admin/nodes/node/register_remote.html", context)
|
|
334
351
|
|
|
352
|
+
def _load_local_private_key(self, node):
|
|
353
|
+
security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
|
|
354
|
+
priv_path = security_dir / f"{node.public_endpoint}"
|
|
355
|
+
if not priv_path.exists():
|
|
356
|
+
return None, _("Local node private key not found.")
|
|
357
|
+
try:
|
|
358
|
+
return (
|
|
359
|
+
serialization.load_pem_private_key(
|
|
360
|
+
priv_path.read_bytes(), password=None
|
|
361
|
+
),
|
|
362
|
+
"",
|
|
363
|
+
)
|
|
364
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
365
|
+
return None, str(exc)
|
|
366
|
+
|
|
367
|
+
def _build_proxy_payload(self, request, local_node):
|
|
368
|
+
user = request.user
|
|
369
|
+
payload = {
|
|
370
|
+
"requester": str(local_node.uuid),
|
|
371
|
+
"user": {
|
|
372
|
+
"username": user.get_username(),
|
|
373
|
+
"email": user.email or "",
|
|
374
|
+
"first_name": user.first_name or "",
|
|
375
|
+
"last_name": user.last_name or "",
|
|
376
|
+
"is_staff": user.is_staff,
|
|
377
|
+
"is_superuser": user.is_superuser,
|
|
378
|
+
"groups": list(user.groups.values_list("name", flat=True)),
|
|
379
|
+
"permissions": sorted(user.get_all_permissions()),
|
|
380
|
+
},
|
|
381
|
+
"target": reverse("admin:index"),
|
|
382
|
+
}
|
|
383
|
+
return payload
|
|
384
|
+
|
|
385
|
+
def _start_proxy_session(self, request, node):
|
|
386
|
+
if node.is_local:
|
|
387
|
+
return {"ok": False, "message": _("Local node cannot be proxied.")}
|
|
388
|
+
|
|
389
|
+
local_node = Node.get_local()
|
|
390
|
+
if local_node is None:
|
|
391
|
+
try:
|
|
392
|
+
local_node, _ = Node.register_current()
|
|
393
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
394
|
+
return {"ok": False, "message": str(exc)}
|
|
395
|
+
|
|
396
|
+
private_key, error = self._load_local_private_key(local_node)
|
|
397
|
+
if private_key is None:
|
|
398
|
+
return {"ok": False, "message": error}
|
|
399
|
+
|
|
400
|
+
payload = self._build_proxy_payload(request, local_node)
|
|
401
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
402
|
+
try:
|
|
403
|
+
signature = private_key.sign(
|
|
404
|
+
body.encode(),
|
|
405
|
+
padding.PKCS1v15(),
|
|
406
|
+
hashes.SHA256(),
|
|
407
|
+
)
|
|
408
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
409
|
+
return {"ok": False, "message": str(exc)}
|
|
410
|
+
|
|
411
|
+
headers = {
|
|
412
|
+
"Content-Type": "application/json",
|
|
413
|
+
"X-Signature": base64.b64encode(signature).decode(),
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
last_error = ""
|
|
417
|
+
for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
|
|
418
|
+
try:
|
|
419
|
+
response = requests.post(url, data=body, headers=headers, timeout=5)
|
|
420
|
+
except RequestException as exc:
|
|
421
|
+
last_error = str(exc)
|
|
422
|
+
continue
|
|
423
|
+
if not response.ok:
|
|
424
|
+
last_error = f"{response.status_code} {response.text}"
|
|
425
|
+
continue
|
|
426
|
+
try:
|
|
427
|
+
data = response.json()
|
|
428
|
+
except ValueError:
|
|
429
|
+
last_error = "Invalid JSON response"
|
|
430
|
+
continue
|
|
431
|
+
login_url = data.get("login_url")
|
|
432
|
+
if not login_url:
|
|
433
|
+
last_error = "login_url missing"
|
|
434
|
+
continue
|
|
435
|
+
return {
|
|
436
|
+
"ok": True,
|
|
437
|
+
"login_url": login_url,
|
|
438
|
+
"expires": data.get("expires"),
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
"ok": False,
|
|
443
|
+
"message": last_error or "Unable to initiate proxy.",
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
def proxy_node(self, request, node_id):
|
|
447
|
+
node = self.get_queryset(request).filter(pk=node_id).first()
|
|
448
|
+
if not node:
|
|
449
|
+
raise Http404
|
|
450
|
+
if not self.has_view_permission(request):
|
|
451
|
+
raise PermissionDenied
|
|
452
|
+
result = self._start_proxy_session(request, node)
|
|
453
|
+
if not result.get("ok"):
|
|
454
|
+
message = result.get("message") or _("Unable to proxy node.")
|
|
455
|
+
self.message_user(request, message, messages.ERROR)
|
|
456
|
+
return redirect("admin:nodes_node_changelist")
|
|
457
|
+
|
|
458
|
+
context = {
|
|
459
|
+
**self.admin_site.each_context(request),
|
|
460
|
+
"opts": self.model._meta,
|
|
461
|
+
"node": node,
|
|
462
|
+
"frame_url": result.get("login_url"),
|
|
463
|
+
"expires": result.get("expires"),
|
|
464
|
+
}
|
|
465
|
+
return TemplateResponse(request, "admin/nodes/node/proxy.html", context)
|
|
466
|
+
|
|
335
467
|
@admin.action(description="Register Visitor")
|
|
336
468
|
def register_visitor(self, request, queryset=None):
|
|
337
469
|
return self.register_visitor_view(request)
|
|
@@ -1565,7 +1697,7 @@ class NetMessageAdmin(EntityModelAdmin):
|
|
|
1565
1697
|
search_fields = ("subject", "body")
|
|
1566
1698
|
list_filter = ("complete", "filter_node_role", "filter_current_relation")
|
|
1567
1699
|
ordering = ("-created",)
|
|
1568
|
-
readonly_fields = ("complete",
|
|
1700
|
+
readonly_fields = ("complete",)
|
|
1569
1701
|
actions = ["send_messages"]
|
|
1570
1702
|
fieldsets = (
|
|
1571
1703
|
(None, {"fields": ("subject", "body")}),
|
|
@@ -1590,7 +1722,6 @@ class NetMessageAdmin(EntityModelAdmin):
|
|
|
1590
1722
|
"node_origin",
|
|
1591
1723
|
"target_limit",
|
|
1592
1724
|
"propagated_to",
|
|
1593
|
-
"confirmed_peers",
|
|
1594
1725
|
"complete",
|
|
1595
1726
|
)
|
|
1596
1727
|
},
|