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.

Files changed (81) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +133 -16
  10. config/urls.py +65 -6
  11. core/admin.py +1226 -191
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1071 -264
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +358 -63
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +7 -3
  42. core/workgroup_views.py +43 -6
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +1 -1
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.8.dist-info/RECORD +0 -80
  77. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  78. config/workgroup_app.py +0 -7
  79. core/checks.py +0 -29
  80. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  81. {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.fields import (
4
- SigilShortAutoField,
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, send_mail
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
- has_lcd_screen = models.BooleanField(default=False)
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 = [k for k in defaults.keys() if k != "has_lcd_screen"]
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 previous_clipboard != self.clipboard_polling:
209
- self._sync_clipboard_task()
210
- if previous_screenshot != self.screenshot_polling:
211
- self._sync_screenshot_task()
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 _sync_clipboard_task(self):
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 self.clipboard_polling:
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 self.screenshot_polling:
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
- def send_mail(self, subject: str, message: str, recipient_list: list[str], from_email: str | None = None, **kwargs):
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 sending email to %s using %s backend",
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
- if outbox:
268
- result = outbox.send_mail(subject, message, recipient_list, from_email, **kwargs)
269
- logger.info("Outbox send_mail result: %s", result)
270
- return result
271
- from_email = from_email or settings.DEFAULT_FROM_EMAIL
272
- result = send_mail(subject, message, from_email, recipient_list, **kwargs)
273
- logger.info("Default send_mail result: %s", result)
274
- return result
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
- class EmailOutbox(Entity):
278
- """SMTP credentials for sending mail from a node."""
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, on_delete=models.CASCADE, related_name="email_outbox"
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
- class Meta:
327
- verbose_name = "Email Outbox"
328
- verbose_name_plural = "Email Outboxes"
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
- return send_mail(
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
- connection=connection,
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
- "Particle": ["Particle"],
452
- "Terminal": ["Terminal", "Particle"],
453
- "Control": ["Control", "Terminal", "Particle"],
454
- "Satellite": ["Satellite", "Control", "Terminal", "Particle"],
455
- "Constellation": ["Constellation", "Satellite", "Control", "Terminal", "Particle"],
456
- "Virtual": ["Virtual", "Constellation", "Satellite", "Control", "Terminal", "Particle"],
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
-