arthexis 0.1.6__py3-none-any.whl → 0.1.8__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.
- {arthexis-0.1.6.dist-info → arthexis-0.1.8.dist-info}/METADATA +12 -8
- {arthexis-0.1.6.dist-info → arthexis-0.1.8.dist-info}/RECORD +36 -29
- config/celery.py +7 -0
- config/horologia_app.py +7 -0
- config/logging.py +8 -3
- config/settings.py +3 -2
- config/urls.py +9 -0
- config/workgroup_app.py +7 -0
- core/admin.py +192 -17
- core/admindocs.py +44 -0
- core/apps.py +2 -1
- core/checks.py +29 -0
- core/entity.py +29 -7
- core/models.py +124 -14
- core/release.py +29 -141
- core/system.py +2 -2
- core/test_system_info.py +21 -0
- core/tests.py +292 -1
- core/views.py +153 -134
- core/workgroup_urls.py +13 -0
- core/workgroup_views.py +57 -0
- nodes/admin.py +211 -0
- nodes/apps.py +1 -1
- nodes/models.py +103 -7
- nodes/tests.py +27 -0
- ocpp/apps.py +4 -3
- ocpp/models.py +1 -1
- ocpp/simulator.py +4 -0
- ocpp/tests.py +5 -1
- pages/admin.py +8 -3
- pages/apps.py +1 -1
- pages/tests.py +23 -4
- pages/views.py +22 -3
- {arthexis-0.1.6.dist-info → arthexis-0.1.8.dist-info}/WHEEL +0 -0
- {arthexis-0.1.6.dist-info → arthexis-0.1.8.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.6.dist-info → arthexis-0.1.8.dist-info}/top_level.txt +0 -0
nodes/apps.py
CHANGED
|
@@ -68,7 +68,7 @@ def _trigger_startup_notification(**_: object) -> None:
|
|
|
68
68
|
class NodesConfig(AppConfig):
|
|
69
69
|
default_auto_field = "django.db.models.BigAutoField"
|
|
70
70
|
name = "nodes"
|
|
71
|
-
verbose_name = "
|
|
71
|
+
verbose_name = "2. Infrastructure"
|
|
72
72
|
|
|
73
73
|
def ready(self): # pragma: no cover - exercised on app start
|
|
74
74
|
request_started.connect(
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
434
|
-
"
|
|
435
|
-
"
|
|
436
|
-
"
|
|
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,11 +6,12 @@ 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 = "
|
|
9
|
+
verbose_name = "3. Protocols"
|
|
10
10
|
|
|
11
11
|
def ready(self): # pragma: no cover - startup side effects
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
control_lock = Path(settings.BASE_DIR) / "locks" / "control.lck"
|
|
13
|
+
rfid_lock = Path(settings.BASE_DIR) / "locks" / "rfid.lck"
|
|
14
|
+
if not (control_lock.exists() and rfid_lock.exists()):
|
|
14
15
|
return
|
|
15
16
|
from .rfid.background_reader import start
|
|
16
17
|
from .rfid.signals import tag_scanned
|
ocpp/models.py
CHANGED
ocpp/simulator.py
CHANGED
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
|
-
|
|
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(
|
|
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 = "
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|