arthexis 0.1.10__py3-none-any.whl → 0.1.11__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.11.dist-info}/METADATA +36 -26
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/RECORD +42 -38
- config/context_processors.py +1 -0
- config/settings.py +24 -3
- config/urls.py +5 -4
- core/admin.py +184 -22
- core/apps.py +27 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +270 -31
- core/reference_utils.py +19 -8
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +247 -1
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +105 -3
- core/user_data.py +51 -8
- core/views.py +245 -8
- nodes/admin.py +137 -2
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/models.py +293 -7
- nodes/tests.py +312 -2
- nodes/views.py +14 -0
- ocpp/consumers.py +11 -8
- ocpp/models.py +3 -0
- ocpp/reference_utils.py +42 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +30 -0
- ocpp/views.py +8 -0
- pages/admin.py +9 -1
- pages/context_processors.py +6 -6
- pages/defaults.py +14 -0
- pages/models.py +53 -14
- pages/tests.py +19 -4
- pages/urls.py +3 -0
- pages/views.py +86 -19
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
nodes/models.py
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from collections.abc import Iterable
|
|
2
4
|
from django.db import models
|
|
5
|
+
from django.db.utils import DatabaseError
|
|
3
6
|
from django.db.models.signals import post_delete
|
|
4
7
|
from django.dispatch import Signal, receiver
|
|
5
8
|
from core.entity import Entity
|
|
6
9
|
from core.models import Profile
|
|
7
|
-
from core.fields import SigilShortAutoField
|
|
10
|
+
from core.fields import SigilLongAutoField, SigilShortAutoField
|
|
8
11
|
import re
|
|
9
12
|
import json
|
|
10
13
|
import base64
|
|
@@ -122,12 +125,23 @@ def get_terminal_role():
|
|
|
122
125
|
class Node(Entity):
|
|
123
126
|
"""Information about a running node in the network."""
|
|
124
127
|
|
|
128
|
+
class Relation(models.TextChoices):
|
|
129
|
+
UPSTREAM = "UPSTREAM", "Upstream"
|
|
130
|
+
DOWNSTREAM = "DOWNSTREAM", "Downstream"
|
|
131
|
+
PEER = "PEER", "Peer"
|
|
132
|
+
SELF = "SELF", "Self"
|
|
133
|
+
|
|
125
134
|
hostname = models.CharField(max_length=100)
|
|
126
135
|
address = models.GenericIPAddressField()
|
|
127
136
|
mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
|
|
128
137
|
port = models.PositiveIntegerField(default=8000)
|
|
129
138
|
badge_color = models.CharField(max_length=7, default="#28a745")
|
|
130
139
|
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
140
|
+
current_relation = models.CharField(
|
|
141
|
+
max_length=10,
|
|
142
|
+
choices=Relation.choices,
|
|
143
|
+
default=Relation.PEER,
|
|
144
|
+
)
|
|
131
145
|
last_seen = models.DateTimeField(auto_now=True)
|
|
132
146
|
enable_public_api = models.BooleanField(
|
|
133
147
|
default=False,
|
|
@@ -178,11 +192,35 @@ class Node(Entity):
|
|
|
178
192
|
"""Return the MAC address of the current host."""
|
|
179
193
|
return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
|
|
180
194
|
|
|
195
|
+
@classmethod
|
|
196
|
+
def normalize_relation(cls, value):
|
|
197
|
+
"""Normalize ``value`` to a valid :class:`Relation`."""
|
|
198
|
+
|
|
199
|
+
if isinstance(value, cls.Relation):
|
|
200
|
+
return value
|
|
201
|
+
if value is None:
|
|
202
|
+
return cls.Relation.PEER
|
|
203
|
+
text = str(value).strip()
|
|
204
|
+
if not text:
|
|
205
|
+
return cls.Relation.PEER
|
|
206
|
+
for relation in cls.Relation:
|
|
207
|
+
if text.lower() == relation.label.lower():
|
|
208
|
+
return relation
|
|
209
|
+
if text.upper() == relation.name:
|
|
210
|
+
return relation
|
|
211
|
+
if text.lower() == relation.value.lower():
|
|
212
|
+
return relation
|
|
213
|
+
return cls.Relation.PEER
|
|
214
|
+
|
|
181
215
|
@classmethod
|
|
182
216
|
def get_local(cls):
|
|
183
217
|
"""Return the node representing the current host if it exists."""
|
|
184
218
|
mac = cls.get_current_mac()
|
|
185
|
-
|
|
219
|
+
try:
|
|
220
|
+
return cls.objects.filter(mac_address=mac).first()
|
|
221
|
+
except DatabaseError:
|
|
222
|
+
logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
|
|
223
|
+
return None
|
|
186
224
|
|
|
187
225
|
@classmethod
|
|
188
226
|
def register_current(cls):
|
|
@@ -212,6 +250,7 @@ class Node(Entity):
|
|
|
212
250
|
"installed_revision": installed_revision,
|
|
213
251
|
"public_endpoint": slug,
|
|
214
252
|
"mac_address": mac,
|
|
253
|
+
"current_relation": cls.Relation.SELF,
|
|
215
254
|
}
|
|
216
255
|
if node:
|
|
217
256
|
for field, value in defaults.items():
|
|
@@ -713,6 +752,243 @@ def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
|
|
|
713
752
|
node.sync_feature_tasks()
|
|
714
753
|
|
|
715
754
|
|
|
755
|
+
class NodeManager(Profile):
|
|
756
|
+
"""Credentials for interacting with external DNS providers."""
|
|
757
|
+
|
|
758
|
+
class Provider(models.TextChoices):
|
|
759
|
+
GODADDY = "godaddy", "GoDaddy"
|
|
760
|
+
|
|
761
|
+
profile_fields = (
|
|
762
|
+
"provider",
|
|
763
|
+
"api_key",
|
|
764
|
+
"api_secret",
|
|
765
|
+
"customer_id",
|
|
766
|
+
"default_domain",
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
provider = models.CharField(
|
|
770
|
+
max_length=20,
|
|
771
|
+
choices=Provider.choices,
|
|
772
|
+
default=Provider.GODADDY,
|
|
773
|
+
)
|
|
774
|
+
api_key = SigilShortAutoField(
|
|
775
|
+
max_length=255,
|
|
776
|
+
help_text="API key issued by the DNS provider.",
|
|
777
|
+
)
|
|
778
|
+
api_secret = SigilShortAutoField(
|
|
779
|
+
max_length=255,
|
|
780
|
+
help_text="API secret issued by the DNS provider.",
|
|
781
|
+
)
|
|
782
|
+
customer_id = SigilShortAutoField(
|
|
783
|
+
max_length=100,
|
|
784
|
+
blank=True,
|
|
785
|
+
help_text="Optional GoDaddy customer identifier for the account.",
|
|
786
|
+
)
|
|
787
|
+
default_domain = SigilShortAutoField(
|
|
788
|
+
max_length=253,
|
|
789
|
+
blank=True,
|
|
790
|
+
help_text="Fallback domain when records omit one.",
|
|
791
|
+
)
|
|
792
|
+
use_sandbox = models.BooleanField(
|
|
793
|
+
default=False,
|
|
794
|
+
help_text="Use the GoDaddy OTE (test) environment.",
|
|
795
|
+
)
|
|
796
|
+
is_enabled = models.BooleanField(
|
|
797
|
+
default=True,
|
|
798
|
+
help_text="Disable to prevent deployments with this manager.",
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
class Meta:
|
|
802
|
+
verbose_name = "Node Manager"
|
|
803
|
+
verbose_name_plural = "Node Managers"
|
|
804
|
+
|
|
805
|
+
def __str__(self) -> str:
|
|
806
|
+
owner = self.owner_display()
|
|
807
|
+
provider = self.get_provider_display()
|
|
808
|
+
if owner:
|
|
809
|
+
return f"{provider} ({owner})"
|
|
810
|
+
return provider
|
|
811
|
+
|
|
812
|
+
def clean(self):
|
|
813
|
+
if self.user_id or self.group_id:
|
|
814
|
+
super().clean()
|
|
815
|
+
else:
|
|
816
|
+
super(Profile, self).clean()
|
|
817
|
+
|
|
818
|
+
def get_base_url(self) -> str:
|
|
819
|
+
if self.provider != self.Provider.GODADDY:
|
|
820
|
+
raise ValueError("Unsupported DNS provider")
|
|
821
|
+
if self.use_sandbox:
|
|
822
|
+
return "https://api.ote-godaddy.com"
|
|
823
|
+
return "https://api.godaddy.com"
|
|
824
|
+
|
|
825
|
+
def get_auth_header(self) -> str:
|
|
826
|
+
key = (self.resolve_sigils("api_key") or "").strip()
|
|
827
|
+
secret = (self.resolve_sigils("api_secret") or "").strip()
|
|
828
|
+
if not key or not secret:
|
|
829
|
+
raise ValueError("API credentials are required for DNS deployment")
|
|
830
|
+
return f"sso-key {key}:{secret}"
|
|
831
|
+
|
|
832
|
+
def get_customer_id(self) -> str:
|
|
833
|
+
return (self.resolve_sigils("customer_id") or "").strip()
|
|
834
|
+
|
|
835
|
+
def get_default_domain(self) -> str:
|
|
836
|
+
return (self.resolve_sigils("default_domain") or "").strip()
|
|
837
|
+
|
|
838
|
+
def publish_dns_records(self, records: Iterable["DNSRecord"]):
|
|
839
|
+
from . import dns as dns_utils
|
|
840
|
+
|
|
841
|
+
return dns_utils.deploy_records(self, records)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
class DNSRecord(Entity):
|
|
845
|
+
"""Stored DNS configuration ready for deployment."""
|
|
846
|
+
|
|
847
|
+
class Type(models.TextChoices):
|
|
848
|
+
A = "A", "A"
|
|
849
|
+
AAAA = "AAAA", "AAAA"
|
|
850
|
+
CNAME = "CNAME", "CNAME"
|
|
851
|
+
MX = "MX", "MX"
|
|
852
|
+
NS = "NS", "NS"
|
|
853
|
+
SRV = "SRV", "SRV"
|
|
854
|
+
TXT = "TXT", "TXT"
|
|
855
|
+
|
|
856
|
+
class Provider(models.TextChoices):
|
|
857
|
+
GODADDY = "godaddy", "GoDaddy"
|
|
858
|
+
|
|
859
|
+
provider = models.CharField(
|
|
860
|
+
max_length=20,
|
|
861
|
+
choices=Provider.choices,
|
|
862
|
+
default=Provider.GODADDY,
|
|
863
|
+
)
|
|
864
|
+
node_manager = models.ForeignKey(
|
|
865
|
+
"NodeManager",
|
|
866
|
+
on_delete=models.SET_NULL,
|
|
867
|
+
null=True,
|
|
868
|
+
blank=True,
|
|
869
|
+
related_name="dns_records",
|
|
870
|
+
)
|
|
871
|
+
domain = SigilShortAutoField(
|
|
872
|
+
max_length=253,
|
|
873
|
+
help_text="Base domain such as example.com.",
|
|
874
|
+
)
|
|
875
|
+
name = SigilShortAutoField(
|
|
876
|
+
max_length=253,
|
|
877
|
+
help_text="Record host. Use @ for the zone apex.",
|
|
878
|
+
)
|
|
879
|
+
record_type = models.CharField(
|
|
880
|
+
max_length=10,
|
|
881
|
+
choices=Type.choices,
|
|
882
|
+
default=Type.A,
|
|
883
|
+
verbose_name="Type",
|
|
884
|
+
)
|
|
885
|
+
data = SigilLongAutoField(
|
|
886
|
+
help_text="Record value such as an IP address or hostname.",
|
|
887
|
+
)
|
|
888
|
+
ttl = models.PositiveIntegerField(
|
|
889
|
+
default=600,
|
|
890
|
+
help_text="Time to live in seconds.",
|
|
891
|
+
)
|
|
892
|
+
priority = models.PositiveIntegerField(
|
|
893
|
+
null=True,
|
|
894
|
+
blank=True,
|
|
895
|
+
help_text="Priority for MX and SRV records.",
|
|
896
|
+
)
|
|
897
|
+
port = models.PositiveIntegerField(
|
|
898
|
+
null=True,
|
|
899
|
+
blank=True,
|
|
900
|
+
help_text="Port for SRV records.",
|
|
901
|
+
)
|
|
902
|
+
weight = models.PositiveIntegerField(
|
|
903
|
+
null=True,
|
|
904
|
+
blank=True,
|
|
905
|
+
help_text="Weight for SRV records.",
|
|
906
|
+
)
|
|
907
|
+
service = SigilShortAutoField(
|
|
908
|
+
max_length=50,
|
|
909
|
+
blank=True,
|
|
910
|
+
help_text="Service label for SRV records (for example _sip).",
|
|
911
|
+
)
|
|
912
|
+
protocol = SigilShortAutoField(
|
|
913
|
+
max_length=10,
|
|
914
|
+
blank=True,
|
|
915
|
+
help_text="Protocol label for SRV records (for example _tcp).",
|
|
916
|
+
)
|
|
917
|
+
last_synced_at = models.DateTimeField(null=True, blank=True)
|
|
918
|
+
last_verified_at = models.DateTimeField(null=True, blank=True)
|
|
919
|
+
last_error = models.TextField(blank=True)
|
|
920
|
+
|
|
921
|
+
class Meta:
|
|
922
|
+
verbose_name = "DNS Record"
|
|
923
|
+
verbose_name_plural = "DNS Records"
|
|
924
|
+
|
|
925
|
+
def __str__(self) -> str:
|
|
926
|
+
return f"{self.record_type} {self.fqdn()}"
|
|
927
|
+
|
|
928
|
+
def get_domain(self, manager: "NodeManager" | None = None) -> str:
|
|
929
|
+
domain = (self.resolve_sigils("domain") or "").strip()
|
|
930
|
+
if domain:
|
|
931
|
+
return domain.rstrip(".")
|
|
932
|
+
if manager:
|
|
933
|
+
fallback = manager.get_default_domain()
|
|
934
|
+
if fallback:
|
|
935
|
+
return fallback.rstrip(".")
|
|
936
|
+
return ""
|
|
937
|
+
|
|
938
|
+
def get_name(self) -> str:
|
|
939
|
+
name = (self.resolve_sigils("name") or "").strip()
|
|
940
|
+
return name or "@"
|
|
941
|
+
|
|
942
|
+
def fqdn(self, manager: "NodeManager" | None = None) -> str:
|
|
943
|
+
domain = self.get_domain(manager)
|
|
944
|
+
name = self.get_name()
|
|
945
|
+
if name in {"@", ""}:
|
|
946
|
+
return domain
|
|
947
|
+
if name.endswith("."):
|
|
948
|
+
return name.rstrip(".")
|
|
949
|
+
if domain:
|
|
950
|
+
return f"{name}.{domain}".rstrip(".")
|
|
951
|
+
return name.rstrip(".")
|
|
952
|
+
|
|
953
|
+
def to_godaddy_payload(self) -> dict[str, object]:
|
|
954
|
+
payload: dict[str, object] = {
|
|
955
|
+
"data": (self.resolve_sigils("data") or "").strip(),
|
|
956
|
+
"ttl": self.ttl,
|
|
957
|
+
}
|
|
958
|
+
if self.priority is not None:
|
|
959
|
+
payload["priority"] = self.priority
|
|
960
|
+
if self.port is not None:
|
|
961
|
+
payload["port"] = self.port
|
|
962
|
+
if self.weight is not None:
|
|
963
|
+
payload["weight"] = self.weight
|
|
964
|
+
service = (self.resolve_sigils("service") or "").strip()
|
|
965
|
+
if service:
|
|
966
|
+
payload["service"] = service
|
|
967
|
+
protocol = (self.resolve_sigils("protocol") or "").strip()
|
|
968
|
+
if protocol:
|
|
969
|
+
payload["protocol"] = protocol
|
|
970
|
+
return payload
|
|
971
|
+
|
|
972
|
+
def mark_deployed(self, manager: "NodeManager" | None = None, timestamp=None) -> None:
|
|
973
|
+
if timestamp is None:
|
|
974
|
+
timestamp = timezone.now()
|
|
975
|
+
update_fields = ["last_synced_at", "last_error"]
|
|
976
|
+
self.last_synced_at = timestamp
|
|
977
|
+
self.last_error = ""
|
|
978
|
+
if manager and self.node_manager_id != getattr(manager, "pk", None):
|
|
979
|
+
self.node_manager = manager
|
|
980
|
+
update_fields.append("node_manager")
|
|
981
|
+
self.save(update_fields=update_fields)
|
|
982
|
+
|
|
983
|
+
def mark_error(self, message: str, manager: "NodeManager" | None = None) -> None:
|
|
984
|
+
update_fields = ["last_error"]
|
|
985
|
+
self.last_error = message
|
|
986
|
+
if manager and self.node_manager_id != getattr(manager, "pk", None):
|
|
987
|
+
self.node_manager = manager
|
|
988
|
+
update_fields.append("node_manager")
|
|
989
|
+
self.save(update_fields=update_fields)
|
|
990
|
+
|
|
991
|
+
|
|
716
992
|
class EmailOutbox(Profile):
|
|
717
993
|
"""SMTP credentials for sending mail."""
|
|
718
994
|
|
|
@@ -765,6 +1041,10 @@ class EmailOutbox(Profile):
|
|
|
765
1041
|
max_length=254,
|
|
766
1042
|
help_text="Default From address; usually the same as username",
|
|
767
1043
|
)
|
|
1044
|
+
is_enabled = models.BooleanField(
|
|
1045
|
+
default=True,
|
|
1046
|
+
help_text="Disable to remove this outbox from automatic selection.",
|
|
1047
|
+
)
|
|
768
1048
|
|
|
769
1049
|
class Meta:
|
|
770
1050
|
verbose_name = "Email Outbox"
|
|
@@ -778,6 +1058,8 @@ class EmailOutbox(Profile):
|
|
|
778
1058
|
username = (self.username or "").strip()
|
|
779
1059
|
host = (self.host or "").strip()
|
|
780
1060
|
if username and host:
|
|
1061
|
+
if "@" in username:
|
|
1062
|
+
return username
|
|
781
1063
|
return f"{username}@{host}"
|
|
782
1064
|
if username:
|
|
783
1065
|
return username
|
|
@@ -849,7 +1131,6 @@ class NetMessage(Entity):
|
|
|
849
1131
|
on_delete=models.SET_NULL,
|
|
850
1132
|
null=True,
|
|
851
1133
|
blank=True,
|
|
852
|
-
default=get_terminal_role,
|
|
853
1134
|
)
|
|
854
1135
|
propagated_to = models.ManyToManyField(
|
|
855
1136
|
Node, blank=True, related_name="received_net_messages"
|
|
@@ -880,7 +1161,7 @@ class NetMessage(Entity):
|
|
|
880
1161
|
msg = cls.objects.create(
|
|
881
1162
|
subject=subject[:64],
|
|
882
1163
|
body=body[:256],
|
|
883
|
-
reach=role
|
|
1164
|
+
reach=role,
|
|
884
1165
|
node_origin=origin,
|
|
885
1166
|
)
|
|
886
1167
|
msg.propagate(seen=seen or [])
|
|
@@ -951,7 +1232,7 @@ class NetMessage(Entity):
|
|
|
951
1232
|
|
|
952
1233
|
target_limit = min(3, len(remaining))
|
|
953
1234
|
|
|
954
|
-
reach_name = self.reach.name if self.reach else
|
|
1235
|
+
reach_name = self.reach.name if self.reach else None
|
|
955
1236
|
role_map = {
|
|
956
1237
|
"Terminal": ["Terminal"],
|
|
957
1238
|
"Control": ["Control", "Terminal"],
|
|
@@ -963,10 +1244,15 @@ class NetMessage(Entity):
|
|
|
963
1244
|
"Terminal",
|
|
964
1245
|
],
|
|
965
1246
|
}
|
|
966
|
-
role_order = role_map.get(reach_name, [
|
|
1247
|
+
role_order = role_map.get(reach_name, [None])
|
|
967
1248
|
selected: list[Node] = []
|
|
968
1249
|
for role_name in role_order:
|
|
969
|
-
|
|
1250
|
+
if role_name is None:
|
|
1251
|
+
role_nodes = remaining[:]
|
|
1252
|
+
else:
|
|
1253
|
+
role_nodes = [
|
|
1254
|
+
n for n in remaining if n.role and n.role.name == role_name
|
|
1255
|
+
]
|
|
970
1256
|
random.shuffle(role_nodes)
|
|
971
1257
|
for n in role_nodes:
|
|
972
1258
|
selected.append(n)
|