arthexis 0.1.8__py3-none-any.whl → 0.1.10__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.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.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 +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- 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 +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- 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 +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- 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.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
nodes/models.py
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
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 Signal, 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
|
|
11
|
+
from django.utils import timezone
|
|
11
12
|
from django.utils.text import slugify
|
|
12
13
|
from django.conf import settings
|
|
13
14
|
from django.contrib.sites.models import Site
|
|
15
|
+
from datetime import timedelta
|
|
14
16
|
import uuid
|
|
15
17
|
import os
|
|
18
|
+
import shutil
|
|
16
19
|
import socket
|
|
20
|
+
import stat
|
|
21
|
+
import subprocess
|
|
17
22
|
from pathlib import Path
|
|
18
23
|
from utils import revision
|
|
24
|
+
from core.notifications import notify_async
|
|
19
25
|
from django.core.exceptions import ValidationError
|
|
20
26
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
21
27
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
22
28
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
23
29
|
from django.contrib.auth import get_user_model
|
|
24
|
-
from django.core.mail import get_connection
|
|
30
|
+
from django.core.mail import get_connection
|
|
31
|
+
from core import mailer
|
|
25
32
|
import logging
|
|
26
33
|
|
|
27
34
|
|
|
@@ -53,6 +60,60 @@ class NodeRole(Entity):
|
|
|
53
60
|
return self.name
|
|
54
61
|
|
|
55
62
|
|
|
63
|
+
class NodeFeatureManager(models.Manager):
|
|
64
|
+
def get_by_natural_key(self, slug: str):
|
|
65
|
+
return self.get(slug=slug)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NodeFeature(Entity):
|
|
69
|
+
"""Feature that may be enabled on nodes and roles."""
|
|
70
|
+
|
|
71
|
+
slug = models.SlugField(max_length=50, unique=True)
|
|
72
|
+
display = models.CharField(max_length=50)
|
|
73
|
+
roles = models.ManyToManyField(NodeRole, blank=True, related_name="features")
|
|
74
|
+
|
|
75
|
+
objects = NodeFeatureManager()
|
|
76
|
+
|
|
77
|
+
class Meta:
|
|
78
|
+
ordering = ["display"]
|
|
79
|
+
verbose_name = "Node Feature"
|
|
80
|
+
verbose_name_plural = "Node Features"
|
|
81
|
+
|
|
82
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
83
|
+
return (self.slug,)
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
86
|
+
return self.display
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def is_enabled(self) -> bool:
|
|
90
|
+
from django.conf import settings
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
|
|
93
|
+
node = Node.get_local()
|
|
94
|
+
if not node:
|
|
95
|
+
return False
|
|
96
|
+
if node.features.filter(pk=self.pk).exists():
|
|
97
|
+
return True
|
|
98
|
+
if self.slug == "gui-toast":
|
|
99
|
+
from core.notifications import supports_gui_toast
|
|
100
|
+
|
|
101
|
+
return supports_gui_toast()
|
|
102
|
+
if self.slug == "rpi-camera":
|
|
103
|
+
return Node._has_rpi_camera()
|
|
104
|
+
lock_map = {
|
|
105
|
+
"lcd-screen": "lcd_screen.lck",
|
|
106
|
+
"rfid-scanner": "rfid.lck",
|
|
107
|
+
"celery-queue": "celery.lck",
|
|
108
|
+
"nginx-server": "nginx_mode.lck",
|
|
109
|
+
}
|
|
110
|
+
lock = lock_map.get(self.slug)
|
|
111
|
+
if lock:
|
|
112
|
+
base_path = Path(node.base_path or settings.BASE_DIR)
|
|
113
|
+
return (base_path / "locks" / lock).exists()
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
56
117
|
def get_terminal_role():
|
|
57
118
|
"""Return the NodeRole representing a Terminal if it exists."""
|
|
58
119
|
return NodeRole.objects.filter(name="Terminal").first()
|
|
@@ -63,9 +124,7 @@ class Node(Entity):
|
|
|
63
124
|
|
|
64
125
|
hostname = models.CharField(max_length=100)
|
|
65
126
|
address = models.GenericIPAddressField()
|
|
66
|
-
mac_address = models.CharField(
|
|
67
|
-
max_length=17, unique=True, null=True, blank=True
|
|
68
|
-
)
|
|
127
|
+
mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
|
|
69
128
|
port = models.PositiveIntegerField(default=8000)
|
|
70
129
|
badge_color = models.CharField(max_length=7, default="#28a745")
|
|
71
130
|
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
@@ -75,8 +134,6 @@ class Node(Entity):
|
|
|
75
134
|
verbose_name="enable public API",
|
|
76
135
|
)
|
|
77
136
|
public_endpoint = models.SlugField(blank=True, unique=True)
|
|
78
|
-
clipboard_polling = models.BooleanField(default=False)
|
|
79
|
-
screenshot_polling = models.BooleanField(default=False)
|
|
80
137
|
uuid = models.UUIDField(
|
|
81
138
|
default=uuid.uuid4,
|
|
82
139
|
unique=True,
|
|
@@ -87,7 +144,31 @@ class Node(Entity):
|
|
|
87
144
|
base_path = models.CharField(max_length=255, blank=True)
|
|
88
145
|
installed_version = models.CharField(max_length=20, blank=True)
|
|
89
146
|
installed_revision = models.CharField(max_length=40, blank=True)
|
|
90
|
-
|
|
147
|
+
features = models.ManyToManyField(
|
|
148
|
+
NodeFeature,
|
|
149
|
+
through="NodeFeatureAssignment",
|
|
150
|
+
related_name="nodes",
|
|
151
|
+
blank=True,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
FEATURE_LOCK_MAP = {
|
|
155
|
+
"lcd-screen": "lcd_screen.lck",
|
|
156
|
+
"rfid-scanner": "rfid.lck",
|
|
157
|
+
"celery-queue": "celery.lck",
|
|
158
|
+
"nginx-server": "nginx_mode.lck",
|
|
159
|
+
}
|
|
160
|
+
RPI_CAMERA_DEVICE = Path("/dev/video0")
|
|
161
|
+
RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
|
|
162
|
+
AP_ROUTER_SSID = "gelectriic-ap"
|
|
163
|
+
NMCLI_TIMEOUT = 5
|
|
164
|
+
AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
|
|
165
|
+
"gui-toast",
|
|
166
|
+
"rpi-camera",
|
|
167
|
+
"ap-router",
|
|
168
|
+
"ap-public-wifi",
|
|
169
|
+
"postgres-db",
|
|
170
|
+
}
|
|
171
|
+
MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll"}
|
|
91
172
|
|
|
92
173
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
93
174
|
return f"{self.hostname}:{self.port}"
|
|
@@ -122,7 +203,6 @@ class Node(Entity):
|
|
|
122
203
|
node = cls.objects.filter(mac_address=mac).first()
|
|
123
204
|
if not node:
|
|
124
205
|
node = cls.objects.filter(public_endpoint=slug).first()
|
|
125
|
-
lcd_lock = Path(settings.BASE_DIR) / "locks" / "lcd_screen.lck"
|
|
126
206
|
defaults = {
|
|
127
207
|
"hostname": hostname,
|
|
128
208
|
"address": address,
|
|
@@ -132,14 +212,11 @@ class Node(Entity):
|
|
|
132
212
|
"installed_revision": installed_revision,
|
|
133
213
|
"public_endpoint": slug,
|
|
134
214
|
"mac_address": mac,
|
|
135
|
-
"has_lcd_screen": lcd_lock.exists(),
|
|
136
215
|
}
|
|
137
216
|
if node:
|
|
138
217
|
for field, value in defaults.items():
|
|
139
|
-
if field == "has_lcd_screen":
|
|
140
|
-
continue
|
|
141
218
|
setattr(node, field, value)
|
|
142
|
-
update_fields =
|
|
219
|
+
update_fields = list(defaults.keys())
|
|
143
220
|
node.save(update_fields=update_fields)
|
|
144
221
|
created = False
|
|
145
222
|
else:
|
|
@@ -161,17 +238,119 @@ class Node(Entity):
|
|
|
161
238
|
node.save(update_fields=["role"])
|
|
162
239
|
Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
|
|
163
240
|
node.ensure_keys()
|
|
241
|
+
node.notify_peers_of_update()
|
|
164
242
|
return node, created
|
|
165
243
|
|
|
244
|
+
def notify_peers_of_update(self):
|
|
245
|
+
"""Attempt to update this node's registration with known peers."""
|
|
246
|
+
|
|
247
|
+
from secrets import token_hex
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
import requests
|
|
251
|
+
except Exception: # pragma: no cover - requests should be available
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
|
|
255
|
+
priv_path = security_dir / f"{self.public_endpoint}"
|
|
256
|
+
if not priv_path.exists():
|
|
257
|
+
logger.debug("Private key for %s not found; skipping peer update", self)
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
private_key = serialization.load_pem_private_key(
|
|
261
|
+
priv_path.read_bytes(), password=None
|
|
262
|
+
)
|
|
263
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
264
|
+
logger.warning("Failed to load private key for %s: %s", self, exc)
|
|
265
|
+
return
|
|
266
|
+
token = token_hex(16)
|
|
267
|
+
try:
|
|
268
|
+
signature = private_key.sign(
|
|
269
|
+
token.encode(),
|
|
270
|
+
padding.PKCS1v15(),
|
|
271
|
+
hashes.SHA256(),
|
|
272
|
+
)
|
|
273
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
274
|
+
logger.warning("Failed to sign peer update for %s: %s", self, exc)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
payload = {
|
|
278
|
+
"hostname": self.hostname,
|
|
279
|
+
"address": self.address,
|
|
280
|
+
"port": self.port,
|
|
281
|
+
"mac_address": self.mac_address,
|
|
282
|
+
"public_key": self.public_key,
|
|
283
|
+
"token": token,
|
|
284
|
+
"signature": base64.b64encode(signature).decode(),
|
|
285
|
+
}
|
|
286
|
+
if self.installed_version:
|
|
287
|
+
payload["installed_version"] = self.installed_version
|
|
288
|
+
if self.installed_revision:
|
|
289
|
+
payload["installed_revision"] = self.installed_revision
|
|
290
|
+
|
|
291
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
292
|
+
headers = {"Content-Type": "application/json"}
|
|
293
|
+
|
|
294
|
+
peers = Node.objects.exclude(pk=self.pk)
|
|
295
|
+
for peer in peers:
|
|
296
|
+
host_candidates: list[str] = []
|
|
297
|
+
if peer.address:
|
|
298
|
+
host_candidates.append(peer.address)
|
|
299
|
+
if peer.hostname and peer.hostname not in host_candidates:
|
|
300
|
+
host_candidates.append(peer.hostname)
|
|
301
|
+
port = peer.port or 8000
|
|
302
|
+
urls: list[str] = []
|
|
303
|
+
for host in host_candidates:
|
|
304
|
+
host = host.strip()
|
|
305
|
+
if not host:
|
|
306
|
+
continue
|
|
307
|
+
if ":" in host and not host.startswith("["):
|
|
308
|
+
host = f"[{host}]"
|
|
309
|
+
http_url = (
|
|
310
|
+
f"http://{host}/nodes/register/"
|
|
311
|
+
if port == 80
|
|
312
|
+
else f"http://{host}:{port}/nodes/register/"
|
|
313
|
+
)
|
|
314
|
+
https_url = (
|
|
315
|
+
f"https://{host}/nodes/register/"
|
|
316
|
+
if port in {80, 443}
|
|
317
|
+
else f"https://{host}:{port}/nodes/register/"
|
|
318
|
+
)
|
|
319
|
+
for url in (https_url, http_url):
|
|
320
|
+
if url not in urls:
|
|
321
|
+
urls.append(url)
|
|
322
|
+
if not urls:
|
|
323
|
+
continue
|
|
324
|
+
for url in urls:
|
|
325
|
+
try:
|
|
326
|
+
response = requests.post(
|
|
327
|
+
url, data=payload_json, headers=headers, timeout=2
|
|
328
|
+
)
|
|
329
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
330
|
+
logger.debug("Failed to update %s via %s: %s", peer, url, exc)
|
|
331
|
+
continue
|
|
332
|
+
if response.ok:
|
|
333
|
+
version_display = _format_upgrade_body(
|
|
334
|
+
self.installed_version,
|
|
335
|
+
self.installed_revision,
|
|
336
|
+
)
|
|
337
|
+
version_suffix = f" ({version_display})" if version_display else ""
|
|
338
|
+
logger.info(
|
|
339
|
+
"Announced startup to %s%s",
|
|
340
|
+
peer,
|
|
341
|
+
version_suffix,
|
|
342
|
+
)
|
|
343
|
+
break
|
|
344
|
+
else:
|
|
345
|
+
logger.warning("Unable to notify node %s of startup", peer)
|
|
346
|
+
|
|
166
347
|
def ensure_keys(self):
|
|
167
348
|
security_dir = Path(settings.BASE_DIR) / "security"
|
|
168
349
|
security_dir.mkdir(parents=True, exist_ok=True)
|
|
169
350
|
priv_path = security_dir / f"{self.public_endpoint}"
|
|
170
351
|
pub_path = security_dir / f"{self.public_endpoint}.pub"
|
|
171
352
|
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
|
-
)
|
|
353
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
175
354
|
private_bytes = private_key.private_bytes(
|
|
176
355
|
encoding=serialization.Encoding.PEM,
|
|
177
356
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
@@ -199,22 +378,196 @@ class Node(Entity):
|
|
|
199
378
|
self.mac_address = self.mac_address.lower()
|
|
200
379
|
if not self.public_endpoint:
|
|
201
380
|
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
381
|
super().save(*args, **kwargs)
|
|
208
|
-
if
|
|
209
|
-
self.
|
|
210
|
-
|
|
211
|
-
|
|
382
|
+
if self.pk:
|
|
383
|
+
self.refresh_features()
|
|
384
|
+
|
|
385
|
+
def has_feature(self, slug: str) -> bool:
|
|
386
|
+
return self.features.filter(slug=slug).exists()
|
|
387
|
+
|
|
388
|
+
@classmethod
|
|
389
|
+
def _has_rpi_camera(cls) -> bool:
|
|
390
|
+
"""Return ``True`` when the Raspberry Pi camera stack is available."""
|
|
391
|
+
|
|
392
|
+
device = cls.RPI_CAMERA_DEVICE
|
|
393
|
+
if not device.exists():
|
|
394
|
+
return False
|
|
395
|
+
device_path = str(device)
|
|
396
|
+
try:
|
|
397
|
+
mode = os.stat(device_path).st_mode
|
|
398
|
+
except OSError:
|
|
399
|
+
return False
|
|
400
|
+
if not stat.S_ISCHR(mode):
|
|
401
|
+
return False
|
|
402
|
+
if not os.access(device_path, os.R_OK | os.W_OK):
|
|
403
|
+
return False
|
|
404
|
+
for binary in cls.RPI_CAMERA_BINARIES:
|
|
405
|
+
tool_path = shutil.which(binary)
|
|
406
|
+
if not tool_path:
|
|
407
|
+
return False
|
|
408
|
+
try:
|
|
409
|
+
result = subprocess.run(
|
|
410
|
+
[tool_path, "--help"],
|
|
411
|
+
capture_output=True,
|
|
412
|
+
text=True,
|
|
413
|
+
check=False,
|
|
414
|
+
timeout=5,
|
|
415
|
+
)
|
|
416
|
+
except Exception:
|
|
417
|
+
return False
|
|
418
|
+
if result.returncode != 0:
|
|
419
|
+
return False
|
|
420
|
+
return True
|
|
421
|
+
|
|
422
|
+
@classmethod
|
|
423
|
+
def _hosts_gelectriic_ap(cls) -> bool:
|
|
424
|
+
"""Return ``True`` when the node is hosting the gelectriic access point."""
|
|
425
|
+
|
|
426
|
+
nmcli_path = shutil.which("nmcli")
|
|
427
|
+
if not nmcli_path:
|
|
428
|
+
return False
|
|
429
|
+
try:
|
|
430
|
+
result = subprocess.run(
|
|
431
|
+
[
|
|
432
|
+
nmcli_path,
|
|
433
|
+
"-t",
|
|
434
|
+
"-f",
|
|
435
|
+
"NAME,DEVICE,TYPE",
|
|
436
|
+
"connection",
|
|
437
|
+
"show",
|
|
438
|
+
"--active",
|
|
439
|
+
],
|
|
440
|
+
capture_output=True,
|
|
441
|
+
text=True,
|
|
442
|
+
check=False,
|
|
443
|
+
timeout=cls.NMCLI_TIMEOUT,
|
|
444
|
+
)
|
|
445
|
+
except Exception:
|
|
446
|
+
return False
|
|
447
|
+
if result.returncode != 0:
|
|
448
|
+
return False
|
|
449
|
+
for line in result.stdout.splitlines():
|
|
450
|
+
if not line:
|
|
451
|
+
continue
|
|
452
|
+
parts = line.split(":", 2)
|
|
453
|
+
if not parts:
|
|
454
|
+
continue
|
|
455
|
+
name = parts[0]
|
|
456
|
+
conn_type = ""
|
|
457
|
+
if len(parts) == 3:
|
|
458
|
+
conn_type = parts[2]
|
|
459
|
+
elif len(parts) > 1:
|
|
460
|
+
conn_type = parts[1]
|
|
461
|
+
if name != cls.AP_ROUTER_SSID:
|
|
462
|
+
continue
|
|
463
|
+
conn_type_normalized = conn_type.strip().lower()
|
|
464
|
+
if conn_type_normalized not in {"wifi", "802-11-wireless"}:
|
|
465
|
+
continue
|
|
466
|
+
try:
|
|
467
|
+
mode_result = subprocess.run(
|
|
468
|
+
[
|
|
469
|
+
nmcli_path,
|
|
470
|
+
"-g",
|
|
471
|
+
"802-11-wireless.mode",
|
|
472
|
+
"connection",
|
|
473
|
+
"show",
|
|
474
|
+
name,
|
|
475
|
+
],
|
|
476
|
+
capture_output=True,
|
|
477
|
+
text=True,
|
|
478
|
+
check=False,
|
|
479
|
+
timeout=cls.NMCLI_TIMEOUT,
|
|
480
|
+
)
|
|
481
|
+
except Exception:
|
|
482
|
+
continue
|
|
483
|
+
if mode_result.returncode != 0:
|
|
484
|
+
continue
|
|
485
|
+
if mode_result.stdout.strip() == "ap":
|
|
486
|
+
return True
|
|
487
|
+
return False
|
|
488
|
+
|
|
489
|
+
@staticmethod
|
|
490
|
+
def _uses_postgres() -> bool:
|
|
491
|
+
"""Return ``True`` when the default database uses PostgreSQL."""
|
|
212
492
|
|
|
213
|
-
|
|
493
|
+
engine = settings.DATABASES.get("default", {}).get("ENGINE", "")
|
|
494
|
+
return "postgresql" in engine.lower()
|
|
495
|
+
|
|
496
|
+
def refresh_features(self):
|
|
497
|
+
if not self.pk:
|
|
498
|
+
return
|
|
499
|
+
if not self.is_local:
|
|
500
|
+
self.sync_feature_tasks()
|
|
501
|
+
return
|
|
502
|
+
detected_slugs = set()
|
|
503
|
+
base_path = Path(self.base_path or settings.BASE_DIR)
|
|
504
|
+
locks_dir = base_path / "locks"
|
|
505
|
+
for slug, filename in self.FEATURE_LOCK_MAP.items():
|
|
506
|
+
if (locks_dir / filename).exists():
|
|
507
|
+
detected_slugs.add(slug)
|
|
508
|
+
if self._has_rpi_camera():
|
|
509
|
+
detected_slugs.add("rpi-camera")
|
|
510
|
+
public_mode_lock = locks_dir / "public_wifi_mode.lck"
|
|
511
|
+
if self._hosts_gelectriic_ap():
|
|
512
|
+
if public_mode_lock.exists():
|
|
513
|
+
detected_slugs.add("ap-public-wifi")
|
|
514
|
+
else:
|
|
515
|
+
detected_slugs.add("ap-router")
|
|
516
|
+
if self._uses_postgres():
|
|
517
|
+
detected_slugs.add("postgres-db")
|
|
518
|
+
try:
|
|
519
|
+
from core.notifications import supports_gui_toast
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
else:
|
|
523
|
+
try:
|
|
524
|
+
if supports_gui_toast():
|
|
525
|
+
detected_slugs.add("gui-toast")
|
|
526
|
+
except Exception:
|
|
527
|
+
pass
|
|
528
|
+
current_slugs = set(
|
|
529
|
+
self.features.filter(slug__in=self.AUTO_MANAGED_FEATURES).values_list(
|
|
530
|
+
"slug", flat=True
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
add_slugs = detected_slugs - current_slugs
|
|
534
|
+
if add_slugs:
|
|
535
|
+
for feature in NodeFeature.objects.filter(slug__in=add_slugs):
|
|
536
|
+
NodeFeatureAssignment.objects.update_or_create(
|
|
537
|
+
node=self, feature=feature
|
|
538
|
+
)
|
|
539
|
+
remove_slugs = current_slugs - detected_slugs
|
|
540
|
+
if remove_slugs:
|
|
541
|
+
NodeFeatureAssignment.objects.filter(
|
|
542
|
+
node=self, feature__slug__in=remove_slugs
|
|
543
|
+
).delete()
|
|
544
|
+
self.sync_feature_tasks()
|
|
545
|
+
|
|
546
|
+
def update_manual_features(self, slugs: Iterable[str]):
|
|
547
|
+
desired = {slug for slug in slugs if slug in self.MANUAL_FEATURE_SLUGS}
|
|
548
|
+
remove_slugs = self.MANUAL_FEATURE_SLUGS - desired
|
|
549
|
+
if remove_slugs:
|
|
550
|
+
NodeFeatureAssignment.objects.filter(
|
|
551
|
+
node=self, feature__slug__in=remove_slugs
|
|
552
|
+
).delete()
|
|
553
|
+
if desired:
|
|
554
|
+
for feature in NodeFeature.objects.filter(slug__in=desired):
|
|
555
|
+
NodeFeatureAssignment.objects.update_or_create(
|
|
556
|
+
node=self, feature=feature
|
|
557
|
+
)
|
|
558
|
+
self.sync_feature_tasks()
|
|
559
|
+
|
|
560
|
+
def sync_feature_tasks(self):
|
|
561
|
+
clipboard_enabled = self.has_feature("clipboard-poll")
|
|
562
|
+
screenshot_enabled = self.has_feature("screenshot-poll")
|
|
563
|
+
self._sync_clipboard_task(clipboard_enabled)
|
|
564
|
+
self._sync_screenshot_task(screenshot_enabled)
|
|
565
|
+
|
|
566
|
+
def _sync_clipboard_task(self, enabled: bool):
|
|
214
567
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
215
568
|
|
|
216
569
|
task_name = f"poll_clipboard_node_{self.pk}"
|
|
217
|
-
if
|
|
570
|
+
if enabled:
|
|
218
571
|
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
219
572
|
every=5, period=IntervalSchedule.SECONDS
|
|
220
573
|
)
|
|
@@ -228,12 +581,12 @@ class Node(Entity):
|
|
|
228
581
|
else:
|
|
229
582
|
PeriodicTask.objects.filter(name=task_name).delete()
|
|
230
583
|
|
|
231
|
-
def _sync_screenshot_task(self):
|
|
584
|
+
def _sync_screenshot_task(self, enabled: bool):
|
|
232
585
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
233
586
|
import json
|
|
234
587
|
|
|
235
588
|
task_name = f"capture_screenshot_node_{self.pk}"
|
|
236
|
-
if
|
|
589
|
+
if enabled:
|
|
237
590
|
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
238
591
|
every=1, period=IntervalSchedule.MINUTES
|
|
239
592
|
)
|
|
@@ -254,45 +607,139 @@ class Node(Entity):
|
|
|
254
607
|
else:
|
|
255
608
|
PeriodicTask.objects.filter(name=task_name).delete()
|
|
256
609
|
|
|
257
|
-
|
|
258
|
-
|
|
610
|
+
def send_mail(
|
|
611
|
+
self,
|
|
612
|
+
subject: str,
|
|
613
|
+
message: str,
|
|
614
|
+
recipient_list: list[str],
|
|
615
|
+
from_email: str | None = None,
|
|
616
|
+
**kwargs,
|
|
617
|
+
):
|
|
259
618
|
"""Send an email using this node's configured outbox if available."""
|
|
260
619
|
outbox = getattr(self, "email_outbox", None)
|
|
261
620
|
logger.info(
|
|
262
|
-
"Node %s
|
|
621
|
+
"Node %s queueing email to %s using %s backend",
|
|
263
622
|
self.pk,
|
|
264
623
|
recipient_list,
|
|
265
624
|
"outbox" if outbox else "default",
|
|
266
625
|
)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
626
|
+
return mailer.send(
|
|
627
|
+
subject,
|
|
628
|
+
message,
|
|
629
|
+
recipient_list,
|
|
630
|
+
from_email,
|
|
631
|
+
outbox=outbox,
|
|
632
|
+
**kwargs,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
node_information_updated = Signal()
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _format_upgrade_body(version: str, revision: str) -> str:
|
|
640
|
+
version = (version or "").strip()
|
|
641
|
+
revision = (revision or "").strip()
|
|
642
|
+
parts: list[str] = []
|
|
643
|
+
if version:
|
|
644
|
+
normalized = version.lstrip("vV") or version
|
|
645
|
+
parts.append(f"v{normalized}")
|
|
646
|
+
if revision:
|
|
647
|
+
rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
|
|
648
|
+
rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
|
|
649
|
+
parts.append(f"r{rev_short}")
|
|
650
|
+
return " ".join(parts).strip()
|
|
651
|
+
|
|
275
652
|
|
|
653
|
+
@receiver(node_information_updated)
|
|
654
|
+
def _announce_peer_startup(
|
|
655
|
+
sender,
|
|
656
|
+
*,
|
|
657
|
+
node: "Node",
|
|
658
|
+
previous_version: str = "",
|
|
659
|
+
previous_revision: str = "",
|
|
660
|
+
current_version: str = "",
|
|
661
|
+
current_revision: str = "",
|
|
662
|
+
**_: object,
|
|
663
|
+
) -> None:
|
|
664
|
+
current_version = (current_version or "").strip()
|
|
665
|
+
current_revision = (current_revision or "").strip()
|
|
666
|
+
previous_version = (previous_version or "").strip()
|
|
667
|
+
previous_revision = (previous_revision or "").strip()
|
|
276
668
|
|
|
277
|
-
|
|
278
|
-
|
|
669
|
+
local = Node.get_local()
|
|
670
|
+
if local and node.pk == local.pk:
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
body = _format_upgrade_body(current_version, current_revision)
|
|
674
|
+
if not body:
|
|
675
|
+
body = "Online"
|
|
676
|
+
|
|
677
|
+
hostname = (node.hostname or "Node").strip() or "Node"
|
|
678
|
+
subject = f"UP {hostname}"
|
|
679
|
+
notify_async(subject, body)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
class NodeFeatureAssignment(Entity):
|
|
683
|
+
"""Bridge between :class:`Node` and :class:`NodeFeature`."""
|
|
684
|
+
|
|
685
|
+
node = models.ForeignKey(
|
|
686
|
+
Node, on_delete=models.CASCADE, related_name="feature_assignments"
|
|
687
|
+
)
|
|
688
|
+
feature = models.ForeignKey(
|
|
689
|
+
NodeFeature, on_delete=models.CASCADE, related_name="node_assignments"
|
|
690
|
+
)
|
|
691
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
692
|
+
|
|
693
|
+
class Meta:
|
|
694
|
+
unique_together = ("node", "feature")
|
|
695
|
+
verbose_name = "Node Feature Assignment"
|
|
696
|
+
verbose_name_plural = "Node Feature Assignments"
|
|
697
|
+
|
|
698
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
699
|
+
return f"{self.node} -> {self.feature}"
|
|
700
|
+
|
|
701
|
+
def save(self, *args, **kwargs):
|
|
702
|
+
super().save(*args, **kwargs)
|
|
703
|
+
self.node.sync_feature_tasks()
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@receiver(post_delete, sender=NodeFeatureAssignment)
|
|
707
|
+
def _sync_tasks_on_assignment_delete(sender, instance, **kwargs):
|
|
708
|
+
node_id = getattr(instance, "node_id", None)
|
|
709
|
+
if not node_id:
|
|
710
|
+
return
|
|
711
|
+
node = Node.objects.filter(pk=node_id).first()
|
|
712
|
+
if node:
|
|
713
|
+
node.sync_feature_tasks()
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
class EmailOutbox(Profile):
|
|
717
|
+
"""SMTP credentials for sending mail."""
|
|
718
|
+
|
|
719
|
+
profile_fields = (
|
|
720
|
+
"host",
|
|
721
|
+
"port",
|
|
722
|
+
"username",
|
|
723
|
+
"password",
|
|
724
|
+
"use_tls",
|
|
725
|
+
"use_ssl",
|
|
726
|
+
"from_email",
|
|
727
|
+
)
|
|
279
728
|
|
|
280
729
|
node = models.OneToOneField(
|
|
281
|
-
Node,
|
|
730
|
+
Node,
|
|
731
|
+
on_delete=models.CASCADE,
|
|
732
|
+
related_name="email_outbox",
|
|
733
|
+
null=True,
|
|
734
|
+
blank=True,
|
|
282
735
|
)
|
|
283
736
|
host = SigilShortAutoField(
|
|
284
737
|
max_length=100,
|
|
285
|
-
help_text=(
|
|
286
|
-
"Gmail: smtp.gmail.com. "
|
|
287
|
-
"GoDaddy: smtpout.secureserver.net"
|
|
288
|
-
),
|
|
738
|
+
help_text=("Gmail: smtp.gmail.com. " "GoDaddy: smtpout.secureserver.net"),
|
|
289
739
|
)
|
|
290
740
|
port = models.PositiveIntegerField(
|
|
291
741
|
default=587,
|
|
292
|
-
help_text=(
|
|
293
|
-
"Gmail: 587 (TLS). "
|
|
294
|
-
"GoDaddy: 587 (TLS) or 465 (SSL)"
|
|
295
|
-
),
|
|
742
|
+
help_text=("Gmail: 587 (TLS). " "GoDaddy: 587 (TLS) or 465 (SSL)"),
|
|
296
743
|
)
|
|
297
744
|
username = SigilShortAutoField(
|
|
298
745
|
max_length=100,
|
|
@@ -323,12 +770,35 @@ class EmailOutbox(Entity):
|
|
|
323
770
|
verbose_name = "Email Outbox"
|
|
324
771
|
verbose_name_plural = "Email Outboxes"
|
|
325
772
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
773
|
+
def __str__(self) -> str:
|
|
774
|
+
address = (self.from_email or "").strip()
|
|
775
|
+
if address:
|
|
776
|
+
return address
|
|
777
|
+
|
|
778
|
+
username = (self.username or "").strip()
|
|
779
|
+
host = (self.host or "").strip()
|
|
780
|
+
if username and host:
|
|
781
|
+
return f"{username}@{host}"
|
|
782
|
+
if username:
|
|
783
|
+
return username
|
|
784
|
+
if host:
|
|
785
|
+
return host
|
|
786
|
+
|
|
787
|
+
owner = self.owner_display()
|
|
788
|
+
if owner:
|
|
789
|
+
return owner
|
|
790
|
+
|
|
791
|
+
return super().__str__()
|
|
792
|
+
|
|
793
|
+
def clean(self):
|
|
794
|
+
if self.user_id or self.group_id:
|
|
795
|
+
super().clean()
|
|
796
|
+
else:
|
|
797
|
+
super(Profile, self).clean()
|
|
329
798
|
|
|
330
799
|
def get_connection(self):
|
|
331
800
|
return get_connection(
|
|
801
|
+
"django.core.mail.backends.smtp.EmailBackend",
|
|
332
802
|
host=self.host,
|
|
333
803
|
port=self.port,
|
|
334
804
|
username=self.username or None,
|
|
@@ -338,17 +808,23 @@ class EmailOutbox(Entity):
|
|
|
338
808
|
)
|
|
339
809
|
|
|
340
810
|
def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
|
|
341
|
-
connection = self.get_connection()
|
|
342
811
|
from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
|
|
343
|
-
|
|
812
|
+
logger.info("EmailOutbox %s queueing email to %s", self.pk, recipient_list)
|
|
813
|
+
return mailer.send(
|
|
344
814
|
subject,
|
|
345
815
|
message,
|
|
346
|
-
from_email,
|
|
347
816
|
recipient_list,
|
|
348
|
-
|
|
817
|
+
from_email,
|
|
818
|
+
outbox=self,
|
|
349
819
|
**kwargs,
|
|
350
820
|
)
|
|
351
821
|
|
|
822
|
+
def owner_display(self):
|
|
823
|
+
owner = super().owner_display()
|
|
824
|
+
if owner:
|
|
825
|
+
return owner
|
|
826
|
+
return str(self.node) if self.node_id else ""
|
|
827
|
+
|
|
352
828
|
|
|
353
829
|
class NetMessage(Entity):
|
|
354
830
|
"""Message propagated across nodes."""
|
|
@@ -359,6 +835,13 @@ class NetMessage(Entity):
|
|
|
359
835
|
editable=False,
|
|
360
836
|
verbose_name="UUID",
|
|
361
837
|
)
|
|
838
|
+
node_origin = models.ForeignKey(
|
|
839
|
+
"Node",
|
|
840
|
+
on_delete=models.SET_NULL,
|
|
841
|
+
null=True,
|
|
842
|
+
blank=True,
|
|
843
|
+
related_name="originated_net_messages",
|
|
844
|
+
)
|
|
362
845
|
subject = models.CharField(max_length=64, blank=True)
|
|
363
846
|
body = models.CharField(max_length=256, blank=True)
|
|
364
847
|
reach = models.ForeignKey(
|
|
@@ -393,10 +876,12 @@ class NetMessage(Entity):
|
|
|
393
876
|
role = reach
|
|
394
877
|
else:
|
|
395
878
|
role = NodeRole.objects.filter(name=reach).first()
|
|
879
|
+
origin = Node.get_local()
|
|
396
880
|
msg = cls.objects.create(
|
|
397
881
|
subject=subject[:64],
|
|
398
882
|
body=body[:256],
|
|
399
883
|
reach=role or get_terminal_role(),
|
|
884
|
+
node_origin=origin,
|
|
400
885
|
)
|
|
401
886
|
msg.propagate(seen=seen or [])
|
|
402
887
|
return msg
|
|
@@ -406,8 +891,28 @@ class NetMessage(Entity):
|
|
|
406
891
|
import random
|
|
407
892
|
import requests
|
|
408
893
|
|
|
409
|
-
notify(self.subject, self.body)
|
|
894
|
+
displayed = notify(self.subject, self.body)
|
|
410
895
|
local = Node.get_local()
|
|
896
|
+
if displayed:
|
|
897
|
+
cutoff = timezone.now() - timedelta(days=7)
|
|
898
|
+
prune_qs = type(self).objects.filter(created__lt=cutoff)
|
|
899
|
+
if local:
|
|
900
|
+
prune_qs = prune_qs.filter(
|
|
901
|
+
models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
|
|
902
|
+
)
|
|
903
|
+
else:
|
|
904
|
+
prune_qs = prune_qs.filter(node_origin__isnull=True)
|
|
905
|
+
if self.pk:
|
|
906
|
+
prune_qs = prune_qs.exclude(pk=self.pk)
|
|
907
|
+
prune_qs.delete()
|
|
908
|
+
if local and not self.node_origin_id:
|
|
909
|
+
self.node_origin = local
|
|
910
|
+
self.save(update_fields=["node_origin"])
|
|
911
|
+
origin_uuid = None
|
|
912
|
+
if self.node_origin_id:
|
|
913
|
+
origin_uuid = str(self.node_origin.uuid)
|
|
914
|
+
elif local:
|
|
915
|
+
origin_uuid = str(local.uuid)
|
|
411
916
|
private_key = None
|
|
412
917
|
seen = list(seen or [])
|
|
413
918
|
local_id = None
|
|
@@ -448,12 +953,15 @@ class NetMessage(Entity):
|
|
|
448
953
|
|
|
449
954
|
reach_name = self.reach.name if self.reach else "Terminal"
|
|
450
955
|
role_map = {
|
|
451
|
-
"
|
|
452
|
-
"
|
|
453
|
-
"
|
|
454
|
-
"
|
|
455
|
-
|
|
456
|
-
|
|
956
|
+
"Terminal": ["Terminal"],
|
|
957
|
+
"Control": ["Control", "Terminal"],
|
|
958
|
+
"Satellite": ["Satellite", "Control", "Terminal"],
|
|
959
|
+
"Constellation": [
|
|
960
|
+
"Constellation",
|
|
961
|
+
"Satellite",
|
|
962
|
+
"Control",
|
|
963
|
+
"Terminal",
|
|
964
|
+
],
|
|
457
965
|
}
|
|
458
966
|
role_order = role_map.get(reach_name, ["Terminal"])
|
|
459
967
|
selected: list[Node] = []
|
|
@@ -479,6 +987,7 @@ class NetMessage(Entity):
|
|
|
479
987
|
"seen": payload_seen,
|
|
480
988
|
"reach": reach_name,
|
|
481
989
|
"sender": local_id,
|
|
990
|
+
"origin": origin_uuid,
|
|
482
991
|
}
|
|
483
992
|
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
484
993
|
headers = {"Content-Type": "application/json"}
|
|
@@ -527,9 +1036,7 @@ class ContentSample(Entity):
|
|
|
527
1036
|
db_index=True,
|
|
528
1037
|
verbose_name="transaction UUID",
|
|
529
1038
|
)
|
|
530
|
-
node = models.ForeignKey(
|
|
531
|
-
Node, on_delete=models.SET_NULL, null=True, blank=True
|
|
532
|
-
)
|
|
1039
|
+
node = models.ForeignKey(Node, on_delete=models.SET_NULL, null=True, blank=True)
|
|
533
1040
|
user = models.ForeignKey(
|
|
534
1041
|
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
|
|
535
1042
|
)
|
|
@@ -555,109 +1062,6 @@ class ContentSample(Entity):
|
|
|
555
1062
|
return str(self.name)
|
|
556
1063
|
|
|
557
1064
|
|
|
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
1065
|
UserModel = get_user_model()
|
|
662
1066
|
|
|
663
1067
|
|
|
@@ -667,7 +1071,3 @@ class User(UserModel):
|
|
|
667
1071
|
app_label = "nodes"
|
|
668
1072
|
verbose_name = UserModel._meta.verbose_name
|
|
669
1073
|
verbose_name_plural = UserModel._meta.verbose_name_plural
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|