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.

Files changed (54) hide show
  1. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
  2. arthexis-0.1.12.dist-info/RECORD +102 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +31 -5
  5. config/urls.py +5 -4
  6. core/admin.py +430 -90
  7. core/apps.py +48 -2
  8. core/backends.py +38 -0
  9. core/environment.py +23 -5
  10. core/mailer.py +3 -1
  11. core/models.py +303 -31
  12. core/reference_utils.py +20 -9
  13. core/release.py +4 -0
  14. core/sigil_builder.py +7 -2
  15. core/sigil_resolver.py +35 -4
  16. core/system.py +250 -1
  17. core/tasks.py +92 -40
  18. core/temp_passwords.py +181 -0
  19. core/test_system_info.py +62 -2
  20. core/tests.py +169 -3
  21. core/user_data.py +51 -8
  22. core/views.py +371 -20
  23. nodes/admin.py +453 -8
  24. nodes/backends.py +21 -6
  25. nodes/dns.py +203 -0
  26. nodes/feature_checks.py +133 -0
  27. nodes/models.py +374 -31
  28. nodes/reports.py +411 -0
  29. nodes/tests.py +677 -38
  30. nodes/utils.py +32 -0
  31. nodes/views.py +14 -0
  32. ocpp/admin.py +278 -15
  33. ocpp/consumers.py +517 -16
  34. ocpp/evcs_discovery.py +158 -0
  35. ocpp/models.py +237 -4
  36. ocpp/reference_utils.py +42 -0
  37. ocpp/simulator.py +321 -22
  38. ocpp/store.py +110 -2
  39. ocpp/test_rfid.py +169 -7
  40. ocpp/tests.py +819 -6
  41. ocpp/transactions_io.py +17 -3
  42. ocpp/views.py +233 -19
  43. pages/admin.py +144 -4
  44. pages/context_processors.py +21 -7
  45. pages/defaults.py +13 -0
  46. pages/forms.py +38 -0
  47. pages/models.py +189 -15
  48. pages/tests.py +281 -8
  49. pages/urls.py +4 -0
  50. pages/views.py +137 -21
  51. arthexis-0.1.10.dist-info/RECORD +0 -95
  52. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
  53. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
  54. {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="#28a745")
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
- return cls.objects.filter(mac_address=mac).first()
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
- setattr(node, field, value)
219
- update_fields = list(defaults.keys())
220
- node.save(update_fields=update_fields)
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
- # assign role from installation lock file
226
- role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
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 or get_terminal_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 "Terminal"
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, ["Terminal"])
1304
+ role_order = role_map.get(reach_name, [None])
967
1305
  selected: list[Node] = []
968
1306
  for role_name in role_order:
969
- role_nodes = [n for n in remaining if n.role and n.role.name == role_name]
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)