arthexis 0.1.3__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 (73) hide show
  1. arthexis-0.1.3.dist-info/METADATA +126 -0
  2. arthexis-0.1.3.dist-info/RECORD +73 -0
  3. arthexis-0.1.3.dist-info/WHEEL +5 -0
  4. arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
  5. arthexis-0.1.3.dist-info/top_level.txt +5 -0
  6. config/__init__.py +6 -0
  7. config/active_app.py +15 -0
  8. config/asgi.py +29 -0
  9. config/auth_app.py +8 -0
  10. config/celery.py +19 -0
  11. config/context_processors.py +68 -0
  12. config/loadenv.py +11 -0
  13. config/logging.py +43 -0
  14. config/middleware.py +25 -0
  15. config/offline.py +47 -0
  16. config/settings.py +374 -0
  17. config/urls.py +91 -0
  18. config/wsgi.py +17 -0
  19. core/__init__.py +0 -0
  20. core/admin.py +830 -0
  21. core/apps.py +67 -0
  22. core/backends.py +82 -0
  23. core/entity.py +97 -0
  24. core/environment.py +43 -0
  25. core/fields.py +70 -0
  26. core/lcd_screen.py +77 -0
  27. core/middleware.py +34 -0
  28. core/models.py +1277 -0
  29. core/notifications.py +95 -0
  30. core/release.py +451 -0
  31. core/system.py +111 -0
  32. core/tasks.py +100 -0
  33. core/tests.py +483 -0
  34. core/urls.py +11 -0
  35. core/user_data.py +333 -0
  36. core/views.py +431 -0
  37. nodes/__init__.py +0 -0
  38. nodes/actions.py +72 -0
  39. nodes/admin.py +347 -0
  40. nodes/apps.py +76 -0
  41. nodes/lcd.py +151 -0
  42. nodes/models.py +577 -0
  43. nodes/tasks.py +50 -0
  44. nodes/tests.py +1072 -0
  45. nodes/urls.py +13 -0
  46. nodes/utils.py +62 -0
  47. nodes/views.py +262 -0
  48. ocpp/__init__.py +0 -0
  49. ocpp/admin.py +392 -0
  50. ocpp/apps.py +24 -0
  51. ocpp/consumers.py +267 -0
  52. ocpp/evcs.py +911 -0
  53. ocpp/models.py +300 -0
  54. ocpp/routing.py +9 -0
  55. ocpp/simulator.py +357 -0
  56. ocpp/store.py +175 -0
  57. ocpp/tasks.py +27 -0
  58. ocpp/test_export_import.py +129 -0
  59. ocpp/test_rfid.py +345 -0
  60. ocpp/tests.py +1229 -0
  61. ocpp/transactions_io.py +119 -0
  62. ocpp/urls.py +17 -0
  63. ocpp/views.py +359 -0
  64. pages/__init__.py +0 -0
  65. pages/admin.py +231 -0
  66. pages/apps.py +10 -0
  67. pages/checks.py +41 -0
  68. pages/context_processors.py +72 -0
  69. pages/models.py +224 -0
  70. pages/tests.py +628 -0
  71. pages/urls.py +17 -0
  72. pages/utils.py +13 -0
  73. pages/views.py +191 -0
nodes/models.py ADDED
@@ -0,0 +1,577 @@
1
+ from django.db import models
2
+ from core.entity import Entity
3
+ from core.fields import SigilShortAutoField
4
+ import re
5
+ import json
6
+ import base64
7
+ from django.utils.text import slugify
8
+ from django.conf import settings
9
+ from django.contrib.sites.models import Site
10
+ import uuid
11
+ import os
12
+ import socket
13
+ from pathlib import Path
14
+ from utils import revision
15
+ from django.core.exceptions import ValidationError
16
+ from cryptography.hazmat.primitives.asymmetric import rsa
17
+ from cryptography.hazmat.primitives import serialization, hashes
18
+ from cryptography.hazmat.primitives.asymmetric import padding
19
+ from django.contrib.auth import get_user_model
20
+ from django.core.mail import get_connection, send_mail
21
+
22
+
23
+ class NodeRoleManager(models.Manager):
24
+ def get_by_natural_key(self, name: str):
25
+ return self.get(name=name)
26
+
27
+
28
+ class NodeRole(Entity):
29
+ """Assignable role for a :class:`Node`."""
30
+
31
+ name = models.CharField(max_length=50, unique=True)
32
+ description = models.CharField(max_length=200, blank=True)
33
+
34
+ objects = NodeRoleManager()
35
+
36
+ class Meta:
37
+ ordering = ["name"]
38
+ verbose_name = "Node Role"
39
+ verbose_name_plural = "Node Roles"
40
+
41
+ def natural_key(self): # pragma: no cover - simple representation
42
+ return (self.name,)
43
+
44
+ def __str__(self) -> str: # pragma: no cover - simple representation
45
+ return self.name
46
+
47
+
48
+ def get_terminal_role():
49
+ """Return the NodeRole representing a Terminal if it exists."""
50
+ return NodeRole.objects.filter(name="Terminal").first()
51
+
52
+
53
+ class Node(Entity):
54
+ """Information about a running node in the network."""
55
+
56
+ hostname = models.CharField(max_length=100)
57
+ address = models.GenericIPAddressField()
58
+ mac_address = models.CharField(
59
+ max_length=17, unique=True, null=True, blank=True
60
+ )
61
+ port = models.PositiveIntegerField(default=8000)
62
+ badge_color = models.CharField(max_length=7, default="#28a745")
63
+ role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
64
+ last_seen = models.DateTimeField(auto_now=True)
65
+ enable_public_api = models.BooleanField(
66
+ default=False,
67
+ verbose_name="enable public API",
68
+ )
69
+ public_endpoint = models.SlugField(blank=True, unique=True)
70
+ clipboard_polling = models.BooleanField(default=False)
71
+ screenshot_polling = models.BooleanField(default=False)
72
+ uuid = models.UUIDField(
73
+ default=uuid.uuid4,
74
+ unique=True,
75
+ editable=False,
76
+ verbose_name="UUID",
77
+ )
78
+ public_key = models.TextField(blank=True)
79
+ base_path = models.CharField(max_length=255, blank=True)
80
+ installed_version = models.CharField(max_length=20, blank=True)
81
+ installed_revision = models.CharField(max_length=40, blank=True)
82
+ has_lcd_screen = models.BooleanField(default=False)
83
+
84
+ def __str__(self) -> str: # pragma: no cover - simple representation
85
+ return f"{self.hostname}:{self.port}"
86
+
87
+ @staticmethod
88
+ def get_current_mac() -> str:
89
+ """Return the MAC address of the current host."""
90
+ return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
91
+
92
+ @classmethod
93
+ def get_local(cls):
94
+ """Return the node representing the current host if it exists."""
95
+ mac = cls.get_current_mac()
96
+ return cls.objects.filter(mac_address=mac).first()
97
+
98
+ @classmethod
99
+ def register_current(cls):
100
+ """Create or update the :class:`Node` entry for this host."""
101
+ hostname = socket.gethostname()
102
+ try:
103
+ address = socket.gethostbyname(hostname)
104
+ except OSError:
105
+ address = "127.0.0.1"
106
+ port = int(os.environ.get("PORT", 8000))
107
+ base_path = str(settings.BASE_DIR)
108
+ ver_path = Path(settings.BASE_DIR) / "VERSION"
109
+ installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
110
+ rev_value = revision.get_revision()
111
+ installed_revision = rev_value if rev_value else ""
112
+ mac = cls.get_current_mac()
113
+ slug = slugify(hostname)
114
+ node = cls.objects.filter(mac_address=mac).first()
115
+ if not node:
116
+ node = cls.objects.filter(public_endpoint=slug).first()
117
+ lcd_lock = Path(settings.BASE_DIR) / "locks" / "lcd_screen.lck"
118
+ defaults = {
119
+ "hostname": hostname,
120
+ "address": address,
121
+ "port": port,
122
+ "base_path": base_path,
123
+ "installed_version": installed_version,
124
+ "installed_revision": installed_revision,
125
+ "public_endpoint": slug,
126
+ "mac_address": mac,
127
+ "has_lcd_screen": lcd_lock.exists(),
128
+ }
129
+ if node:
130
+ for field, value in defaults.items():
131
+ if field == "has_lcd_screen":
132
+ continue
133
+ setattr(node, field, value)
134
+ update_fields = [k for k in defaults.keys() if k != "has_lcd_screen"]
135
+ node.save(update_fields=update_fields)
136
+ created = False
137
+ else:
138
+ node = cls.objects.create(**defaults)
139
+ created = True
140
+ # assign role from installation lock file
141
+ role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
142
+ role_name = (
143
+ role_lock.read_text().strip() if role_lock.exists() else "Terminal"
144
+ )
145
+ role = NodeRole.objects.filter(name=role_name).first()
146
+ if role:
147
+ node.role = role
148
+ node.save(update_fields=["role"])
149
+ if created and node.role is None:
150
+ terminal = NodeRole.objects.filter(name="Terminal").first()
151
+ if terminal:
152
+ node.role = terminal
153
+ node.save(update_fields=["role"])
154
+ Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
155
+ node.ensure_keys()
156
+ return node, created
157
+
158
+ def ensure_keys(self):
159
+ security_dir = Path(settings.BASE_DIR) / "security"
160
+ security_dir.mkdir(parents=True, exist_ok=True)
161
+ priv_path = security_dir / f"{self.public_endpoint}"
162
+ pub_path = security_dir / f"{self.public_endpoint}.pub"
163
+ if not priv_path.exists() or not pub_path.exists():
164
+ private_key = rsa.generate_private_key(
165
+ public_exponent=65537, key_size=2048
166
+ )
167
+ private_bytes = private_key.private_bytes(
168
+ encoding=serialization.Encoding.PEM,
169
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
170
+ encryption_algorithm=serialization.NoEncryption(),
171
+ )
172
+ public_bytes = private_key.public_key().public_bytes(
173
+ encoding=serialization.Encoding.PEM,
174
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
175
+ )
176
+ priv_path.write_bytes(private_bytes)
177
+ pub_path.write_bytes(public_bytes)
178
+ self.public_key = public_bytes.decode()
179
+ self.save(update_fields=["public_key"])
180
+ elif not self.public_key:
181
+ self.public_key = pub_path.read_text()
182
+ self.save(update_fields=["public_key"])
183
+
184
+ @property
185
+ def is_local(self):
186
+ """Determine if this node represents the current host."""
187
+ return self.mac_address == self.get_current_mac()
188
+
189
+ def save(self, *args, **kwargs):
190
+ if self.mac_address:
191
+ self.mac_address = self.mac_address.lower()
192
+ if not self.public_endpoint:
193
+ self.public_endpoint = slugify(self.hostname)
194
+ previous_clipboard = previous_screenshot = None
195
+ if self.pk:
196
+ previous = Node.objects.get(pk=self.pk)
197
+ previous_clipboard = previous.clipboard_polling
198
+ previous_screenshot = previous.screenshot_polling
199
+ super().save(*args, **kwargs)
200
+ if previous_clipboard != self.clipboard_polling:
201
+ self._sync_clipboard_task()
202
+ if previous_screenshot != self.screenshot_polling:
203
+ self._sync_screenshot_task()
204
+
205
+ def _sync_clipboard_task(self):
206
+ from django_celery_beat.models import IntervalSchedule, PeriodicTask
207
+
208
+ task_name = f"poll_clipboard_node_{self.pk}"
209
+ if self.clipboard_polling:
210
+ schedule, _ = IntervalSchedule.objects.get_or_create(
211
+ every=5, period=IntervalSchedule.SECONDS
212
+ )
213
+ PeriodicTask.objects.update_or_create(
214
+ name=task_name,
215
+ defaults={
216
+ "interval": schedule,
217
+ "task": "nodes.tasks.sample_clipboard",
218
+ },
219
+ )
220
+ else:
221
+ PeriodicTask.objects.filter(name=task_name).delete()
222
+
223
+ def _sync_screenshot_task(self):
224
+ from django_celery_beat.models import IntervalSchedule, PeriodicTask
225
+ import json
226
+
227
+ task_name = f"capture_screenshot_node_{self.pk}"
228
+ if self.screenshot_polling:
229
+ schedule, _ = IntervalSchedule.objects.get_or_create(
230
+ every=1, period=IntervalSchedule.MINUTES
231
+ )
232
+ PeriodicTask.objects.update_or_create(
233
+ name=task_name,
234
+ defaults={
235
+ "interval": schedule,
236
+ "task": "nodes.tasks.capture_node_screenshot",
237
+ "kwargs": json.dumps(
238
+ {
239
+ "url": f"http://localhost:{self.port}",
240
+ "port": self.port,
241
+ "method": "AUTO",
242
+ }
243
+ ),
244
+ },
245
+ )
246
+ else:
247
+ PeriodicTask.objects.filter(name=task_name).delete()
248
+
249
+
250
+ def send_mail(self, subject: str, message: str, recipient_list: list[str], from_email: str | None = None, **kwargs):
251
+ """Send an email using this node's configured outbox if available."""
252
+ outbox = getattr(self, "email_outbox", None)
253
+ if outbox:
254
+ return outbox.send_mail(subject, message, recipient_list, from_email, **kwargs)
255
+ from_email = from_email or settings.DEFAULT_FROM_EMAIL
256
+ return send_mail(subject, message, from_email, recipient_list, **kwargs)
257
+
258
+
259
+ class EmailOutbox(Entity):
260
+ """SMTP credentials for sending mail from a node."""
261
+
262
+ node = models.OneToOneField(
263
+ Node, on_delete=models.CASCADE, related_name="email_outbox"
264
+ )
265
+ host = SigilShortAutoField(
266
+ max_length=100,
267
+ help_text=(
268
+ "Gmail: smtp.gmail.com. "
269
+ "GoDaddy: smtpout.secureserver.net"
270
+ ),
271
+ )
272
+ port = models.PositiveIntegerField(
273
+ default=587,
274
+ help_text=(
275
+ "Gmail: 587 (TLS). "
276
+ "GoDaddy: 587 (TLS) or 465 (SSL)"
277
+ ),
278
+ )
279
+ username = SigilShortAutoField(
280
+ max_length=100,
281
+ blank=True,
282
+ help_text="Full email address for Gmail or GoDaddy",
283
+ )
284
+ password = SigilShortAutoField(
285
+ max_length=100,
286
+ blank=True,
287
+ help_text="Email account password or app password",
288
+ )
289
+ use_tls = models.BooleanField(
290
+ default=True,
291
+ help_text="Check for Gmail or GoDaddy on port 587",
292
+ )
293
+ use_ssl = models.BooleanField(
294
+ default=False,
295
+ help_text="Check for GoDaddy on port 465; Gmail does not use SSL",
296
+ )
297
+ from_email = SigilShortAutoField(
298
+ blank=True,
299
+ verbose_name="From Email",
300
+ max_length=254,
301
+ help_text="Default From address; usually the same as username",
302
+ )
303
+
304
+ class Meta:
305
+ verbose_name = "Email Outbox"
306
+ verbose_name_plural = "Email Outboxes"
307
+
308
+ class Meta:
309
+ verbose_name = "Email Outbox"
310
+ verbose_name_plural = "Email Outboxes"
311
+
312
+ def get_connection(self):
313
+ return get_connection(
314
+ host=self.host,
315
+ port=self.port,
316
+ username=self.username or None,
317
+ password=self.password or None,
318
+ use_tls=self.use_tls,
319
+ use_ssl=self.use_ssl,
320
+ )
321
+
322
+ def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
323
+ connection = self.get_connection()
324
+ from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
325
+ return send_mail(
326
+ subject,
327
+ message,
328
+ from_email,
329
+ recipient_list,
330
+ connection=connection,
331
+ **kwargs,
332
+ )
333
+
334
+
335
+ class NetMessage(Entity):
336
+ """Message propagated across nodes."""
337
+
338
+ uuid = models.UUIDField(
339
+ default=uuid.uuid4,
340
+ unique=True,
341
+ editable=False,
342
+ verbose_name="UUID",
343
+ )
344
+ subject = models.CharField(max_length=64, blank=True)
345
+ body = models.CharField(max_length=256, blank=True)
346
+ reach = models.ForeignKey(
347
+ NodeRole,
348
+ on_delete=models.SET_NULL,
349
+ null=True,
350
+ blank=True,
351
+ default=get_terminal_role,
352
+ )
353
+ propagated_to = models.ManyToManyField(
354
+ Node, blank=True, related_name="received_net_messages"
355
+ )
356
+ created = models.DateTimeField(auto_now_add=True)
357
+ complete = models.BooleanField(default=False, editable=False)
358
+
359
+ class Meta:
360
+ ordering = ["-created"]
361
+ verbose_name = "Net Message"
362
+ verbose_name_plural = "Net Messages"
363
+
364
+ @classmethod
365
+ def broadcast(
366
+ cls,
367
+ subject: str,
368
+ body: str,
369
+ reach: NodeRole | str | None = None,
370
+ seen: list[str] | None = None,
371
+ ):
372
+ role = None
373
+ if reach:
374
+ if isinstance(reach, NodeRole):
375
+ role = reach
376
+ else:
377
+ role = NodeRole.objects.filter(name=reach).first()
378
+ msg = cls.objects.create(
379
+ subject=subject[:64],
380
+ body=body[:256],
381
+ reach=role or get_terminal_role(),
382
+ )
383
+ msg.propagate(seen=seen or [])
384
+ return msg
385
+
386
+ def propagate(self, seen: list[str] | None = None):
387
+ from core.notifications import notify
388
+ import random
389
+ import requests
390
+
391
+ notify(self.subject, self.body)
392
+ local = Node.get_local()
393
+ private_key = None
394
+ seen = list(seen or [])
395
+ local_id = None
396
+ if local:
397
+ local_id = str(local.uuid)
398
+ if local_id not in seen:
399
+ seen.append(local_id)
400
+ priv_path = (
401
+ Path(local.base_path or settings.BASE_DIR)
402
+ / "security"
403
+ / f"{local.public_endpoint}"
404
+ )
405
+ try:
406
+ private_key = serialization.load_pem_private_key(
407
+ priv_path.read_bytes(), password=None
408
+ )
409
+ except Exception:
410
+ private_key = None
411
+ for node_id in seen:
412
+ node = Node.objects.filter(uuid=node_id).first()
413
+ if node and (not local or node.pk != local.pk):
414
+ self.propagated_to.add(node)
415
+
416
+ all_nodes = Node.objects.all()
417
+ if local:
418
+ all_nodes = all_nodes.exclude(pk=local.pk)
419
+ total_known = all_nodes.count()
420
+
421
+ remaining = list(
422
+ all_nodes.exclude(pk__in=self.propagated_to.values_list("pk", flat=True))
423
+ )
424
+ if not remaining:
425
+ self.complete = True
426
+ self.save(update_fields=["complete"])
427
+ return
428
+
429
+ target_limit = min(3, len(remaining))
430
+
431
+ reach_name = self.reach.name if self.reach else "Terminal"
432
+ role_map = {
433
+ "Terminal": ["Terminal"],
434
+ "Control": ["Control", "Terminal"],
435
+ "Satellite": ["Satellite", "Control", "Terminal"],
436
+ "Constellation": ["Constellation", "Satellite", "Control", "Terminal"],
437
+ }
438
+ role_order = role_map.get(reach_name, ["Terminal"])
439
+ selected: list[Node] = []
440
+ for role_name in role_order:
441
+ role_nodes = [n for n in remaining if n.role and n.role.name == role_name]
442
+ random.shuffle(role_nodes)
443
+ for n in role_nodes:
444
+ selected.append(n)
445
+ remaining.remove(n)
446
+ if len(selected) >= target_limit:
447
+ break
448
+ if len(selected) >= target_limit:
449
+ break
450
+
451
+ seen_list = seen.copy()
452
+ selected_ids = [str(n.uuid) for n in selected]
453
+ payload_seen = seen_list + selected_ids
454
+ for node in selected:
455
+ payload = {
456
+ "uuid": str(self.uuid),
457
+ "subject": self.subject,
458
+ "body": self.body,
459
+ "seen": payload_seen,
460
+ "reach": reach_name,
461
+ "sender": local_id,
462
+ }
463
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
464
+ headers = {"Content-Type": "application/json"}
465
+ if private_key:
466
+ try:
467
+ signature = private_key.sign(
468
+ payload_json.encode(),
469
+ padding.PKCS1v15(),
470
+ hashes.SHA256(),
471
+ )
472
+ headers["X-Signature"] = base64.b64encode(signature).decode()
473
+ except Exception:
474
+ pass
475
+ try:
476
+ requests.post(
477
+ f"http://{node.address}:{node.port}/nodes/net-message/",
478
+ data=payload_json,
479
+ headers=headers,
480
+ timeout=1,
481
+ )
482
+ except Exception:
483
+ pass
484
+ self.propagated_to.add(node)
485
+
486
+ if total_known and self.propagated_to.count() >= total_known:
487
+ self.complete = True
488
+ self.save(update_fields=["complete"] if self.complete else [])
489
+
490
+
491
+ class ContentSample(Entity):
492
+ """Collected content such as text snippets or screenshots."""
493
+
494
+ TEXT = "TEXT"
495
+ IMAGE = "IMAGE"
496
+ KIND_CHOICES = [(TEXT, "Text"), (IMAGE, "Image")]
497
+
498
+ name = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
499
+ kind = models.CharField(max_length=10, choices=KIND_CHOICES)
500
+ content = models.TextField(blank=True)
501
+ path = models.CharField(max_length=255, blank=True)
502
+ method = models.CharField(max_length=10, default="", blank=True)
503
+ hash = models.CharField(max_length=64, unique=True, null=True, blank=True)
504
+ transaction_uuid = models.UUIDField(
505
+ default=uuid.uuid4,
506
+ editable=True,
507
+ db_index=True,
508
+ verbose_name="transaction UUID",
509
+ )
510
+ node = models.ForeignKey(
511
+ Node, on_delete=models.SET_NULL, null=True, blank=True
512
+ )
513
+ user = models.ForeignKey(
514
+ settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
515
+ )
516
+ created_at = models.DateTimeField(auto_now_add=True)
517
+
518
+ class Meta:
519
+ ordering = ["-created_at"]
520
+ verbose_name = "Content Sample"
521
+ verbose_name_plural = "Content Samples"
522
+
523
+ def save(self, *args, **kwargs):
524
+ if self.pk:
525
+ original = type(self).all_objects.get(pk=self.pk)
526
+ if original.transaction_uuid != self.transaction_uuid:
527
+ raise ValidationError(
528
+ {"transaction_uuid": "Cannot modify transaction UUID"}
529
+ )
530
+ if self.node_id is None:
531
+ self.node = Node.get_local()
532
+ super().save(*args, **kwargs)
533
+
534
+ def __str__(self) -> str: # pragma: no cover - simple representation
535
+ return str(self.name)
536
+
537
+
538
+ class NodeTask(Entity):
539
+ """Script that can be executed on nodes."""
540
+
541
+ recipe = models.TextField()
542
+ role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
543
+ created = models.DateTimeField(auto_now_add=True)
544
+
545
+ class Meta:
546
+ ordering = ["-created"]
547
+ verbose_name = "Node Task"
548
+ verbose_name_plural = "Node Tasks"
549
+
550
+ def __str__(self) -> str: # pragma: no cover - simple representation
551
+ return self.recipe
552
+
553
+ def run(self, node: Node):
554
+ """Execute this script on ``node`` and return its output."""
555
+ if not node.is_local:
556
+ raise NotImplementedError("Remote node execution is not implemented")
557
+ import subprocess
558
+
559
+ result = subprocess.run(
560
+ self.recipe, shell=True, capture_output=True, text=True
561
+ )
562
+ return result.stdout + result.stderr
563
+
564
+
565
+ UserModel = get_user_model()
566
+
567
+
568
+ class User(UserModel):
569
+ class Meta:
570
+ proxy = True
571
+ app_label = "nodes"
572
+ verbose_name = UserModel._meta.verbose_name
573
+ verbose_name_plural = UserModel._meta.verbose_name_plural
574
+
575
+
576
+
577
+
nodes/tasks.py ADDED
@@ -0,0 +1,50 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ import pyperclip
5
+ from pyperclip import PyperclipException
6
+ from celery import shared_task
7
+
8
+ from .models import ContentSample, Node
9
+ from .utils import capture_screenshot, save_screenshot
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @shared_task
15
+ def sample_clipboard() -> None:
16
+ """Save current clipboard contents to a :class:`ContentSample` entry."""
17
+ try:
18
+ content = pyperclip.paste()
19
+ except PyperclipException as exc: # pragma: no cover - depends on OS clipboard
20
+ logger.error("Clipboard error: %s", exc)
21
+ return
22
+ if not content:
23
+ logger.info("Clipboard is empty")
24
+ return
25
+ if ContentSample.objects.filter(
26
+ content=content, kind=ContentSample.TEXT
27
+ ).exists():
28
+ logger.info("Duplicate clipboard content; sample not created")
29
+ return
30
+ node = Node.get_local()
31
+ ContentSample.objects.create(content=content, node=node, kind=ContentSample.TEXT)
32
+
33
+
34
+ @shared_task
35
+ def capture_node_screenshot(
36
+ url: str | None = None, port: int = 8000, method: str = "TASK"
37
+ ) -> str:
38
+ """Capture a screenshot of ``url`` and record it as a :class:`ContentSample`."""
39
+ if url is None:
40
+ url = f"http://localhost:{port}"
41
+ try:
42
+ path: Path = capture_screenshot(url)
43
+ except Exception as exc: # pragma: no cover - depends on selenium setup
44
+ logger.error("Screenshot capture failed: %s", exc)
45
+ return ""
46
+ node = Node.get_local()
47
+ save_screenshot(path, node=node, method=method)
48
+ return str(path)
49
+
50
+