arthexis 0.1.8__py3-none-any.whl → 0.1.10__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.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.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 +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- 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 +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- 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 +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- 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.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
nodes/admin.py
CHANGED
|
@@ -4,41 +4,30 @@ 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.
|
|
13
|
-
from django.core.management import call_command
|
|
12
|
+
from django.utils.translation import gettext_lazy as _
|
|
14
13
|
import base64
|
|
15
14
|
import pyperclip
|
|
16
15
|
from pyperclip import PyperclipException
|
|
17
16
|
import uuid
|
|
18
17
|
import subprocess
|
|
19
|
-
import io
|
|
20
|
-
import threading
|
|
21
|
-
import re
|
|
22
18
|
from .utils import capture_screenshot, save_screenshot
|
|
23
19
|
from .actions import NodeAction
|
|
24
20
|
|
|
25
21
|
from .models import (
|
|
26
22
|
Node,
|
|
27
|
-
EmailOutbox
|
|
23
|
+
EmailOutbox,
|
|
28
24
|
NodeRole,
|
|
25
|
+
NodeFeature,
|
|
26
|
+
NodeFeatureAssignment,
|
|
29
27
|
ContentSample,
|
|
30
|
-
NodeTask,
|
|
31
28
|
NetMessage,
|
|
32
|
-
Operation,
|
|
33
|
-
Interrupt,
|
|
34
|
-
Logbook,
|
|
35
|
-
User,
|
|
36
29
|
)
|
|
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_]+\]")
|
|
30
|
+
from core.user_data import EntityModelAdmin
|
|
42
31
|
|
|
43
32
|
|
|
44
33
|
class NodeAdminForm(forms.ModelForm):
|
|
@@ -48,8 +37,14 @@ class NodeAdminForm(forms.ModelForm):
|
|
|
48
37
|
widgets = {"badge_color": CopyColorWidget()}
|
|
49
38
|
|
|
50
39
|
|
|
40
|
+
class NodeFeatureAssignmentInline(admin.TabularInline):
|
|
41
|
+
model = NodeFeatureAssignment
|
|
42
|
+
extra = 0
|
|
43
|
+
autocomplete_fields = ("feature",)
|
|
44
|
+
|
|
45
|
+
|
|
51
46
|
@admin.register(Node)
|
|
52
|
-
class NodeAdmin(
|
|
47
|
+
class NodeAdmin(EntityModelAdmin):
|
|
53
48
|
list_display = (
|
|
54
49
|
"hostname",
|
|
55
50
|
"mac_address",
|
|
@@ -62,8 +57,8 @@ class NodeAdmin(admin.ModelAdmin):
|
|
|
62
57
|
change_list_template = "admin/nodes/node/change_list.html"
|
|
63
58
|
change_form_template = "admin/nodes/node/change_form.html"
|
|
64
59
|
form = NodeAdminForm
|
|
65
|
-
actions = ["run_task", "take_screenshots"]
|
|
66
|
-
|
|
60
|
+
actions = ["register_visitor", "run_task", "take_screenshots"]
|
|
61
|
+
inlines = [NodeFeatureAssignmentInline]
|
|
67
62
|
|
|
68
63
|
def get_urls(self):
|
|
69
64
|
urls = super().get_urls()
|
|
@@ -73,6 +68,11 @@ class NodeAdmin(admin.ModelAdmin):
|
|
|
73
68
|
self.admin_site.admin_view(self.register_current),
|
|
74
69
|
name="nodes_node_register_current",
|
|
75
70
|
),
|
|
71
|
+
path(
|
|
72
|
+
"register-visitor/",
|
|
73
|
+
self.admin_site.admin_view(self.register_visitor_view),
|
|
74
|
+
name="nodes_node_register_visitor",
|
|
75
|
+
),
|
|
76
76
|
path(
|
|
77
77
|
"<int:node_id>/action/<str:action>/",
|
|
78
78
|
self.admin_site.admin_view(self.action_view),
|
|
@@ -100,6 +100,32 @@ class NodeAdmin(admin.ModelAdmin):
|
|
|
100
100
|
}
|
|
101
101
|
return render(request, "admin/nodes/node/register_remote.html", context)
|
|
102
102
|
|
|
103
|
+
@admin.action(description="Register Visitor Node")
|
|
104
|
+
def register_visitor(self, request, queryset=None):
|
|
105
|
+
return self.register_visitor_view(request)
|
|
106
|
+
|
|
107
|
+
def register_visitor_view(self, request):
|
|
108
|
+
"""Exchange registration data with the visiting node."""
|
|
109
|
+
|
|
110
|
+
node, created = Node.register_current()
|
|
111
|
+
if created:
|
|
112
|
+
self.message_user(
|
|
113
|
+
request, f"Current host registered as {node}", messages.SUCCESS
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
token = uuid.uuid4().hex
|
|
117
|
+
context = {
|
|
118
|
+
**self.admin_site.each_context(request),
|
|
119
|
+
"opts": self.model._meta,
|
|
120
|
+
"title": _("Register Visitor Node"),
|
|
121
|
+
"token": token,
|
|
122
|
+
"info_url": reverse("node-info"),
|
|
123
|
+
"register_url": reverse("register-node"),
|
|
124
|
+
"visitor_info_url": "http://localhost:8000/nodes/info/",
|
|
125
|
+
"visitor_register_url": "http://localhost:8000/nodes/register/",
|
|
126
|
+
}
|
|
127
|
+
return render(request, "admin/nodes/node/register_visitor.html", context)
|
|
128
|
+
|
|
103
129
|
def public_key(self, request, node_id):
|
|
104
130
|
node = self.get_object(request, node_id)
|
|
105
131
|
if not node:
|
|
@@ -117,11 +143,21 @@ class NodeAdmin(admin.ModelAdmin):
|
|
|
117
143
|
def run_task(self, request, queryset):
|
|
118
144
|
if "apply" in request.POST:
|
|
119
145
|
recipe_text = request.POST.get("recipe", "")
|
|
120
|
-
task_obj, _ = NodeTask.objects.get_or_create(recipe=recipe_text)
|
|
121
146
|
results = []
|
|
122
147
|
for node in queryset:
|
|
123
148
|
try:
|
|
124
|
-
|
|
149
|
+
if not node.is_local:
|
|
150
|
+
raise NotImplementedError(
|
|
151
|
+
"Remote node execution is not implemented"
|
|
152
|
+
)
|
|
153
|
+
command = ["/bin/sh", "-c", recipe_text]
|
|
154
|
+
result = subprocess.run(
|
|
155
|
+
command,
|
|
156
|
+
check=False,
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
)
|
|
160
|
+
output = result.stdout + result.stderr
|
|
125
161
|
except Exception as exc:
|
|
126
162
|
output = str(exc)
|
|
127
163
|
results.append((node, output))
|
|
@@ -194,21 +230,67 @@ class NodeAdmin(admin.ModelAdmin):
|
|
|
194
230
|
return redirect(reverse("admin:nodes_node_change", args=[node_id]))
|
|
195
231
|
|
|
196
232
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
233
|
+
@admin.register(EmailOutbox)
|
|
234
|
+
class EmailOutboxAdmin(EntityModelAdmin):
|
|
235
|
+
list_display = ("owner_label", "host", "port", "username", "use_tls", "use_ssl")
|
|
236
|
+
change_form_template = "admin/nodes/emailoutbox/change_form.html"
|
|
237
|
+
fieldsets = (
|
|
238
|
+
("Owner", {"fields": ("user", "group", "node")}),
|
|
239
|
+
(
|
|
240
|
+
None,
|
|
241
|
+
{
|
|
242
|
+
"fields": (
|
|
243
|
+
"host",
|
|
244
|
+
"port",
|
|
245
|
+
"username",
|
|
246
|
+
"password",
|
|
247
|
+
"use_tls",
|
|
248
|
+
"use_ssl",
|
|
249
|
+
"from_email",
|
|
250
|
+
)
|
|
251
|
+
},
|
|
252
|
+
),
|
|
253
|
+
)
|
|
203
254
|
|
|
255
|
+
@admin.display(description="Owner")
|
|
256
|
+
def owner_label(self, obj):
|
|
257
|
+
return obj.owner_display()
|
|
204
258
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
259
|
+
def get_urls(self):
|
|
260
|
+
urls = super().get_urls()
|
|
261
|
+
custom = [
|
|
262
|
+
path(
|
|
263
|
+
"<path:object_id>/test/",
|
|
264
|
+
self.admin_site.admin_view(self.test_outbox),
|
|
265
|
+
name="nodes_emailoutbox_test",
|
|
266
|
+
)
|
|
267
|
+
]
|
|
268
|
+
return custom + urls
|
|
208
269
|
|
|
209
|
-
def
|
|
210
|
-
|
|
211
|
-
|
|
270
|
+
def test_outbox(self, request, object_id):
|
|
271
|
+
outbox = self.get_object(request, object_id)
|
|
272
|
+
if not outbox:
|
|
273
|
+
self.message_user(request, "Unknown outbox", messages.ERROR)
|
|
274
|
+
return redirect("..")
|
|
275
|
+
recipient = request.user.email or outbox.username
|
|
276
|
+
try:
|
|
277
|
+
outbox.send_mail(
|
|
278
|
+
"Test email",
|
|
279
|
+
"This is a test email.",
|
|
280
|
+
[recipient],
|
|
281
|
+
)
|
|
282
|
+
self.message_user(request, "Test email sent", messages.SUCCESS)
|
|
283
|
+
except Exception as exc: # pragma: no cover - admin feedback
|
|
284
|
+
self.message_user(request, str(exc), messages.ERROR)
|
|
285
|
+
return redirect("..")
|
|
286
|
+
|
|
287
|
+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
288
|
+
extra_context = extra_context or {}
|
|
289
|
+
if object_id:
|
|
290
|
+
extra_context["test_url"] = reverse(
|
|
291
|
+
"admin:nodes_emailoutbox_test", args=[object_id]
|
|
292
|
+
)
|
|
293
|
+
return super().changeform_view(request, object_id, form_url, extra_context)
|
|
212
294
|
|
|
213
295
|
|
|
214
296
|
class NodeRoleAdminForm(forms.ModelForm):
|
|
@@ -229,16 +311,48 @@ class NodeRoleAdminForm(forms.ModelForm):
|
|
|
229
311
|
|
|
230
312
|
|
|
231
313
|
@admin.register(NodeRole)
|
|
232
|
-
class NodeRoleAdmin(
|
|
314
|
+
class NodeRoleAdmin(EntityModelAdmin):
|
|
233
315
|
form = NodeRoleAdminForm
|
|
234
|
-
list_display = ("name", "description")
|
|
316
|
+
list_display = ("name", "description", "registered", "default_features")
|
|
317
|
+
|
|
318
|
+
def get_queryset(self, request):
|
|
319
|
+
qs = super().get_queryset(request)
|
|
320
|
+
return qs.annotate(_registered=Count("node", distinct=True)).prefetch_related(
|
|
321
|
+
"features"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
@admin.display(description="Registered", ordering="_registered")
|
|
325
|
+
def registered(self, obj):
|
|
326
|
+
return getattr(obj, "_registered", obj.node_set.count())
|
|
327
|
+
|
|
328
|
+
@admin.display(description="Default Features")
|
|
329
|
+
def default_features(self, obj):
|
|
330
|
+
features = [feature.display for feature in obj.features.all()]
|
|
331
|
+
return ", ".join(features) if features else "—"
|
|
235
332
|
|
|
236
333
|
def save_model(self, request, obj, form, change):
|
|
237
334
|
obj.node_set.set(form.cleaned_data.get("nodes", []))
|
|
238
335
|
|
|
239
336
|
|
|
337
|
+
@admin.register(NodeFeature)
|
|
338
|
+
class NodeFeatureAdmin(EntityModelAdmin):
|
|
339
|
+
filter_horizontal = ("roles",)
|
|
340
|
+
list_display = ("display", "slug", "default_roles", "is_enabled")
|
|
341
|
+
readonly_fields = ("is_enabled",)
|
|
342
|
+
search_fields = ("display", "slug")
|
|
343
|
+
|
|
344
|
+
def get_queryset(self, request):
|
|
345
|
+
qs = super().get_queryset(request)
|
|
346
|
+
return qs.prefetch_related("roles")
|
|
347
|
+
|
|
348
|
+
@admin.display(description="Default Roles")
|
|
349
|
+
def default_roles(self, obj):
|
|
350
|
+
roles = [role.name for role in obj.roles.all()]
|
|
351
|
+
return ", ".join(roles) if roles else "—"
|
|
352
|
+
|
|
353
|
+
|
|
240
354
|
@admin.register(ContentSample)
|
|
241
|
-
class ContentSampleAdmin(
|
|
355
|
+
class ContentSampleAdmin(EntityModelAdmin):
|
|
242
356
|
list_display = ("name", "kind", "node", "user", "created_at")
|
|
243
357
|
readonly_fields = ("created_at", "name", "user", "image_preview")
|
|
244
358
|
|
|
@@ -267,13 +381,17 @@ class ContentSampleAdmin(admin.ModelAdmin):
|
|
|
267
381
|
if not content:
|
|
268
382
|
self.message_user(request, "Clipboard is empty.", level=messages.INFO)
|
|
269
383
|
return redirect("..")
|
|
270
|
-
if ContentSample.objects.filter(
|
|
384
|
+
if ContentSample.objects.filter(
|
|
385
|
+
content=content, kind=ContentSample.TEXT
|
|
386
|
+
).exists():
|
|
271
387
|
self.message_user(
|
|
272
388
|
request, "Duplicate sample not created.", level=messages.INFO
|
|
273
389
|
)
|
|
274
390
|
return redirect("..")
|
|
275
391
|
user = request.user if request.user.is_authenticated else None
|
|
276
|
-
ContentSample.objects.create(
|
|
392
|
+
ContentSample.objects.create(
|
|
393
|
+
content=content, user=user, kind=ContentSample.TEXT
|
|
394
|
+
)
|
|
277
395
|
self.message_user(
|
|
278
396
|
request, "Text sample added from clipboard.", level=messages.SUCCESS
|
|
279
397
|
)
|
|
@@ -291,9 +409,7 @@ class ContentSampleAdmin(admin.ModelAdmin):
|
|
|
291
409
|
if sample:
|
|
292
410
|
self.message_user(request, f"Screenshot saved to {path}", messages.SUCCESS)
|
|
293
411
|
else:
|
|
294
|
-
self.message_user(
|
|
295
|
-
request, "Duplicate screenshot; not saved", messages.INFO
|
|
296
|
-
)
|
|
412
|
+
self.message_user(request, "Duplicate screenshot; not saved", messages.INFO)
|
|
297
413
|
return redirect("..")
|
|
298
414
|
|
|
299
415
|
@admin.display(description="Screenshot")
|
|
@@ -314,8 +430,15 @@ class ContentSampleAdmin(admin.ModelAdmin):
|
|
|
314
430
|
|
|
315
431
|
|
|
316
432
|
@admin.register(NetMessage)
|
|
317
|
-
class NetMessageAdmin(
|
|
318
|
-
list_display = (
|
|
433
|
+
class NetMessageAdmin(EntityModelAdmin):
|
|
434
|
+
list_display = (
|
|
435
|
+
"subject",
|
|
436
|
+
"body",
|
|
437
|
+
"reach",
|
|
438
|
+
"node_origin",
|
|
439
|
+
"created",
|
|
440
|
+
"complete",
|
|
441
|
+
)
|
|
319
442
|
search_fields = ("subject", "body")
|
|
320
443
|
list_filter = ("complete", "reach")
|
|
321
444
|
ordering = ("-created",)
|
|
@@ -328,243 +451,3 @@ class NetMessageAdmin(admin.ModelAdmin):
|
|
|
328
451
|
self.message_user(request, f"{queryset.count()} messages sent")
|
|
329
452
|
|
|
330
453
|
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)
|
nodes/apps.py
CHANGED
|
@@ -7,7 +7,6 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from django.apps import AppConfig
|
|
9
9
|
from django.conf import settings
|
|
10
|
-
from django.core.signals import request_started
|
|
11
10
|
from django.db import connections
|
|
12
11
|
from django.db.utils import OperationalError
|
|
13
12
|
from utils import revision
|
|
@@ -17,13 +16,9 @@ logger = logging.getLogger(__name__)
|
|
|
17
16
|
|
|
18
17
|
|
|
19
18
|
def _startup_notification() -> None:
|
|
20
|
-
"""Queue a
|
|
19
|
+
"""Queue a Net Message with ``hostname:port`` and version info."""
|
|
21
20
|
|
|
22
21
|
host = socket.gethostname()
|
|
23
|
-
try:
|
|
24
|
-
address = socket.gethostbyname(host)
|
|
25
|
-
except socket.gaierror:
|
|
26
|
-
address = host
|
|
27
22
|
|
|
28
23
|
port = os.environ.get("PORT", "8000")
|
|
29
24
|
|
|
@@ -35,9 +30,9 @@ def _startup_notification() -> None:
|
|
|
35
30
|
revision_value = revision.get_revision()
|
|
36
31
|
rev_short = revision_value[-6:] if revision_value else ""
|
|
37
32
|
|
|
38
|
-
body =
|
|
33
|
+
body = version
|
|
39
34
|
if rev_short:
|
|
40
|
-
body
|
|
35
|
+
body = f"{body} r{rev_short}" if body else f"r{rev_short}"
|
|
41
36
|
|
|
42
37
|
def _worker() -> None: # pragma: no cover - background thread
|
|
43
38
|
# Allow the LCD a moment to become ready and retry a few times
|
|
@@ -45,7 +40,7 @@ def _startup_notification() -> None:
|
|
|
45
40
|
try:
|
|
46
41
|
from nodes.models import NetMessage
|
|
47
42
|
|
|
48
|
-
NetMessage.broadcast(subject=f"{
|
|
43
|
+
NetMessage.broadcast(subject=f"{host}:{port}", body=body)
|
|
49
44
|
break
|
|
50
45
|
except Exception:
|
|
51
46
|
time.sleep(1)
|
|
@@ -54,9 +49,8 @@ def _startup_notification() -> None:
|
|
|
54
49
|
|
|
55
50
|
|
|
56
51
|
def _trigger_startup_notification(**_: object) -> None:
|
|
57
|
-
"""
|
|
52
|
+
"""Attempt to send the startup notification in the background."""
|
|
58
53
|
|
|
59
|
-
request_started.disconnect(_trigger_startup_notification, dispatch_uid="nodes-startup")
|
|
60
54
|
try:
|
|
61
55
|
connections["default"].ensure_connection()
|
|
62
56
|
except OperationalError:
|
|
@@ -68,9 +62,9 @@ def _trigger_startup_notification(**_: object) -> None:
|
|
|
68
62
|
class NodesConfig(AppConfig):
|
|
69
63
|
default_auto_field = "django.db.models.BigAutoField"
|
|
70
64
|
name = "nodes"
|
|
71
|
-
verbose_name = "
|
|
65
|
+
verbose_name = "4. Infrastructure"
|
|
72
66
|
|
|
73
67
|
def ready(self): # pragma: no cover - exercised on app start
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
68
|
+
from django.db.models.signals import post_migrate
|
|
69
|
+
|
|
70
|
+
post_migrate.connect(_trigger_startup_notification, sender=self)
|