Skip to content
This repository was archived by the owner on Oct 7, 2025. It is now read-only.

Commit 998f39e

Browse files
committed
State changes on exception. Merge @bacongobbler idea. Close #39
1 parent 7601f52 commit 998f39e

File tree

5 files changed

+96
-16
lines changed

5 files changed

+96
-16
lines changed

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ model = BlogPost()
121121
model.state = 'invalid' # Raises AttributeError
122122
```
123123

124+
125+
### `custom` properties
124126
Custom properties can be added by providing a dictionary to the `custom` keyword on the `transition` decorator.
125127
```python
126128
@transition(field=state,
@@ -133,6 +135,18 @@ def legal_hold(self):
133135
"""
134136
```
135137

138+
### `on_error` state
139+
140+
In case of transition method would raise exception, you can provide specific target state
141+
142+
```python
143+
@transition(field=state, source='new', target='published', on_error='failed')
144+
def publish(self):
145+
"""
146+
Some exceptio could happends here
147+
"""
148+
```
149+
136150
### `state_choices`
137151
Instead of passing two elements list `choices` you could use three elements `state_choices`,
138152
the last element states for string reference to model proxy class.
@@ -295,7 +309,7 @@ that have been executed in an inconsistent (out of sync) state, thus practically
295309

296310
Renders a graphical overview of your models states transitions
297311

298-
You need `pip install graphviz` library
312+
You need `pip install graphviz>=0.4` library
299313

300314
```bash
301315
# Create a dot file
@@ -314,6 +328,8 @@ Changelog
314328
* Support for [class substitution](http://schinckel.net/2013/06/13/django-proxy-model-state-machine/) to proxy classes depending on the state
315329
* Added ConcurrentTransitionMixin with optimistic locking support
316330
* Default db_index=True for FSMIntegerField removed
331+
* Graph transition code migrated to new graphviz library with python 3 support
332+
* Ability to change state on transition exception
317333

318334
### django-fsm 2.1.0 2014-05-15
319335
* Support for attaching permission checks on model transitions

django_fsm/__init__.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,11 @@ class ConcurrentTransition(Exception):
6060

6161

6262
class Transition(object):
63-
def __init__(self, method, source, target, conditions, permission, custom):
63+
def __init__(self, method, source, target, on_error, conditions, permission, custom):
6464
self.method = method
6565
self.source = source
6666
self.target = target
67+
self.on_error = on_error
6768
self.conditions = conditions
6869
self.permission = permission
6970
self.custom = custom
@@ -132,14 +133,15 @@ def get_transition(self, source):
132133
transition = self.transitions.get('*', None)
133134
return transition
134135

135-
def add_transition(self, method, source, target, conditions=[], permission=None, custom={}):
136+
def add_transition(self, method, source, target, on_error=None, conditions=[], permission=None, custom={}):
136137
if source in self.transitions:
137138
raise AssertionError('Duplicate transition for {0} state'.format(source))
138139

139140
self.transitions[source] = Transition(
140141
method=method,
141142
source=source,
142143
target=target,
144+
on_error=on_error,
143145
conditions=conditions,
144146
permission=permission,
145147
custom=custom)
@@ -179,6 +181,14 @@ def next_state(self, current_state):
179181

180182
return transition.target
181183

184+
def exception_state(self, current_state):
185+
transition = self.get_transition(current_state)
186+
187+
if transition is None:
188+
raise TransitionNotAllowed('No transition from {0}'.format(current_state))
189+
190+
return transition.on_error
191+
182192

183193
class FSMFieldDescriptor(object):
184194
def __init__(self, field):
@@ -273,12 +283,21 @@ def change_state(self, instance, method, *args, **kwargs):
273283

274284
pre_transition.send(**signal_kwargs)
275285

276-
result = method(instance, *args, **kwargs)
277-
if next_state:
278-
self.set_proxy(instance, next_state)
279-
self.set_state(instance, next_state)
280-
281-
post_transition.send(**signal_kwargs)
286+
try:
287+
result = method(instance, *args, **kwargs)
288+
if next_state:
289+
self.set_proxy(instance, next_state)
290+
self.set_state(instance, next_state)
291+
except Exception as exc:
292+
exception_state = meta.exception_state(current_state)
293+
if exception_state:
294+
self.set_proxy(instance, exception_state)
295+
self.set_state(instance, exception_state)
296+
signal_kwargs['target'] = exception_state
297+
signal_kwargs['exception'] = exc
298+
raise
299+
finally:
300+
post_transition.send(**signal_kwargs)
282301

283302
return result
284303

@@ -431,7 +450,7 @@ def save(self, *args, **kwargs):
431450
self._update_initial_state()
432451

433452

434-
def transition(field, source='*', target=None, conditions=[], permission=None, custom={}):
453+
def transition(field, source='*', target=None, on_error=None, conditions=[], permission=None, custom={}):
435454
"""
436455
Method decorator for mark allowed transitions
437456
@@ -450,9 +469,9 @@ def _change_state(instance, *args, **kwargs):
450469

451470
if isinstance(source, (list, tuple)):
452471
for state in source:
453-
func._django_fsm.add_transition(func, state, target, conditions, permission, custom)
472+
func._django_fsm.add_transition(func, state, target, on_error, conditions, permission, custom)
454473
else:
455-
func._django_fsm.add_transition(func, source, target, conditions, permission, custom)
474+
func._django_fsm.add_transition(func, source, target, on_error, conditions, permission, custom)
456475

457476
return _change_state
458477

django_fsm/signals.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
from django.dispatch import Signal
33

44
pre_transition = Signal(providing_args=['instance', 'name', 'source', 'target'])
5-
post_transition = Signal(providing_args=['instance', 'name', 'source', 'target'])
5+
post_transition = Signal(providing_args=['instance', 'name', 'source', 'target', 'exception'])
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from django.db import models
2+
from django.test import TestCase
3+
4+
from django_fsm import FSMField, transition, can_proceed
5+
from django_fsm.signals import post_transition
6+
7+
8+
class ExceptionalBlogPost(models.Model):
9+
state = FSMField(default='new')
10+
11+
@transition(field=state, source='new', target='published', on_error='crashed')
12+
def publish(self):
13+
raise Exception('Upss')
14+
15+
@transition(field=state, source='new', target='deleted')
16+
def delete(self):
17+
raise Exception('Upss')
18+
19+
20+
class FSMFieldExceptionTest(TestCase):
21+
def setUp(self):
22+
self.model = ExceptionalBlogPost()
23+
post_transition.connect(self.on_post_transition, sender=ExceptionalBlogPost)
24+
self.post_transition_data = None
25+
26+
def on_post_transition(self, **kwargs):
27+
self.post_transition_data = kwargs
28+
29+
def test_state_changed_after_fail(self):
30+
self.assertTrue(can_proceed(self.model.publish))
31+
self.assertRaises(Exception, self.model.publish)
32+
self.assertEqual(self.model.state, 'crashed')
33+
self.assertEqual(self.post_transition_data['target'], 'crashed')
34+
self.assertTrue('exception' in self.post_transition_data)
35+
36+
def test_state_not_changed_after_fail(self):
37+
self.assertTrue(can_proceed(self.model.delete))
38+
self.assertRaises(Exception, self.model.delete)
39+
self.assertEqual(self.model.state, 'new')

tox.ini

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,28 @@ envlist = py26, py27, py33
55
usedevelop = True
66
commands = python tests/manage.py {posargs:jenkins --pep8-max-line-length=150 --output-dir=reports/{envname}}
77
deps = -r{toxinidir}/requirements.txt
8+
graphviz>=0.4
89
django-jenkins
910
coverage
1011
pep8
1112
pyflakes
12-
graphviz
1313
ipdb
1414

1515

1616
[testenv:py26]
17-
deps = {[testenv]deps}
17+
deps = django==1.6.5
1818
ipython==2.1.0
19+
graphviz>=0.4
20+
django-jenkins
21+
coverage
22+
pep8
23+
pyflakes
24+
ipdb
1925

2026
[testenv:alpha]
2127
basepython = python3.3
2228
deps = git+https://github.com/django/django.git
23-
graphviz
29+
graphviz>=0.4
2430
django-jenkins
2531
coverage
2632
pep8

0 commit comments

Comments
 (0)