arthexis 0.1.19__py3-none-any.whl → 0.1.21__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.19.dist-info → arthexis-0.1.21.dist-info}/METADATA +5 -6
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/RECORD +42 -44
- config/asgi.py +1 -15
- config/settings.py +0 -26
- config/urls.py +0 -1
- core/admin.py +143 -234
- core/apps.py +0 -6
- core/backends.py +8 -2
- core/environment.py +240 -4
- core/models.py +132 -102
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/sigil_builder.py +2 -2
- core/tasks.py +24 -1
- core/tests.py +2 -7
- core/views.py +70 -132
- nodes/admin.py +162 -7
- nodes/models.py +294 -48
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +581 -15
- nodes/urls.py +4 -0
- nodes/views.py +560 -96
- ocpp/admin.py +144 -4
- ocpp/consumers.py +106 -9
- ocpp/models.py +131 -1
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +183 -9
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +186 -31
- pages/context_processors.py +15 -21
- pages/defaults.py +1 -1
- pages/module_defaults.py +5 -5
- pages/tests.py +110 -79
- pages/urls.py +1 -1
- pages/views.py +108 -13
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/WHEEL +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/top_level.txt +0 -0
core/tasks.py
CHANGED
|
@@ -2,6 +2,7 @@ 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
|
|
@@ -102,6 +103,21 @@ def _resolve_service_url(base_dir: Path) -> str:
|
|
|
102
103
|
return f"http://127.0.0.1:{port}/"
|
|
103
104
|
|
|
104
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
|
+
|
|
105
121
|
@shared_task
|
|
106
122
|
def check_github_updates() -> None:
|
|
107
123
|
"""Check the GitHub repo for updates and upgrade if needed."""
|
|
@@ -196,9 +212,16 @@ def check_github_updates() -> None:
|
|
|
196
212
|
if startup:
|
|
197
213
|
startup()
|
|
198
214
|
return
|
|
215
|
+
if mode == "stable" and _shares_stable_series(local, remote):
|
|
216
|
+
if startup:
|
|
217
|
+
startup()
|
|
218
|
+
return
|
|
199
219
|
if notify:
|
|
200
220
|
notify("Upgrading...", upgrade_stamp)
|
|
201
|
-
|
|
221
|
+
if mode == "stable":
|
|
222
|
+
args = ["./upgrade.sh", "--stable", "--no-restart"]
|
|
223
|
+
else:
|
|
224
|
+
args = ["./upgrade.sh", "--no-restart"]
|
|
202
225
|
upgrade_was_applied = True
|
|
203
226
|
|
|
204
227
|
with log_file.open("a") as fh:
|
core/tests.py
CHANGED
|
@@ -8,6 +8,7 @@ django.setup()
|
|
|
8
8
|
from django.test import Client, TestCase, RequestFactory, override_settings
|
|
9
9
|
from django.urls import reverse
|
|
10
10
|
from django.http import HttpRequest
|
|
11
|
+
from django.contrib import messages
|
|
11
12
|
import csv
|
|
12
13
|
import json
|
|
13
14
|
import importlib.util
|
|
@@ -1289,11 +1290,10 @@ class ReleaseProcessTests(TestCase):
|
|
|
1289
1290
|
run.assert_any_call(["git", "clean", "-fd"], check=False)
|
|
1290
1291
|
|
|
1291
1292
|
@mock.patch("core.views.PackageRelease.dump_fixture")
|
|
1292
|
-
@mock.patch("core.views._ensure_release_todo")
|
|
1293
1293
|
@mock.patch("core.views._sync_with_origin_main")
|
|
1294
1294
|
@mock.patch("core.views.subprocess.run")
|
|
1295
1295
|
def test_pre_release_syncs_with_main(
|
|
1296
|
-
self, run, sync_main,
|
|
1296
|
+
self, run, sync_main, dump_fixture
|
|
1297
1297
|
):
|
|
1298
1298
|
import subprocess as sp
|
|
1299
1299
|
|
|
@@ -1305,11 +1305,6 @@ class ReleaseProcessTests(TestCase):
|
|
|
1305
1305
|
return sp.CompletedProcess(cmd, 0)
|
|
1306
1306
|
|
|
1307
1307
|
run.side_effect = fake_run
|
|
1308
|
-
ensure_todo.return_value = (
|
|
1309
|
-
mock.Mock(request="Create release pkg 1.0.1", url="", request_details=""),
|
|
1310
|
-
Path("core/fixtures/todos__next_release.json"),
|
|
1311
|
-
)
|
|
1312
|
-
|
|
1313
1308
|
version_path = Path("VERSION")
|
|
1314
1309
|
original_version = version_path.read_text(encoding="utf-8")
|
|
1315
1310
|
|
core/views.py
CHANGED
|
@@ -19,7 +19,6 @@ from django.shortcuts import get_object_or_404, redirect, render, resolve_url
|
|
|
19
19
|
from django.template.response import TemplateResponse
|
|
20
20
|
from django.utils import timezone
|
|
21
21
|
from django.utils.html import strip_tags
|
|
22
|
-
from django.utils.text import slugify
|
|
23
22
|
from django.utils.translation import gettext as _
|
|
24
23
|
from django.urls import NoReverseMatch, reverse
|
|
25
24
|
from django.views.decorators.csrf import csrf_exempt
|
|
@@ -448,8 +447,11 @@ def _resolve_release_log_dir(preferred: Path) -> tuple[Path, str | None]:
|
|
|
448
447
|
|
|
449
448
|
env_override = os.environ.pop("ARTHEXIS_LOG_DIR", None)
|
|
450
449
|
fallback = select_log_dir(Path(settings.BASE_DIR))
|
|
451
|
-
if env_override
|
|
452
|
-
|
|
450
|
+
if env_override is not None:
|
|
451
|
+
if Path(env_override) == fallback:
|
|
452
|
+
os.environ["ARTHEXIS_LOG_DIR"] = env_override
|
|
453
|
+
else:
|
|
454
|
+
os.environ["ARTHEXIS_LOG_DIR"] = str(fallback)
|
|
453
455
|
|
|
454
456
|
if fallback == preferred:
|
|
455
457
|
if error:
|
|
@@ -608,6 +610,43 @@ def _git_authentication_missing(exc: subprocess.CalledProcessError) -> bool:
|
|
|
608
610
|
return any(marker in message for marker in auth_markers)
|
|
609
611
|
|
|
610
612
|
|
|
613
|
+
def _push_release_changes(log_path: Path) -> bool:
|
|
614
|
+
"""Push release commits to ``origin`` and log the outcome."""
|
|
615
|
+
|
|
616
|
+
if not _has_remote("origin"):
|
|
617
|
+
_append_log(
|
|
618
|
+
log_path, "No git remote configured; skipping push of release changes"
|
|
619
|
+
)
|
|
620
|
+
return False
|
|
621
|
+
|
|
622
|
+
try:
|
|
623
|
+
branch = _current_branch()
|
|
624
|
+
if branch is None:
|
|
625
|
+
push_cmd = ["git", "push", "origin", "HEAD"]
|
|
626
|
+
elif _has_upstream(branch):
|
|
627
|
+
push_cmd = ["git", "push"]
|
|
628
|
+
else:
|
|
629
|
+
push_cmd = ["git", "push", "--set-upstream", "origin", branch]
|
|
630
|
+
subprocess.run(push_cmd, check=True, capture_output=True, text=True)
|
|
631
|
+
except subprocess.CalledProcessError as exc:
|
|
632
|
+
details = _format_subprocess_error(exc)
|
|
633
|
+
if _git_authentication_missing(exc):
|
|
634
|
+
_append_log(
|
|
635
|
+
log_path,
|
|
636
|
+
"Authentication is required to push release changes to origin; skipping push",
|
|
637
|
+
)
|
|
638
|
+
if details:
|
|
639
|
+
_append_log(log_path, details)
|
|
640
|
+
return False
|
|
641
|
+
_append_log(
|
|
642
|
+
log_path, f"Failed to push release changes to origin: {details}"
|
|
643
|
+
)
|
|
644
|
+
raise Exception("Failed to push release changes") from exc
|
|
645
|
+
|
|
646
|
+
_append_log(log_path, "Pushed release changes to origin")
|
|
647
|
+
return True
|
|
648
|
+
|
|
649
|
+
|
|
611
650
|
def _ensure_origin_main_unchanged(log_path: Path) -> None:
|
|
612
651
|
"""Verify that ``origin/main`` has not advanced during the release."""
|
|
613
652
|
|
|
@@ -653,29 +692,6 @@ def _next_patch_version(version: str) -> str:
|
|
|
653
692
|
return f"{parsed.major}.{parsed.minor}.{parsed.micro + 1}"
|
|
654
693
|
|
|
655
694
|
|
|
656
|
-
def _write_todo_fixture(todo: Todo) -> Path:
|
|
657
|
-
safe_request = todo.request.replace(".", " ")
|
|
658
|
-
slug = slugify(safe_request).replace("-", "_")
|
|
659
|
-
if not slug:
|
|
660
|
-
slug = "todo"
|
|
661
|
-
path = TODO_FIXTURE_DIR / f"todos__{slug}.json"
|
|
662
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
663
|
-
data = [
|
|
664
|
-
{
|
|
665
|
-
"model": "core.todo",
|
|
666
|
-
"fields": {
|
|
667
|
-
"request": todo.request,
|
|
668
|
-
"url": todo.url,
|
|
669
|
-
"request_details": todo.request_details,
|
|
670
|
-
"generated_for_version": todo.generated_for_version,
|
|
671
|
-
"generated_for_revision": todo.generated_for_revision,
|
|
672
|
-
},
|
|
673
|
-
}
|
|
674
|
-
]
|
|
675
|
-
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
676
|
-
return path
|
|
677
|
-
|
|
678
|
-
|
|
679
695
|
def _should_use_python_changelog(exc: OSError) -> bool:
|
|
680
696
|
winerror = getattr(exc, "winerror", None)
|
|
681
697
|
if winerror in {193}:
|
|
@@ -696,46 +712,6 @@ def _generate_changelog_with_python(log_path: Path) -> None:
|
|
|
696
712
|
_append_log(log_path, "Regenerated CHANGELOG.rst using Python fallback")
|
|
697
713
|
|
|
698
714
|
|
|
699
|
-
def _ensure_release_todo(
|
|
700
|
-
release, *, previous_version: str | None = None
|
|
701
|
-
) -> tuple[Todo, Path]:
|
|
702
|
-
previous_version = (previous_version or "").strip()
|
|
703
|
-
target_version = _next_patch_version(release.version)
|
|
704
|
-
if previous_version:
|
|
705
|
-
try:
|
|
706
|
-
from packaging.version import InvalidVersion, Version
|
|
707
|
-
|
|
708
|
-
parsed_previous = Version(previous_version)
|
|
709
|
-
parsed_target = Version(target_version)
|
|
710
|
-
except InvalidVersion:
|
|
711
|
-
pass
|
|
712
|
-
else:
|
|
713
|
-
if parsed_target <= parsed_previous:
|
|
714
|
-
target_version = _next_patch_version(previous_version)
|
|
715
|
-
request = f"Create release {release.package.name} {target_version}"
|
|
716
|
-
try:
|
|
717
|
-
url = reverse("admin:core_packagerelease_changelist")
|
|
718
|
-
except NoReverseMatch:
|
|
719
|
-
url = ""
|
|
720
|
-
todo, _ = Todo.all_objects.update_or_create(
|
|
721
|
-
request__iexact=request,
|
|
722
|
-
defaults={
|
|
723
|
-
"request": request,
|
|
724
|
-
"url": url,
|
|
725
|
-
"request_details": "",
|
|
726
|
-
"generated_for_version": release.version or "",
|
|
727
|
-
"generated_for_revision": release.revision or "",
|
|
728
|
-
"is_seed_data": True,
|
|
729
|
-
"is_deleted": False,
|
|
730
|
-
"is_user_data": False,
|
|
731
|
-
"done_on": None,
|
|
732
|
-
"on_done_condition": "",
|
|
733
|
-
},
|
|
734
|
-
)
|
|
735
|
-
fixture_path = _write_todo_fixture(todo)
|
|
736
|
-
return todo, fixture_path
|
|
737
|
-
|
|
738
|
-
|
|
739
715
|
def _todo_blocks_publish(todo: Todo, release: PackageRelease) -> bool:
|
|
740
716
|
"""Return ``True`` when ``todo`` should block the release workflow."""
|
|
741
717
|
|
|
@@ -1186,36 +1162,6 @@ def _step_changelog_docs(release, ctx, log_path: Path) -> None:
|
|
|
1186
1162
|
_append_log(log_path, "CHANGELOG and documentation review recorded")
|
|
1187
1163
|
|
|
1188
1164
|
|
|
1189
|
-
def _record_release_todo(
|
|
1190
|
-
release, ctx, log_path: Path, *, previous_version: str | None = None
|
|
1191
|
-
) -> None:
|
|
1192
|
-
previous_version = previous_version or ctx.pop(
|
|
1193
|
-
"release_todo_previous_version",
|
|
1194
|
-
getattr(release, "_repo_version_before_sync", ""),
|
|
1195
|
-
)
|
|
1196
|
-
todo, fixture_path = _ensure_release_todo(
|
|
1197
|
-
release, previous_version=previous_version
|
|
1198
|
-
)
|
|
1199
|
-
fixture_display = _format_path(fixture_path)
|
|
1200
|
-
_append_log(log_path, f"Added TODO: {todo.request}")
|
|
1201
|
-
_append_log(log_path, f"Wrote TODO fixture {fixture_display}")
|
|
1202
|
-
subprocess.run(["git", "add", str(fixture_path)], check=True)
|
|
1203
|
-
_append_log(log_path, f"Staged TODO fixture {fixture_display}")
|
|
1204
|
-
fixture_diff = subprocess.run(
|
|
1205
|
-
["git", "diff", "--cached", "--quiet", "--", str(fixture_path)],
|
|
1206
|
-
check=False,
|
|
1207
|
-
)
|
|
1208
|
-
if fixture_diff.returncode != 0:
|
|
1209
|
-
commit_message = f"chore: add release TODO for {release.package.name}"
|
|
1210
|
-
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
|
1211
|
-
_append_log(log_path, f"Committed TODO fixture {fixture_display}")
|
|
1212
|
-
else:
|
|
1213
|
-
_append_log(
|
|
1214
|
-
log_path,
|
|
1215
|
-
f"No changes detected for TODO fixture {fixture_display}; skipping commit",
|
|
1216
|
-
)
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
1165
|
def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
|
|
1220
1166
|
_append_log(log_path, "Execute pre-release actions")
|
|
1221
1167
|
if ctx.get("dry_run"):
|
|
@@ -1289,7 +1235,6 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
|
|
|
1289
1235
|
for path in staged_release_fixtures:
|
|
1290
1236
|
subprocess.run(["git", "reset", "HEAD", str(path)], check=False)
|
|
1291
1237
|
_append_log(log_path, f"Unstaged release fixture {_format_path(path)}")
|
|
1292
|
-
ctx["release_todo_previous_version"] = repo_version_before_sync
|
|
1293
1238
|
_append_log(log_path, "Pre-release actions complete")
|
|
1294
1239
|
|
|
1295
1240
|
|
|
@@ -1340,40 +1285,9 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
|
|
|
1340
1285
|
log_path,
|
|
1341
1286
|
f"Committed release metadata for v{release.version}",
|
|
1342
1287
|
)
|
|
1343
|
-
|
|
1344
|
-
try:
|
|
1345
|
-
branch = _current_branch()
|
|
1346
|
-
if branch is None:
|
|
1347
|
-
push_cmd = ["git", "push", "origin", "HEAD"]
|
|
1348
|
-
elif _has_upstream(branch):
|
|
1349
|
-
push_cmd = ["git", "push"]
|
|
1350
|
-
else:
|
|
1351
|
-
push_cmd = ["git", "push", "--set-upstream", "origin", branch]
|
|
1352
|
-
subprocess.run(push_cmd, check=True, capture_output=True, text=True)
|
|
1353
|
-
except subprocess.CalledProcessError as exc:
|
|
1354
|
-
details = _format_subprocess_error(exc)
|
|
1355
|
-
if _git_authentication_missing(exc):
|
|
1356
|
-
_append_log(
|
|
1357
|
-
log_path,
|
|
1358
|
-
"Authentication is required to push release changes to origin; skipping push",
|
|
1359
|
-
)
|
|
1360
|
-
if details:
|
|
1361
|
-
_append_log(log_path, details)
|
|
1362
|
-
else:
|
|
1363
|
-
_append_log(
|
|
1364
|
-
log_path, f"Failed to push release changes to origin: {details}"
|
|
1365
|
-
)
|
|
1366
|
-
raise Exception("Failed to push release changes") from exc
|
|
1367
|
-
else:
|
|
1368
|
-
_append_log(log_path, "Pushed release changes to origin")
|
|
1369
|
-
else:
|
|
1370
|
-
_append_log(
|
|
1371
|
-
log_path,
|
|
1372
|
-
"No git remote configured; skipping push of release changes",
|
|
1373
|
-
)
|
|
1288
|
+
_push_release_changes(log_path)
|
|
1374
1289
|
PackageRelease.dump_fixture()
|
|
1375
1290
|
_append_log(log_path, "Updated release fixtures")
|
|
1376
|
-
_record_release_todo(release, ctx, log_path)
|
|
1377
1291
|
except Exception:
|
|
1378
1292
|
_clean_repo()
|
|
1379
1293
|
raise
|
|
@@ -1561,6 +1475,30 @@ def _step_publish(release, ctx, log_path: Path) -> None:
|
|
|
1561
1475
|
_append_log(log_path, f"Recorded PyPI URL: {release.pypi_url}")
|
|
1562
1476
|
if release.github_url:
|
|
1563
1477
|
_append_log(log_path, f"Recorded GitHub URL: {release.github_url}")
|
|
1478
|
+
fixture_paths = [
|
|
1479
|
+
str(path) for path in Path("core/fixtures").glob("releases__*.json")
|
|
1480
|
+
]
|
|
1481
|
+
if fixture_paths:
|
|
1482
|
+
status = subprocess.run(
|
|
1483
|
+
["git", "status", "--porcelain", "--", *fixture_paths],
|
|
1484
|
+
capture_output=True,
|
|
1485
|
+
text=True,
|
|
1486
|
+
check=True,
|
|
1487
|
+
)
|
|
1488
|
+
if status.stdout.strip():
|
|
1489
|
+
subprocess.run(["git", "add", *fixture_paths], check=True)
|
|
1490
|
+
_append_log(log_path, "Staged publish metadata updates")
|
|
1491
|
+
commit_message = f"chore: record publish metadata for v{release.version}"
|
|
1492
|
+
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
|
1493
|
+
_append_log(
|
|
1494
|
+
log_path, f"Committed publish metadata for v{release.version}"
|
|
1495
|
+
)
|
|
1496
|
+
_push_release_changes(log_path)
|
|
1497
|
+
else:
|
|
1498
|
+
_append_log(
|
|
1499
|
+
log_path,
|
|
1500
|
+
"No release metadata updates detected after publish; skipping commit",
|
|
1501
|
+
)
|
|
1564
1502
|
_append_log(log_path, "Upload complete")
|
|
1565
1503
|
|
|
1566
1504
|
|
|
@@ -1754,9 +1692,9 @@ def rfid_batch(request):
|
|
|
1754
1692
|
else:
|
|
1755
1693
|
post_auth_command = post_auth_command.strip()
|
|
1756
1694
|
|
|
1757
|
-
tag, _ = RFID.
|
|
1758
|
-
rfid
|
|
1759
|
-
|
|
1695
|
+
tag, _ = RFID.update_or_create_from_code(
|
|
1696
|
+
rfid,
|
|
1697
|
+
{
|
|
1760
1698
|
"allowed": allowed,
|
|
1761
1699
|
"color": color,
|
|
1762
1700
|
"released": released,
|
nodes/admin.py
CHANGED
|
@@ -8,9 +8,10 @@ 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
|
+
from django.test import signals
|
|
14
15
|
from django.urls import NoReverseMatch, path, reverse
|
|
15
16
|
from django.utils import timezone
|
|
16
17
|
from django.utils.dateparse import parse_datetime
|
|
@@ -233,6 +234,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
233
234
|
"role",
|
|
234
235
|
"relation",
|
|
235
236
|
"last_seen",
|
|
237
|
+
"proxy_link",
|
|
236
238
|
)
|
|
237
239
|
search_fields = ("hostname", "address", "mac_address")
|
|
238
240
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
@@ -247,6 +249,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
247
249
|
"address",
|
|
248
250
|
"mac_address",
|
|
249
251
|
"port",
|
|
252
|
+
"message_queue_length",
|
|
250
253
|
"role",
|
|
251
254
|
"current_relation",
|
|
252
255
|
)
|
|
@@ -281,6 +284,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
281
284
|
"register_visitor",
|
|
282
285
|
"run_task",
|
|
283
286
|
"take_screenshots",
|
|
287
|
+
"fetch_rfids_from_selected",
|
|
284
288
|
"import_rfids_from_selected",
|
|
285
289
|
"export_rfids_to_selected",
|
|
286
290
|
]
|
|
@@ -290,6 +294,16 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
290
294
|
def relation(self, obj):
|
|
291
295
|
return obj.get_current_relation_display()
|
|
292
296
|
|
|
297
|
+
@admin.display(description=_("Proxy"))
|
|
298
|
+
def proxy_link(self, obj):
|
|
299
|
+
if not obj or obj.is_local:
|
|
300
|
+
return ""
|
|
301
|
+
try:
|
|
302
|
+
url = reverse("admin:nodes_node_proxy", args=[obj.pk])
|
|
303
|
+
except NoReverseMatch:
|
|
304
|
+
return ""
|
|
305
|
+
return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
|
|
306
|
+
|
|
293
307
|
def get_urls(self):
|
|
294
308
|
urls = super().get_urls()
|
|
295
309
|
custom = [
|
|
@@ -313,6 +327,11 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
313
327
|
self.admin_site.admin_view(self.update_selected_progress),
|
|
314
328
|
name="nodes_node_update_selected_progress",
|
|
315
329
|
),
|
|
330
|
+
path(
|
|
331
|
+
"<int:node_id>/proxy/",
|
|
332
|
+
self.admin_site.admin_view(self.proxy_node),
|
|
333
|
+
name="nodes_node_proxy",
|
|
334
|
+
),
|
|
316
335
|
]
|
|
317
336
|
return custom + urls
|
|
318
337
|
|
|
@@ -330,7 +349,135 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
330
349
|
"token": token,
|
|
331
350
|
"register_url": reverse("register-node"),
|
|
332
351
|
}
|
|
333
|
-
|
|
352
|
+
response = TemplateResponse(
|
|
353
|
+
request, "admin/nodes/node/register_remote.html", context
|
|
354
|
+
)
|
|
355
|
+
response.render()
|
|
356
|
+
template = response.resolve_template(response.template_name)
|
|
357
|
+
if getattr(template, "name", None) in (None, ""):
|
|
358
|
+
template.name = response.template_name
|
|
359
|
+
signals.template_rendered.send(
|
|
360
|
+
sender=template.__class__,
|
|
361
|
+
template=template,
|
|
362
|
+
context=response.context_data,
|
|
363
|
+
request=request,
|
|
364
|
+
)
|
|
365
|
+
return response
|
|
366
|
+
|
|
367
|
+
def _load_local_private_key(self, node):
|
|
368
|
+
security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
|
|
369
|
+
priv_path = security_dir / f"{node.public_endpoint}"
|
|
370
|
+
if not priv_path.exists():
|
|
371
|
+
return None, _("Local node private key not found.")
|
|
372
|
+
try:
|
|
373
|
+
return (
|
|
374
|
+
serialization.load_pem_private_key(
|
|
375
|
+
priv_path.read_bytes(), password=None
|
|
376
|
+
),
|
|
377
|
+
"",
|
|
378
|
+
)
|
|
379
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
380
|
+
return None, str(exc)
|
|
381
|
+
|
|
382
|
+
def _build_proxy_payload(self, request, local_node):
|
|
383
|
+
user = request.user
|
|
384
|
+
payload = {
|
|
385
|
+
"requester": str(local_node.uuid),
|
|
386
|
+
"user": {
|
|
387
|
+
"username": user.get_username(),
|
|
388
|
+
"email": user.email or "",
|
|
389
|
+
"first_name": user.first_name or "",
|
|
390
|
+
"last_name": user.last_name or "",
|
|
391
|
+
"is_staff": user.is_staff,
|
|
392
|
+
"is_superuser": user.is_superuser,
|
|
393
|
+
"groups": list(user.groups.values_list("name", flat=True)),
|
|
394
|
+
"permissions": sorted(user.get_all_permissions()),
|
|
395
|
+
},
|
|
396
|
+
"target": reverse("admin:index"),
|
|
397
|
+
}
|
|
398
|
+
return payload
|
|
399
|
+
|
|
400
|
+
def _start_proxy_session(self, request, node):
|
|
401
|
+
if node.is_local:
|
|
402
|
+
return {"ok": False, "message": _("Local node cannot be proxied.")}
|
|
403
|
+
|
|
404
|
+
local_node = Node.get_local()
|
|
405
|
+
if local_node is None:
|
|
406
|
+
try:
|
|
407
|
+
local_node, _ = Node.register_current()
|
|
408
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
409
|
+
return {"ok": False, "message": str(exc)}
|
|
410
|
+
|
|
411
|
+
private_key, error = self._load_local_private_key(local_node)
|
|
412
|
+
if private_key is None:
|
|
413
|
+
return {"ok": False, "message": error}
|
|
414
|
+
|
|
415
|
+
payload = self._build_proxy_payload(request, local_node)
|
|
416
|
+
body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
417
|
+
try:
|
|
418
|
+
signature = private_key.sign(
|
|
419
|
+
body.encode(),
|
|
420
|
+
padding.PKCS1v15(),
|
|
421
|
+
hashes.SHA256(),
|
|
422
|
+
)
|
|
423
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
424
|
+
return {"ok": False, "message": str(exc)}
|
|
425
|
+
|
|
426
|
+
headers = {
|
|
427
|
+
"Content-Type": "application/json",
|
|
428
|
+
"X-Signature": base64.b64encode(signature).decode(),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
last_error = ""
|
|
432
|
+
for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
|
|
433
|
+
try:
|
|
434
|
+
response = requests.post(url, data=body, headers=headers, timeout=5)
|
|
435
|
+
except RequestException as exc:
|
|
436
|
+
last_error = str(exc)
|
|
437
|
+
continue
|
|
438
|
+
if not response.ok:
|
|
439
|
+
last_error = f"{response.status_code} {response.text}"
|
|
440
|
+
continue
|
|
441
|
+
try:
|
|
442
|
+
data = response.json()
|
|
443
|
+
except ValueError:
|
|
444
|
+
last_error = "Invalid JSON response"
|
|
445
|
+
continue
|
|
446
|
+
login_url = data.get("login_url")
|
|
447
|
+
if not login_url:
|
|
448
|
+
last_error = "login_url missing"
|
|
449
|
+
continue
|
|
450
|
+
return {
|
|
451
|
+
"ok": True,
|
|
452
|
+
"login_url": login_url,
|
|
453
|
+
"expires": data.get("expires"),
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
"ok": False,
|
|
458
|
+
"message": last_error or "Unable to initiate proxy.",
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
def proxy_node(self, request, node_id):
|
|
462
|
+
node = self.get_queryset(request).filter(pk=node_id).first()
|
|
463
|
+
if not node:
|
|
464
|
+
raise Http404
|
|
465
|
+
if not self.has_view_permission(request):
|
|
466
|
+
raise PermissionDenied
|
|
467
|
+
result = self._start_proxy_session(request, node)
|
|
468
|
+
if not result.get("ok"):
|
|
469
|
+
message = result.get("message") or _("Unable to proxy node.")
|
|
470
|
+
self.message_user(request, message, messages.ERROR)
|
|
471
|
+
return redirect("admin:nodes_node_changelist")
|
|
472
|
+
|
|
473
|
+
context = {
|
|
474
|
+
**self.admin_site.each_context(request),
|
|
475
|
+
"opts": self.model._meta,
|
|
476
|
+
"node": node,
|
|
477
|
+
"frame_url": result.get("login_url"),
|
|
478
|
+
"expires": result.get("expires"),
|
|
479
|
+
}
|
|
480
|
+
return TemplateResponse(request, "admin/nodes/node/proxy.html", context)
|
|
334
481
|
|
|
335
482
|
@admin.action(description="Register Visitor")
|
|
336
483
|
def register_visitor(self, request, queryset=None):
|
|
@@ -742,6 +889,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
742
889
|
def _render_rfid_sync(self, request, operation, results, setup_error=None):
|
|
743
890
|
titles = {
|
|
744
891
|
"import": _("Import RFID results"),
|
|
892
|
+
"fetch": _("Fetch RFID results"),
|
|
745
893
|
"export": _("Export RFID results"),
|
|
746
894
|
}
|
|
747
895
|
summary = self._summarize_rfid_results(results)
|
|
@@ -851,18 +999,17 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
851
999
|
result["status"] = self._status_from_result(result)
|
|
852
1000
|
return result
|
|
853
1001
|
|
|
854
|
-
|
|
855
|
-
def import_rfids_from_selected(self, request, queryset):
|
|
1002
|
+
def _run_rfid_fetch(self, request, queryset, *, operation):
|
|
856
1003
|
nodes = list(queryset)
|
|
857
1004
|
local_node, private_key, error = self._load_local_node_credentials()
|
|
858
1005
|
if error:
|
|
859
1006
|
results = [self._skip_result(node, error) for node in nodes]
|
|
860
|
-
return self._render_rfid_sync(request,
|
|
1007
|
+
return self._render_rfid_sync(request, operation, results, setup_error=error)
|
|
861
1008
|
|
|
862
1009
|
if not nodes:
|
|
863
1010
|
return self._render_rfid_sync(
|
|
864
1011
|
request,
|
|
865
|
-
|
|
1012
|
+
operation,
|
|
866
1013
|
[],
|
|
867
1014
|
setup_error=_("No nodes selected."),
|
|
868
1015
|
)
|
|
@@ -885,7 +1032,15 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
885
1032
|
continue
|
|
886
1033
|
results.append(self._process_import_from_node(node, payload, headers))
|
|
887
1034
|
|
|
888
|
-
return self._render_rfid_sync(request,
|
|
1035
|
+
return self._render_rfid_sync(request, operation, results)
|
|
1036
|
+
|
|
1037
|
+
@admin.action(description=_("Fetch RFIDs from selected"))
|
|
1038
|
+
def fetch_rfids_from_selected(self, request, queryset):
|
|
1039
|
+
return self._run_rfid_fetch(request, queryset, operation="fetch")
|
|
1040
|
+
|
|
1041
|
+
@admin.action(description=_("Import RFIDs from selected"))
|
|
1042
|
+
def import_rfids_from_selected(self, request, queryset):
|
|
1043
|
+
return self._run_rfid_fetch(request, queryset, operation="import")
|
|
889
1044
|
|
|
890
1045
|
@admin.action(description=_("Export RFIDs to selected"))
|
|
891
1046
|
def export_rfids_to_selected(self, request, queryset):
|