arthexis 0.1.6__py3-none-any.whl → 0.1.7__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.

nodes/models.py CHANGED
@@ -1,6 +1,10 @@
1
1
  from django.db import models
2
2
  from core.entity import Entity
3
- from core.fields import SigilShortAutoField
3
+ from core.fields import (
4
+ SigilShortAutoField,
5
+ SigilLongCheckField,
6
+ SigilLongAutoField,
7
+ )
4
8
  import re
5
9
  import json
6
10
  import base64
@@ -18,6 +22,10 @@ from cryptography.hazmat.primitives import serialization, hashes
18
22
  from cryptography.hazmat.primitives.asymmetric import padding
19
23
  from django.contrib.auth import get_user_model
20
24
  from django.core.mail import get_connection, send_mail
25
+ import logging
26
+
27
+
28
+ logger = logging.getLogger(__name__)
21
29
 
22
30
 
23
31
  class NodeRoleManager(models.Manager):
@@ -250,10 +258,20 @@ class Node(Entity):
250
258
  def send_mail(self, subject: str, message: str, recipient_list: list[str], from_email: str | None = None, **kwargs):
251
259
  """Send an email using this node's configured outbox if available."""
252
260
  outbox = getattr(self, "email_outbox", None)
261
+ logger.info(
262
+ "Node %s sending email to %s using %s backend",
263
+ self.pk,
264
+ recipient_list,
265
+ "outbox" if outbox else "default",
266
+ )
253
267
  if outbox:
254
- return outbox.send_mail(subject, message, recipient_list, from_email, **kwargs)
268
+ result = outbox.send_mail(subject, message, recipient_list, from_email, **kwargs)
269
+ logger.info("Outbox send_mail result: %s", result)
270
+ return result
255
271
  from_email = from_email or settings.DEFAULT_FROM_EMAIL
256
- return send_mail(subject, message, from_email, recipient_list, **kwargs)
272
+ result = send_mail(subject, message, from_email, recipient_list, **kwargs)
273
+ logger.info("Default send_mail result: %s", result)
274
+ return result
257
275
 
258
276
 
259
277
  class EmailOutbox(Entity):
@@ -430,10 +448,12 @@ class NetMessage(Entity):
430
448
 
431
449
  reach_name = self.reach.name if self.reach else "Terminal"
432
450
  role_map = {
433
- "Terminal": ["Terminal"],
434
- "Control": ["Control", "Terminal"],
435
- "Satellite": ["Satellite", "Control", "Terminal"],
436
- "Constellation": ["Constellation", "Satellite", "Control", "Terminal"],
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"],
437
457
  }
438
458
  role_order = role_map.get(reach_name, ["Terminal"])
439
459
  selected: list[Node] = []
@@ -562,6 +582,82 @@ class NodeTask(Entity):
562
582
  return result.stdout + result.stderr
563
583
 
564
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
+
565
661
  UserModel = get_user_model()
566
662
 
567
663
 
nodes/tests.py CHANGED
@@ -37,6 +37,8 @@ from .tasks import capture_node_screenshot, sample_clipboard
37
37
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
38
38
  from cryptography.hazmat.primitives import serialization, hashes
39
39
  from core.models import PackageRelease
40
+ from .models import Operation
41
+ from .admin import RUN_CONTEXTS
40
42
 
41
43
 
42
44
  class NodeTests(TestCase):
@@ -1065,3 +1067,28 @@ class NodeRoleAdminTests(TestCase):
1065
1067
  self.assertEqual(node2.role, role)
1066
1068
 
1067
1069
 
1070
+ class OperationWorkflowTests(TestCase):
1071
+ def setUp(self):
1072
+ self.client = Client()
1073
+ User = get_user_model()
1074
+ self.user = User.objects.create_superuser(
1075
+ username="admin", email="admin@example.com", password="pwd"
1076
+ )
1077
+ self.client.force_login(self.user)
1078
+
1079
+ def tearDown(self):
1080
+ RUN_CONTEXTS.clear()
1081
+
1082
+ def test_unresolved_sigils_prompt(self):
1083
+ op = Operation.objects.create(name="op1", command="[ENV.MISSING]")
1084
+ url = reverse("admin:nodes_operation_run", args=[op.pk])
1085
+ resp = self.client.post(url, follow=True)
1086
+ self.assertContains(resp, 'name="ENV__MISSING"')
1087
+
1088
+ def test_continue_effect(self):
1089
+ op = Operation.objects.create(name="op2", command="...")
1090
+ url = reverse("admin:nodes_operation_run", args=[op.pk])
1091
+ resp = self.client.post(url, follow=True)
1092
+ self.assertContains(resp, 'value="Continue"')
1093
+
1094
+
ocpp/apps.py CHANGED
@@ -6,7 +6,7 @@ from django.conf import settings
6
6
  class OcppConfig(AppConfig):
7
7
  default_auto_field = "django.db.models.BigAutoField"
8
8
  name = "ocpp"
9
- verbose_name = "OCPP"
9
+ verbose_name = "3. Protocols"
10
10
 
11
11
  def ready(self): # pragma: no cover - startup side effects
12
12
  lock = Path(settings.BASE_DIR) / "locks" / "control.lck"
ocpp/models.py CHANGED
@@ -172,7 +172,7 @@ class Transaction(Entity):
172
172
 
173
173
  class Meta:
174
174
  verbose_name = _("Transaction")
175
- verbose_name_plural = _("Transactions")
175
+ verbose_name_plural = _("CP Transactions")
176
176
 
177
177
  @property
178
178
  def kw(self) -> float:
ocpp/simulator.py CHANGED
@@ -94,6 +94,10 @@ class ChargePointSimulator:
94
94
  log_type="simulator",
95
95
  )
96
96
  raise
97
+ except websockets.exceptions.ConnectionClosed:
98
+ self.status = "stopped"
99
+ self._stop_event.set()
100
+ raise
97
101
  except Exception:
98
102
  self.status = "error"
99
103
  raise
ocpp/tests.py CHANGED
@@ -1027,7 +1027,11 @@ class ChargePointSimulatorTests(TransactionTestCase):
1027
1027
  sim = ChargePointSimulator(cfg)
1028
1028
  store.simulators[99] = sim
1029
1029
  try:
1030
- with patch("ocpp.simulator.asyncio.wait_for", side_effect=asyncio.TimeoutError):
1030
+ async def fake_wait_for(coro, timeout):
1031
+ coro.close()
1032
+ raise asyncio.TimeoutError
1033
+
1034
+ with patch("ocpp.simulator.asyncio.wait_for", fake_wait_for):
1031
1035
  started, status, _ = await asyncio.to_thread(sim.start)
1032
1036
  await asyncio.to_thread(sim._thread.join)
1033
1037
  self.assertFalse(started)
pages/admin.py CHANGED
@@ -170,8 +170,9 @@ class ModuleAdmin(admin.ModelAdmin):
170
170
  def favorite_toggle(request, ct_id):
171
171
  ct = get_object_or_404(ContentType, pk=ct_id)
172
172
  fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
173
+ next_url = request.GET.get("next")
173
174
  if fav:
174
- return redirect("admin:favorite_list")
175
+ return redirect(next_url or "admin:favorite_list")
175
176
  if request.method == "POST":
176
177
  label = request.POST.get("custom_label", "").strip()
177
178
  user_data = request.POST.get("user_data") == "on"
@@ -181,8 +182,12 @@ def favorite_toggle(request, ct_id):
181
182
  custom_label=label,
182
183
  user_data=user_data,
183
184
  )
184
- return redirect("admin:index")
185
- return render(request, "admin/favorite_confirm.html", {"content_type": ct})
185
+ return redirect(next_url or "admin:index")
186
+ return render(
187
+ request,
188
+ "admin/favorite_confirm.html",
189
+ {"content_type": ct, "next": next_url},
190
+ )
186
191
 
187
192
 
188
193
  def favorite_list(request):
pages/apps.py CHANGED
@@ -4,7 +4,7 @@ from django.apps import AppConfig
4
4
  class PagesConfig(AppConfig):
5
5
  default_auto_field = "django.db.models.BigAutoField"
6
6
  name = "pages"
7
- verbose_name = "Web Experience"
7
+ verbose_name = "7. Experience"
8
8
 
9
9
  def ready(self): # pragma: no cover - import for side effects
10
10
  from . import checks # noqa: F401
pages/tests.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from django.test import Client, TestCase, override_settings
2
2
  from django.urls import reverse
3
+ from urllib.parse import quote
3
4
  from django.contrib.auth import get_user_model
4
5
  from django.contrib.sites.models import Site
5
6
  from django.contrib import admin
@@ -9,7 +10,7 @@ from pages.models import Application, Module, SiteBadge, Favorite
9
10
  from core.user_data import UserDatum
10
11
  from pages.admin import ApplicationAdmin
11
12
  from django.apps import apps as django_apps
12
- from core.models import AdminHistory
13
+ from core.models import AdminHistory, InviteLead
13
14
  from django.core.files.uploadedfile import SimpleUploadedFile
14
15
  import base64
15
16
  import tempfile
@@ -118,6 +119,16 @@ class InvitationTests(TestCase):
118
119
  resp, "If the email exists, an invitation has been sent."
119
120
  )
120
121
 
122
+ def test_request_invite_creates_lead_with_comment(self):
123
+ resp = self.client.post(
124
+ reverse("pages:request-invite"),
125
+ {"email": "new@example.com", "comment": "Hello"},
126
+ )
127
+ self.assertEqual(resp.status_code, 200)
128
+ lead = InviteLead.objects.get()
129
+ self.assertEqual(lead.email, "new@example.com")
130
+ self.assertEqual(lead.comment, "Hello")
131
+
121
132
 
122
133
  class NavbarBrandTests(TestCase):
123
134
  def setUp(self):
@@ -600,13 +611,21 @@ class FavoriteTests(TestCase):
600
611
 
601
612
  def test_add_favorite(self):
602
613
  ct = ContentType.objects.get_by_natural_key("pages", "application")
603
- url = reverse("admin:favorite_toggle", args=[ct.id])
614
+ next_url = reverse("admin:pages_application_changelist")
615
+ url = reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
604
616
  resp = self.client.post(url, {"custom_label": "Apps", "user_data": "on"})
605
- self.assertRedirects(resp, reverse("admin:index"))
617
+ self.assertRedirects(resp, next_url)
606
618
  fav = Favorite.objects.get(user=self.user, content_type=ct)
607
619
  self.assertEqual(fav.custom_label, "Apps")
608
620
  self.assertTrue(fav.user_data)
609
621
 
622
+ def test_cancel_link_uses_next(self):
623
+ ct = ContentType.objects.get_by_natural_key("pages", "application")
624
+ next_url = reverse("admin:pages_application_changelist")
625
+ url = reverse("admin:favorite_toggle", args=[ct.id]) + f"?next={quote(next_url)}"
626
+ resp = self.client.get(url)
627
+ self.assertContains(resp, f'href="{next_url}"')
628
+
610
629
  def test_existing_favorite_redirects_to_list(self):
611
630
  ct = ContentType.objects.get_by_natural_key("pages", "application")
612
631
  Favorite.objects.create(user=self.user, content_type=ct)
@@ -647,5 +666,5 @@ class FavoriteTests(TestCase):
647
666
  )
648
667
  resp = self.client.get(reverse("admin:index"))
649
668
  url = reverse("admin:nodes_noderole_changelist")
650
- self.assertEqual(resp.content.decode().count(url), 1)
669
+ self.assertGreaterEqual(resp.content.decode().count(url), 1)
651
670
  self.assertContains(resp, NodeRole._meta.verbose_name_plural)
pages/views.py CHANGED
@@ -17,6 +17,7 @@ from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
17
17
  from django.core.mail import send_mail
18
18
  from django.utils.translation import gettext as _
19
19
  from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
20
+ from core.models import InviteLead
20
21
 
21
22
  import markdown
22
23
  from pages.utils import landing
@@ -115,6 +116,7 @@ login_view = CustomLoginView.as_view()
115
116
 
116
117
  class InvitationRequestForm(forms.Form):
117
118
  email = forms.EmailField()
119
+ comment = forms.CharField(required=False, widget=forms.Textarea, label=_("Comment"))
118
120
 
119
121
  @csrf_exempt
120
122
  @ensure_csrf_cookie
@@ -123,8 +125,22 @@ def request_invite(request):
123
125
  sent = False
124
126
  if request.method == "POST" and form.is_valid():
125
127
  email = form.cleaned_data["email"]
128
+ comment = form.cleaned_data.get("comment", "")
129
+ InviteLead.objects.create(
130
+ email=email,
131
+ comment=comment,
132
+ user=request.user if request.user.is_authenticated else None,
133
+ path=request.path,
134
+ referer=request.META.get("HTTP_REFERER", ""),
135
+ user_agent=request.META.get("HTTP_USER_AGENT", ""),
136
+ ip_address=request.META.get("REMOTE_ADDR"),
137
+ )
138
+ logger.info("Invitation requested for %s", email)
126
139
  User = get_user_model()
127
- for user in User.objects.filter(email__iexact=email):
140
+ users = list(User.objects.filter(email__iexact=email))
141
+ if not users:
142
+ logger.warning("Invitation requested for unknown email %s", email)
143
+ for user in users:
128
144
  uid = urlsafe_base64_encode(force_bytes(user.pk))
129
145
  token = default_token_generator.make_token(user)
130
146
  link = request.build_absolute_uri(
@@ -137,9 +153,12 @@ def request_invite(request):
137
153
  try:
138
154
  node = Node.get_local()
139
155
  if node:
140
- node.send_mail(subject, body, [email])
156
+ result = node.send_mail(subject, body, [email])
141
157
  else:
142
- send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [email])
158
+ result = send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [email])
159
+ logger.info(
160
+ "Invitation email sent to %s (user %s): %s", email, user.pk, result
161
+ )
143
162
  except Exception:
144
163
  logger.exception("Failed to send invitation email to %s", email)
145
164
  sent = True