├── .github └── workflows │ ├── build.yml │ ├── codecov.yml │ ├── mypy.yml │ └── pythonpublish.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── mypy.ini ├── pyproject.toml ├── requirements.txt ├── src └── admin_wizard │ ├── __init__.py │ ├── admin.py │ ├── forms.py │ ├── locale │ └── ru │ │ └── LC_MESSAGES │ │ └── django.po │ ├── models.py │ ├── templates │ └── admin │ │ ├── admin_action_wizard.html │ │ └── admin_update_wizard.html │ └── tests.py ├── tests ├── manage.py └── testproject │ ├── __init__.py │ ├── settings.py │ ├── testapp │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py │ ├── urls.py │ └── wsgi.py └── tox.ini /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install tox tox-gh-actions build wheel 23 | sed -i '/Django==.*/d' ./requirements.txt # delete django dependency 24 | - name: Test with tox 25 | run: | 26 | tox 27 | python -m build 28 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: codecov 2 | on: [push, pull_request] 3 | jobs: 4 | run: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@master 8 | - name: Setup Python 9 | uses: actions/setup-python@v2 10 | with: 11 | python-version: '3.x' 12 | - name: Generate coverage report 13 | run: | 14 | pip install -r requirements.txt 15 | pip install coverage 16 | pip install -q -e . 17 | coverage run --source=admin_wizard tests/manage.py test testproject 18 | coverage xml 19 | - name: Upload coverage to Codecov 20 | uses: codecov/codecov-action@v2 21 | with: 22 | token: ${{ secrets.CODECOV_TOKEN }} 23 | file: ./coverage.xml 24 | flags: unittests 25 | name: codecov-umbrella 26 | fail_ci_if_error: true 27 | -------------------------------------------------------------------------------- /.github/workflows/mypy.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | 3 | name: mypy testing 4 | 5 | on: [ push, pull_request ] 6 | 7 | jobs: 8 | deploy: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: '3.x' 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install -r requirements.txt 22 | pip install django-stubs[compatible-mypy] 23 | - name: Run Mypy tests 24 | run: | 25 | python -m mypy --config-file=mypy.ini . 26 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | 3 | name: Upload Python Package 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | deploy: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: '3.x' 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install build wheel twine 24 | - name: Build and publish 25 | env: 26 | TWINE_USERNAME: '__token__' 27 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 28 | run: | 29 | python -m build 30 | twine upload dist/* 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Just Work 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include admin_wizard/locale/*/LC_MESSAGES/django.po 2 | recursive-include admin_wizard/templates * 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-admin-wizard 2 | =================== 3 | 4 | 5 | django-admin-wizard is a Django app providing helpers for django admin actions 6 | with intermediate forms. 7 | 8 | [![Build Status](https://github.com/just-work/django-admin-wizard/workflows/build/badge.svg?branch=master&event=push)](https://github.com/just-work/django-admin-wizard/actions?query=event%3Apush+branch%3Amaster+workflow%3Abuild) 9 | [![codecov](https://codecov.io/gh/just-work/django-admin-wizard/branch/master/graph/badge.svg)](https://codecov.io/gh/just-work/django-admin-wizard) 10 | [![PyPI version](https://badge.fury.io/py/django-admin-wizard.svg)](https://badge.fury.io/py/django-admin-wizard) 11 | 12 | Description 13 | ----------- 14 | 15 | Do you know "delete selected" action in Django-admin? This package provides 16 | helpers for creating such actions with intermediate forms in two lines of code. 17 | Also, you may add a link from django admin change page to a custom form view to 18 | perform some form-supplied action on single object. 19 | 20 | Installation 21 | ------------ 22 | 23 | ```shell script 24 | pip install django-admin-wizard 25 | ``` 26 | 27 | Working example is in `testproject.testapp`. 28 | 29 | 1. Add application to installed apps in django settings: 30 | ```python 31 | INSTALLED_APPS.append('admin_wizard') 32 | ``` 33 | 2. And an action to your admin: 34 | ```python 35 | from django.contrib import admin 36 | from admin_wizard.admin import UpdateAction 37 | 38 | from testproject.testapp import forms, models 39 | 40 | 41 | @admin.register(models.MyModel) 42 | class MyModelAdmin(admin.ModelAdmin): 43 | actions = [UpdateAction(form_class=forms.RenameForm)] 44 | ``` 45 | 3. Add custom view to your admin: 46 | ```python 47 | from django.contrib import admin 48 | from django.urls import path 49 | from admin_wizard.admin import UpdateDialog 50 | 51 | from testproject.testapp import forms, models 52 | 53 | 54 | @admin.register(models.MyModel) 55 | class MyModelAdmin(admin.ModelAdmin): 56 | 57 | def get_urls(self): 58 | urls = [ 59 | path('/rename/', 60 | UpdateDialog.as_view(model_admin=self, 61 | model=models.MyModel, 62 | form_class=forms.RenameForm), 63 | name='rename') 64 | ] 65 | return urls + super().get_urls() 66 | 67 | ``` 68 | 4. Add a link to custom dialog in admin change page: 69 | ```python 70 | from django.contrib import admin 71 | from django.urls import reverse 72 | 73 | from testproject.testapp import models 74 | 75 | 76 | @admin.register(models.MyModel) 77 | class MyModelAdmin(admin.ModelAdmin): 78 | readonly_fields = ('update_obj_url',) 79 | 80 | def update_obj_url(self, obj): 81 | # FIXME: it's XSS, don't copy-paste 82 | url = reverse('admin:rename', kwargs=dict(pk=obj.pk)) 83 | return f'Rename...' 84 | update_obj_url.short_description = 'rename' 85 | ``` 86 | 87 | Now you have "rename" action in changelist and "rename" link in change view. 88 | Enjoy :) 89 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | follow_imports = normal 3 | mypy_path = src:tests 4 | plugins = 5 | mypy_django_plugin.main 6 | [mypy.plugins.django-stubs] 7 | django_settings_module = "testproject.settings" 8 | 9 | [mypy-admin_wizard.*] 10 | disallow_untyped_calls = true 11 | disallow_untyped_defs = true 12 | disallow_incomplete_defs = true 13 | 14 | [mypy-admin_smoke.*] 15 | ignore_missing_imports = true 16 | 17 | [mypy-django_stubs_ext] 18 | follow_imports = skip 19 | 20 | [mypy-*.migrations.*] 21 | ignore_errors = True -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools-git-versioning"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools-git-versioning] 6 | enabled = true 7 | 8 | [tool.setuptools.package-data] 9 | admin_wizard = ["**/*.html"] 10 | 11 | [project] 12 | name = "django-admin-wizard" 13 | dynamic = ["version"] 14 | description = "Django Admin dialogs and actions with intermediate forms" 15 | readme = "README.md" 16 | authors = [ 17 | { name = "Sergey Tikhonov", email = "zimbler@gmail.com" }, 18 | ] 19 | 20 | dependencies = [ 21 | "Django>=3.2,<5.1", 22 | ] 23 | license = { text = "MIT" } 24 | keywords = [ 25 | "django", 26 | "admin", 27 | "wizard", 28 | "actions", 29 | ] 30 | classifiers = [ 31 | 'Development Status :: 4 - Beta', 32 | 'Environment :: Console', 33 | 'Framework :: Django :: 3.2', 34 | 'Framework :: Django :: 4.0', 35 | 'Framework :: Django :: 4.1', 36 | 'Framework :: Django :: 4.2', 37 | 'Framework :: Django :: 5.0', 38 | 'Operating System :: POSIX', 39 | 'Programming Language :: Python :: 3.7', 40 | 'Programming Language :: Python :: 3.8', 41 | 'Programming Language :: Python :: 3.9', 42 | 'Programming Language :: Python :: 3.10', 43 | 'Programming Language :: Python :: 3.11', 44 | 'Programming Language :: Python :: 3.12', 45 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System', 46 | ] 47 | 48 | [project.urls] 49 | homepage = "https://github.com/just-work/django-admin-wizard" 50 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==5.0.3 2 | django-admin-smoke==0.5.0 3 | -------------------------------------------------------------------------------- /src/admin_wizard/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/just-work/django-admin-wizard/8675d6cb1fa344bec6cb39bd3817466c30c50353/src/admin_wizard/__init__.py -------------------------------------------------------------------------------- /src/admin_wizard/admin.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Any, Dict, cast, Optional 2 | 3 | from django import forms 4 | from django.contrib import admin 5 | from django.contrib.admin import helpers 6 | from django.db import models 7 | from django.db.models import QuerySet 8 | from django.http import HttpRequest, HttpResponse 9 | from django.shortcuts import redirect 10 | from django.urls import reverse 11 | from django.utils.translation import gettext_lazy as _ 12 | from django.views.generic import FormView, UpdateView 13 | 14 | from admin_wizard.forms import RedirectForm 15 | 16 | 17 | def admin_url(obj: models.Model) -> str: 18 | # noinspection PyProtectedMember 19 | url = reverse(f'admin:{obj._meta.app_label}_{obj._meta.model_name}_change', 20 | kwargs={'object_id': obj.pk}) 21 | return url 22 | 23 | 24 | class WizardBase: 25 | model_admin: admin.ModelAdmin 26 | 27 | def get_admin_form(self, form: forms.ModelForm) -> helpers.AdminForm: 28 | fieldsets = [(None, {'fields': list(form.fields)})] 29 | admin_form = helpers.AdminForm( 30 | form, 31 | fieldsets, 32 | prepopulated_fields={}, 33 | model_admin=self.model_admin) 34 | return admin_form 35 | 36 | 37 | class UpdateAction(WizardBase, FormView): 38 | template_name = "admin/admin_action_wizard.html" 39 | submit = "apply" 40 | summary = _("Summary") 41 | 42 | queryset: models.QuerySet 43 | 44 | def __init__(self, form_class: Type[forms.BaseForm], 45 | title: Optional[str] = None, 46 | short_description: Optional[str] = None): 47 | super().__init__() 48 | # used as action slug in ModelAdmin.actions 49 | self.__name__: str = self.__class__.__name__ 50 | self.form_class = form_class 51 | self.title = title or self.__name__ 52 | self.short_description = short_description or self.title 53 | 54 | def __call__(self, model_admin: admin.ModelAdmin, request: HttpRequest, 55 | queryset: QuerySet) -> HttpResponse: 56 | self.request = request 57 | self.queryset = queryset 58 | self.model_admin = model_admin 59 | if self.submit not in request.POST: 60 | # Came here from admin changelist 61 | return self.get(request) 62 | return self.post(request) 63 | 64 | def get_form_kwargs(self) -> Dict[str, Any]: 65 | """Return the keyword arguments for instantiating the form.""" 66 | kwargs: Dict[str, Any] = { 67 | 'initial': self.get_initial(), 68 | 'prefix': self.get_prefix(), 69 | } 70 | 71 | if self.submit in self.request.POST: 72 | kwargs.update({ 73 | 'data': self.request.POST, 74 | 'files': self.request.FILES, 75 | }) 76 | return kwargs 77 | 78 | def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: 79 | cd = super().get_context_data(**kwargs) 80 | has_view_permission = self.model_admin.has_view_permission(self.request) 81 | cd.update({ 82 | 'has_view_permission': has_view_permission, 83 | 'opts': self.model_admin.opts, 84 | 'object_list': self.queryset, 85 | 'title': self.title, 86 | 'button': self.short_description, 87 | 'summary': self.summary, 88 | 'action': self.request.POST['action'], 89 | 'adminform': self.get_admin_form(cd['form']), 90 | 'media': self.model_admin.media + cd['form'].media 91 | }) 92 | return cd 93 | 94 | def form_valid(self, form: forms.BaseForm # type: ignore 95 | ) -> Optional[HttpResponse]: 96 | self.queryset.update(**form.cleaned_data) 97 | return None 98 | 99 | 100 | class UpdateDialog(WizardBase, UpdateView): 101 | template_name = "admin/admin_update_wizard.html" 102 | 103 | # needed for as_view() call 104 | model_admin: admin.ModelAdmin = None # type: ignore 105 | 106 | def __init__(self, *, 107 | model_admin: admin.ModelAdmin, 108 | title: Optional[str] = None, 109 | short_description: Optional[str] = None, 110 | **kwargs: Any): 111 | super().__init__(**kwargs) 112 | self.model_admin = model_admin 113 | self.title = title or self.__class__.__name__ 114 | self.short_description = short_description or self.title 115 | 116 | def get_redirect_form(self) -> RedirectForm: 117 | return RedirectForm( 118 | initial={'_redirect': self.request.META.get('HTTP_REFERER')}, 119 | data=self.request.POST or None) 120 | 121 | def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: 122 | cd = super().get_context_data(**kwargs) 123 | has_view_permission = self.model_admin.has_view_permission(self.request) 124 | cd.update({ 125 | 'has_view_permission': has_view_permission, 126 | 'opts': self.model_admin.opts, 127 | 'original': self.get_object(self.get_queryset()), 128 | 'title': self.title, 129 | 'button': self.short_description, 130 | 'redirect_form': self.get_redirect_form(), 131 | 'adminform': self.get_admin_form(cd['form']), 132 | 'media': self.model_admin.media + cd['form'].media 133 | }) 134 | return cd 135 | 136 | def post(self, request: HttpRequest, *args: Any, **kwargs: Any 137 | ) -> HttpResponse: 138 | response = super().post(request, *args, **kwargs) 139 | if response is not None: 140 | return response 141 | form = self.get_redirect_form() 142 | if form.is_valid(): 143 | url = form.cleaned_data['_redirect'] 144 | else: 145 | url = None 146 | return redirect(url or admin_url(self.get_object(self.get_queryset()))) 147 | 148 | # noinspection PyMethodMayBeStatic 149 | def form_valid(self, form: forms.BaseForm) -> None: # type: ignore 150 | form = cast(forms.ModelForm, form) 151 | form.save() 152 | -------------------------------------------------------------------------------- /src/admin_wizard/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class RedirectForm(forms.Form): 5 | _redirect = forms.CharField(widget=forms.HiddenInput, required=False) 6 | -------------------------------------------------------------------------------- /src/admin_wizard/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-04-15 08:36+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 20 | "%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n" 21 | "%100>=11 && n%100<=14)? 2 : 3);\n" 22 | 23 | #: admin_wizard/admin.py:42 24 | msgid "Summary" 25 | msgstr "Общая информация" 26 | 27 | #: admin_wizard/templates/admin/admin_action_wizard.html:13 28 | #: admin_wizard/templates/admin/admin_update_wizard.html:12 29 | msgid "Home" 30 | msgstr "Начало" 31 | -------------------------------------------------------------------------------- /src/admin_wizard/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/just-work/django-admin-wizard/8675d6cb1fa344bec6cb39bd3817466c30c50353/src/admin_wizard/models.py -------------------------------------------------------------------------------- /src/admin_wizard/templates/admin/admin_action_wizard.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load static i18n admin_urls %} 3 | {% block extrahead %}{{ block.super }} 4 | 5 | {{ media }} 6 | {% endblock %} 7 | {% block extrastyle %}{{ block.super }} 8 | 10 | {% endblock %} 11 | {% block breadcrumbs %} 12 | 21 | {% endblock %} 22 | {% block content %} 23 | {% block summary_title %}{% if summary %}

{{ summary }}

24 | {% endif %}{% endblock %} 25 | {% block summary %} 26 |
    27 | {% for obj in object_list %} 28 |
  • {{ obj }}
  • 29 | {% endfor %} 30 |
31 | {% endblock %} 32 | {% block form %} 33 |
34 | {% csrf_token %} 35 | {% for obj in object_list %} 36 | 38 | {% endfor %} 39 | {% block field_sets %} 40 | {% for fieldset in adminform %} 41 | {% include "admin/includes/fieldset.html" %} 42 | {% endfor %} 43 | {% if adminform.form.errors %} 44 | {{ adminform.form.non_field_errors }} 45 | {% endif %} 46 | {% endblock %} 47 | 48 | 49 | 50 |
51 | {% endblock %} 52 | {% endblock %} -------------------------------------------------------------------------------- /src/admin_wizard/templates/admin/admin_update_wizard.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load static i18n admin_urls %} 3 | {% block extrahead %}{{ block.super }} 4 | 5 | {{ media }} 6 | {% endblock %} 7 | {% block extrastyle %}{{ block.super }} 8 | 9 | {% endblock %} 10 | {% block breadcrumbs %} 11 | 18 | {% endblock %} 19 | {% block content %} 20 | {% block summary_title %}{% if summary %}

{{ summary }}

21 | {% endif %}{% endblock %} 22 | {% block form %} 23 |
24 | {% csrf_token %} 25 | {% block field_sets %} 26 | {% for fieldset in adminform %} 27 | {% include "admin/includes/fieldset.html" %} 28 | {% endfor %} 29 | {% if adminform.form.errors %} 30 | {{ adminform.form.non_field_errors }} 31 | {% endif %} 32 | {% endblock %} 33 | {{ redirect_form }} 34 | 35 | 36 |
37 | {% endblock %} 38 | {% endblock %} -------------------------------------------------------------------------------- /src/admin_wizard/tests.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from admin_smoke.tests import AdminBaseTestCase 4 | from django.db import models 5 | from django.http import HttpResponse 6 | from django.urls import reverse 7 | 8 | 9 | class AdminWizardBaseTestCase(AdminBaseTestCase): 10 | 11 | def transform_to_new(self, data: dict) -> dict: 12 | raise NotImplementedError() 13 | 14 | def post_admin_action(self, action_name: str, *objects: models.Model, 15 | submit_field: str = 'apply', submit: bool = True, 16 | **form_data: Any) -> HttpResponse: 17 | """ 18 | Performs requests on django admin action. 19 | * if submit is True, performs final request from intermediate page; 20 | * else performs initial request from changelist. 21 | :param submit: final/initial request flag 22 | :param submit_field: name of submit input field in intermediate form 23 | :param action_name: action name as set in ModelAdmin.actions 24 | :param objects: selected objects list 25 | :param form_data: form data passed from intermediate page 26 | :returns: response with results of processing form data 27 | """ 28 | data: Dict[str, Any] = { 29 | '_selected_action': [obj.pk for obj in objects], 30 | 'action': action_name, 31 | **form_data 32 | } 33 | if submit: 34 | data[submit_field] = submit 35 | return self.client.post(self.changelist_url, data=data) 36 | 37 | def post_admin_url(self, url_name: str, obj: models.Model, 38 | submit: bool = True, **form_data: Any) -> HttpResponse: 39 | """ Performs requests on django admin custom view. 40 | 41 | * if submit, performs post request with form data; 42 | * else performs get request with referer set to change_url 43 | 44 | :param url_name: url name as defined in ModelAdmin.get_urls() 45 | :param obj: object for change_view url 46 | :param submit: final/initial request flag 47 | :param form_data: form data passed from intermediate page 48 | :returns: response with results of processing form data 49 | """ 50 | if not url_name.startswith('admin:'): 51 | url_name = f'admin:{url_name}' 52 | url = reverse(url_name, kwargs=dict(pk=obj.pk)) 53 | if submit: 54 | # redirect is saved in intermediate page as a hidden input for 55 | # RedirectForm 56 | form_data.update(_redirect=self.change_url) 57 | return self.client.post(url, HTTP_REFERER=url, data=form_data) 58 | else: 59 | return self.client.get(url, HTTP_REFERER=self.change_url) 60 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /tests/testproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/just-work/django-admin-wizard/8675d6cb1fa344bec6cb39bd3817466c30c50353/tests/testproject/__init__.py -------------------------------------------------------------------------------- /tests/testproject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.11. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | from typing import List, Dict 17 | 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = '#p%nkhep3#3n@(l^)y3_37d32mw*xe1rzzpu$l(ua)ylp6q#-^' 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS: List[str] = [] 31 | 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | 'django.contrib.admin', 37 | 'django.contrib.auth', 38 | 'django.contrib.contenttypes', 39 | 'django.contrib.sessions', 40 | 'django.contrib.messages', 41 | 'django.contrib.staticfiles', 42 | 43 | 'admin_wizard', 44 | 45 | 'testproject.testapp' 46 | ] 47 | 48 | MIDDLEWARE = [ 49 | 'django.middleware.security.SecurityMiddleware', 50 | 'django.contrib.sessions.middleware.SessionMiddleware', 51 | 'django.middleware.common.CommonMiddleware', 52 | 'django.middleware.csrf.CsrfViewMiddleware', 53 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 54 | 'django.contrib.messages.middleware.MessageMiddleware', 55 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 56 | ] 57 | 58 | ROOT_URLCONF = 'testproject.urls' 59 | 60 | TEMPLATES = [ 61 | { 62 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 63 | 'DIRS': [], 64 | 'APP_DIRS': True, 65 | 'OPTIONS': { 66 | 'context_processors': [ 67 | 'django.template.context_processors.debug', 68 | 'django.template.context_processors.request', 69 | 'django.contrib.auth.context_processors.auth', 70 | 'django.contrib.messages.context_processors.messages', 71 | ], 72 | }, 73 | }, 74 | ] 75 | 76 | WSGI_APPLICATION = 'testproject.wsgi.application' 77 | 78 | 79 | # Database 80 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 81 | 82 | DATABASES = { 83 | 'default': { 84 | 'ENGINE': 'django.db.backends.sqlite3', 85 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 86 | } 87 | } 88 | 89 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 90 | 91 | 92 | # Password validation 93 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 94 | 95 | AUTH_PASSWORD_VALIDATORS: List[Dict[str, str]] = [] 96 | 97 | 98 | # Internationalization 99 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 100 | 101 | LANGUAGE_CODE = 'en-us' 102 | 103 | TIME_ZONE = 'UTC' 104 | 105 | USE_I18N = True 106 | 107 | USE_L10N = True 108 | 109 | USE_TZ = True 110 | 111 | 112 | # Static files (CSS, JavaScript, Images) 113 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 114 | 115 | STATIC_URL = '/static/' 116 | -------------------------------------------------------------------------------- /tests/testproject/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/just-work/django-admin-wizard/8675d6cb1fa344bec6cb39bd3817466c30c50353/tests/testproject/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testproject/testapp/admin.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Callable, cast 2 | 3 | from django.contrib import admin 4 | from django.db.models import QuerySet 5 | from django.http import HttpRequest 6 | from django.urls import path 7 | 8 | from admin_wizard.admin import UpdateAction, UpdateDialog 9 | from testproject.testapp import models, forms 10 | 11 | Action = Callable[[admin.ModelAdmin, HttpRequest, QuerySet], None] 12 | 13 | 14 | @admin.register(models.MyModel) 15 | class MyModelAdmin(admin.ModelAdmin): 16 | actions = [cast(Action, UpdateAction(form_class=forms.RenameForm))] 17 | 18 | def get_urls(self): 19 | urls = [ 20 | path('/rename/', 21 | UpdateDialog.as_view(model_admin=self, 22 | model=models.MyModel, 23 | form_class=forms.RenameForm), 24 | name='rename') 25 | ] 26 | return urls + super().get_urls() 27 | -------------------------------------------------------------------------------- /tests/testproject/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestappConfig(AppConfig): 5 | name = 'testproject.testapp' 6 | -------------------------------------------------------------------------------- /tests/testproject/testapp/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError, NON_FIELD_ERRORS 3 | 4 | from testproject.testapp import models 5 | 6 | 7 | class RenameForm(forms.ModelForm): 8 | class Meta: 9 | model = models.MyModel 10 | fields = ('name',) 11 | 12 | def clean(self): 13 | cd = self.cleaned_data 14 | if 'xxx' in cd['name']: 15 | raise ValidationError({NON_FIELD_ERRORS: ["xxx is forbidden"]}) 16 | return cd -------------------------------------------------------------------------------- /tests/testproject/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-15 07:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='MyModel', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(max_length=20)), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /tests/testproject/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/just-work/django-admin-wizard/8675d6cb1fa344bec6cb39bd3817466c30c50353/tests/testproject/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/testproject/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class MyModel(models.Model): 5 | name = models.CharField(max_length=20) 6 | -------------------------------------------------------------------------------- /tests/testproject/testapp/tests.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from admin_smoke.tests import AdminTests 4 | from django.template.response import TemplateResponse 5 | 6 | from admin_wizard.tests import AdminWizardBaseTestCase 7 | from testproject.testapp import models, admin, forms 8 | 9 | 10 | class MyModelAdminTestCase(AdminTests, AdminWizardBaseTestCase): 11 | model = models.MyModel 12 | model_admin = admin.MyModelAdmin 13 | object_name = 'obj' 14 | 15 | @classmethod 16 | def setUpTestData(cls) -> None: 17 | super().setUpTestData() 18 | cls.obj = models.MyModel.objects.create(name='name') 19 | cls.another = models.MyModel.objects.create(name='not_changed') 20 | 21 | def transform_to_new(self, data: dict) -> dict: 22 | return data 23 | 24 | def test_rename_action(self): 25 | """ Checks action wizard form_valid handling.""" 26 | name = admin.UpdateAction.__name__ 27 | 28 | r = cast(TemplateResponse, self.post_admin_action( 29 | name, self.obj, submit=False)) 30 | 31 | self.assertEqual(r.status_code, 200) 32 | form = r.context_data['form'] 33 | self.assertIsInstance(form, forms.RenameForm) 34 | 35 | r = self.post_admin_action(name, self.obj, name='new_name') 36 | 37 | self.assertRedirects(r, self.changelist_url) 38 | 39 | self.assert_object_fields( 40 | self.obj, name='new_name') 41 | self.assert_object_fields( 42 | self.another, name='not_changed') 43 | 44 | def test_non_field_errors_in_action(self): 45 | """ Check non field errors rendering. """ 46 | name = admin.UpdateAction.__name__ 47 | 48 | r = self.post_admin_action(name, self.obj, name='xxx') 49 | 50 | self.assertIn("xxx is forbidden", r.content.decode('utf-8')) 51 | 52 | def test_rename_dialog(self): 53 | """ Checks action dialog form_valid handling.""" 54 | r = cast(TemplateResponse, self.post_admin_url( 55 | 'rename', self.obj, submit=False)) 56 | 57 | self.assertEqual(r.status_code, 200) 58 | form = r.context_data['form'] 59 | self.assertIsInstance(form, forms.RenameForm) 60 | 61 | r = self.post_admin_url('rename', self.obj, name='new_name') 62 | 63 | self.assertRedirects(r, self.change_url) 64 | self.assert_object_fields( 65 | self.obj, name='new_name') 66 | self.assert_object_fields( 67 | self.another, name='not_changed') 68 | 69 | def test_non_field_errors_in_dialog(self): 70 | """ Check non field errors rendering. """ 71 | r = self.post_admin_url('rename', self.obj, name='xxx') 72 | 73 | self.assertIn("xxx is forbidden", r.content.decode('utf-8')) 74 | -------------------------------------------------------------------------------- /tests/testproject/testapp/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /tests/testproject/urls.py: -------------------------------------------------------------------------------- 1 | """testproject URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /tests/testproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py3.7,py3.8,py3.9,py3.10}-django3.2 4 | {py3.8,py3.9,py3.10}-django4.0 5 | {py3.8,py3.9,py3.10,py3.11}-django4.1 6 | {py3.8,py3.9,py3.10,py3.11,py3.12}-django4.2 7 | {py3.10,py3.11,py3.12}-django5.0 8 | 9 | [gh-actions] 10 | python = 11 | 3.7: py3.7 12 | 3.8: py3.8 13 | 3.9: py3.9 14 | 3.10: py3.10 15 | 3.11: py3.11 16 | 3.12: py3.12 17 | 18 | [testenv] 19 | basepython = 20 | py3.7: python3.7 21 | py3.8: python3.8 22 | py3.9: python3.9 23 | py3.10: python3.10 24 | py3.11: python3.11 25 | py3.12: python3.12 26 | deps = 27 | -r requirements.txt 28 | django3.2: Django~=3.2.0 29 | django4.0: Django~=4.0.0 30 | django4.1: Django~=4.1.0 31 | django4.2: Django~=4.2.0 32 | django5.0: Django~=5.0.0 33 | change_dir = tests 34 | commands = python manage.py test 35 | --------------------------------------------------------------------------------