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.
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/METADATA +16 -11
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/RECORD +39 -38
- config/settings.py +7 -1
- config/settings_helpers.py +176 -1
- config/urls.py +18 -2
- core/admin.py +265 -23
- core/apps.py +6 -2
- core/celery_utils.py +73 -0
- core/models.py +307 -63
- core/system.py +17 -2
- core/tasks.py +304 -129
- core/test_system_info.py +43 -5
- core/tests.py +202 -2
- core/user_data.py +52 -19
- core/views.py +70 -3
- nodes/admin.py +348 -3
- nodes/apps.py +1 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +146 -18
- nodes/tasks.py +1 -1
- nodes/tests.py +181 -48
- nodes/views.py +148 -3
- ocpp/admin.py +1001 -10
- ocpp/consumers.py +572 -7
- ocpp/models.py +499 -33
- ocpp/store.py +406 -40
- ocpp/tasks.py +109 -145
- ocpp/test_rfid.py +73 -2
- ocpp/tests.py +982 -90
- ocpp/urls.py +5 -0
- ocpp/views.py +172 -70
- pages/context_processors.py +2 -0
- pages/models.py +9 -0
- pages/tests.py +166 -18
- pages/urls.py +1 -0
- pages/views.py +66 -3
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
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=
|
|
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
|
|
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",
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1164
|
+
raw_task_name = f"poll_clipboard_node_{self.pk}"
|
|
1102
1165
|
if enabled:
|
|
1103
1166
|
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
1104
|
-
every=
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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=
|
|
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(
|
|
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 =
|
|
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:
|