arthexis 0.1.21__py3-none-any.whl → 0.1.23__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.
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/METADATA +9 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/RECORD +33 -33
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +224 -32
- core/environment.py +2 -239
- core/models.py +903 -65
- core/release.py +0 -5
- core/system.py +76 -0
- core/tests.py +181 -9
- core/user_data.py +42 -2
- core/views.py +68 -27
- nodes/admin.py +211 -60
- nodes/apps.py +11 -0
- nodes/models.py +35 -7
- nodes/tests.py +288 -1
- nodes/views.py +101 -48
- ocpp/admin.py +32 -2
- ocpp/consumers.py +1 -0
- ocpp/models.py +52 -3
- ocpp/tasks.py +99 -1
- ocpp/tests.py +350 -2
- ocpp/views.py +300 -6
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +386 -28
- pages/urls.py +10 -0
- pages/views.py +347 -18
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/top_level.txt +0 -0
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
|
|
21
|
+
from types import SimpleNamespace
|
|
22
|
+
from urllib.parse import urljoin, 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
|
|
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 = [
|
|
@@ -395,6 +440,12 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
395
440
|
},
|
|
396
441
|
"target": reverse("admin:index"),
|
|
397
442
|
}
|
|
443
|
+
mac_address = str(local_node.mac_address or "").strip()
|
|
444
|
+
if mac_address:
|
|
445
|
+
payload["requester_mac"] = mac_address
|
|
446
|
+
public_key = local_node.public_key
|
|
447
|
+
if public_key:
|
|
448
|
+
payload["requester_public_key"] = public_key
|
|
398
449
|
return payload
|
|
399
450
|
|
|
400
451
|
def _start_proxy_session(self, request, node):
|
|
@@ -429,29 +480,64 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
429
480
|
}
|
|
430
481
|
|
|
431
482
|
last_error = ""
|
|
483
|
+
redirect_codes = {301, 302, 303, 307, 308}
|
|
484
|
+
|
|
432
485
|
for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
486
|
+
candidate_url = url
|
|
487
|
+
redirects_followed = 0
|
|
488
|
+
success = False
|
|
489
|
+
|
|
490
|
+
while True:
|
|
491
|
+
try:
|
|
492
|
+
response = requests.post(
|
|
493
|
+
candidate_url,
|
|
494
|
+
data=body,
|
|
495
|
+
headers=headers,
|
|
496
|
+
timeout=5,
|
|
497
|
+
allow_redirects=False,
|
|
498
|
+
)
|
|
499
|
+
except RequestException as exc:
|
|
500
|
+
last_error = str(exc)
|
|
501
|
+
break
|
|
502
|
+
|
|
503
|
+
if response.status_code in redirect_codes:
|
|
504
|
+
location = response.headers.get("Location")
|
|
505
|
+
if not location:
|
|
506
|
+
last_error = f"{response.status_code} redirect missing Location header"
|
|
507
|
+
break
|
|
508
|
+
|
|
509
|
+
redirects_followed += 1
|
|
510
|
+
if redirects_followed > 3:
|
|
511
|
+
last_error = "Too many redirects"
|
|
512
|
+
break
|
|
513
|
+
|
|
514
|
+
candidate_url = urljoin(candidate_url, location)
|
|
515
|
+
continue
|
|
516
|
+
|
|
517
|
+
if not response.ok:
|
|
518
|
+
last_error = f"{response.status_code} {response.text}"
|
|
519
|
+
break
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
data = response.json()
|
|
523
|
+
except ValueError:
|
|
524
|
+
last_error = "Invalid JSON response"
|
|
525
|
+
break
|
|
526
|
+
|
|
527
|
+
login_url = data.get("login_url")
|
|
528
|
+
if not login_url:
|
|
529
|
+
last_error = "login_url missing"
|
|
530
|
+
break
|
|
531
|
+
|
|
532
|
+
success = True
|
|
533
|
+
break
|
|
534
|
+
|
|
535
|
+
if success:
|
|
536
|
+
return {
|
|
537
|
+
"ok": True,
|
|
538
|
+
"login_url": login_url,
|
|
539
|
+
"expires": data.get("expires"),
|
|
540
|
+
}
|
|
455
541
|
|
|
456
542
|
return {
|
|
457
543
|
"ok": False,
|
|
@@ -598,6 +684,17 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
598
684
|
setattr(node, field, value)
|
|
599
685
|
changed.append(field)
|
|
600
686
|
|
|
687
|
+
role_value = payload.get("role") or payload.get("role_name")
|
|
688
|
+
if role_value is not None:
|
|
689
|
+
role_name = str(role_value).strip()
|
|
690
|
+
if role_name:
|
|
691
|
+
desired_role = NodeRole.objects.filter(name=role_name).first()
|
|
692
|
+
else:
|
|
693
|
+
desired_role = None
|
|
694
|
+
if desired_role and node.role_id != desired_role.id:
|
|
695
|
+
node.role = desired_role
|
|
696
|
+
changed.append("role")
|
|
697
|
+
|
|
601
698
|
node.last_seen = timezone.now()
|
|
602
699
|
if "last_seen" not in changed:
|
|
603
700
|
changed.append("last_seen")
|
|
@@ -677,40 +774,96 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
677
774
|
return {"ok": False, "message": last_error or "Unable to reach remote node."}
|
|
678
775
|
|
|
679
776
|
def _iter_remote_urls(self, node, path):
|
|
680
|
-
host_candidates = []
|
|
777
|
+
host_candidates: list[str] = []
|
|
681
778
|
for attr in ("public_endpoint", "address", "hostname"):
|
|
682
779
|
value = getattr(node, attr, "") or ""
|
|
683
|
-
|
|
684
|
-
if
|
|
685
|
-
host_candidates.append(
|
|
780
|
+
cleaned = value.strip()
|
|
781
|
+
if cleaned and cleaned not in host_candidates:
|
|
782
|
+
host_candidates.append(cleaned)
|
|
686
783
|
|
|
687
|
-
|
|
784
|
+
default_port = node.port or 8000
|
|
688
785
|
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
689
|
-
seen = set()
|
|
786
|
+
seen: set[str] = set()
|
|
690
787
|
|
|
691
788
|
for host in host_candidates:
|
|
789
|
+
base_path = ""
|
|
692
790
|
formatted_host = host
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
f"
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
791
|
+
port_override: int | None = None
|
|
792
|
+
|
|
793
|
+
if "://" in host:
|
|
794
|
+
parsed = urlparse(host)
|
|
795
|
+
netloc = parsed.netloc or parsed.path
|
|
796
|
+
base_path = (parsed.path or "").rstrip("/")
|
|
797
|
+
combined_path = (
|
|
798
|
+
f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
799
|
+
)
|
|
800
|
+
primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
|
|
801
|
+
if primary not in seen:
|
|
802
|
+
seen.add(primary)
|
|
803
|
+
yield primary
|
|
804
|
+
if parsed.scheme == "https":
|
|
805
|
+
fallback = urlunsplit(("http", netloc, combined_path, "", ""))
|
|
806
|
+
if fallback not in seen:
|
|
807
|
+
seen.add(fallback)
|
|
808
|
+
yield fallback
|
|
809
|
+
elif parsed.scheme == "http":
|
|
810
|
+
alternate = urlunsplit(("https", netloc, combined_path, "", ""))
|
|
811
|
+
if alternate not in seen:
|
|
812
|
+
seen.add(alternate)
|
|
813
|
+
yield alternate
|
|
814
|
+
continue
|
|
712
815
|
|
|
713
|
-
|
|
816
|
+
if host.startswith("[") and "]" in host:
|
|
817
|
+
end = host.index("]")
|
|
818
|
+
core_host = host[1:end]
|
|
819
|
+
remainder = host[end + 1 :]
|
|
820
|
+
if remainder.startswith(":"):
|
|
821
|
+
remainder = remainder[1:]
|
|
822
|
+
port_part, sep, path_tail = remainder.partition("/")
|
|
823
|
+
if port_part:
|
|
824
|
+
try:
|
|
825
|
+
port_override = int(port_part)
|
|
826
|
+
except ValueError:
|
|
827
|
+
port_override = None
|
|
828
|
+
if sep:
|
|
829
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
830
|
+
elif "/" in remainder:
|
|
831
|
+
_, _, path_tail = remainder.partition("/")
|
|
832
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
833
|
+
formatted_host = f"[{core_host}]"
|
|
834
|
+
else:
|
|
835
|
+
if "/" in host:
|
|
836
|
+
host_only, _, path_tail = host.partition("/")
|
|
837
|
+
formatted_host = host_only or host
|
|
838
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
839
|
+
try:
|
|
840
|
+
ip_obj = ipaddress.ip_address(formatted_host)
|
|
841
|
+
except ValueError:
|
|
842
|
+
parts = formatted_host.rsplit(":", 1)
|
|
843
|
+
if len(parts) == 2 and parts[1].isdigit():
|
|
844
|
+
formatted_host = parts[0]
|
|
845
|
+
port_override = int(parts[1])
|
|
846
|
+
try:
|
|
847
|
+
ip_obj = ipaddress.ip_address(formatted_host)
|
|
848
|
+
except ValueError:
|
|
849
|
+
ip_obj = None
|
|
850
|
+
else:
|
|
851
|
+
if ip_obj.version == 6 and not formatted_host.startswith("["):
|
|
852
|
+
formatted_host = f"[{formatted_host}]"
|
|
853
|
+
|
|
854
|
+
effective_port = port_override if port_override is not None else default_port
|
|
855
|
+
combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
856
|
+
|
|
857
|
+
for scheme, scheme_default_port in (("https", 443), ("http", 80)):
|
|
858
|
+
base = f"{scheme}://{formatted_host}"
|
|
859
|
+
if effective_port and (
|
|
860
|
+
port_override is not None or effective_port != scheme_default_port
|
|
861
|
+
):
|
|
862
|
+
explicit = f"{base}:{effective_port}{combined_path}"
|
|
863
|
+
if explicit not in seen:
|
|
864
|
+
seen.add(explicit)
|
|
865
|
+
yield explicit
|
|
866
|
+
candidate = f"{base}{combined_path}"
|
|
714
867
|
if candidate not in seen:
|
|
715
868
|
seen.add(candidate)
|
|
716
869
|
yield candidate
|
|
@@ -999,17 +1152,19 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
999
1152
|
result["status"] = self._status_from_result(result)
|
|
1000
1153
|
return result
|
|
1001
1154
|
|
|
1002
|
-
def
|
|
1155
|
+
def _run_rfid_import(self, request, queryset):
|
|
1003
1156
|
nodes = list(queryset)
|
|
1004
1157
|
local_node, private_key, error = self._load_local_node_credentials()
|
|
1005
1158
|
if error:
|
|
1006
1159
|
results = [self._skip_result(node, error) for node in nodes]
|
|
1007
|
-
return self._render_rfid_sync(
|
|
1160
|
+
return self._render_rfid_sync(
|
|
1161
|
+
request, "import", results, setup_error=error
|
|
1162
|
+
)
|
|
1008
1163
|
|
|
1009
1164
|
if not nodes:
|
|
1010
1165
|
return self._render_rfid_sync(
|
|
1011
1166
|
request,
|
|
1012
|
-
|
|
1167
|
+
"import",
|
|
1013
1168
|
[],
|
|
1014
1169
|
setup_error=_("No nodes selected."),
|
|
1015
1170
|
)
|
|
@@ -1032,15 +1187,11 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
1032
1187
|
continue
|
|
1033
1188
|
results.append(self._process_import_from_node(node, payload, headers))
|
|
1034
1189
|
|
|
1035
|
-
return self._render_rfid_sync(request,
|
|
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")
|
|
1190
|
+
return self._render_rfid_sync(request, "import", results)
|
|
1040
1191
|
|
|
1041
1192
|
@admin.action(description=_("Import RFIDs from selected"))
|
|
1042
1193
|
def import_rfids_from_selected(self, request, queryset):
|
|
1043
|
-
return self.
|
|
1194
|
+
return self._run_rfid_import(request, queryset)
|
|
1044
1195
|
|
|
1045
1196
|
@admin.action(description=_("Export RFIDs to selected"))
|
|
1046
1197
|
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
|
@@ -4,6 +4,7 @@ from collections.abc import Iterable
|
|
|
4
4
|
from copy import deepcopy
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from django.db import models
|
|
7
|
+
from django.db.models import Q
|
|
7
8
|
from django.db.utils import DatabaseError
|
|
8
9
|
from django.db.models.signals import post_delete
|
|
9
10
|
from django.dispatch import Signal, receiver
|
|
@@ -16,7 +17,7 @@ import base64
|
|
|
16
17
|
from django.utils import timezone
|
|
17
18
|
from django.utils.text import slugify
|
|
18
19
|
from django.conf import settings
|
|
19
|
-
from datetime import timedelta
|
|
20
|
+
from datetime import datetime, timedelta, timezone as datetime_timezone
|
|
20
21
|
import uuid
|
|
21
22
|
import os
|
|
22
23
|
import shutil
|
|
@@ -294,7 +295,14 @@ class Node(Entity):
|
|
|
294
295
|
"""Return the node representing the current host if it exists."""
|
|
295
296
|
mac = cls.get_current_mac()
|
|
296
297
|
try:
|
|
297
|
-
|
|
298
|
+
node = cls.objects.filter(mac_address__iexact=mac).first()
|
|
299
|
+
if node:
|
|
300
|
+
return node
|
|
301
|
+
return (
|
|
302
|
+
cls.objects.filter(current_relation=cls.Relation.SELF)
|
|
303
|
+
.filter(Q(mac_address__isnull=True) | Q(mac_address=""))
|
|
304
|
+
.first()
|
|
305
|
+
)
|
|
298
306
|
except DatabaseError:
|
|
299
307
|
logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
|
|
300
308
|
return None
|
|
@@ -481,7 +489,24 @@ class Node(Entity):
|
|
|
481
489
|
security_dir.mkdir(parents=True, exist_ok=True)
|
|
482
490
|
priv_path = security_dir / f"{self.public_endpoint}"
|
|
483
491
|
pub_path = security_dir / f"{self.public_endpoint}.pub"
|
|
484
|
-
|
|
492
|
+
regenerate = not priv_path.exists() or not pub_path.exists()
|
|
493
|
+
if not regenerate:
|
|
494
|
+
key_max_age = getattr(settings, "NODE_KEY_MAX_AGE", timedelta(days=90))
|
|
495
|
+
if key_max_age is not None:
|
|
496
|
+
try:
|
|
497
|
+
priv_mtime = datetime.fromtimestamp(
|
|
498
|
+
priv_path.stat().st_mtime, tz=datetime_timezone.utc
|
|
499
|
+
)
|
|
500
|
+
pub_mtime = datetime.fromtimestamp(
|
|
501
|
+
pub_path.stat().st_mtime, tz=datetime_timezone.utc
|
|
502
|
+
)
|
|
503
|
+
except OSError:
|
|
504
|
+
regenerate = True
|
|
505
|
+
else:
|
|
506
|
+
cutoff = timezone.now() - key_max_age
|
|
507
|
+
if priv_mtime < cutoff or pub_mtime < cutoff:
|
|
508
|
+
regenerate = True
|
|
509
|
+
if regenerate:
|
|
485
510
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
486
511
|
private_bytes = private_key.private_bytes(
|
|
487
512
|
encoding=serialization.Encoding.PEM,
|
|
@@ -494,8 +519,10 @@ class Node(Entity):
|
|
|
494
519
|
)
|
|
495
520
|
priv_path.write_bytes(private_bytes)
|
|
496
521
|
pub_path.write_bytes(public_bytes)
|
|
497
|
-
|
|
498
|
-
self.
|
|
522
|
+
public_text = public_bytes.decode()
|
|
523
|
+
if self.public_key != public_text:
|
|
524
|
+
self.public_key = public_text
|
|
525
|
+
self.save(update_fields=["public_key"])
|
|
499
526
|
elif not self.public_key:
|
|
500
527
|
self.public_key = pub_path.read_text()
|
|
501
528
|
self.save(update_fields=["public_key"])
|
|
@@ -1088,8 +1115,8 @@ class NodeManager(Profile):
|
|
|
1088
1115
|
)
|
|
1089
1116
|
|
|
1090
1117
|
class Meta:
|
|
1091
|
-
verbose_name = "Node
|
|
1092
|
-
verbose_name_plural = "Node
|
|
1118
|
+
verbose_name = "Node Profile"
|
|
1119
|
+
verbose_name_plural = "Node Profiles"
|
|
1093
1120
|
|
|
1094
1121
|
def __str__(self) -> str:
|
|
1095
1122
|
owner = self.owner_display()
|
|
@@ -1542,6 +1569,7 @@ class NetMessage(Entity):
|
|
|
1542
1569
|
payload = attachments if attachments is not None else self.attachments or []
|
|
1543
1570
|
if not payload:
|
|
1544
1571
|
return
|
|
1572
|
+
|
|
1545
1573
|
try:
|
|
1546
1574
|
objects = list(
|
|
1547
1575
|
serializers.deserialize(
|