├── .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 |
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 |
--------------------------------------------------------------------------------