django-admin-runner 0.1.0__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.
- django_admin_runner/__init__.py +15 -0
- django_admin_runner/_ansi.py +227 -0
- django_admin_runner/admin.py +450 -0
- django_admin_runner/admin_compat.py +26 -0
- django_admin_runner/apps.py +22 -0
- django_admin_runner/celery_tasks.py +94 -0
- django_admin_runner/context.py +44 -0
- django_admin_runner/forms.py +322 -0
- django_admin_runner/hooks.py +179 -0
- django_admin_runner/migrations/0001_initial.py +63 -0
- django_admin_runner/migrations/0002_commandexecution_result_html.py +17 -0
- django_admin_runner/migrations/0003_registeredcommand.py +35 -0
- django_admin_runner/migrations/0004_alter_commandexecution_options_and_more.py +28 -0
- django_admin_runner/migrations/__init__.py +0 -0
- django_admin_runner/models.py +60 -0
- django_admin_runner/registry.py +138 -0
- django_admin_runner/runners/__init__.py +73 -0
- django_admin_runner/runners/celery.py +52 -0
- django_admin_runner/runners/django_q2.py +50 -0
- django_admin_runner/runners/django_tasks.py +58 -0
- django_admin_runner/runners/sync.py +33 -0
- django_admin_runner/static/django_admin_runner/ansi-output.css +239 -0
- django_admin_runner/sync.py +60 -0
- django_admin_runner/tasks.py +144 -0
- django_admin_runner/templates/admin/django_admin_runner/commandexecution/change_list.html +11 -0
- django_admin_runner/templates/django_admin_runner/base/list.html +50 -0
- django_admin_runner/templates/django_admin_runner/base/result.html +35 -0
- django_admin_runner/templates/django_admin_runner/base/run.html +55 -0
- django_admin_runner/templates/django_admin_runner/unfold/list.html +57 -0
- django_admin_runner/templates/django_admin_runner/unfold/result.html +33 -0
- django_admin_runner/templates/django_admin_runner/unfold/run.html +103 -0
- django_admin_runner/templates/django_admin_runner/widgets/file_or_path.html +15 -0
- django_admin_runner/templates/django_admin_runner/widgets/file_or_path_unfold.html +23 -0
- django_admin_runner-0.1.0.dist-info/METADATA +151 -0
- django_admin_runner-0.1.0.dist-info/RECORD +36 -0
- django_admin_runner-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from django_admin_runner.context import is_admin_runner, set_result_html
|
|
2
|
+
from django_admin_runner.forms import FileField, FileOrPathField, FileOrPathWidget, ImageField
|
|
3
|
+
from django_admin_runner.registry import register_command
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"register_command",
|
|
9
|
+
"is_admin_runner",
|
|
10
|
+
"set_result_html",
|
|
11
|
+
"FileOrPathField",
|
|
12
|
+
"FileOrPathWidget",
|
|
13
|
+
"FileField",
|
|
14
|
+
"ImageField",
|
|
15
|
+
]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Minimal ANSI escape sequence to HTML converter.
|
|
2
|
+
|
|
3
|
+
Converts SGR (Select Graphic Rendition) escape sequences to HTML ``<span>``
|
|
4
|
+
elements with CSS classes and/or inline ``style`` attributes.
|
|
5
|
+
|
|
6
|
+
Supported codes:
|
|
7
|
+
- ``0`` — reset
|
|
8
|
+
- ``1`` bold, ``2`` dim, ``3`` italic, ``4`` underline
|
|
9
|
+
- ``22`` reset bold/dim, ``23`` reset italic, ``24`` reset underline
|
|
10
|
+
- ``30–37`` / ``90–97`` — 4-bit foreground (standard + bright) → CSS class
|
|
11
|
+
- ``40–47`` / ``100–107`` — 4-bit background → CSS class
|
|
12
|
+
- ``38;5;N`` / ``48;5;N`` — 256-colour: N<16 → CSS class, N≥16 → inline rgb()
|
|
13
|
+
- ``38;2;r;g;b`` / ``48;2;r;g;b`` — truecolor → inline rgb()
|
|
14
|
+
- ``39`` / ``49`` — reset foreground / background
|
|
15
|
+
|
|
16
|
+
4-bit colours map to ``ansi-fg-N`` / ``ansi-bg-N`` CSS classes (N = 0–15).
|
|
17
|
+
Style attributes are provided by ``ansi-output.css`` via CSS custom properties,
|
|
18
|
+
making the palette theme-aware (dark / light mode).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import html as _html
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
_ANSI_RE = re.compile(r"\x1b\[([0-9;]*)m")
|
|
28
|
+
_URL_RE = re.compile(r"https?://[^\s<>\"]+")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class _State:
|
|
33
|
+
fg: int | None = None # 0-15 → CSS class
|
|
34
|
+
bg: int | None = None
|
|
35
|
+
fg_rgb: tuple[int, int, int] | None = None # truecolor / 256-colour ≥16
|
|
36
|
+
bg_rgb: tuple[int, int, int] | None = None
|
|
37
|
+
attrs: set[str] = field(default_factory=set) # bold, dim, italic, underline
|
|
38
|
+
|
|
39
|
+
def reset(self) -> None:
|
|
40
|
+
self.fg = None
|
|
41
|
+
self.bg = None
|
|
42
|
+
self.fg_rgb = None
|
|
43
|
+
self.bg_rgb = None
|
|
44
|
+
self.attrs.clear()
|
|
45
|
+
|
|
46
|
+
def is_default(self) -> bool:
|
|
47
|
+
return (
|
|
48
|
+
self.fg is None
|
|
49
|
+
and self.bg is None
|
|
50
|
+
and self.fg_rgb is None
|
|
51
|
+
and self.bg_rgb is None
|
|
52
|
+
and not self.attrs
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def css_classes(self) -> list[str]:
|
|
56
|
+
classes = [f"ansi-{a}" for a in sorted(self.attrs)]
|
|
57
|
+
if self.fg is not None:
|
|
58
|
+
classes.append(f"ansi-fg-{self.fg}")
|
|
59
|
+
if self.bg is not None:
|
|
60
|
+
classes.append(f"ansi-bg-{self.bg}")
|
|
61
|
+
return classes
|
|
62
|
+
|
|
63
|
+
def inline_styles(self) -> list[str]:
|
|
64
|
+
styles = []
|
|
65
|
+
if self.fg_rgb is not None:
|
|
66
|
+
r, g, b = self.fg_rgb
|
|
67
|
+
styles.append(f"color:rgb({r},{g},{b})")
|
|
68
|
+
if self.bg_rgb is not None:
|
|
69
|
+
r, g, b = self.bg_rgb
|
|
70
|
+
styles.append(f"background:rgb({r},{g},{b})")
|
|
71
|
+
return styles
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _256_to_rgb(n: int) -> tuple[int, int, int]:
|
|
75
|
+
"""Convert 256-colour index ≥16 to ``(r, g, b)``."""
|
|
76
|
+
if n < 232:
|
|
77
|
+
n -= 16
|
|
78
|
+
return ((n // 36) * 51, ((n % 36) // 6) * 51, (n % 6) * 51)
|
|
79
|
+
v = (n - 232) * 10 + 8
|
|
80
|
+
return (v, v, v)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _apply_sgr(state: _State, params: list[int]) -> None:
|
|
84
|
+
"""Apply a list of SGR parameter values to *state* in-place."""
|
|
85
|
+
i = 0
|
|
86
|
+
while i < len(params):
|
|
87
|
+
p = params[i]
|
|
88
|
+
|
|
89
|
+
if p == 0:
|
|
90
|
+
state.reset()
|
|
91
|
+
elif p == 1:
|
|
92
|
+
state.attrs.add("bold")
|
|
93
|
+
elif p == 2:
|
|
94
|
+
state.attrs.add("dim")
|
|
95
|
+
elif p == 3:
|
|
96
|
+
state.attrs.add("italic")
|
|
97
|
+
elif p == 4:
|
|
98
|
+
state.attrs.add("underline")
|
|
99
|
+
elif p == 22:
|
|
100
|
+
state.attrs.discard("bold")
|
|
101
|
+
state.attrs.discard("dim")
|
|
102
|
+
elif p == 23:
|
|
103
|
+
state.attrs.discard("italic")
|
|
104
|
+
elif p == 24:
|
|
105
|
+
state.attrs.discard("underline")
|
|
106
|
+
elif 30 <= p <= 37:
|
|
107
|
+
state.fg = p - 30
|
|
108
|
+
state.fg_rgb = None
|
|
109
|
+
elif p == 38:
|
|
110
|
+
if i + 1 < len(params) and params[i + 1] == 5 and i + 2 < len(params):
|
|
111
|
+
n = params[i + 2]
|
|
112
|
+
if n < 16:
|
|
113
|
+
state.fg = n
|
|
114
|
+
state.fg_rgb = None
|
|
115
|
+
else:
|
|
116
|
+
state.fg = None
|
|
117
|
+
state.fg_rgb = _256_to_rgb(n)
|
|
118
|
+
i += 2
|
|
119
|
+
elif i + 1 < len(params) and params[i + 1] == 2 and i + 4 < len(params):
|
|
120
|
+
state.fg = None
|
|
121
|
+
state.fg_rgb = (params[i + 2], params[i + 3], params[i + 4])
|
|
122
|
+
i += 4
|
|
123
|
+
elif p == 39:
|
|
124
|
+
state.fg = None
|
|
125
|
+
state.fg_rgb = None
|
|
126
|
+
elif 40 <= p <= 47:
|
|
127
|
+
state.bg = p - 40
|
|
128
|
+
state.bg_rgb = None
|
|
129
|
+
elif p == 48:
|
|
130
|
+
if i + 1 < len(params) and params[i + 1] == 5 and i + 2 < len(params):
|
|
131
|
+
n = params[i + 2]
|
|
132
|
+
if n < 16:
|
|
133
|
+
state.bg = n
|
|
134
|
+
state.bg_rgb = None
|
|
135
|
+
else:
|
|
136
|
+
state.bg = None
|
|
137
|
+
state.bg_rgb = _256_to_rgb(n)
|
|
138
|
+
i += 2
|
|
139
|
+
elif i + 1 < len(params) and params[i + 1] == 2 and i + 4 < len(params):
|
|
140
|
+
state.bg = None
|
|
141
|
+
state.bg_rgb = (params[i + 2], params[i + 3], params[i + 4])
|
|
142
|
+
i += 4
|
|
143
|
+
elif p == 49:
|
|
144
|
+
state.bg = None
|
|
145
|
+
state.bg_rgb = None
|
|
146
|
+
elif 90 <= p <= 97:
|
|
147
|
+
state.fg = p - 90 + 8 # bright colours → indices 8–15
|
|
148
|
+
state.fg_rgb = None
|
|
149
|
+
elif 100 <= p <= 107:
|
|
150
|
+
state.bg = p - 100 + 8
|
|
151
|
+
state.bg_rgb = None
|
|
152
|
+
|
|
153
|
+
i += 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def ansi_to_html(text: str) -> str:
|
|
157
|
+
"""Convert ANSI escape sequences in *text* to HTML ``<span>`` elements.
|
|
158
|
+
|
|
159
|
+
Text content is HTML-escaped. Spans use CSS classes for 4-bit colours
|
|
160
|
+
(``ansi-fg-N`` / ``ansi-bg-N``) and inline ``style`` attributes for
|
|
161
|
+
256-colour (indices ≥16) and truecolor values.
|
|
162
|
+
"""
|
|
163
|
+
result: list[str] = []
|
|
164
|
+
state = _State()
|
|
165
|
+
span_open = False
|
|
166
|
+
|
|
167
|
+
def flush_span() -> None:
|
|
168
|
+
nonlocal span_open
|
|
169
|
+
if span_open:
|
|
170
|
+
result.append("</span>")
|
|
171
|
+
span_open = False
|
|
172
|
+
|
|
173
|
+
def emit_text(chunk: str) -> None:
|
|
174
|
+
nonlocal span_open
|
|
175
|
+
if not chunk:
|
|
176
|
+
return
|
|
177
|
+
if not span_open and not state.is_default():
|
|
178
|
+
classes = state.css_classes()
|
|
179
|
+
styles = state.inline_styles()
|
|
180
|
+
attrs = ""
|
|
181
|
+
if classes:
|
|
182
|
+
attrs += f' class="{" ".join(classes)}"'
|
|
183
|
+
if styles:
|
|
184
|
+
attrs += f' style="{";".join(styles)}"'
|
|
185
|
+
result.append(f"<span{attrs}>")
|
|
186
|
+
span_open = True
|
|
187
|
+
result.append(_html.escape(chunk))
|
|
188
|
+
|
|
189
|
+
pos = 0
|
|
190
|
+
for match in _ANSI_RE.finditer(text):
|
|
191
|
+
start, end = match.span()
|
|
192
|
+
emit_text(text[pos:start])
|
|
193
|
+
flush_span()
|
|
194
|
+
raw = match.group(1)
|
|
195
|
+
params = [int(x) for x in raw.split(";") if x] if raw else [0]
|
|
196
|
+
_apply_sgr(state, params)
|
|
197
|
+
pos = end
|
|
198
|
+
|
|
199
|
+
emit_text(text[pos:])
|
|
200
|
+
flush_span()
|
|
201
|
+
|
|
202
|
+
return "".join(result)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def linkify_urls(html: str) -> str:
|
|
206
|
+
"""Wrap plain-text URLs in *html* with ``<a>`` tags.
|
|
207
|
+
|
|
208
|
+
Operates on the output of :func:`ansi_to_html` — text content has already
|
|
209
|
+
been HTML-escaped (e.g. ``&``), so URL detection runs on the escaped
|
|
210
|
+
form. The regex avoids matching inside existing HTML tags by excluding
|
|
211
|
+
``<``, ``>``, and ``"`` characters.
|
|
212
|
+
"""
|
|
213
|
+
trailing_punct = set(".,;:!?")
|
|
214
|
+
|
|
215
|
+
def _replace(m: re.Match[str]) -> str:
|
|
216
|
+
url = m.group(0)
|
|
217
|
+
# Strip trailing punctuation that's unlikely to be part of the URL
|
|
218
|
+
stripped = []
|
|
219
|
+
while url and url[-1] in trailing_punct:
|
|
220
|
+
stripped.append(url[-1])
|
|
221
|
+
url = url[:-1]
|
|
222
|
+
href = _html.unescape(url)
|
|
223
|
+
link = f'<a href="{_html.escape(href)}" target="_blank">{url}</a>'
|
|
224
|
+
# Re-append stripped punctuation after the link
|
|
225
|
+
return link + "".join(reversed(stripped))
|
|
226
|
+
|
|
227
|
+
return _URL_RE.sub(_replace, html)
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, cast
|
|
4
|
+
|
|
5
|
+
from django.contrib import admin
|
|
6
|
+
from django.http import Http404, HttpResponse, HttpResponseForbidden
|
|
7
|
+
from django.shortcuts import redirect, render
|
|
8
|
+
from django.urls import path, reverse
|
|
9
|
+
from django.utils.safestring import SafeString, mark_safe
|
|
10
|
+
|
|
11
|
+
from ._ansi import ansi_to_html as _convert_ansi
|
|
12
|
+
from ._ansi import linkify_urls as _linkify
|
|
13
|
+
from .admin_compat import get_model_admin_base, get_template, is_unfold_installed
|
|
14
|
+
from .forms import form_from_command
|
|
15
|
+
from .models import CommandExecution, RegisteredCommand
|
|
16
|
+
from .registry import _registry, has_permission
|
|
17
|
+
from .runners import get_runner
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from django.db import models as _models
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _ansi_to_html(text: str) -> SafeString:
|
|
24
|
+
"""Wrap ANSI-coded *text* in a themed ``<pre>`` block with clickable URLs."""
|
|
25
|
+
html = _linkify(_convert_ansi(text))
|
|
26
|
+
return cast(SafeString, mark_safe(f'<pre class="ansi-output">{html}</pre>'))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Mixin for model admins that want attached command run links
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CommandRunnerModelAdminMixin:
|
|
35
|
+
"""Mix into any ``ModelAdmin`` to show "Run" links for commands registered with
|
|
36
|
+
``models=[ThisModel]``.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from django.contrib import admin
|
|
42
|
+
from django_admin_runner.admin import CommandRunnerModelAdminMixin
|
|
43
|
+
|
|
44
|
+
@admin.register(Book)
|
|
45
|
+
class BookAdmin(CommandRunnerModelAdminMixin, admin.ModelAdmin):
|
|
46
|
+
...
|
|
47
|
+
```
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
model: type[_models.Model] # provided by the ModelAdmin subclass
|
|
51
|
+
|
|
52
|
+
def changelist_view(self, request, extra_context=None):
|
|
53
|
+
attached = [
|
|
54
|
+
entry
|
|
55
|
+
for entry in _registry.values()
|
|
56
|
+
if self.model in entry["models"] and has_permission(request.user, entry)
|
|
57
|
+
]
|
|
58
|
+
extra_context = extra_context or {}
|
|
59
|
+
extra_context["admin_runner_commands"] = attached
|
|
60
|
+
return super().changelist_view(request, extra_context=extra_context) # type: ignore[misc]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# CommandExecution admin (also hosts the command list/run views)
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
_ModelAdminBase = get_model_admin_base()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ActiveListFilter(admin.SimpleListFilter):
|
|
71
|
+
title = "active"
|
|
72
|
+
parameter_name = "active"
|
|
73
|
+
|
|
74
|
+
def lookups(self, request, model_admin):
|
|
75
|
+
return [("1", "Yes"), ("0", "No")]
|
|
76
|
+
|
|
77
|
+
def queryset(self, request, queryset):
|
|
78
|
+
if self.value() == "0":
|
|
79
|
+
return queryset.filter(active=False)
|
|
80
|
+
return queryset.filter(active=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@admin.register(RegisteredCommand)
|
|
84
|
+
class RegisteredCommandAdmin(_ModelAdminBase): # type: ignore[misc]
|
|
85
|
+
list_display = [
|
|
86
|
+
"name_link",
|
|
87
|
+
"group",
|
|
88
|
+
"active",
|
|
89
|
+
"updated_at",
|
|
90
|
+
"buttons",
|
|
91
|
+
]
|
|
92
|
+
list_display_links = None
|
|
93
|
+
search_fields = ["name", "display_name"]
|
|
94
|
+
list_filter = [ActiveListFilter, "group"]
|
|
95
|
+
ordering = ["group", "name"]
|
|
96
|
+
|
|
97
|
+
def has_add_permission(self, request):
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
def has_change_permission(self, request, obj=None):
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def has_delete_permission(self, request, obj=None):
|
|
104
|
+
return request.user.is_superuser
|
|
105
|
+
|
|
106
|
+
def changelist_view(self, request, extra_context=None):
|
|
107
|
+
# Default the active filter to "Yes" when no active param is present
|
|
108
|
+
if "active" not in request.GET:
|
|
109
|
+
qp = request.GET.copy()
|
|
110
|
+
qp["active"] = "1"
|
|
111
|
+
return redirect(f"{request.path}?{qp.urlencode()}")
|
|
112
|
+
return super().changelist_view(request, extra_context=extra_context)
|
|
113
|
+
|
|
114
|
+
@admin.display(description="Name", ordering="name")
|
|
115
|
+
def name_link(self, obj: RegisteredCommand) -> SafeString:
|
|
116
|
+
name_html = f"<strong>{obj.display_name}</strong>"
|
|
117
|
+
desc_html = ""
|
|
118
|
+
if obj.description:
|
|
119
|
+
desc_html = (
|
|
120
|
+
f'<br><span style="color:var(--body-quiet-color,#666);'
|
|
121
|
+
f"max-width:300px;display:inline-block;overflow:hidden;"
|
|
122
|
+
f"text-overflow:ellipsis;white-space:nowrap;"
|
|
123
|
+
f'font-size:12px;">{obj.description}</span>'
|
|
124
|
+
)
|
|
125
|
+
if obj.active:
|
|
126
|
+
run_url = reverse("admin:django_admin_runner_command_run", args=[obj.name])
|
|
127
|
+
return cast(SafeString, mark_safe(f'<a href="{run_url}">{name_html}</a>{desc_html}'))
|
|
128
|
+
return cast(SafeString, mark_safe(f"{name_html}{desc_html}"))
|
|
129
|
+
|
|
130
|
+
@admin.display(description="")
|
|
131
|
+
def buttons(self, obj: RegisteredCommand) -> SafeString:
|
|
132
|
+
parts: list[str] = []
|
|
133
|
+
if obj.active:
|
|
134
|
+
run_url = reverse("admin:django_admin_runner_command_run", args=[obj.name])
|
|
135
|
+
parts.append(
|
|
136
|
+
f'<a href="{run_url}" '
|
|
137
|
+
f'style="display:inline-block;padding:4px 10px;border-radius:4px;'
|
|
138
|
+
f"font-size:11px;font-weight:600;color:#fff;"
|
|
139
|
+
f'background:#28a745;text-decoration:none;">Run</a>'
|
|
140
|
+
)
|
|
141
|
+
history_url = (
|
|
142
|
+
reverse("admin:django_admin_runner_commandexecution_changelist")
|
|
143
|
+
+ f"?command_name={obj.name}"
|
|
144
|
+
)
|
|
145
|
+
parts.append(
|
|
146
|
+
f'<a href="{history_url}" '
|
|
147
|
+
f'style="display:inline-block;padding:4px 10px;border-radius:4px;'
|
|
148
|
+
f"font-size:11px;font-weight:600;color:#fff;"
|
|
149
|
+
f'background:#0d6efd;text-decoration:none;">History</a>'
|
|
150
|
+
)
|
|
151
|
+
return cast(SafeString, mark_safe(" ".join(parts)))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@admin.register(CommandExecution)
|
|
155
|
+
class CommandExecutionAdmin(_ModelAdminBase): # type: ignore[misc]
|
|
156
|
+
class Media:
|
|
157
|
+
css = {"all": ("django_admin_runner/ansi-output.css",)}
|
|
158
|
+
|
|
159
|
+
list_display = [
|
|
160
|
+
"command_name",
|
|
161
|
+
"status",
|
|
162
|
+
"triggered_by",
|
|
163
|
+
"backend",
|
|
164
|
+
"created_at",
|
|
165
|
+
"result_button",
|
|
166
|
+
]
|
|
167
|
+
list_filter = ["status", "backend"]
|
|
168
|
+
search_fields = ["command_name", "triggered_by__username"]
|
|
169
|
+
readonly_fields = [
|
|
170
|
+
"command_name",
|
|
171
|
+
"status",
|
|
172
|
+
"result_html_display",
|
|
173
|
+
"stdout_display",
|
|
174
|
+
"stderr_display",
|
|
175
|
+
"kwargs",
|
|
176
|
+
"triggered_by",
|
|
177
|
+
"backend",
|
|
178
|
+
"task_id",
|
|
179
|
+
"created_at",
|
|
180
|
+
"started_at",
|
|
181
|
+
"finished_at",
|
|
182
|
+
]
|
|
183
|
+
fieldsets = [
|
|
184
|
+
(
|
|
185
|
+
None,
|
|
186
|
+
{
|
|
187
|
+
"fields": [
|
|
188
|
+
"command_name",
|
|
189
|
+
"status",
|
|
190
|
+
"kwargs",
|
|
191
|
+
"triggered_by",
|
|
192
|
+
"backend",
|
|
193
|
+
"task_id",
|
|
194
|
+
]
|
|
195
|
+
},
|
|
196
|
+
),
|
|
197
|
+
(
|
|
198
|
+
"Result",
|
|
199
|
+
{
|
|
200
|
+
"fields": ["result_html_display"],
|
|
201
|
+
},
|
|
202
|
+
),
|
|
203
|
+
(
|
|
204
|
+
"Output",
|
|
205
|
+
{
|
|
206
|
+
"fields": ["stdout_display", "stderr_display"],
|
|
207
|
+
},
|
|
208
|
+
),
|
|
209
|
+
(
|
|
210
|
+
"Timing",
|
|
211
|
+
{
|
|
212
|
+
"fields": ["created_at", "started_at", "finished_at"],
|
|
213
|
+
"classes": ["collapse"],
|
|
214
|
+
},
|
|
215
|
+
),
|
|
216
|
+
]
|
|
217
|
+
ordering = ["-created_at"]
|
|
218
|
+
|
|
219
|
+
@admin.display(description="Standard output")
|
|
220
|
+
def stdout_display(self, obj: CommandExecution) -> SafeString:
|
|
221
|
+
stdout = str(obj.stdout)
|
|
222
|
+
if not stdout:
|
|
223
|
+
return cast(SafeString, mark_safe("<em>—</em>"))
|
|
224
|
+
url = reverse(
|
|
225
|
+
"admin:django_admin_runner_commandexecution_stdout",
|
|
226
|
+
args=[obj.pk],
|
|
227
|
+
)
|
|
228
|
+
html = f'{_ansi_to_html(stdout)}<p><a href="{url}">Full View</a></p>'
|
|
229
|
+
return cast(SafeString, mark_safe(html))
|
|
230
|
+
|
|
231
|
+
@admin.display(description="Standard error / traceback")
|
|
232
|
+
def stderr_display(self, obj: CommandExecution) -> SafeString:
|
|
233
|
+
stderr = str(obj.stderr)
|
|
234
|
+
if not stderr:
|
|
235
|
+
return cast(SafeString, mark_safe("<em>—</em>"))
|
|
236
|
+
url = reverse(
|
|
237
|
+
"admin:django_admin_runner_commandexecution_stderr",
|
|
238
|
+
args=[obj.pk],
|
|
239
|
+
)
|
|
240
|
+
html = f'{_ansi_to_html(stderr)}<p><a href="{url}">Full View</a></p>'
|
|
241
|
+
return cast(SafeString, mark_safe(html))
|
|
242
|
+
|
|
243
|
+
@admin.display(description="Result")
|
|
244
|
+
def result_html_display(self, obj: CommandExecution) -> SafeString:
|
|
245
|
+
if not obj.result_html:
|
|
246
|
+
return cast(SafeString, mark_safe("<em>—</em>"))
|
|
247
|
+
result_url = reverse(
|
|
248
|
+
"admin:django_admin_runner_commandexecution_result",
|
|
249
|
+
args=[obj.pk],
|
|
250
|
+
)
|
|
251
|
+
html = (
|
|
252
|
+
f'<div style="max-height:300px;overflow:auto;border:1px solid #ddd;'
|
|
253
|
+
f'padding:8px;border-radius:4px;margin-bottom:8px;">'
|
|
254
|
+
f"{obj.result_html}</div>"
|
|
255
|
+
f'<a href="{result_url}">Full View</a>'
|
|
256
|
+
)
|
|
257
|
+
return cast(SafeString, mark_safe(html))
|
|
258
|
+
|
|
259
|
+
@admin.display(description="", ordering="created_at")
|
|
260
|
+
def result_button(self, obj: CommandExecution) -> SafeString:
|
|
261
|
+
buttons: list[str] = []
|
|
262
|
+
if obj.result_html:
|
|
263
|
+
url = reverse(
|
|
264
|
+
"admin:django_admin_runner_commandexecution_result",
|
|
265
|
+
args=[obj.pk],
|
|
266
|
+
)
|
|
267
|
+
buttons.append(
|
|
268
|
+
f'<a href="{url}" '
|
|
269
|
+
f'style="display:inline-block;padding:4px 10px;border-radius:4px;'
|
|
270
|
+
f"font-size:11px;font-weight:600;color:#fff;"
|
|
271
|
+
f'background:#28a745;text-decoration:none;margin-right:4px;"'
|
|
272
|
+
f">View</a>"
|
|
273
|
+
)
|
|
274
|
+
if obj.stdout:
|
|
275
|
+
url = reverse(
|
|
276
|
+
"admin:django_admin_runner_commandexecution_stdout",
|
|
277
|
+
args=[obj.pk],
|
|
278
|
+
)
|
|
279
|
+
buttons.append(
|
|
280
|
+
f'<a href="{url}" '
|
|
281
|
+
f'style="display:inline-block;padding:4px 10px;border-radius:4px;'
|
|
282
|
+
f"font-size:11px;font-weight:600;color:#fff;"
|
|
283
|
+
f'background:#0d6efd;text-decoration:none;margin-right:4px;"'
|
|
284
|
+
f">Stdout</a>"
|
|
285
|
+
)
|
|
286
|
+
if obj.stderr:
|
|
287
|
+
url = reverse(
|
|
288
|
+
"admin:django_admin_runner_commandexecution_stderr",
|
|
289
|
+
args=[obj.pk],
|
|
290
|
+
)
|
|
291
|
+
buttons.append(
|
|
292
|
+
f'<a href="{url}" '
|
|
293
|
+
f'style="display:inline-block;padding:4px 10px;border-radius:4px;'
|
|
294
|
+
f"font-size:11px;font-weight:600;color:#fff;"
|
|
295
|
+
f'background:#dc3545;text-decoration:none;margin-right:4px;"'
|
|
296
|
+
f">Stderr</a>"
|
|
297
|
+
)
|
|
298
|
+
if not buttons:
|
|
299
|
+
return cast(SafeString, mark_safe("<span>—</span>"))
|
|
300
|
+
return cast(SafeString, mark_safe("".join(buttons)))
|
|
301
|
+
|
|
302
|
+
def has_add_permission(self, request):
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
def get_queryset(self, request):
|
|
306
|
+
qs = super().get_queryset(request)
|
|
307
|
+
if request.user.has_perm("django_admin_runner.view_all_executions"):
|
|
308
|
+
return qs
|
|
309
|
+
return qs.filter(triggered_by=request.user)
|
|
310
|
+
|
|
311
|
+
# ------------------------------------------------------------------
|
|
312
|
+
# Extra URLs: command list + run form
|
|
313
|
+
# ------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def get_urls(self):
|
|
316
|
+
urls = super().get_urls()
|
|
317
|
+
custom = [
|
|
318
|
+
path(
|
|
319
|
+
"commands/<str:command_name>/run/",
|
|
320
|
+
self.admin_site.admin_view(self._command_run_view),
|
|
321
|
+
name="django_admin_runner_command_run",
|
|
322
|
+
),
|
|
323
|
+
path(
|
|
324
|
+
"<path:object_id>/result/",
|
|
325
|
+
self.admin_site.admin_view(self._result_view),
|
|
326
|
+
name="django_admin_runner_commandexecution_result",
|
|
327
|
+
),
|
|
328
|
+
path(
|
|
329
|
+
"<path:object_id>/stdout/",
|
|
330
|
+
self.admin_site.admin_view(self._stdout_view),
|
|
331
|
+
name="django_admin_runner_commandexecution_stdout",
|
|
332
|
+
),
|
|
333
|
+
path(
|
|
334
|
+
"<path:object_id>/stderr/",
|
|
335
|
+
self.admin_site.admin_view(self._stderr_view),
|
|
336
|
+
name="django_admin_runner_commandexecution_stderr",
|
|
337
|
+
),
|
|
338
|
+
]
|
|
339
|
+
return custom + urls
|
|
340
|
+
|
|
341
|
+
def _get_execution(self, request, object_id):
|
|
342
|
+
"""Fetch execution or 404, respecting queryset permissions."""
|
|
343
|
+
execution = self.get_queryset(request).filter(pk=object_id).first()
|
|
344
|
+
if execution is None:
|
|
345
|
+
raise Http404
|
|
346
|
+
return execution
|
|
347
|
+
|
|
348
|
+
def _render_output(
|
|
349
|
+
self,
|
|
350
|
+
request,
|
|
351
|
+
execution: CommandExecution,
|
|
352
|
+
title: str,
|
|
353
|
+
content: SafeString,
|
|
354
|
+
) -> HttpResponse:
|
|
355
|
+
change_url = reverse(
|
|
356
|
+
"admin:django_admin_runner_commandexecution_change",
|
|
357
|
+
args=[execution.pk],
|
|
358
|
+
)
|
|
359
|
+
context = {
|
|
360
|
+
**self.admin_site.each_context(request),
|
|
361
|
+
"title": title,
|
|
362
|
+
"execution": execution,
|
|
363
|
+
"content": content,
|
|
364
|
+
"change_url": change_url,
|
|
365
|
+
"opts": self.model._meta,
|
|
366
|
+
"is_unfold": is_unfold_installed(),
|
|
367
|
+
}
|
|
368
|
+
return render(request, get_template("result"), context)
|
|
369
|
+
|
|
370
|
+
def _result_view(self, request, object_id):
|
|
371
|
+
"""Standalone result page: result_html if set, otherwise stdout."""
|
|
372
|
+
execution = self._get_execution(request, object_id)
|
|
373
|
+
|
|
374
|
+
if execution.result_html:
|
|
375
|
+
content = cast(SafeString, mark_safe(execution.result_html))
|
|
376
|
+
else:
|
|
377
|
+
stdout = str(execution.stdout)
|
|
378
|
+
content = _ansi_to_html(stdout) if stdout else cast(SafeString, mark_safe(""))
|
|
379
|
+
|
|
380
|
+
return self._render_output(
|
|
381
|
+
request,
|
|
382
|
+
execution,
|
|
383
|
+
f"Result: {execution.command_name}",
|
|
384
|
+
content,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _stdout_view(self, request, object_id):
|
|
388
|
+
"""Standalone stdout page."""
|
|
389
|
+
execution = self._get_execution(request, object_id)
|
|
390
|
+
stdout = str(execution.stdout)
|
|
391
|
+
content = _ansi_to_html(stdout) if stdout else cast(SafeString, mark_safe("<em>—</em>"))
|
|
392
|
+
return self._render_output(
|
|
393
|
+
request,
|
|
394
|
+
execution,
|
|
395
|
+
f"Output: {execution.command_name}",
|
|
396
|
+
content,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _stderr_view(self, request, object_id):
|
|
400
|
+
"""Standalone stderr/traceback page."""
|
|
401
|
+
execution = self._get_execution(request, object_id)
|
|
402
|
+
stderr = str(execution.stderr)
|
|
403
|
+
content = _ansi_to_html(stderr) if stderr else cast(SafeString, mark_safe("<em>—</em>"))
|
|
404
|
+
return self._render_output(
|
|
405
|
+
request,
|
|
406
|
+
execution,
|
|
407
|
+
f"Traceback: {execution.command_name}",
|
|
408
|
+
content,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def _command_run_view(self, request, command_name: str):
|
|
412
|
+
if command_name not in _registry:
|
|
413
|
+
raise Http404(f"Command '{command_name}' is not registered.")
|
|
414
|
+
|
|
415
|
+
entry = _registry[command_name]
|
|
416
|
+
if not has_permission(request.user, entry):
|
|
417
|
+
return HttpResponseForbidden(b"You do not have permission to run this command.")
|
|
418
|
+
|
|
419
|
+
FormClass = form_from_command(command_name)
|
|
420
|
+
|
|
421
|
+
if request.method == "POST":
|
|
422
|
+
form = FormClass(request.POST, request.FILES)
|
|
423
|
+
if form.is_valid():
|
|
424
|
+
kwargs = {}
|
|
425
|
+
for k, v in form.cleaned_data.items():
|
|
426
|
+
if isinstance(v, bool):
|
|
427
|
+
kwargs[k] = v # always include booleans (True and False)
|
|
428
|
+
elif v not in ("", None):
|
|
429
|
+
kwargs[k] = v # exclude empty strings and None
|
|
430
|
+
execution = CommandExecution.objects.create(
|
|
431
|
+
command_name=command_name,
|
|
432
|
+
kwargs=kwargs,
|
|
433
|
+
triggered_by=request.user,
|
|
434
|
+
)
|
|
435
|
+
runner = get_runner()
|
|
436
|
+
result = runner.run(command_name, kwargs, request.user, execution)
|
|
437
|
+
return redirect(result.redirect_url)
|
|
438
|
+
else:
|
|
439
|
+
form = FormClass()
|
|
440
|
+
|
|
441
|
+
context = {
|
|
442
|
+
**self.admin_site.each_context(request),
|
|
443
|
+
"title": f"Run: {command_name}",
|
|
444
|
+
"form": form,
|
|
445
|
+
"command_name": command_name,
|
|
446
|
+
"entry": entry,
|
|
447
|
+
"opts": self.model._meta,
|
|
448
|
+
"is_unfold": is_unfold_installed(),
|
|
449
|
+
}
|
|
450
|
+
return render(request, get_template("run"), context)
|