pax_global_header 0000666 0000000 0000000 00000000064 13456326721 0014523 g ustar 00root root 0000000 0000000 52 comment=0b9f7ec725deb64ba5fe7a0ce56bea6c5ea765e7
django-fsm-2.6.1/ 0000775 0000000 0000000 00000000000 13456326721 0013556 5 ustar 00root root 0000000 0000000 django-fsm-2.6.1/.checkignore 0000664 0000000 0000000 00000000032 13456326721 0016033 0 ustar 00root root 0000000 0000000 tests/*
django_fsm/tests/* django-fsm-2.6.1/.gitignore 0000664 0000000 0000000 00000000056 13456326721 0015547 0 ustar 00root root 0000000 0000000 *.pyc
dist/
build/
django_fsm.egg-info/
.tox/
django-fsm-2.6.1/.pylintrc 0000664 0000000 0000000 00000002400 13456326721 0015417 0 ustar 00root root 0000000 0000000 [MASTER]
persistent=yes
[MESSAGES CONTROL]
# C0111 = Missing docstring
# I0011 = # Warning locally suppressed using disable-msg
# I0012 = # Warning locally suppressed using disable-msg
disable=I0011,I0012
[REPORTS]
output-format=parseable
include-ids=no
[TYPECHECK]
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
ignore-mixin-members=yes
# List of classes names for which member attributes should not be checked
# (useful for classes with attributes dynamically set).
ignored-classes=SQLObject
# When zope mode is activated, add a predefined set of Zope acquired attributes
# to generated-members.
zope=no
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E0201 when accessed.
generated-members=REQUEST,acl_users,aq_parent
[VARIABLES]
init-import=no
[SIMILARITIES]
min-similarity-lines=4
ignore-comments=yes
ignore-docstrings=yes
[MISCELLANEOUS]
notes=FIXME,XXX,TODO
[FORMAT]
max-line-length=160
max-module-lines=500
indent-string=' '
[DESIGN]
max-args=5
max-locals=15
max-returns=6
max-branchs=12
max-statements=50
max-parents=7
max-attributes=7
min-public-methods=0
max-public-methods=20
django-fsm-2.6.1/.travis.yml 0000664 0000000 0000000 00000000262 13456326721 0015667 0 ustar 00root root 0000000 0000000 dist: xenial
language: python
sudo: false
cache: pip
python:
- 2.7
- 3.6
- 3.7
install:
- pip install tox tox-travis
script:
- tox --skip-missing-interpreters
django-fsm-2.6.1/CHANGELOG.rst 0000664 0000000 0000000 00000006417 13456326721 0015607 0 ustar 00root root 0000000 0000000 Changelog
=========
django-fsm 2.6.0 2017-06-08
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Fix django 1.11 compatibility
- Fix TypeError in `graph_transitions` command when using django's lazy translations
django-fsm 2.5.0 2017-03-04
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- graph_transition command fix for django 1.10
- graph_transition command supports GET_STATE targets
- signal data extended with method args/kwargs and field
- sets allowed to be passed to the transition decorator
django-fsm 2.4.0 2016-05-14
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- graph_transition commnad now works with multiple FSM's per model
- Add ability to set target state from transition return value or callable
django-fsm 2.3.0 2015-10-15
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Add source state shortcut '+' to specify transitions from all states except the target
- Add object-level permission checks
- Fix translated labels for graph of FSMIntegerField
- Fix multiple signals for several transition decorators
django-fsm 2.2.1 2015-04-27
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Improved exception message for unmet transition conditions.
- Don't send post transition signal in case of no state changes on
exception
- Allow empty string as correct state value
- Improved graphviz fsm visualisation
- Clean django 1.8 warnings
django-fsm 2.2.0 2014-09-03
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Support for `class
substitution `__
to proxy classes depending on the state
- Added ConcurrentTransitionMixin with optimistic locking support
- Default db\_index=True for FSMIntegerField removed
- Graph transition code migrated to new graphviz library with python 3
support
- Ability to change state on transition exception
django-fsm 2.1.0 2014-05-15
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Support for attaching permission checks on model transitions
django-fsm 2.0.0 2014-03-15
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Backward incompatible release
- All public code import moved directly to django\_fsm package
- Correct support for several @transitions decorator with different
source states and conditions on same method
- save parameter from transition decorator removed
- get\_available\_FIELD\_transitions return Transition data object
instead of tuple
- Models got get\_available\_FIELD\_transitions, even if field
specified as string reference
- New get\_all\_FIELD\_transitions method contributed to class
django-fsm 1.6.0 2014-03-15
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- FSMIntegerField and FSMKeyField support
django-fsm 1.5.1 2014-01-04
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Ad-hoc support for state fields from proxy and inherited models
django-fsm 1.5.0 2013-09-17
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Python 3 compatibility
django-fsm 1.4.0 2011-12-21
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Add graph\_transition command for drawing state transition picture
django-fsm 1.3.0 2011-07-28
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Add direct field modification protection
django-fsm 1.2.0 2011-03-23
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Add pre\_transition and post\_transition signals
django-fsm 1.1.0 2011-02-22
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Add support for transition conditions
- Allow multiple FSMField in one model
- Contribute get\_available\_FIELD\_transitions for model class
django-fsm 1.0.0 2010-10-12
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Initial public release
django-fsm-2.6.1/LICENSE 0000664 0000000 0000000 00000002046 13456326721 0014565 0 ustar 00root root 0000000 0000000 copyright (c) 2010 Mikhail Podgurskiy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
django-fsm-2.6.1/README.rst 0000664 0000000 0000000 00000027001 13456326721 0015245 0 ustar 00root root 0000000 0000000 Django friendly finite state machine support
============================================
|Build Status|
django-fsm adds simple declarative state management for django models.
If you need parallel task execution, view and background task code reuse
over different flows - check my new project django-viewflow:
https://github.com/viewflow/viewflow
Instead of adding a state field to a django model and managing its
values by hand, you use ``FSMField`` and mark model methods with
the ``transition`` decorator. These methods could contain side-effects
of the state change.
Nice introduction is available here:
https://gist.github.com/Nagyman/9502133
You may also take a look at django-fsm-admin project containing a mixin
and template tags to integrate django-fsm state transitions into the
django admin.
https://github.com/gadventures/django-fsm-admin
Transition logging support could be achived with help of django-fsm-log
package
https://github.com/gizmag/django-fsm-log
FSM really helps to structure the code, especially when a new developer
comes to the project. FSM is most effective when you use it for some
sequential steps.
Installation
------------
.. code:: bash
$ pip install django-fsm
Or, for the latest git version
.. code:: bash
$ pip install -e git://github.com/kmmbvnr/django-fsm.git#egg=django-fsm
The library has full Python 3 support
Usage
-----
Add FSMState field to your model
.. code:: python
from django_fsm import FSMField, transition
class BlogPost(models.Model):
state = FSMField(default='new')
Use the ``transition`` decorator to annotate model methods
.. code:: python
@transition(field=state, source='new', target='published')
def publish(self):
"""
This function may contain side-effects,
like updating caches, notifying users, etc.
The return value will be discarded.
"""
``source`` parameter accepts a list of states, or an individual state.
You can use ``*`` for source to allow switching to ``target`` from any
state. The ``field`` parameter accepts both a string attribute name or an
actual field instance.
If calling publish() succeeds without raising an exception, the state
field will be changed, but not written to the database.
.. code:: python
from django_fsm import can_proceed
def publish_view(request, post_id):
post = get_object__or_404(BlogPost, pk=post_id)
if not can_proceed(post.publish):
raise PermissionDenied
post.publish()
post.save()
return redirect('/')
If some conditions are required to be met before changing the state, use
the ``conditions`` argument to ``transition``. ``conditions`` must be a
list of functions taking one argument, the model instance. The function
must return either ``True`` or ``False`` or a value that evaluates to
``True`` or ``False``. If all functions return ``True``, all conditions
are considered to be met and the transition is allowed to happen. If one
of the functions returns ``False``, the transition will not happen.
These functions should not have any side effects.
You can use ordinary functions
.. code:: python
def can_publish(instance):
# No publishing after 17 hours
if datetime.datetime.now().hour > 17:
return False
return True
Or model methods
.. code:: python
def can_destroy(self):
return self.is_under_investigation()
Use the conditions like this:
.. code:: python
@transition(field=state, source='new', target='published', conditions=[can_publish])
def publish(self):
"""
Side effects galore
"""
@transition(field=state, source='*', target='destroyed', conditions=[can_destroy])
def destroy(self):
"""
Side effects galore
"""
You can instantiate a field with ``protected=True`` option to prevent
direct state field modification.
.. code:: python
class BlogPost(models.Model):
state = FSMField(default='new', protected=True)
model = BlogPost()
model.state = 'invalid' # Raises AttributeError
Note that calling
`refresh_from_db `_
on a model instance with a protected FSMField will cause an exception.
`target`
~~~~~~~~
`target` state parameter could point to a specific state or `django_fsm.State` implementation
.. code:: python
from django_fsm import FSMField, transition, RETURN_VALUE, GET_STATE
@transition(field=state,
source='*',
target=RETURN_VALUE('for_moderators', 'published'))
def publish(self, is_public=False):
return 'for_moderators' if is_public else 'published'
@transition(
field=state,
source='for_moderators',
target=GET_STATE(
lambda self, allowed: 'published' if allowed else 'rejected',
states=['published', 'rejected']))
def moderate(self, allowed):
self.allowed=allowed
``custom`` properties
~~~~~~~~~~~~~~~~~~~~~
Custom properties can be added by providing a dictionary to the
``custom`` keyword on the ``transition`` decorator.
.. code:: python
@transition(field=state,
source='*',
target='onhold',
custom=dict(verbose='Hold for legal reasons'))
def legal_hold(self):
"""
Side effects galore
"""
``on_error`` state
~~~~~~~~~~~~~~~~~~
If the transition method raises an exception, you can provide a
specific target state
.. code:: python
@transition(field=state, source='new', target='published', on_error='failed')
def publish(self):
"""
Some exception could happen here
"""
``state_choices``
~~~~~~~~~~~~~~~~~
Instead of passing a two-item iterable ``choices`` you can instead use the
three-element ``state_choices``, the last element being a string reference
to a model proxy class.
The base class instance would be dynamically changed to the corresponding Proxy
class instance, depending on the state. Even for queryset results, you
will get Proxy class instances, even if the QuerySet is executed on the base class.
Check the `test
case `__
for example usage. Or read about `implementation
internals `__
Permissions
~~~~~~~~~~~
It is common to have permissions attached to each model transition.
``django-fsm`` handles this with ``permission`` keyword on the
``transition`` decorator. ``permission`` accepts a permission string, or
callable that expects ``instance`` and ``user`` arguments and returns
True if the user can perform the transition.
.. code:: python
@transition(field=state, source='*', target='publish',
permission=lambda instance, user: not user.has_perm('myapp.can_make_mistakes'))
def publish(self):
pass
@transition(field=state, source='*', target='publish',
permission='myapp.can_remove_post')
def remove(self):
pass
You can check permission with ``has_transition_permission`` method
.. code:: python
from django_fsm import has_transition_perm
def publish_view(request, post_id):
post = get_object_or_404(BlogPost, pk=post_id)
if not has_transition_perm(post.publish, request.user):
raise PermissionDenied
post.publish()
post.save()
return redirect('/')
Model methods
~~~~~~~~~~~~~
``get_all_FIELD_transitions`` Enumerates all declared transitions
``get_available_FIELD_transitions`` Returns all transitions data
available in current state
``get_available_user_FIELD_transitions`` Enumerates all transitions data
available in current state for provided user
Foreign Key constraints support
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you store the states in the db table you could use FSMKeyField to
ensure Foreign Key database integrity.
In your model :
.. code:: python
class DbState(models.Model):
id = models.CharField(primary_key=True, max_length=50)
label = models.CharField(max_length=255)
def __unicode__(self):
return self.label
class BlogPost(models.Model):
state = FSMKeyField(DbState, default='new')
@transition(field=state, source='new', target='published')
def publish(self):
pass
In your fixtures/initial\_data.json :
.. code:: json
[
{
"pk": "new",
"model": "myapp.dbstate",
"fields": {
"label": "_NEW_"
}
},
{
"pk": "published",
"model": "myapp.dbstate",
"fields": {
"label": "_PUBLISHED_"
}
}
]
Note : source and target parameters in @transition decorator use pk
values of DBState model as names, even if field "real" name is used,
without \_id postfix, as field parameter.
Integer Field support
~~~~~~~~~~~~~~~~~~~~~
You can also use ``FSMIntegerField``. This is handy when you want to use
enum style constants.
.. code:: python
class BlogPostStateEnum(object):
NEW = 10
PUBLISHED = 20
HIDDEN = 30
class BlogPostWithIntegerField(models.Model):
state = FSMIntegerField(default=BlogPostStateEnum.NEW)
@transition(field=state, source=BlogPostStateEnum.NEW, target=BlogPostStateEnum.PUBLISHED)
def publish(self):
pass
Signals
~~~~~~~
``django_fsm.signals.pre_transition`` and
``django_fsm.signals.post_transition`` are called before and after
allowed transition. No signals on invalid transition are called.
Arguments sent with these signals:
**sender** The model class.
**instance** The actual instance being processed
**name** Transition name
**source** Source model state
**target** Target model state
Optimistic locking
------------------
``django-fsm`` provides optimistic locking mixin, to avoid concurrent
model state changes. If model state was changed in database
``django_fsm.ConcurrentTransition`` exception would be raised on
model.save()
.. code:: python
from django_fsm import FSMField, ConcurrentTransitionMixin
class BlogPost(ConcurrentTransitionMixin, models.Model):
state = FSMField(default='new')
For guaranteed protection against race conditions caused by concurrently
executed transitions, make sure:
- Your transitions do not have any side effects except for changes in the database,
- You always run the save() method on the object within ``django.db.transaction.atomic()`` block.
Following these recommendations, you can rely on
ConcurrentTransitionMixin to cause a rollback of all the changes that
have been executed in an inconsistent (out of sync) state, thus
practically negating their effect.
Drawing transitions
-------------------
Renders a graphical overview of your models states transitions
You need ``pip install graphviz>=0.4`` library and add ``django_fsm`` to
your ``INSTALLED_APPS``:
.. code:: python
INSTALLED_APPS = (
...
'django_fsm',
...
)
.. code:: bash
# Create a dot file
$ ./manage.py graph_transitions > transitions.dot
# Create a PNG image file only for specific model
$ ./manage.py graph_transitions -o blog_transitions.png myapp.Blog
Changelog
---------
django-fsm 2.6.0 2017-06-08
~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Fix django 1.11 compatibility
- Fix TypeError in `graph_transitions` command when using django's lazy translations
.. |Build Status| image:: https://travis-ci.org/viewflow/django-fsm.svg?branch=master
:target: https://travis-ci.org/viewflow/django-fsm
django-fsm-2.6.1/django_fsm/ 0000775 0000000 0000000 00000000000 13456326721 0015665 5 ustar 00root root 0000000 0000000 django-fsm-2.6.1/django_fsm/__init__.py 0000664 0000000 0000000 00000051733 13456326721 0020007 0 ustar 00root root 0000000 0000000 # -*- coding: utf-8 -*-
"""
State tracking functionality for django models
"""
import inspect
import sys
from functools import wraps
from django.db import models
from django.db.models.signals import class_prepared
from django.utils.functional import curry
from django_fsm.signals import pre_transition, post_transition
try:
from django.apps import apps as django_apps
def get_model(app_label, model_name):
app = django_apps.get_app_config(app_label)
return app.get_model(model_name)
except ImportError:
from django.db.models.loading import get_model
__all__ = ['TransitionNotAllowed', 'ConcurrentTransition',
'FSMFieldMixin', 'FSMField', 'FSMIntegerField',
'FSMKeyField', 'ConcurrentTransitionMixin', 'transition',
'can_proceed', 'has_transition_perm']
if sys.version_info[:2] == (2, 6):
# Backport of Python 2.7 inspect.getmembers,
# since Python 2.6 ships buggy implementation
def __getmembers(object, predicate=None):
"""Return all members of an object as (name, value) pairs sorted by name.
Optionally, only return members that satisfy a given predicate."""
results = []
for key in dir(object):
try:
value = getattr(object, key)
except AttributeError:
continue
if not predicate or predicate(value):
results.append((key, value))
results.sort()
return results
inspect.getmembers = __getmembers
# South support; see http://south.aeracode.org/docs/tutorial/part4.html#simple-inheritance
try:
from south.modelsinspector import add_introspection_rules
except ImportError:
pass
else:
add_introspection_rules([], [r"^django_fsm\.FSMField"])
add_introspection_rules([], [r"^django_fsm\.FSMIntegerField"])
add_introspection_rules([], [r"^django_fsm\.FSMKeyField"])
class TransitionNotAllowed(Exception):
"""Raised when a transition is not allowed"""
def __init__(self, *args, **kwargs):
self.object = kwargs.pop('object', None)
self.method = kwargs.pop('method', None)
super(TransitionNotAllowed, self).__init__(*args, **kwargs)
class InvalidResultState(Exception):
"""Raised when we got invalid result state"""
class ConcurrentTransition(Exception):
"""
Raised when the transition cannot be executed because the
object has become stale (state has been changed since it
was fetched from the database).
"""
class Transition(object):
def __init__(self, method, source, target, on_error, conditions, permission, custom):
self.method = method
self.source = source
self.target = target
self.on_error = on_error
self.conditions = conditions
self.permission = permission
self.custom = custom
@property
def name(self):
return self.method.__name__
def has_perm(self, instance, user):
if not self.permission:
return True
elif callable(self.permission):
return bool(self.permission(instance, user))
elif user.has_perm(self.permission, instance):
return True
elif user.has_perm(self.permission):
return True
else:
return False
def get_available_FIELD_transitions(instance, field):
"""
List of transitions available in current model state
with all conditions met
"""
curr_state = field.get_state(instance)
transitions = field.transitions[instance.__class__]
for name, transition in transitions.items():
meta = transition._django_fsm
if meta.has_transition(curr_state) and meta.conditions_met(instance, curr_state):
yield meta.get_transition(curr_state)
def get_all_FIELD_transitions(instance, field):
"""
List of all transitions available in current model state
"""
return field.get_all_transitions(instance.__class__)
def get_available_user_FIELD_transitions(instance, user, field):
"""
List of transitions available in current model state
with all conditions met and user have rights on it
"""
for transition in get_available_FIELD_transitions(instance, field):
if transition.has_perm(instance, user):
yield transition
class FSMMeta(object):
"""
Models methods transitions meta information
"""
def __init__(self, field, method):
self.field = field
self.transitions = {} # source -> Transition
def get_transition(self, source):
transition = self.transitions.get(source, None)
if transition is None:
transition = self.transitions.get('*', None)
if transition is None:
transition = self.transitions.get('+', None)
return transition
def add_transition(self, method, source, target, on_error=None, conditions=[], permission=None, custom={}):
if source in self.transitions:
raise AssertionError('Duplicate transition for {0} state'.format(source))
self.transitions[source] = Transition(
method=method,
source=source,
target=target,
on_error=on_error,
conditions=conditions,
permission=permission,
custom=custom)
def has_transition(self, state):
"""
Lookup if any transition exists from current model state using current method
"""
if state in self.transitions:
return True
if '*' in self.transitions:
return True
if '+' in self.transitions and self.transitions['+'].target != state:
return True
return False
def conditions_met(self, instance, state):
"""
Check if all conditions have been met
"""
transition = self.get_transition(state)
if transition is None:
return False
elif transition.conditions is None:
return True
else:
return all(map(lambda condition: condition(instance), transition.conditions))
def has_transition_perm(self, instance, state, user):
transition = self.get_transition(state)
if not transition:
return False
else:
return transition.has_perm(instance, user)
def next_state(self, current_state):
transition = self.get_transition(current_state)
if transition is None:
raise TransitionNotAllowed('No transition from {0}'.format(current_state))
return transition.target
def exception_state(self, current_state):
transition = self.get_transition(current_state)
if transition is None:
raise TransitionNotAllowed('No transition from {0}'.format(current_state))
return transition.on_error
class FSMFieldDescriptor(object):
def __init__(self, field):
self.field = field
def __get__(self, instance, type=None):
if instance is None:
return self
return self.field.get_state(instance)
def __set__(self, instance, value):
if self.field.protected and self.field.name in instance.__dict__:
raise AttributeError('Direct {0} modification is not allowed'.format(self.field.name))
# Update state
self.field.set_proxy(instance, value)
self.field.set_state(instance, value)
class FSMFieldMixin(object):
descriptor_class = FSMFieldDescriptor
def __init__(self, *args, **kwargs):
self.protected = kwargs.pop('protected', False)
self.transitions = {} # cls -> (transitions name -> method)
self.state_proxy = {} # state -> ProxyClsRef
state_choices = kwargs.pop('state_choices', None)
choices = kwargs.get('choices', None)
if state_choices is not None and choices is not None:
raise ValueError('Use one of choices or state_choices value')
if state_choices is not None:
choices = []
for state, title, proxy_cls_ref in state_choices:
choices.append((state, title))
self.state_proxy[state] = proxy_cls_ref
kwargs['choices'] = choices
super(FSMFieldMixin, self).__init__(*args, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super(FSMFieldMixin, self).deconstruct()
if self.protected:
kwargs['protected'] = self.protected
return name, path, args, kwargs
def get_state(self, instance):
return instance.__dict__[self.name]
def set_state(self, instance, state):
instance.__dict__[self.name] = state
def set_proxy(self, instance, state):
"""
Change class
"""
if state in self.state_proxy:
state_proxy = self.state_proxy[state]
try:
app_label, model_name = state_proxy.split(".")
except ValueError:
# If we can't split, assume a model in current app
app_label = instance._meta.app_label
model_name = state_proxy
model = get_model(app_label, model_name)
if model is None:
raise ValueError('No model found {0}'.format(state_proxy))
instance.__class__ = model
def change_state(self, instance, method, *args, **kwargs):
meta = method._django_fsm
method_name = method.__name__
current_state = self.get_state(instance)
if not meta.has_transition(current_state):
raise TransitionNotAllowed(
"Can't switch from state '{0}' using method '{1}'".format(current_state, method_name),
object=instance, method=method)
if not meta.conditions_met(instance, current_state):
raise TransitionNotAllowed(
"Transition conditions have not been met for method '{0}'".format(method_name),
object=instance, method=method)
next_state = meta.next_state(current_state)
signal_kwargs = {
'sender': instance.__class__,
'instance': instance,
'name': method_name,
'field': meta.field,
'source': current_state,
'target': next_state,
'method_args': args,
'method_kwargs': kwargs
}
pre_transition.send(**signal_kwargs)
try:
result = method(instance, *args, **kwargs)
if next_state is not None:
if hasattr(next_state, 'get_state'):
next_state = next_state.get_state(
instance, transition, result,
args=args, kwargs=kwargs)
signal_kwargs['target'] = next_state
self.set_proxy(instance, next_state)
self.set_state(instance, next_state)
except Exception as exc:
exception_state = meta.exception_state(current_state)
if exception_state:
self.set_proxy(instance, exception_state)
self.set_state(instance, exception_state)
signal_kwargs['target'] = exception_state
signal_kwargs['exception'] = exc
post_transition.send(**signal_kwargs)
raise
else:
post_transition.send(**signal_kwargs)
return result
def get_all_transitions(self, instance_cls):
"""
Returns [(source, target, name, method)] for all field transitions
"""
transitions = self.transitions[instance_cls]
for name, transition in transitions.items():
meta = transition._django_fsm
for transition in meta.transitions.values():
yield transition
def contribute_to_class(self, cls, name, **kwargs):
self.base_cls = cls
super(FSMFieldMixin, self).contribute_to_class(cls, name, **kwargs)
setattr(cls, self.name, self.descriptor_class(self))
setattr(cls, 'get_all_{0}_transitions'.format(self.name),
curry(get_all_FIELD_transitions, field=self))
setattr(cls, 'get_available_{0}_transitions'.format(self.name),
curry(get_available_FIELD_transitions, field=self))
setattr(cls, 'get_available_user_{0}_transitions'.format(self.name),
curry(get_available_user_FIELD_transitions, field=self))
class_prepared.connect(self._collect_transitions)
def _collect_transitions(self, *args, **kwargs):
sender = kwargs['sender']
if not issubclass(sender, self.base_cls):
return
def is_field_transition_method(attr):
return (inspect.ismethod(attr) or inspect.isfunction(attr)) \
and hasattr(attr, '_django_fsm') \
and attr._django_fsm.field in [self, self.name]
sender_transitions = {}
transitions = inspect.getmembers(sender, predicate=is_field_transition_method)
for method_name, method in transitions:
method._django_fsm.field = self
sender_transitions[method_name] = method
self.transitions[sender] = sender_transitions
class FSMField(FSMFieldMixin, models.CharField):
"""
State Machine support for Django model as CharField
"""
def __init__(self, *args, **kwargs):
kwargs.setdefault('max_length', 50)
super(FSMField, self).__init__(*args, **kwargs)
class FSMIntegerField(FSMFieldMixin, models.IntegerField):
"""
Same as FSMField, but stores the state value in an IntegerField.
"""
pass
class FSMKeyField(FSMFieldMixin, models.ForeignKey):
"""
State Machine support for Django model
"""
def get_state(self, instance):
return instance.__dict__[self.attname]
def set_state(self, instance, state):
instance.__dict__[self.attname] = self.to_python(state)
class ConcurrentTransitionMixin(object):
"""
Protects a Model from undesirable effects caused by concurrently executed transitions,
e.g. running the same transition multiple times at the same time, or running different
transitions with the same SOURCE state at the same time.
This behavior is achieved using an idea based on optimistic locking. No additional
version field is required though; only the state field(s) is/are used for the tracking.
This scheme is not that strict as true *optimistic locking* mechanism, it is however
more lightweight - leveraging the specifics of FSM models.
Instance of a model based on this Mixin will be prevented from saving into DB if any
of its state fields (instances of FSMFieldMixin) has been changed since the object
was fetched from the database. *ConcurrentTransition* exception will be raised in such
cases.
For guaranteed protection against such race conditions, make sure:
* Your transitions do not have any side effects except for changes in the database,
* You always run the save() method on the object within django.db.transaction.atomic()
block.
Following these recommendations, you can rely on ConcurrentTransitionMixin to cause
a rollback of all the changes that have been executed in an inconsistent (out of sync)
state, thus practically negating their effect.
"""
def __init__(self, *args, **kwargs):
super(ConcurrentTransitionMixin, self).__init__(*args, **kwargs)
self._update_initial_state()
@property
def state_fields(self):
return filter(
lambda field: isinstance(field, FSMFieldMixin),
self._meta.fields
)
def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update):
# _do_update is called once for each model class in the inheritance hierarchy.
# We can only filter the base_qs on state fields (can be more than one!) present in this particular model.
# Select state fields to filter on
filter_on = filter(lambda field: field.model == base_qs.model, self.state_fields)
# state filter will be used to narrow down the standard filter checking only PK
state_filter = dict((field.attname, self.__initial_states[field.attname]) for field in filter_on)
updated = super(ConcurrentTransitionMixin, self)._do_update(
base_qs=base_qs.filter(**state_filter),
using=using,
pk_val=pk_val,
values=values,
update_fields=update_fields,
forced_update=forced_update
)
# It may happen that nothing was updated in the original _do_update method not because of unmatching state,
# but because of missing PK. This codepath is possible when saving a new model instance with *preset PK*.
# In this case Django does not know it has to do INSERT operation, so it tries UPDATE first and falls back to
# INSERT if UPDATE fails.
# Thus, we need to make sure we only catch the case when the object *is* in the DB, but with changed state; and
# mimic standard _do_update behavior otherwise. Django will pick it up and execute _do_insert.
if not updated and base_qs.filter(pk=pk_val).exists():
raise ConcurrentTransition("Cannot save object! The state has been changed since fetched from the database!")
return updated
def _update_initial_state(self):
self.__initial_states = dict(
(field.attname, field.value_from_object(self)) for field in self.state_fields
)
def save(self, *args, **kwargs):
super(ConcurrentTransitionMixin, self).save(*args, **kwargs)
self._update_initial_state()
def transition(field, source='*', target=None, on_error=None, conditions=[], permission=None, custom={}):
"""
Method decorator to mark allowed transitions.
Set target to None if current state needs to be validated and
has not changed after the function call.
"""
def inner_transition(func):
wrapper_installed, fsm_meta = True, getattr(func, '_django_fsm', None)
if not fsm_meta:
wrapper_installed = False
fsm_meta = FSMMeta(field=field, method=func)
setattr(func, '_django_fsm', fsm_meta)
if isinstance(source, (list, tuple, set)):
for state in source:
func._django_fsm.add_transition(func, state, target, on_error, conditions, permission, custom)
else:
func._django_fsm.add_transition(func, source, target, on_error, conditions, permission, custom)
@wraps(func)
def _change_state(instance, *args, **kwargs):
return fsm_meta.field.change_state(instance, func, *args, **kwargs)
if not wrapper_installed:
return _change_state
return func
return inner_transition
def can_proceed(bound_method, check_conditions=True):
"""
Returns True if model in state allows to call bound_method
Set ``check_conditions`` argument to ``False`` to skip checking
conditions.
"""
if not hasattr(bound_method, '_django_fsm'):
im_func = getattr(bound_method, 'im_func', getattr(bound_method, '__func__'))
raise TypeError('%s method is not transition' % im_func.__name__)
meta = bound_method._django_fsm
im_self = getattr(bound_method, 'im_self', getattr(bound_method, '__self__'))
current_state = meta.field.get_state(im_self)
return meta.has_transition(current_state) and (
not check_conditions or meta.conditions_met(im_self, current_state))
def has_transition_perm(bound_method, user):
"""
Returns True if model in state allows to call bound_method and user have rights on it
"""
if not hasattr(bound_method, '_django_fsm'):
im_func = getattr(bound_method, 'im_func', getattr(bound_method, '__func__'))
raise TypeError('%s method is not transition' % im_func.__name__)
meta = bound_method._django_fsm
im_self = getattr(bound_method, 'im_self', getattr(bound_method, '__self__'))
current_state = meta.field.get_state(im_self)
return (meta.has_transition(current_state) and
meta.conditions_met(im_self, current_state) and
meta.has_transition_perm(im_self, current_state, user))
class State(object):
def get_state(self, model, transition, result, args=[], kwargs={}):
raise NotImplementedError
class RETURN_VALUE(State):
def __init__(self, *allowed_states):
self.allowed_states = allowed_states if allowed_states else None
def get_state(self, model, transition, result, args=[], kwargs={}):
if self.allowed_states is not None:
if result not in self.allowed_states:
raise InvalidResultState(
'{} is not in list of allowed states\n{}'.format(
result, self.allowed_states))
return result
class GET_STATE(State):
def __init__(self, func, states=None):
self.func = func
self.allowed_states = states
def get_state(self, model, transition, result, args=[], kwargs={}):
result_state = self.func(model, *args, **kwargs)
if self.allowed_states is not None:
if result_state not in self.allowed_states:
raise InvalidResultState(
'{} is not in list of allowed states\n{}'.format(
result, self.allowed_states))
return result_state
django-fsm-2.6.1/django_fsm/management/ 0000775 0000000 0000000 00000000000 13456326721 0020001 5 ustar 00root root 0000000 0000000 django-fsm-2.6.1/django_fsm/management/__init__.py 0000664 0000000 0000000 00000000000 13456326721 0022100 0 ustar 00root root 0000000 0000000 django-fsm-2.6.1/django_fsm/management/commands/ 0000775 0000000 0000000 00000000000 13456326721 0021602 5 ustar 00root root 0000000 0000000 django-fsm-2.6.1/django_fsm/management/commands/__init__.py 0000664 0000000 0000000 00000000000 13456326721 0023701 0 ustar 00root root 0000000 0000000 django-fsm-2.6.1/django_fsm/management/commands/graph_transitions.py 0000664 0000000 0000000 00000020430 13456326721 0025711 0 ustar 00root root 0000000 0000000 # -*- coding: utf-8; mode: django -*-
import graphviz
from optparse import make_option
from django.core.management.base import BaseCommand
from django.utils.encoding import force_text
from django_fsm import FSMFieldMixin, GET_STATE, RETURN_VALUE
try:
from django.db.models import get_apps, get_app, get_models, get_model
NEW_META_API = False
except ImportError:
from django.apps import apps
NEW_META_API = True
from django import VERSION
HAS_ARGPARSE = VERSION >= (1, 10)
def all_fsm_fields_data(model):
if NEW_META_API:
return [(field, model) for field in model._meta.get_fields()
if isinstance(field, FSMFieldMixin)]
else:
return [(field, model) for field in model._meta.fields
if isinstance(field, FSMFieldMixin)]
def node_name(field, state):
opts = field.model._meta
return "%s.%s.%s.%s" % (opts.app_label, opts.verbose_name.replace(' ', '_'), field.name, state)
def node_label(field, state):
if isinstance(state, int):
return force_text(dict(field.choices).get(state))
else:
return state
def generate_dot(fields_data):
result = graphviz.Digraph()
for field, model in fields_data:
sources, targets, edges, any_targets, any_except_targets = set(), set(), set(), set(), set()
# dump nodes and edges
for transition in field.get_all_transitions(model):
if transition.source == '*':
any_targets.add((transition.target, transition.name))
elif transition.source == '+':
any_except_targets.add((transition.target, transition.name))
else:
source_name = node_name(field, transition.source)
if transition.target is not None:
if isinstance(transition.target, GET_STATE) or isinstance(transition.target, RETURN_VALUE):
if transition.target.allowed_states:
for transition_target_index, transition_target in enumerate(transition.target.allowed_states):
add_transition(transition.source, transition_target, transition.name,
source_name, field, sources, targets, edges)
else:
add_transition(transition.source, transition.target, transition.name,
source_name, field, sources, targets, edges)
if transition.on_error:
on_error_name = node_name(field, transition.on_error)
targets.add(
(on_error_name, node_label(field, transition.on_error))
)
edges.add((source_name, on_error_name, (('style', 'dotted'),)))
for target, name in any_targets:
target_name = node_name(field, target)
targets.add((target_name, node_label(field, target)))
for source_name, label in sources:
edges.add((source_name, target_name, (('label', name),)))
for target, name in any_except_targets:
target_name = node_name(field, target)
targets.add((target_name, node_label(field, target)))
for source_name, label in sources:
if target_name == source_name:
continue
edges.add((source_name, target_name, (('label', name),)))
# construct subgraph
opts = field.model._meta
subgraph = graphviz.Digraph(
name="cluster_%s_%s_%s" % (opts.app_label, opts.object_name, field.name),
graph_attr={'label': "%s.%s.%s" % (opts.app_label, opts.object_name, field.name)})
final_states = targets - sources
for name, label in final_states:
subgraph.node(name, label=label, shape='doublecircle')
for name, label in (sources | targets) - final_states:
subgraph.node(name, label=label, shape='circle')
if field.default: # Adding initial state notation
if label == field.default:
initial_name = node_name(field, '_initial')
subgraph.node(name=initial_name, label='', shape='point')
subgraph.edge(initial_name, name)
for source_name, target_name, attrs in edges:
subgraph.edge(source_name, target_name, **dict(attrs))
result.subgraph(subgraph)
return result
def add_transition(transition_source, transition_target, transition_name, source_name, field, sources, targets, edges):
target_name = node_name(field, transition_target)
sources.add((source_name, node_label(field, transition_source)))
targets.add((target_name, node_label(field, transition_target)))
edges.add((source_name, target_name, (('label', transition_name),)))
def get_graphviz_layouts():
try:
import graphviz
return graphviz.backend.ENGINES
except Exception:
return {'sfdp', 'circo', 'twopi', 'dot', 'neato', 'fdp', 'osage', 'patchwork'}
class Command(BaseCommand):
requires_system_checks = True
if not HAS_ARGPARSE:
option_list = BaseCommand.option_list + (
make_option('--output', '-o', action='store', dest='outputfile',
help=('Render output file. Type of output dependent on file extensions. '
'Use png or jpg to render graph to image.')),
# NOQA
make_option('--layout', '-l', action='store', dest='layout', default='dot',
help=('Layout to be used by GraphViz for visualization. '
'Layouts: %s.' % ' '.join(get_graphviz_layouts()))),
)
args = "[appname[.model[.field]]]"
else:
def add_arguments(self, parser):
parser.add_argument(
'--output', '-o', action='store', dest='outputfile',
help=('Render output file. Type of output dependent on file extensions. '
'Use png or jpg to render graph to image.'))
parser.add_argument(
'--layout', '-l', action='store', dest='layout', default='dot',
help=('Layout to be used by GraphViz for visualization. '
'Layouts: %s.' % ' '.join(get_graphviz_layouts())))
parser.add_argument('args', nargs='*',
help=('[appname[.model[.field]]]'))
help = ("Creates a GraphViz dot file with transitions for selected fields")
def render_output(self, graph, **options):
filename, format = options['outputfile'].rsplit('.', 1)
graph.engine = options['layout']
graph.format = format
graph.render(filename)
def handle(self, *args, **options):
fields_data = []
if len(args) != 0:
for arg in args:
field_spec = arg.split('.')
if len(field_spec) == 1:
if NEW_META_API:
app = apps.get_app(field_spec[0])
models = apps.get_models(app)
else:
app = get_app(field_spec[0])
models = get_models(app)
for model in models:
fields_data += all_fsm_fields_data(model)
elif len(field_spec) == 2:
if NEW_META_API:
model = apps.get_model(field_spec[0], field_spec[1])
else:
model = get_model(field_spec[0], field_spec[1])
fields_data += all_fsm_fields_data(model)
elif len(field_spec) == 3:
if NEW_META_API:
model = apps.get_model(field_spec[0], field_spec[1])
else:
model = get_model(field_spec[0], field_spec[1])
fields_data += all_fsm_fields_data(model)
else:
if NEW_META_API:
for model in apps.get_models():
fields_data += all_fsm_fields_data(model)
else:
for app in get_apps():
for model in get_models(app):
fields_data += all_fsm_fields_data(model)
dotdata = generate_dot(fields_data)
if options['outputfile']:
self.render_output(dotdata, **options)
else:
print(dotdata)
django-fsm-2.6.1/django_fsm/models.py 0000664 0000000 0000000 00000000130 13456326721 0017514 0 ustar 00root root 0000000 0000000 # -*- coding: utf-8 -*-
"""
Empty file to mark package as valid django application.
"""
django-fsm-2.6.1/django_fsm/signals.py 0000664 0000000 0000000 00000000354 13456326721 0017701 0 ustar 00root root 0000000 0000000 # -*- coding: utf-8 -*-
from django.dispatch import Signal
pre_transition = Signal(providing_args=['instance', 'name', 'source', 'target'])
post_transition = Signal(providing_args=['instance', 'name', 'source', 'target', 'exception'])
django-fsm-2.6.1/django_fsm/tests/ 0000775 0000000 0000000 00000000000 13456326721 0017027 5 ustar 00root root 0000000 0000000 django-fsm-2.6.1/django_fsm/tests/__init__.py 0000664 0000000 0000000 00000000000 13456326721 0021126 0 ustar 00root root 0000000 0000000 django-fsm-2.6.1/django_fsm/tests/test_basic_transitions.py 0000664 0000000 0000000 00000017654 13456326721 0024173 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, TransitionNotAllowed, transition, can_proceed
from django_fsm.signals import pre_transition, post_transition
class BlogPost(models.Model):
state = FSMField(default='new')
@transition(field=state, source='new', target='published')
def publish(self):
pass
@transition(source='published', field=state)
def notify_all(self):
pass
@transition(source='published', target='hidden', field=state)
def hide(self):
pass
@transition(source='new', target='removed', field=state)
def remove(self):
raise Exception('Upss')
@transition(source=['published', 'hidden'], target='stolen', field=state)
def steal(self):
pass
@transition(source='*', target='moderated', field=state)
def moderate(self):
pass
@transition(source='+', target='blocked', field=state)
def block(self):
pass
@transition(source='*', target='', field=state)
def empty(self):
pass
class FSMFieldTest(TestCase):
def setUp(self):
self.model = BlogPost()
def test_initial_state_instantiated(self):
self.assertEqual(self.model.state, 'new')
def test_known_transition_should_succeed(self):
self.assertTrue(can_proceed(self.model.publish))
self.model.publish()
self.assertEqual(self.model.state, 'published')
self.assertTrue(can_proceed(self.model.hide))
self.model.hide()
self.assertEqual(self.model.state, 'hidden')
def test_unknown_transition_fails(self):
self.assertFalse(can_proceed(self.model.hide))
self.assertRaises(TransitionNotAllowed, self.model.hide)
def test_state_non_changed_after_fail(self):
self.assertTrue(can_proceed(self.model.remove))
self.assertRaises(Exception, self.model.remove)
self.assertEqual(self.model.state, 'new')
def test_allowed_null_transition_should_succeed(self):
self.model.publish()
self.model.notify_all()
self.assertEqual(self.model.state, 'published')
def test_unknown_null_transition_should_fail(self):
self.assertRaises(TransitionNotAllowed, self.model.notify_all)
self.assertEqual(self.model.state, 'new')
def test_multiple_source_support_path_1_works(self):
self.model.publish()
self.model.steal()
self.assertEqual(self.model.state, 'stolen')
def test_multiple_source_support_path_2_works(self):
self.model.publish()
self.model.hide()
self.model.steal()
self.assertEqual(self.model.state, 'stolen')
def test_star_shortcut_succeed(self):
self.assertTrue(can_proceed(self.model.moderate))
self.model.moderate()
self.assertEqual(self.model.state, 'moderated')
def test_plus_shortcut_succeeds_for_other_source(self):
"""Tests that the '+' shortcut succeeds for a source
other than the target.
"""
self.assertTrue(can_proceed(self.model.block))
self.model.block()
self.assertEqual(self.model.state, 'blocked')
def test_plus_shortcut_fails_for_same_source(self):
"""Tests that the '+' shortcut fails if the source
equals the target.
"""
self.model.block()
self.assertFalse(can_proceed(self.model.block))
self.assertRaises(TransitionNotAllowed, self.model.block)
def test_empty_string_target(self):
self.model.empty()
self.assertEqual(self.model.state, '')
class StateSignalsTests(TestCase):
def setUp(self):
self.model = BlogPost()
self.pre_transition_called = False
self.post_transition_called = False
pre_transition.connect(self.on_pre_transition, sender=BlogPost)
post_transition.connect(self.on_post_transition, sender=BlogPost)
def on_pre_transition(self, sender, instance, name, source, target, **kwargs):
self.assertEqual(instance.state, source)
self.pre_transition_called = True
def on_post_transition(self, sender, instance, name, source, target, **kwargs):
self.assertEqual(instance.state, target)
self.post_transition_called = True
def test_signals_called_on_valid_transition(self):
self.model.publish()
self.assertTrue(self.pre_transition_called)
self.assertTrue(self.post_transition_called)
def test_signals_not_called_on_invalid_transition(self):
self.assertRaises(TransitionNotAllowed, self.model.hide)
self.assertFalse(self.pre_transition_called)
self.assertFalse(self.post_transition_called)
class TestFieldTransitionsInspect(TestCase):
def setUp(self):
self.model = BlogPost()
def test_available_conditions_from_new(self):
transitions = self.model.get_available_state_transitions()
actual = set((transition.source, transition.target) for transition in transitions)
expected = set([('*', 'moderated'),
('new', 'published'),
('new', 'removed'),
('*', ''),
('+', 'blocked')])
self.assertEqual(actual, expected)
def test_available_conditions_from_published(self):
self.model.publish()
transitions = self.model.get_available_state_transitions()
actual = set((transition.source, transition.target) for transition in transitions)
expected = set([('*', 'moderated'),
('published', None),
('published', 'hidden'),
('published', 'stolen'),
('*', ''),
('+', 'blocked')])
self.assertEqual(actual, expected)
def test_available_conditions_from_hidden(self):
self.model.publish()
self.model.hide()
transitions = self.model.get_available_state_transitions()
actual = set((transition.source, transition.target) for transition in transitions)
expected = set([('*', 'moderated'),
('hidden', 'stolen'),
('*', ''),
('+', 'blocked')])
self.assertEqual(actual, expected)
def test_available_conditions_from_stolen(self):
self.model.publish()
self.model.steal()
transitions = self.model.get_available_state_transitions()
actual = set((transition.source, transition.target) for transition in transitions)
expected = set([('*', 'moderated'),
('*', ''),
('+', 'blocked')])
self.assertEqual(actual, expected)
def test_available_conditions_from_blocked(self):
self.model.block()
transitions = self.model.get_available_state_transitions()
actual = set((transition.source, transition.target) for transition in transitions)
expected = set([('*', 'moderated'),
('*', '')])
self.assertEqual(actual, expected)
def test_available_conditions_from_empty(self):
self.model.empty()
transitions = self.model.get_available_state_transitions()
actual = set((transition.source, transition.target) for transition in transitions)
expected = set([('*', 'moderated'),
('*', ''),
('+', 'blocked')])
self.assertEqual(actual, expected)
def test_all_conditions(self):
transitions = self.model.get_all_state_transitions()
actual = set((transition.source, transition.target) for transition in transitions)
expected = set([('*', 'moderated'),
('new', 'published'),
('new', 'removed'),
('published', None),
('published', 'hidden'),
('published', 'stolen'),
('hidden', 'stolen'),
('*', ''),
('+', 'blocked')])
self.assertEqual(actual, expected)
django-fsm-2.6.1/django_fsm/tests/test_conditions.py 0000664 0000000 0000000 00000002664 13456326721 0022621 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, TransitionNotAllowed, \
transition, can_proceed
def condition_func(instance):
return True
class BlogPostWithConditions(models.Model):
state = FSMField(default='new')
def model_condition(self):
return True
def unmet_condition(self):
return False
@transition(field=state, source='new', target='published',
conditions=[condition_func, model_condition])
def publish(self):
pass
@transition(field=state, source='published', target='destroyed',
conditions=[condition_func, unmet_condition])
def destroy(self):
pass
class ConditionalTest(TestCase):
def setUp(self):
self.model = BlogPostWithConditions()
def test_initial_staet(self):
self.assertEqual(self.model.state, 'new')
def test_known_transition_should_succeed(self):
self.assertTrue(can_proceed(self.model.publish))
self.model.publish()
self.assertEqual(self.model.state, 'published')
def test_unmet_condition(self):
self.model.publish()
self.assertEqual(self.model.state, 'published')
self.assertFalse(can_proceed(self.model.destroy))
self.assertRaises(TransitionNotAllowed, self.model.destroy)
self.assertTrue(can_proceed(self.model.destroy,
check_conditions=False))
django-fsm-2.6.1/django_fsm/tests/test_inheritance.py 0000664 0000000 0000000 00000003304 13456326721 0022731 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition, can_proceed
class BaseModel(models.Model):
state = FSMField(default='new')
@transition(field=state, source='new', target='published')
def publish(self):
pass
class InheritedModel(BaseModel):
@transition(field='state', source='published', target='sticked')
def stick(self):
pass
class Meta:
proxy = True
class TestinheritedModel(TestCase):
def setUp(self):
self.model = InheritedModel()
def test_known_transition_should_succeed(self):
self.assertTrue(can_proceed(self.model.publish))
self.model.publish()
self.assertEqual(self.model.state, 'published')
self.assertTrue(can_proceed(self.model.stick))
self.model.stick()
self.assertEqual(self.model.state, 'sticked')
def test_field_available_transitions_works(self):
self.model.publish()
self.assertEqual(self.model.state, 'published')
transitions = self.model.get_available_state_transitions()
self.assertEqual(['sticked'], [data.target for data in transitions])
def test_field_all_transitions_base_model(self):
transitions = BaseModel().get_all_state_transitions()
self.assertEqual(set([('new', 'published')]),
set((data.source, data.target) for data in transitions))
def test_field_all_transitions_works(self):
transitions = self.model.get_all_state_transitions()
self.assertEqual(set([('new', 'published'),
('published', 'sticked')]),
set((data.source, data.target) for data in transitions))
django-fsm-2.6.1/django_fsm/tests/test_integer_field.py 0000664 0000000 0000000 00000002052 13456326721 0023237 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMIntegerField, TransitionNotAllowed, transition
class BlogPostStateEnum(object):
NEW = 10
PUBLISHED = 20
HIDDEN = 30
class BlogPostWithIntegerField(models.Model):
state = FSMIntegerField(default=BlogPostStateEnum.NEW)
@transition(field=state, source=BlogPostStateEnum.NEW, target=BlogPostStateEnum.PUBLISHED)
def publish(self):
pass
@transition(field=state, source=BlogPostStateEnum.PUBLISHED, target=BlogPostStateEnum.HIDDEN)
def hide(self):
pass
class BlogPostWithIntegerFieldTest(TestCase):
def setUp(self):
self.model = BlogPostWithIntegerField()
def test_known_transition_should_succeed(self):
self.model.publish()
self.assertEqual(self.model.state, BlogPostStateEnum.PUBLISHED)
self.model.hide()
self.assertEqual(self.model.state, BlogPostStateEnum.HIDDEN)
def test_unknow_transition_fails(self):
self.assertRaises(TransitionNotAllowed, self.model.hide)
django-fsm-2.6.1/django_fsm/tests/test_key_field.py 0000664 0000000 0000000 00000010555 13456326721 0022401 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMKeyField, TransitionNotAllowed, transition, can_proceed
FK_AVAILABLE_STATES = (
('New', '_NEW_'),
('Published', '_PUBLISHED_'),
('Hidden', '_HIDDEN_'),
('Removed', '_REMOVED_'),
('Stolen', '_STOLEN_'),
('Moderated', '_MODERATED_'))
class DBState(models.Model):
id = models.CharField(primary_key=True, max_length=50)
label = models.CharField(max_length=255)
def __unicode__(self):
return self.label
class Meta:
app_label = 'django_fsm'
class FKBlogPost(models.Model):
state = FSMKeyField(DBState, default='new', protected=True, on_delete=models.CASCADE)
@transition(field=state, source='new', target='published')
def publish(self):
pass
@transition(field=state, source='published')
def notify_all(self):
pass
@transition(field=state, source='published', target='hidden')
def hide(self):
pass
@transition(field=state, source='new', target='removed')
def remove(self):
raise Exception('Upss')
@transition(field=state, source=['published', 'hidden'], target='stolen')
def steal(self):
pass
@transition(field=state, source='*', target='moderated')
def moderate(self):
pass
class Meta:
app_label = 'django_fsm'
class FSMKeyFieldTest(TestCase):
def setUp(self):
for item in FK_AVAILABLE_STATES:
DBState.objects.create(pk=item[0], label=item[1])
self.model = FKBlogPost()
def test_initial_state_instatiated(self):
self.assertEqual(self.model.state, 'new',)
def test_known_transition_should_succeed(self):
self.assertTrue(can_proceed(self.model.publish))
self.model.publish()
self.assertEqual(self.model.state, 'published')
self.assertTrue(can_proceed(self.model.hide))
self.model.hide()
self.assertEqual(self.model.state, 'hidden')
def test_unknow_transition_fails(self):
self.assertFalse(can_proceed(self.model.hide))
self.assertRaises(TransitionNotAllowed, self.model.hide)
def test_state_non_changed_after_fail(self):
self.assertTrue(can_proceed(self.model.remove))
self.assertRaises(Exception, self.model.remove)
self.assertEqual(self.model.state, 'new')
def test_allowed_null_transition_should_succeed(self):
self.assertTrue(can_proceed(self.model.publish))
self.model.publish()
self.model.notify_all()
self.assertEqual(self.model.state, 'published')
def test_unknow_null_transition_should_fail(self):
self.assertRaises(TransitionNotAllowed, self.model.notify_all)
self.assertEqual(self.model.state, 'new')
def test_mutiple_source_support_path_1_works(self):
self.model.publish()
self.model.steal()
self.assertEqual(self.model.state, 'stolen')
def test_mutiple_source_support_path_2_works(self):
self.model.publish()
self.model.hide()
self.model.steal()
self.assertEqual(self.model.state, 'stolen')
def test_star_shortcut_succeed(self):
self.assertTrue(can_proceed(self.model.moderate))
self.model.moderate()
self.assertEqual(self.model.state, 'moderated')
"""
TODO FIX it
class BlogPostStatus(models.Model):
name = models.CharField(max_length=10, unique=True)
objects = models.Manager()
class Meta:
app_label = 'django_fsm'
class BlogPostWithFKState(models.Model):
status = FSMKeyField(BlogPostStatus, default=lambda: BlogPostStatus.objects.get(name="new"))
@transition(field=status, source='new', target='published')
def publish(self):
pass
@transition(field=status, source='published', target='hidden')
def hide(self):
pass
class BlogPostWithFKStateTest(TestCase):
def setUp(self):
BlogPostStatus.objects.create(name="new")
BlogPostStatus.objects.create(name="published")
BlogPostStatus.objects.create(name="hidden")
self.model = BlogPostWithFKState()
def test_known_transition_should_succeed(self):
self.model.publish()
self.assertEqual(self.model.state, 'published')
self.model.hide()
self.assertEqual(self.model.state, 'hidden')
def test_unknow_transition_fails(self):
self.assertRaises(TransitionNotAllowed, self.model.hide)
"""
django-fsm-2.6.1/django_fsm/tests/test_protected_field.py 0000664 0000000 0000000 00000001361 13456326721 0023575 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition
class ProtectedAccessModel(models.Model):
status = FSMField(default='new', protected=True)
@transition(field=status, source='new', target='published')
def publish(self):
pass
class Meta:
app_label = 'django_fsm'
class TestDirectAccessModels(TestCase):
def test_no_direct_access(self):
instance = ProtectedAccessModel()
self.assertEqual(instance.status, 'new')
def try_change():
instance.status = 'change'
self.assertRaises(AttributeError, try_change)
instance.publish()
instance.save()
self.assertEqual(instance.status, 'published')
django-fsm-2.6.1/requirements.txt 0000664 0000000 0000000 00000000014 13456326721 0017035 0 ustar 00root root 0000000 0000000 django>=1.6
django-fsm-2.6.1/setup.cfg 0000664 0000000 0000000 00000000034 13456326721 0015374 0 ustar 00root root 0000000 0000000 [bdist_wheel]
universal = 1
django-fsm-2.6.1/setup.py 0000664 0000000 0000000 00000003125 13456326721 0015271 0 ustar 00root root 0000000 0000000 from setuptools import setup
try:
long_description = open('README.rst').read()
except IOError:
long_description = ''
setup(
name='django-fsm',
version='2.6.1',
description='Django friendly finite state machine support.',
author='Mikhail Podgurskiy',
author_email='kmmbvnr@gmail.com',
url='http://github.com/kmmbvnr/django-fsm',
keywords="django",
packages=['django_fsm', 'django_fsm.management', 'django_fsm.management.commands'],
include_package_data=True,
zip_safe=False,
license='MIT License',
platforms=['any'],
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
"Framework :: Django",
"Framework :: Django :: 1.8",
"Framework :: Django :: 1.9",
"Framework :: Django :: 1.10",
"Framework :: Django :: 1.11",
"Framework :: Django :: 2.0",
"Framework :: Django :: 2.1",
"Framework :: Django :: 2.2",
'Programming Language :: Python',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Framework :: Django',
'Topic :: Software Development :: Libraries :: Python Modules',
]
)
django-fsm-2.6.1/tests/ 0000775 0000000 0000000 00000000000 13456326721 0014720 5 ustar 00root root 0000000 0000000 django-fsm-2.6.1/tests/__init__.py 0000664 0000000 0000000 00000000000 13456326721 0017017 0 ustar 00root root 0000000 0000000 django-fsm-2.6.1/tests/manage.py 0000664 0000000 0000000 00000000632 13456326721 0016523 0 ustar 00root root 0000000 0000000 # -*- coding: utf-8 -*-
import os
import sys
from django.core.management import execute_from_command_line
PROJECT_ROOT = os.path.dirname(os.path.abspath(os.path.dirname(__file__)))
sys.path.insert(0, PROJECT_ROOT)
if __name__ == "__main__":
if len(sys.argv) == 1:
sys.argv += ['test']
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
execute_from_command_line(sys.argv)
django-fsm-2.6.1/tests/settings.py 0000664 0000000 0000000 00000001522 13456326721 0017132 0 ustar 00root root 0000000 0000000 import django
PROJECT_APPS = ('django_fsm', 'testapp',)
INSTALLED_APPS = (
'django.contrib.contenttypes',
'django.contrib.auth',
'guardian',
) + PROJECT_APPS
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', # this is default
'guardian.backends.ObjectPermissionBackend',
)
DATABASE_ENGINE = 'sqlite3'
SECRET_KEY = 'nokey'
MIDDLEWARE_CLASSES = ()
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
}
}
if django.VERSION < (1, 9):
class DisableMigrations(object):
def __contains__(self, item):
return True
def __getitem__(self, item):
return 'notmigrations'
MIGRATION_MODULES = DisableMigrations()
else:
MIGRATION_MODULES = {
'auth': None,
'contenttypes': None,
'guardian': None,
}
ANONYMOUS_USER_ID = 0
django-fsm-2.6.1/tests/testapp/ 0000775 0000000 0000000 00000000000 13456326721 0016400 5 ustar 00root root 0000000 0000000 django-fsm-2.6.1/tests/testapp/__init__.py 0000664 0000000 0000000 00000000000 13456326721 0020477 0 ustar 00root root 0000000 0000000 django-fsm-2.6.1/tests/testapp/fixtures/ 0000775 0000000 0000000 00000000000 13456326721 0020251 5 ustar 00root root 0000000 0000000 django-fsm-2.6.1/tests/testapp/fixtures/test_states_data.json 0000664 0000000 0000000 00000001056 13456326721 0024501 0 ustar 00root root 0000000 0000000 [
{
"model": "testapp.dbstate",
"pk": "new",
"fields": { "label": "_New"}
},
{
"model": "testapp.dbstate",
"pk": "draft",
"fields": { "label": "_Draft"}
},
{
"model": "testapp.dbstate",
"pk": "dept",
"fields": { "label": "_Dept"}
},
{
"model": "testapp.dbstate",
"pk": "dean",
"fields": { "label": "_Dean"}
},
{
"model": "testapp.dbstate",
"pk": "done",
"fields": { "label": "_Done"}
}
]
django-fsm-2.6.1/tests/testapp/models.py 0000664 0000000 0000000 00000006400 13456326721 0020235 0 ustar 00root root 0000000 0000000 from django.db import models
from django_fsm import FSMField, FSMKeyField, transition
class Application(models.Model):
"""
Student application need to be approved by dept chair and dean.
Test workflow
"""
state = FSMField(default='new')
@transition(field=state, source='new', target='draft')
def draft(self):
pass
@transition(field=state, source=['new', 'draft'], target='dept')
def to_approvement(self):
pass
@transition(field=state, source='dept', target='dean')
def dept_approved(self):
pass
@transition(field=state, source='dept', target='new')
def dept_rejected(self):
pass
@transition(field=state, source='dean', target='done')
def dean_approved(self):
pass
@transition(field=state, source='dean', target='dept')
def dean_rejected(self):
pass
class FKApplication(models.Model):
"""
Student application need to be approved by dept chair and dean.
Test workflow for FSMKeyField
"""
state = FSMKeyField('testapp.DbState', default='new', on_delete=models.CASCADE)
@transition(field=state, source='new', target='draft')
def draft(self):
pass
@transition(field=state, source=['new', 'draft'], target='dept')
def to_approvement(self):
pass
@transition(field=state, source='dept', target='dean')
def dept_approved(self):
pass
@transition(field=state, source='dept', target='new')
def dept_rejected(self):
pass
@transition(field=state, source='dean', target='done')
def dean_approved(self):
pass
@transition(field=state, source='dean', target='dept')
def dean_rejected(self):
pass
class DbState(models.Model):
'''
States in DB
'''
id = models.CharField(primary_key=True, max_length=50)
label = models.CharField(max_length=255)
def __unicode__(self):
return self.label
class BlogPost(models.Model):
"""
Test workflow
"""
state = FSMField(default='new', protected=True)
def can_restore(self, user):
return user.is_superuser or user.is_staff
@transition(field=state, source='new', target='published',
on_error='failed', permission='testapp.can_publish_post')
def publish(self):
pass
@transition(field=state, source='published')
def notify_all(self):
pass
@transition(field=state, source='published', target='hidden', on_error='failed',)
def hide(self):
pass
@transition(
field=state,
source='new',
target='removed',
on_error='failed',
permission=lambda self, u: u.has_perm('testapp.can_remove_post'))
def remove(self):
raise Exception('No rights to delete %s' % self)
@transition(field=state, source='new', target='restored',
on_error='failed', permission=can_restore)
def restore(self):
pass
@transition(field=state, source=['published', 'hidden'], target='stolen')
def steal(self):
pass
@transition(field=state, source='*', target='moderated')
def moderate(self):
pass
class Meta:
permissions = [
('can_publish_post', 'Can publish post'),
('can_remove_post', 'Can remove post'),
]
django-fsm-2.6.1/tests/testapp/tests/ 0000775 0000000 0000000 00000000000 13456326721 0017542 5 ustar 00root root 0000000 0000000 django-fsm-2.6.1/tests/testapp/tests/__init__.py 0000664 0000000 0000000 00000000000 13456326721 0021641 0 ustar 00root root 0000000 0000000 django-fsm-2.6.1/tests/testapp/tests/test_custom_data.py 0000664 0000000 0000000 00000002665 13456326721 0023467 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition
class BlogPostWithCustomData(models.Model):
state = FSMField(default='new')
@transition(field=state, source='new', target='published', conditions=[],
custom={'label': 'Publish', 'type': '*'})
def publish(self):
pass
@transition(field=state, source='published', target='destroyed',
custom=dict(label="Destroy", type='manual'))
def destroy(self):
pass
@transition(field=state, source='published', target='review',
custom=dict(label="Periodic review", type='automated'))
def review(self):
pass
class Meta:
app_label = 'testapp'
class CustomTransitionDataTest(TestCase):
def setUp(self):
self.model = BlogPostWithCustomData()
def test_initial_state(self):
self.assertEqual(self.model.state, 'new')
transitions = list(self.model.get_available_state_transitions())
self.assertEquals(len(transitions), 1)
self.assertEqual(transitions[0].target, 'published')
self.assertDictEqual(transitions[0].custom, {'label': 'Publish', 'type': '*'})
def test_all_transitions_have_custom_data(self):
transitions = self.model.get_all_state_transitions()
for t in transitions:
self.assertIsNotNone(t.custom['label'])
self.assertIsNotNone(t.custom['type'])
django-fsm-2.6.1/tests/testapp/tests/test_exception_transitions.py 0000664 0000000 0000000 00000002715 13456326721 0025613 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition, can_proceed
from django_fsm.signals import post_transition
class ExceptionalBlogPost(models.Model):
state = FSMField(default='new')
@transition(field=state, source='new', target='published', on_error='crashed')
def publish(self):
raise Exception('Upss')
@transition(field=state, source='new', target='deleted')
def delete(self):
raise Exception('Upss')
class Meta:
app_label = 'testapp'
class FSMFieldExceptionTest(TestCase):
def setUp(self):
self.model = ExceptionalBlogPost()
post_transition.connect(self.on_post_transition, sender=ExceptionalBlogPost)
self.post_transition_data = None
def on_post_transition(self, **kwargs):
self.post_transition_data = kwargs
def test_state_changed_after_fail(self):
self.assertTrue(can_proceed(self.model.publish))
self.assertRaises(Exception, self.model.publish)
self.assertEqual(self.model.state, 'crashed')
self.assertEqual(self.post_transition_data['target'], 'crashed')
self.assertTrue('exception' in self.post_transition_data)
def test_state_not_changed_after_fail(self):
self.assertTrue(can_proceed(self.model.delete))
self.assertRaises(Exception, self.model.delete)
self.assertEqual(self.model.state, 'new')
self.assertIsNone(self.post_transition_data)
django-fsm-2.6.1/tests/testapp/tests/test_lock_mixin.py 0000664 0000000 0000000 00000004714 13456326721 0023315 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, ConcurrentTransitionMixin, ConcurrentTransition, transition
class LockedBlogPost(ConcurrentTransitionMixin, models.Model):
state = FSMField(default='new', protected=True)
text = models.CharField(max_length=50)
@transition(field=state, source='new', target='published')
def publish(self):
pass
@transition(field=state, source='published', target='removed')
def remove(self):
pass
class Meta:
app_label = 'testapp'
class ExtendedBlogPost(LockedBlogPost):
review_state = FSMField(default='waiting', protected=True)
notes = models.CharField(max_length=50)
@transition(field=review_state, source='waiting', target='rejected')
def reject(self):
pass
class Meta:
app_label = 'testapp'
class TestLockMixin(TestCase):
def test_create_succeed(self):
LockedBlogPost.objects.create(text='test_create_succeed')
def test_crud_succeed(self):
post = LockedBlogPost(text='test_crud_succeed')
post.publish()
post.save()
post = LockedBlogPost.objects.get(pk=post.pk)
self.assertEqual('published', post.state)
post.text = 'test_crud_succeed2'
post.save()
post = LockedBlogPost.objects.get(pk=post.pk)
self.assertEqual('test_crud_succeed2', post.text)
def test_save_and_change_succeed(self):
post = LockedBlogPost(text='test_crud_succeed')
post.publish()
post.save()
post.remove()
post.save()
def test_concurent_modifications_raise_exception(self):
post1 = LockedBlogPost.objects.create()
post2 = LockedBlogPost.objects.get(pk=post1.pk)
post1.publish()
post1.save()
post2.text = 'aaa'
post2.publish()
with self.assertRaises(ConcurrentTransition):
post2.save()
def test_inheritance_crud_succeed(self):
post = ExtendedBlogPost(text='test_inheritance_crud_succeed', notes='reject me')
post.publish()
post.save()
post = ExtendedBlogPost.objects.get(pk=post.pk)
self.assertEqual('published', post.state)
post.text = 'test_inheritance_crud_succeed2'
post.reject()
post.save()
post = ExtendedBlogPost.objects.get(pk=post.pk)
self.assertEqual('rejected', post.review_state)
self.assertEqual('test_inheritance_crud_succeed2', post.text)
django-fsm-2.6.1/tests/testapp/tests/test_mixin_support.py 0000664 0000000 0000000 00000001313 13456326721 0024071 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition
class WorkflowMixin(object):
@transition(field='state', source="*", target='draft')
def draft(self):
pass
@transition(field='state', source="draft", target='published')
def publish(self):
pass
class Meta:
app_label = 'testapp'
class MixinSupportTestModel(WorkflowMixin, models.Model):
state = FSMField(default="new")
class Test(TestCase):
def test_usecase(self):
model = MixinSupportTestModel()
model.draft()
self.assertEqual(model.state, 'draft')
model.publish()
self.assertEqual(model.state, 'published')
django-fsm-2.6.1/tests/testapp/tests/test_model_create_with_generic.py 0000664 0000000 0000000 00000002263 13456326721 0026330 0 ustar 00root root 0000000 0000000 try:
from django.contrib.contenttypes.fields import GenericForeignKey
except ImportError:
# Django 1.6
from django.contrib.contenttypes.generic import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition
class Ticket(models.Model):
class Meta:
app_label = 'testapp'
class Task(models.Model):
class STATE:
NEW = 'new'
DONE = 'done'
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
causality = GenericForeignKey('content_type', 'object_id')
state = FSMField(default=STATE.NEW)
@transition(field=state, source=STATE.NEW, target=STATE.DONE)
def do(self):
pass
class Meta:
app_label = 'testapp'
class Test(TestCase):
def setUp(self):
self.ticket = Ticket.objects.create()
def test_model_objects_create(self):
"""Check a model with state field can be created
if one of the other fields is a property or a virtual field.
"""
Task.objects.create(causality=self.ticket)
django-fsm-2.6.1/tests/testapp/tests/test_multi_resultstate.py 0000664 0000000 0000000 00000004457 13456326721 0024756 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition, RETURN_VALUE, GET_STATE
from django_fsm.signals import pre_transition, post_transition
class MultiResultTest(models.Model):
state = FSMField(default='new')
@transition(
field=state,
source='new',
target=RETURN_VALUE('for_moderators', 'published'))
def publish(self, is_public=False):
return 'published' if is_public else 'for_moderators'
@transition(
field=state,
source='for_moderators',
target=GET_STATE(
lambda self, allowed: 'published' if allowed else 'rejected',
states=['published', 'rejected']
)
)
def moderate(self, allowed):
pass
class Meta:
app_label = 'testapp'
class Test(TestCase):
def test_return_state_succeed(self):
instance = MultiResultTest()
instance.publish(is_public=True)
self.assertEqual(instance.state, 'published')
def test_get_state_succeed(self):
instance = MultiResultTest(state='for_moderators')
instance.moderate(allowed=False)
self.assertEqual(instance.state, 'rejected')
class TestSignals(TestCase):
def setUp(self):
self.pre_transition_called = False
self.post_transition_called = False
pre_transition.connect(self.on_pre_transition, sender=MultiResultTest)
post_transition.connect(self.on_post_transition, sender=MultiResultTest)
def on_pre_transition(self, sender, instance, name, source, target, **kwargs):
self.assertEqual(instance.state, source)
self.pre_transition_called = True
def on_post_transition(self, sender, instance, name, source, target, **kwargs):
self.assertEqual(instance.state, target)
self.post_transition_called = True
def test_signals_called_with_get_state(self):
instance = MultiResultTest(state='for_moderators')
instance.moderate(allowed=False)
self.assertTrue(self.pre_transition_called)
self.assertTrue(self.post_transition_called)
def test_signals_called_with_return_value(self):
instance = MultiResultTest()
instance.publish(is_public=True)
self.assertTrue(self.pre_transition_called)
self.assertTrue(self.post_transition_called)
django-fsm-2.6.1/tests/testapp/tests/test_multidecorators.py 0000664 0000000 0000000 00000002051 13456326721 0024371 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition
from django_fsm.signals import post_transition
class TestModel(models.Model):
counter = models.IntegerField(default=0)
signal_counter = models.IntegerField(default=0)
state = FSMField(default="SUBMITTED_BY_USER")
@transition(field=state, source="SUBMITTED_BY_USER", target="REVIEW_USER")
@transition(field=state, source="SUBMITTED_BY_ADMIN", target="REVIEW_ADMIN")
@transition(field=state, source="SUBMITTED_BY_ANONYMOUS", target="REVIEW_ANONYMOUS")
def review(self):
self.counter += 1
class Meta:
app_label = 'testapp'
def count_calls(sender, instance, name, source, target, **kwargs):
instance.signal_counter += 1
post_transition.connect(count_calls, sender=TestModel)
class TestStateProxy(TestCase):
def test_transition_method_called_once(self):
model = TestModel()
model.review()
self.assertEqual(1, model.counter)
self.assertEqual(1, model.signal_counter)
django-fsm-2.6.1/tests/testapp/tests/test_object_permissions.py 0000664 0000000 0000000 00000003135 13456326721 0025056 0 ustar 00root root 0000000 0000000 from django.contrib.auth.models import User
from django.db import models
from django.test import TestCase
from django.test.utils import override_settings
from guardian.shortcuts import assign_perm
from django_fsm import FSMField, transition, has_transition_perm
class ObjectPermissionTestModel(models.Model):
state = FSMField(default="new")
@transition(field=state, source='new', target='published',
on_error='failed', permission='testapp.can_publish_objectpermissiontestmodel')
def publish(self):
pass
class Meta:
app_label = 'testapp'
permissions = [
('can_publish_objectpermissiontestmodel', 'Can publish ObjectPermissionTestModel'),
]
@override_settings(
AUTHENTICATION_BACKENDS=('django.contrib.auth.backends.ModelBackend',
'guardian.backends.ObjectPermissionBackend'))
class ObjectPermissionFSMFieldTest(TestCase):
def setUp(self):
super(ObjectPermissionFSMFieldTest, self).setUp()
self.model = ObjectPermissionTestModel.objects.create()
self.unprivileged = User.objects.create(username='unpriviledged')
self.privileged = User.objects.create(username='object_only_privileged')
assign_perm('can_publish_objectpermissiontestmodel', self.privileged, self.model)
def test_object_only_access_success(self):
self.assertTrue(has_transition_perm(self.model.publish, self.privileged))
self.model.publish()
def test_object_only_other_access_prohibited(self):
self.assertFalse(has_transition_perm(self.model.publish, self.unprivileged))
django-fsm-2.6.1/tests/testapp/tests/test_permissions.py 0000664 0000000 0000000 00000003504 13456326721 0023530 0 ustar 00root root 0000000 0000000 from django.contrib.auth.models import User, Permission
from django.test import TestCase
from django_fsm import has_transition_perm
from testapp.models import BlogPost
class PermissionFSMFieldTest(TestCase):
def setUp(self):
self.model = BlogPost()
self.unpriviledged = User.objects.create(username='unpriviledged')
self.priviledged = User.objects.create(username='priviledged')
self.staff = User.objects.create(username='staff', is_staff=True)
self.priviledged.user_permissions.add(
Permission.objects.get_by_natural_key('can_publish_post', 'testapp', 'blogpost'))
self.priviledged.user_permissions.add(
Permission.objects.get_by_natural_key('can_remove_post', 'testapp', 'blogpost'))
def test_proviledged_access_succed(self):
self.assertTrue(has_transition_perm(self.model.publish, self.priviledged))
self.assertTrue(has_transition_perm(self.model.remove, self.priviledged))
transitions = self.model.get_available_user_state_transitions(self.priviledged)
self.assertEquals(set(['publish', 'remove', 'moderate']),
set(transition.name for transition in transitions))
def test_unpriviledged_access_prohibited(self):
self.assertFalse(has_transition_perm(self.model.publish, self.unpriviledged))
self.assertFalse(has_transition_perm(self.model.remove, self.unpriviledged))
transitions = self.model.get_available_user_state_transitions(self.unpriviledged)
self.assertEquals(set(['moderate']),
set(transition.name for transition in transitions))
def test_permission_instance_method(self):
self.assertFalse(has_transition_perm(self.model.restore, self.unpriviledged))
self.assertTrue(has_transition_perm(self.model.restore, self.staff))
django-fsm-2.6.1/tests/testapp/tests/test_state_transitions.py 0000664 0000000 0000000 00000003131 13456326721 0024726 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition
class Insect(models.Model):
class STATE:
CATERPILLAR = 'CTR'
BUTTERFLY = 'BTF'
STATE_CHOICES = ((STATE.CATERPILLAR, 'Caterpillar', 'Caterpillar'),
(STATE.BUTTERFLY, 'Butterfly', 'Butterfly'))
state = FSMField(default=STATE.CATERPILLAR, state_choices=STATE_CHOICES)
@transition(field=state, source=STATE.CATERPILLAR, target=STATE.BUTTERFLY)
def cocoon(self):
pass
def fly(self):
raise NotImplementedError
def crawl(self):
raise NotImplementedError
class Meta:
app_label = 'testapp'
class Caterpillar(Insect):
def crawl(self):
"""
Do crawl
"""
class Meta:
app_label = 'testapp'
proxy = True
class Butterfly(Insect):
def fly(self):
"""
Do fly
"""
class Meta:
app_label = 'testapp'
proxy = True
class TestStateProxy(TestCase):
def test_initial_proxy_set_succeed(self):
insect = Insect()
self.assertTrue(isinstance(insect, Caterpillar))
def test_transition_proxy_set_succeed(self):
insect = Insect()
insect.cocoon()
self.assertTrue(isinstance(insect, Butterfly))
def test_load_proxy_set(self):
Insect.objects.create(state=Insect.STATE.CATERPILLAR)
Insect.objects.create(state=Insect.STATE.BUTTERFLY)
insects = Insect.objects.all()
self.assertEqual(set([Caterpillar, Butterfly]), set(insect.__class__ for insect in insects))
django-fsm-2.6.1/tests/testapp/tests/test_string_field_parameter.py 0000664 0000000 0000000 00000001512 13456326721 0025663 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition
class BlogPostWithStringField(models.Model):
state = FSMField(default='new')
@transition(field='state', source='new', target='published', conditions=[])
def publish(self):
pass
@transition(field='state', source='published', target='destroyed')
def destroy(self):
pass
@transition(field='state', source='published', target='review')
def review(self):
pass
class Meta:
app_label = 'testapp'
class StringFieldTestCase(TestCase):
def setUp(self):
self.model = BlogPostWithStringField()
def test_initial_state(self):
self.assertEqual(self.model.state, 'new')
self.model.publish()
self.assertEqual(self.model.state, 'published')
django-fsm-2.6.1/tests/testapp/tests/test_transition_all_except_target.py 0000664 0000000 0000000 00000001501 13456326721 0027110 0 ustar 00root root 0000000 0000000 from django.db import models
from django.test import TestCase
from django_fsm import FSMField, transition, can_proceed
class TestExceptTargetTransitionShortcut(models.Model):
state = FSMField(default='new')
@transition(field=state, source='new', target='published')
def publish(self):
pass
@transition(field=state, source='+', target='removed')
def remove(self):
pass
class Meta:
app_label = 'testapp'
class Test(TestCase):
def setUp(self):
self.model = TestExceptTargetTransitionShortcut()
def test_usecase(self):
self.assertEqual(self.model.state, 'new')
self.assertTrue(can_proceed(self.model.remove))
self.model.remove()
self.assertEqual(self.model.state, 'removed')
self.assertFalse(can_proceed(self.model.remove))
django-fsm-2.6.1/tests/testapp/views.py 0000664 0000000 0000000 00000000032 13456326721 0020102 0 ustar 00root root 0000000 0000000 # Create your views here.
django-fsm-2.6.1/tox.ini 0000664 0000000 0000000 00000002162 13456326721 0015072 0 ustar 00root root 0000000 0000000 [tox]
envlist =
py26-dj{16}
py27-dj{16,18,19,110,111}
py33-dj{16,18}
py{34,35,36}-dj{18,19,110,111}
py{36,37}-dj{20,21,22}
skipsdist = True
[testenv]
deps =
py26: ipython==2.1.0
{py27,py32,py33}: ipython==5.4.1
{py34,py35,py36}: ipython==6.1.0
{py37}: ipython==7.4.0
dj16: Django==1.6.11
dj16: coverage<=3.999
dj16: django-guardian==1.3.2
dj18: Django==1.8.18
dj18: coverage==4.1
dj18: django-guardian==1.4.4
dj19: Django==1.9.13
dj19: coverage==4.1
dj19: django-guardian==1.4.4
dj110: Django==1.10.7
dj110: coverage==4.1
dj110: django-guardian==1.4.4
dj111: Django==1.11.8
dj111: coverage==4.5.3
dj111: django-guardian==1.4.8
dj20: Django==2.0.13
dj20: coverage==4.5.3
dj20: django-guardian==1.5.0
dj21: Django==2.1.8
dj21: coverage==4.5.3
dj21: django-guardian==1.5.0
dj22: Django==2.2
dj22: coverage==4.5.3
dj22: django-guardian==1.5.0
graphviz==0.7.1
pep8==1.7.1
pyflakes==1.6.0
ipdb==0.10.3
commands = {posargs:python ./tests/manage.py test}
[flake8]
max-line-length = 130