arthexis 0.1.10__py3-none-any.whl → 0.1.12__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.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
- arthexis-0.1.12.dist-info/RECORD +102 -0
- config/context_processors.py +1 -0
- config/settings.py +31 -5
- config/urls.py +5 -4
- core/admin.py +430 -90
- core/apps.py +48 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +303 -31
- core/reference_utils.py +20 -9
- core/release.py +4 -0
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +250 -1
- core/tasks.py +92 -40
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +169 -3
- core/user_data.py +51 -8
- core/views.py +371 -20
- nodes/admin.py +453 -8
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +374 -31
- nodes/reports.py +411 -0
- nodes/tests.py +677 -38
- nodes/utils.py +32 -0
- nodes/views.py +14 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +517 -16
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +237 -4
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +819 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +233 -19
- pages/admin.py +144 -4
- pages/context_processors.py +21 -7
- pages/defaults.py +13 -0
- pages/forms.py +38 -0
- pages/models.py +189 -15
- pages/tests.py +281 -8
- pages/urls.py +4 -0
- pages/views.py +137 -21
- arthexis-0.1.10.dist-info/RECORD +0 -95
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
nodes/models.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from collections.abc import Iterable
|
|
4
|
+
from dataclasses import dataclass
|
|
2
5
|
from django.db import models
|
|
6
|
+
from django.db.utils import DatabaseError
|
|
3
7
|
from django.db.models.signals import post_delete
|
|
4
8
|
from django.dispatch import Signal, receiver
|
|
5
9
|
from core.entity import Entity
|
|
6
10
|
from core.models import Profile
|
|
7
|
-
from core.fields import SigilShortAutoField
|
|
11
|
+
from core.fields import SigilLongAutoField, SigilShortAutoField
|
|
8
12
|
import re
|
|
9
13
|
import json
|
|
10
14
|
import base64
|
|
@@ -65,6 +69,12 @@ class NodeFeatureManager(models.Manager):
|
|
|
65
69
|
return self.get(slug=slug)
|
|
66
70
|
|
|
67
71
|
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class NodeFeatureDefaultAction:
|
|
74
|
+
label: str
|
|
75
|
+
url_name: str
|
|
76
|
+
|
|
77
|
+
|
|
68
78
|
class NodeFeature(Entity):
|
|
69
79
|
"""Feature that may be enabled on nodes and roles."""
|
|
70
80
|
|
|
@@ -74,6 +84,24 @@ class NodeFeature(Entity):
|
|
|
74
84
|
|
|
75
85
|
objects = NodeFeatureManager()
|
|
76
86
|
|
|
87
|
+
DEFAULT_ACTIONS = {
|
|
88
|
+
"rfid-scanner": NodeFeatureDefaultAction(
|
|
89
|
+
label="Scan RFIDs", url_name="admin:core_rfid_scan"
|
|
90
|
+
),
|
|
91
|
+
"celery-queue": NodeFeatureDefaultAction(
|
|
92
|
+
label="Celery Report",
|
|
93
|
+
url_name="admin:nodes_nodefeature_celery_report",
|
|
94
|
+
),
|
|
95
|
+
"screenshot-poll": NodeFeatureDefaultAction(
|
|
96
|
+
label="Take Screenshot",
|
|
97
|
+
url_name="admin:nodes_nodefeature_take_screenshot",
|
|
98
|
+
),
|
|
99
|
+
"rpi-camera": NodeFeatureDefaultAction(
|
|
100
|
+
label="Take a Snapshot",
|
|
101
|
+
url_name="admin:nodes_nodefeature_take_snapshot",
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
class Meta:
|
|
78
106
|
ordering = ["display"]
|
|
79
107
|
verbose_name = "Node Feature"
|
|
@@ -113,6 +141,11 @@ class NodeFeature(Entity):
|
|
|
113
141
|
return (base_path / "locks" / lock).exists()
|
|
114
142
|
return False
|
|
115
143
|
|
|
144
|
+
def get_default_action(self) -> NodeFeatureDefaultAction | None:
|
|
145
|
+
"""Return the configured default action for this feature if any."""
|
|
146
|
+
|
|
147
|
+
return self.DEFAULT_ACTIONS.get(self.slug)
|
|
148
|
+
|
|
116
149
|
|
|
117
150
|
def get_terminal_role():
|
|
118
151
|
"""Return the NodeRole representing a Terminal if it exists."""
|
|
@@ -122,12 +155,29 @@ def get_terminal_role():
|
|
|
122
155
|
class Node(Entity):
|
|
123
156
|
"""Information about a running node in the network."""
|
|
124
157
|
|
|
158
|
+
DEFAULT_BADGE_COLOR = "#28a745"
|
|
159
|
+
ROLE_BADGE_COLORS = {
|
|
160
|
+
"Constellation": "#daa520", # goldenrod
|
|
161
|
+
"Control": "#673ab7", # deep purple
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
class Relation(models.TextChoices):
|
|
165
|
+
UPSTREAM = "UPSTREAM", "Upstream"
|
|
166
|
+
DOWNSTREAM = "DOWNSTREAM", "Downstream"
|
|
167
|
+
PEER = "PEER", "Peer"
|
|
168
|
+
SELF = "SELF", "Self"
|
|
169
|
+
|
|
125
170
|
hostname = models.CharField(max_length=100)
|
|
126
171
|
address = models.GenericIPAddressField()
|
|
127
172
|
mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
|
|
128
173
|
port = models.PositiveIntegerField(default=8000)
|
|
129
|
-
badge_color = models.CharField(max_length=7, default=
|
|
174
|
+
badge_color = models.CharField(max_length=7, default=DEFAULT_BADGE_COLOR)
|
|
130
175
|
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
176
|
+
current_relation = models.CharField(
|
|
177
|
+
max_length=10,
|
|
178
|
+
choices=Relation.choices,
|
|
179
|
+
default=Relation.PEER,
|
|
180
|
+
)
|
|
131
181
|
last_seen = models.DateTimeField(auto_now=True)
|
|
132
182
|
enable_public_api = models.BooleanField(
|
|
133
183
|
default=False,
|
|
@@ -166,7 +216,6 @@ class Node(Entity):
|
|
|
166
216
|
"rpi-camera",
|
|
167
217
|
"ap-router",
|
|
168
218
|
"ap-public-wifi",
|
|
169
|
-
"postgres-db",
|
|
170
219
|
}
|
|
171
220
|
MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
|
|
172
221
|
|
|
@@ -178,11 +227,35 @@ class Node(Entity):
|
|
|
178
227
|
"""Return the MAC address of the current host."""
|
|
179
228
|
return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
|
|
180
229
|
|
|
230
|
+
@classmethod
|
|
231
|
+
def normalize_relation(cls, value):
|
|
232
|
+
"""Normalize ``value`` to a valid :class:`Relation`."""
|
|
233
|
+
|
|
234
|
+
if isinstance(value, cls.Relation):
|
|
235
|
+
return value
|
|
236
|
+
if value is None:
|
|
237
|
+
return cls.Relation.PEER
|
|
238
|
+
text = str(value).strip()
|
|
239
|
+
if not text:
|
|
240
|
+
return cls.Relation.PEER
|
|
241
|
+
for relation in cls.Relation:
|
|
242
|
+
if text.lower() == relation.label.lower():
|
|
243
|
+
return relation
|
|
244
|
+
if text.upper() == relation.name:
|
|
245
|
+
return relation
|
|
246
|
+
if text.lower() == relation.value.lower():
|
|
247
|
+
return relation
|
|
248
|
+
return cls.Relation.PEER
|
|
249
|
+
|
|
181
250
|
@classmethod
|
|
182
251
|
def get_local(cls):
|
|
183
252
|
"""Return the node representing the current host if it exists."""
|
|
184
253
|
mac = cls.get_current_mac()
|
|
185
|
-
|
|
254
|
+
try:
|
|
255
|
+
return cls.objects.filter(mac_address=mac).first()
|
|
256
|
+
except DatabaseError:
|
|
257
|
+
logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
|
|
258
|
+
return None
|
|
186
259
|
|
|
187
260
|
@classmethod
|
|
188
261
|
def register_current(cls):
|
|
@@ -212,24 +285,29 @@ class Node(Entity):
|
|
|
212
285
|
"installed_revision": installed_revision,
|
|
213
286
|
"public_endpoint": slug,
|
|
214
287
|
"mac_address": mac,
|
|
288
|
+
"current_relation": cls.Relation.SELF,
|
|
215
289
|
}
|
|
290
|
+
role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
|
|
291
|
+
role_name = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
|
|
292
|
+
desired_role = NodeRole.objects.filter(name=role_name).first()
|
|
293
|
+
|
|
216
294
|
if node:
|
|
295
|
+
update_fields = []
|
|
217
296
|
for field, value in defaults.items():
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
297
|
+
if getattr(node, field) != value:
|
|
298
|
+
setattr(node, field, value)
|
|
299
|
+
update_fields.append(field)
|
|
300
|
+
if desired_role and node.role_id != desired_role.id:
|
|
301
|
+
node.role = desired_role
|
|
302
|
+
update_fields.append("role")
|
|
303
|
+
if update_fields:
|
|
304
|
+
node.save(update_fields=update_fields)
|
|
221
305
|
created = False
|
|
222
306
|
else:
|
|
223
307
|
node = cls.objects.create(**defaults)
|
|
224
308
|
created = True
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
role_name = (
|
|
228
|
-
role_lock.read_text().strip() if role_lock.exists() else "Terminal"
|
|
229
|
-
)
|
|
230
|
-
role = NodeRole.objects.filter(name=role_name).first()
|
|
231
|
-
if role:
|
|
232
|
-
node.role = role
|
|
309
|
+
if desired_role:
|
|
310
|
+
node.role = desired_role
|
|
233
311
|
node.save(update_fields=["role"])
|
|
234
312
|
if created and node.role is None:
|
|
235
313
|
terminal = NodeRole.objects.filter(name="Terminal").first()
|
|
@@ -374,6 +452,29 @@ class Node(Entity):
|
|
|
374
452
|
return self.mac_address == self.get_current_mac()
|
|
375
453
|
|
|
376
454
|
def save(self, *args, **kwargs):
|
|
455
|
+
role_name = None
|
|
456
|
+
role = getattr(self, "role", None)
|
|
457
|
+
if role and getattr(role, "name", None):
|
|
458
|
+
role_name = role.name
|
|
459
|
+
elif self.role_id:
|
|
460
|
+
role_name = (
|
|
461
|
+
NodeRole.objects.filter(pk=self.role_id)
|
|
462
|
+
.values_list("name", flat=True)
|
|
463
|
+
.first()
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
role_color = self.ROLE_BADGE_COLORS.get(role_name)
|
|
467
|
+
if role_color and (
|
|
468
|
+
not self.badge_color or self.badge_color == self.DEFAULT_BADGE_COLOR
|
|
469
|
+
):
|
|
470
|
+
self.badge_color = role_color
|
|
471
|
+
update_fields = kwargs.get("update_fields")
|
|
472
|
+
if update_fields:
|
|
473
|
+
fields = set(update_fields)
|
|
474
|
+
if "badge_color" not in fields:
|
|
475
|
+
fields.add("badge_color")
|
|
476
|
+
kwargs["update_fields"] = tuple(fields)
|
|
477
|
+
|
|
377
478
|
if self.mac_address:
|
|
378
479
|
self.mac_address = self.mac_address.lower()
|
|
379
480
|
if not self.public_endpoint:
|
|
@@ -486,13 +587,6 @@ class Node(Entity):
|
|
|
486
587
|
return True
|
|
487
588
|
return False
|
|
488
589
|
|
|
489
|
-
@staticmethod
|
|
490
|
-
def _uses_postgres() -> bool:
|
|
491
|
-
"""Return ``True`` when the default database uses PostgreSQL."""
|
|
492
|
-
|
|
493
|
-
engine = settings.DATABASES.get("default", {}).get("ENGINE", "")
|
|
494
|
-
return "postgresql" in engine.lower()
|
|
495
|
-
|
|
496
590
|
def refresh_features(self):
|
|
497
591
|
if not self.pk:
|
|
498
592
|
return
|
|
@@ -513,8 +607,6 @@ class Node(Entity):
|
|
|
513
607
|
detected_slugs.add("ap-public-wifi")
|
|
514
608
|
else:
|
|
515
609
|
detected_slugs.add("ap-router")
|
|
516
|
-
if self._uses_postgres():
|
|
517
|
-
detected_slugs.add("postgres-db")
|
|
518
610
|
try:
|
|
519
611
|
from core.notifications import supports_gui_toast
|
|
520
612
|
except Exception:
|
|
@@ -713,6 +805,243 @@ def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
|
|
|
713
805
|
node.sync_feature_tasks()
|
|
714
806
|
|
|
715
807
|
|
|
808
|
+
class NodeManager(Profile):
|
|
809
|
+
"""Credentials for interacting with external DNS providers."""
|
|
810
|
+
|
|
811
|
+
class Provider(models.TextChoices):
|
|
812
|
+
GODADDY = "godaddy", "GoDaddy"
|
|
813
|
+
|
|
814
|
+
profile_fields = (
|
|
815
|
+
"provider",
|
|
816
|
+
"api_key",
|
|
817
|
+
"api_secret",
|
|
818
|
+
"customer_id",
|
|
819
|
+
"default_domain",
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
provider = models.CharField(
|
|
823
|
+
max_length=20,
|
|
824
|
+
choices=Provider.choices,
|
|
825
|
+
default=Provider.GODADDY,
|
|
826
|
+
)
|
|
827
|
+
api_key = SigilShortAutoField(
|
|
828
|
+
max_length=255,
|
|
829
|
+
help_text="API key issued by the DNS provider.",
|
|
830
|
+
)
|
|
831
|
+
api_secret = SigilShortAutoField(
|
|
832
|
+
max_length=255,
|
|
833
|
+
help_text="API secret issued by the DNS provider.",
|
|
834
|
+
)
|
|
835
|
+
customer_id = SigilShortAutoField(
|
|
836
|
+
max_length=100,
|
|
837
|
+
blank=True,
|
|
838
|
+
help_text="Optional GoDaddy customer identifier for the account.",
|
|
839
|
+
)
|
|
840
|
+
default_domain = SigilShortAutoField(
|
|
841
|
+
max_length=253,
|
|
842
|
+
blank=True,
|
|
843
|
+
help_text="Fallback domain when records omit one.",
|
|
844
|
+
)
|
|
845
|
+
use_sandbox = models.BooleanField(
|
|
846
|
+
default=False,
|
|
847
|
+
help_text="Use the GoDaddy OTE (test) environment.",
|
|
848
|
+
)
|
|
849
|
+
is_enabled = models.BooleanField(
|
|
850
|
+
default=True,
|
|
851
|
+
help_text="Disable to prevent deployments with this manager.",
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
class Meta:
|
|
855
|
+
verbose_name = "Node Manager"
|
|
856
|
+
verbose_name_plural = "Node Managers"
|
|
857
|
+
|
|
858
|
+
def __str__(self) -> str:
|
|
859
|
+
owner = self.owner_display()
|
|
860
|
+
provider = self.get_provider_display()
|
|
861
|
+
if owner:
|
|
862
|
+
return f"{provider} ({owner})"
|
|
863
|
+
return provider
|
|
864
|
+
|
|
865
|
+
def clean(self):
|
|
866
|
+
if self.user_id or self.group_id:
|
|
867
|
+
super().clean()
|
|
868
|
+
else:
|
|
869
|
+
super(Profile, self).clean()
|
|
870
|
+
|
|
871
|
+
def get_base_url(self) -> str:
|
|
872
|
+
if self.provider != self.Provider.GODADDY:
|
|
873
|
+
raise ValueError("Unsupported DNS provider")
|
|
874
|
+
if self.use_sandbox:
|
|
875
|
+
return "https://api.ote-godaddy.com"
|
|
876
|
+
return "https://api.godaddy.com"
|
|
877
|
+
|
|
878
|
+
def get_auth_header(self) -> str:
|
|
879
|
+
key = (self.resolve_sigils("api_key") or "").strip()
|
|
880
|
+
secret = (self.resolve_sigils("api_secret") or "").strip()
|
|
881
|
+
if not key or not secret:
|
|
882
|
+
raise ValueError("API credentials are required for DNS deployment")
|
|
883
|
+
return f"sso-key {key}:{secret}"
|
|
884
|
+
|
|
885
|
+
def get_customer_id(self) -> str:
|
|
886
|
+
return (self.resolve_sigils("customer_id") or "").strip()
|
|
887
|
+
|
|
888
|
+
def get_default_domain(self) -> str:
|
|
889
|
+
return (self.resolve_sigils("default_domain") or "").strip()
|
|
890
|
+
|
|
891
|
+
def publish_dns_records(self, records: Iterable["DNSRecord"]):
|
|
892
|
+
from . import dns as dns_utils
|
|
893
|
+
|
|
894
|
+
return dns_utils.deploy_records(self, records)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class DNSRecord(Entity):
|
|
898
|
+
"""Stored DNS configuration ready for deployment."""
|
|
899
|
+
|
|
900
|
+
class Type(models.TextChoices):
|
|
901
|
+
A = "A", "A"
|
|
902
|
+
AAAA = "AAAA", "AAAA"
|
|
903
|
+
CNAME = "CNAME", "CNAME"
|
|
904
|
+
MX = "MX", "MX"
|
|
905
|
+
NS = "NS", "NS"
|
|
906
|
+
SRV = "SRV", "SRV"
|
|
907
|
+
TXT = "TXT", "TXT"
|
|
908
|
+
|
|
909
|
+
class Provider(models.TextChoices):
|
|
910
|
+
GODADDY = "godaddy", "GoDaddy"
|
|
911
|
+
|
|
912
|
+
provider = models.CharField(
|
|
913
|
+
max_length=20,
|
|
914
|
+
choices=Provider.choices,
|
|
915
|
+
default=Provider.GODADDY,
|
|
916
|
+
)
|
|
917
|
+
node_manager = models.ForeignKey(
|
|
918
|
+
"NodeManager",
|
|
919
|
+
on_delete=models.SET_NULL,
|
|
920
|
+
null=True,
|
|
921
|
+
blank=True,
|
|
922
|
+
related_name="dns_records",
|
|
923
|
+
)
|
|
924
|
+
domain = SigilShortAutoField(
|
|
925
|
+
max_length=253,
|
|
926
|
+
help_text="Base domain such as example.com.",
|
|
927
|
+
)
|
|
928
|
+
name = SigilShortAutoField(
|
|
929
|
+
max_length=253,
|
|
930
|
+
help_text="Record host. Use @ for the zone apex.",
|
|
931
|
+
)
|
|
932
|
+
record_type = models.CharField(
|
|
933
|
+
max_length=10,
|
|
934
|
+
choices=Type.choices,
|
|
935
|
+
default=Type.A,
|
|
936
|
+
verbose_name="Type",
|
|
937
|
+
)
|
|
938
|
+
data = SigilLongAutoField(
|
|
939
|
+
help_text="Record value such as an IP address or hostname.",
|
|
940
|
+
)
|
|
941
|
+
ttl = models.PositiveIntegerField(
|
|
942
|
+
default=600,
|
|
943
|
+
help_text="Time to live in seconds.",
|
|
944
|
+
)
|
|
945
|
+
priority = models.PositiveIntegerField(
|
|
946
|
+
null=True,
|
|
947
|
+
blank=True,
|
|
948
|
+
help_text="Priority for MX and SRV records.",
|
|
949
|
+
)
|
|
950
|
+
port = models.PositiveIntegerField(
|
|
951
|
+
null=True,
|
|
952
|
+
blank=True,
|
|
953
|
+
help_text="Port for SRV records.",
|
|
954
|
+
)
|
|
955
|
+
weight = models.PositiveIntegerField(
|
|
956
|
+
null=True,
|
|
957
|
+
blank=True,
|
|
958
|
+
help_text="Weight for SRV records.",
|
|
959
|
+
)
|
|
960
|
+
service = SigilShortAutoField(
|
|
961
|
+
max_length=50,
|
|
962
|
+
blank=True,
|
|
963
|
+
help_text="Service label for SRV records (for example _sip).",
|
|
964
|
+
)
|
|
965
|
+
protocol = SigilShortAutoField(
|
|
966
|
+
max_length=10,
|
|
967
|
+
blank=True,
|
|
968
|
+
help_text="Protocol label for SRV records (for example _tcp).",
|
|
969
|
+
)
|
|
970
|
+
last_synced_at = models.DateTimeField(null=True, blank=True)
|
|
971
|
+
last_verified_at = models.DateTimeField(null=True, blank=True)
|
|
972
|
+
last_error = models.TextField(blank=True)
|
|
973
|
+
|
|
974
|
+
class Meta:
|
|
975
|
+
verbose_name = "DNS Record"
|
|
976
|
+
verbose_name_plural = "DNS Records"
|
|
977
|
+
|
|
978
|
+
def __str__(self) -> str:
|
|
979
|
+
return f"{self.record_type} {self.fqdn()}"
|
|
980
|
+
|
|
981
|
+
def get_domain(self, manager: "NodeManager" | None = None) -> str:
|
|
982
|
+
domain = (self.resolve_sigils("domain") or "").strip()
|
|
983
|
+
if domain:
|
|
984
|
+
return domain.rstrip(".")
|
|
985
|
+
if manager:
|
|
986
|
+
fallback = manager.get_default_domain()
|
|
987
|
+
if fallback:
|
|
988
|
+
return fallback.rstrip(".")
|
|
989
|
+
return ""
|
|
990
|
+
|
|
991
|
+
def get_name(self) -> str:
|
|
992
|
+
name = (self.resolve_sigils("name") or "").strip()
|
|
993
|
+
return name or "@"
|
|
994
|
+
|
|
995
|
+
def fqdn(self, manager: "NodeManager" | None = None) -> str:
|
|
996
|
+
domain = self.get_domain(manager)
|
|
997
|
+
name = self.get_name()
|
|
998
|
+
if name in {"@", ""}:
|
|
999
|
+
return domain
|
|
1000
|
+
if name.endswith("."):
|
|
1001
|
+
return name.rstrip(".")
|
|
1002
|
+
if domain:
|
|
1003
|
+
return f"{name}.{domain}".rstrip(".")
|
|
1004
|
+
return name.rstrip(".")
|
|
1005
|
+
|
|
1006
|
+
def to_godaddy_payload(self) -> dict[str, object]:
|
|
1007
|
+
payload: dict[str, object] = {
|
|
1008
|
+
"data": (self.resolve_sigils("data") or "").strip(),
|
|
1009
|
+
"ttl": self.ttl,
|
|
1010
|
+
}
|
|
1011
|
+
if self.priority is not None:
|
|
1012
|
+
payload["priority"] = self.priority
|
|
1013
|
+
if self.port is not None:
|
|
1014
|
+
payload["port"] = self.port
|
|
1015
|
+
if self.weight is not None:
|
|
1016
|
+
payload["weight"] = self.weight
|
|
1017
|
+
service = (self.resolve_sigils("service") or "").strip()
|
|
1018
|
+
if service:
|
|
1019
|
+
payload["service"] = service
|
|
1020
|
+
protocol = (self.resolve_sigils("protocol") or "").strip()
|
|
1021
|
+
if protocol:
|
|
1022
|
+
payload["protocol"] = protocol
|
|
1023
|
+
return payload
|
|
1024
|
+
|
|
1025
|
+
def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
|
|
1026
|
+
if timestamp is None:
|
|
1027
|
+
timestamp = timezone.now()
|
|
1028
|
+
update_fields = ["last_synced_at", "last_error"]
|
|
1029
|
+
self.last_synced_at = timestamp
|
|
1030
|
+
self.last_error = ""
|
|
1031
|
+
if manager and self.node_manager_id != getattr(manager, "pk", None):
|
|
1032
|
+
self.node_manager = manager
|
|
1033
|
+
update_fields.append("node_manager")
|
|
1034
|
+
self.save(update_fields=update_fields)
|
|
1035
|
+
|
|
1036
|
+
def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
|
|
1037
|
+
update_fields = ["last_error"]
|
|
1038
|
+
self.last_error = message
|
|
1039
|
+
if manager and self.node_manager_id != getattr(manager, "pk", None):
|
|
1040
|
+
self.node_manager = manager
|
|
1041
|
+
update_fields.append("node_manager")
|
|
1042
|
+
self.save(update_fields=update_fields)
|
|
1043
|
+
|
|
1044
|
+
|
|
716
1045
|
class EmailOutbox(Profile):
|
|
717
1046
|
"""SMTP credentials for sending mail."""
|
|
718
1047
|
|
|
@@ -765,6 +1094,10 @@ class EmailOutbox(Profile):
|
|
|
765
1094
|
max_length=254,
|
|
766
1095
|
help_text="Default From address; usually the same as username",
|
|
767
1096
|
)
|
|
1097
|
+
is_enabled = models.BooleanField(
|
|
1098
|
+
default=True,
|
|
1099
|
+
help_text="Disable to remove this outbox from automatic selection.",
|
|
1100
|
+
)
|
|
768
1101
|
|
|
769
1102
|
class Meta:
|
|
770
1103
|
verbose_name = "Email Outbox"
|
|
@@ -777,9 +1110,15 @@ class EmailOutbox(Profile):
|
|
|
777
1110
|
|
|
778
1111
|
username = (self.username or "").strip()
|
|
779
1112
|
host = (self.host or "").strip()
|
|
780
|
-
if username and host:
|
|
781
|
-
return f"{username}@{host}"
|
|
782
1113
|
if username:
|
|
1114
|
+
local, sep, domain = username.partition("@")
|
|
1115
|
+
if sep and domain:
|
|
1116
|
+
return username
|
|
1117
|
+
if host:
|
|
1118
|
+
sanitized = username.rstrip("@")
|
|
1119
|
+
if sanitized:
|
|
1120
|
+
return f"{sanitized}@{host}"
|
|
1121
|
+
return host
|
|
783
1122
|
return username
|
|
784
1123
|
if host:
|
|
785
1124
|
return host
|
|
@@ -849,7 +1188,6 @@ class NetMessage(Entity):
|
|
|
849
1188
|
on_delete=models.SET_NULL,
|
|
850
1189
|
null=True,
|
|
851
1190
|
blank=True,
|
|
852
|
-
default=get_terminal_role,
|
|
853
1191
|
)
|
|
854
1192
|
propagated_to = models.ManyToManyField(
|
|
855
1193
|
Node, blank=True, related_name="received_net_messages"
|
|
@@ -880,7 +1218,7 @@ class NetMessage(Entity):
|
|
|
880
1218
|
msg = cls.objects.create(
|
|
881
1219
|
subject=subject[:64],
|
|
882
1220
|
body=body[:256],
|
|
883
|
-
reach=role
|
|
1221
|
+
reach=role,
|
|
884
1222
|
node_origin=origin,
|
|
885
1223
|
)
|
|
886
1224
|
msg.propagate(seen=seen or [])
|
|
@@ -951,7 +1289,7 @@ class NetMessage(Entity):
|
|
|
951
1289
|
|
|
952
1290
|
target_limit = min(3, len(remaining))
|
|
953
1291
|
|
|
954
|
-
reach_name = self.reach.name if self.reach else
|
|
1292
|
+
reach_name = self.reach.name if self.reach else None
|
|
955
1293
|
role_map = {
|
|
956
1294
|
"Terminal": ["Terminal"],
|
|
957
1295
|
"Control": ["Control", "Terminal"],
|
|
@@ -963,10 +1301,15 @@ class NetMessage(Entity):
|
|
|
963
1301
|
"Terminal",
|
|
964
1302
|
],
|
|
965
1303
|
}
|
|
966
|
-
role_order = role_map.get(reach_name, [
|
|
1304
|
+
role_order = role_map.get(reach_name, [None])
|
|
967
1305
|
selected: list[Node] = []
|
|
968
1306
|
for role_name in role_order:
|
|
969
|
-
|
|
1307
|
+
if role_name is None:
|
|
1308
|
+
role_nodes = remaining[:]
|
|
1309
|
+
else:
|
|
1310
|
+
role_nodes = [
|
|
1311
|
+
n for n in remaining if n.role and n.role.name == role_name
|
|
1312
|
+
]
|
|
970
1313
|
random.shuffle(role_nodes)
|
|
971
1314
|
for n in role_nodes:
|
|
972
1315
|
selected.append(n)
|