arthexis 0.1.21__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.

core/tests.py CHANGED
@@ -1969,7 +1969,7 @@ class PackageReleaseAdminActionTests(TestCase):
1969
1969
 
1970
1970
  @mock.patch("core.admin.PackageRelease.dump_fixture")
1971
1971
  @mock.patch("core.admin.requests.get")
1972
- def test_refresh_from_pypi_creates_releases(self, mock_get, dump):
1972
+ def test_refresh_from_pypi_reports_missing_releases(self, mock_get, dump):
1973
1973
  mock_get.return_value.raise_for_status.return_value = None
1974
1974
  mock_get.return_value.json.return_value = {
1975
1975
  "releases": {
@@ -1982,13 +1982,17 @@ class PackageReleaseAdminActionTests(TestCase):
1982
1982
  }
1983
1983
  }
1984
1984
  self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1985
- new_release = PackageRelease.objects.get(version="1.1.0")
1986
- self.assertEqual(new_release.revision, "")
1987
- self.assertEqual(
1988
- new_release.release_on,
1989
- datetime(2024, 2, 2, 15, 45, tzinfo=datetime_timezone.utc),
1985
+ self.assertFalse(
1986
+ PackageRelease.objects.filter(version="1.1.0").exists()
1987
+ )
1988
+ dump.assert_not_called()
1989
+ self.assertIn(
1990
+ (
1991
+ "Manual creation required for 1 release: 1.1.0",
1992
+ messages.WARNING,
1993
+ ),
1994
+ self.messages,
1990
1995
  )
1991
- dump.assert_called_once()
1992
1996
 
1993
1997
  @mock.patch("core.admin.PackageRelease.dump_fixture")
1994
1998
  @mock.patch("core.admin.requests.get")
@@ -2212,13 +2216,97 @@ class TodoDoneTests(TestCase):
2212
2216
  User.objects.create_superuser("admin", "admin@example.com", "pw")
2213
2217
  self.client.force_login(User.objects.get(username="admin"))
2214
2218
 
2215
- def test_mark_done_sets_timestamp(self):
2219
+ @mock.patch("core.models.revision_utils.get_revision", return_value="rev123")
2220
+ def test_mark_done_sets_timestamp(self, _get_revision):
2216
2221
  todo = Todo.objects.create(request="Task", is_seed_data=True)
2217
2222
  resp = self.client.post(reverse("todo-done", args=[todo.pk]))
2218
2223
  self.assertRedirects(resp, reverse("admin:index"))
2219
2224
  todo.refresh_from_db()
2220
2225
  self.assertIsNotNone(todo.done_on)
2221
2226
  self.assertFalse(todo.is_deleted)
2227
+ self.assertIsNone(todo.done_node)
2228
+ version_path = Path(settings.BASE_DIR) / "VERSION"
2229
+ expected_version = ""
2230
+ if version_path.exists():
2231
+ expected_version = version_path.read_text(encoding="utf-8").strip()
2232
+ self.assertEqual(todo.done_version, expected_version)
2233
+ self.assertEqual(todo.done_revision, "rev123")
2234
+ self.assertEqual(todo.done_username, "admin")
2235
+
2236
+ def test_mark_done_updates_seed_fixture(self):
2237
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2238
+ with tempfile.TemporaryDirectory() as tmp:
2239
+ base = Path(tmp)
2240
+ fixture_dir = base / "core" / "fixtures"
2241
+ fixture_dir.mkdir(parents=True)
2242
+ fixture_path = fixture_dir / "todo__task.json"
2243
+ fixture_path.write_text(
2244
+ json.dumps(
2245
+ [
2246
+ {
2247
+ "model": "core.todo",
2248
+ "fields": {
2249
+ "request": "Task",
2250
+ "url": "",
2251
+ "request_details": "",
2252
+ },
2253
+ }
2254
+ ],
2255
+ indent=2,
2256
+ )
2257
+ + "\n",
2258
+ encoding="utf-8",
2259
+ )
2260
+
2261
+ with override_settings(BASE_DIR=base):
2262
+ with mock.patch(
2263
+ "core.models.revision_utils.get_revision", return_value="rev456"
2264
+ ):
2265
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
2266
+
2267
+ self.assertRedirects(resp, reverse("admin:index"))
2268
+ data = json.loads(fixture_path.read_text(encoding="utf-8"))
2269
+ self.assertEqual(len(data), 1)
2270
+ fields = data[0]["fields"]
2271
+ self.assertIn("done_on", fields)
2272
+ self.assertTrue(fields["done_on"])
2273
+ self.assertFalse(fields.get("is_deleted", False))
2274
+ self.assertIn("done_version", fields)
2275
+ self.assertEqual(fields.get("done_revision"), "rev456")
2276
+ self.assertEqual(fields.get("done_username"), "admin")
2277
+
2278
+ def test_soft_delete_updates_seed_fixture(self):
2279
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2280
+ with tempfile.TemporaryDirectory() as tmp:
2281
+ base = Path(tmp)
2282
+ fixture_dir = base / "core" / "fixtures"
2283
+ fixture_dir.mkdir(parents=True)
2284
+ fixture_path = fixture_dir / "todo__task.json"
2285
+ fixture_path.write_text(
2286
+ json.dumps(
2287
+ [
2288
+ {
2289
+ "model": "core.todo",
2290
+ "fields": {
2291
+ "request": "Task",
2292
+ "url": "",
2293
+ "request_details": "",
2294
+ },
2295
+ }
2296
+ ],
2297
+ indent=2,
2298
+ )
2299
+ + "\n",
2300
+ encoding="utf-8",
2301
+ )
2302
+
2303
+ with override_settings(BASE_DIR=base):
2304
+ todo.delete()
2305
+
2306
+ data = json.loads(fixture_path.read_text(encoding="utf-8"))
2307
+ self.assertEqual(len(data), 1)
2308
+ fields = data[0]["fields"]
2309
+ self.assertTrue(fields.get("is_deleted"))
2222
2310
 
2223
2311
  def test_mark_done_missing_task_refreshes(self):
2224
2312
  todo = Todo.objects.create(request="Task", is_seed_data=True)
@@ -2324,6 +2412,61 @@ class TodoDoneTests(TestCase):
2324
2412
  self.assertTrue(todo.is_seed_data)
2325
2413
 
2326
2414
 
2415
+ class TodoDeleteTests(TestCase):
2416
+ def setUp(self):
2417
+ self.client = Client()
2418
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
2419
+ self.client.force_login(User.objects.get(username="admin"))
2420
+
2421
+ def test_delete_marks_task_deleted(self):
2422
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2423
+ resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
2424
+ self.assertRedirects(resp, reverse("admin:index"))
2425
+ todo.refresh_from_db()
2426
+ self.assertTrue(todo.is_deleted)
2427
+ self.assertIsNone(todo.done_on)
2428
+
2429
+ def test_delete_updates_seed_fixture(self):
2430
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
2431
+ with tempfile.TemporaryDirectory() as tmp:
2432
+ base = Path(tmp)
2433
+ fixture_dir = base / "core" / "fixtures"
2434
+ fixture_dir.mkdir(parents=True)
2435
+ fixture_path = fixture_dir / "todo__task.json"
2436
+ fixture_path.write_text(
2437
+ json.dumps(
2438
+ [
2439
+ {
2440
+ "model": "core.todo",
2441
+ "fields": {
2442
+ "request": "Task",
2443
+ "url": "",
2444
+ "request_details": "",
2445
+ },
2446
+ }
2447
+ ],
2448
+ indent=2,
2449
+ )
2450
+ + "\n",
2451
+ encoding="utf-8",
2452
+ )
2453
+
2454
+ with override_settings(BASE_DIR=base):
2455
+ resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
2456
+ self.assertRedirects(resp, reverse("admin:index"))
2457
+ data = json.loads(fixture_path.read_text(encoding="utf-8"))
2458
+ self.assertEqual(len(data), 1)
2459
+ fields = data[0]["fields"]
2460
+ self.assertTrue(fields.get("is_deleted"))
2461
+
2462
+ def test_delete_missing_task_redirects(self):
2463
+ todo = Todo.objects.create(request="Task")
2464
+ todo.is_deleted = True
2465
+ todo.save(update_fields=["is_deleted"])
2466
+ resp = self.client.post(reverse("todo-delete", args=[todo.pk]))
2467
+ self.assertRedirects(resp, reverse("admin:index"))
2468
+
2469
+
2327
2470
  class TodoFocusViewTests(TestCase):
2328
2471
  def setUp(self):
2329
2472
  self.client = Client()
@@ -2341,6 +2484,7 @@ class TodoFocusViewTests(TestCase):
2341
2484
  self.assertEqual(resp["X-Frame-Options"], "SAMEORIGIN")
2342
2485
  self.assertContains(resp, f'src="{todo.url}"')
2343
2486
  self.assertContains(resp, "Done")
2487
+ self.assertContains(resp, "Delete")
2344
2488
  self.assertContains(resp, "Back")
2345
2489
  self.assertContains(resp, "Take Snapshot")
2346
2490
  snapshot_url = reverse("todo-snapshot", args=[todo.pk])
core/views.py CHANGED
@@ -365,6 +365,7 @@ def request_temp_password(request):
365
365
  )
366
366
 
367
367
 
368
+ @staff_member_required
368
369
  @require_GET
369
370
  def version_info(request):
370
371
  """Return the running application version and Git revision."""
@@ -495,6 +496,16 @@ def _sync_with_origin_main(log_path: Path) -> None:
495
496
  if stderr:
496
497
  _append_log(log_path, "git errors:\n" + stderr)
497
498
 
499
+ status = subprocess.run(
500
+ ["git", "status"], capture_output=True, text=True, check=False
501
+ )
502
+ status_output = (status.stdout or "").strip()
503
+ status_errors = (status.stderr or "").strip()
504
+ if status_output:
505
+ _append_log(log_path, "git status:\n" + status_output)
506
+ if status_errors:
507
+ _append_log(log_path, "git status errors:\n" + status_errors)
508
+
498
509
  branch = _current_branch() or "(detached HEAD)"
499
510
  instructions = [
500
511
  "Manual intervention required to finish syncing with origin/main.",
@@ -2360,6 +2371,7 @@ def todo_focus(request, pk: int):
2360
2371
  "focus_auth": focus_auth,
2361
2372
  "next_url": _get_return_url(request),
2362
2373
  "done_url": reverse("todo-done", args=[todo.pk]),
2374
+ "delete_url": reverse("todo-delete", args=[todo.pk]),
2363
2375
  "snapshot_url": reverse("todo-snapshot", args=[todo.pk]),
2364
2376
  }
2365
2377
  return render(request, "core/todo_focus.html", context)
@@ -2378,7 +2390,29 @@ def todo_done(request, pk: int):
2378
2390
  messages.error(request, _format_condition_failure(todo, result))
2379
2391
  return redirect(redirect_to)
2380
2392
  todo.done_on = timezone.now()
2381
- todo.save(update_fields=["done_on"])
2393
+ todo.populate_done_metadata(request.user)
2394
+ todo.save(
2395
+ update_fields=[
2396
+ "done_on",
2397
+ "done_node",
2398
+ "done_version",
2399
+ "done_revision",
2400
+ "done_username",
2401
+ ]
2402
+ )
2403
+ return redirect(redirect_to)
2404
+
2405
+
2406
+ @staff_member_required
2407
+ @require_POST
2408
+ def todo_delete(request, pk: int):
2409
+ redirect_to = reverse("admin:index")
2410
+ try:
2411
+ todo = Todo.objects.get(pk=pk, is_deleted=False)
2412
+ except Todo.DoesNotExist:
2413
+ return redirect(redirect_to)
2414
+ todo.is_deleted = True
2415
+ todo.save(update_fields=["is_deleted"])
2382
2416
  return redirect(redirect_to)
2383
2417
 
2384
2418
 
nodes/admin.py CHANGED
@@ -18,8 +18,10 @@ from django.utils.dateparse import parse_datetime
18
18
  from django.utils.html import format_html, format_html_join
19
19
  from django.utils.translation import gettext_lazy as _
20
20
  from pathlib import Path
21
- from urllib.parse import urlsplit, urlunsplit
21
+ from types import SimpleNamespace
22
+ from urllib.parse import urlparse, urlsplit, urlunsplit
22
23
  import base64
24
+ import ipaddress
23
25
  import json
24
26
  import subprocess
25
27
  import uuid
@@ -85,7 +87,7 @@ class NodeFeatureAssignmentInline(admin.TabularInline):
85
87
 
86
88
  class DeployDNSRecordsForm(forms.Form):
87
89
  manager = forms.ModelChoiceField(
88
- label="Node Manager",
90
+ label="Node Profile",
89
91
  queryset=NodeManager.objects.none(),
90
92
  help_text="Credentials used to authenticate with the DNS provider.",
91
93
  )
@@ -234,6 +236,7 @@ class NodeAdmin(EntityModelAdmin):
234
236
  "role",
235
237
  "relation",
236
238
  "last_seen",
239
+ "visit_link",
237
240
  "proxy_link",
238
241
  )
239
242
  search_fields = ("hostname", "address", "mac_address")
@@ -284,7 +287,6 @@ class NodeAdmin(EntityModelAdmin):
284
287
  "register_visitor",
285
288
  "run_task",
286
289
  "take_screenshots",
287
- "fetch_rfids_from_selected",
288
290
  "import_rfids_from_selected",
289
291
  "export_rfids_to_selected",
290
292
  ]
@@ -304,6 +306,49 @@ class NodeAdmin(EntityModelAdmin):
304
306
  return ""
305
307
  return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
306
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
+
307
352
  def get_urls(self):
308
353
  urls = super().get_urls()
309
354
  custom = [
@@ -598,6 +643,17 @@ class NodeAdmin(EntityModelAdmin):
598
643
  setattr(node, field, value)
599
644
  changed.append(field)
600
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
+
601
657
  node.last_seen = timezone.now()
602
658
  if "last_seen" not in changed:
603
659
  changed.append("last_seen")
@@ -677,40 +733,96 @@ class NodeAdmin(EntityModelAdmin):
677
733
  return {"ok": False, "message": last_error or "Unable to reach remote node."}
678
734
 
679
735
  def _iter_remote_urls(self, node, path):
680
- host_candidates = []
736
+ host_candidates: list[str] = []
681
737
  for attr in ("public_endpoint", "address", "hostname"):
682
738
  value = getattr(node, attr, "") or ""
683
- value = value.strip()
684
- if value and value not in host_candidates:
685
- host_candidates.append(value)
739
+ cleaned = value.strip()
740
+ if cleaned and cleaned not in host_candidates:
741
+ host_candidates.append(cleaned)
686
742
 
687
- port = node.port or 8000
743
+ default_port = node.port or 8000
688
744
  normalized_path = path if path.startswith("/") else f"/{path}"
689
- seen = set()
745
+ seen: set[str] = set()
690
746
 
691
747
  for host in host_candidates:
748
+ base_path = ""
692
749
  formatted_host = host
693
- if ":" in host and not host.startswith("["):
694
- formatted_host = f"[{host}]"
695
-
696
- candidates = []
697
- if port == 80:
698
- candidates = [
699
- f"http://{formatted_host}{normalized_path}",
700
- f"https://{formatted_host}{normalized_path}",
701
- ]
702
- elif port == 443:
703
- candidates = [
704
- f"https://{formatted_host}{normalized_path}",
705
- f"http://{formatted_host}:{port}{normalized_path}",
706
- ]
707
- else:
708
- candidates = [
709
- f"http://{formatted_host}:{port}{normalized_path}",
710
- f"https://{formatted_host}:{port}{normalized_path}",
711
- ]
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
712
774
 
713
- 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}"
714
826
  if candidate not in seen:
715
827
  seen.add(candidate)
716
828
  yield candidate
@@ -999,17 +1111,19 @@ class NodeAdmin(EntityModelAdmin):
999
1111
  result["status"] = self._status_from_result(result)
1000
1112
  return result
1001
1113
 
1002
- def _run_rfid_fetch(self, request, queryset, *, operation):
1114
+ def _run_rfid_import(self, request, queryset):
1003
1115
  nodes = list(queryset)
1004
1116
  local_node, private_key, error = self._load_local_node_credentials()
1005
1117
  if error:
1006
1118
  results = [self._skip_result(node, error) for node in nodes]
1007
- return self._render_rfid_sync(request, operation, results, setup_error=error)
1119
+ return self._render_rfid_sync(
1120
+ request, "import", results, setup_error=error
1121
+ )
1008
1122
 
1009
1123
  if not nodes:
1010
1124
  return self._render_rfid_sync(
1011
1125
  request,
1012
- operation,
1126
+ "import",
1013
1127
  [],
1014
1128
  setup_error=_("No nodes selected."),
1015
1129
  )
@@ -1032,15 +1146,11 @@ class NodeAdmin(EntityModelAdmin):
1032
1146
  continue
1033
1147
  results.append(self._process_import_from_node(node, payload, headers))
1034
1148
 
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")
1149
+ return self._render_rfid_sync(request, "import", results)
1040
1150
 
1041
1151
  @admin.action(description=_("Import RFIDs from selected"))
1042
1152
  def import_rfids_from_selected(self, request, queryset):
1043
- return self._run_rfid_fetch(request, queryset, operation="import")
1153
+ return self._run_rfid_import(request, queryset)
1044
1154
 
1045
1155
  @admin.action(description=_("Export RFIDs to selected"))
1046
1156
  def export_rfids_to_selected(self, request, 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(