├── django_auto_actions ├── tests │ ├── __init__.py │ └── test_main.py ├── __init__.py └── main.py ├── MANIFEST.in ├── requirements.txt ├── images ├── example_actions.png └── example_success_message.png ├── .coveragerc ├── docker-compose.yml ├── CHANGELOG.md ├── pyproject.toml ├── LICENSE.txt ├── setup.py ├── .github └── workflows │ └── test.yml ├── runtests.py ├── README.md └── .gitignore /django_auto_actions/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_auto_actions/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import AutoActionsMixin, AutoActionsModelAdmin 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft django_auto_actions 2 | include pyproject.toml 3 | include setup.py 4 | global-exclude *.py[cod] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=4.2 2 | psycopg[binary] 3 | coverage 4 | setuptools 5 | build 6 | twine 7 | ruff 8 | -e . -------------------------------------------------------------------------------- /images/example_actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flexonze/django-auto-actions/HEAD/images/example_actions.png -------------------------------------------------------------------------------- /images/example_success_message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flexonze/django-auto-actions/HEAD/images/example_success_message.png -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | */tests/* 4 | */migrations/* 5 | */__init__.py 6 | setup.py 7 | runtests.py 8 | 9 | [report] 10 | show_missing = True -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:16 4 | environment: 5 | POSTGRES_DB: django_auto_actions 6 | POSTGRES_USER: django 7 | POSTGRES_PASSWORD: django 8 | ports: 9 | - "5432:5432" -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.0.0] - 2024-09-22 8 | - Added tests 9 | - Added instructions for running tests 10 | - Added coverage 11 | - Added ruff for code formatting 12 | - Updated README.md badges 13 | - Updated package description 14 | - Added a CHANGELOG.md file 15 | 16 | 17 | ## [0.1.7] - 2024-09-13 18 | - Completed main features 19 | 20 | 21 | ## [0.1.6] -> [0.1.0] - ... 22 | - Work in progress -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.ruff] 6 | exclude = [ 7 | ".bzr", 8 | ".direnv", 9 | ".eggs", 10 | ".git", 11 | ".git-rewrite", 12 | ".hg", 13 | ".ipynb_checkpoints", 14 | ".mypy_cache", 15 | ".nox", 16 | ".pants.d", 17 | ".pyenv", 18 | ".pytest_cache", 19 | ".pytype", 20 | ".ruff_cache", 21 | ".svn", 22 | ".tox", 23 | ".venv", 24 | ".vscode", 25 | "__pypackages__", 26 | "_build", 27 | "buck-out", 28 | "build", 29 | "dist", 30 | "node_modules", 31 | "site-packages", 32 | "venv", 33 | ] 34 | line-length = 88 35 | indent-width = 4 36 | target-version = "py312" 37 | 38 | [tool.ruff.format] 39 | quote-style = "double" 40 | indent-style = "space" 41 | skip-magic-trailing-comma = false 42 | line-ending = "auto" 43 | 44 | [tool.ruff.lint.per-file-ignores] 45 | "__init__.py" = ["F401"] -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Félix Gravel 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. -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from setuptools import find_packages, setup 4 | 5 | VERSION = "1.0.0" 6 | DESCRIPTION = ( 7 | "Automatically generates Django admin actions based on your model's fields" 8 | ) 9 | this_directory = Path(__file__).parent 10 | LONG_DESCRIPTION = (this_directory / "README.md").read_text() 11 | 12 | setup( 13 | name="django-auto-actions", 14 | description=DESCRIPTION, 15 | long_description=LONG_DESCRIPTION, 16 | long_description_content_type="text/markdown", 17 | version=VERSION, 18 | author="Félix Gravel", 19 | author_email="felix.gravel@tlmgo.com", 20 | url="https://github.com/Flexonze/django-auto-actions", 21 | packages=find_packages(), 22 | include_package_data=True, 23 | python_requires=">=3.9", 24 | install_requires=[ 25 | "Django>=4.2", 26 | ], 27 | license="MIT", 28 | classifiers=[ 29 | "License :: OSI Approved :: MIT License", 30 | "Intended Audience :: Developers", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python", 33 | "Programming Language :: Python :: 3.9", 34 | "Programming Language :: Python :: 3.10", 35 | "Programming Language :: Python :: 3.11", 36 | "Programming Language :: Python :: 3.12", 37 | "Topic :: Software Development :: Libraries :: Python Modules", 38 | "Framework :: Django", 39 | "Framework :: Django :: 4.2", 40 | "Framework :: Django :: 5.1", 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Django Tests & Linting 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | 10 | services: 11 | postgres: 12 | image: postgres:16 13 | env: 14 | POSTGRES_DB: django_auto_actions 15 | POSTGRES_USER: django 16 | POSTGRES_PASSWORD: django 17 | ports: 18 | - 5432:5432 19 | options: >- 20 | --health-cmd="pg_isready -U $POSTGRES_USER" 21 | --health-interval=10s 22 | --health-timeout=5s 23 | --health-retries=5 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Python 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: '3.11' 33 | 34 | # Install PostgreSQL client 35 | - name: Install PostgreSQL client tools 36 | run: | 37 | sudo apt-get update 38 | sudo apt-get install -y postgresql-client 39 | 40 | - name: Install dependencies 41 | run: | 42 | python -m pip install --upgrade pip 43 | pip install -r requirements.txt 44 | 45 | - name: Wait for Postgres to be ready 46 | run: | 47 | until pg_isready --host=localhost --port=5432 --username=django; do 48 | echo "Waiting for postgres..." 49 | sleep 1 50 | done 51 | 52 | - name: Run tests with coverage 53 | run: | 54 | coverage run --source='.' runtests.py 55 | coverage xml 56 | 57 | - name: Upload coverage to Codecov 58 | uses: codecov/codecov-action@v4 59 | with: 60 | file: ./coverage.xml 61 | flags: unittests 62 | name: codecov-coverage 63 | env: 64 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 65 | 66 | - name: Upload coverage report 67 | uses: actions/upload-artifact@v4 68 | with: 69 | name: coverage-report 70 | path: coverage.xml 71 | 72 | lint: 73 | runs-on: ubuntu-latest 74 | 75 | steps: 76 | - name: Checkout code 77 | uses: actions/checkout@v4 78 | 79 | - name: Set up Python 80 | uses: actions/setup-python@v5 81 | with: 82 | python-version: '3.11' 83 | 84 | - name: Install dependencies 85 | run: | 86 | python -m pip install --upgrade pip 87 | pip install ruff 88 | 89 | - name: Run Ruff 90 | run: | 91 | ruff check . -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | 5 | import coverage 6 | import django 7 | from django.conf import settings 8 | from django.core.management import call_command 9 | 10 | 11 | def runtests(): 12 | if not settings.configured: 13 | DATABASES = { 14 | "default": { 15 | "ENGINE": "django.db.backends.postgresql", 16 | "NAME": "django_auto_actions", 17 | "USER": "django", 18 | "PASSWORD": "django", 19 | "HOST": "localhost", 20 | "PORT": "5432", 21 | } 22 | } 23 | 24 | settings.configure( 25 | DATABASES=DATABASES, 26 | INSTALLED_APPS=( 27 | "django.contrib.contenttypes", 28 | "django.contrib.auth", 29 | "django.contrib.sites", 30 | "django.contrib.sessions", 31 | "django.contrib.messages", 32 | "django.contrib.admin.apps.SimpleAdminConfig", 33 | "django.contrib.staticfiles", 34 | "django_auto_actions", 35 | ), 36 | ROOT_URLCONF="", 37 | MIDDLEWARE=[ 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.middleware.common.CommonMiddleware", 40 | "django.middleware.csrf.CsrfViewMiddleware", 41 | "django.contrib.auth.middleware.AuthenticationMiddleware", 42 | "django.contrib.messages.middleware.MessageMiddleware", 43 | ], 44 | TEMPLATES=[ 45 | { 46 | "BACKEND": "django.template.backends.django.DjangoTemplates", 47 | "DIRS": [], 48 | "APP_DIRS": True, 49 | "OPTIONS": { 50 | "context_processors": [ 51 | "django.template.context_processors.debug", 52 | "django.template.context_processors.request", 53 | "django.contrib.auth.context_processors.auth", 54 | "django.contrib.messages.context_processors.messages", 55 | ], 56 | }, 57 | }, 58 | ], 59 | DEFAULT_AUTO_FIELD="django.db.models.BigAutoField", 60 | MESSAGE_STORAGE="django.contrib.messages.storage.cookie.CookieStorage", 61 | SECRET_KEY="super_secret", 62 | ) 63 | 64 | cov = coverage.Coverage() 65 | cov.start() 66 | 67 | django.setup() 68 | 69 | failures = call_command( 70 | "test", "django_auto_actions", interactive=False, failfast=False, verbosity=2 71 | ) 72 | 73 | cov.stop() 74 | cov.save() 75 | cov.report() 76 | 77 | # Safely delete the generated migrations folder 78 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 79 | folder = os.path.join( 80 | BASE_DIR, "django_auto_actions", "django_auto_actions", "migrations" 81 | ) 82 | try: 83 | shutil.rmtree(folder) 84 | except FileNotFoundError: 85 | print(f"Folder {folder} does not exist. Skipping deletion.") 86 | 87 | sys.exit(bool(failures)) 88 | 89 | 90 | if __name__ == "__main__": 91 | runtests() 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Auto Actions 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/django-auto-actions?style=flat-square)](https://pypi.python.org/pypi/django-auto-actions/) 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-auto-actions?style=flat-square)](https://pypi.python.org/pypi/django-auto-actions/) 5 | [![PyPI - License](https://img.shields.io/pypi/l/django-auto-actions?style=flat-square)](https://pypi.python.org/pypi/django-auto-actions/) 6 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 7 | [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://img.shields.io/badge/coverage-100%25-brightgreen) 8 | 9 | Automatically generates basic Django admin actions based on your models' fields 10 | 11 | ## Installation 12 | 13 | Install the package using [pip](https://pip.pypa.io/en/stable/) 14 | 15 | ```bash 16 | pip install django-auto-actions 17 | ``` 18 | 19 | ## Usage 20 | 21 | There are two ways to integrate `django-auto-actions` into your project: 22 | 23 | 1. Using **AutoActionsMixin** *(recommended way)* 24 | 25 | ```python 26 | from django.contrib.admin import ModelAdmin 27 | from django_auto_actions import AutoActionsMixin 28 | 29 | 30 | @admin.register(YourModel) 31 | class YourModelAdmin(AutoActionsMixin, ModelAdmin): 32 | ... 33 | ``` 34 | 35 | 2. Using **AutoActionsModelAdmin** instead of ModelAdmin 36 | 37 | ```python 38 | from django_auto_actions import AutoActionsModelAdmin 39 | 40 | 41 | @admin.register(YourModel) 42 | class YourModelAdmin(AutoActionsModelAdmin): 43 | ... 44 | ``` 45 | 46 | With either method, `django-auto-actions` will automatically generate [admin actions](https://docs.djangoproject.com/en/dev/ref/contrib/admin/actions/#admin-actions) for your model's [BooleanFields](https://docs.djangoproject.com/fr/4.2/ref/models/fields/#booleanfield), [DateTimeFields](https://docs.djangoproject.com/fr/4.2/ref/models/fields/#datetimefield), [DateFields](https://docs.djangoproject.com/fr/4.2/ref/models/fields/#datefield) and [TimeFields](https://docs.djangoproject.com/fr/4.2/ref/models/fields/#timefield). 47 | 48 | To exclude certain fields from having automatic admin actions generated, set the `exclude_auto_actions` class-level attribute. 49 | 50 | ```python 51 | @admin.register(YourModel) 52 | class YourModelAdmin(AutoActionsMixin, ModelAdmin): 53 | exclude_auto_actions = ["is_example", "created_at"] 54 | ``` 55 | 56 | ## Example 57 | 58 | Here's an example of what it might look like for a simple `Homework` model: 59 | ![Example auto actions](https://github.com/Flexonze/django-auto-actions/raw/main/images/example_actions.png) 60 | And will display a success message like this: 61 | ![Example success message](https://github.com/Flexonze/django-auto-actions/raw/main/images/example_success_message.png) 62 | 63 | ## Running Tests 64 | 65 | ```bash 66 | pip install -r requirements.txt 67 | docker compose up -d 68 | python runtests.py 69 | ``` 70 | 71 | ## Running ruff (linting & formatting) 72 | 73 | ```bash 74 | pip install -r requirements.txt 75 | ruff check 76 | ``` 77 | 78 | You can also run `ruff fix` to automatically fix some of the issues. 79 | 80 | ```bash 81 | ruff check --fix 82 | ``` 83 | 84 | ## Support & Contributing 85 | 86 | If you like it, please consider giving this project a star. If you’re using the package, let me know! You can also [create an issue](https://github.com/Flexonze/django-auto-actions/issues/new) for any problems or suggestions. PRs are always welcome! 87 | 88 | ## Authors 89 | 90 | - **Félix Gravel** — [@Flexonze](https://www.github.com/flexonze) 91 | 92 | ## License 93 | 94 | [MIT](LICENSE.txt) © [Félix Gravel](https://github.com/Flexonze) 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 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 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | .idea/ 162 | 163 | -------------------------------------------------------------------------------- /django_auto_actions/main.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin, messages 2 | from django.db.models import BooleanField, DateField, DateTimeField, TimeField 3 | from django.utils import timezone 4 | from django.utils.translation import gettext_lazy as _ 5 | from django.utils.translation import ngettext 6 | 7 | 8 | class AutoActionsMixin: 9 | """ 10 | Mixin for adding auto actions to your ModelAdmins subclasses. 11 | 12 | Usage: 13 | 1. Add the mixin to your ModelAdmin subclass. 14 | 2. Define a list of fields to exclude from auto actions in the `exclude_auto_actions` attribute. 15 | 16 | Example: 17 | ```python 18 | from django.db import models 19 | from django.contrib import admin 20 | from django_auto_actions import AutoActionsMixin 21 | 22 | class MyModel(models.Model): 23 | name = models.CharField(max_length=255) 24 | is_active = models.BooleanField(null=True, blank=True) 25 | submitted_at = models.DateTimeField(null=True, blank=True) 26 | created_at = models.DateTimeField(auto_now_add=True) 27 | 28 | @admin.register(MyModel) 29 | class MyModelAdmin(AutoActionsMixin, admin.ModelAdmin): 30 | exclude_auto_actions = ["created_at"] 31 | ``` 32 | 33 | This will add the following actions: 34 | - Set is_active to False 35 | - Set is_active to None 36 | - Set is_active to True 37 | - Set submitted_at to None 38 | - Set submitted_at to now 39 | """ 40 | 41 | def get_actions(self, request): 42 | actions = super().get_actions(request) 43 | auto_actions = self._get_auto_actions() 44 | auto_actions = dict(sorted(auto_actions.items())) 45 | actions.update(auto_actions) 46 | return actions 47 | 48 | def _get_auto_actions(self): 49 | auto_actions = {} 50 | exclude_fields = getattr(self, "exclude_auto_actions", []) 51 | now = timezone.now() 52 | 53 | def create_action(field_name, value, display_value): 54 | def action(modeladmin, request, queryset): 55 | updated_count = queryset.update(**{field_name: value}) 56 | model_name = modeladmin.model._meta.verbose_name 57 | model_name_plural = modeladmin.model._meta.verbose_name_plural 58 | messages.success( 59 | request, 60 | ngettext( 61 | f"Successfully updated {updated_count} {model_name} to {field_name} = {display_value}", 62 | f"Successfully updated {updated_count} {model_name_plural} to {field_name} = {display_value}", 63 | updated_count, 64 | ), 65 | ) 66 | 67 | return action 68 | 69 | for field in ( 70 | f for f in self.model._meta.fields if f.name not in exclude_fields 71 | ): 72 | field_name = field.name 73 | 74 | if isinstance(field, BooleanField): 75 | possible_states = [True, False] 76 | if field.null: 77 | possible_states.append(None) 78 | 79 | for state in possible_states: 80 | action_name = f"set_{field_name}_{state}" 81 | auto_actions[action_name] = ( 82 | create_action(field_name, state, state), 83 | action_name, 84 | _(f"Set {field_name} to {state}"), 85 | ) 86 | 87 | elif isinstance(field, (DateTimeField, DateField, TimeField)): 88 | possible_states = {} 89 | 90 | if isinstance(field, DateTimeField): 91 | possible_states = {"now": now} 92 | 93 | elif isinstance(field, DateField): 94 | possible_states = {"today": now.date()} 95 | 96 | elif isinstance(field, TimeField): 97 | possible_states = {"now": now.time()} 98 | 99 | if field.null: 100 | possible_states["None"] = None 101 | 102 | for display_value, state in possible_states.items(): 103 | action_name = f"set_{field_name}_{display_value}" 104 | auto_actions[action_name] = ( 105 | create_action(field_name, state, display_value), 106 | action_name, 107 | _(f"Set {field_name} to {display_value}"), 108 | ) 109 | 110 | return auto_actions 111 | 112 | 113 | class AutoActionsModelAdmin(AutoActionsMixin, admin.ModelAdmin): 114 | """ 115 | A ModelAdmin subclass that includes the AutoActionsMixin to add auto actions. 116 | 117 | Usage: 118 | 1. Replace `admin.ModelAdmin` with `AutoActionsModelAdmin` in your ModelAdmin subclass. 119 | 2. Define a list of fields to exclude from auto actions in the `exclude_auto_actions` attribute. 120 | 121 | Example: 122 | ```python 123 | from django.db import models 124 | from django.contrib import admin 125 | from django_auto_actions import AutoActionsModelAdmin 126 | 127 | class MyModel(models.Model): 128 | name = models.CharField(max_length=255) 129 | is_active = models.BooleanField(null=True, blank=True) 130 | submitted_at = models.DateTimeField(null=True, blank=True) 131 | created_at = models.DateTimeField(auto_now_add=True) 132 | 133 | @admin.register(MyModel) 134 | class MyModelAdmin(AutoActionsModelAdmin): 135 | exclude_auto_actions = ["created_at"] 136 | ``` 137 | 138 | This will add the following actions: 139 | - Set is_active to False 140 | - Set is_active to None 141 | - Set is_active to True 142 | - Set submitted_at to None 143 | - Set submitted_at to now 144 | """ 145 | 146 | pass 147 | -------------------------------------------------------------------------------- /django_auto_actions/tests/test_main.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin, messages 2 | from django.contrib.auth.models import AnonymousUser 3 | from django.contrib.messages import get_messages 4 | from django.core.management import call_command 5 | from django.db import models 6 | from django.test import RequestFactory, TestCase 7 | 8 | from django_auto_actions.main import AutoActionsMixin 9 | 10 | 11 | class TestModel(models.Model): 12 | is_approved = models.BooleanField(null=True, blank=True) 13 | due_date = models.DateField(null=True, blank=True) 14 | presentation_time = models.TimeField(null=True, blank=True) 15 | submitted_at = models.DateTimeField(null=True, blank=True) 16 | 17 | class Meta: 18 | app_label = "django_auto_actions" 19 | 20 | 21 | call_command("makemigrations", "django_auto_actions") 22 | call_command("migrate", run_syncdb=True) 23 | 24 | 25 | @admin.register(TestModel) 26 | class TestModelAdmin(AutoActionsMixin, admin.ModelAdmin): 27 | exclude_auto_actions = [] 28 | 29 | 30 | class AutoActionsMixinTest(TestCase): 31 | def setUp(self): 32 | self.model_admin = TestModelAdmin(TestModel, admin.site) 33 | self.model_admin.model = TestModel 34 | 35 | self.request = RequestFactory().get("/admin/") 36 | self.request.user = AnonymousUser() 37 | self.request._messages = messages.storage.default_storage(self.request) 38 | # empty cookie storage 39 | 40 | def test_actions_generated(self): 41 | actions = self.model_admin.get_actions(self.request) 42 | expected_actions = [ 43 | "set_is_approved_True", 44 | "set_is_approved_False", 45 | "set_is_approved_None", 46 | "set_due_date_today", 47 | "set_due_date_None", 48 | "set_presentation_time_now", 49 | "set_presentation_time_None", 50 | "set_submitted_at_now", 51 | "set_submitted_at_None", 52 | ] 53 | 54 | for action in expected_actions: 55 | self.assertIn(action, actions) 56 | 57 | def test_actions_can_be_excluded(self): 58 | class TestModelAdmin(AutoActionsMixin, admin.ModelAdmin): 59 | exclude_auto_actions = ["is_approved"] 60 | 61 | model_admin = TestModelAdmin(TestModel, admin.site) 62 | actions = model_admin.get_actions(self.request) 63 | self.assertEqual(len(actions), 6) # 9 - 3 = 6 64 | 65 | def test_boolean_fields_actions(self): 66 | instance1 = TestModel.objects.create(is_approved=True) 67 | instance2 = TestModel.objects.create(is_approved=True) 68 | 69 | states = ["None", "True", "False"] 70 | 71 | message_index = 0 72 | for state in states: 73 | actions = self.model_admin.get_actions(self.request) 74 | set_is_approved_action = actions[f"set_is_approved_{state}"][0] 75 | 76 | queryset = TestModel.objects.all() 77 | set_is_approved_action(self.model_admin, self.request, queryset) 78 | 79 | instance1.refresh_from_db() 80 | instance2.refresh_from_db() 81 | 82 | if state == "None": 83 | self.assertIsNone(instance1.is_approved) 84 | self.assertIsNone(instance2.is_approved) 85 | else: 86 | self.assertEqual(instance1.is_approved, state == "True") 87 | self.assertEqual(instance2.is_approved, state == "True") 88 | 89 | expected_message = ( 90 | f"Successfully updated 2 test models to is_approved = {state}" 91 | ) 92 | message = list(get_messages(self.request))[message_index] 93 | message_index += 1 94 | 95 | self.assertEqual(message.message, expected_message) 96 | 97 | def test_datetime_fields_actions(self): 98 | instance1 = TestModel.objects.create(submitted_at=None) 99 | instance2 = TestModel.objects.create(submitted_at=None) 100 | 101 | states = ["None", "now"] 102 | 103 | message_index = 0 104 | for state in states: 105 | actions = self.model_admin.get_actions(self.request) 106 | set_submitted_at_action = actions[f"set_submitted_at_{state}"][0] 107 | 108 | queryset = TestModel.objects.all() 109 | set_submitted_at_action(self.model_admin, self.request, queryset) 110 | 111 | instance1.refresh_from_db() 112 | instance2.refresh_from_db() 113 | 114 | if state == "None": 115 | self.assertIsNone(instance1.submitted_at) 116 | self.assertIsNone(instance2.submitted_at) 117 | else: 118 | self.assertIsNotNone(instance1.submitted_at) 119 | self.assertIsNotNone(instance2.submitted_at) 120 | 121 | expected_message = ( 122 | f"Successfully updated 2 test models to submitted_at = {state}" 123 | ) 124 | message = list(get_messages(self.request))[message_index] 125 | message_index += 1 126 | 127 | self.assertEqual(message.message, expected_message) 128 | 129 | def test_date_fields_actions(self): 130 | instance1 = TestModel.objects.create(due_date=None) 131 | instance2 = TestModel.objects.create(due_date=None) 132 | 133 | states = ["None", "today"] 134 | 135 | message_index = 0 136 | for state in states: 137 | actions = self.model_admin.get_actions(self.request) 138 | set_due_date_action = actions[f"set_due_date_{state}"][0] 139 | 140 | queryset = TestModel.objects.all() 141 | set_due_date_action(self.model_admin, self.request, queryset) 142 | 143 | instance1.refresh_from_db() 144 | instance2.refresh_from_db() 145 | 146 | if state == "None": 147 | self.assertIsNone(instance1.due_date) 148 | self.assertIsNone(instance2.due_date) 149 | else: 150 | self.assertIsNotNone(instance1.due_date) 151 | self.assertIsNotNone(instance2.due_date) 152 | 153 | expected_message = ( 154 | f"Successfully updated 2 test models to due_date = {state}" 155 | ) 156 | message = list(get_messages(self.request))[message_index] 157 | message_index += 1 158 | 159 | self.assertEqual(message.message, expected_message) 160 | 161 | def test_time_fields_actions(self): 162 | instance1 = TestModel.objects.create(presentation_time=None) 163 | instance2 = TestModel.objects.create(presentation_time=None) 164 | 165 | states = ["None", "now"] 166 | 167 | message_index = 0 168 | for state in states: 169 | actions = self.model_admin.get_actions(self.request) 170 | set_presentation_time_action = actions[f"set_presentation_time_{state}"][0] 171 | 172 | queryset = TestModel.objects.all() 173 | set_presentation_time_action(self.model_admin, self.request, queryset) 174 | 175 | instance1.refresh_from_db() 176 | instance2.refresh_from_db() 177 | 178 | if state == "None": 179 | self.assertIsNone(instance1.presentation_time) 180 | self.assertIsNone(instance2.presentation_time) 181 | else: 182 | self.assertIsNotNone(instance1.presentation_time) 183 | self.assertIsNotNone(instance2.presentation_time) 184 | 185 | expected_message = ( 186 | f"Successfully updated 2 test models to presentation_time = {state}" 187 | ) 188 | message = list(get_messages(self.request))[message_index] 189 | message_index += 1 190 | 191 | self.assertEqual(message.message, expected_message) 192 | --------------------------------------------------------------------------------