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.

Files changed (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.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 +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {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 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
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 as NodeEmailOutbox,
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.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_]+\]")
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(admin.ModelAdmin):
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
- output = task_obj.run(node)
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
- 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
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
- @admin.register(EmailOutbox)
206
- class EmailOutboxAdmin(admin.ModelAdmin):
207
- list_display = ("node", "host", "port", "username", "use_tls", "use_ssl")
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 save_model(self, request, obj, form, change):
210
- super().save_model(request, obj, form, change)
211
- obj.__class__ = EmailOutbox
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(admin.ModelAdmin):
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(admin.ModelAdmin):
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(content=content, kind=ContentSample.TEXT).exists():
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(content=content, user=user, kind=ContentSample.TEXT)
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(admin.ModelAdmin):
318
- list_display = ("subject", "body", "reach", "created", "complete")
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 notification with host:port and version on a background thread."""
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 = f"v{version}"
33
+ body = version
39
34
  if rev_short:
40
- body += f" r{rev_short}"
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"{address}:{port}", body=body)
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
- """Send the startup notification once a request has started."""
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 = "2. Infrastructure"
65
+ verbose_name = "4. Infrastructure"
72
66
 
73
67
  def ready(self): # pragma: no cover - exercised on app start
74
- request_started.connect(
75
- _trigger_startup_notification, dispatch_uid="nodes-startup"
76
- )
68
+ from django.db.models.signals import post_migrate
69
+
70
+ post_migrate.connect(_trigger_startup_notification, sender=self)