├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── conftest.py ├── django_managerie ├── __init__.py ├── blocklist.py ├── commands.py ├── forms.py ├── managerie.py ├── py.typed ├── templates │ └── django_managerie │ │ └── admin │ │ ├── command.html │ │ └── list.html ├── types.py └── views.py ├── manage.py ├── managerie_test_app ├── __init__.py ├── admin.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── mg_disabled_command.py │ │ ├── mg_stdin_command.py │ │ ├── mg_test_command.py │ │ └── mg_unprivileged_command.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py ├── urls.py └── wsgi.py ├── managerie_tests ├── __init__.py ├── conftest.py └── test_managerie.py ├── pyproject.toml └── screenshot.png /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | tags: 7 | - 'v*' 8 | pull_request: 9 | jobs: 10 | Test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | include: 15 | - python-version: "3.10" 16 | - python-version: "3.11" 17 | - python-version: "3.12" 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: astral-sh/setup-uv@v5 21 | with: 22 | python-version: "${{ matrix.python-version }}" 23 | cache-dependency-glob: pyproject.toml 24 | - run: uvx --with=tox-gh --with=tox-uv tox 25 | Lint: 26 | runs-on: ubuntu-24.04 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: akx/pre-commit-uv-action@v0.1.0 30 | Build: 31 | needs: 32 | - Test 33 | - Lint 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: astral-sh/setup-uv@v5 38 | - run: uv build 39 | - name: Upload artifact 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: dist 43 | path: dist 44 | Publish: 45 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 46 | needs: 47 | - Build 48 | name: Upload release to PyPI 49 | runs-on: ubuntu-latest 50 | environment: 51 | name: release 52 | url: https://pypi.org/p/django-managerie/ 53 | permissions: 54 | id-token: write 55 | steps: 56 | - uses: actions/download-artifact@v4 57 | with: 58 | name: dist 59 | path: dist/ 60 | - name: Publish package distributions to PyPI 61 | uses: pypa/gh-action-pypi-publish@release/v1 62 | with: 63 | verbose: true 64 | print-hash: true 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg* 2 | *.py[cod] 3 | *.sqlite3 4 | .*cache 5 | .coverage 6 | .tox 7 | build 8 | dist 9 | htmlcov 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.9.9 4 | hooks: 5 | - id: ruff 6 | args: 7 | - --fix 8 | - id: ruff-format 9 | - repo: https://github.com/crate-ci/typos 10 | rev: v1.30.1 11 | hooks: 12 | - id: typos 13 | - repo: https://github.com/pre-commit/mirrors-mypy 14 | rev: v1.15.0 15 | hooks: 16 | - id: mypy 17 | args: 18 | - --show-error-codes 19 | additional_dependencies: 20 | - django-stubs 21 | - pytest 22 | - repo: https://github.com/pre-commit/pre-commit-hooks 23 | rev: v5.0.0 24 | hooks: 25 | - id: debug-statements 26 | - id: end-of-file-fixer 27 | - id: trailing-whitespace 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Aarni Koskela 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![pypi](https://img.shields.io/pypi/v/django-managerie.svg)](https://pypi.python.org/pypi/django-managerie/) 2 | 3 | # django-managerie 4 | 5 | :lightbulb: Expose Django management commands as forms in the admin, like so: 6 | 7 | ![Screenshot](./screenshot.png) 8 | 9 | ## Requirements 10 | 11 | - Django 4.2+ (this project tracks Django's end-of-life policy) 12 | - Python 3.10+ 13 | 14 | ## Installation 15 | 16 | Install the package as you would any Python package, then add `django_managerie` to your `INSTALLED_APPS`. 17 | 18 | ### Automatic patching 19 | 20 | This is the easiest way to get up and running. 21 | You can have Managerie patch the admin site's dashboard view to include pseudo-models with the name "Commands" 22 | for all apps where management commands are available, and while it's at it, it'll also include URLs of its own. 23 | 24 | Hook up Managerie to your admin site (e.g. in `urls.py`, where you have `admin.autodiscover()`), like so: 25 | 26 | ```python 27 | from django.contrib import admin 28 | from django_managerie import Managerie 29 | # ... 30 | managerie = Managerie(admin_site=admin.site) 31 | managerie.patch() 32 | ``` 33 | 34 | ### No patching 35 | 36 | This is likely safer (in the presence of slightly less tolerant 3rd party apps that mangle the admin, for instance), 37 | but you can't enjoy the luxury of the Commands buttons in the admin dashboard. 38 | 39 | ```python 40 | from django.contrib import admin 41 | from django.conf.urls import include, url 42 | from django_managerie import Managerie 43 | # ... 44 | managerie = Managerie(admin_site=admin.site) 45 | # ... 46 | urlpatterns = [ 47 | # ... 48 | # ... url(r'^admin/', include(admin.site.urls)), ... 49 | url(r'^admin/', include(managerie.urls)), # Add this! 50 | ] 51 | ``` 52 | 53 | ## Usage 54 | 55 | If you allowed Managerie to patch your admin, superusers can now see `Commands` "objects" in the admin dashboard. 56 | If you didn't patch the admin, you can access a list of all commands through `/admin/managerie/` 57 | (or wherever your admin is mounted). 58 | 59 | If you click through to a command, you'll see the arguments of the command laid out as a form. 60 | Fill the form, then hit "Execute Command", and you're done! :sparkles: 61 | 62 | ### Accessing the Django request from a managerie'd command 63 | 64 | Managerie sets `_managerie_request` on the command instance to the current Django request. 65 | You can use it to access the request, for instance, to get the current user. 66 | 67 | ### Accessing standard input 68 | 69 | By default, Managerie will patch `sys.stdin` to be an empty stream. 70 | If you need to read from standard input (e.g. long input), 71 | set the `managerie_accepts_stdin` attribute on your command class to `True`. 72 | 73 | This will cause Managerie to add a text-area to the form, which will be passed to the command as standard input. 74 | 75 | Note that `sys.stdin.buffer` (binary mode) is not supported. 76 | 77 | ## TODO 78 | 79 | - More `argparse` action support 80 | - Multiple-argument support (`nargs`) 81 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/django-managerie/b1684c8e72242592f141b3b6adf09c25d7367f14/conftest.py -------------------------------------------------------------------------------- /django_managerie/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.6.1" 2 | 3 | from .managerie import Managerie # noqa 4 | -------------------------------------------------------------------------------- /django_managerie/blocklist.py: -------------------------------------------------------------------------------- 1 | COMMAND_BLOCKLIST = { 2 | "auth.changepassword", # This is unusable due to getpass() 3 | } 4 | -------------------------------------------------------------------------------- /django_managerie/commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections import OrderedDict, defaultdict 3 | from functools import lru_cache 4 | from importlib import import_module 5 | 6 | from django.apps import apps 7 | from django.core.management import find_commands 8 | from django.urls import reverse 9 | 10 | 11 | class ManagementCommand: 12 | def __init__(self, app_config, name): 13 | self.app_config = app_config 14 | self.name = name 15 | self.title = self.name.replace("_", " ").title() 16 | 17 | @property 18 | def url(self): 19 | return reverse( 20 | "admin:managerie_command", 21 | kwargs={"app_label": self.app_config.label, "command": self.name}, 22 | ) 23 | 24 | def get_command_class(self): 25 | mname = f"{self.app_config.name}.management.commands.{self.name}" 26 | return import_module(mname).Command 27 | 28 | def get_command_instance(self): 29 | cls = self.get_command_class() 30 | return cls() 31 | 32 | @property 33 | def full_title(self): 34 | return f"{self.app_config.verbose_name} – {self.title}" 35 | 36 | @property 37 | def full_name(self): 38 | return f"{self.app_config.label}.{self.name}" 39 | 40 | 41 | @lru_cache(maxsize=None) 42 | def get_commands(): 43 | # Logic filched from django.core.management.get_commands(), but expressed in a saner way. 44 | apps_to_commands = defaultdict(OrderedDict) 45 | 46 | for app_config in apps.get_app_configs(): 47 | path = os.path.join(app_config.path, "management") 48 | for command_name in find_commands(path): 49 | apps_to_commands[app_config][command_name] = ManagementCommand( 50 | app_config=app_config, 51 | name=command_name, 52 | ) 53 | return apps_to_commands 54 | -------------------------------------------------------------------------------- /django_managerie/forms.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import warnings 3 | from typing import Any, Mapping, Optional, Type 4 | 5 | from django import forms 6 | from django.contrib.admin.widgets import AdminRadioSelect 7 | 8 | BOOLEAN_ACTIONS = ( 9 | argparse._StoreTrueAction, 10 | argparse._StoreFalseAction, 11 | argparse._StoreConstAction, 12 | ) 13 | 14 | FIELD_CLASS_MAP: Mapping[Any, Type[forms.Field]] = { 15 | float: forms.FloatField, 16 | int: forms.IntegerField, 17 | None: forms.CharField, 18 | } 19 | 20 | 21 | class ArgumentParserForm(forms.Form): 22 | IGNORED_DESTS = { 23 | "force_color", 24 | "no_color", 25 | "pythonpath", 26 | "settings", 27 | "traceback", 28 | } 29 | 30 | def __init__(self, *, parser: argparse.ArgumentParser, **kwargs) -> None: 31 | super().__init__(**kwargs) 32 | self.parser = parser 33 | for action in self.parser._actions: 34 | self._process_action(action) 35 | 36 | def _process_action(self, action: argparse.Action) -> None: 37 | if isinstance(action, argparse._HelpAction): 38 | return 39 | if action.dest in self.IGNORED_DESTS: 40 | return 41 | field_cls: Optional[Type[forms.Field]] = None 42 | field_kwargs = dict( 43 | initial=action.default, 44 | label=action.dest.replace("_", " ").title(), 45 | help_text=action.help, 46 | required=action.required, 47 | ) 48 | if isinstance(action, BOOLEAN_ACTIONS): 49 | field_cls = forms.BooleanField 50 | elif isinstance(action, argparse._StoreAction): 51 | if action.type not in FIELD_CLASS_MAP: 52 | warnings.warn(f"No specific field class for type {action.type!r}") 53 | if action.choices: 54 | field_cls = forms.ChoiceField 55 | try: 56 | if len(action.choices) < 10: # type: ignore[arg-type] 57 | field_kwargs["widget"] = AdminRadioSelect 58 | except Exception: # Might not be len-able, so don't crash 59 | pass 60 | field_kwargs["choices"] = [(str(c), str(c)) for c in action.choices] 61 | else: 62 | field_cls = FIELD_CLASS_MAP.get(action.type, forms.Field) 63 | if field_cls: 64 | self.fields[action.dest] = field_cls(**field_kwargs) 65 | 66 | # TODO: Probably support for more fields :) 67 | -------------------------------------------------------------------------------- /django_managerie/managerie.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Dict, List 3 | 4 | from django.apps.config import AppConfig 5 | from django.contrib.admin.sites import AdminSite 6 | from django.http import HttpRequest 7 | from django.urls import URLPattern, path, reverse 8 | 9 | from django_managerie.blocklist import COMMAND_BLOCKLIST 10 | from django_managerie.commands import ManagementCommand, get_commands 11 | from django_managerie.types import CommandMap 12 | 13 | 14 | def user_is_superuser(request: HttpRequest) -> bool: 15 | """ 16 | Return True if the user is an active superuser, False otherwise. 17 | """ 18 | return bool(request.user.is_active and getattr(request.user, "is_superuser", None)) 19 | 20 | 21 | class Managerie: 22 | ignored_app_names = { 23 | "django.core", 24 | "django.contrib.staticfiles", 25 | } 26 | 27 | def __init__(self, admin_site: AdminSite) -> None: 28 | self.admin_site = admin_site 29 | 30 | def patch(self) -> None: 31 | if hasattr(self.admin_site, "patched_by_managerie"): 32 | return 33 | old_get_app_list = self.admin_site.get_app_list 34 | old_get_urls = self.admin_site.get_urls 35 | 36 | @wraps(old_get_app_list) 37 | def patched_get_app_list(request: HttpRequest, *args, **kwargs): 38 | app_list = old_get_app_list(request, *args, **kwargs) 39 | if user_is_superuser(request): 40 | self._augment_app_list(request, app_list) 41 | return app_list 42 | 43 | @wraps(old_get_urls) 44 | def patched_get_urls() -> list: 45 | return self._get_urls() + list(old_get_urls()) 46 | 47 | self.admin_site.get_app_list = patched_get_app_list # type: ignore[assignment] 48 | self.admin_site.get_urls = patched_get_urls # type: ignore[assignment] 49 | self.admin_site.patched_by_managerie = True # type: ignore[attr-defined] 50 | 51 | def is_command_allowed( 52 | self, 53 | request: HttpRequest, 54 | command: ManagementCommand, 55 | ) -> bool: 56 | """ 57 | Return True if the command is allowed to be run in the current request. 58 | 59 | The default implementation checks if the command is enabled and then 60 | whether the user is a superuser. 61 | 62 | This can be overridden to implement per-command permissions. 63 | """ 64 | if not self.is_command_enabled(command): 65 | return False 66 | return user_is_superuser(request) 67 | 68 | def is_command_enabled(self, command: ManagementCommand) -> bool: 69 | """ 70 | Return True if the command is enabled (not blocklisted or opt-outed). 71 | 72 | This is only called by `is_command_allowed` as a pre-check before 73 | checking for per-request permissions. 74 | """ 75 | if command.full_name in COMMAND_BLOCKLIST: 76 | return False 77 | if getattr(command.get_command_class(), "disable_managerie", False): 78 | return False 79 | return True 80 | 81 | def get_commands( 82 | self, 83 | request: HttpRequest, 84 | ) -> Dict[AppConfig, CommandMap]: 85 | command_map: Dict[AppConfig, CommandMap] = {} 86 | for app_config, commands in get_commands().items(): 87 | command_map[app_config] = { 88 | command_name: command 89 | for (command_name, command) in commands.items() 90 | if self.is_command_allowed(command=command, request=request) 91 | } 92 | return command_map 93 | 94 | def get_commands_for_app_label( 95 | self, 96 | request: HttpRequest, 97 | app_label: str, 98 | ) -> CommandMap: 99 | for app_config, commands in self.get_commands(request).items(): 100 | if app_config.label == app_label: 101 | return commands 102 | return {} 103 | 104 | def _augment_app_list( 105 | self, 106 | request: HttpRequest, 107 | app_list: List[Dict], 108 | ): 109 | # TODO: apps without models won't have their commands shown here since they 110 | # don't show up in the app_list. We should probably show them anyway. 111 | all_commands: Dict[str, CommandMap] = { 112 | app_config.label: commands for (app_config, commands) in self.get_commands(request).items() 113 | } 114 | for app in app_list: 115 | if all_commands.get(app["app_label"]): # Has commands? 116 | app.setdefault("models", []).append(self._make_app_command(app)) 117 | 118 | def _make_app_command(self, app): 119 | return { 120 | "perms": {"change": True}, 121 | "admin_url": reverse( 122 | "admin:managerie_list", 123 | kwargs={"app_label": app["app_label"]}, 124 | current_app=self.admin_site.name, 125 | ), 126 | "name": "Commands", 127 | "object_name": "_ManagerieCommands_", 128 | } 129 | 130 | def _get_urls(self) -> List[URLPattern]: 131 | from django_managerie.views import ManagerieCommandView, ManagerieListView 132 | 133 | return [ 134 | path( 135 | "managerie///", 136 | ManagerieCommandView.as_view(managerie=self), 137 | name="managerie_command", 138 | ), 139 | path( 140 | "managerie//", 141 | ManagerieListView.as_view(managerie=self), 142 | name="managerie_list", 143 | ), 144 | path( 145 | "managerie/", 146 | ManagerieListView.as_view(managerie=self), 147 | name="managerie_list_all", 148 | ), 149 | ] 150 | 151 | @property 152 | def urls(self): 153 | return (self._get_urls(), "admin", self.admin_site.name) 154 | -------------------------------------------------------------------------------- /django_managerie/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/django-managerie/b1684c8e72242592f141b3b6adf09c25d7367f14/django_managerie/py.typed -------------------------------------------------------------------------------- /django_managerie/templates/django_managerie/admin/command.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n %} 3 | 4 | {% block extrahead %}{{ block.super }} 5 | 6 | {{ media }} 7 | {% endblock %} 8 | {% block breadcrumbs %} 9 | 14 | {% endblock %} 15 | {% block coltype %}colM{% endblock %} 16 | {% block content %} 17 |
18 |
{{ command_help }}
19 |
20 | {% csrf_token %} 21 | 22 | {{ form.as_table }} 23 |
24 | 25 |
26 | {% if executed %} 27 |

Result (executed in {{ duration|floatformat:3 }} seconds)

28 | {% if error %} 29 |

Error: {{ error }}

30 |
{{ error_tb }}
31 | {% else %} 32 | Command executed successfully. 33 | {% endif %} 34 |
35 | {% if stdout %} 36 |
37 |

Stdout

38 |
{{ stdout }}
39 |
40 | {% endif %} 41 | {% if stderr %} 42 |
43 |

Stderr

44 |
{{ stderr }}
45 |
46 | {% endif %} 47 |
48 | {% endif %} 49 |
50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /django_managerie/templates/django_managerie/admin/list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n %} 3 | {% block breadcrumbs %} 4 | 8 | {% endblock %} 9 | {% block content %} 10 |
11 | {% if commands %} 12 | 21 | {% else %} 22 | No commands available. 23 | {% endif %} 24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /django_managerie/types.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping 2 | 3 | from django_managerie.commands import ManagementCommand 4 | 5 | CommandMap = Mapping[str, ManagementCommand] 6 | -------------------------------------------------------------------------------- /django_managerie/views.py: -------------------------------------------------------------------------------- 1 | import io 2 | import sys 3 | import time 4 | import traceback 5 | from contextlib import contextmanager, redirect_stderr, redirect_stdout 6 | from itertools import chain 7 | from typing import Any, BinaryIO, Dict, Iterable, Optional 8 | 9 | from django import forms 10 | from django.apps import apps 11 | from django.apps.config import AppConfig 12 | from django.contrib.auth.mixins import AccessMixin 13 | from django.http import Http404, HttpResponse 14 | from django.views.generic import FormView, TemplateView 15 | 16 | from django_managerie.commands import ManagementCommand 17 | from django_managerie.forms import ArgumentParserForm 18 | from django_managerie.managerie import Managerie 19 | 20 | 21 | @contextmanager 22 | def redirect_stdin_binary(input_bin_stream: BinaryIO): 23 | old_stdin = sys.stdin 24 | try: 25 | sys.stdin = io.TextIOWrapper(input_bin_stream, encoding="UTF-8") 26 | assert sys.stdin.buffer is input_bin_stream 27 | yield 28 | finally: 29 | sys.stdin = old_stdin 30 | 31 | 32 | class ManagerieBaseMixin: 33 | managerie: Optional[Managerie] = None 34 | kwargs: Dict[str, Any] 35 | _app: Optional[AppConfig] 36 | 37 | def get_app(self) -> Optional[AppConfig]: 38 | if hasattr(self, "_app"): 39 | return self._app 40 | if "app_label" in self.kwargs: 41 | self._app = apps.get_app_config(self.kwargs["app_label"]) 42 | return self._app 43 | return None 44 | 45 | 46 | class StaffRequiredMixin(AccessMixin): 47 | """ 48 | Verify that the current user is authenticated and is a staff member. 49 | """ 50 | 51 | def dispatch(self, request, *args, **kwargs): 52 | if not request.user.is_authenticated: 53 | return self.handle_no_permission() 54 | if not request.user.is_staff: 55 | return self.handle_no_permission() 56 | return super().dispatch(request, *args, **kwargs) 57 | 58 | 59 | class ManagerieListView(ManagerieBaseMixin, StaffRequiredMixin, TemplateView): 60 | template_name = "django_managerie/admin/list.html" 61 | 62 | def get_context_data(self, **kwargs) -> Dict[str, Any]: 63 | context = super().get_context_data(**kwargs) 64 | context["app"] = app = self.get_app() 65 | context["title"] = f"{app.verbose_name if app else 'All Apps'} – Commands" 66 | managerie = self.managerie 67 | assert managerie 68 | commands: Iterable[ManagementCommand] 69 | if app: 70 | commands = managerie.get_commands_for_app_label( 71 | request=self.request, 72 | app_label=app.label, 73 | ).values() 74 | else: 75 | commands = chain( 76 | *(app_commands.values() for app_commands in managerie.get_commands(request=self.request).values()), 77 | ) 78 | context["commands"] = sorted(commands, key=lambda cmd: cmd.full_title) 79 | return context 80 | 81 | 82 | class ManagerieCommandView(ManagerieBaseMixin, StaffRequiredMixin, FormView): 83 | template_name = "django_managerie/admin/command.html" 84 | 85 | @property 86 | def command_name(self) -> str: 87 | return self.kwargs["command"] 88 | 89 | def get_command_object(self) -> ManagementCommand: 90 | app = self.get_app() 91 | managerie = self.managerie 92 | assert app and managerie 93 | try: 94 | return managerie.get_commands_for_app_label( 95 | request=self.request, 96 | app_label=app.label, 97 | )[self.command_name] 98 | except KeyError: 99 | raise Http404( 100 | f"Command {self.command_name} not found in {app.label} (or you don't have permission to run it)", 101 | ) 102 | 103 | def get_form(self, form_class=None) -> ArgumentParserForm: 104 | cmd = self.get_command_object().get_command_instance() 105 | parser = cmd.create_parser("django", self.command_name) 106 | form = ArgumentParserForm(parser=parser, **self.get_form_kwargs()) 107 | if getattr(cmd, "managerie_accepts_stdin", False): 108 | form.fields["_managerie_stdin_file"] = forms.FileField( 109 | label="Input file", 110 | required=False, 111 | ) 112 | form.fields["_managerie_stdin_content"] = forms.CharField( 113 | label="Input text", 114 | widget=forms.Textarea, 115 | help_text="Used only if input file is not set", 116 | required=False, 117 | ) 118 | return form 119 | 120 | def get_context_data(self, **kwargs) -> Dict[str, Any]: 121 | context = super().get_context_data(**kwargs) 122 | command = self.get_command_object() 123 | context.update( 124 | app=self.get_app(), 125 | command=command, 126 | command_help=command.get_command_instance().help, 127 | title=command.full_title, 128 | has_file_field=context["form"].is_multipart(), 129 | ) 130 | return context 131 | 132 | def form_valid(self, form: ArgumentParserForm) -> HttpResponse: 133 | # This mimics BaseCommand.run_from_argv(): 134 | options = dict(form.cleaned_data) 135 | # "Move positional args out of options to mimic legacy optparse" 136 | args = options.pop("args", ()) 137 | 138 | # Handle input 139 | if stdin_file := form.cleaned_data.pop("_managerie_stdin_file", None): 140 | stdin_binary = stdin_file.file 141 | elif stdin_content := form.cleaned_data.pop("_managerie_stdin_content", None): 142 | stdin_binary = io.BytesIO(stdin_content.encode("UTF-8")) 143 | else: 144 | stdin_binary = io.BytesIO() 145 | 146 | stdout = io.StringIO() 147 | stderr = io.StringIO() 148 | error = None 149 | error_tb = None 150 | t0 = time.time() 151 | co = self.get_command_object() 152 | with redirect_stdin_binary(stdin_binary), redirect_stdout(stdout), redirect_stderr(stderr): 153 | options.update( 154 | { 155 | "traceback": True, 156 | "no_color": True, 157 | "force_color": False, 158 | "stdout": stdout, 159 | "stderr": stderr, 160 | }, 161 | ) 162 | cmd = co.get_command_instance() 163 | try: 164 | cmd._managerie_request = self.request 165 | cmd.execute(*args, **options) 166 | except SystemExit as se: # We don't want any stray sys.exit()s to quit the app server 167 | stderr.write(f"") 168 | except Exception as exc: 169 | error = exc 170 | error_tb = traceback.format_exc() 171 | context = self.get_context_data( 172 | executed=True, 173 | form=form, 174 | error=error, 175 | error_tb=error_tb, 176 | stdout=stdout.getvalue(), 177 | stderr=stderr.getvalue(), 178 | duration=(time.time() - t0), 179 | ) 180 | return self.render_to_response(context=context, status=(400 if error else 200)) 181 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "managerie_test_app.settings") 5 | 6 | 7 | def manage(): 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | 12 | 13 | if __name__ == "__main__": 14 | manage() 15 | -------------------------------------------------------------------------------- /managerie_test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/django-managerie/b1684c8e72242592f141b3b6adf09c25d7367f14/managerie_test_app/__init__.py -------------------------------------------------------------------------------- /managerie_test_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from managerie_test_app.models import Example 4 | 5 | admin.site.register(Example) 6 | -------------------------------------------------------------------------------- /managerie_test_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ManagerieTestAppConfig(AppConfig): 5 | name = "managerie_test_app" 6 | verbose_name = "Managerie Test App" 7 | -------------------------------------------------------------------------------- /managerie_test_app/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/django-managerie/b1684c8e72242592f141b3b6adf09c25d7367f14/managerie_test_app/management/__init__.py -------------------------------------------------------------------------------- /managerie_test_app/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/django-managerie/b1684c8e72242592f141b3b6adf09c25d7367f14/managerie_test_app/management/commands/__init__.py -------------------------------------------------------------------------------- /managerie_test_app/management/commands/mg_disabled_command.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | 3 | 4 | class Command(BaseCommand): 5 | disable_managerie = True 6 | -------------------------------------------------------------------------------- /managerie_test_app/management/commands/mg_stdin_command.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from django.core.management import BaseCommand 4 | from django.core.management.base import CommandParser 5 | 6 | 7 | class Command(BaseCommand): 8 | managerie_accepts_stdin = True 9 | 10 | def add_arguments(self, parser: CommandParser) -> None: 11 | parser.add_argument("--operation", choices=["uppercase", "reverse", "count FF bytes"], required=True) 12 | 13 | def handle(self, operation, **options): 14 | if operation == "uppercase": 15 | content = sys.stdin.read().upper() 16 | elif operation == "reverse": 17 | content = sys.stdin.read()[::-1] 18 | elif operation == "count FF bytes": 19 | n = sys.stdin.buffer.read().count(b"\xff") 20 | content = f"Found {n} FF bytes" 21 | self.stdout.write(content) 22 | -------------------------------------------------------------------------------- /managerie_test_app/management/commands/mg_test_command.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.core.management import BaseCommand 4 | from django.core.management.base import CommandParser 5 | 6 | 7 | class Command(BaseCommand): 8 | def add_arguments(self, parser: CommandParser) -> None: 9 | parser.add_argument("--true-option", action="store_true", dest="foo") 10 | parser.add_argument("--false-option", action="store_false", dest="bar") 11 | parser.add_argument("string_option", default="wololo") 12 | 13 | def handle(self, **options): 14 | request = getattr(self, "_managerie_request", None) 15 | data = json.dumps( 16 | { 17 | **options, 18 | "username": request.user.username, 19 | }, 20 | default=str, 21 | sort_keys=True, 22 | ) 23 | self.stdout.write("XXX:" + data + ":XXX") 24 | -------------------------------------------------------------------------------- /managerie_test_app/management/commands/mg_unprivileged_command.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | 3 | 4 | class Command(BaseCommand): 5 | def handle(self, **options): 6 | pass 7 | -------------------------------------------------------------------------------- /managerie_test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.6 on 2025-03-05 10:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Example", 14 | fields=[ 15 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 16 | ], 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /managerie_test_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/django-managerie/b1684c8e72242592f141b3b6adf09c25d7367f14/managerie_test_app/migrations/__init__.py -------------------------------------------------------------------------------- /managerie_test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Example(models.Model): 5 | pass 6 | -------------------------------------------------------------------------------- /managerie_test_app/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | SECRET_KEY = "vpf9qs8wv6b8k(%6%=0)six1u4z6g@gb0l5(duj$hw_lg_45$l" 6 | DEBUG = True 7 | ALLOWED_HOSTS = ["*"] 8 | 9 | INSTALLED_APPS = [ 10 | "django.contrib.admin", 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "django.contrib.sessions", 14 | "django.contrib.messages", 15 | "django.contrib.staticfiles", 16 | "django_managerie", 17 | "managerie_test_app", 18 | ] 19 | 20 | MIDDLEWARE = [ 21 | "django.middleware.security.SecurityMiddleware", 22 | "django.contrib.sessions.middleware.SessionMiddleware", 23 | "django.middleware.common.CommonMiddleware", 24 | "django.middleware.csrf.CsrfViewMiddleware", 25 | "django.contrib.auth.middleware.AuthenticationMiddleware", 26 | "django.contrib.messages.middleware.MessageMiddleware", 27 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 28 | ] 29 | 30 | ROOT_URLCONF = "managerie_test_app.urls" 31 | 32 | TEMPLATES = [ 33 | { 34 | "BACKEND": "django.template.backends.django.DjangoTemplates", 35 | "DIRS": [], 36 | "APP_DIRS": True, 37 | "OPTIONS": { 38 | "context_processors": [ 39 | "django.template.context_processors.debug", 40 | "django.template.context_processors.request", 41 | "django.contrib.auth.context_processors.auth", 42 | "django.contrib.messages.context_processors.messages", 43 | ], 44 | }, 45 | }, 46 | ] 47 | 48 | WSGI_APPLICATION = "managerie_test_app.wsgi.application" 49 | 50 | DATABASES = { 51 | "default": { 52 | "ENGINE": "django.db.backends.sqlite3", 53 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 54 | }, 55 | } 56 | 57 | AUTH_PASSWORD_VALIDATORS: List[str] = [] 58 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 59 | LANGUAGE_CODE = "en-us" 60 | TIME_ZONE = "UTC" 61 | USE_I18N = True 62 | USE_L10N = True 63 | USE_TZ = True 64 | 65 | STATIC_URL = "/static/" 66 | -------------------------------------------------------------------------------- /managerie_test_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.http import HttpRequest 3 | from django.urls import path 4 | 5 | from django_managerie import Managerie 6 | from django_managerie.commands import ManagementCommand 7 | 8 | 9 | class CustomManagerie(Managerie): 10 | def is_command_allowed(self, request: HttpRequest, command: ManagementCommand) -> bool: 11 | if command.full_name == "managerie_test_app.mg_unprivileged_command": 12 | return bool(getattr(request.user, "is_staff", False)) 13 | return super().is_command_allowed(request, command) 14 | 15 | 16 | m = CustomManagerie(admin.site) 17 | m.patch() 18 | 19 | urlpatterns = [ 20 | path("admin/", admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /managerie_test_app/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "managerie_test_app.settings") 6 | application = get_wsgi_application() 7 | -------------------------------------------------------------------------------- /managerie_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/django-managerie/b1684c8e72242592f141b3b6adf09c25d7367f14/managerie_tests/__init__.py -------------------------------------------------------------------------------- /managerie_tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture() 5 | def staff_user(db: None, django_user_model, django_username_field: str): 6 | UserModel = django_user_model 7 | username_field = django_username_field 8 | username = "staff@example.com" if username_field == "email" else "staff" 9 | try: 10 | user = UserModel._default_manager.get_by_natural_key(username) 11 | except UserModel.DoesNotExist: 12 | user_data = { 13 | "password": "staff", 14 | username_field: username, 15 | "is_staff": True, 16 | "email": "staff@example.com", 17 | } 18 | user = UserModel._default_manager.create_user(**user_data) 19 | return user 20 | 21 | 22 | @pytest.fixture() 23 | def staff_client(db: None, staff_user): 24 | """A Django test client logged in as a staff user.""" 25 | from django.test.client import Client 26 | 27 | client = Client() 28 | client.force_login(staff_user) 29 | return client 30 | -------------------------------------------------------------------------------- /managerie_tests/test_managerie.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | from html import unescape 4 | 5 | import pytest 6 | from django.utils.crypto import get_random_string 7 | 8 | 9 | @pytest.mark.django_db 10 | def test_managerie(admin_client): 11 | assert "Commands" in admin_client.get("/admin/").content.decode() 12 | csup_content = admin_client.get("/admin/managerie/auth/createsuperuser/").content.decode() 13 | assert "Used to create a superuser." in csup_content 14 | assert "Verbosity" in csup_content 15 | assert "Username" in csup_content 16 | assert "Database" in csup_content 17 | all_commands_content = admin_client.get("/admin/managerie/").content.decode() 18 | assert "Mg Test Command" in all_commands_content 19 | assert "mg_test_command" in all_commands_content 20 | assert "Mg Disabled Command" not in all_commands_content 21 | assert "mg_disabled_command" not in all_commands_content 22 | 23 | 24 | @pytest.mark.django_db 25 | def test_mg_test_command(admin_client, admin_user): 26 | url = "/admin/managerie/managerie_test_app/mg_test_command/" 27 | resp = admin_client.get(url).content.decode() 28 | assert "wololo" in resp 29 | assert "multipart/form-data" not in resp 30 | string = get_random_string(42) 31 | content = admin_client.post( 32 | url, 33 | { 34 | "true_option": "1", 35 | "false_option": "1", 36 | "string_option": string, 37 | }, 38 | ).content.decode() 39 | assert "Command executed successfully." in content 40 | data = json.loads(unescape(content[content.index("XXX:") + 4 : content.index(":XXX")])) 41 | assert data["string_option"] == string 42 | assert admin_user.username 43 | assert data["username"] == admin_user.username 44 | 45 | 46 | @pytest.mark.django_db 47 | def test_mg_stdin_command_text(admin_client): 48 | url = "/admin/managerie/managerie_test_app/mg_stdin_command/" 49 | content = admin_client.post( 50 | url, 51 | { 52 | "operation": "uppercase", 53 | "_managerie_stdin_content": "let us scream", 54 | }, 55 | ).content.decode() 56 | assert "Command executed successfully." in content 57 | assert "LET US SCREAM" in content 58 | 59 | 60 | @pytest.mark.django_db 61 | def test_mg_stdin_command_file(admin_client): 62 | url = "/admin/managerie/managerie_test_app/mg_stdin_command/" 63 | assert "multipart/form-data" in admin_client.get(url).content.decode() 64 | content = admin_client.post( 65 | url, 66 | { 67 | "operation": "count FF bytes", 68 | # This can't be interpreted as UTF-8 69 | "_managerie_stdin_file": io.BytesIO(b"\xff\x00\xff"), 70 | }, 71 | ).content.decode() 72 | assert "Command executed successfully." in content 73 | assert "Found 2 FF bytes" in content 74 | 75 | 76 | @pytest.mark.django_db 77 | def test_mg_disabled_command(admin_client): 78 | url = "/admin/managerie/managerie_test_app/mg_disabled_command/" 79 | assert "Not Found" in admin_client.get(url).content.decode() 80 | 81 | 82 | @pytest.mark.django_db 83 | def test_staff_no_access(staff_client): 84 | # Test there's no access to these commands for staff users 85 | for command in ("mg_disabled_command", "mg_test_command"): 86 | url = f"/admin/managerie/managerie_test_app/{command}/" 87 | assert "Not Found" in staff_client.get(url).content.decode() 88 | 89 | 90 | @pytest.mark.django_db 91 | def test_staff_custom_access(staff_client): 92 | # Test there's access to mg_unprivileged_command for staff users 93 | url = "/admin/managerie/managerie_test_app/mg_unprivileged_command/" 94 | assert "Unprivileged" in staff_client.get(url).content.decode() 95 | content = staff_client.post(url, {}).content.decode() 96 | assert "Command executed successfully." in content 97 | 98 | 99 | @pytest.mark.django_db 100 | def test_outsider_no_access(client): 101 | # Test there's no access to these commands for outsiders 102 | for command in ( 103 | "mg_disabled_command", 104 | "mg_test_command", 105 | "mg_unprivileged_command", 106 | ): 107 | url = f"/admin/managerie/managerie_test_app/{command}/" 108 | resp = client.get(url) 109 | assert resp.status_code == 302 # Redirect to login 110 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-managerie" 7 | dynamic = ["version"] 8 | description = "Expose Django management commands in the admin" 9 | readme = "README.md" 10 | license = "MIT" 11 | requires-python = ">=3.10" 12 | authors = [ 13 | { name = "Aarni Koskela", email = "akx@iki.fi" }, 14 | ] 15 | classifiers = [ 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3 :: Only", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | ] 25 | dependencies = [ 26 | "Django>=4.2", 27 | ] 28 | 29 | [project.optional-dependencies] 30 | dev = [ 31 | "pytest-django~=4.10", 32 | "pytest>=7.2", 33 | ] 34 | 35 | [project.urls] 36 | Homepage = "https://github.com/akx/django-managerie" 37 | 38 | [tool.hatch.version] 39 | path = "django_managerie/__init__.py" 40 | 41 | [tool.hatch.build.targets.sdist] 42 | include = [ 43 | "/django_managerie", 44 | ] 45 | 46 | [tool.pytest.ini_options] 47 | DJANGO_SETTINGS_MODULE = "managerie_test_app.settings" 48 | 49 | [tool.ruff] 50 | line-length = 120 51 | 52 | [tool.ruff.lint] 53 | extend-select = [ 54 | "COM", 55 | "C9", 56 | "E", 57 | "F", 58 | "I", 59 | "W", 60 | ] 61 | 62 | [tool.ruff.lint.mccabe] 63 | max-complexity = 10 64 | 65 | [tool.tox] 66 | legacy_tox_ini = """ 67 | [tox] 68 | isolated_build = true 69 | envlist = 70 | py31{0,1}-django{42} 71 | py31{2,3}-django{50,51} 72 | 73 | [testenv] 74 | commands = py.test {posargs} 75 | extras = dev 76 | deps = 77 | django42: Django~=4.2.0 78 | django50: Django~=5.0.0 79 | django51: Django~=5.1.0 80 | 81 | [gh] 82 | python = 83 | 3.10 = py310 84 | 3.11 = py311 85 | 3.12 = py312 86 | 3.13 = py313 87 | """ 88 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/django-managerie/b1684c8e72242592f141b3b6adf09c25d7367f14/screenshot.png --------------------------------------------------------------------------------