arthexis 0.1.8__py3-none-any.whl → 0.1.9__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.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
- arthexis-0.1.9.dist-info/RECORD +92 -0
- arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +133 -16
- config/urls.py +65 -6
- core/admin.py +1226 -191
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +158 -3
- core/backends.py +46 -4
- core/entity.py +62 -48
- core/fields.py +6 -1
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1071 -264
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/release.py +27 -20
- core/sigil_builder.py +131 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +129 -10
- core/tasks.py +118 -19
- core/test_system_info.py +22 -0
- core/tests.py +358 -63
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +329 -167
- core/views.py +383 -57
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +159 -284
- nodes/apps.py +9 -15
- nodes/backends.py +53 -0
- nodes/lcd.py +24 -10
- nodes/models.py +375 -178
- nodes/tasks.py +1 -5
- nodes/tests.py +524 -129
- nodes/utils.py +13 -2
- nodes/views.py +66 -23
- ocpp/admin.py +150 -61
- ocpp/apps.py +1 -1
- ocpp/consumers.py +432 -69
- ocpp/evcs.py +25 -8
- ocpp/models.py +408 -68
- ocpp/simulator.py +13 -6
- ocpp/store.py +258 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1198 -135
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +654 -101
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +19 -6
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +759 -40
- pages/urls.py +3 -0
- pages/utils.py +0 -1
- pages/views.py +576 -25
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
nodes/models.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
1
2
|
from django.db import models
|
|
3
|
+
from django.db.models.signals import post_delete
|
|
4
|
+
from django.dispatch import receiver
|
|
2
5
|
from core.entity import Entity
|
|
3
|
-
from core.
|
|
4
|
-
|
|
5
|
-
SigilLongCheckField,
|
|
6
|
-
SigilLongAutoField,
|
|
7
|
-
)
|
|
6
|
+
from core.models import Profile
|
|
7
|
+
from core.fields import SigilShortAutoField
|
|
8
8
|
import re
|
|
9
9
|
import json
|
|
10
10
|
import base64
|
|
@@ -13,7 +13,10 @@ from django.conf import settings
|
|
|
13
13
|
from django.contrib.sites.models import Site
|
|
14
14
|
import uuid
|
|
15
15
|
import os
|
|
16
|
+
import shutil
|
|
16
17
|
import socket
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
17
20
|
from pathlib import Path
|
|
18
21
|
from utils import revision
|
|
19
22
|
from django.core.exceptions import ValidationError
|
|
@@ -21,7 +24,8 @@ from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
21
24
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
22
25
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
23
26
|
from django.contrib.auth import get_user_model
|
|
24
|
-
from django.core.mail import get_connection
|
|
27
|
+
from django.core.mail import get_connection
|
|
28
|
+
from core import mailer
|
|
25
29
|
import logging
|
|
26
30
|
|
|
27
31
|
|
|
@@ -53,6 +57,60 @@ class NodeRole(Entity):
|
|
|
53
57
|
return self.name
|
|
54
58
|
|
|
55
59
|
|
|
60
|
+
class NodeFeatureManager(models.Manager):
|
|
61
|
+
def get_by_natural_key(self, slug: str):
|
|
62
|
+
return self.get(slug=slug)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NodeFeature(Entity):
|
|
66
|
+
"""Feature that may be enabled on nodes and roles."""
|
|
67
|
+
|
|
68
|
+
slug = models.SlugField(max_length=50, unique=True)
|
|
69
|
+
display = models.CharField(max_length=50)
|
|
70
|
+
roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
|
|
71
|
+
|
|
72
|
+
objects = NodeFeatureManager()
|
|
73
|
+
|
|
74
|
+
class Meta:
|
|
75
|
+
ordering = ["display"]
|
|
76
|
+
verbose_name = "Node Feature"
|
|
77
|
+
verbose_name_plural = "Node Features"
|
|
78
|
+
|
|
79
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
80
|
+
return (self.slug,)
|
|
81
|
+
|
|
82
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
83
|
+
return self.display
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_enabled(self) -> bool:
|
|
87
|
+
from django.conf import settings
|
|
88
|
+
from pathlib import Path
|
|
89
|
+
|
|
90
|
+
node = Node.get_local()
|
|
91
|
+
if not node:
|
|
92
|
+
return False
|
|
93
|
+
if node.features.filter(pk=self.pk).exists():
|
|
94
|
+
return True
|
|
95
|
+
if self.slug == "gui-toast":
|
|
96
|
+
from core.notifications import supports_gui_toast
|
|
97
|
+
|
|
98
|
+
return supports_gui_toast()
|
|
99
|
+
if self.slug == "rpi-camera":
|
|
100
|
+
return Node._has_rpi_camera()
|
|
101
|
+
lock_map = {
|
|
102
|
+
"lcd-screen": "lcd_screen.lck",
|
|
103
|
+
"rfid-scanner": "rfid.lck",
|
|
104
|
+
"celery-queue": "celery.lck",
|
|
105
|
+
"nginx-server": "nginx_mode.lck",
|
|
106
|
+
}
|
|
107
|
+
lock = lock_map.get(self.slug)
|
|
108
|
+
if lock:
|
|
109
|
+
base_path = Path(node.base_path or settings.BASE_DIR)
|
|
110
|
+
return (base_path / "locks" / lock).exists()
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
56
114
|
def get_terminal_role():
|
|
57
115
|
"""Return the NodeRole representing a Terminal if it exists."""
|
|
58
116
|
return NodeRole.objects.filter(name="Terminal").first()
|
|
@@ -63,9 +121,7 @@ class Node(Entity):
|
|
|
63
121
|
|
|
64
122
|
hostname = models.CharField(max_length=100)
|
|
65
123
|
address = models.GenericIPAddressField()
|
|
66
|
-
mac_address = models.CharField(
|
|
67
|
-
max_length=17, unique=True, null=True, blank=True
|
|
68
|
-
)
|
|
124
|
+
mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
|
|
69
125
|
port = models.PositiveIntegerField(default=8000)
|
|
70
126
|
badge_color = models.CharField(max_length=7, default="#28a745")
|
|
71
127
|
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
@@ -75,8 +131,6 @@ class Node(Entity):
|
|
|
75
131
|
verbose_name="enable public API",
|
|
76
132
|
)
|
|
77
133
|
public_endpoint = models.SlugField(blank=True, unique=True)
|
|
78
|
-
clipboard_polling = models.BooleanField(default=False)
|
|
79
|
-
screenshot_polling = models.BooleanField(default=False)
|
|
80
134
|
uuid = models.UUIDField(
|
|
81
135
|
default=uuid.uuid4,
|
|
82
136
|
unique=True,
|
|
@@ -87,7 +141,31 @@ class Node(Entity):
|
|
|
87
141
|
base_path = models.CharField(max_length=255, blank=True)
|
|
88
142
|
installed_version = models.CharField(max_length=20, blank=True)
|
|
89
143
|
installed_revision = models.CharField(max_length=40, blank=True)
|
|
90
|
-
|
|
144
|
+
features = models.ManyToManyField(
|
|
145
|
+
NodeFeature,
|
|
146
|
+
through="NodeFeatureAssignment",
|
|
147
|
+
related_name="nodes",
|
|
148
|
+
blank=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
FEATURE_LOCK_MAP = {
|
|
152
|
+
"lcd-screen": "lcd_screen.lck",
|
|
153
|
+
"rfid-scanner": "rfid.lck",
|
|
154
|
+
"celery-queue": "celery.lck",
|
|
155
|
+
"nginx-server": "nginx_mode.lck",
|
|
156
|
+
}
|
|
157
|
+
RPI_CAMERA_DEVICE = Path("/dev/video0")
|
|
158
|
+
RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
|
|
159
|
+
AP_ROUTER_SSID = "gelectriic-ap"
|
|
160
|
+
NMCLI_TIMEOUT = 5
|
|
161
|
+
AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
|
|
162
|
+
"gui-toast",
|
|
163
|
+
"rpi-camera",
|
|
164
|
+
"ap-router",
|
|
165
|
+
"ap-public-wifi",
|
|
166
|
+
"postgres-db",
|
|
167
|
+
}
|
|
168
|
+
MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
|
|
91
169
|
|
|
92
170
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
93
171
|
return f"{self.hostname}:{self.port}"
|
|
@@ -122,7 +200,6 @@ class Node(Entity):
|
|
|
122
200
|
node = cls.objects.filter(mac_address=mac).first()
|
|
123
201
|
if not node:
|
|
124
202
|
node = cls.objects.filter(public_endpoint=slug).first()
|
|
125
|
-
lcd_lock = Path(settings.BASE_DIR) / "locks" / "lcd_screen.lck"
|
|
126
203
|
defaults = {
|
|
127
204
|
"hostname": hostname,
|
|
128
205
|
"address": address,
|
|
@@ -132,14 +209,11 @@ class Node(Entity):
|
|
|
132
209
|
"installed_revision": installed_revision,
|
|
133
210
|
"public_endpoint": slug,
|
|
134
211
|
"mac_address": mac,
|
|
135
|
-
"has_lcd_screen": lcd_lock.exists(),
|
|
136
212
|
}
|
|
137
213
|
if node:
|
|
138
214
|
for field, value in defaults.items():
|
|
139
|
-
if field == "has_lcd_screen":
|
|
140
|
-
continue
|
|
141
215
|
setattr(node, field, value)
|
|
142
|
-
update_fields =
|
|
216
|
+
update_fields = list(defaults.keys())
|
|
143
217
|
node.save(update_fields=update_fields)
|
|
144
218
|
created = False
|
|
145
219
|
else:
|
|
@@ -169,9 +243,7 @@ class Node(Entity):
|
|
|
169
243
|
priv_path = security_dir / f"{self.public_endpoint}"
|
|
170
244
|
pub_path = security_dir / f"{self.public_endpoint}.pub"
|
|
171
245
|
if not priv_path.exists() or not pub_path.exists():
|
|
172
|
-
private_key = rsa.generate_private_key(
|
|
173
|
-
public_exponent=65537, key_size=2048
|
|
174
|
-
)
|
|
246
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
175
247
|
private_bytes = private_key.private_bytes(
|
|
176
248
|
encoding=serialization.Encoding.PEM,
|
|
177
249
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
@@ -199,22 +271,196 @@ class Node(Entity):
|
|
|
199
271
|
self.mac_address = self.mac_address.lower()
|
|
200
272
|
if not self.public_endpoint:
|
|
201
273
|
self.public_endpoint = slugify(self.hostname)
|
|
202
|
-
previous_clipboard = previous_screenshot = None
|
|
203
|
-
if self.pk:
|
|
204
|
-
previous = Node.objects.get(pk=self.pk)
|
|
205
|
-
previous_clipboard = previous.clipboard_polling
|
|
206
|
-
previous_screenshot = previous.screenshot_polling
|
|
207
274
|
super().save(*args, **kwargs)
|
|
208
|
-
if
|
|
209
|
-
self.
|
|
210
|
-
|
|
211
|
-
|
|
275
|
+
if self.pk:
|
|
276
|
+
self.refresh_features()
|
|
277
|
+
|
|
278
|
+
def has_feature(self, slug: str) -> bool:
|
|
279
|
+
return self.features.filter(slug=slug).exists()
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def _has_rpi_camera(cls) -> bool:
|
|
283
|
+
"""Return ``True`` when the Raspberry Pi camera stack is available."""
|
|
284
|
+
|
|
285
|
+
device = cls.RPI_CAMERA_DEVICE
|
|
286
|
+
if not device.exists():
|
|
287
|
+
return False
|
|
288
|
+
device_path = str(device)
|
|
289
|
+
try:
|
|
290
|
+
mode = os.stat(device_path).st_mode
|
|
291
|
+
except OSError:
|
|
292
|
+
return False
|
|
293
|
+
if not stat.S_ISCHR(mode):
|
|
294
|
+
return False
|
|
295
|
+
if not os.access(device_path, os.R_OK | os.W_OK):
|
|
296
|
+
return False
|
|
297
|
+
for binary in cls.RPI_CAMERA_BINARIES:
|
|
298
|
+
tool_path = shutil.which(binary)
|
|
299
|
+
if not tool_path:
|
|
300
|
+
return False
|
|
301
|
+
try:
|
|
302
|
+
result = subprocess.run(
|
|
303
|
+
[tool_path, "--help"],
|
|
304
|
+
capture_output=True,
|
|
305
|
+
text=True,
|
|
306
|
+
check=False,
|
|
307
|
+
timeout=5,
|
|
308
|
+
)
|
|
309
|
+
except Exception:
|
|
310
|
+
return False
|
|
311
|
+
if result.returncode != 0:
|
|
312
|
+
return False
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def _hosts_gelectriic_ap(cls) -> bool:
|
|
317
|
+
"""Return ``True`` when the node is hosting the gelectriic access point."""
|
|
318
|
+
|
|
319
|
+
nmcli_path = shutil.which("nmcli")
|
|
320
|
+
if not nmcli_path:
|
|
321
|
+
return False
|
|
322
|
+
try:
|
|
323
|
+
result = subprocess.run(
|
|
324
|
+
[
|
|
325
|
+
nmcli_path,
|
|
326
|
+
"-t",
|
|
327
|
+
"-f",
|
|
328
|
+
"NAME,DEVICE,TYPE",
|
|
329
|
+
"connection",
|
|
330
|
+
"show",
|
|
331
|
+
"--active",
|
|
332
|
+
],
|
|
333
|
+
capture_output=True,
|
|
334
|
+
text=True,
|
|
335
|
+
check=False,
|
|
336
|
+
timeout=cls.NMCLI_TIMEOUT,
|
|
337
|
+
)
|
|
338
|
+
except Exception:
|
|
339
|
+
return False
|
|
340
|
+
if result.returncode != 0:
|
|
341
|
+
return False
|
|
342
|
+
for line in result.stdout.splitlines():
|
|
343
|
+
if not line:
|
|
344
|
+
continue
|
|
345
|
+
parts = line.split(":", 2)
|
|
346
|
+
if not parts:
|
|
347
|
+
continue
|
|
348
|
+
name = parts[0]
|
|
349
|
+
conn_type = ""
|
|
350
|
+
if len(parts) == 3:
|
|
351
|
+
conn_type = parts[2]
|
|
352
|
+
elif len(parts) > 1:
|
|
353
|
+
conn_type = parts[1]
|
|
354
|
+
if name != cls.AP_ROUTER_SSID:
|
|
355
|
+
continue
|
|
356
|
+
conn_type_normalized = conn_type.strip().lower()
|
|
357
|
+
if conn_type_normalized not in {"wifi", "802-11-wireless"}:
|
|
358
|
+
continue
|
|
359
|
+
try:
|
|
360
|
+
mode_result = subprocess.run(
|
|
361
|
+
[
|
|
362
|
+
nmcli_path,
|
|
363
|
+
"-g",
|
|
364
|
+
"802-11-wireless.mode",
|
|
365
|
+
"connection",
|
|
366
|
+
"show",
|
|
367
|
+
name,
|
|
368
|
+
],
|
|
369
|
+
capture_output=True,
|
|
370
|
+
text=True,
|
|
371
|
+
check=False,
|
|
372
|
+
timeout=cls.NMCLI_TIMEOUT,
|
|
373
|
+
)
|
|
374
|
+
except Exception:
|
|
375
|
+
continue
|
|
376
|
+
if mode_result.returncode != 0:
|
|
377
|
+
continue
|
|
378
|
+
if mode_result.stdout.strip() == "ap":
|
|
379
|
+
return True
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _uses_postgres() -> bool:
|
|
384
|
+
"""Return ``True`` when the default database uses PostgreSQL."""
|
|
385
|
+
|
|
386
|
+
engine = settings.DATABASES.get("default", {}).get("ENGINE", "")
|
|
387
|
+
return "postgresql" in engine.lower()
|
|
388
|
+
|
|
389
|
+
def refresh_features(self):
|
|
390
|
+
if not self.pk:
|
|
391
|
+
return
|
|
392
|
+
if not self.is_local:
|
|
393
|
+
self.sync_feature_tasks()
|
|
394
|
+
return
|
|
395
|
+
detected_slugs = set()
|
|
396
|
+
base_path = Path(self.base_path or settings.BASE_DIR)
|
|
397
|
+
locks_dir = base_path / "locks"
|
|
398
|
+
for slug, filename in self.FEATURE_LOCK_MAP.items():
|
|
399
|
+
if (locks_dir / filename).exists():
|
|
400
|
+
detected_slugs.add(slug)
|
|
401
|
+
if self._has_rpi_camera():
|
|
402
|
+
detected_slugs.add("rpi-camera")
|
|
403
|
+
public_mode_lock = locks_dir / "public_wifi_mode.lck"
|
|
404
|
+
if self._hosts_gelectriic_ap():
|
|
405
|
+
if public_mode_lock.exists():
|
|
406
|
+
detected_slugs.add("ap-public-wifi")
|
|
407
|
+
else:
|
|
408
|
+
detected_slugs.add("ap-router")
|
|
409
|
+
if self._uses_postgres():
|
|
410
|
+
detected_slugs.add("postgres-db")
|
|
411
|
+
try:
|
|
412
|
+
from core.notifications import supports_gui_toast
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
else:
|
|
416
|
+
try:
|
|
417
|
+
if supports_gui_toast():
|
|
418
|
+
detected_slugs.add("gui-toast")
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
421
|
+
current_slugs = set(
|
|
422
|
+
self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
|
|
423
|
+
"slug", flat=True
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
add_slugs = detected_slugs - current_slugs
|
|
427
|
+
if add_slugs:
|
|
428
|
+
for feature in NodeFeature.objects.filter(slug__in=add_slugs):
|
|
429
|
+
NodeFeatureAssignment.objects.update_or_create(
|
|
430
|
+
node=self, feature=feature
|
|
431
|
+
)
|
|
432
|
+
remove_slugs = current_slugs - detected_slugs
|
|
433
|
+
if remove_slugs:
|
|
434
|
+
NodeFeatureAssignment.objects.filter(
|
|
435
|
+
node=self, feature__slug__in=remove_slugs
|
|
436
|
+
).delete()
|
|
437
|
+
self.sync_feature_tasks()
|
|
438
|
+
|
|
439
|
+
def update_manual_features(self, slugs: Iterable[str]):
|
|
440
|
+
desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
|
|
441
|
+
remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
|
|
442
|
+
if remove_slugs:
|
|
443
|
+
NodeFeatureAssignment.objects.filter(
|
|
444
|
+
node=self, feature__slug__in=remove_slugs
|
|
445
|
+
).delete()
|
|
446
|
+
if desired:
|
|
447
|
+
for feature in NodeFeature.objects.filter(slug__in=desired):
|
|
448
|
+
NodeFeatureAssignment.objects.update_or_create(
|
|
449
|
+
node=self, feature=feature
|
|
450
|
+
)
|
|
451
|
+
self.sync_feature_tasks()
|
|
212
452
|
|
|
213
|
-
def
|
|
453
|
+
def sync_feature_tasks(self):
|
|
454
|
+
clipboard_enabled = self.has_feature("clipboard-poll")
|
|
455
|
+
screenshot_enabled = self.has_feature("screenshot-poll")
|
|
456
|
+
self._sync_clipboard_task(clipboard_enabled)
|
|
457
|
+
self._sync_screenshot_task(screenshot_enabled)
|
|
458
|
+
|
|
459
|
+
def _sync_clipboard_task(self, enabled: bool):
|
|
214
460
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
215
461
|
|
|
216
462
|
task_name = f"poll_clipboard_node_{self.pk}"
|
|
217
|
-
if
|
|
463
|
+
if enabled:
|
|
218
464
|
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
219
465
|
every=5, period=IntervalSchedule.SECONDS
|
|
220
466
|
)
|
|
@@ -228,12 +474,12 @@ class Node(Entity):
|
|
|
228
474
|
else:
|
|
229
475
|
PeriodicTask.objects.filter(name=task_name).delete()
|
|
230
476
|
|
|
231
|
-
def _sync_screenshot_task(self):
|
|
477
|
+
def _sync_screenshot_task(self, enabled: bool):
|
|
232
478
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
233
479
|
import json
|
|
234
480
|
|
|
235
481
|
task_name = f"capture_screenshot_node_{self.pk}"
|
|
236
|
-
if
|
|
482
|
+
if enabled:
|
|
237
483
|
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
238
484
|
every=1, period=IntervalSchedule.MINUTES
|
|
239
485
|
)
|
|
@@ -254,45 +500,93 @@ class Node(Entity):
|
|
|
254
500
|
else:
|
|
255
501
|
PeriodicTask.objects.filter(name=task_name).delete()
|
|
256
502
|
|
|
257
|
-
|
|
258
|
-
|
|
503
|
+
def send_mail(
|
|
504
|
+
self,
|
|
505
|
+
subject: str,
|
|
506
|
+
message: str,
|
|
507
|
+
recipient_list: list[str],
|
|
508
|
+
from_email: str | None = None,
|
|
509
|
+
**kwargs,
|
|
510
|
+
):
|
|
259
511
|
"""Send an email using this node's configured outbox if available."""
|
|
260
512
|
outbox = getattr(self, "email_outbox", None)
|
|
261
513
|
logger.info(
|
|
262
|
-
"Node %s
|
|
514
|
+
"Node %s queueing email to %s using %s backend",
|
|
263
515
|
self.pk,
|
|
264
516
|
recipient_list,
|
|
265
517
|
"outbox" if outbox else "default",
|
|
266
518
|
)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
519
|
+
return mailer.send(
|
|
520
|
+
subject,
|
|
521
|
+
message,
|
|
522
|
+
recipient_list,
|
|
523
|
+
from_email,
|
|
524
|
+
outbox=outbox,
|
|
525
|
+
**kwargs,
|
|
526
|
+
)
|
|
527
|
+
|
|
275
528
|
|
|
529
|
+
class NodeFeatureAssignment(Entity):
|
|
530
|
+
"""Bridge between :class:`Node` and :class:`NodeFeature`."""
|
|
276
531
|
|
|
277
|
-
|
|
278
|
-
|
|
532
|
+
node = models.ForeignKey(
|
|
533
|
+
Node, on_delete=models.CASCADE, related_name="feature_assignments"
|
|
534
|
+
)
|
|
535
|
+
feature = models.ForeignKey(
|
|
536
|
+
NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
|
|
537
|
+
)
|
|
538
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
539
|
+
|
|
540
|
+
class Meta:
|
|
541
|
+
unique_together = ("node", "feature")
|
|
542
|
+
verbose_name = "Node Feature Assignment"
|
|
543
|
+
verbose_name_plural = "Node Feature Assignments"
|
|
544
|
+
|
|
545
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
546
|
+
return f"{self.node} -> {self.feature}"
|
|
547
|
+
|
|
548
|
+
def save(self, *args, **kwargs):
|
|
549
|
+
super().save(*args, **kwargs)
|
|
550
|
+
self.node.sync_feature_tasks()
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@receiver(post_delete, sender=NodeFeatureAssignment)
|
|
554
|
+
def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
|
|
555
|
+
node_id = getattr(instance, "node_id", None)
|
|
556
|
+
if not node_id:
|
|
557
|
+
return
|
|
558
|
+
node = Node.objects.filter(pk=node_id).first()
|
|
559
|
+
if node:
|
|
560
|
+
node.sync_feature_tasks()
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class EmailOutbox(Profile):
|
|
564
|
+
"""SMTP credentials for sending mail."""
|
|
565
|
+
|
|
566
|
+
profile_fields = (
|
|
567
|
+
"host",
|
|
568
|
+
"port",
|
|
569
|
+
"username",
|
|
570
|
+
"password",
|
|
571
|
+
"use_tls",
|
|
572
|
+
"use_ssl",
|
|
573
|
+
"from_email",
|
|
574
|
+
)
|
|
279
575
|
|
|
280
576
|
node = models.OneToOneField(
|
|
281
|
-
Node,
|
|
577
|
+
Node,
|
|
578
|
+
on_delete=models.CASCADE,
|
|
579
|
+
related_name="email_outbox",
|
|
580
|
+
null=True,
|
|
581
|
+
blank=True,
|
|
282
582
|
)
|
|
283
583
|
host = SigilShortAutoField(
|
|
284
584
|
max_length=100,
|
|
285
|
-
help_text=(
|
|
286
|
-
"Gmail: smtp.gmail.com. "
|
|
287
|
-
"GoDaddy: smtpout.secureserver.net"
|
|
288
|
-
),
|
|
585
|
+
help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
|
|
289
586
|
)
|
|
290
587
|
port = models.PositiveIntegerField(
|
|
291
588
|
default=587,
|
|
292
|
-
help_text=(
|
|
293
|
-
"Gmail: 587 (TLS). "
|
|
294
|
-
"GoDaddy: 587 (TLS) or 465 (SSL)"
|
|
295
|
-
),
|
|
589
|
+
help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
|
|
296
590
|
)
|
|
297
591
|
username = SigilShortAutoField(
|
|
298
592
|
max_length=100,
|
|
@@ -323,12 +617,15 @@ class EmailOutbox(Entity):
|
|
|
323
617
|
verbose_name = "Email Outbox"
|
|
324
618
|
verbose_name_plural = "Email Outboxes"
|
|
325
619
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
620
|
+
def clean(self):
|
|
621
|
+
if self.user_id or self.group_id:
|
|
622
|
+
super().clean()
|
|
623
|
+
else:
|
|
624
|
+
super(Profile, self).clean()
|
|
329
625
|
|
|
330
626
|
def get_connection(self):
|
|
331
627
|
return get_connection(
|
|
628
|
+
"django.core.mail.backends.smtp.EmailBackend",
|
|
332
629
|
host=self.host,
|
|
333
630
|
port=self.port,
|
|
334
631
|
username=self.username or None,
|
|
@@ -338,17 +635,23 @@ class EmailOutbox(Entity):
|
|
|
338
635
|
)
|
|
339
636
|
|
|
340
637
|
def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
|
|
341
|
-
connection = self.get_connection()
|
|
342
638
|
from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
|
|
343
|
-
|
|
639
|
+
logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
|
|
640
|
+
return mailer.send(
|
|
344
641
|
subject,
|
|
345
642
|
message,
|
|
346
|
-
from_email,
|
|
347
643
|
recipient_list,
|
|
348
|
-
|
|
644
|
+
from_email,
|
|
645
|
+
outbox=self,
|
|
349
646
|
**kwargs,
|
|
350
647
|
)
|
|
351
648
|
|
|
649
|
+
def owner_display(self):
|
|
650
|
+
owner = super().owner_display()
|
|
651
|
+
if owner:
|
|
652
|
+
return owner
|
|
653
|
+
return str(self.node) if self.node_id else ""
|
|
654
|
+
|
|
352
655
|
|
|
353
656
|
class NetMessage(Entity):
|
|
354
657
|
"""Message propagated across nodes."""
|
|
@@ -448,12 +751,15 @@ class NetMessage(Entity):
|
|
|
448
751
|
|
|
449
752
|
reach_name = self.reach.name if self.reach else "Terminal"
|
|
450
753
|
role_map = {
|
|
451
|
-
"
|
|
452
|
-
"
|
|
453
|
-
"
|
|
454
|
-
"
|
|
455
|
-
|
|
456
|
-
|
|
754
|
+
"Terminal": ["Terminal"],
|
|
755
|
+
"Control": ["Control", "Terminal"],
|
|
756
|
+
"Satellite": ["Satellite", "Control", "Terminal"],
|
|
757
|
+
"Constellation": [
|
|
758
|
+
"Constellation",
|
|
759
|
+
"Satellite",
|
|
760
|
+
"Control",
|
|
761
|
+
"Terminal",
|
|
762
|
+
],
|
|
457
763
|
}
|
|
458
764
|
role_order = role_map.get(reach_name, ["Terminal"])
|
|
459
765
|
selected: list[Node] = []
|
|
@@ -527,9 +833,7 @@ class ContentSample(Entity):
|
|
|
527
833
|
db_index=True,
|
|
528
834
|
verbose_name="transaction UUID",
|
|
529
835
|
)
|
|
530
|
-
node = models.ForeignKey(
|
|
531
|
-
Node, on_delete=models.SET_NULL, null=True, blank=True
|
|
532
|
-
)
|
|
836
|
+
node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
|
|
533
837
|
user = models.ForeignKey(
|
|
534
838
|
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
|
|
535
839
|
)
|
|
@@ -555,109 +859,6 @@ class ContentSample(Entity):
|
|
|
555
859
|
return str(self.name)
|
|
556
860
|
|
|
557
861
|
|
|
558
|
-
class NodeTask(Entity):
|
|
559
|
-
"""Script that can be executed on nodes."""
|
|
560
|
-
|
|
561
|
-
recipe = models.TextField()
|
|
562
|
-
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
563
|
-
created = models.DateTimeField(auto_now_add=True)
|
|
564
|
-
|
|
565
|
-
class Meta:
|
|
566
|
-
ordering = ["-created"]
|
|
567
|
-
verbose_name = "Node Task"
|
|
568
|
-
verbose_name_plural = "Node Tasks"
|
|
569
|
-
|
|
570
|
-
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
571
|
-
return self.recipe
|
|
572
|
-
|
|
573
|
-
def run(self, node: Node):
|
|
574
|
-
"""Execute this script on ``node`` and return its output."""
|
|
575
|
-
if not node.is_local:
|
|
576
|
-
raise NotImplementedError("Remote node execution is not implemented")
|
|
577
|
-
import subprocess
|
|
578
|
-
|
|
579
|
-
result = subprocess.run(
|
|
580
|
-
self.recipe, shell=True, capture_output=True, text=True
|
|
581
|
-
)
|
|
582
|
-
return result.stdout + result.stderr
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
class Operation(Entity):
|
|
586
|
-
"""Action that can change node or constellation state."""
|
|
587
|
-
|
|
588
|
-
name = models.SlugField(unique=True)
|
|
589
|
-
template = SigilLongCheckField(blank=True)
|
|
590
|
-
command = SigilLongAutoField(blank=True)
|
|
591
|
-
is_django = models.BooleanField(default=False)
|
|
592
|
-
next_operations = models.ManyToManyField(
|
|
593
|
-
"self",
|
|
594
|
-
through="Interrupt",
|
|
595
|
-
through_fields=("from_operation", "to_operation"),
|
|
596
|
-
symmetrical=False,
|
|
597
|
-
related_name="previous_operations",
|
|
598
|
-
)
|
|
599
|
-
|
|
600
|
-
class Meta:
|
|
601
|
-
ordering = ["name"]
|
|
602
|
-
|
|
603
|
-
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
604
|
-
return self.name
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
class Interrupt(Entity):
|
|
608
|
-
"""Intermediate transition between operations."""
|
|
609
|
-
|
|
610
|
-
name = models.CharField(max_length=100)
|
|
611
|
-
preview = SigilLongAutoField(blank=True)
|
|
612
|
-
priority = models.PositiveIntegerField(default=0)
|
|
613
|
-
from_operation = models.ForeignKey(
|
|
614
|
-
Operation,
|
|
615
|
-
on_delete=models.CASCADE,
|
|
616
|
-
related_name="outgoing_interrupts",
|
|
617
|
-
)
|
|
618
|
-
to_operation = models.ForeignKey(
|
|
619
|
-
Operation,
|
|
620
|
-
on_delete=models.CASCADE,
|
|
621
|
-
related_name="incoming_interrupts",
|
|
622
|
-
)
|
|
623
|
-
|
|
624
|
-
class Meta:
|
|
625
|
-
ordering = ["-priority"]
|
|
626
|
-
|
|
627
|
-
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
628
|
-
return self.name
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
class Logbook(Entity):
|
|
632
|
-
"""Record of executed operations."""
|
|
633
|
-
|
|
634
|
-
operation = models.ForeignKey(
|
|
635
|
-
Operation, on_delete=models.CASCADE, related_name="logs"
|
|
636
|
-
)
|
|
637
|
-
user = models.ForeignKey(
|
|
638
|
-
settings.AUTH_USER_MODEL,
|
|
639
|
-
null=True,
|
|
640
|
-
blank=True,
|
|
641
|
-
on_delete=models.SET_NULL,
|
|
642
|
-
)
|
|
643
|
-
input_text = models.TextField(blank=True)
|
|
644
|
-
output = models.TextField(blank=True)
|
|
645
|
-
error = models.TextField(blank=True)
|
|
646
|
-
interrupted = models.BooleanField(default=False)
|
|
647
|
-
interrupt = models.ForeignKey(
|
|
648
|
-
Interrupt, null=True, blank=True, on_delete=models.SET_NULL
|
|
649
|
-
)
|
|
650
|
-
created = models.DateTimeField(auto_now_add=True)
|
|
651
|
-
|
|
652
|
-
class Meta:
|
|
653
|
-
ordering = ["-created"]
|
|
654
|
-
verbose_name = "Logbook Entry"
|
|
655
|
-
verbose_name_plural = "Logbook"
|
|
656
|
-
|
|
657
|
-
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
658
|
-
return f"{self.operation} @ {self.created:%Y-%m-%d %H:%M:%S}"
|
|
659
|
-
|
|
660
|
-
|
|
661
862
|
UserModel = get_user_model()
|
|
662
863
|
|
|
663
864
|
|
|
@@ -667,7 +868,3 @@ class User(UserModel):
|
|
|
667
868
|
app_label = "nodes"
|
|
668
869
|
verbose_name = UserModel._meta.verbose_name
|
|
669
870
|
verbose_name_plural = UserModel._meta.verbose_name_plural
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|