arthexis 0.1.26__py3-none-any.whl → 0.1.28__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.

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.apps import apps
7
8
  from django.db.models import Q
8
9
  from django.db.utils import DatabaseError
9
10
  from django.db.models.signals import post_delete
@@ -29,6 +30,10 @@ from pathlib import Path
29
30
  from urllib.parse import urlparse, urlunsplit
30
31
  from utils import revision
31
32
  from core.notifications import notify_async
33
+ from core.celery_utils import (
34
+ normalize_periodic_task_name,
35
+ periodic_task_name_variants,
36
+ )
32
37
  from django.core.exceptions import ValidationError
33
38
  from cryptography.hazmat.primitives.asymmetric import rsa
34
39
  from cryptography.hazmat.primitives import serialization, hashes
@@ -214,7 +219,7 @@ class Node(Entity):
214
219
  )
215
220
  address = models.GenericIPAddressField(blank=True, null=True)
216
221
  mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
217
- port = models.PositiveIntegerField(default=8000)
222
+ port = models.PositiveIntegerField(default=8888)
218
223
  message_queue_length = models.PositiveSmallIntegerField(
219
224
  default=10,
220
225
  help_text="Maximum queued NetMessages to retain for this peer.",
@@ -258,6 +263,7 @@ class Node(Entity):
258
263
  RPI_CAMERA_DEVICE = Path("/dev/video0")
259
264
  RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
260
265
  AP_ROUTER_SSID = "gelectriic-ap"
266
+ AUDIO_CAPTURE_PCM_PATH = Path("/proc/asound/pcm")
261
267
  NMCLI_TIMEOUT = 5
262
268
  AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
263
269
  "gui-toast",
@@ -407,7 +413,7 @@ class Node(Entity):
407
413
  """Yield potential remote URLs for ``path`` on this node."""
408
414
 
409
415
  host_candidates = self.get_remote_host_candidates()
410
- default_port = self.port or 8000
416
+ default_port = self.port or 8888
411
417
  normalized_path = path if path.startswith("/") else f"/{path}"
412
418
  seen: set[str] = set()
413
419
 
@@ -602,7 +608,7 @@ class Node(Entity):
602
608
  ipv6_address = cls._select_preferred_ip(ipv6_candidates)
603
609
 
604
610
  preferred_contact = ipv4_address or ipv6_address or direct_address or "127.0.0.1"
605
- port = int(os.environ.get("PORT", 8000))
611
+ port = int(os.environ.get("PORT", 8888))
606
612
  base_path = str(settings.BASE_DIR)
607
613
  ver_path = Path(settings.BASE_DIR) / "VERSION"
608
614
  installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
@@ -721,7 +727,7 @@ class Node(Entity):
721
727
  peers = Node.objects.exclude(pk=self.pk)
722
728
  for peer in peers:
723
729
  host_candidates = peer.get_remote_host_candidates()
724
- port = peer.port or 8000
730
+ port = peer.port or 8888
725
731
  urls: list[str] = []
726
732
  for host in host_candidates:
727
733
  host = host.strip()
@@ -839,7 +845,13 @@ class Node(Entity):
839
845
  @property
840
846
  def is_local(self):
841
847
  """Determine if this node represents the current host."""
842
- return self.mac_address == self.get_current_mac()
848
+ current_mac = self.get_current_mac()
849
+ stored_mac = (self.mac_address or "").strip()
850
+ if stored_mac:
851
+ normalized_stored = stored_mac.replace("-", ":").lower()
852
+ normalized_current = current_mac.replace("-", ":").lower()
853
+ return normalized_stored == normalized_current
854
+ return self.current_relation == self.Relation.SELF
843
855
 
844
856
  @classmethod
845
857
  def _generate_unique_public_endpoint(
@@ -919,13 +931,41 @@ class Node(Entity):
919
931
  if self.public_endpoint != endpoint_value:
920
932
  self.public_endpoint = endpoint_value
921
933
  include_update_field("public_endpoint")
934
+ is_new = self.pk is None
922
935
  super().save(*args, **kwargs)
923
936
  if self.pk:
937
+ if is_new:
938
+ self._apply_role_manual_features()
924
939
  self.refresh_features()
925
940
 
926
941
  def has_feature(self, slug: str) -> bool:
927
942
  return self.features.filter(slug=slug).exists()
928
943
 
944
+ def _apply_role_manual_features(self) -> None:
945
+ """Enable manual features configured as defaults for this node's role."""
946
+
947
+ if not self.role_id:
948
+ return
949
+
950
+ role_features = self.role.features.filter(
951
+ slug__in=self.MANUAL_FEATURE_SLUGS
952
+ ).values_list("slug", flat=True)
953
+ desired = set(role_features)
954
+ if not desired:
955
+ return
956
+
957
+ existing = set(
958
+ self.features.filter(slug__in=desired).values_list("slug", flat=True)
959
+ )
960
+ missing = desired - existing
961
+ if not missing:
962
+ return
963
+
964
+ for feature in NodeFeature.objects.filter(slug__in=missing):
965
+ NodeFeatureAssignment.objects.update_or_create(
966
+ node=self, feature=feature
967
+ )
968
+
929
969
  @classmethod
930
970
  def _has_rpi_camera(cls) -> bool:
931
971
  """Return ``True`` when the Raspberry Pi camera stack is available."""
@@ -960,6 +1000,29 @@ class Node(Entity):
960
1000
  return False
961
1001
  return True
962
1002
 
1003
+ @classmethod
1004
+ def _has_audio_capture_device(cls) -> bool:
1005
+ """Return ``True`` when an audio capture device is available."""
1006
+
1007
+ pcm_path = cls.AUDIO_CAPTURE_PCM_PATH
1008
+ try:
1009
+ contents = pcm_path.read_text(errors="ignore")
1010
+ except OSError:
1011
+ return False
1012
+ for line in contents.splitlines():
1013
+ candidate = line.strip()
1014
+ if not candidate:
1015
+ continue
1016
+ lower_candidate = candidate.lower()
1017
+ if "capture" not in lower_candidate:
1018
+ continue
1019
+ match = re.search(r"capture\s+(\d+)", lower_candidate)
1020
+ if not match:
1021
+ continue
1022
+ if int(match.group(1)) > 0:
1023
+ return True
1024
+ return False
1025
+
963
1026
  @classmethod
964
1027
  def _hosts_gelectriic_ap(cls) -> bool:
965
1028
  """Return ``True`` when the node is hosting the gelectriic access point."""
@@ -1098,10 +1161,13 @@ class Node(Entity):
1098
1161
  def _sync_clipboard_task(self, enabled: bool):
1099
1162
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
1100
1163
 
1101
- task_name = f"poll_clipboard_node_{self.pk}"
1164
+ raw_task_name = f"poll_clipboard_node_{self.pk}"
1102
1165
  if enabled:
1103
1166
  schedule, _ = IntervalSchedule.objects.get_or_create(
1104
- every=5, period=IntervalSchedule.SECONDS
1167
+ every=10, period=IntervalSchedule.SECONDS
1168
+ )
1169
+ task_name = normalize_periodic_task_name(
1170
+ PeriodicTask.objects, raw_task_name
1105
1171
  )
1106
1172
  PeriodicTask.objects.update_or_create(
1107
1173
  name=task_name,
@@ -1111,17 +1177,22 @@ class Node(Entity):
1111
1177
  },
1112
1178
  )
1113
1179
  else:
1114
- PeriodicTask.objects.filter(name=task_name).delete()
1180
+ PeriodicTask.objects.filter(
1181
+ name__in=periodic_task_name_variants(raw_task_name)
1182
+ ).delete()
1115
1183
 
1116
1184
  def _sync_screenshot_task(self, enabled: bool):
1117
1185
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
1118
1186
  import json
1119
1187
 
1120
- task_name = f"capture_screenshot_node_{self.pk}"
1188
+ raw_task_name = f"capture_screenshot_node_{self.pk}"
1121
1189
  if enabled:
1122
1190
  schedule, _ = IntervalSchedule.objects.get_or_create(
1123
1191
  every=1, period=IntervalSchedule.MINUTES
1124
1192
  )
1193
+ task_name = normalize_periodic_task_name(
1194
+ PeriodicTask.objects, raw_task_name
1195
+ )
1125
1196
  PeriodicTask.objects.update_or_create(
1126
1197
  name=task_name,
1127
1198
  defaults={
@@ -1137,7 +1208,9 @@ class Node(Entity):
1137
1208
  },
1138
1209
  )
1139
1210
  else:
1140
- PeriodicTask.objects.filter(name=task_name).delete()
1211
+ PeriodicTask.objects.filter(
1212
+ name__in=periodic_task_name_variants(raw_task_name)
1213
+ ).delete()
1141
1214
 
1142
1215
  def _sync_landing_lead_task(self, enabled: bool):
1143
1216
  if not self.is_local:
@@ -1145,7 +1218,10 @@ class Node(Entity):
1145
1218
 
1146
1219
  from django_celery_beat.models import CrontabSchedule, PeriodicTask
1147
1220
 
1148
- task_name = "pages_purge_landing_leads"
1221
+ raw_task_name = "pages_purge_landing_leads"
1222
+ task_name = normalize_periodic_task_name(
1223
+ PeriodicTask.objects, raw_task_name
1224
+ )
1149
1225
  if enabled:
1150
1226
  schedule, _ = CrontabSchedule.objects.get_or_create(
1151
1227
  minute="0",
@@ -1164,19 +1240,26 @@ class Node(Entity):
1164
1240
  },
1165
1241
  )
1166
1242
  else:
1167
- PeriodicTask.objects.filter(name=task_name).delete()
1243
+ PeriodicTask.objects.filter(
1244
+ name__in=periodic_task_name_variants(raw_task_name)
1245
+ ).delete()
1168
1246
 
1169
1247
  def _sync_ocpp_session_report_task(self, celery_enabled: bool):
1170
1248
  from django_celery_beat.models import CrontabSchedule, PeriodicTask
1171
1249
  from django.db.utils import OperationalError, ProgrammingError
1172
1250
 
1173
- task_name = "ocpp_send_daily_session_report"
1251
+ raw_task_name = "ocpp_send_daily_session_report"
1252
+ task_name = normalize_periodic_task_name(
1253
+ PeriodicTask.objects, raw_task_name
1254
+ )
1174
1255
 
1175
1256
  if not self.is_local:
1176
1257
  return
1177
1258
 
1178
1259
  if not celery_enabled or not mailer.can_send_email():
1179
- PeriodicTask.objects.filter(name=task_name).delete()
1260
+ PeriodicTask.objects.filter(
1261
+ name__in=periodic_task_name_variants(raw_task_name)
1262
+ ).delete()
1180
1263
  return
1181
1264
 
1182
1265
  try:
@@ -1205,10 +1288,13 @@ class Node(Entity):
1205
1288
 
1206
1289
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
1207
1290
 
1208
- task_name = "nodes_poll_upstream_messages"
1291
+ raw_task_name = "nodes_poll_upstream_messages"
1292
+ task_name = normalize_periodic_task_name(
1293
+ PeriodicTask.objects, raw_task_name
1294
+ )
1209
1295
  if celery_enabled:
1210
1296
  schedule, _ = IntervalSchedule.objects.get_or_create(
1211
- every=1, period=IntervalSchedule.MINUTES
1297
+ every=5, period=IntervalSchedule.MINUTES
1212
1298
  )
1213
1299
  PeriodicTask.objects.update_or_create(
1214
1300
  name=task_name,
@@ -1219,7 +1305,9 @@ class Node(Entity):
1219
1305
  },
1220
1306
  )
1221
1307
  else:
1222
- PeriodicTask.objects.filter(name=task_name).delete()
1308
+ PeriodicTask.objects.filter(
1309
+ name__in=periodic_task_name_variants(raw_task_name)
1310
+ ).delete()
1223
1311
 
1224
1312
  def send_mail(
1225
1313
  self,
@@ -1246,6 +1334,10 @@ class Node(Entity):
1246
1334
  **kwargs,
1247
1335
  )
1248
1336
 
1337
+ class Meta:
1338
+ verbose_name = "Node"
1339
+ verbose_name_plural = "Nodes"
1340
+
1249
1341
 
1250
1342
  node_information_updated = Signal()
1251
1343
 
@@ -1698,7 +1790,6 @@ class EmailOutbox(Profile):
1698
1790
  return owner
1699
1791
  return str(self.node) if self.node_id else ""
1700
1792
 
1701
-
1702
1793
  class NetMessage(Entity):
1703
1794
  """Message propagated across nodes."""
1704
1795
 
@@ -1808,9 +1899,44 @@ class NetMessage(Entity):
1808
1899
  )
1809
1900
  if normalized_attachments:
1810
1901
  msg.apply_attachments(normalized_attachments)
1902
+ msg.notify_slack()
1811
1903
  msg.propagate(seen=seen or [])
1812
1904
  return msg
1813
1905
 
1906
+ def notify_slack(self):
1907
+ """Send this Net Message to any Slack chatbots owned by the origin node."""
1908
+
1909
+ try:
1910
+ SlackBotProfile = apps.get_model("teams", "SlackBotProfile")
1911
+ except (LookupError, ValueError):
1912
+ return
1913
+ if SlackBotProfile is None:
1914
+ return
1915
+
1916
+ origin = self.node_origin
1917
+ if origin is None:
1918
+ origin = Node.get_local()
1919
+ if not origin:
1920
+ return
1921
+
1922
+ try:
1923
+ bots = SlackBotProfile.objects.filter(node=origin, is_enabled=True)
1924
+ except Exception: # pragma: no cover - database errors surfaced in logs
1925
+ logger.exception(
1926
+ "Failed to load Slack chatbots for node %s", getattr(origin, "pk", None)
1927
+ )
1928
+ return
1929
+
1930
+ for bot in bots:
1931
+ try:
1932
+ bot.broadcast_net_message(self)
1933
+ except Exception: # pragma: no cover - network errors logged for diagnosis
1934
+ logger.exception(
1935
+ "Slack bot %s failed to broadcast NetMessage %s",
1936
+ getattr(bot, "pk", None),
1937
+ getattr(self, "pk", None),
1938
+ )
1939
+
1814
1940
  @staticmethod
1815
1941
  def normalize_attachments(
1816
1942
  attachments: object,
@@ -2251,6 +2377,8 @@ class PendingNetMessage(models.Model):
2251
2377
  class Meta:
2252
2378
  unique_together = ("node", "message")
2253
2379
  ordering = ("queued_at",)
2380
+ verbose_name = "Pending Net Message"
2381
+ verbose_name_plural = "Pending Net Messages"
2254
2382
 
2255
2383
  def __str__(self) -> str: # pragma: no cover - simple representation
2256
2384
  return f"{self.message_id} → {self.node_id}"
nodes/tasks.py CHANGED
@@ -36,7 +36,7 @@ def sample_clipboard() -> None:
36
36
 
37
37
  @shared_task
38
38
  def capture_node_screenshot(
39
- url: str | None = None, port: int = 8000, method: str = "TASK"
39
+ url: str | None = None, port: int = 8888, method: str = "TASK"
40
40
  ) -> str:
41
41
  """Capture a screenshot of ``url`` and record it as a :class:`ContentSample`."""
42
42
  if url is None: