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.
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
- arthexis-0.1.9.dist-info/RECORD +92 -0
- arthexis-0.1.9.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 +133 -16
- config/urls.py +65 -6
- core/admin.py +1226 -191
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +158 -3
- core/backends.py +46 -4
- core/entity.py +62 -48
- core/fields.py +6 -1
- 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 +1071 -264
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/release.py +27 -20
- core/sigil_builder.py +131 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +129 -10
- core/tasks.py +118 -19
- core/test_system_info.py +22 -0
- core/tests.py +358 -63
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +329 -167
- core/views.py +383 -57
- 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 +159 -284
- nodes/apps.py +9 -15
- nodes/backends.py +53 -0
- nodes/lcd.py +24 -10
- nodes/models.py +375 -178
- nodes/tasks.py +1 -5
- nodes/tests.py +524 -129
- nodes/utils.py +13 -2
- nodes/views.py +66 -23
- ocpp/admin.py +150 -61
- ocpp/apps.py +1 -1
- ocpp/consumers.py +432 -69
- ocpp/evcs.py +25 -8
- ocpp/models.py +408 -68
- ocpp/simulator.py +13 -6
- ocpp/store.py +258 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1198 -135
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +654 -101
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +19 -6
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +759 -40
- pages/urls.py +3 -0
- pages/utils.py +0 -1
- pages/views.py +576 -25
- 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.9.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
core/workgroup_urls.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""URL routes for
|
|
1
|
+
"""URL routes for assistant profile endpoints."""
|
|
2
2
|
|
|
3
3
|
from django.urls import path
|
|
4
4
|
|
|
@@ -7,7 +7,11 @@ from . import workgroup_views as views
|
|
|
7
7
|
app_name = "workgroup"
|
|
8
8
|
|
|
9
9
|
urlpatterns = [
|
|
10
|
-
path(
|
|
10
|
+
path(
|
|
11
|
+
"assistant-profiles/<int:user_id>/",
|
|
12
|
+
views.issue_key,
|
|
13
|
+
name="assistantprofile-issue",
|
|
14
|
+
),
|
|
11
15
|
path("assistant/test/", views.assistant_test, name="assistant-test"),
|
|
16
|
+
path("chat/", views.chat, name="chat"),
|
|
12
17
|
]
|
|
13
|
-
|
core/workgroup_views.py
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
"""REST endpoints for
|
|
1
|
+
"""REST endpoints for AssistantProfile issuance and authentication."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from functools import wraps
|
|
6
6
|
|
|
7
|
+
from django.apps import apps
|
|
7
8
|
from django.contrib.auth import get_user_model
|
|
9
|
+
from django.forms.models import model_to_dict
|
|
8
10
|
from django.http import HttpResponse, JsonResponse
|
|
9
11
|
from django.views.decorators.csrf import csrf_exempt
|
|
10
12
|
from django.views.decorators.http import require_GET, require_POST
|
|
11
13
|
|
|
12
|
-
from .models import
|
|
14
|
+
from .models import AssistantProfile, hash_key
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
@csrf_exempt
|
|
@@ -21,7 +23,7 @@ def issue_key(request, user_id: int) -> JsonResponse:
|
|
|
21
23
|
"""
|
|
22
24
|
|
|
23
25
|
user = get_user_model().objects.get(pk=user_id)
|
|
24
|
-
profile, key =
|
|
26
|
+
profile, key = AssistantProfile.issue_key(user)
|
|
25
27
|
return JsonResponse({"user_id": user_id, "user_key": key})
|
|
26
28
|
|
|
27
29
|
|
|
@@ -36,11 +38,14 @@ def authenticate(view_func):
|
|
|
36
38
|
|
|
37
39
|
key_hash = hash_key(header.split(" ", 1)[1])
|
|
38
40
|
try:
|
|
39
|
-
profile =
|
|
40
|
-
|
|
41
|
+
profile = AssistantProfile.objects.get(
|
|
42
|
+
user_key_hash=key_hash, is_active=True
|
|
43
|
+
)
|
|
44
|
+
except AssistantProfile.DoesNotExist:
|
|
41
45
|
return HttpResponse(status=401)
|
|
42
46
|
|
|
43
47
|
profile.touch()
|
|
48
|
+
request.assistant_profile = profile
|
|
44
49
|
request.chat_profile = profile
|
|
45
50
|
return view_func(request, *args, **kwargs)
|
|
46
51
|
|
|
@@ -52,6 +57,38 @@ def authenticate(view_func):
|
|
|
52
57
|
def assistant_test(request):
|
|
53
58
|
"""Return a simple greeting to confirm authentication."""
|
|
54
59
|
|
|
55
|
-
|
|
60
|
+
profile = getattr(request, "assistant_profile", None)
|
|
61
|
+
user_id = profile.user_id if profile else None
|
|
56
62
|
return JsonResponse({"message": f"Hello from user {user_id}"})
|
|
57
63
|
|
|
64
|
+
|
|
65
|
+
@require_GET
|
|
66
|
+
@authenticate
|
|
67
|
+
def chat(request):
|
|
68
|
+
"""Return serialized data from any model.
|
|
69
|
+
|
|
70
|
+
Clients must provide ``model`` as ``app_label.ModelName`` and may include a
|
|
71
|
+
``pk`` to fetch a specific record. When ``pk`` is omitted, the view returns
|
|
72
|
+
up to 100 records.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
model_label = request.GET.get("model")
|
|
76
|
+
if not model_label:
|
|
77
|
+
return JsonResponse({"error": "model parameter required"}, status=400)
|
|
78
|
+
try:
|
|
79
|
+
model = apps.get_model(model_label)
|
|
80
|
+
except LookupError:
|
|
81
|
+
return JsonResponse({"error": "unknown model"}, status=400)
|
|
82
|
+
|
|
83
|
+
qs = model.objects.all()
|
|
84
|
+
pk = request.GET.get("pk")
|
|
85
|
+
if pk is not None:
|
|
86
|
+
try:
|
|
87
|
+
obj = qs.get(pk=pk)
|
|
88
|
+
except model.DoesNotExist:
|
|
89
|
+
return JsonResponse({"error": "object not found"}, status=404)
|
|
90
|
+
data = model_to_dict(obj)
|
|
91
|
+
else:
|
|
92
|
+
data = [model_to_dict(o) for o in qs[:100]]
|
|
93
|
+
|
|
94
|
+
return JsonResponse({"data": data})
|
nodes/actions.py
CHANGED
nodes/admin.py
CHANGED
|
@@ -4,41 +4,29 @@ from django.shortcuts import redirect, render
|
|
|
4
4
|
from django.utils.html import format_html
|
|
5
5
|
from django import forms
|
|
6
6
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
|
7
|
-
from
|
|
8
|
-
from django.db import
|
|
7
|
+
from core.widgets import CopyColorWidget
|
|
8
|
+
from django.db.models import Count
|
|
9
9
|
from django.conf import settings
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from django.http import HttpResponse
|
|
12
|
-
from django.template.response import TemplateResponse
|
|
13
|
-
from django.core.management import call_command
|
|
14
12
|
import base64
|
|
15
13
|
import pyperclip
|
|
16
14
|
from pyperclip import PyperclipException
|
|
17
15
|
import uuid
|
|
18
16
|
import subprocess
|
|
19
|
-
import io
|
|
20
|
-
import threading
|
|
21
|
-
import re
|
|
22
17
|
from .utils import capture_screenshot, save_screenshot
|
|
23
18
|
from .actions import NodeAction
|
|
24
19
|
|
|
25
20
|
from .models import (
|
|
26
21
|
Node,
|
|
27
|
-
EmailOutbox
|
|
22
|
+
EmailOutbox,
|
|
28
23
|
NodeRole,
|
|
24
|
+
NodeFeature,
|
|
25
|
+
NodeFeatureAssignment,
|
|
29
26
|
ContentSample,
|
|
30
|
-
NodeTask,
|
|
31
27
|
NetMessage,
|
|
32
|
-
Operation,
|
|
33
|
-
Interrupt,
|
|
34
|
-
Logbook,
|
|
35
|
-
User,
|
|
36
28
|
)
|
|
37
|
-
from core.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
RUN_CONTEXTS: dict[int, dict] = {}
|
|
41
|
-
SIGIL_RE = re.compile(r"\[[A-Za-z0-9_]+\.[A-Za-z0-9_]+\]")
|
|
29
|
+
from core.user_data import EntityModelAdmin
|
|
42
30
|
|
|
43
31
|
|
|
44
32
|
class NodeAdminForm(forms.ModelForm):
|
|
@@ -48,8 +36,14 @@ class NodeAdminForm(forms.ModelForm):
|
|
|
48
36
|
widgets = {"badge_color": CopyColorWidget()}
|
|
49
37
|
|
|
50
38
|
|
|
39
|
+
class NodeFeatureAssignmentInline(admin.TabularInline):
|
|
40
|
+
model = NodeFeatureAssignment
|
|
41
|
+
extra = 0
|
|
42
|
+
autocomplete_fields = ("feature",)
|
|
43
|
+
|
|
44
|
+
|
|
51
45
|
@admin.register(Node)
|
|
52
|
-
class NodeAdmin(
|
|
46
|
+
class NodeAdmin(EntityModelAdmin):
|
|
53
47
|
list_display = (
|
|
54
48
|
"hostname",
|
|
55
49
|
"mac_address",
|
|
@@ -62,8 +56,8 @@ class NodeAdmin(admin.ModelAdmin):
|
|
|
62
56
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
63
57
|
change_form_template = "admin/nodes/node/change_form.html"
|
|
64
58
|
form = NodeAdminForm
|
|
65
|
-
actions = ["run_task", "take_screenshots"]
|
|
66
|
-
|
|
59
|
+
actions = ["register_visitor", "run_task", "take_screenshots"]
|
|
60
|
+
inlines = [NodeFeatureAssignmentInline]
|
|
67
61
|
|
|
68
62
|
def get_urls(self):
|
|
69
63
|
urls = super().get_urls()
|
|
@@ -73,6 +67,11 @@ class NodeAdmin(admin.ModelAdmin):
|
|
|
73
67
|
self.admin_site.admin_view(self.register_current),
|
|
74
68
|
name="nodes_node_register_current",
|
|
75
69
|
),
|
|
70
|
+
path(
|
|
71
|
+
"register-visitor/",
|
|
72
|
+
self.admin_site.admin_view(self.register_visitor_view),
|
|
73
|
+
name="nodes_node_register_visitor",
|
|
74
|
+
),
|
|
76
75
|
path(
|
|
77
76
|
"<int:node_id>/action/<str:action>/",
|
|
78
77
|
self.admin_site.admin_view(self.action_view),
|
|
@@ -100,6 +99,29 @@ class NodeAdmin(admin.ModelAdmin):
|
|
|
100
99
|
}
|
|
101
100
|
return render(request, "admin/nodes/node/register_remote.html", context)
|
|
102
101
|
|
|
102
|
+
@admin.action(description="Register Visitor Node")
|
|
103
|
+
def register_visitor(self, request, queryset=None):
|
|
104
|
+
return self.register_visitor_view(request)
|
|
105
|
+
|
|
106
|
+
def register_visitor_view(self, request):
|
|
107
|
+
"""Exchange registration data with the visiting node."""
|
|
108
|
+
|
|
109
|
+
node, created = Node.register_current()
|
|
110
|
+
if created:
|
|
111
|
+
self.message_user(
|
|
112
|
+
request, f"Current host registered as {node}", messages.SUCCESS
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
token = uuid.uuid4().hex
|
|
116
|
+
context = {
|
|
117
|
+
"token": token,
|
|
118
|
+
"info_url": reverse("node-info"),
|
|
119
|
+
"register_url": reverse("register-node"),
|
|
120
|
+
"visitor_info_url": "http://localhost:8000/nodes/info/",
|
|
121
|
+
"visitor_register_url": "http://localhost:8000/nodes/register/",
|
|
122
|
+
}
|
|
123
|
+
return render(request, "admin/nodes/node/register_visitor.html", context)
|
|
124
|
+
|
|
103
125
|
def public_key(self, request, node_id):
|
|
104
126
|
node = self.get_object(request, node_id)
|
|
105
127
|
if not node:
|
|
@@ -117,11 +139,21 @@ class NodeAdmin(admin.ModelAdmin):
|
|
|
117
139
|
def run_task(self, request, queryset):
|
|
118
140
|
if "apply" in request.POST:
|
|
119
141
|
recipe_text = request.POST.get("recipe", "")
|
|
120
|
-
task_obj, _ = NodeTask.objects.get_or_create(recipe=recipe_text)
|
|
121
142
|
results = []
|
|
122
143
|
for node in queryset:
|
|
123
144
|
try:
|
|
124
|
-
|
|
145
|
+
if not node.is_local:
|
|
146
|
+
raise NotImplementedError(
|
|
147
|
+
"Remote node execution is not implemented"
|
|
148
|
+
)
|
|
149
|
+
command = ["/bin/sh", "-c", recipe_text]
|
|
150
|
+
result = subprocess.run(
|
|
151
|
+
command,
|
|
152
|
+
check=False,
|
|
153
|
+
capture_output=True,
|
|
154
|
+
text=True,
|
|
155
|
+
)
|
|
156
|
+
output = result.stdout + result.stderr
|
|
125
157
|
except Exception as exc:
|
|
126
158
|
output = str(exc)
|
|
127
159
|
results.append((node, output))
|
|
@@ -194,21 +226,70 @@ class NodeAdmin(admin.ModelAdmin):
|
|
|
194
226
|
return redirect(reverse("admin:nodes_node_change", args=[node_id]))
|
|
195
227
|
|
|
196
228
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
229
|
+
@admin.register(EmailOutbox)
|
|
230
|
+
class EmailOutboxAdmin(EntityModelAdmin):
|
|
231
|
+
list_display = ("owner_label", "host", "port", "username", "use_tls", "use_ssl")
|
|
232
|
+
change_form_template = "admin/nodes/emailoutbox/change_form.html"
|
|
233
|
+
fieldsets = (
|
|
234
|
+
("Owner", {"fields": ("user", "group", "node")}),
|
|
235
|
+
(
|
|
236
|
+
None,
|
|
237
|
+
{
|
|
238
|
+
"fields": (
|
|
239
|
+
"host",
|
|
240
|
+
"port",
|
|
241
|
+
"username",
|
|
242
|
+
"password",
|
|
243
|
+
"use_tls",
|
|
244
|
+
"use_ssl",
|
|
245
|
+
"from_email",
|
|
246
|
+
)
|
|
247
|
+
},
|
|
248
|
+
),
|
|
249
|
+
)
|
|
203
250
|
|
|
251
|
+
@admin.display(description="Owner")
|
|
252
|
+
def owner_label(self, obj):
|
|
253
|
+
return obj.owner_display()
|
|
204
254
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
255
|
+
def get_urls(self):
|
|
256
|
+
urls = super().get_urls()
|
|
257
|
+
custom = [
|
|
258
|
+
path(
|
|
259
|
+
"<path:object_id>/test/",
|
|
260
|
+
self.admin_site.admin_view(self.test_outbox),
|
|
261
|
+
name="nodes_emailoutbox_test",
|
|
262
|
+
)
|
|
263
|
+
]
|
|
264
|
+
return custom + urls
|
|
208
265
|
|
|
209
|
-
def
|
|
210
|
-
|
|
211
|
-
|
|
266
|
+
def test_outbox(self, request, object_id):
|
|
267
|
+
outbox = self.get_object(request, object_id)
|
|
268
|
+
if not outbox:
|
|
269
|
+
self.message_user(request, "Unknown outbox", messages.ERROR)
|
|
270
|
+
return redirect("..")
|
|
271
|
+
recipient = request.user.email or outbox.username
|
|
272
|
+
try:
|
|
273
|
+
outbox.send_mail(
|
|
274
|
+
"Test email",
|
|
275
|
+
"This is a test email.",
|
|
276
|
+
[recipient],
|
|
277
|
+
)
|
|
278
|
+
self.message_user(request, "Test email sent", messages.SUCCESS)
|
|
279
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
280
|
+
self.message_user(request, str(exc), messages.ERROR)
|
|
281
|
+
return redirect("..")
|
|
282
|
+
|
|
283
|
+
def get_model_perms(self, request): # pragma: no cover - hide from index
|
|
284
|
+
return {}
|
|
285
|
+
|
|
286
|
+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
287
|
+
extra_context = extra_context or {}
|
|
288
|
+
if object_id:
|
|
289
|
+
extra_context["test_url"] = reverse(
|
|
290
|
+
"admin:nodes_emailoutbox_test", args=[object_id]
|
|
291
|
+
)
|
|
292
|
+
return super().changeform_view(request, object_id, form_url, extra_context)
|
|
212
293
|
|
|
213
294
|
|
|
214
295
|
class NodeRoleAdminForm(forms.ModelForm):
|
|
@@ -229,16 +310,48 @@ class NodeRoleAdminForm(forms.ModelForm):
|
|
|
229
310
|
|
|
230
311
|
|
|
231
312
|
@admin.register(NodeRole)
|
|
232
|
-
class NodeRoleAdmin(
|
|
313
|
+
class NodeRoleAdmin(EntityModelAdmin):
|
|
233
314
|
form = NodeRoleAdminForm
|
|
234
|
-
list_display = ("name", "description")
|
|
315
|
+
list_display = ("name", "description", "registered", "default_features")
|
|
316
|
+
|
|
317
|
+
def get_queryset(self, request):
|
|
318
|
+
qs = super().get_queryset(request)
|
|
319
|
+
return qs.annotate(_registered=Count("node", distinct=True)).prefetch_related(
|
|
320
|
+
"features"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
@admin.display(description="Registered", ordering="_registered")
|
|
324
|
+
def registered(self, obj):
|
|
325
|
+
return getattr(obj, "_registered", obj.node_set.count())
|
|
326
|
+
|
|
327
|
+
@admin.display(description="Default Features")
|
|
328
|
+
def default_features(self, obj):
|
|
329
|
+
features = [feature.display for feature in obj.features.all()]
|
|
330
|
+
return ", ".join(features) if features else "—"
|
|
235
331
|
|
|
236
332
|
def save_model(self, request, obj, form, change):
|
|
237
333
|
obj.node_set.set(form.cleaned_data.get("nodes", []))
|
|
238
334
|
|
|
239
335
|
|
|
336
|
+
@admin.register(NodeFeature)
|
|
337
|
+
class NodeFeatureAdmin(EntityModelAdmin):
|
|
338
|
+
filter_horizontal = ("roles",)
|
|
339
|
+
list_display = ("display", "slug", "default_roles", "is_enabled")
|
|
340
|
+
readonly_fields = ("is_enabled",)
|
|
341
|
+
search_fields = ("display", "slug")
|
|
342
|
+
|
|
343
|
+
def get_queryset(self, request):
|
|
344
|
+
qs = super().get_queryset(request)
|
|
345
|
+
return qs.prefetch_related("roles")
|
|
346
|
+
|
|
347
|
+
@admin.display(description="Default Roles")
|
|
348
|
+
def default_roles(self, obj):
|
|
349
|
+
roles = [role.name for role in obj.roles.all()]
|
|
350
|
+
return ", ".join(roles) if roles else "—"
|
|
351
|
+
|
|
352
|
+
|
|
240
353
|
@admin.register(ContentSample)
|
|
241
|
-
class ContentSampleAdmin(
|
|
354
|
+
class ContentSampleAdmin(EntityModelAdmin):
|
|
242
355
|
list_display = ("name", "kind", "node", "user", "created_at")
|
|
243
356
|
readonly_fields = ("created_at", "name", "user", "image_preview")
|
|
244
357
|
|
|
@@ -267,13 +380,17 @@ class ContentSampleAdmin(admin.ModelAdmin):
|
|
|
267
380
|
if not content:
|
|
268
381
|
self.message_user(request, "Clipboard is empty.", level=messages.INFO)
|
|
269
382
|
return redirect("..")
|
|
270
|
-
if ContentSample.objects.filter(
|
|
383
|
+
if ContentSample.objects.filter(
|
|
384
|
+
content=content, kind=ContentSample.TEXT
|
|
385
|
+
).exists():
|
|
271
386
|
self.message_user(
|
|
272
387
|
request, "Duplicate sample not created.", level=messages.INFO
|
|
273
388
|
)
|
|
274
389
|
return redirect("..")
|
|
275
390
|
user = request.user if request.user.is_authenticated else None
|
|
276
|
-
ContentSample.objects.create(
|
|
391
|
+
ContentSample.objects.create(
|
|
392
|
+
content=content, user=user, kind=ContentSample.TEXT
|
|
393
|
+
)
|
|
277
394
|
self.message_user(
|
|
278
395
|
request, "Text sample added from clipboard.", level=messages.SUCCESS
|
|
279
396
|
)
|
|
@@ -291,9 +408,7 @@ class ContentSampleAdmin(admin.ModelAdmin):
|
|
|
291
408
|
if sample:
|
|
292
409
|
self.message_user(request, f"Screenshot saved to {path}", messages.SUCCESS)
|
|
293
410
|
else:
|
|
294
|
-
self.message_user(
|
|
295
|
-
request, "Duplicate screenshot; not saved", messages.INFO
|
|
296
|
-
)
|
|
411
|
+
self.message_user(request, "Duplicate screenshot; not saved", messages.INFO)
|
|
297
412
|
return redirect("..")
|
|
298
413
|
|
|
299
414
|
@admin.display(description="Screenshot")
|
|
@@ -314,7 +429,7 @@ class ContentSampleAdmin(admin.ModelAdmin):
|
|
|
314
429
|
|
|
315
430
|
|
|
316
431
|
@admin.register(NetMessage)
|
|
317
|
-
class NetMessageAdmin(
|
|
432
|
+
class NetMessageAdmin(EntityModelAdmin):
|
|
318
433
|
list_display = ("subject", "body", "reach", "created", "complete")
|
|
319
434
|
search_fields = ("subject", "body")
|
|
320
435
|
list_filter = ("complete", "reach")
|
|
@@ -328,243 +443,3 @@ class NetMessageAdmin(admin.ModelAdmin):
|
|
|
328
443
|
self.message_user(request, f"{queryset.count()} messages sent")
|
|
329
444
|
|
|
330
445
|
send_messages.short_description = "Send selected messages"
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
class NodeTaskForm(forms.ModelForm):
|
|
334
|
-
class Meta:
|
|
335
|
-
model = NodeTask
|
|
336
|
-
fields = "__all__"
|
|
337
|
-
widgets = {"recipe": CodeEditorWidget()}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
@admin.register(NodeTask)
|
|
341
|
-
class NodeTaskAdmin(admin.ModelAdmin):
|
|
342
|
-
form = NodeTaskForm
|
|
343
|
-
list_display = ("recipe", "role", "created")
|
|
344
|
-
actions = ["execute"]
|
|
345
|
-
|
|
346
|
-
def execute(self, request, queryset):
|
|
347
|
-
if queryset.count() != 1:
|
|
348
|
-
self.message_user(
|
|
349
|
-
request, "Please select exactly one task", messages.ERROR
|
|
350
|
-
)
|
|
351
|
-
return
|
|
352
|
-
task_obj = queryset.first()
|
|
353
|
-
if "apply" in request.POST:
|
|
354
|
-
node_ids = request.POST.getlist("nodes")
|
|
355
|
-
nodes_qs = Node.objects.filter(pk__in=node_ids)
|
|
356
|
-
results = []
|
|
357
|
-
for node in nodes_qs:
|
|
358
|
-
try:
|
|
359
|
-
output = task_obj.run(node)
|
|
360
|
-
except Exception as exc:
|
|
361
|
-
output = str(exc)
|
|
362
|
-
results.append((node, output))
|
|
363
|
-
context = {"recipe": task_obj.recipe, "results": results}
|
|
364
|
-
return render(request, "admin/nodes/task_result.html", context)
|
|
365
|
-
nodes = Node.objects.all()
|
|
366
|
-
context = {"nodes": nodes, "task_obj": task_obj}
|
|
367
|
-
return render(request, "admin/nodes/nodetask/run.html", context)
|
|
368
|
-
|
|
369
|
-
execute.short_description = "Run task on nodes"
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
@admin.register(Operation)
|
|
373
|
-
class OperationAdmin(admin.ModelAdmin):
|
|
374
|
-
list_display = ("name",)
|
|
375
|
-
formfield_overrides = {models.TextField: {"widget": CodeEditorWidget}}
|
|
376
|
-
change_form_template = "admin/nodes/operation/change_form.html"
|
|
377
|
-
|
|
378
|
-
def get_urls(self):
|
|
379
|
-
urls = super().get_urls()
|
|
380
|
-
custom = [
|
|
381
|
-
path(
|
|
382
|
-
"<path:object_id>/run/",
|
|
383
|
-
self.admin_site.admin_view(self.run_view),
|
|
384
|
-
name="nodes_operation_run",
|
|
385
|
-
)
|
|
386
|
-
]
|
|
387
|
-
return custom + urls
|
|
388
|
-
|
|
389
|
-
def run_view(self, request, object_id):
|
|
390
|
-
operation = self.get_object(request, object_id)
|
|
391
|
-
if not operation:
|
|
392
|
-
self.message_user(request, "Unknown operation", messages.ERROR)
|
|
393
|
-
return redirect("..")
|
|
394
|
-
context = RUN_CONTEXTS.setdefault(operation.pk, {"inputs": {}})
|
|
395
|
-
template_text = operation.resolve_sigils("template")
|
|
396
|
-
|
|
397
|
-
# Interrupt handling
|
|
398
|
-
interrupt_id = request.GET.get("interrupt")
|
|
399
|
-
if interrupt_id:
|
|
400
|
-
try:
|
|
401
|
-
interrupt = operation.outgoing_interrupts.get(pk=interrupt_id)
|
|
402
|
-
except Interrupt.DoesNotExist:
|
|
403
|
-
self.message_user(request, "Unknown interrupt", messages.ERROR)
|
|
404
|
-
return redirect(request.path)
|
|
405
|
-
proc = context.get("process")
|
|
406
|
-
if proc and proc.poll() is None:
|
|
407
|
-
proc.terminate()
|
|
408
|
-
out, err = proc.communicate()
|
|
409
|
-
log = context.get("log")
|
|
410
|
-
if log:
|
|
411
|
-
log.output = out
|
|
412
|
-
log.error = err
|
|
413
|
-
log.interrupted = True
|
|
414
|
-
log.interrupt = interrupt
|
|
415
|
-
log.save()
|
|
416
|
-
RUN_CONTEXTS.pop(operation.pk, None)
|
|
417
|
-
return redirect(
|
|
418
|
-
reverse("admin:nodes_operation_run", args=[interrupt.to_operation.pk])
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
# Check running processes
|
|
422
|
-
proc = context.get("process")
|
|
423
|
-
thread = context.get("thread")
|
|
424
|
-
if proc and proc.poll() is not None:
|
|
425
|
-
out, err = proc.communicate()
|
|
426
|
-
log = context.pop("log")
|
|
427
|
-
log.output = out
|
|
428
|
-
log.error = err
|
|
429
|
-
log.save()
|
|
430
|
-
RUN_CONTEXTS.pop(operation.pk, None)
|
|
431
|
-
self.message_user(request, "Operation executed", messages.SUCCESS)
|
|
432
|
-
return redirect("..")
|
|
433
|
-
if thread and not thread.is_alive():
|
|
434
|
-
out = context.pop("out")
|
|
435
|
-
err = context.pop("err")
|
|
436
|
-
log = context.pop("log")
|
|
437
|
-
log.output = out.getvalue()
|
|
438
|
-
log.error = err.getvalue()
|
|
439
|
-
log.save()
|
|
440
|
-
RUN_CONTEXTS.pop(operation.pk, None)
|
|
441
|
-
self.message_user(request, "Operation executed", messages.SUCCESS)
|
|
442
|
-
return redirect("..")
|
|
443
|
-
|
|
444
|
-
interrupts = [
|
|
445
|
-
(i, i.resolve_sigils("preview"))
|
|
446
|
-
for i in operation.outgoing_interrupts.all().order_by("-priority")
|
|
447
|
-
]
|
|
448
|
-
logs = Logbook.objects.filter(operation=operation).order_by("created")
|
|
449
|
-
|
|
450
|
-
# Waiting for user-provided sigils
|
|
451
|
-
waiting = context.get("waiting_inputs")
|
|
452
|
-
if waiting:
|
|
453
|
-
if request.method == "POST":
|
|
454
|
-
for token in waiting:
|
|
455
|
-
name = token[1:-1].replace(".", "__")
|
|
456
|
-
context["inputs"][token] = request.POST.get(name, "")
|
|
457
|
-
command = context.pop("pending_command")
|
|
458
|
-
for token, value in context["inputs"].items():
|
|
459
|
-
command = command.replace(token, value)
|
|
460
|
-
context["waiting_inputs"] = None
|
|
461
|
-
self._start_operation(context, operation, command, request.user)
|
|
462
|
-
return redirect(request.path)
|
|
463
|
-
form_fields = [(t, t[1:-1].replace(".", "__")) for t in waiting]
|
|
464
|
-
tpl_context = {
|
|
465
|
-
**self.admin_site.each_context(request),
|
|
466
|
-
"operation": operation,
|
|
467
|
-
"interrupts": interrupts,
|
|
468
|
-
"logs": logs,
|
|
469
|
-
"waiting_inputs": form_fields,
|
|
470
|
-
"template": template_text,
|
|
471
|
-
}
|
|
472
|
-
return TemplateResponse(
|
|
473
|
-
request, "admin/nodes/operation/run.html", tpl_context
|
|
474
|
-
)
|
|
475
|
-
|
|
476
|
-
# Waiting for user continuation
|
|
477
|
-
if context.get("waiting_continue"):
|
|
478
|
-
if request.method == "POST":
|
|
479
|
-
context["waiting_continue"] = False
|
|
480
|
-
RUN_CONTEXTS.pop(operation.pk, None)
|
|
481
|
-
self.message_user(request, "Operation executed", messages.SUCCESS)
|
|
482
|
-
return redirect("..")
|
|
483
|
-
tpl_context = {
|
|
484
|
-
**self.admin_site.each_context(request),
|
|
485
|
-
"operation": operation,
|
|
486
|
-
"interrupts": interrupts,
|
|
487
|
-
"logs": logs,
|
|
488
|
-
"waiting_continue": True,
|
|
489
|
-
"template": template_text,
|
|
490
|
-
}
|
|
491
|
-
return TemplateResponse(
|
|
492
|
-
request, "admin/nodes/operation/run.html", tpl_context
|
|
493
|
-
)
|
|
494
|
-
|
|
495
|
-
# If a process or thread is running, show running state
|
|
496
|
-
if context.get("process") or context.get("thread"):
|
|
497
|
-
tpl_context = {
|
|
498
|
-
**self.admin_site.each_context(request),
|
|
499
|
-
"operation": operation,
|
|
500
|
-
"interrupts": interrupts,
|
|
501
|
-
"logs": logs,
|
|
502
|
-
"running": True,
|
|
503
|
-
"template": template_text,
|
|
504
|
-
}
|
|
505
|
-
return TemplateResponse(
|
|
506
|
-
request, "admin/nodes/operation/run.html", tpl_context
|
|
507
|
-
)
|
|
508
|
-
|
|
509
|
-
if request.method == "POST":
|
|
510
|
-
command = operation.resolve_sigils("command")
|
|
511
|
-
for token, value in context["inputs"].items():
|
|
512
|
-
command = command.replace(token, value)
|
|
513
|
-
unresolved = SIGIL_RE.findall(command)
|
|
514
|
-
if unresolved:
|
|
515
|
-
context["waiting_inputs"] = unresolved
|
|
516
|
-
context["pending_command"] = command
|
|
517
|
-
return redirect(request.path)
|
|
518
|
-
if command.strip() == "...":
|
|
519
|
-
log = Logbook.objects.create(
|
|
520
|
-
operation=operation,
|
|
521
|
-
user=request.user,
|
|
522
|
-
input_text=command,
|
|
523
|
-
output="Waiting for user continuation",
|
|
524
|
-
)
|
|
525
|
-
context["log"] = log
|
|
526
|
-
context["waiting_continue"] = True
|
|
527
|
-
return redirect(request.path)
|
|
528
|
-
self._start_operation(context, operation, command, request.user)
|
|
529
|
-
return redirect(request.path)
|
|
530
|
-
|
|
531
|
-
tpl_context = {
|
|
532
|
-
**self.admin_site.each_context(request),
|
|
533
|
-
"operation": operation,
|
|
534
|
-
"interrupts": interrupts,
|
|
535
|
-
"logs": logs,
|
|
536
|
-
"template": template_text,
|
|
537
|
-
}
|
|
538
|
-
return TemplateResponse(request, "admin/nodes/operation/run.html", tpl_context)
|
|
539
|
-
|
|
540
|
-
def _start_operation(self, ctx, operation, command, user):
|
|
541
|
-
log = Logbook.objects.create(operation=operation, user=user, input_text=command)
|
|
542
|
-
if operation.is_django:
|
|
543
|
-
out = io.StringIO()
|
|
544
|
-
err = io.StringIO()
|
|
545
|
-
|
|
546
|
-
def target():
|
|
547
|
-
try:
|
|
548
|
-
call_command(*command.split(), stdout=out, stderr=err)
|
|
549
|
-
except Exception as exc: # pragma: no cover - unexpected errors
|
|
550
|
-
err.write(str(exc))
|
|
551
|
-
|
|
552
|
-
thread = threading.Thread(target=target)
|
|
553
|
-
thread.start()
|
|
554
|
-
ctx.update({"thread": thread, "out": out, "err": err, "log": log})
|
|
555
|
-
else:
|
|
556
|
-
proc = subprocess.Popen(
|
|
557
|
-
command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
558
|
-
)
|
|
559
|
-
ctx.update({"process": proc, "log": log})
|
|
560
|
-
|
|
561
|
-
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
562
|
-
extra_context = extra_context or {}
|
|
563
|
-
if object_id:
|
|
564
|
-
extra_context["run_url"] = reverse(
|
|
565
|
-
"admin:nodes_operation_run", args=[object_id]
|
|
566
|
-
)
|
|
567
|
-
return super().changeform_view(request, object_id, form_url, extra_context)
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
admin.site.register(User, CoreUserAdmin)
|