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.
Files changed (36) hide show
  1. django_admin_runner/__init__.py +15 -0
  2. django_admin_runner/_ansi.py +227 -0
  3. django_admin_runner/admin.py +450 -0
  4. django_admin_runner/admin_compat.py +26 -0
  5. django_admin_runner/apps.py +22 -0
  6. django_admin_runner/celery_tasks.py +94 -0
  7. django_admin_runner/context.py +44 -0
  8. django_admin_runner/forms.py +322 -0
  9. django_admin_runner/hooks.py +179 -0
  10. django_admin_runner/migrations/0001_initial.py +63 -0
  11. django_admin_runner/migrations/0002_commandexecution_result_html.py +17 -0
  12. django_admin_runner/migrations/0003_registeredcommand.py +35 -0
  13. django_admin_runner/migrations/0004_alter_commandexecution_options_and_more.py +28 -0
  14. django_admin_runner/migrations/__init__.py +0 -0
  15. django_admin_runner/models.py +60 -0
  16. django_admin_runner/registry.py +138 -0
  17. django_admin_runner/runners/__init__.py +73 -0
  18. django_admin_runner/runners/celery.py +52 -0
  19. django_admin_runner/runners/django_q2.py +50 -0
  20. django_admin_runner/runners/django_tasks.py +58 -0
  21. django_admin_runner/runners/sync.py +33 -0
  22. django_admin_runner/static/django_admin_runner/ansi-output.css +239 -0
  23. django_admin_runner/sync.py +60 -0
  24. django_admin_runner/tasks.py +144 -0
  25. django_admin_runner/templates/admin/django_admin_runner/commandexecution/change_list.html +11 -0
  26. django_admin_runner/templates/django_admin_runner/base/list.html +50 -0
  27. django_admin_runner/templates/django_admin_runner/base/result.html +35 -0
  28. django_admin_runner/templates/django_admin_runner/base/run.html +55 -0
  29. django_admin_runner/templates/django_admin_runner/unfold/list.html +57 -0
  30. django_admin_runner/templates/django_admin_runner/unfold/result.html +33 -0
  31. django_admin_runner/templates/django_admin_runner/unfold/run.html +103 -0
  32. django_admin_runner/templates/django_admin_runner/widgets/file_or_path.html +15 -0
  33. django_admin_runner/templates/django_admin_runner/widgets/file_or_path_unfold.html +23 -0
  34. django_admin_runner-0.1.0.dist-info/METADATA +151 -0
  35. django_admin_runner-0.1.0.dist-info/RECORD +36 -0
  36. 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. ``&amp;``), 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)