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.
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
- args = ["./upgrade.sh", "--no-restart"]
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
@@ -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
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 and Path(env_override) != fallback:
452
- os.environ["ARTHEXIS_LOG_DIR"] = str(fallback)
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
- if _has_remote("origin"):
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.objects.update_or_create(
1730
- rfid=rfid.upper(),
1731
- defaults={
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
- if not pending_items:
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 pending_items:
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 pending_items
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 pending_items:
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", "confirmed_peers")
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
  },