arthexis 0.1.20__py3-none-any.whl → 0.1.22__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.

nodes/admin.py CHANGED
@@ -11,14 +11,17 @@ from django.db.models import Count
11
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
17
18
  from django.utils.html import format_html, format_html_join
18
19
  from django.utils.translation import gettext_lazy as _
19
20
  from pathlib import Path
20
- from urllib.parse import urlsplit, urlunsplit
21
+ from types import SimpleNamespace
22
+ from urllib.parse import urlparse, urlsplit, urlunsplit
21
23
  import base64
24
+ import ipaddress
22
25
  import json
23
26
  import subprocess
24
27
  import uuid
@@ -84,7 +87,7 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
84
87
 
85
88
  class DeployDNSRecordsForm(forms.Form):
86
89
  manager = forms.ModelChoiceField(
87
- label="Node Manager",
90
+ label="Node Profile",
88
91
  queryset=NodeManager.objects.none(),
89
92
  help_text="Credentials used to authenticate with the DNS provider.",
90
93
  )
@@ -233,6 +236,7 @@ class NodeAdmin(EntityModelAdmin):
233
236
  "role",
234
237
  "relation",
235
238
  "last_seen",
239
+ "visit_link",
236
240
  "proxy_link",
237
241
  )
238
242
  search_fields = ("hostname", "address", "mac_address")
@@ -302,6 +306,49 @@ class NodeAdmin(EntityModelAdmin):
302
306
  return ""
303
307
  return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
304
308
 
309
+ @admin.display(description=_("Visit"))
310
+ def visit_link(self, obj):
311
+ if not obj:
312
+ return ""
313
+ if obj.is_local:
314
+ try:
315
+ url = reverse("admin:index")
316
+ except NoReverseMatch:
317
+ return ""
318
+ return format_html(
319
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
320
+ url,
321
+ _("Visit"),
322
+ )
323
+
324
+ host_values: list[str] = []
325
+ for attr in ("hostname", "address", "public_endpoint"):
326
+ value = getattr(obj, attr, "") or ""
327
+ cleaned = value.strip()
328
+ if cleaned and cleaned not in host_values:
329
+ host_values.append(cleaned)
330
+
331
+ remote_url = ""
332
+ for host in host_values:
333
+ temp_node = SimpleNamespace(
334
+ public_endpoint=host,
335
+ address="",
336
+ hostname="",
337
+ port=obj.port,
338
+ )
339
+ remote_url = next(self._iter_remote_urls(temp_node, "/admin/"), "")
340
+ if remote_url:
341
+ break
342
+
343
+ if not remote_url:
344
+ return ""
345
+
346
+ return format_html(
347
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
348
+ remote_url,
349
+ _("Visit"),
350
+ )
351
+
305
352
  def get_urls(self):
306
353
  urls = super().get_urls()
307
354
  custom = [
@@ -347,7 +394,20 @@ class NodeAdmin(EntityModelAdmin):
347
394
  "token": token,
348
395
  "register_url": reverse("register-node"),
349
396
  }
350
- return render(request, "admin/nodes/node/register_remote.html", context)
397
+ response = TemplateResponse(
398
+ request, "admin/nodes/node/register_remote.html", context
399
+ )
400
+ response.render()
401
+ template = response.resolve_template(response.template_name)
402
+ if getattr(template, "name", None) in (None, ""):
403
+ template.name = response.template_name
404
+ signals.template_rendered.send(
405
+ sender=template.__class__,
406
+ template=template,
407
+ context=response.context_data,
408
+ request=request,
409
+ )
410
+ return response
351
411
 
352
412
  def _load_local_private_key(self, node):
353
413
  security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
@@ -583,6 +643,17 @@ class NodeAdmin(EntityModelAdmin):
583
643
  setattr(node, field, value)
584
644
  changed.append(field)
585
645
 
646
+ role_value = payload.get("role") or payload.get("role_name")
647
+ if role_value is not None:
648
+ role_name = str(role_value).strip()
649
+ if role_name:
650
+ desired_role = NodeRole.objects.filter(name=role_name).first()
651
+ else:
652
+ desired_role = None
653
+ if desired_role and node.role_id != desired_role.id:
654
+ node.role = desired_role
655
+ changed.append("role")
656
+
586
657
  node.last_seen = timezone.now()
587
658
  if "last_seen" not in changed:
588
659
  changed.append("last_seen")
@@ -662,40 +733,96 @@ class NodeAdmin(EntityModelAdmin):
662
733
  return {"ok": False, "message": last_error or "Unable to reach remote node."}
663
734
 
664
735
  def _iter_remote_urls(self, node, path):
665
- host_candidates = []
736
+ host_candidates: list[str] = []
666
737
  for attr in ("public_endpoint", "address", "hostname"):
667
738
  value = getattr(node, attr, "") or ""
668
- value = value.strip()
669
- if value and value not in host_candidates:
670
- host_candidates.append(value)
739
+ cleaned = value.strip()
740
+ if cleaned and cleaned not in host_candidates:
741
+ host_candidates.append(cleaned)
671
742
 
672
- port = node.port or 8000
743
+ default_port = node.port or 8000
673
744
  normalized_path = path if path.startswith("/") else f"/{path}"
674
- seen = set()
745
+ seen: set[str] = set()
675
746
 
676
747
  for host in host_candidates:
748
+ base_path = ""
677
749
  formatted_host = host
678
- if ":" in host and not host.startswith("["):
679
- formatted_host = f"[{host}]"
680
-
681
- candidates = []
682
- if port == 80:
683
- candidates = [
684
- f"http://{formatted_host}{normalized_path}",
685
- f"https://{formatted_host}{normalized_path}",
686
- ]
687
- elif port == 443:
688
- candidates = [
689
- f"https://{formatted_host}{normalized_path}",
690
- f"http://{formatted_host}:{port}{normalized_path}",
691
- ]
692
- else:
693
- candidates = [
694
- f"http://{formatted_host}:{port}{normalized_path}",
695
- f"https://{formatted_host}:{port}{normalized_path}",
696
- ]
750
+ port_override: int | None = None
751
+
752
+ if "://" in host:
753
+ parsed = urlparse(host)
754
+ netloc = parsed.netloc or parsed.path
755
+ base_path = (parsed.path or "").rstrip("/")
756
+ combined_path = (
757
+ f"{base_path}{normalized_path}" if base_path else normalized_path
758
+ )
759
+ primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
760
+ if primary not in seen:
761
+ seen.add(primary)
762
+ yield primary
763
+ if parsed.scheme == "https":
764
+ fallback = urlunsplit(("http", netloc, combined_path, "", ""))
765
+ if fallback not in seen:
766
+ seen.add(fallback)
767
+ yield fallback
768
+ elif parsed.scheme == "http":
769
+ alternate = urlunsplit(("https", netloc, combined_path, "", ""))
770
+ if alternate not in seen:
771
+ seen.add(alternate)
772
+ yield alternate
773
+ continue
697
774
 
698
- for candidate in candidates:
775
+ if host.startswith("[") and "]" in host:
776
+ end = host.index("]")
777
+ core_host = host[1:end]
778
+ remainder = host[end + 1 :]
779
+ if remainder.startswith(":"):
780
+ remainder = remainder[1:]
781
+ port_part, sep, path_tail = remainder.partition("/")
782
+ if port_part:
783
+ try:
784
+ port_override = int(port_part)
785
+ except ValueError:
786
+ port_override = None
787
+ if sep:
788
+ base_path = f"/{path_tail}".rstrip("/")
789
+ elif "/" in remainder:
790
+ _, _, path_tail = remainder.partition("/")
791
+ base_path = f"/{path_tail}".rstrip("/")
792
+ formatted_host = f"[{core_host}]"
793
+ else:
794
+ if "/" in host:
795
+ host_only, _, path_tail = host.partition("/")
796
+ formatted_host = host_only or host
797
+ base_path = f"/{path_tail}".rstrip("/")
798
+ try:
799
+ ip_obj = ipaddress.ip_address(formatted_host)
800
+ except ValueError:
801
+ parts = formatted_host.rsplit(":", 1)
802
+ if len(parts) == 2 and parts[1].isdigit():
803
+ formatted_host = parts[0]
804
+ port_override = int(parts[1])
805
+ try:
806
+ ip_obj = ipaddress.ip_address(formatted_host)
807
+ except ValueError:
808
+ ip_obj = None
809
+ else:
810
+ if ip_obj.version == 6 and not formatted_host.startswith("["):
811
+ formatted_host = f"[{formatted_host}]"
812
+
813
+ effective_port = port_override if port_override is not None else default_port
814
+ combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
815
+
816
+ for scheme, scheme_default_port in (("https", 443), ("http", 80)):
817
+ base = f"{scheme}://{formatted_host}"
818
+ if effective_port and (
819
+ port_override is not None or effective_port != scheme_default_port
820
+ ):
821
+ explicit = f"{base}:{effective_port}{combined_path}"
822
+ if explicit not in seen:
823
+ seen.add(explicit)
824
+ yield explicit
825
+ candidate = f"{base}{combined_path}"
699
826
  if candidate not in seen:
700
827
  seen.add(candidate)
701
828
  yield candidate
@@ -874,6 +1001,7 @@ class NodeAdmin(EntityModelAdmin):
874
1001
  def _render_rfid_sync(self, request, operation, results, setup_error=None):
875
1002
  titles = {
876
1003
  "import": _("Import RFID results"),
1004
+ "fetch": _("Fetch RFID results"),
877
1005
  "export": _("Export RFID results"),
878
1006
  }
879
1007
  summary = self._summarize_rfid_results(results)
@@ -983,13 +1111,14 @@ class NodeAdmin(EntityModelAdmin):
983
1111
  result["status"] = self._status_from_result(result)
984
1112
  return result
985
1113
 
986
- @admin.action(description=_("Import RFIDs from selected"))
987
- def import_rfids_from_selected(self, request, queryset):
1114
+ def _run_rfid_import(self, request, queryset):
988
1115
  nodes = list(queryset)
989
1116
  local_node, private_key, error = self._load_local_node_credentials()
990
1117
  if error:
991
1118
  results = [self._skip_result(node, error) for node in nodes]
992
- return self._render_rfid_sync(request, "import", results, setup_error=error)
1119
+ return self._render_rfid_sync(
1120
+ request, "import", results, setup_error=error
1121
+ )
993
1122
 
994
1123
  if not nodes:
995
1124
  return self._render_rfid_sync(
@@ -1019,6 +1148,10 @@ class NodeAdmin(EntityModelAdmin):
1019
1148
 
1020
1149
  return self._render_rfid_sync(request, "import", results)
1021
1150
 
1151
+ @admin.action(description=_("Import RFIDs from selected"))
1152
+ def import_rfids_from_selected(self, request, queryset):
1153
+ return self._run_rfid_import(request, queryset)
1154
+
1022
1155
  @admin.action(description=_("Export RFIDs to selected"))
1023
1156
  def export_rfids_to_selected(self, request, queryset):
1024
1157
  nodes = list(queryset)
nodes/apps.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
  import os
3
3
  import socket
4
+ import sys
4
5
  import threading
5
6
  import time
6
7
  from pathlib import Path
@@ -66,6 +67,10 @@ def _startup_notification() -> None:
66
67
  def _trigger_startup_notification(**_: object) -> None:
67
68
  """Attempt to send the startup notification in the background."""
68
69
 
70
+ if _is_running_migration_command():
71
+ logger.debug("Startup notification skipped: running migration command")
72
+ return
73
+
69
74
  try:
70
75
  connections["default"].ensure_connection()
71
76
  except OperationalError:
@@ -74,6 +79,12 @@ def _trigger_startup_notification(**_: object) -> None:
74
79
  _startup_notification()
75
80
 
76
81
 
82
+ def _is_running_migration_command() -> bool:
83
+ """Return ``True`` when Django's ``migrate`` command is executing."""
84
+
85
+ return len(sys.argv) > 1 and sys.argv[1] == "migrate"
86
+
87
+
77
88
  class NodesConfig(AppConfig):
78
89
  default_auto_field = "django.db.models.BigAutoField"
79
90
  name = "nodes"
nodes/models.py CHANGED
@@ -16,7 +16,7 @@ import base64
16
16
  from django.utils import timezone
17
17
  from django.utils.text import slugify
18
18
  from django.conf import settings
19
- from datetime import timedelta
19
+ from datetime import datetime, timedelta, timezone as datetime_timezone
20
20
  import uuid
21
21
  import os
22
22
  import shutil
@@ -481,7 +481,24 @@ class Node(Entity):
481
481
  security_dir.mkdir(parents=True, exist_ok=True)
482
482
  priv_path = security_dir / f"{self.public_endpoint}"
483
483
  pub_path = security_dir / f"{self.public_endpoint}.pub"
484
- if not priv_path.exists() or not pub_path.exists():
484
+ regenerate = not priv_path.exists() or not pub_path.exists()
485
+ if not regenerate:
486
+ key_max_age = getattr(settings, "NODE_KEY_MAX_AGE", timedelta(days=90))
487
+ if key_max_age is not None:
488
+ try:
489
+ priv_mtime = datetime.fromtimestamp(
490
+ priv_path.stat().st_mtime, tz=datetime_timezone.utc
491
+ )
492
+ pub_mtime = datetime.fromtimestamp(
493
+ pub_path.stat().st_mtime, tz=datetime_timezone.utc
494
+ )
495
+ except OSError:
496
+ regenerate = True
497
+ else:
498
+ cutoff = timezone.now() - key_max_age
499
+ if priv_mtime < cutoff or pub_mtime < cutoff:
500
+ regenerate = True
501
+ if regenerate:
485
502
  private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
486
503
  private_bytes = private_key.private_bytes(
487
504
  encoding=serialization.Encoding.PEM,
@@ -494,8 +511,10 @@ class Node(Entity):
494
511
  )
495
512
  priv_path.write_bytes(private_bytes)
496
513
  pub_path.write_bytes(public_bytes)
497
- self.public_key = public_bytes.decode()
498
- self.save(update_fields=["public_key"])
514
+ public_text = public_bytes.decode()
515
+ if self.public_key != public_text:
516
+ self.public_key = public_text
517
+ self.save(update_fields=["public_key"])
499
518
  elif not self.public_key:
500
519
  self.public_key = pub_path.read_text()
501
520
  self.save(update_fields=["public_key"])
@@ -1088,8 +1107,8 @@ class NodeManager(Profile):
1088
1107
  )
1089
1108
 
1090
1109
  class Meta:
1091
- verbose_name = "Node Manager"
1092
- verbose_name_plural = "Node Managers"
1110
+ verbose_name = "Node Profile"
1111
+ verbose_name_plural = "Node Profiles"
1093
1112
 
1094
1113
  def __str__(self) -> str:
1095
1114
  owner = self.owner_display()
@@ -1542,6 +1561,7 @@ class NetMessage(Entity):
1542
1561
  payload = attachments if attachments is not None else self.attachments or []
1543
1562
  if not payload:
1544
1563
  return
1564
+
1545
1565
  try:
1546
1566
  objects = list(
1547
1567
  serializers.deserialize(