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.

Files changed (81) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +133 -16
  10. config/urls.py +65 -6
  11. core/admin.py +1226 -191
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1071 -264
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +358 -63
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +7 -3
  42. core/workgroup_views.py +43 -6
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +1 -1
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.8.dist-info/RECORD +0 -80
  77. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  78. config/workgroup_app.py +0 -7
  79. core/checks.py +0 -29
  80. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  81. {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 chat profile endpoints."""
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("chat-profiles/<int:user_id>/", views.issue_key, name="chatprofile-issue"),
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 ChatProfile issuance and authentication."""
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 ChatProfile, hash_key
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 = ChatProfile.issue_key(user)
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 = ChatProfile.objects.get(user_key_hash=key_hash, is_active=True)
40
- except ChatProfile.DoesNotExist:
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
- user_id = request.chat_profile.user_id
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
@@ -68,5 +68,3 @@ class CaptureScreenshotAction(NodeAction):
68
68
  path = capture_screenshot(url)
69
69
  save_screenshot(path, node=node, method="NODE_ACTION")
70
70
  return path
71
-
72
-
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 app.widgets import CopyColorWidget, CodeEditorWidget
8
- from django.db import models
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 as NodeEmailOutbox,
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.admin import UserAdmin as CoreUserAdmin
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(admin.ModelAdmin):
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
- output = task_obj.run(node)
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
- class EmailOutbox(NodeEmailOutbox):
198
- class Meta:
199
- proxy = True
200
- app_label = "post_office"
201
- verbose_name = NodeEmailOutbox._meta.verbose_name
202
- verbose_name_plural = NodeEmailOutbox._meta.verbose_name_plural
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
- @admin.register(EmailOutbox)
206
- class EmailOutboxAdmin(admin.ModelAdmin):
207
- list_display = ("node", "host", "port", "username", "use_tls", "use_ssl")
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 save_model(self, request, obj, form, change):
210
- super().save_model(request, obj, form, change)
211
- obj.__class__ = EmailOutbox
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(admin.ModelAdmin):
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(admin.ModelAdmin):
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(content=content, kind=ContentSample.TEXT).exists():
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(content=content, user=user, kind=ContentSample.TEXT)
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(admin.ModelAdmin):
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)