├── .gitignore ├── .travis.yml ├── AUTHORS ├── CHANGELOG.rst ├── LICENCSE.txt ├── Makefile ├── README.rst ├── django_states ├── __init__.py ├── compat.py ├── conf.py ├── exceptions.py ├── fields.py ├── log.py ├── machine.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── graph_states2.py ├── model_methods.py ├── models.py ├── signals.py ├── templatetags │ ├── __init__.py │ └── django_states.py ├── tests.py ├── urls.py └── views.py ├── docs ├── .gitignore ├── Makefile ├── changelog.rst ├── conf.py ├── django_states │ ├── conf.rst │ ├── exceptions.rst │ ├── fields.rst │ ├── log.rst │ ├── machine.rst │ ├── model_methods.rst │ ├── models.rst │ ├── templatetags.rst │ └── views.rst ├── index.rst ├── make.bat └── readme.rst ├── setup.py ├── test_proj ├── __init__.py ├── manage.py ├── runtests.py ├── settings.py └── urls.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | develop-eggs 12 | .installed.cfg 13 | 14 | # Installer logs 15 | pip-log.txt 16 | 17 | # Unit test / coverage reports 18 | .coverage 19 | .tox 20 | ######### 21 | ## Django 22 | ######### 23 | *.log 24 | *.pot 25 | local_settings.py 26 | settings_local.py 27 | ########## 28 | ## Eclipse 29 | ########## 30 | *.pydevproject 31 | .project 32 | .metadata 33 | bin/** 34 | tmp/** 35 | tmp/**/* 36 | *.tmp 37 | *.bak 38 | *.swp 39 | *~.nib 40 | local.properties 41 | .classpath 42 | .settings/ 43 | .loadpath 44 | 45 | ########## 46 | ## PyCharm 47 | ########## 48 | .idea/ 49 | 50 | # CDT-specific 51 | .cproject -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 2.7 3 | env: 4 | - TOX_ENV=py27-dj16 5 | - TOX_ENV=py27-dj17 6 | - TOX_ENV=py27-dj18 7 | install: 8 | - pip install tox 9 | script: 10 | - tox -e $TOX_ENV 11 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Ben Mason 2 | Dirk Moors 3 | Gert Van Gool 4 | Giovanni Collazo 5 | Jakub Paczkowski 6 | Jan Fabry 7 | Jef Geskens 8 | Jonathan Slenders 9 | José Padilla 10 | Linsy Aerts 11 | Maarten Timmerman 12 | Niels Van Och 13 | Olivier Sels 14 | OpenShift guest 15 | San Gillis 16 | Simon Andersson 17 | Steven Klass 18 | sgillis 19 | techdragon 20 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ~~~~~~~~~ 2 | CHANGELOG 3 | ~~~~~~~~~ 4 | 5 | 1.6.4 (2015-03-05) 6 | ================== 7 | 8 | * Maintainer is now Jef Geskens 9 | * Django 1.7 support 10 | * Merged pull request #65 from José Padilla 11 | * Starting point of more regular PyPI releases 12 | 13 | v1.4.3 14 | ====== 15 | Release date: 16 | 2012-01-04 17 | Notes: 18 | * Adds signals around state transition executions (``before_state_execute`` 19 | and ``after_state_execute`` in ``states2.signals``) 20 | * Updates docs 21 | 22 | v1.4.2 23 | ====== 24 | Release date: 25 | 2011-11-05 26 | Notes: 27 | * Updates ``StateGroup`` to support an exclude list (instead of stating all 28 | states that included in the group, state the ones that are not included) 29 | * Updates ``get_admin_actions`` to support a non-default (``state``) field 30 | name 31 | * Updates docs 32 | 33 | v1.4.1 34 | ====== 35 | Release date: 36 | 2011-10-28 37 | Notes: 38 | * Store the version differently in Sphinx configuration 39 | 40 | v1.4.0 41 | ====== 42 | Release date: 43 | 2011-10-28 44 | Notes: 45 | * Adds documentation 46 | * Adds supports for ``StateGroup`` 47 | * Supports multiple ``from_states`` in ``StateTransition`` 48 | * Adds ``graph_states`` 49 | 50 | v1.3.11 51 | ======= 52 | Release date: 53 | 2011-09-22 54 | Notes: 55 | * Updates ``save()`` to support disabling state validation (used mainly 56 | during migrations) 57 | * Reverts change of v1.3.10 in ``get_STATE_info`` 58 | 59 | v1.3.10 60 | ======= 61 | Release date: 62 | 2011-08-31 63 | Notes: 64 | * Corrects ``get_STATE_info`` 65 | 66 | v1.3.9 67 | ====== 68 | Release date: 69 | 2011-08-17 70 | Notes: 71 | **same as v1.3.7** 72 | 73 | v1.3.8 74 | ====== 75 | Release date: 76 | 2011-08-24 77 | Notes: 78 | **broken release** -- replaced by v1.3.9 in the meantime 79 | 80 | v1.3.7 81 | ====== 82 | Release date: 83 | 2011-08-17 84 | Notes: 85 | * Hide the ``KeyError`` that could be raised by ``get_state`` 86 | * Corrects the ``__init__`` calls in the exceptions 87 | 88 | v1.3.6 89 | ====== 90 | Release date: 91 | 2011-08-16 92 | Notes: 93 | * Moves the ``ValidationError`` to the ``states2.exceptions`` 94 | 95 | v1.3.5 96 | ====== 97 | Release date: 98 | 2011-08-12 99 | Notes: 100 | * Adds transition validation 101 | 102 | v1.3.4 103 | ====== 104 | Release date: 105 | 2011-08-10 106 | Notes: 107 | * Removes forgotten ``pdb`` statement 108 | 109 | v1.3.3 110 | ====== 111 | Release date: 112 | 2011-08-10 113 | Notes: 114 | * Corrects overridden ``save()``: use the ``class_prepared`` signal to 115 | rewrite the ``save()`` 116 | 117 | v1.3.2 118 | ====== 119 | Release date: 120 | 2011-07-18 121 | Notes: 122 | * Corrects overridden ``save()``: handler only needs to be called when object 123 | is created 124 | 125 | v1.3.1 126 | ====== 127 | Release date: 128 | 2011-07-18 129 | Notes: 130 | * Corrects overridden ``save()`` (first save the DB, then call the handler) 131 | 132 | v1.3.0 133 | ====== 134 | Release date: 135 | 2011-07-08 136 | Notes: 137 | * Adds an handler that will be called after the object arrived in a new 138 | state 139 | * Overriding the ``save()`` method of models from now on 140 | 141 | v1.2.21 142 | ======= 143 | Release date: 144 | 2011-07-18 145 | Notes: 146 | **incorrect tag** -- replaced by 1.3.1 147 | 148 | v1.2.20 149 | ======= 150 | Release date: 151 | 2011-05-13 152 | Notes: 153 | * Print the traceback when an exception occurs during a failed state 154 | transition 155 | 156 | v1.2.19 157 | ======= 158 | Release date: 159 | 2011-05-06 160 | Notes: 161 | * Use custom exception instead of a plain ``Exception`` 162 | 163 | v1.2.18 164 | ======= 165 | Release date: 166 | 2011-05-02 167 | Notes: 168 | * Use the ``get_state_info()`` method instead of deep-calling the 169 | ``StateMachine`` 170 | 171 | v1.2.17 172 | ======= 173 | Release date: 174 | 2011-05-02 175 | Notes: 176 | * Updates South support 177 | * Store transition kwargs in log 178 | 179 | v1.2.16 180 | ======= 181 | Release date: 182 | 2011-04-29 183 | Notes: 184 | * Created a ``StateField`` (and updated ``StateModel`` to use this) 185 | * Removed model cache. Use the one build into Django. 186 | 187 | v1.2.15 188 | ======= 189 | Release date: 190 | 2011-04-28 191 | Notes: 192 | * Added Gert to authors 193 | * Moved code outside the src dir into a top-level dir 194 | * Added version information to the module 195 | * Created a machine module 196 | * Added generic base exception 197 | * Updated the README file 198 | 199 | * Cleaned up documentation 200 | * Converted to ReST syntax 201 | * PEP8-ify 202 | 203 | Older versions 204 | ============== 205 | - v1.2.14 206 | - v1.2.13 207 | - v1.2.12 208 | - v1.2.11 209 | - v1.2.10 210 | - v1.2.9 211 | - v1.2.8 212 | - v1.2.7 213 | - v1.2.6 214 | - v1.2.5 215 | - v1.2.4 216 | - v1.2.3 217 | - v1.2.2 218 | - v1.2.1 219 | - v1.1.1 220 | -------------------------------------------------------------------------------- /LICENCSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) Unleashed NV and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of City Live nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCS_MAKE_CMD = html dirhtml latex latexpdf 2 | 3 | .PHONY: $(DOCS_MAKE_CMD) docs clean test coverage 4 | 5 | docs: $(DOCS_MAKE_CMD) 6 | 7 | $(DOCS_MAKE_CMD): 8 | DJANGO_SETTINGS_MODULE=test_proj.settings $(MAKE) -C docs $@ 9 | 10 | clean: 11 | $(MAKE) -C docs clean 12 | 13 | test: 14 | tox 15 | 16 | coverage: 17 | coverage run --source='.' setup.py test 18 | coverage html --include="django_states*" --omit="*test*" --directory=.direnv/htmlcov 19 | coverage report --include="django_states*" --omit="*test*" 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | This repository is deprecated. Consider using https://pypi.org/project/django-fsm/ instead. 2 | -------------------------------------------------------------------------------- /django_states/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | State engine for django models. 4 | 5 | Define a state graph for a model and remember the state of each object. 6 | State transitions can be logged for objects. 7 | """ 8 | from __future__ import absolute_import 9 | -------------------------------------------------------------------------------- /django_states/compat.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | # Django >= 1.4 moves handler404, handler500, include, patterns and url from 3 | # django.conf.urls.defaults to django.conf.urls. 4 | try: 5 | from django.conf.urls import (patterns, url, include) 6 | except ImportError: 7 | from django.conf.urls.defaults import (patterns, url, include) 8 | -------------------------------------------------------------------------------- /django_states/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Configuration options""" 3 | from __future__ import absolute_import 4 | from django.conf import settings 5 | 6 | 7 | #: The basic configuration 8 | base_conf = getattr(settings, 'STATES2_CONF', {}) 9 | 10 | #: The model name for the state transition logs. 11 | #: It will be string replaced with ``%(model_name)s`` and ``%(field_name)s``. 12 | LOG_MODEL_NAME = base_conf.get('LOG_MODEL_NAME', 13 | '%(model_name)s%(field_name)sLog') 14 | -------------------------------------------------------------------------------- /django_states/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Declared Exceptions""" 3 | 4 | 5 | class States2Exception(Exception): 6 | pass 7 | 8 | 9 | # ==========[ Transition exceptions ]========== 10 | 11 | class TransitionException(States2Exception): 12 | pass 13 | 14 | 15 | class TransitionOnUnsavedObject(TransitionException): 16 | def __init__(self, instance): 17 | TransitionException.__init__(self, "Cannot run state transition on unsaved object '%s'. " 18 | "Please call save() on this object first." % instance) 19 | 20 | 21 | class PermissionDenied(TransitionException): 22 | def __init__(self, instance, transition, user): 23 | if user.is_authenticated(): 24 | username = user.get_full_name() 25 | else: 26 | username = 'AnonymousUser' 27 | TransitionException.__init__(self, "Permission for executing the state '%s' has be denied to %s." 28 | % (transition, username)) 29 | 30 | 31 | class UnknownTransition(TransitionException): 32 | def __init__(self, instance, transition): 33 | TransitionException.__init__(self, "Unknown transition '%s' on %s" % 34 | (transition, instance.__class__.__name__)) 35 | 36 | 37 | class TransitionNotFound(TransitionException): 38 | def __init__(self, model, from_state, to_state): 39 | TransitionException.__init__(self, "Transition from '%s' to '%s' on %s not found" % 40 | (from_state, to_state, model.__name__)) 41 | 42 | 43 | class TransitionCannotStart(TransitionException): 44 | def __init__(self, instance, transition): 45 | TransitionException.__init__(self, "Transition '%s' on %s cannot start in the state '%s'" % 46 | (transition, instance.__class__.__name__, instance.state)) 47 | 48 | 49 | class TransitionNotValidated(TransitionException): 50 | def __init__(self, instance, transition, validation_errors): 51 | TransitionException.__init__(self, "Transition '%s' on %s does not validate (%i errors)" % 52 | (transition, instance.__class__.__name__, len(validation_errors))) 53 | self.validation_errors = validation_errors 54 | 55 | 56 | class MachineDefinitionException(States2Exception): 57 | def __init__(self, machine, description): 58 | States2Exception.__init__(self, 'Error in state machine definition: ' + description) 59 | 60 | 61 | class TransitionValidationError(TransitionException): 62 | """ 63 | Errors yielded from StateTransition.validate. 64 | """ 65 | pass 66 | 67 | 68 | # ==========[ Other exceptions ]========== 69 | 70 | class UnknownState(States2Exception): 71 | def __init__(self, state): 72 | States2Exception.__init__(self, 'State "%s" does not exist' % state) 73 | -------------------------------------------------------------------------------- /django_states/fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Fields used""" 3 | from __future__ import absolute_import 4 | 5 | __all__ = ('StateField',) 6 | 7 | from django.db import models 8 | from django.utils.functional import curry 9 | from django_states.machine import StateMachine 10 | 11 | from django_states.model_methods import (get_STATE_transitions, 12 | get_public_STATE_transitions, 13 | get_STATE_info, get_STATE_machine, 14 | get_STATE_display) 15 | 16 | 17 | class StateField(models.CharField): 18 | """ 19 | Add state information to a model. 20 | 21 | This will add extra methods to the model. 22 | 23 | Usage:: 24 | 25 | status = StateField(machine=PowerState) 26 | """ 27 | def __init__(self, **kwargs): 28 | # State machine parameter. (Fall back to default machine. 29 | # e.g. when South is creating an instance.) 30 | self._machine = kwargs.pop('machine', StateMachine) 31 | 32 | kwargs.setdefault('max_length', 100) 33 | kwargs['choices'] = None 34 | super(StateField, self).__init__(**kwargs) 35 | 36 | def contribute_to_class(self, cls, name): 37 | """ 38 | Adds methods to the :class:`~django.db.models.Model`. 39 | 40 | The extra methods will be added for each :class:`StateField` in a 41 | model: 42 | 43 | - :meth:`~django_states.model_methods.get_STATE_transitions` 44 | - :meth:`~django_states.model_methods.get_public_STATE_transitions` 45 | - :meth:`~django_states.model_methods.get_STATE_info` 46 | - :meth:`~django_states.model_methods.get_STATE_machine` 47 | """ 48 | super(StateField, self).contribute_to_class(cls, name) 49 | 50 | # Set choice options (for combo box) 51 | self._choices = self._machine.get_state_choices() 52 | self.default = self._machine.initial_state 53 | 54 | # Do we need logging? 55 | # For Django 1.7: the migrations framework creates copies for all 56 | # the models, placing them all in a module name 57 | # "__fake__". Of course, for Django, for each module, 58 | # the names should be unique, so that wouldn't work. 59 | # We decide just to not have a logging model for the 60 | # migrations. 61 | # https://github.com/django/django/blob/f2ddc439b1938acb6cae693bda9d8cf83a4583be/django/db/migrations/state.py#L316 62 | if self._machine.log_transitions and cls.__module__ != '__fake__': 63 | from django_states.log import _create_state_log_model 64 | log_model = _create_state_log_model(cls, name, self._machine) 65 | else: 66 | log_model = None 67 | 68 | setattr(cls, '_%s_log_model' % name, log_model) 69 | 70 | # adding extra methods 71 | setattr(cls, 'get_%s_display' % name, 72 | curry(get_STATE_display, field=name, machine=self._machine)) 73 | setattr(cls, 'get_%s_transitions' % name, 74 | curry(get_STATE_transitions, field=name)) 75 | setattr(cls, 'get_public_%s_transitions' % name, 76 | curry(get_public_STATE_transitions, field=name)) 77 | setattr(cls, 'get_%s_info' % name, 78 | curry(get_STATE_info, field=name, machine=self._machine)) 79 | setattr(cls, 'get_%s_machine' % name, 80 | curry(get_STATE_machine, field=name, machine=self._machine)) 81 | 82 | models.signals.class_prepared.connect(self.finalize, sender=cls) 83 | 84 | def finalize(self, sender, **kwargs): 85 | """ 86 | Override ``save``, call initial state handler on save. 87 | 88 | When ``.save(no_state_validation=True)`` has been used, the state won't 89 | be validated, and the handler won't we executed. It's recommended to 90 | use this parameter in South migrations, because South is not really 91 | aware of which state machine is used for which classes. 92 | 93 | Note that we wrap ``save`` only after the ``class_prepared`` signal 94 | has been sent, it won't work otherwise when the model has a 95 | custom ``save`` method. 96 | """ 97 | real_save = sender.save 98 | 99 | def new_save(obj, *args, **kwargs): 100 | created = not obj.id 101 | 102 | # Validate whether this is an existing state 103 | if kwargs.pop('no_state_validation', True): 104 | state = None 105 | else: 106 | # Can raise UnknownState 107 | state = self._machine.get_state(obj.state) 108 | 109 | # Save first using the real save function 110 | result = real_save(obj, *args, **kwargs) 111 | 112 | # Now call the handler 113 | if created and state: 114 | state.handler(obj) 115 | return result 116 | 117 | sender.save = new_save 118 | 119 | 120 | # South introspection 121 | try: 122 | from south.modelsinspector import add_introspection_rules 123 | except ImportError: 124 | pass 125 | else: 126 | add_introspection_rules([ 127 | ( 128 | (StateField,), 129 | [], 130 | { 131 | 'max_length': [100, {"is_value": True}], 132 | }, 133 | ), 134 | 135 | ], ["^django_states\.fields\.StateField"]) 136 | -------------------------------------------------------------------------------- /django_states/log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """log model""" 3 | from __future__ import absolute_import 4 | 5 | import json 6 | import sys 7 | 8 | from django.db import models 9 | from django.db.models.base import ModelBase 10 | from django.utils.encoding import python_2_unicode_compatible 11 | from django.utils.translation import ugettext_lazy as _ 12 | from django.conf import settings 13 | 14 | from django_states import conf 15 | from django_states.fields import StateField 16 | from django_states.machine import StateMachine, StateDefinition, StateTransition 17 | import six 18 | 19 | 20 | def _create_state_log_model(state_model, field_name, machine): 21 | """ 22 | Create a new model for logging the state transitions. 23 | 24 | :param django.db.models.Model state_model: the model that has the 25 | :class:`~django_states.fields.StateField` 26 | :param str field_name: the field name of the 27 | :class:`~django_states.fields.StateField` on the model 28 | :param django_states.machine.StateMachine machine: the state machine that's used 29 | """ 30 | class StateTransitionMachine(StateMachine): 31 | """ 32 | A :class:`~django_states.machine.StateMachine` for log entries (depending on 33 | what happens). 34 | """ 35 | # We don't need logging of state transitions in a state transition log 36 | # entry, as this would cause eternal, recursively nested state 37 | # transition models. 38 | log_transitions = False 39 | 40 | class transition_initiated(StateDefinition): 41 | """Transition has initiated""" 42 | description = _('State transition initiated') 43 | initial = True 44 | 45 | class transition_started(StateDefinition): 46 | """Transition has started""" 47 | description = _('State transition started') 48 | 49 | class transition_failed(StateDefinition): 50 | """Transition has failed""" 51 | description = _('State transition failed') 52 | 53 | class transition_completed(StateDefinition): 54 | """Transition has completed""" 55 | description = _('State transition completed') 56 | 57 | class start(StateTransition): 58 | """Transition Started""" 59 | from_state = 'transition_initiated' 60 | to_state = 'transition_started' 61 | description = _('Start state transition') 62 | 63 | class complete(StateTransition): 64 | """Transition Complete""" 65 | from_state = 'transition_started' 66 | to_state = 'transition_completed' 67 | description = _('Complete state transition') 68 | 69 | class fail(StateTransition): 70 | """Transition Failure""" 71 | from_states = ('transition_initiated', 'transition_started') 72 | to_state = 'transition_failed' 73 | description = _('Mark state transition as failed') 74 | 75 | class _StateTransitionMeta(ModelBase): 76 | """ 77 | Make :class:`_StateTransition` act like it has another name and was 78 | defined in another model. 79 | """ 80 | def __new__(c, name, bases, attrs): 81 | 82 | new_unicode = u'' 83 | if '__unicode__' in attrs: 84 | old_unicode = attrs['__unicode__'] 85 | 86 | def new_unicode(self): 87 | """New Unicode""" 88 | return u'%s (%s)' % (old_unicode(self), self.get_state_info().description) 89 | 90 | attrs['__unicode__'] = new_unicode 91 | 92 | attrs['__module__'] = state_model.__module__ 93 | values = {'model_name': state_model.__name__, 94 | 'field_name': field_name.capitalize()} 95 | class_name = conf.LOG_MODEL_NAME % values 96 | 97 | # Make sure that for Python2, class_name is a 'str' object. 98 | # In Django 1.7, `field_name` returns a unicode object, causing 99 | # `class_name` to be unicode as well. 100 | if sys.version_info[0] == 2: 101 | class_name = str(class_name) 102 | 103 | return ModelBase.__new__(c, class_name, bases, attrs) 104 | 105 | get_state_choices = machine.get_state_choices 106 | 107 | @python_2_unicode_compatible 108 | class _StateTransition(six.with_metaclass(_StateTransitionMeta, models.Model)): 109 | """ 110 | The log entries for :class:`~django_states.machine.StateTransition`. 111 | """ 112 | 113 | state = StateField(max_length=100, default='0', 114 | verbose_name=_('state id'), 115 | machine=StateTransitionMachine) 116 | 117 | from_state = models.CharField(max_length=100, 118 | choices=get_state_choices()) 119 | to_state = models.CharField(max_length=100, choices=get_state_choices()) 120 | user = models.ForeignKey(getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), on_delete=models.CASCADE, 121 | blank=True, null=True) 122 | serialized_kwargs = models.TextField(blank=True) 123 | 124 | start_time = models.DateTimeField( 125 | auto_now_add=True, db_index=True, 126 | verbose_name=_('transition started at') 127 | ) 128 | on = models.ForeignKey(state_model, on_delete=models.CASCADE, related_name=('%s_history' % field_name)) 129 | 130 | class Meta: 131 | """Non-field Options""" 132 | verbose_name = '%s transition' % state_model._meta.verbose_name 133 | 134 | # When the state class has been given an app_label, use 135 | # use this app_label as well for this StateTransition model. 136 | if hasattr(state_model._meta, 'app_label'): 137 | app_label = state_model._meta.app_label 138 | 139 | @property 140 | def kwargs(self): 141 | """ 142 | The ``kwargs`` that were used when calling the state transition. 143 | """ 144 | if not self.serialized_kwargs: 145 | return {} 146 | return json.loads(self.serialized_kwargs) 147 | 148 | @property 149 | def completed(self): 150 | """ 151 | Was the transition completed? 152 | """ 153 | return self.state == 'transition_completed' 154 | 155 | @property 156 | def state_transition_definition(self): 157 | """ 158 | Gets the :class:`django_states.machine.StateTransition` that was used. 159 | """ 160 | return machine.get_transition_from_states(self.from_state, self.to_state) 161 | 162 | @property 163 | def from_state_definition(self): 164 | """ 165 | Gets the :class:`django_states.machine.StateDefinition` from which we 166 | originated. 167 | """ 168 | return machine.get_state(self.from_state) 169 | 170 | @property 171 | def from_state_description(self): 172 | """ 173 | Gets the description of the 174 | :class:`django_states.machine.StateDefinition` from which we were 175 | originated. 176 | """ 177 | return six.text_type(self.from_state_definition.description) 178 | 179 | @property 180 | def to_state_definition(self): 181 | """ 182 | Gets the :class:`django_states.machine.StateDefinition` to which we 183 | transitioning. 184 | """ 185 | return machine.get_state(self.to_state) 186 | 187 | @property 188 | def to_state_description(self): 189 | """ 190 | Gets the description of the 191 | :class:`django_states.machine.StateDefinition` to which we were 192 | transitioning. 193 | """ 194 | return six.text_type(self.to_state_definition.description) 195 | 196 | def make_transition(self, transition, user=None): 197 | """ 198 | Execute state transition. 199 | Provide ``user`` to do permission checking. 200 | :param transition: Name of the transition 201 | :param user: User object 202 | """ 203 | return self.get_state_info().make_transition(transition, user=user) 204 | 205 | @property 206 | def is_public(self): 207 | """ 208 | Returns ``True`` when this state transition is defined public in 209 | the machine. 210 | """ 211 | return self.state_transition_definition.public 212 | 213 | @property 214 | def transition_description(self): 215 | """ 216 | Returns the description for this transition as defined in the 217 | :class:`django_states.machine.StateTransition` declaration of the 218 | machine. 219 | """ 220 | return six.text_type(self.state_transition_definition.description) 221 | 222 | def __str__(self): 223 | return ''.format( 224 | state_model.__name__, self.start_time, self.from_state, self.to_state) 225 | 226 | # This model will be detected by South because of the models.Model.__new__ 227 | # constructor, which will register it somewhere in a global variable. 228 | return _StateTransition 229 | -------------------------------------------------------------------------------- /django_states/machine.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """State Machine""" 3 | from __future__ import absolute_import 4 | import six 5 | 6 | __all__ = ('StateMachine', 'StateDefinition', 'StateTransition') 7 | 8 | from collections import defaultdict 9 | import logging 10 | 11 | from django.contrib import messages 12 | from django_states.exceptions import (TransitionNotFound, TransitionValidationError, 13 | UnknownState, TransitionException, MachineDefinitionException) 14 | from django.utils.encoding import python_2_unicode_compatible 15 | 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class StateMachineMeta(type): 21 | def __new__(c, name, bases, attrs): 22 | """ 23 | Validate state machine, and make ``states``, ``transitions`` and 24 | ``initial_state`` attributes available. 25 | """ 26 | states = {} 27 | transitions = {} 28 | groups = {} 29 | initial_state = None 30 | for a in attrs: 31 | # All definitions are derived from StateDefinition and should be 32 | # addressable by Machine.states 33 | if isinstance(attrs[a], StateDefinitionMeta): 34 | states[a] = attrs[a] 35 | logger.debug('Found state: %s' % states[a].get_name()) 36 | if states[a].initial: 37 | logger.debug('Found initial state: %s' % states[a].get_name()) 38 | if not initial_state: 39 | initial_state = a 40 | else: 41 | raise Exception('Machine defines multiple initial states') 42 | 43 | # All transitions are derived from StateTransition and should be 44 | # addressable by Machine.transitions 45 | if isinstance(attrs[a], StateTransitionMeta): 46 | transitions[a] = attrs[a] 47 | logger.debug('Found state transition: %s' % transitions[a].get_name()) 48 | 49 | # All definitions derived from StateGroup 50 | # should be addressable by Machine.groups 51 | if isinstance(attrs[a], StateGroupMeta): 52 | groups[a] = attrs[a] 53 | logger.debug('Found state group: %s' % groups[a].get_name()) 54 | 55 | # At least one initial state required. (But don't throw error for the 56 | # base defintion.) 57 | if not initial_state and bases != (object,): 58 | raise MachineDefinitionException(c, 'Machine does not define initial state') 59 | 60 | attrs['states'] = states 61 | attrs['transitions'] = transitions 62 | attrs['initial_state'] = initial_state 63 | attrs['groups'] = groups 64 | 65 | # Give all state transitions a 'to_state_description' attribute. 66 | # by copying the description from the state definition. (no 67 | # from_state_description, because multiple from-states are possible.) 68 | for t in list(transitions.values()): 69 | t.to_state_description = states[t.to_state].description 70 | 71 | return type.__new__(c, name, bases, attrs) 72 | 73 | def has_transition(self, transition_name): 74 | """ 75 | Gets whether a transition with the given name is defined in the 76 | machine. 77 | 78 | :param str transition_name: the transition name 79 | 80 | :returns: ``True`` or ``False`` 81 | """ 82 | return transition_name in self.transitions 83 | 84 | def get_transitions(self, transition_name): 85 | """ 86 | Gets a transition with the given name. 87 | 88 | :param str transition_name: the transition name 89 | 90 | :returns: the :class:`StateTransition` or raises a :class:`KeyError` 91 | """ 92 | return self.transitions[transition_name] 93 | 94 | def has_state(self, state_name): 95 | """ 96 | Gets whether a state with given name is defined in the machine. 97 | 98 | :param str state_name: the state name 99 | 100 | :returns: ``True`` or ``False`` 101 | """ 102 | return state_name in self.states 103 | 104 | def get_state(self, state_name): 105 | """ 106 | Gets the state with given name 107 | 108 | :param str state_name: the state name 109 | 110 | :returns: a :class:`StateDefinition` or raises 111 | a :class:`~django_states.exceptions.UnknownState` 112 | """ 113 | try: 114 | return self.states[state_name] 115 | except KeyError: 116 | raise UnknownState(state_name) 117 | 118 | def get_transition_from_states(self, from_state, to_state): 119 | """ 120 | Gets the transitions between 2 specified states. 121 | 122 | :param str from_state: the from state 123 | :param str to_state: the to state 124 | 125 | :returns: a :class:`StateTransition` or raises 126 | a :class:`~django_states.exceptions.TransitionNotFound` 127 | """ 128 | for t in list(self.transitions.values()): 129 | if from_state in t.from_states and t.to_state == to_state: 130 | return t 131 | raise TransitionNotFound(self, from_state, to_state) 132 | 133 | def get_state_groups(self, state_name): 134 | """ 135 | Gets a :class:`dict` of state groups, which will be either ``True`` or 136 | ``False`` if the current state is specified in that group. 137 | 138 | .. note:: That groups that are not defined will still return ``False`` 139 | and not raise a ``KeyError``. 140 | 141 | :param str state_name: the current state 142 | """ 143 | result = defaultdict(lambda: False) 144 | for group in self.groups: 145 | sg = self.groups[group] 146 | if hasattr(sg, 'states'): 147 | result[group] = state_name in sg.states 148 | elif hasattr(sg, 'exclude_states'): 149 | result[group] = not state_name in sg.exclude_states 150 | return result 151 | 152 | 153 | class StateDefinitionMeta(type): 154 | def __new__(c, name, bases, attrs): 155 | """ 156 | Validate state definition 157 | """ 158 | if bases != (object,): 159 | if name.lower() != name and not attrs.get('abstract', False): 160 | raise Exception('Please use lowercase names for state definitions (instead of %s)' % name) 161 | if not 'description' in attrs and not attrs.get('abstract', False): 162 | raise Exception('Please give a description to this state definition') 163 | 164 | if 'handler' in attrs and len(attrs['handler'].__code__.co_varnames) < 2: 165 | raise Exception('StateDefinition handler needs at least two arguments') 166 | 167 | # Turn `handler` into classmethod 168 | if 'handler' in attrs: 169 | attrs['handler'] = classmethod(attrs['handler']) 170 | 171 | return type.__new__(c, name, bases, attrs) 172 | 173 | 174 | class StateGroupMeta(type): 175 | def __new__(c, name, bases, attrs): 176 | """ 177 | Validate state group definition 178 | """ 179 | if bases != (object,): 180 | # check attributes 181 | if 'states' in attrs and 'exclude_states' in attrs: 182 | raise Exception('Use either states or exclude_states but not both') 183 | elif not 'states' in attrs and not 'exclude_states' in attrs: 184 | raise Exception('Please specify states or exclude_states to this state group') 185 | # check type of attributes 186 | if 'exclude_states' in attrs and not isinstance(attrs['exclude_states'], (list, set)): 187 | raise Exception('Please give a list (or set) of states to this state group') 188 | elif 'states' in attrs and not isinstance(attrs['states'], (list, set)): 189 | raise Exception('Please give a list (or set) of states to this state group') 190 | 191 | return type.__new__(c, name, bases, attrs) 192 | 193 | 194 | @python_2_unicode_compatible 195 | class StateTransitionMeta(type): 196 | def __new__(c, name, bases, attrs): 197 | """ 198 | Validate state transition definition 199 | """ 200 | if bases != (object,): 201 | if 'from_state' in attrs and 'from_states' in attrs: 202 | raise Exception('Please use either from_state or from_states') 203 | if 'from_state' in attrs: 204 | attrs['from_states'] = (attrs['from_state'],) 205 | del attrs['from_state'] 206 | if not 'from_states' in attrs: 207 | raise Exception('Please give a from_state to this state transition') 208 | if not 'to_state' in attrs: 209 | raise Exception('Please give a from_state to this state transition') 210 | if not 'description' in attrs: 211 | raise Exception('Please give a description to this state transition') 212 | 213 | if 'handler' in attrs and len(attrs['handler'].__code__.co_varnames) < 3: 214 | raise Exception('StateTransition handler needs at least three arguments') 215 | 216 | # Turn `has_permission` and `handler` into classmethods 217 | for m in ('has_permission', 'handler', 'validate'): 218 | if m in attrs: 219 | attrs[m] = classmethod(attrs[m]) 220 | 221 | return type.__new__(c, name, bases, attrs) 222 | 223 | def __str__(self): 224 | return '%s: (from %s to %s)' % (six.text_type(self.description), ' or '.join(self.from_states), self.to_state) 225 | 226 | 227 | class StateMachine(six.with_metaclass(StateMachineMeta, object)): 228 | """ 229 | Base class for a state machine definition 230 | """ 231 | 232 | #: Log transitions? Log by default. 233 | log_transitions = True 234 | 235 | @classmethod 236 | def get_admin_actions(cls, field_name='state'): 237 | """ 238 | Creates a list of actions for use in the Django Admin. 239 | """ 240 | actions = [] 241 | 242 | def create_action(transition_name): 243 | def action(modeladmin, request, queryset): 244 | # Dry run first 245 | for o in queryset: 246 | get_STATE_info = getattr(o, 'get_%s_info' % field_name) 247 | try: 248 | get_STATE_info().test_transition(transition_name, 249 | request.user) 250 | except TransitionException as e: 251 | modeladmin.message_user(request, 'ERROR: %s on: %s' % (e.message, six.text_type(o)), 252 | level=messages.ERROR) 253 | return 254 | 255 | # Make actual transitions 256 | for o in queryset: 257 | get_STATE_info = getattr(o, 'get_%s_info' % field_name) 258 | get_STATE_info().make_transition(transition_name, 259 | request.user) 260 | 261 | # Feeback 262 | modeladmin.message_user(request, 'State changed for %s objects.' % len(queryset)) 263 | 264 | action.short_description = six.text_type(cls.transitions[transition_name]) 265 | action.__name__ = 'state_transition_%s' % transition_name 266 | return action 267 | 268 | for t in list(cls.transitions.keys()): 269 | actions.append(create_action(t)) 270 | 271 | return actions 272 | 273 | @classmethod 274 | def get_state_choices(cls): 275 | """ 276 | Gets all possible choices for a model. 277 | """ 278 | return [(k, cls.states[k].description) for k in list(cls.states.keys())] 279 | 280 | 281 | class StateDefinition(six.with_metaclass(StateDefinitionMeta, object)): 282 | """ 283 | Base class for a state definition 284 | """ 285 | 286 | #: Is this the initial state? Not initial by default. The machine should 287 | # define at least one state where ``initial=True`` 288 | initial = False 289 | 290 | def handler(cls, instance): 291 | """ 292 | Override this method if some specific actions need 293 | to be executed *after arriving* in this state. 294 | """ 295 | pass 296 | 297 | @classmethod 298 | def get_name(cls): 299 | """ 300 | The name of the state is given by its classname 301 | """ 302 | return cls.__name__ 303 | 304 | 305 | class StateGroup(six.with_metaclass(StateGroupMeta, object)): 306 | """ 307 | Base class for a state groups 308 | """ 309 | 310 | #: Description for this state group 311 | description = '' 312 | 313 | @classmethod 314 | def get_name(cls): 315 | """ 316 | The name of the state group is given by its classname 317 | """ 318 | return cls.__name__ 319 | 320 | 321 | class StateTransition(six.with_metaclass(StateTransitionMeta, object)): 322 | """ 323 | Base class for a state transitions 324 | """ 325 | 326 | #: When a transition has been defined as public, is meant to be seen 327 | #: by the end-user. 328 | public = False 329 | 330 | def has_permission(cls, instance, user): 331 | """ 332 | Check whether this user is allowed to execute this state transition on 333 | this object. You can override this function for every StateTransition. 334 | """ 335 | return user.is_superuser 336 | # By default, only superusers are allowed to execute this transition. 337 | # Note that this is the only permission checking for the POST views. 338 | 339 | def validate(cls, instance): 340 | """ 341 | Validates whether this object is valid to make this state transition. 342 | 343 | Yields a list of 344 | :class:`~django_states.exceptions.TransitionValidationError`. You can 345 | override this function for every StateTransition. 346 | """ 347 | if False: 348 | yield TransitionValidationError('Example error') # pragma: no cover 349 | # Don't use the 'raise'-statement in here, just yield all the errors. 350 | # yield TransitionValidationError("This object needs ....") 351 | # yield TransitionValidationError("Another error ....") 352 | 353 | def handler(cls, instance, user): 354 | """ 355 | Override this method if some specific actions need 356 | to be executed during this state transition. 357 | """ 358 | pass 359 | 360 | @classmethod 361 | def get_name(cls): 362 | """ 363 | The name of the state transition is always given by its classname 364 | """ 365 | return cls.__name__ 366 | 367 | @property 368 | def handler_kwargs(self): 369 | return self.handler.__code__.co_varnames[3:] 370 | -------------------------------------------------------------------------------- /django_states/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikingco/django-states2/4205506194cf998ffc4b298020478727e17cce0e/django_states/management/__init__.py -------------------------------------------------------------------------------- /django_states/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikingco/django-states2/4205506194cf998ffc4b298020478727e17cce0e/django_states/management/commands/__init__.py -------------------------------------------------------------------------------- /django_states/management/commands/graph_states2.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import logging 3 | import os 4 | from optparse import make_option 5 | from yapgvb import Graph 6 | 7 | from django.conf import settings 8 | from django.core.management.base import BaseCommand, CommandError 9 | from django.db.models import get_model 10 | import six 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Command(BaseCommand): 16 | help = '''Generates a graph of available state machines''' 17 | option_list = BaseCommand.option_list + ( 18 | make_option('--layout', '-l', action='store', dest='layout', default='dot', 19 | help='Layout to be used by GraphViz for visualization. Layouts: circo dot fdp neato twopi'), 20 | make_option('--format', '-f', action='store', dest='format', default='pdf', 21 | help='Format of the output file. Formats: pdf, jpg, png'), 22 | make_option('--create-dot', action='store_true', dest='create_dot', default=False, 23 | help='Create a dot file'), 24 | ) 25 | args = '[model_label.field]' 26 | label = 'model name, i.e. mvno.subscription.state' 27 | 28 | def handle(self, *args, **options): 29 | if len(args) < 1: 30 | raise CommandError('need one or more arguments for model_name.field') 31 | 32 | for model_label in args: 33 | self.render_for_model(model_label, **options) 34 | 35 | def render_for_model(self, model_label, **options): 36 | app_label,model,field = model_label.split('.') 37 | try: 38 | Model = get_model(app_label, model) 39 | except LookupError: 40 | Model = None 41 | STATE_MACHINE = getattr(Model(), 'get_%s_machine' % field)() 42 | 43 | name = six.text_type(Model._meta.verbose_name) 44 | g = Graph('state_machine_graph_%s' % model_label, False) 45 | g.label = 'State Machine Graph %s' % name 46 | nodes = {} 47 | edges = {} 48 | 49 | for state in STATE_MACHINE.states: 50 | nodes[state] = g.add_node(state, 51 | label=state.upper(), 52 | shape='rect', 53 | fontname='Arial') 54 | logger.debug('Created node for %s', state) 55 | 56 | def find(f, a): 57 | for i in a: 58 | if f(i): return i 59 | return None 60 | 61 | for trion_name,trion in six.iteritems(STATE_MACHINE.transitions): 62 | for from_state in trion.from_states: 63 | edge = g.add_edge(nodes[from_state], nodes[trion.to_state]) 64 | edge.dir = 'forward' 65 | edge.arrowhead = 'normal' 66 | edge.label = '\n_'.join(trion.get_name().split('_')) 67 | edge.fontsize = 8 68 | edge.fontname = 'Arial' 69 | 70 | if getattr(trion, 'confirm_needed', False): 71 | edge.style = 'dotted' 72 | edges[u'%s-->%s' % (from_state, trion.to_state)] = edge 73 | logger.debug('Created %d edges for %s', len(trion.from_states), trion.get_name()) 74 | 75 | #if trion.next_function_name is not None: 76 | # tr = find(lambda t: t.function_name == trion.next_function_name and t.from_state == trion.to_state, STATE_MACHINE.trions) 77 | # while tr.next_function_name is not None: 78 | # tr = find(lambda t: t.function_name == tr.next_function_name and t.from_state == tr.to_state, STATE_MACHINE.trions) 79 | 80 | # if tr is not None: 81 | # meta_edge = g.add_edge(nodes[trion.from_state], nodes[tr.to_state]) 82 | # meta_edge.arrowhead = 'empty' 83 | # meta_edge.label = '\n_'.join(trion.function_name.split('_')) + '\n(compound)' 84 | # meta_edge.fontsize = 8 85 | # meta_edge.fontname = 'Arial' 86 | # meta_edge.color = 'blue' 87 | 88 | #if any(lambda t: (t.next_function_name == trion.function_name), STATE_MACHINE.trions): 89 | # edge.color = 'red' 90 | # edge.style = 'dashed' 91 | # edge.label += '\n(auto)' 92 | logger.info('Creating state graph for %s with %d nodes and %d edges' % (name, len(nodes), len(edges))) 93 | 94 | loc = 'state_machine_%s' % (model_label,) 95 | if options['create_dot']: 96 | g.write('%s.dot' % loc) 97 | 98 | logger.debug('Setting layout %s' % options['layout']) 99 | g.layout(options['layout']) 100 | format = options['format'] 101 | logger.debug('Trying to render %s' % loc) 102 | g.render(loc + '.' + format, format, None) 103 | logger.info('Created state graph for %s at %s' % (name, loc)) 104 | -------------------------------------------------------------------------------- /django_states/model_methods.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Model Methods""" 3 | from __future__ import absolute_import 4 | 5 | import json 6 | 7 | from django_states.exceptions import PermissionDenied, TransitionCannotStart, \ 8 | TransitionException, TransitionNotValidated, UnknownTransition 9 | from django_states.machine import StateMachineMeta 10 | from django_states.signals import before_state_execute, after_state_execute 11 | 12 | 13 | def get_STATE_transitions(self, field='state'): 14 | """ 15 | Returns state transitions logs. 16 | 17 | :param str field: the name of the :class:`~django_states.fields.StateField` 18 | """ 19 | if getattr(self, '_%s_log_model' % field, None): 20 | LogModel = getattr(self, '_%s_log_model' % field, None) 21 | return LogModel.objects.filter(on=self) 22 | else: 23 | raise Exception('This model does not log state transitions. ' 24 | 'Please enable it by setting log_transitions=True') 25 | 26 | 27 | def get_public_STATE_transitions(self, field='state'): 28 | """ 29 | Returns the transitions which are meant to be seen by the customer. 30 | The admin on the other hand should be able to see everything. 31 | 32 | :param str field: the name of the :class:`~django_states.fields.StateField` 33 | """ 34 | if getattr(self, '_%s_log_model' % field, None): 35 | transitions = getattr(self, 'get_%s_transitions' % field) 36 | return [t for t in transitions() if t.is_public and t.completed] 37 | else: 38 | return [] 39 | 40 | 41 | def get_STATE_machine(self, field='state', machine=None): 42 | """ 43 | Gets the machine 44 | 45 | :param str field: the name of the :class:`~django_states.fields.StateField` 46 | :param django_states.machine.StateMachine machine: the state machine, default 47 | ``None`` 48 | """ 49 | return machine 50 | 51 | 52 | def get_STATE_display(self, field='state', machine=None): 53 | """ 54 | Gets the description of the current state from the machine 55 | """ 56 | 57 | if machine is None: 58 | return None 59 | assert isinstance(machine, StateMachineMeta), "Machine must be a valid StateMachine" 60 | 61 | si = machine.get_state(getattr(self, field)) 62 | return si.description 63 | 64 | 65 | def get_STATE_info(self, field='state', machine=None): 66 | """ 67 | Gets the state definition from the machine 68 | 69 | :param str field: the name of the :class:`~django_states.fields.StateField` 70 | :param django_states.machine.StateMachine machine: the state machine, default 71 | ``None`` 72 | """ 73 | if machine is None: 74 | return None 75 | assert isinstance(machine, StateMachineMeta), "Machine must be a valid StateMachine" 76 | 77 | class state_info(object): 78 | """ 79 | An extra object that hijacks the actual state methods. 80 | """ 81 | @property 82 | def name(si_self): 83 | """ 84 | The name of the current state 85 | """ 86 | return getattr(self, field) 87 | 88 | @property 89 | def description(si_self): 90 | """ 91 | The description of the current state 92 | """ 93 | si = machine.get_state(getattr(self, field)) 94 | return si.description 95 | 96 | @property 97 | def in_group(si_self): 98 | """ 99 | In what groups is this state? It's a dictionary that will return 100 | ``True`` for the state groups that this state is in. 101 | """ 102 | return machine.get_state_groups(getattr(self, field)) 103 | 104 | @property 105 | def initial(si_self): 106 | return self.state == machine.initial_state 107 | 108 | @property 109 | def possible_transitions(si_self): 110 | """ 111 | Return list of transitions which can be made from the current 112 | state. 113 | """ 114 | for name in machine.transitions: 115 | t = machine.transitions[name] 116 | if getattr(self, field) in t.from_states: 117 | yield t 118 | 119 | def test_transition(si_self, transition, user=None): 120 | """ 121 | Check whether we could execute this transition. 122 | 123 | :param str transition: the transition name 124 | :param user: the user that will execute the transition. Used for 125 | permission checking 126 | :type: :class:`django.contrib.auth.models.User` or ``None`` 127 | 128 | :returns:``True`` when we expect this transition to be executed 129 | successfully. It will raise an ``Exception`` when this 130 | transition is impossible or not allowed. 131 | """ 132 | # Transition name should be known 133 | if not machine.has_transition(transition): 134 | raise UnknownTransition(self, transition) 135 | 136 | t = machine.get_transitions(transition) 137 | 138 | if getattr(self, field) not in t.from_states: 139 | raise TransitionCannotStart(self, transition) 140 | 141 | # User should have permissions for this transition 142 | if user and not t.has_permission(self, user): 143 | raise PermissionDenied(self, transition, user) 144 | 145 | # Transition should validate 146 | validation_errors = list(t.validate(self)) 147 | if validation_errors: 148 | raise TransitionNotValidated(si_self, transition, validation_errors) 149 | 150 | return True 151 | 152 | def make_transition(si_self, transition, user=None, **kwargs): 153 | """ 154 | Executes state transition. 155 | 156 | :param str transition: the transition name 157 | :param user: the user that will execute the transition. Used for 158 | permission checking 159 | :type: :class:`django.contrib.auth.models.User` or ``None`` 160 | :param dict kwargs: the kwargs that will be passed to 161 | :meth:`~django_states.machine.StateTransition.handler` 162 | """ 163 | # Transition name should be known 164 | if not machine.has_transition(transition): 165 | raise UnknownTransition(self, transition) 166 | t = machine.get_transitions(transition) 167 | 168 | _state_log_model = getattr(self, '_%s_log_model' % field, None) 169 | 170 | # Start transition log 171 | if _state_log_model: 172 | # Try to serialize kwargs, for the log. Save null 173 | # when it's not serializable. 174 | try: 175 | serialized_kwargs = json.dumps(kwargs) 176 | except TypeError: 177 | serialized_kwargs = json.dumps(None) 178 | 179 | transition_log = _state_log_model.objects.create( 180 | on=self, from_state=getattr(self, field), to_state=t.to_state, 181 | user=user, serialized_kwargs=serialized_kwargs) 182 | 183 | # Test transition (access/execution validation) 184 | try: 185 | si_self.test_transition(transition, user) 186 | except TransitionException as e: 187 | if _state_log_model: 188 | transition_log.make_transition('fail') 189 | raise e 190 | 191 | # Execute 192 | if _state_log_model: 193 | transition_log.make_transition('start') 194 | 195 | try: 196 | from_state = getattr(self, field) 197 | 198 | before_state_execute.send(sender=self, 199 | from_state=from_state, 200 | to_state=t.to_state) 201 | # First call handler (handler should still see the original 202 | # state.) 203 | t.handler(self, user, **kwargs) 204 | 205 | # Then set new state and save. 206 | setattr(self, field, t.to_state) 207 | self.save() 208 | after_state_execute.send(sender=self, 209 | from_state=from_state, 210 | to_state=t.to_state) 211 | except Exception as e: 212 | if _state_log_model: 213 | transition_log.make_transition('fail') 214 | 215 | raise 216 | else: 217 | if _state_log_model: 218 | transition_log.make_transition('complete') 219 | 220 | # *After completion*, call the handler of this state 221 | # definition 222 | machine.get_state(t.to_state).handler(self) 223 | 224 | return state_info() 225 | -------------------------------------------------------------------------------- /django_states/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Models""" 3 | from __future__ import absolute_import 4 | import six 5 | 6 | # Author: Jonathan Slenders, CityLive 7 | 8 | __doc__ = \ 9 | """ 10 | 11 | Base models for every State. 12 | 13 | """ 14 | 15 | 16 | __all__ = ('StateMachine', 'StateDefinition', 'StateTransition', 'StateModel') 17 | 18 | from django.db import models 19 | from django.db.models.base import ModelBase 20 | from django.utils.encoding import python_2_unicode_compatible 21 | from django.utils.translation import ugettext_lazy as _ 22 | 23 | from django_states.machine import StateMachine, StateDefinition, StateTransition 24 | from django_states.exceptions import States2Exception 25 | from django_states.fields import StateField 26 | 27 | 28 | # =======================[ State ]===================== 29 | class StateModelBase(ModelBase): 30 | """ 31 | Metaclass for State models. 32 | 33 | This metaclass will initiate a logging model as well, if required. 34 | """ 35 | def __new__(cls, name, bases, attrs): 36 | """ 37 | Instantiation of the State type. 38 | 39 | When this type is created, also create a logging model if required. 40 | """ 41 | if name != 'StateModel' and 'Machine' in attrs: 42 | attrs['state'] = StateField(max_length=100, default='0', 43 | verbose_name=_('state id'), 44 | machine=attrs['Machine']) 45 | 46 | # Wrap __unicode__ for state model 47 | if '__unicode__' in attrs: 48 | old_unicode = attrs['__unicode__'] 49 | 50 | def new_unicode(self): 51 | return '%s (%s)' % (old_unicode(self), self.Machine.get_state(self.state).description) 52 | attrs['__unicode__'] = new_unicode 53 | 54 | # Call class constructor of parent 55 | return ModelBase.__new__(cls, name, bases, attrs) 56 | 57 | 58 | @python_2_unicode_compatible 59 | class StateModel(six.with_metaclass(StateModelBase, models.Model)): 60 | """ 61 | Every model which needs state can inherit this abstract model. 62 | 63 | This will dynamically add a :class:`~django_states.fields.StateField` named 64 | ``state``. 65 | """ 66 | 67 | class Machine(StateMachine): 68 | """ 69 | Example machine definition. 70 | 71 | State machines should override this by creating a new machine, 72 | inherited directly from :class:`~django_states.machine.StateMachine`. 73 | """ 74 | #: True when we should log all transitions 75 | log_transitions = False 76 | 77 | # Definition of states (mapping from state_slug to description) 78 | class initial(StateDefinition): 79 | initial = True 80 | description = _('Initial state') 81 | 82 | # Possible transitions, and their names 83 | class dummy(StateTransition): 84 | from_state = 'initial' 85 | to_state = 'initial' 86 | description = _('Make dummy state transition') 87 | 88 | class Meta: 89 | abstract = True 90 | 91 | def __str__(self): 92 | return 'State: ' + self.state 93 | 94 | @property 95 | def state_transitions(self): 96 | """ 97 | Wraps :meth:`django_states.model_methods.get_STATE_transitions` 98 | """ 99 | return self.get_state_transitions() 100 | 101 | @property 102 | def public_transitions(self): 103 | """ 104 | Wraps :meth:`django_states.model_methods.get_public_STATE_transitions` 105 | """ 106 | return self.get_public_state_transitions() 107 | 108 | @property 109 | def state_description(self): 110 | """ 111 | Gets the full description of the (current) state 112 | """ 113 | return six.text_type(self.get_state_info().description) 114 | 115 | @property 116 | def is_initial_state(self): 117 | """ 118 | Gets whether this is the initial state. 119 | 120 | :returns: ``True`` when the current state is the initial state 121 | """ 122 | return bool(self.get_state_info().initial) 123 | 124 | @property 125 | def possible_transitions(self): 126 | """ 127 | Gets the list of transitions which can be made from the current state. 128 | 129 | :returns: list of transitions which can be made from the current state 130 | """ 131 | return self.get_state_info().possible_transitions 132 | 133 | @classmethod 134 | def get_state_model_name(self): 135 | """ 136 | Gets the state model 137 | """ 138 | return '%s.%s' % (self._meta.app_label, self._meta.object_name) 139 | 140 | def can_make_transition(self, transition, user=None): 141 | """ 142 | Gets whether we can make the transition. 143 | 144 | :param str transition: the transition name 145 | :param user: the user that will execute the transition. Used for 146 | permission checking 147 | :type: :class:`django.contrib.auth.models.User` or ``None`` 148 | 149 | :returns: ``True`` when we should be able to make this transition 150 | """ 151 | try: 152 | return self.test_transition(transition, user) 153 | except States2Exception: 154 | return False 155 | 156 | def test_transition(self, transition, user=None): 157 | """ 158 | Check whether we could execute this transition. 159 | 160 | :param str transition: the transition name 161 | :param user: the user that will execute the transition. Used for 162 | permission checking 163 | :type: :class:`django.contrib.auth.models.User` or ``None`` 164 | 165 | :returns:``True`` when we expect this transition to be executed 166 | succesfully. It will raise an ``Exception`` when this 167 | transition is impossible or not allowed. 168 | """ 169 | return self.get_state_info().test_transition(transition, user=user) 170 | 171 | def make_transition(self, transition, user=None, **kwargs): 172 | """ 173 | Executes state transition. 174 | 175 | :param str transition: the transition name 176 | :param user: the user that will execute the transition. Used for 177 | permission checking 178 | :type: :class:`django.contrib.auth.models.User` or ``None`` 179 | :param dict kwargs: the kwargs that will be passed to 180 | :meth:`~django_states.machine.StateTransition.handler` 181 | """ 182 | return self.get_state_info().make_transition(transition, user=user, **kwargs) 183 | 184 | @classmethod 185 | def get_state_choices(cls): 186 | return cls.Machine.get_state_choices() 187 | -------------------------------------------------------------------------------- /django_states/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Signals""" 3 | from __future__ import absolute_import 4 | 5 | import django.dispatch 6 | 7 | #: Signal that is sent before a state transition is executed 8 | before_state_execute = django.dispatch.Signal(providing_args=['from_state', 9 | 'to_state']) 10 | #: Signal that s sent after a state transition is executed 11 | after_state_execute = django.dispatch.Signal(providing_args=['from_state', 12 | 'to_state']) 13 | -------------------------------------------------------------------------------- /django_states/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikingco/django-states2/4205506194cf998ffc4b298020478727e17cce0e/django_states/templatetags/__init__.py -------------------------------------------------------------------------------- /django_states/templatetags/django_states.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from django.template import Node, NodeList, Variable 3 | from django.template import TemplateSyntaxError, VariableDoesNotExist 4 | from django.template import Library 5 | 6 | register = Library() 7 | 8 | 9 | class CanMakeTransitionNode(Node): 10 | def __init__(self, object, transition_name, nodelist): 11 | self.object = object 12 | self.transition_name = transition_name 13 | self.nodelist = nodelist 14 | 15 | def render(self, context): 16 | object = Variable(self.object).resolve(context) 17 | transition_name = Variable(self.transition_name).resolve(context) 18 | user = Variable('request.user').resolve(context) 19 | 20 | if user and object.can_make_transition(transition_name, user): 21 | return self.nodelist.render(context) 22 | else: 23 | return '' 24 | 25 | 26 | @register.tag 27 | def can_make_transition(parser, token): 28 | """ 29 | Conditional tag to validate whether it's possible to make a state 30 | transition (and the user is allowed to make the transition) 31 | 32 | Usage:: 33 | 34 | {% can_make_transition object transition_name %} 35 | ... 36 | {% end_can_make_transition %} 37 | """ 38 | # Parameters 39 | args = token.split_contents() 40 | 41 | # Read nodelist 42 | nodelist = parser.parse(('endcan_make_transition', )) 43 | parser.delete_first_token() 44 | 45 | # Return meta node 46 | return CanMakeTransitionNode(args[1], args[2], nodelist) 47 | -------------------------------------------------------------------------------- /django_states/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests""" 3 | from __future__ import absolute_import 4 | from django.contrib.auth.models import User 5 | from django.db import models 6 | from django.test import TransactionTestCase 7 | 8 | from django_states.exceptions import (PermissionDenied, TransitionNotFound, 9 | UnknownState, UnknownTransition) 10 | from django_states.fields import StateField 11 | from django_states.machine import (StateDefinition, StateGroup, StateMachine, 12 | StateTransition) 13 | from django_states.models import StateModel 14 | 15 | 16 | class TestMachine(StateMachine): 17 | """A basic state machine""" 18 | log_transitions = False 19 | 20 | # States 21 | class start(StateDefinition): 22 | """Start""" 23 | description = "Starting State." 24 | initial = True 25 | 26 | class step_1(StateDefinition): 27 | """Normal State""" 28 | description = "Normal State" 29 | 30 | class step_2_fail(StateDefinition): 31 | """Failure State""" 32 | description = "Failure State" 33 | 34 | class step_3(StateDefinition): 35 | """Completed""" 36 | description = "Completed" 37 | 38 | # Transitions 39 | class start_step_1(StateTransition): 40 | """Transition from start to normal""" 41 | from_state = 'start' 42 | to_state = 'step_1' 43 | description = "Transition from start to normal" 44 | 45 | class step_1_step_2_fail(StateTransition): 46 | """Transition from normal to failure""" 47 | from_state = 'step_1' 48 | to_state = 'step_2_fail' 49 | description = "Transition from normal to failure" 50 | 51 | class step_1_step_3(StateTransition): 52 | """Transition from normal to complete""" 53 | from_state = 'step_1' 54 | to_state = 'step_3' 55 | description = "Transition from normal to complete" 56 | 57 | class step_2_fail_step_1(StateTransition): 58 | """Transition from failure back to normal""" 59 | from_state = 'step_2_fail' 60 | to_state = 'step_1' 61 | description = "Transition from failure back to normal" 62 | 63 | """ 64 | GROUPS 65 | """ 66 | class states_valid_start(StateGroup): 67 | # Valid initial states 68 | states = ['start', 'step_1'] 69 | 70 | class states_error(StateGroup): 71 | # Error states 72 | states = ['step_2_fail'] 73 | 74 | 75 | class TestLogMachine(StateMachine): 76 | """Same as above but this one logs""" 77 | log_transitions = True 78 | 79 | # States 80 | class start(StateDefinition): 81 | """Start""" 82 | description = "Starting State." 83 | initial = True 84 | 85 | class first_step(StateDefinition): 86 | """Normal State""" 87 | description = "Normal State" 88 | 89 | class final_step(StateDefinition): 90 | """Completed""" 91 | description = "Completed" 92 | 93 | # Transitions 94 | class start_step_1(StateTransition): 95 | """Transition from start to normal""" 96 | from_state = 'start' 97 | to_state = 'first_step' 98 | description = "Transition from start to normal" 99 | public = True 100 | 101 | class step_1_final_step(StateTransition): 102 | """Transition from normal to complete""" 103 | from_state = 'first_step' 104 | to_state = 'final_step' 105 | description = "Transition from normal to complete" 106 | public = True 107 | 108 | # ----- Django Test Models ------ 109 | 110 | 111 | class DjangoStateClass(StateModel): 112 | """Django Test Model implementing a State Machine: DEPRECATED""" 113 | field1 = models.IntegerField() 114 | field2 = models.CharField(max_length=25) 115 | Machine = TestMachine 116 | 117 | 118 | class DjangoState2Class(models.Model): 119 | """Django Test Model implementing a State Machine used since django-states2""" 120 | field1 = models.IntegerField() 121 | field2 = models.CharField(max_length=25) 122 | 123 | state = StateField(machine=TestMachine) 124 | 125 | 126 | class DjangoStateLogClass(models.Model): 127 | """Django Test Model implementing a Logging State Machine""" 128 | field1 = models.IntegerField() 129 | field2 = models.CharField(max_length=25) 130 | 131 | state = StateField(machine=TestLogMachine) 132 | 133 | # ---- Tests ---- 134 | 135 | 136 | class StateMachineTestCase(TransactionTestCase): 137 | 138 | def test_initial_states(self): 139 | with self.assertRaises(Exception): 140 | class T1Machine(StateMachine): 141 | class start(StateDefinition): 142 | description = 'start state' 143 | initial = True 144 | 145 | class running(StateDefinition): 146 | description = 'running state' 147 | initial = True 148 | 149 | with self.assertRaises(Exception): 150 | class T1Machine(StateMachine): 151 | class start(StateDefinition): 152 | description = 'start state' 153 | 154 | class running(StateDefinition): 155 | description = 'running state' 156 | 157 | with self.assertRaises(Exception): 158 | class T1Machine(StateMachine): 159 | class START(StateDefinition): 160 | description = 'start state' 161 | initial = True 162 | 163 | with self.assertRaises(Exception): 164 | class T1Machine(StateMachine): 165 | class start(StateDefinition): 166 | initial = True 167 | 168 | with self.assertRaises(Exception): 169 | class T1Machine(StateMachine): 170 | class start(StateDefinition): 171 | description = 'start state' 172 | initial = True 173 | 174 | class running(StateDefinition): 175 | description = 'running state' 176 | 177 | class not_runing(StateGroup): 178 | pass 179 | 180 | with self.assertRaises(Exception): 181 | class T1Machine(StateMachine): 182 | class start(StateDefinition): 183 | description = 'start state' 184 | initial = True 185 | 186 | class running(StateDefinition): 187 | description = 'running state' 188 | 189 | class not_runing(StateGroup): 190 | states = ['start'] 191 | exclude_states = ['running'] 192 | 193 | with self.assertRaises(Exception): 194 | class T1Machine(StateMachine): 195 | class start(StateDefinition): 196 | description = 'start state' 197 | initial = True 198 | 199 | class running(StateDefinition): 200 | description = 'running state' 201 | 202 | class not_runing(StateGroup): 203 | states = 'start' 204 | 205 | with self.assertRaises(Exception): 206 | class T1Machine(StateMachine): 207 | class start(StateDefinition): 208 | description = 'start state' 209 | initial = True 210 | 211 | class running(StateDefinition): 212 | description = 'running state' 213 | 214 | class not_runing(StateGroup): 215 | exclude_states = 'running' 216 | 217 | with self.assertRaises(Exception): 218 | class T1Machine(StateMachine): 219 | class start(StateDefinition): 220 | description = 'start state' 221 | initial = True 222 | 223 | class running(StateDefinition): 224 | description = 'running state' 225 | 226 | class startup(StateTransition): 227 | '''Transition from stopped to running''' 228 | to_state = 'running' 229 | description = 'Start up the machine!' 230 | 231 | with self.assertRaises(Exception): 232 | class T1Machine(StateMachine): 233 | class start(StateDefinition): 234 | description = 'start state' 235 | initial = True 236 | 237 | class running(StateDefinition): 238 | description = 'running state' 239 | 240 | class startup(StateTransition): 241 | '''Transition from stopped to running''' 242 | from_state = 'start' 243 | from_states = ['start'] 244 | to_state = 'running' 245 | description = 'Start up the machine!' 246 | 247 | with self.assertRaises(Exception): 248 | class T1Machine(StateMachine): 249 | class start(StateDefinition): 250 | description = 'start state' 251 | initial = True 252 | 253 | class running(StateDefinition): 254 | description = 'running state' 255 | 256 | class startup(StateTransition): 257 | '''Transition from stopped to running''' 258 | from_state = 'start' 259 | description = 'Start up the machine!' 260 | 261 | with self.assertRaises(Exception): 262 | class T1Machine(StateMachine): 263 | class start(StateDefinition): 264 | description = 'start state' 265 | initial = True 266 | 267 | class running(StateDefinition): 268 | description = 'running state' 269 | 270 | class startup(StateTransition): 271 | '''Transition from stopped to running''' 272 | from_state = 'start' 273 | to_state = 'running' 274 | 275 | with self.assertRaises(Exception): 276 | class T1Machine(StateMachine): 277 | class start(StateDefinition): 278 | description = 'start state' 279 | initial = True 280 | 281 | class running(StateDefinition): 282 | description = 'running state' 283 | 284 | def handler(self): 285 | pass 286 | 287 | with self.assertRaises(Exception): 288 | class T1Machine(StateMachine): 289 | class start(StateDefinition): 290 | description = 'start state' 291 | initial = True 292 | 293 | class running(StateDefinition): 294 | description = 'running state' 295 | 296 | class startup(StateTransition): 297 | '''Transition from stopped to running''' 298 | from_state = 'start' 299 | to_state = 'running' 300 | description = 'Start your engines!' 301 | 302 | def handler(self, instance): 303 | pass 304 | 305 | def test_machine_functions(self): 306 | class T3Machine(StateMachine): 307 | class stopped(StateDefinition): 308 | description = 'stopped state' 309 | initial = True 310 | 311 | class running(StateDefinition): 312 | description = 'running state' 313 | 314 | class crashed(StateDefinition): 315 | description = 'crashed state' 316 | 317 | def handler(self, instance): 318 | pass 319 | 320 | class startup(StateTransition): 321 | '''Transition from stopped to running''' 322 | from_state = 'stopped' 323 | to_state = 'running' 324 | description = 'Start up the machine!' 325 | 326 | class working(StateGroup): 327 | states = ['running'] 328 | 329 | class not_runing(StateGroup): 330 | exclude_states = ['running'] 331 | 332 | self.assertTrue(T3Machine.has_state('stopped')) 333 | stopped = T3Machine.get_state('stopped') 334 | self.assertTrue(stopped.initial) 335 | self.assertFalse(T3Machine.has_state('died')) 336 | with self.assertRaises(UnknownState): 337 | T3Machine.get_state('died') 338 | 339 | self.assertTrue(T3Machine.get_state_groups('stopped')['not_runing']) 340 | groups = T3Machine.get_state_groups('running') 341 | self.assertFalse(groups['not_runing']) 342 | self.assertTrue(groups['working']) 343 | 344 | T3Machine.get_transition_from_states('stopped', 'running') 345 | with self.assertRaises(TransitionNotFound): 346 | T3Machine.get_transition_from_states('running', 'crashed') 347 | self.assertTrue(T3Machine.has_transition('startup')) 348 | self.assertFalse(T3Machine.has_transition('crash')) 349 | trion = T3Machine.get_transitions('startup') 350 | self.assertFalse(hasattr(trion, 'from_state')) 351 | self.assertEqual(trion.from_states[0], 'stopped') 352 | self.assertEqual(trion.to_state, 'running') 353 | with self.assertRaises(KeyError): 354 | T3Machine.get_transitions('crash') 355 | # Admin actions 356 | actions = T3Machine.get_admin_actions() 357 | self.assertEqual(len(actions), 1) 358 | action = actions[0] 359 | self.assertEqual(action.__name__, 'state_transition_startup') 360 | self.assertTrue('stopped' in action.short_description) 361 | self.assertTrue('running' in action.short_description) 362 | self.assertTrue('Start up the machine!' in action.short_description) 363 | 364 | 365 | class StateFieldTestCase(TransactionTestCase): 366 | """This will test out the non-logging side of things""" 367 | 368 | def setUp(self): 369 | self.superuser = User.objects.create_superuser( 370 | username='super', email="super@h.us", password="pass") 371 | 372 | def test_initial_state(self): 373 | """Full end to end test""" 374 | testclass = DjangoState2Class(field1=100, field2="LALALALALA") 375 | testclass.save() 376 | 377 | self.assertEqual(testclass.get_state_machine(), TestMachine) 378 | self.assertEqual(testclass.get_state_display(), 'Starting State.') 379 | 380 | state_info = testclass.get_state_info() 381 | 382 | self.assertEqual(testclass.state, 'start') 383 | self.assertTrue(state_info.initial) 384 | state_info.make_transition('start_step_1', user=self.superuser) 385 | self.assertFalse(state_info.initial) 386 | 387 | def test_end_to_end(self): 388 | """Full end to end test""" 389 | testclass = DjangoState2Class(field1=100, field2="LALALALALA") 390 | testclass.save() 391 | 392 | state_info = testclass.get_state_info() 393 | 394 | # Verify the starting state. 395 | self.assertEqual(testclass.state, 'start') 396 | self.assertEqual(state_info.name, testclass.state) 397 | self.assertEqual(state_info.description, 'Starting State.') 398 | possible = set([x.get_name() for x in state_info.possible_transitions]) 399 | self.assertEqual(possible, {'start_step_1'}) 400 | # Shift to the first state 401 | state_info.make_transition('start_step_1', user=self.superuser) 402 | self.assertEqual(state_info.name, 'step_1') 403 | self.assertEqual(state_info.description, 'Normal State') 404 | possible = set([x.get_name() for x in state_info.possible_transitions]) 405 | self.assertEqual(possible, {'step_1_step_3', 'step_1_step_2_fail'}) 406 | # Shift to a failure 407 | state_info.make_transition('step_1_step_2_fail', user=self.superuser) 408 | self.assertEqual(state_info.name, 'step_2_fail') 409 | self.assertEqual(state_info.description, 'Failure State') 410 | possible = set([x.get_name() for x in state_info.possible_transitions]) 411 | self.assertEqual(possible, {'step_2_fail_step_1'}) 412 | # Shift to a failure 413 | state_info.make_transition('step_2_fail_step_1', user=self.superuser) 414 | self.assertEqual(state_info.name, 'step_1') 415 | self.assertEqual(state_info.description, 'Normal State') 416 | possible = set([x.get_name() for x in state_info.possible_transitions]) 417 | self.assertEqual(possible, {'step_1_step_3', 'step_1_step_2_fail'}) 418 | # Shift to a completed 419 | state_info.make_transition('step_1_step_3', user=self.superuser) 420 | self.assertEqual(state_info.name, 'step_3') 421 | self.assertEqual(state_info.description, 'Completed') 422 | possible = [x.get_name() for x in state_info.possible_transitions] 423 | self.assertEqual(len(possible), 0) 424 | 425 | def test_invalid_user(self): 426 | """Verify permissions for a user""" 427 | user = User.objects.create( 428 | username='user', email="user@h.us", password="pass") 429 | 430 | testclass = DjangoState2Class(field1=100, field2="LALALALALA") 431 | testclass.save() 432 | 433 | kwargs = {'transition': 'start_step_1', 'user': user} 434 | 435 | state_info = testclass.get_state_info() 436 | 437 | self.assertRaises(PermissionDenied, state_info.make_transition, **kwargs) 438 | 439 | def test_in_group(self): 440 | """Tests in_group functionality""" 441 | testclass = DjangoState2Class(field1=100, field2="LALALALALA") 442 | testclass.save() 443 | 444 | state_info = testclass.get_state_info() 445 | 446 | self.assertTrue(state_info.in_group['states_valid_start']) 447 | state_info.make_transition('start_step_1', user=self.superuser) 448 | self.assertTrue(state_info.in_group['states_valid_start']) 449 | state_info.make_transition('step_1_step_2_fail', user=self.superuser) 450 | self.assertFalse(state_info.in_group['states_valid_start']) 451 | self.assertTrue(state_info.in_group['states_error']) 452 | state_info.make_transition('step_2_fail_step_1', user=self.superuser) 453 | self.assertTrue(state_info.in_group['states_valid_start']) 454 | state_info.make_transition('step_1_step_3', user=self.superuser) 455 | self.assertFalse(state_info.in_group['states_valid_start']) 456 | 457 | def test_unknown_transition(self): 458 | test = DjangoState2Class(field1=100, field2="LALALALALA") 459 | test.save() 460 | 461 | state_info = test.get_state_info() 462 | with self.assertRaises(UnknownTransition): 463 | state_info.make_transition('unknown_transition', user=self.superuser) 464 | 465 | def test_unknown_state(self): 466 | test = DjangoState2Class(field1=100, field2="LALALALALA") 467 | test.save() 468 | 469 | test.state = 'not-existing-state-state' 470 | with self.assertRaises(UnknownState): 471 | test.save(no_state_validation=False) 472 | test.state = 'not-existing-state-state2' 473 | test.save(no_state_validation=True) 474 | test.state = 'not-existing-state-state3' 475 | # TODO: Due to invalid default value of no_state_validation, this won't throw an error 476 | #with self.assertRaises(UnknownState): 477 | # test.save() 478 | 479 | def test_state_save_handler(self): 480 | test = DjangoState2Class(field1=100, field2="LALALALALA") 481 | test.save(no_state_validation=False) 482 | 483 | 484 | class StateModelTestCase(TransactionTestCase): 485 | """This will test out the non-logging side of things""" 486 | 487 | def setUp(self): 488 | self.superuser = User.objects.create_superuser( 489 | username='super', email="super@h.us", password="pass") 490 | 491 | def test_classmethods(self): 492 | self.assertEqual(DjangoStateClass.get_state_model_name(), 493 | 'django_states.DjangoStateClass') 494 | state_choices = DjangoStateClass.get_state_choices() 495 | self.assertEqual(len(state_choices), 4) 496 | self.assertEqual(len(state_choices[0]), 2) 497 | state_choices = dict(state_choices) 498 | self.assertTrue('start' in state_choices) 499 | self.assertEqual(state_choices['start'], 'Starting State.') 500 | 501 | def test_model_end_to_end(self): 502 | test = DjangoStateClass(field1=42, field2="Knock? Knock?") 503 | test.save() 504 | 505 | self.assertEqual(test.state, 'start') 506 | self.assertTrue(test.is_initial_state) 507 | self.assertEqual(test.state_description, "Starting State.") 508 | 509 | self.assertEqual(len(list(test.possible_transitions)), 1) 510 | self.assertEqual(len(list(test.public_transitions)), 0) 511 | with self.assertRaises(Exception): 512 | test.state_transitions 513 | 514 | test.can_make_transition('start_step_1', user=self.superuser) 515 | self.assertTrue(test.is_initial_state) 516 | test.make_transition('start_step_1', user=self.superuser) 517 | self.assertFalse(test.is_initial_state) 518 | 519 | 520 | class StateLogTestCase(TransactionTestCase): 521 | 522 | def setUp(self): 523 | self.superuser = User.objects.create_superuser( 524 | username='super', email="super@h.us", password="pass") 525 | 526 | def test_statelog(self): 527 | test = DjangoStateLogClass(field1=42, field2="Hello world?") 528 | test.save(no_state_validation=False) 529 | 530 | # Verify the starting state. 531 | state_info = test.get_state_info() 532 | self.assertEqual(test.state, 'start') 533 | self.assertEqual(state_info.name, test.state) 534 | # Make transition 535 | state_info.make_transition('start_step_1', user=self.superuser) 536 | 537 | # Test whether log entry was created 538 | StateLogModel = DjangoStateLogClass._state_log_model 539 | self.assertEqual(StateLogModel.objects.count(), 1) 540 | entry = StateLogModel.objects.all()[0] 541 | self.assertTrue(entry.completed) 542 | # We should also be able to find this via 543 | self.assertEqual(test.get_state_transitions().count(), 1) 544 | self.assertEqual(len(test.get_public_state_transitions()), 1) 545 | -------------------------------------------------------------------------------- /django_states/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Urls""" 3 | from __future__ import absolute_import 4 | 5 | from .compat import patterns, url 6 | from django_states.views import make_state_transition 7 | 8 | urlpatterns = patterns('', 9 | url(r'^make-state-transition/$', make_state_transition, name='django_states_make_transition'), 10 | ) 11 | -------------------------------------------------------------------------------- /django_states/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Views""" 3 | from __future__ import absolute_import 4 | 5 | from django.db.models import get_model 6 | from django.http import (HttpResponseRedirect, HttpResponseForbidden, 7 | HttpResponse,) 8 | from django.shortcuts import get_object_or_404 9 | 10 | from django_states.exceptions import PermissionDenied 11 | 12 | 13 | def make_state_transition(request): 14 | """ 15 | View to be called by AJAX code to do state transitions. This must be a 16 | ``POST`` request. 17 | 18 | Required parameters: 19 | 20 | - ``model_name``: the name of the state model, as retured by 21 | ``instance.get_state_model_name``. 22 | - ``action``: the name of the state transition, as given by 23 | ``StateTransition.get_name``. 24 | - ``id``: the ID of the instance on which the state transition is applied. 25 | 26 | When the handler requires additional kwargs, they can be passed through as 27 | optional parameters: ``kwarg-{{ kwargs_name }}`` 28 | """ 29 | if request.method == 'POST': 30 | # Process post parameters 31 | app_label, model_name = request.POST['model_name'].split('.') 32 | try: 33 | model = get_model(app_label, model_name) 34 | except LookupError: 35 | model = None 36 | instance = get_object_or_404(model, id=request.POST['id']) 37 | action = request.POST['action'] 38 | 39 | # Build optional kwargs 40 | kwargs = {} 41 | for p in request.POST: 42 | if p.startswith('kwarg-'): 43 | kwargs[p[len('kwargs-')-1:]] = request.POST[p] 44 | 45 | if not hasattr(instance, 'make_transition'): 46 | raise Exception('No such state model "%s"' % model_name) 47 | 48 | try: 49 | # Make state transition 50 | instance.make_transition(action, request.user, **kwargs) 51 | except PermissionDenied as e: 52 | return HttpResponseForbidden() 53 | else: 54 | # ... Redirect to 'next' 55 | if 'next' in request.POST: 56 | return HttpResponseRedirect(request.POST['next']) 57 | else: 58 | return HttpResponse('OK') 59 | else: 60 | return HttpResponseForbidden() 61 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-states.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-states.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-states" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-states" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-states documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Oct 18 17:22:53 2011. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'django-states' 44 | copyright = u'2011, Jonathan Slenders, Gert Van Gool' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | import django_states 51 | # The short X.Y version, only interested in the number, e.g. 0.9.2 52 | version = django_states.__version__.split(' ')[0] 53 | # The full version, including alpha/beta/rc tags. 54 | release = django_states.__version__ 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | html_theme = 'alabaster' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | #html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | #html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | title_dict = {'project': project, 108 | 'version': version, 109 | 'release': release} 110 | html_title = "%(project)s v%(release)s documentation" % title_dict 111 | 112 | # A shorter title for the navigation bar. Default is the same as html_title. 113 | html_short_title = "%(project)s v%(version)s" % title_dict 114 | 115 | # The name of an image file (relative to this directory) to place at the top 116 | # of the sidebar. 117 | #html_logo = None 118 | 119 | # The name of an image file (within the static path) to use as favicon of the 120 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 121 | # pixels large. 122 | #html_favicon = None 123 | 124 | # Add any paths that contain custom static files (such as style sheets) here, 125 | # relative to this directory. They are copied after the builtin static files, 126 | # so a file named "default.css" will overwrite the builtin "default.css". 127 | html_static_path = ['_static'] 128 | 129 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 130 | # using the given strftime format. 131 | #html_last_updated_fmt = '%b %d, %Y' 132 | 133 | # If true, SmartyPants will be used to convert quotes and dashes to 134 | # typographically correct entities. 135 | #html_use_smartypants = True 136 | 137 | # Custom sidebar templates, maps document names to template names. 138 | #html_sidebars = {} 139 | 140 | # Additional templates that should be rendered to pages, maps page names to 141 | # template names. 142 | #html_additional_pages = {} 143 | 144 | # If false, no module index is generated. 145 | #html_domain_indices = True 146 | 147 | # If false, no index is generated. 148 | #html_use_index = True 149 | 150 | # If true, the index is split into individual pages for each letter. 151 | #html_split_index = False 152 | 153 | # If true, links to the reST sources are added to the pages. 154 | #html_show_sourcelink = True 155 | 156 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 157 | #html_show_sphinx = True 158 | 159 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 160 | #html_show_copyright = True 161 | 162 | # If true, an OpenSearch description file will be output, and all pages will 163 | # contain a tag referring to it. The value of this option must be the 164 | # base URL from which the finished HTML is served. 165 | #html_use_opensearch = '' 166 | 167 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 168 | #html_file_suffix = None 169 | 170 | # Output file base name for HTML help builder. 171 | htmlhelp_basename = 'django-statesdoc' 172 | 173 | 174 | # -- Options for LaTeX output -------------------------------------------------- 175 | 176 | # The paper size ('letter' or 'a4'). 177 | #latex_paper_size = 'letter' 178 | 179 | # The font size ('10pt', '11pt' or '12pt'). 180 | #latex_font_size = '10pt' 181 | 182 | # Grouping the document tree into LaTeX files. List of tuples 183 | # (source start file, target name, title, author, documentclass [howto/manual]). 184 | latex_documents = [ 185 | ('index', 'django-states.tex', u'django-states Documentation', 186 | u'Jonathan Slenders, Gert Van Gool', 'manual'), 187 | ] 188 | 189 | # The name of an image file (relative to this directory) to place at the top of 190 | # the title page. 191 | #latex_logo = None 192 | 193 | # For "manual" documents, if this is true, then toplevel headings are parts, 194 | # not chapters. 195 | #latex_use_parts = False 196 | 197 | # If true, show page references after internal links. 198 | #latex_show_pagerefs = False 199 | 200 | # If true, show URL addresses after external links. 201 | #latex_show_urls = False 202 | 203 | # Additional stuff for the LaTeX preamble. 204 | #latex_preamble = '' 205 | 206 | # Documents to append as an appendix to all manuals. 207 | #latex_appendices = [] 208 | 209 | # If false, no module index is generated. 210 | #latex_domain_indices = True 211 | 212 | 213 | # -- Options for manual page output -------------------------------------------- 214 | 215 | # One entry per manual page. List of tuples 216 | # (source start file, name, description, authors, manual section). 217 | man_pages = [ 218 | ('index', 'django-states', u'django-states Documentation', 219 | [u'Jonathan Slenders, Gert Van Gool'], 1) 220 | ] 221 | 222 | # -- Option for autodoc 223 | autodoc_member_order = 'bysource' 224 | autodoc_default_flags = ['members', 'undoc-members'] 225 | 226 | # Example configuration for intersphinx: refer to the Python standard library. 227 | intersphinx_mapping = {'http://docs.python.org/': None} 228 | -------------------------------------------------------------------------------- /docs/django_states/conf.rst: -------------------------------------------------------------------------------- 1 | ``django_states.conf`` 2 | ====================== 3 | 4 | .. automodule:: django_states.conf 5 | :members: 6 | :undoc-members: 7 | :private-members: 8 | -------------------------------------------------------------------------------- /docs/django_states/exceptions.rst: -------------------------------------------------------------------------------- 1 | ``django_states.exceptions`` 2 | ============================ 3 | 4 | .. automodule:: django_states.exceptions 5 | :members: 6 | :undoc-members: 7 | :private-members: 8 | -------------------------------------------------------------------------------- /docs/django_states/fields.rst: -------------------------------------------------------------------------------- 1 | ``django_states.fields`` 2 | ======================== 3 | 4 | .. automodule:: django_states.fields 5 | :members: 6 | :undoc-members: 7 | :private-members: 8 | -------------------------------------------------------------------------------- /docs/django_states/log.rst: -------------------------------------------------------------------------------- 1 | ``django_states.log`` 2 | ===================== 3 | 4 | .. automodule:: django_states.log 5 | :members: 6 | :undoc-members: 7 | :private-members: 8 | -------------------------------------------------------------------------------- /docs/django_states/machine.rst: -------------------------------------------------------------------------------- 1 | ``django_states.machine`` 2 | ========================= 3 | 4 | .. automodule:: django_states.machine 5 | :members: 6 | :undoc-members: 7 | :private-members: 8 | -------------------------------------------------------------------------------- /docs/django_states/model_methods.rst: -------------------------------------------------------------------------------- 1 | ``django_states.model_methods`` 2 | =============================== 3 | 4 | .. automodule:: django_states.model_methods 5 | :members: 6 | :undoc-members: 7 | :private-members: 8 | -------------------------------------------------------------------------------- /docs/django_states/models.rst: -------------------------------------------------------------------------------- 1 | ``django_states.models`` 2 | ======================== 3 | 4 | .. automodule:: django_states.models 5 | :members: 6 | :undoc-members: 7 | :private-members: 8 | -------------------------------------------------------------------------------- /docs/django_states/templatetags.rst: -------------------------------------------------------------------------------- 1 | ``django_states.templatetags.django_states`` 2 | ============================================ 3 | 4 | .. automodule:: django_states.templatetags.django_states 5 | :members: 6 | :undoc-members: 7 | :private-members: 8 | -------------------------------------------------------------------------------- /docs/django_states/views.rst: -------------------------------------------------------------------------------- 1 | ``django_states.views`` 2 | ======================= 3 | 4 | .. automodule:: django_states.views 5 | :members: 6 | :undoc-members: 7 | :private-members: 8 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-states2 documentation! 2 | ============================= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | readme 8 | changelog 9 | 10 | Internal API 11 | ------------ 12 | .. toctree:: 13 | :maxdepth: 2 14 | 15 | django_states/conf 16 | django_states/exceptions 17 | django_states/fields 18 | django_states/log 19 | django_states/machine 20 | django_states/model_methods 21 | django_states/models 22 | django_states/templatetags 23 | django_states/views 24 | 25 | 26 | Indices and tables 27 | ================== 28 | 29 | * :ref:`genindex` 30 | * :ref:`modindex` 31 | * :ref:`search` 32 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | if errorlevel 1 exit /b 1 46 | echo. 47 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 48 | goto end 49 | ) 50 | 51 | if "%1" == "dirhtml" ( 52 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 53 | if errorlevel 1 exit /b 1 54 | echo. 55 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 56 | goto end 57 | ) 58 | 59 | if "%1" == "singlehtml" ( 60 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 61 | if errorlevel 1 exit /b 1 62 | echo. 63 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 64 | goto end 65 | ) 66 | 67 | if "%1" == "pickle" ( 68 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 69 | if errorlevel 1 exit /b 1 70 | echo. 71 | echo.Build finished; now you can process the pickle files. 72 | goto end 73 | ) 74 | 75 | if "%1" == "json" ( 76 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished; now you can process the JSON files. 80 | goto end 81 | ) 82 | 83 | if "%1" == "htmlhelp" ( 84 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished; now you can run HTML Help Workshop with the ^ 88 | .hhp project file in %BUILDDIR%/htmlhelp. 89 | goto end 90 | ) 91 | 92 | if "%1" == "qthelp" ( 93 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 94 | if errorlevel 1 exit /b 1 95 | echo. 96 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 97 | .qhcp project file in %BUILDDIR%/qthelp, like this: 98 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-states2.qhcp 99 | echo.To view the help file: 100 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-states2.ghc 101 | goto end 102 | ) 103 | 104 | if "%1" == "devhelp" ( 105 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 106 | if errorlevel 1 exit /b 1 107 | echo. 108 | echo.Build finished. 109 | goto end 110 | ) 111 | 112 | if "%1" == "epub" ( 113 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 117 | goto end 118 | ) 119 | 120 | if "%1" == "latex" ( 121 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 122 | if errorlevel 1 exit /b 1 123 | echo. 124 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 125 | goto end 126 | ) 127 | 128 | if "%1" == "text" ( 129 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 130 | if errorlevel 1 exit /b 1 131 | echo. 132 | echo.Build finished. The text files are in %BUILDDIR%/text. 133 | goto end 134 | ) 135 | 136 | if "%1" == "man" ( 137 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 141 | goto end 142 | ) 143 | 144 | if "%1" == "changes" ( 145 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.The overview file is in %BUILDDIR%/changes. 149 | goto end 150 | ) 151 | 152 | if "%1" == "linkcheck" ( 153 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Link check complete; look for any errors in the above output ^ 157 | or in %BUILDDIR%/linkcheck/output.txt. 158 | goto end 159 | ) 160 | 161 | if "%1" == "doctest" ( 162 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 163 | if errorlevel 1 exit /b 1 164 | echo. 165 | echo.Testing of doctests in the sources finished, look at the ^ 166 | results in %BUILDDIR%/doctest/output.txt. 167 | goto end 168 | ) 169 | 170 | :end 171 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os.path 4 | import sys 5 | 6 | from setuptools import setup, find_packages 7 | 8 | sys.path.insert(0, os.path.dirname(__file__)) 9 | 10 | 11 | setup( 12 | name="django-states2", 13 | version='1.6.10', 14 | url='https://github.com/vikingco/django-states2', 15 | license='BSD', 16 | description="State machine for django models", 17 | long_description=open(os.path.join(os.path.dirname(__file__), 'README.rst')).read(), 18 | author='Jonathan Slenders, Gert van Gool, Maarten Timmerman, Steven (rh0dium), Unleashed NV', 19 | author_email='operations@unleashed.be', 20 | packages=find_packages('.', exclude=['test_proj',]), 21 | #package_dir={'': 'templates/*'}, 22 | test_suite='test_proj.runtests.main', 23 | classifiers=[ 24 | 'Intended Audience :: Developers', 25 | 'Programming Language :: Python', 26 | 'Operating System :: OS Independent', 27 | 'Environment :: Web Environment', 28 | 'Framework :: Django', 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /test_proj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikingco/django-states2/4205506194cf998ffc4b298020478727e17cce0e/test_proj/__init__.py -------------------------------------------------------------------------------- /test_proj/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import imp, sys, os 3 | 4 | try: 5 | imp.find_module('settings') # Assumed to be in the same directory. 6 | except ImportError: 7 | import sys 8 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__) 9 | sys.exit(1) 10 | 11 | import settings 12 | 13 | if __name__ == "__main__": 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_proj.settings') 15 | from django.core.management import execute_from_command_line 16 | execute_from_command_line(sys.argv) 17 | -------------------------------------------------------------------------------- /test_proj/runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_proj.settings' 7 | 8 | sys.path.insert(0, os.path.dirname(__file__)) 9 | 10 | import django 11 | from django.conf import settings 12 | from django.test.utils import get_runner 13 | 14 | def main(): 15 | if hasattr(django, 'setup'): 16 | django.setup() 17 | 18 | TestRunner = get_runner(settings) 19 | test_runner = TestRunner(verbosity=1, interactive=False) 20 | failures = test_runner.run_tests(['django_states',]) 21 | sys.exit(1 if failures else 0) 22 | 23 | if __name__ == '__main__': 24 | main() 25 | -------------------------------------------------------------------------------- /test_proj/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for test_proj project. 2 | import os, sys 3 | 4 | DEBUG = True 5 | TEMPLATE_DEBUG = DEBUG 6 | 7 | ADMINS = ( 8 | # ('Your Name', 'your_email@example.com'), 9 | ) 10 | 11 | MANAGERS = ADMINS 12 | 13 | # ROOT = os.path.abspath( 14 | # os.path.join( 15 | # os.path.abspath(os.path.dirname(__file__)), 16 | # '..' 17 | # ) 18 | # ) 19 | # 20 | # path_to = lambda * x: os.path.join(ROOT, *x) 21 | 22 | DATABASES = { 23 | 'default': { 24 | 'ENGINE': 'django.db.backends.sqlite3', 25 | 'NAME': ':memory', 26 | 'USER': '', 27 | 'PASSWORD': '', 28 | 'HOST': '', 29 | 'PORT': '', 30 | } 31 | } 32 | 33 | CACHES = { 34 | 'default': { 35 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 36 | } 37 | } 38 | 39 | # Local time zone for this installation. Choices can be found here: 40 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 41 | # although not all choices may be available on all operating systems. 42 | # On Unix systems, a value of None will cause Django to use the same 43 | # timezone as the operating system. 44 | # If running in a Windows environment this must be set to the same as your 45 | # system time zone. 46 | TIME_ZONE = 'America/Chicago' 47 | 48 | # Language code for this installation. All choices can be found here: 49 | # http://www.i18nguy.com/unicode/language-identifiers.html 50 | LANGUAGE_CODE = 'en-us' 51 | 52 | SITE_ID = 1 53 | 54 | # If you set this to False, Django will make some optimizations so as not 55 | # to load the internationalization machinery. 56 | USE_I18N = True 57 | 58 | # If you set this to False, Django will not format dates, numbers and 59 | # calendars according to the current locale 60 | USE_L10N = True 61 | 62 | # Absolute filesystem path to the directory that will hold user-uploaded files. 63 | # Example: "/home/media/media.lawrence.com/media/" 64 | MEDIA_ROOT = '' 65 | 66 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 67 | # trailing slash. 68 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 69 | MEDIA_URL = '' 70 | 71 | # Absolute path to the directory static files should be collected to. 72 | # Don't put anything in this directory yourself; store your static files 73 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 74 | # Example: "/home/media/media.lawrence.com/static/" 75 | STATIC_ROOT = '' 76 | 77 | # URL prefix for static files. 78 | # Example: "http://media.lawrence.com/static/" 79 | STATIC_URL = '/static/' 80 | 81 | # URL prefix for admin static files -- CSS, JavaScript and images. 82 | # Make sure to use a trailing slash. 83 | # Examples: "http://foo.com/static/admin/", "/static/admin/". 84 | ADMIN_MEDIA_PREFIX = '/static/admin/' 85 | 86 | # Additional locations of static files 87 | STATICFILES_DIRS = ( 88 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 89 | # Always use forward slashes, even on Windows. 90 | # Don't forget to use absolute paths, not relative paths. 91 | ) 92 | 93 | # List of finder classes that know how to find static files in 94 | # various locations. 95 | STATICFILES_FINDERS = ( 96 | 'django.contrib.staticfiles.finders.FileSystemFinder', 97 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 98 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 99 | ) 100 | 101 | # Make this unique, and don't share it with anybody. 102 | SECRET_KEY = '5dtmvd)w%lf8l#!w%gybx^upm0k_&_se-)=0x0ola@(-*&8utn' 103 | 104 | # List of callables that know how to import templates from various sources. 105 | TEMPLATE_LOADERS = ( 106 | 'django.template.loaders.filesystem.Loader', 107 | 'django.template.loaders.app_directories.Loader', 108 | # 'django.template.loaders.eggs.Loader', 109 | ) 110 | 111 | MIDDLEWARE_CLASSES = ( 112 | 'django.middleware.common.CommonMiddleware', 113 | 'django.contrib.sessions.middleware.SessionMiddleware', 114 | 'django.middleware.csrf.CsrfViewMiddleware', 115 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 116 | 'django.contrib.messages.middleware.MessageMiddleware', 117 | ) 118 | 119 | ROOT_URLCONF = 'test_proj.urls' 120 | 121 | TEMPLATE_DIRS = ( 122 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 123 | # Always use forward slashes, even on Windows. 124 | # Don't forget to use absolute paths, not relative paths. 125 | ) 126 | 127 | INSTALLED_APPS = ( 128 | 'django.contrib.auth', 129 | 'django.contrib.contenttypes', 130 | 'django.contrib.sessions', 131 | 'django.contrib.sites', 132 | 'django.contrib.messages', 133 | 'django.contrib.staticfiles', 134 | 'django.contrib.admin', 135 | 'django.contrib.admindocs', 136 | 'django_states' 137 | ) 138 | 139 | # A sample logging configuration. The only tangible logging 140 | # performed by this configuration is to send an email to 141 | # the site admins on every HTTP 500 error. 142 | # See http://docs.djangoproject.com/en/dev/topics/logging for 143 | # more details on how to customize your logging configuration. 144 | LOGGING = { 145 | 'version': 1, 146 | 'disable_existing_loggers': False, 147 | 'handlers': { 148 | 'mail_admins': { 149 | 'level': 'ERROR', 150 | 'class': 'django.utils.log.AdminEmailHandler' 151 | } 152 | }, 153 | 'loggers': { 154 | 'django.request': { 155 | 'handlers': ['mail_admins'], 156 | 'level': 'ERROR', 157 | 'propagate': True, 158 | }, 159 | } 160 | } -------------------------------------------------------------------------------- /test_proj/urls.py: -------------------------------------------------------------------------------- 1 | from .compat import patterns, include, url 2 | 3 | from django.contrib import admin 4 | admin.autodiscover() 5 | 6 | urlpatterns = patterns('', 7 | # Examples: 8 | # url(r'^$', 'test_proj.views.home', name='home'), 9 | # url(r'^test_proj/', include('test_proj.foo.urls')), 10 | 11 | url(r'^admin/doc/', include('django.contrib.admindocs.urls')), 12 | url(r'^admin/', admin.site.urls), 13 | ) 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{27,34}-dj{16,17,18,19} 3 | 4 | [testenv] 5 | commands = ./setup.py test 6 | basepython = 7 | py27: python2.7 8 | py34: python3.4 9 | deps = 10 | six 11 | dj16: django==1.6.11 12 | dj17: django==1.7.10 13 | dj18: django==1.8.4 14 | dj19: django==1.9.1 15 | --------------------------------------------------------------------------------