├── .circleci └── config.yml ├── .coveragerc ├── .gitignore ├── .isort.cfg ├── LICENSE ├── MANIFEST.in ├── README.rst ├── django_transitions ├── __init__.py ├── admin.py ├── models.py ├── templates │ └── transitions │ │ ├── change_form.html │ │ └── read_only_change_form.html └── workflow.py ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── changes.rst │ ├── conf.py │ ├── faq.rst │ ├── index.rst │ ├── lifcycle_state_diagram.svg │ ├── mixins.rst │ ├── overview.rst │ ├── quickstart.rst │ └── templates.rst ├── manage.py ├── pytest.ini ├── requirements ├── base.txt ├── django-1.11.txt ├── django-2.0.txt ├── django-2.1.txt ├── flake8.txt └── tests.txt ├── setup.py ├── testapp ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── tests │ ├── __init__.py │ ├── test_admin.py │ └── test_workflow.py ├── views.py └── workflows.py └── testproj ├── __init__.py ├── settings.py ├── settings_pytest.py ├── urls.py └── wsgi.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | apt-run: &apt-install 5 | name: Install apt packages 6 | command: | 7 | sudo apt update 8 | sudo apt install -y graphviz build-essential 9 | 10 | jobs: 11 | test-static: 12 | docker: 13 | - image: circleci/python:3.6 14 | user: circleci 15 | steps: 16 | - checkout 17 | - run: 18 | name: Flake8 and complexity 19 | command: | 20 | python3 -m venv venv 21 | source venv/bin/activate 22 | pip install -r requirements/flake8.txt 23 | flake8 django_transitions 24 | bandit django_transitions 25 | yamllint .circleci/config.yml 26 | radon cc --min B django_transitions 27 | radon mi --min B django_transitions 28 | lizard -l python -w django_transitions 29 | 30 | test-django-latest-py-3.6: 31 | docker: 32 | - image: circleci/python:3.6 33 | user: circleci 34 | steps: 35 | - checkout 36 | - run: *apt-install 37 | - run: 38 | name: Latest Django with Python 3.6 39 | command: | 40 | python3 -m venv venv 41 | source venv/bin/activate 42 | pip install -r requirements/base.txt 43 | py.test testapp 44 | 45 | test-django-2.0-py-3.6: 46 | docker: 47 | - image: circleci/python:3.6 48 | user: circleci 49 | steps: 50 | - checkout 51 | - run: *apt-install 52 | - run: 53 | name: Django 2.0 with Python 3.6 54 | command: | 55 | python3 -m venv venv 56 | source venv/bin/activate 57 | pip install -r requirements/django-2.0.txt 58 | py.test testapp 59 | 60 | test-django-2.1-py-3.6: 61 | docker: 62 | - image: circleci/python:3.6 63 | user: circleci 64 | steps: 65 | - checkout 66 | - run: *apt-install 67 | - run: 68 | name: Django 2.1 with Python 3.6 69 | command: | 70 | python3 -m venv venv 71 | source venv/bin/activate 72 | pip install -r requirements/django-2.1.txt 73 | py.test testapp --cov=django_transitions 74 | codecov 75 | 76 | test-django-1.11-py-2.7: 77 | docker: 78 | - image: circleci/python:2.7 79 | user: circleci 80 | steps: 81 | - checkout 82 | - run: *apt-install 83 | - run: 84 | name: Django 1.11 with Python 2.7 85 | command: | 86 | virtualenv venv 87 | source venv/bin/activate 88 | pip install mock 89 | pip install -r requirements/django-1.11.txt 90 | py.test testapp 91 | 92 | test-django-1.11-py-3.6: 93 | docker: 94 | - image: circleci/python:3.6 95 | user: circleci 96 | steps: 97 | - checkout 98 | - run: *apt-install 99 | - run: 100 | name: Django 1.11 with Python 3.6 101 | command: | 102 | python3 -m venv venv 103 | source venv/bin/activate 104 | pip install -r requirements/django-1.11.txt 105 | py.test testapp 106 | 107 | workflows: 108 | version: 2 109 | build-and-test: 110 | jobs: 111 | - test-django-latest-py-3.6 112 | - test-django-2.0-py-3.6 113 | - test-django-2.1-py-3.6 114 | - test-django-1.11-py-3.6 115 | - test-django-1.11-py-2.7 116 | - test-static 117 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | include = 5 | django_transitions/* 6 | omit = 7 | */migrations/* 8 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | combine_star=1 3 | force_single_line=1 4 | import_heading_django=Django 5 | import_heading_firstparty=Project 6 | import_heading_future=Future 7 | import_heading_localfolder=Local 8 | import_heading_stdlib=Standard Library 9 | import_heading_thirdparty=3rd-party 10 | known_django=django 11 | known_third_party=transitions 12 | line_length=79 13 | order_by_type=1 14 | sections=FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2018, PrimarySite 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include django_transitions/templates *.html 2 | recursive-exclude testapp * 3 | recursive-exclude testproj * 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-transitions 2 | ==================== 3 | 4 | .. inclusion-marker-do-not-remove 5 | 6 | A wrapper of pytransitions_ for django_ 7 | 8 | .. image:: https://circleci.com/gh/PrimarySite/django-transitions.svg?style=svg 9 | :target: https://circleci.com/gh/PrimarySite/django-transitions 10 | :alt: Test Status 11 | 12 | .. image:: https://codecov.io/gh/PrimarySite/django-transitions/branch/master/graph/badge.svg 13 | :target: https://codecov.io/gh/PrimarySite/django-transitions 14 | :alt: Test Coverage 15 | 16 | .. image:: https://readthedocs.org/projects/django-transitions/badge/?version=latest 17 | :target: https://django-transitions.readthedocs.io/en/latest/?badge=latest 18 | :alt: Documentation Status 19 | 20 | You do not *need* django-transitions to integrate django_ and pytransitions_. 21 | It is meant to be a lightweight wrapper (it has just over 50 logical lines of code) 22 | and documentation how to go about using pytransitions inside a django application. 23 | 24 | This package provides: 25 | 26 | - Example workflow implementation. 27 | - Base classes and mixins to 28 | - Keep it DRY 29 | - Keep transitions consistent 30 | - Reduce cut and paste 31 | - Avoid boiler plate. 32 | - Admin mixin to add workflow actions to the django admin. 33 | - Admin templates 34 | 35 | 36 | .. _django: https://www.djangoproject.com/ 37 | .. _pytransitions: https://pypi.org/project/transitions/ 38 | -------------------------------------------------------------------------------- /django_transitions/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Django transitions.""" 3 | -------------------------------------------------------------------------------- /django_transitions/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Mixins for the django admin.""" 3 | # Django 4 | from django.contrib import messages 5 | from django.http import HttpResponseRedirect 6 | 7 | 8 | class WorkflowAdminMixin(object): 9 | """ 10 | A mixin to provide workflow transition actions. 11 | 12 | It will create an admin log entry. 13 | """ 14 | 15 | change_form_template = 'transitions/change_form.html' 16 | 17 | def response_change(self, request, obj): 18 | """Add actions for the workflow events.""" 19 | events = list(obj.get_available_events()) 20 | for event in events: 21 | if '_' + event['transition'].name not in request.POST: 22 | continue 23 | 24 | before = obj.state 25 | if getattr(obj, event['transition'].name)(): 26 | obj.save() 27 | after = obj.state 28 | message = ('Status changed from {0} to {1} by transition {2}' 29 | .format(before, after, event['transition'].name)) 30 | self.message_user(request, message, messages.SUCCESS) 31 | self.log_change(request, obj, message) 32 | else: 33 | message = ('Status could not be changed from ' 34 | '{0} by transition {1}' 35 | .format(before, event['transition'].name)) 36 | self.message_user(request, message, messages.ERROR) 37 | return HttpResponseRedirect('.') 38 | return super(WorkflowAdminMixin, self).response_change(request, obj) 39 | -------------------------------------------------------------------------------- /django_transitions/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """An opinonated example for a workflow mixin.""" 3 | 4 | # Django 5 | from django.db import models 6 | 7 | 8 | class WorkflowMigrationMixin(models.Model): 9 | """ 10 | A mixin to provide workflow state and workflow date fields. 11 | 12 | This is a minimal example implementation. 13 | """ 14 | 15 | class Meta: # noqa: D106 16 | abstract = True 17 | 18 | wf_state = models.CharField( 19 | verbose_name='Workflow Status', 20 | null=True, 21 | blank=True, 22 | max_length=32, 23 | help_text='Workflow state', 24 | ) 25 | 26 | wf_date = models.DateTimeField( 27 | verbose_name='Workflow Date', 28 | null=True, 29 | blank=True, 30 | help_text='Indicates when this workflowstate was entered.', 31 | ) 32 | -------------------------------------------------------------------------------- /django_transitions/templates/transitions/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/change_form.html' %} 2 | 3 | {% block submit_buttons_bottom %} 4 | 5 | 6 | {{ block.super }} 7 | 8 | 9 |
10 | {% for event in original.get_available_events %} 11 | 12 | {% endfor %} 13 |
14 | 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /django_transitions/templates/transitions/read_only_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/change_form.html' %} 2 | 3 | {% block submit_buttons_bottom %} 4 | 5 |
6 | {% for event in original.get_available_events %} 7 | 8 | {% endfor %} 9 |
10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /django_transitions/workflow.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Mixins for transition workflows.""" 3 | 4 | # Standard Library 5 | from functools import partial 6 | 7 | # 3rd-party 8 | from transitions.extensions import MachineFactory 9 | 10 | 11 | class StatusBase(object): 12 | """Base class for transitions and status definitions.""" 13 | 14 | STATE_CHOICES = ( 15 | # Override this! 16 | # https://docs.djangoproject.com/en/2.1/ref/models/fields/#choices 17 | # to provide human readable labels for states 18 | # (state, 'My Workflow state'), 19 | ) 20 | 21 | TRANSITION_LABELS = { 22 | # Override this! 23 | # Provide human readable labels and css class for transitions 24 | # transition: {'label': 'Label', 'cssclass': 'default'} 25 | } 26 | 27 | SM_STATES = [ 28 | # Override this! 29 | # list of available workflow states 30 | ] 31 | 32 | SM_INITIAL_STATE = None # Initial state of the machine. Override this! 33 | 34 | SM_TRANSITIONS = [ 35 | # Override this! 36 | # trigger, source, destination 37 | ] 38 | 39 | @classmethod 40 | def get_kwargs(cls): 41 | """Get the kwargs to initialize the state machine.""" 42 | kwargs = { 43 | 'initial': cls.SM_INITIAL_STATE, 44 | 'states': cls.SM_STATES, 45 | 'transitions': cls.SM_TRANSITIONS, 46 | } 47 | return kwargs 48 | 49 | 50 | class StateMachineMixinBase(object): 51 | """ 52 | Base class for state machine mixins. 53 | 54 | Class attributes: 55 | 56 | * ``status_class`` must provide ``TRANSITION_LABELS`` property 57 | and the ``get_kwargs`` class method (see ``StatusBase``). 58 | * ``machine`` is a transition machine e.g:: 59 | 60 | machine = Machine( 61 | model=None, 62 | finalize_event='wf_finalize', 63 | auto_transitions=False, 64 | **status_class.get_kwargs() # noqa: C815 65 | ) 66 | 67 | The transition events of the machine will be added as methods to 68 | the mixin. 69 | """ 70 | 71 | status_class = None # Override this! 72 | machine = None # Override this! 73 | 74 | def get_available_events(self): 75 | """ 76 | Get available workflow transition events for the current state. 77 | 78 | Returns a dictionary: 79 | * ``transition``: transition event. 80 | * ``label``: human readable label for the event 81 | * ``cssclass``: css class that will be applied to the button 82 | """ 83 | for trigger in self.machine.get_triggers(self.state): 84 | event = self.machine.events[trigger] 85 | ui_info = self.status_class.TRANSITION_LABELS[event.name] 86 | ui_info['transition'] = event 87 | yield ui_info 88 | 89 | def get_wf_graph(self): 90 | """Get the graph for this machine.""" 91 | diagram_cls = MachineFactory.get_predefined(graph=True) 92 | machine = diagram_cls( 93 | model=self, 94 | auto_transitions=False, 95 | title=type(self).__name__, 96 | **self.status_class.get_kwargs() # noqa: C815 97 | ) 98 | return machine.get_graph() 99 | 100 | def __getattribute__(self, item): 101 | """Propagate events to the workflow state machine.""" 102 | try: 103 | return super(StateMachineMixinBase, self).__getattribute__(item) 104 | except AttributeError: 105 | if item in self.machine.events: 106 | return partial(self.machine.events[item].trigger, self) 107 | raise 108 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | transitions 3 | sphinx 4 | -------------------------------------------------------------------------------- /docs/source/changes.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========== 3 | 4 | 0.2 (2019/01/17) 5 | ----------------- 6 | 7 | - Add optional css class to ``TRANSITION_LABELS`` 8 | 9 | 10 | 0.1 (2018/11/13) 11 | ----------------- 12 | 13 | - Initial release 14 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | root_path = os.path.abspath('../..') 19 | sys.path.insert(0, root_path) 20 | sys.path.insert(0, os.path.join(root_path, 'testproj')) 21 | sys.path.insert(0, os.path.join(root_path, 'testapp')) 22 | sys.path.insert(0, os.path.join(root_path, 'django_transitions')) 23 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') 24 | 25 | 26 | # -- Project information ----------------------------------------------------- 27 | 28 | project = 'django-transitions' 29 | copyright = '2018, Christian Ledermann' 30 | author = 'Christian Ledermann' 31 | 32 | # The short X.Y version 33 | version = '' 34 | # The full version, including alpha/beta/rc tags 35 | release = '0.1' 36 | 37 | 38 | # -- General configuration --------------------------------------------------- 39 | 40 | # If your documentation needs a minimal Sphinx version, state it here. 41 | # 42 | # needs_sphinx = '1.0' 43 | 44 | # Add any Sphinx extension module names here, as strings. They can be 45 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 46 | # ones. 47 | extensions = [ 48 | 'sphinx.ext.autodoc', 49 | 'sphinx.ext.doctest', 50 | 'sphinx.ext.coverage', 51 | 'sphinx.ext.viewcode', 52 | ] 53 | 54 | # Add any paths that contain templates here, relative to this directory. 55 | templates_path = ['_templates'] 56 | 57 | # The suffix(es) of source filenames. 58 | # You can specify multiple suffix as a list of string: 59 | # 60 | # source_suffix = ['.rst', '.md'] 61 | source_suffix = '.rst' 62 | 63 | # The master toctree document. 64 | master_doc = 'index' 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = None 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | # This pattern also affects html_static_path and html_extra_path. 76 | exclude_patterns = [] 77 | 78 | # The name of the Pygments (syntax highlighting) style to use. 79 | pygments_style = None 80 | 81 | 82 | # -- Options for HTML output ------------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = 'classic' 88 | 89 | # Theme options are theme-specific and customize the look and feel of a theme 90 | # further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | # html_theme_options = {} 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | html_static_path = ['_static'] 99 | 100 | # Custom sidebar templates, must be a dictionary that maps document names 101 | # to template names. 102 | # 103 | # The default sidebars (for documents that don't match any pattern) are 104 | # defined by theme itself. Builtin themes are using these templates by 105 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 106 | # 'searchbox.html']``. 107 | # 108 | # html_sidebars = {} 109 | 110 | 111 | # -- Options for HTMLHelp output --------------------------------------------- 112 | 113 | # Output file base name for HTML help builder. 114 | htmlhelp_basename = 'django-transitionsdoc' 115 | 116 | 117 | # -- Options for LaTeX output ------------------------------------------------ 118 | 119 | latex_elements = { 120 | # The paper size ('letterpaper' or 'a4paper'). 121 | # 122 | # 'papersize': 'letterpaper', 123 | 124 | # The font size ('10pt', '11pt' or '12pt'). 125 | # 126 | # 'pointsize': '10pt', 127 | 128 | # Additional stuff for the LaTeX preamble. 129 | # 130 | # 'preamble': '', 131 | 132 | # Latex figure (float) alignment 133 | # 134 | # 'figure_align': 'htbp', 135 | } 136 | 137 | # Grouping the document tree into LaTeX files. List of tuples 138 | # (source start file, target name, title, 139 | # author, documentclass [howto, manual, or own class]). 140 | latex_documents = [ 141 | (master_doc, 'django-transitions.tex', 'django-transitions Documentation', 142 | 'Christian Ledermann', 'manual'), 143 | ] 144 | 145 | 146 | # -- Options for manual page output ------------------------------------------ 147 | 148 | # One entry per manual page. List of tuples 149 | # (source start file, name, description, authors, manual section). 150 | man_pages = [ 151 | (master_doc, 'django-transitions', 'django-transitions Documentation', 152 | [author], 1) 153 | ] 154 | 155 | 156 | # -- Options for Texinfo output ---------------------------------------------- 157 | 158 | # Grouping the document tree into Texinfo files. List of tuples 159 | # (source start file, target name, title, author, 160 | # dir menu entry, description, category) 161 | texinfo_documents = [ 162 | (master_doc, 'django-transitions', 'django-transitions Documentation', 163 | author, 'django-transitions', 'One line description of project.', 164 | 'Miscellaneous'), 165 | ] 166 | 167 | 168 | # -- Options for Epub output ------------------------------------------------- 169 | 170 | # Bibliographic Dublin Core info. 171 | epub_title = project 172 | 173 | # The unique identifier of the text. This can be a ISBN number 174 | # or the project homepage. 175 | # 176 | # epub_identifier = '' 177 | 178 | # A unique identification for the text. 179 | # 180 | # epub_uid = '' 181 | 182 | # A list of files that should not be packed into the epub file. 183 | epub_exclude_files = ['search.html'] 184 | 185 | 186 | # -- Extension configuration ------------------------------------------------- 187 | -------------------------------------------------------------------------------- /docs/source/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently asked questions 2 | =========================== 3 | 4 | What are the advantages of django-transitions over other django workflow applications? 5 | --------------------------------------------------------------------------------------- 6 | 7 | Personally I like to have all the information about my workflow 8 | in one place. 9 | 10 | Are there other packages that provide this functionality? 11 | ---------------------------------------------------------- 12 | 13 | The packages I know of are (in no specific order): 14 | 15 | * `django-fsm `_ 16 | * `viewflow `_ 17 | * `ActivFlow `_ 18 | * `Django-XWorkflows `_ 19 | * `Django River `_ 20 | 21 | You should evaluate if one of the above packages are a better match 22 | for your needs. 23 | 24 | What is the history of django and pytransitions integration? 25 | -------------------------------------------------------------- 26 | 27 | The code from this package was lifted from the discussion in `django and transitions`_ 28 | 29 | .. _django and transitions: https://github.com/pytransitions/transitions/issues/146 30 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. django-transitions documentation master file, created by 2 | sphinx-quickstart on Fri Nov 9 11:46:06 2018. 3 | 4 | Welcome to django-transitions's documentation! 5 | ============================================== 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | :caption: Contents: 10 | 11 | overview 12 | quickstart 13 | mixins 14 | templates 15 | faq 16 | changes 17 | 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | 26 | -------------------------------------------------------------------------------- /docs/source/lifcycle_state_diagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | Lifecycle 13 | 14 | develop 15 | 16 | 17 | develop 18 | 19 | 20 | live 21 | 22 | live 23 | 24 | 25 | develop->live 26 | 27 | 28 | publish 29 | 30 | 31 | deleted 32 | 33 | deleted 34 | 35 | 36 | develop->deleted 37 | 38 | 39 | mark_deleted 40 | 41 | 42 | live->deleted 43 | 44 | 45 | mark_deleted 46 | 47 | 48 | maintenance 49 | 50 | maintenance 51 | 52 | 53 | live->maintenance 54 | 55 | 56 | make_private 57 | 58 | 59 | deleted->maintenance 60 | 61 | 62 | revert_delete 63 | 64 | 65 | maintenance->live 66 | 67 | 68 | publish 69 | 70 | 71 | maintenance->deleted 72 | 73 | 74 | mark_deleted 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /docs/source/mixins.rst: -------------------------------------------------------------------------------- 1 | Mixins and Base Classes 2 | ======================== 3 | 4 | Transition Base Classes 5 | ------------------------ 6 | 7 | .. automodule:: django_transitions.workflow 8 | 9 | StatusBase 10 | ~~~~~~~~~~~~ 11 | 12 | .. autoclass:: django_transitions.workflow.StatusBase 13 | :members: 14 | 15 | 16 | StateMachineMixinBase 17 | ~~~~~~~~~~~~~~~~~~~~~~ 18 | 19 | .. autoclass:: django_transitions.workflow.StateMachineMixinBase 20 | :members: 21 | 22 | Django Admin Mixins 23 | --------------------- 24 | 25 | .. automodule:: django_transitions.admin 26 | :members: 27 | -------------------------------------------------------------------------------- /docs/source/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ========= 3 | 4 | .. include:: ../../README.rst 5 | :start-after: inclusion-marker-do-not-remove 6 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ============ 3 | 4 | Lets implement the following state machine. 5 | 6 | - The object starts of as 'under development' which can then be made 'live'. 7 | - From the 'live' state it can be marked as 'under maintenance'. 8 | - From all states the object can be marked as 'deleted'. 9 | - A 'deleted' object can be recovered into the 'under maintenance' state. 10 | - Whenever a transition occurs the datetime will be recorded in a datefield. 11 | 12 | .. image:: lifcycle_state_diagram.svg 13 | 14 | Import the dependencies:: 15 | 16 | from django_transitions.workflow import StateMachineMixinBase 17 | from django_transitions.workflow import StatusBase 18 | from transitions import Machine 19 | 20 | 21 | States and Transitions 22 | ------------------------- 23 | 24 | We start by defining the states and transitions 25 | 26 | .. literalinclude:: ../../testapp/workflows.py 27 | :pyobject: LiveStatus 28 | 29 | 30 | Statemachine Mixin 31 | ------------------- 32 | 33 | Next we create a mixin to create a state machine for the django model. 34 | 35 | .. note:: The mixin or the model *must* provide a state property. 36 | In this implementation state is mapped to the 37 | django model field ``wf_state`` 38 | 39 | The mixin **must** override the ``machine`` of the ``StateMachineMixinBase`` class. 40 | The minimum boilerplate to achieve this is:: 41 | 42 | machine = Machine( 43 | model=None, 44 | **status_class.get_kwargs() 45 | ) 46 | 47 | In the example we also define a ``wf_finalize`` method that will set the 48 | date when the last transition occurred on every transaction. 49 | 50 | .. literalinclude:: ../../testapp/workflows.py 51 | :pyobject: LifecycleStateMachineMixin 52 | 53 | 54 | Model 55 | ------- 56 | 57 | Set up the django model 58 | 59 | .. literalinclude:: ../../testapp/models.py 60 | :pyobject: Lifecycle 61 | 62 | We can now inspect the behaviour of the model model with 63 | ``python manage.py shell`` :: 64 | 65 | >>> from testapp.models import Lifecycle 66 | >>> lcycle = Lifecycle() 67 | >>> lcycle.state 68 | 'develop' 69 | >>> lcycle.publish() 70 | True 71 | >>> lcycle.state 72 | 'live' 73 | >>> lcycle.publish() 74 | Traceback (most recent call last): 75 | File "", line 1, in 76 | File "/home/christian/devel/django-transitions/.venv/lib/python3.5/site-packages/transitions/core.py", line 383, in trigger 77 | return self.machine._process(func) 78 | File "/home/christian/devel/django-transitions/.venv/lib/python3.5/site-packages/transitions/core.py", line 1047, in _process 79 | return trigger() 80 | File "/home/christian/devel/django-transitions/.venv/lib/python3.5/site-packages/transitions/core.py", line 397, in _trigger 81 | raise MachineError(msg) 82 | transitions.core.MachineError: "Can't trigger event publish from state live!" 83 | >>> lcycle.save() 84 | >>> graph = lcycle.get_wf_graph() 85 | >>> graph.draw('lifcycle_state_diagram.svg', prog='dot') # This produces the above diagram 86 | 87 | 88 | Admin 89 | ------- 90 | 91 | Set up the django admin to include the workflow actions. 92 | 93 | .. literalinclude:: ../../testapp/admin.py 94 | -------------------------------------------------------------------------------- /docs/source/templates.rst: -------------------------------------------------------------------------------- 1 | Templates 2 | ========== 3 | 4 | To use the templates you have to include ``'django_transitions'`` in 5 | ``INSTALLED_APPS`` in the projects ``settings.py`` file:: 6 | 7 | INSTALLED_APPS = [ 8 | 'django.contrib.admin', 9 | ... 10 | 'django_transitions', # this is only needed to find the templates. 11 | ] 12 | 13 | 14 | The ``change_form`` template adds workflow buttons to the admin change form, 15 | and also provides the 'save' and 'delete' buttons. 16 | This template can be applied to the django admin class:: 17 | 18 | change_form_template = 'transitions/change_form.html' 19 | 20 | .. literalinclude:: ../../django_transitions/templates/transitions/change_form.html 21 | :language: Django 22 | 23 | The ``read_only_change_form`` template adds workflow buttons to the admin change form, 24 | and removes the 'save' and 'delete' buttons. 25 | This template can be applied to the django admin class:: 26 | 27 | change_form_template = 'transitions/read_only_change_form.html' 28 | 29 | .. literalinclude:: ../../django_transitions/templates/transitions/read_only_change_form.html 30 | :language: Django 31 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproj.settings') 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = testproj.settings_pytest 3 | norecursedirs = .venv .env .git .hypothesis 4 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | -r tests.txt 2 | 3 | Django 4 | transitions 5 | pygraphviz 6 | -------------------------------------------------------------------------------- /requirements/django-1.11.txt: -------------------------------------------------------------------------------- 1 | -r tests.txt 2 | 3 | Django<2.0 4 | transitions>0.6<0.7 5 | pygraphviz 6 | -------------------------------------------------------------------------------- /requirements/django-2.0.txt: -------------------------------------------------------------------------------- 1 | -r tests.txt 2 | 3 | Django>2.0<2.1 4 | transitions>0.6<0.7 5 | pygraphviz 6 | -------------------------------------------------------------------------------- /requirements/django-2.1.txt: -------------------------------------------------------------------------------- 1 | -r tests.txt 2 | 3 | Django>2.1<2.2 4 | transitions>0.6<0.7 5 | pygraphviz 6 | codecov 7 | -------------------------------------------------------------------------------- /requirements/flake8.txt: -------------------------------------------------------------------------------- 1 | -r django-2.1.txt 2 | 3 | bandit==1.5.1 4 | flake8-blind-except==0.1.1 5 | flake8-bugbear==18.8.0 6 | flake8-coding==1.3.1 7 | flake8-commas==2.0.0 8 | flake8-debugger==3.1.0 9 | flake8-docstrings==1.3.0 10 | flake8-isort==2.5 11 | flake8-pep3101==1.2.1 12 | flake8-quotes==1.0.0 13 | flake8-string-format==0.2.3 14 | flake8==3.6.0 15 | isort==4.3.4 16 | lizard==1.15.6 17 | pep257==0.7.0 18 | pep8-naming==0.7.0 19 | pep8==1.7.1 20 | pip-check==2.3.3 21 | pycodestyle==2.4.0 22 | pyflakes==2.0.0 23 | radon==2.4.0 24 | yamllint==1.12.1 25 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | pytest-cov==2.5.1 2 | pytest-django==3.4.3 3 | pytest==3.10.1 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from setuptools import find_packages 5 | from setuptools import setup 6 | 7 | version = '0.2' 8 | 9 | setup(name='django_transitions', 10 | version=version, 11 | description="Integrate pytransitions with Django.", 12 | long_description=(open("README.rst").read() + "\n"), 13 | classifiers=[ 14 | 'Framework :: Django', 15 | 'Framework :: Django :: 1.11', 16 | 'Framework :: Django :: 2.0', 17 | 'Framework :: Django :: 2.1', 18 | 'Programming Language :: Python :: 2', 19 | 'Programming Language :: Python :: 2.7', 20 | 'Programming Language :: Python :: 3', 21 | 'Programming Language :: Python :: 3.4', 22 | 'Programming Language :: Python :: 3.5', 23 | 'Programming Language :: Python :: 3.6', 24 | 'Development Status :: 3 - Alpha', 25 | 'License :: OSI Approved :: BSD License', 26 | ], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers 27 | keywords='django permission restframework', 28 | author='Christian Ledermann', 29 | author_email='christian.ledermann@gmail.com', 30 | url='https://github.com/PrimarySite/django-transitions', 31 | license='BSD', 32 | packages=find_packages(exclude=['ez_setup', 'testproj', 'testapp']), 33 | package_dir={'django_transitions': 'django_transitions'}, 34 | package_data={'django_transitions': ['templates/transitions/*.html']}, 35 | include_package_data=True, 36 | zip_safe=False, 37 | install_requires=[ 38 | # -*- Extra requirements: -*- 39 | 'Django', 40 | 'transitions', 41 | ], 42 | entry_points=""" 43 | # -*- Entry points: -*- 44 | """, 45 | ) 46 | -------------------------------------------------------------------------------- /testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrimarySite/django-transitions/c1173e2a04ed1eacd5a5cb649b15e976709d4ed6/testapp/__init__.py -------------------------------------------------------------------------------- /testapp/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Example django admin.""" 3 | 4 | from django_transitions.admin import WorkflowAdminMixin 5 | from django.contrib import admin 6 | 7 | from .models import Lifecycle 8 | 9 | 10 | class LifecycleAdmin(WorkflowAdminMixin, admin.ModelAdmin): 11 | """ 12 | Minimal Admin for Lifecycles Example. 13 | 14 | You probably want to make the workflow fields 15 | read only so yo can not change these values 16 | manually. 17 | 18 | readonly_fields = ['wf_state', 'wf_date'] 19 | """ 20 | 21 | list_display = ['wf_date', 'wf_state'] 22 | list_filter = ['wf_state'] 23 | 24 | 25 | admin.site.register(Lifecycle, LifecycleAdmin) 26 | -------------------------------------------------------------------------------- /testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestappConfig(AppConfig): 5 | name = 'testapp' 6 | -------------------------------------------------------------------------------- /testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-12 09:39 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | import testapp.workflows 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Lifecycle', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('wf_state', models.CharField(choices=[('develop', 'Under Development'), ('live', 'Live'), ('maintenance', 'Under Maintenance'), ('deleted', 'Deleted')], default='develop', help_text='Workflow state', max_length=32, verbose_name='Workflow Status')), 21 | ('wf_date', models.DateTimeField(default=django.utils.timezone.now, help_text='Indicates when this workflowstate was entered.', verbose_name='Workflow Date')), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | bases=(testapp.workflows.LifecycleStateMachineMixin, models.Model), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrimarySite/django-transitions/c1173e2a04ed1eacd5a5cb649b15e976709d4ed6/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /testapp/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Django transitions.""" 3 | 4 | from django.db import models 5 | from django.utils import timezone 6 | 7 | from .workflows import LiveStatus 8 | from .workflows import LifecycleStateMachineMixin 9 | 10 | 11 | class Lifecycle(LifecycleStateMachineMixin, models.Model): 12 | """ 13 | A model that provides workflow state and workflow date fields. 14 | 15 | This is a minimal example implementation. 16 | """ 17 | 18 | class Meta: # noqa: D106 19 | abstract = False 20 | 21 | wf_state = models.CharField( 22 | verbose_name = 'Workflow Status', 23 | null=False, 24 | blank=False, 25 | default=LiveStatus.SM_INITIAL_STATE, 26 | choices=LiveStatus.STATE_CHOICES, 27 | max_length=32, 28 | help_text='Workflow state', 29 | ) 30 | 31 | wf_date = models.DateTimeField( 32 | verbose_name = 'Workflow Date', 33 | null=False, 34 | blank=False, 35 | default=timezone.now, 36 | help_text='Indicates when this workflowstate was entered.', 37 | ) 38 | -------------------------------------------------------------------------------- /testapp/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrimarySite/django-transitions/c1173e2a04ed1eacd5a5cb649b15e976709d4ed6/testapp/tests/__init__.py -------------------------------------------------------------------------------- /testapp/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Admin Tests.""" 3 | 4 | # Django 5 | from django.test import TestCase 6 | from django.test import RequestFactory 7 | from django.utils import timezone 8 | from django.contrib.auth import get_user_model 9 | from django.contrib import messages 10 | from django.contrib.admin.sites import AdminSite 11 | 12 | # Local 13 | from ..workflows import LiveStatus 14 | from ..admin import LifecycleAdmin 15 | from ..models import Lifecycle 16 | 17 | try: 18 | from unittest import mock 19 | except ImportError: 20 | import mock 21 | 22 | 23 | class TestLifecycleAdmin(TestCase): 24 | 25 | def setUp(self): 26 | User = get_user_model() 27 | self.user = User.objects.create_user( 28 | username='testuser', password='12345') 29 | self.lifecycle = Lifecycle.objects.create() 30 | 31 | @mock.patch('testapp.admin.LifecycleAdmin.message_user') 32 | def test_response_change(self, mock_message): 33 | """Trigger a workflow event.""" 34 | data = {'_publish': 'Publish'} 35 | request = RequestFactory().post('/admin/lifecycle/', data) 36 | request.user = self.user 37 | assert self.lifecycle.state == LiveStatus.DEVELOP 38 | admin_lifecycle = AdminSite() 39 | before = self.lifecycle.wf_state 40 | after = LiveStatus.LIVE 41 | message = 'Status changed from {0} to {1} by transition {2}'.format( 42 | before, after, 'publish') 43 | lifecycle_admin = LifecycleAdmin(Lifecycle, admin_lifecycle) 44 | 45 | response = lifecycle_admin.response_change(request, self.lifecycle) 46 | 47 | self.lifecycle.refresh_from_db() 48 | assert response.status_code == 302 49 | assert self.lifecycle.state == LiveStatus.LIVE 50 | mock_message.assert_called_once_with(request, message, messages.SUCCESS) 51 | 52 | @mock.patch('testapp.admin.LifecycleAdmin.message_user') 53 | def test_response_change_failing_transition(self, mock_message): 54 | """Trigger a failing workflow event.""" 55 | data = {'_mark_deleted': 'Delete'} 56 | request = RequestFactory().post('/admin/lifecycle/', data) 57 | self.lifecycle.mark_deleted = mock.MagicMock() 58 | self.lifecycle.mark_deleted.return_value = False 59 | request.user = self.user 60 | assert self.lifecycle.state == LiveStatus.DEVELOP 61 | admin_lifecycle = AdminSite() 62 | before = self.lifecycle.wf_state 63 | after = LiveStatus.LIVE 64 | message = 'Status could not be changed from {0} by transition {1}'.format( 65 | before, 'mark_deleted') 66 | lifecycle_admin = LifecycleAdmin(Lifecycle, admin_lifecycle) 67 | 68 | response = lifecycle_admin.response_change(request, self.lifecycle) 69 | 70 | self.lifecycle.refresh_from_db() 71 | assert self.lifecycle.state == LiveStatus.DEVELOP 72 | mock_message.assert_called_once_with(request, message, messages.ERROR) 73 | 74 | @mock.patch('testapp.admin.LifecycleAdmin.message_user') 75 | def test_response_change_no_transition(self, mock_message): 76 | """Trigger a workflow event.""" 77 | data = {'_no_such_transition': 'Publish'} 78 | request = RequestFactory().post('/admin/lifecycle/', data) 79 | request.user = self.user 80 | assert self.lifecycle.state == LiveStatus.DEVELOP 81 | admin_lifecycle = AdminSite() 82 | lifecycle_admin = LifecycleAdmin(Lifecycle, admin_lifecycle) 83 | message = ('The lifecycle ' 84 | '"{0}" was changed successfully.'.format( 85 | self.lifecycle)) 86 | 87 | response = lifecycle_admin.response_change(request, self.lifecycle) 88 | 89 | self.lifecycle.refresh_from_db() 90 | assert self.lifecycle.state == LiveStatus.DEVELOP 91 | mock_message.assert_called_once_with(request, message, messages.SUCCESS) 92 | -------------------------------------------------------------------------------- /testapp/tests/test_workflow.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Workflow Tests.""" 3 | # Standard Library 4 | import re 5 | 6 | # Django 7 | from django.test import TestCase 8 | from django.utils import timezone 9 | 10 | # Local 11 | from ..workflows import LiveStatus 12 | from ..models import Lifecycle 13 | 14 | 15 | def compare_no_whitespace(a, b): 16 | """Compare two base strings, disregarding whitespace.""" 17 | return re.sub('\s*"*', '', a) == re.sub('\s*"*', '', b) 18 | 19 | 20 | class SiteWorkflowTest(TestCase): 21 | """Test that a user has primarysite elevated Privileges.""" 22 | 23 | def setUp(self): # noqa: D102 24 | self.lcycle = Lifecycle() 25 | 26 | def test_initial_state(self): 27 | """Test initial state.""" 28 | assert self.lcycle.state == LiveStatus.DEVELOP 29 | 30 | def test_available_events_initial(self): 31 | """Test the available events for initial state.""" 32 | events = [e['transition'].name for e in self.lcycle.get_available_events()] 33 | assert set(events) == {'mark_deleted', 'publish'} 34 | 35 | def test_publish(self): 36 | """Test the publish transition.""" 37 | dt_before = timezone.now() 38 | 39 | assert self.lcycle.publish() 40 | 41 | dt_after = timezone.now() 42 | assert self.lcycle.state == LiveStatus.LIVE == self.lcycle.wf_state 43 | assert dt_before <= self.lcycle.wf_date <= dt_after 44 | 45 | def test_delete_initial(self): 46 | """Test that delete is possible from initial state.""" 47 | dt_before = timezone.now() 48 | 49 | assert self.lcycle.mark_deleted() 50 | 51 | dt_after = timezone.now() 52 | assert self.lcycle.state == LiveStatus.DELETED == self.lcycle.wf_state 53 | assert dt_before <= self.lcycle.wf_date <= dt_after 54 | 55 | def test_graph(self): 56 | """Compare the graph to ensure the workflow is correct.""" 57 | self.lcycle.save() 58 | site_wf_graph = """ 59 | digraph "" { 60 | graph [compound=True, 61 | label="Lifecycle", 62 | rankdir=LR, 63 | ratio=0.3 64 | ]; 65 | node [color=black, 66 | fillcolor=white, 67 | height=1.2, 68 | shape=circle, 69 | style=filled 70 | ]; 71 | edge [color=black]; 72 | develop [color=red, 73 | fillcolor=darksalmon, 74 | shape=doublecircle]; 75 | develop -> live [label=publish]; 76 | develop -> deleted [label=mark_deleted]; 77 | live -> deleted [label=mark_deleted]; 78 | live -> maintenance [label=make_private]; 79 | deleted -> maintenance [label=revert_delete]; 80 | maintenance -> live [label=publish]; 81 | maintenance -> deleted [label=mark_deleted]; 82 | } 83 | """ 84 | graph = self.lcycle.get_wf_graph() 85 | 86 | assert compare_no_whitespace(graph.string(), site_wf_graph) 87 | -------------------------------------------------------------------------------- /testapp/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /testapp/workflows.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Example Lifecycle workflow.""" 3 | 4 | from django.utils import timezone 5 | 6 | from django_transitions.workflow import StateMachineMixinBase 7 | from django_transitions.workflow import StatusBase 8 | 9 | from transitions import Machine 10 | 11 | 12 | class LiveStatus(StatusBase): 13 | """Workflow for Lifecycle.""" 14 | 15 | # Define the states as constants 16 | DEVELOP = 'develop' 17 | LIVE = 'live' 18 | MAINTENANCE = 'maintenance' 19 | DELETED = 'deleted' 20 | 21 | # Give the states a human readable label 22 | STATE_CHOICES = ( 23 | (DEVELOP, 'Under Development'), 24 | (LIVE, 'Live'), 25 | (MAINTENANCE, 'Under Maintenance'), 26 | (DELETED, 'Deleted'), 27 | ) 28 | 29 | # Define the transitions as constants 30 | PUBLISH = 'publish' 31 | MAKE_PRIVATE = 'make_private' 32 | MARK_DELETED = 'mark_deleted' 33 | REVERT_DELETED = 'revert_delete' 34 | 35 | # Give the transitions a human readable label and css class 36 | # which will be used in the django admin 37 | TRANSITION_LABELS = { 38 | PUBLISH : {'label': 'Make live', 'cssclass': 'default'}, 39 | MAKE_PRIVATE: {'label': 'Under maintenance'}, 40 | MARK_DELETED: {'label': 'Mark as deleted', 'cssclass': 'deletelink'}, 41 | REVERT_DELETED: {'label': 'Revert Delete', 'cssclass': 'default'}, 42 | } 43 | 44 | # Construct the values to pass to the state machine constructor 45 | 46 | # The states of the machine 47 | SM_STATES = [ 48 | DEVELOP, LIVE, MAINTENANCE, DELETED, 49 | ] 50 | 51 | # The machines initial state 52 | SM_INITIAL_STATE = DEVELOP 53 | 54 | # The transititions as a list of dictionaries 55 | SM_TRANSITIONS = [ 56 | # trigger, source, destination 57 | { 58 | 'trigger': PUBLISH, 59 | 'source': [DEVELOP, MAINTENANCE], 60 | 'dest': LIVE, 61 | }, 62 | { 63 | 'trigger': MAKE_PRIVATE, 64 | 'source': LIVE, 65 | 'dest': MAINTENANCE, 66 | }, 67 | { 68 | 'trigger': MARK_DELETED, 69 | 'source': [ 70 | DEVELOP, LIVE, MAINTENANCE, 71 | ], 72 | 'dest': DELETED, 73 | }, 74 | { 75 | 'trigger': REVERT_DELETED, 76 | 'source': DELETED, 77 | 'dest': MAINTENANCE, 78 | }, 79 | ] 80 | 81 | 82 | class LifecycleStateMachineMixin(StateMachineMixinBase): 83 | """Lifecycle workflow state machine.""" 84 | 85 | status_class = LiveStatus 86 | 87 | machine = Machine( 88 | model=None, 89 | finalize_event='wf_finalize', 90 | auto_transitions=False, 91 | **status_class.get_kwargs() # noqa: C815 92 | ) 93 | 94 | @property 95 | def state(self): 96 | """Get the items workflowstate or the initial state if none is set.""" 97 | if self.wf_state: 98 | return self.wf_state 99 | return self.machine.initial 100 | 101 | @state.setter 102 | def state(self, value): 103 | """Set the items workflow state.""" 104 | self.wf_state = value 105 | return self.wf_state 106 | 107 | def wf_finalize(self, *args, **kwargs): 108 | """Run this on all transitions.""" 109 | self.wf_date = timezone.now() 110 | -------------------------------------------------------------------------------- /testproj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrimarySite/django-transitions/c1173e2a04ed1eacd5a5cb649b15e976709d4ed6/testproj/__init__.py -------------------------------------------------------------------------------- /testproj/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testproj project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '41#*n8^vkm(4=p=9m7@(-+_o^!@*&4ku_*iak#e6m=g@@#2m0&' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'testapp', 41 | 'django_transitions', # this is only needed to find the templates. 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'testproj.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = 'testproj.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': 'django.db.backends.sqlite3', 81 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 82 | } 83 | } 84 | 85 | 86 | # Internationalization 87 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 88 | 89 | LANGUAGE_CODE = 'en-gb' 90 | 91 | TIME_ZONE = 'UTC' 92 | 93 | USE_I18N = True 94 | 95 | USE_L10N = True 96 | 97 | USE_TZ = True 98 | 99 | 100 | # Static files (CSS, JavaScript, Images) 101 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 102 | 103 | STATIC_URL = '/static/' 104 | -------------------------------------------------------------------------------- /testproj/settings_pytest.py: -------------------------------------------------------------------------------- 1 | from .settings import * 2 | 3 | # use an in-memory sqlite db 4 | DATABASES = { 5 | 'default': { 6 | 'ENGINE': 'django.db.backends.sqlite3', 7 | 'NAME': ':memory:', 8 | 'USER': '', 9 | 'PASSWORD': '', 10 | 'HOST': '', 11 | 'PORT': '', 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /testproj/urls.py: -------------------------------------------------------------------------------- 1 | """testproj URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.1/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.conf.urls import url 18 | 19 | urlpatterns = [ 20 | url(r'^admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /testproj/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproj 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.1/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', 'testproj.settings') 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------