├── tests ├── __init__.py ├── testapp │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_mixin_support.py │ │ ├── test_transition_all_except_target.py │ │ ├── test_string_field_parameter.py │ │ ├── test_access_deferred_fsm_field.py │ │ ├── test_multidecorators.py │ │ ├── test_model_create_with_generic.py │ │ ├── test_custom_data.py │ │ ├── test_exception_transitions.py │ │ ├── test_object_permissions.py │ │ ├── test_state_transitions.py │ │ ├── test_permissions.py │ │ ├── test_multi_resultstate.py │ │ └── test_lock_mixin.py │ ├── views.py │ ├── fixtures │ │ └── test_states_data.json │ └── models.py ├── manage.py └── settings.py ├── django_fsm ├── tests │ ├── __init__.py │ ├── test_integer_field.py │ ├── test_protected_fields.py │ ├── test_protected_field.py │ ├── test_conditions.py │ ├── test_proxy_inheritance.py │ ├── test_abstract_inheritance.py │ ├── test_key_field.py │ └── test_basic_transitions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── graph_transitions.py ├── models.py ├── signals.py └── __init__.py ├── requirements.txt ├── .checkignore ├── .gitignore ├── setup.cfg ├── .travis.yml ├── LICENSE ├── .pylintrc ├── tox.ini ├── README.rst ├── setup.py └── CHANGELOG.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_fsm/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_fsm/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=1.6 2 | -------------------------------------------------------------------------------- /tests/testapp/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.checkignore: -------------------------------------------------------------------------------- 1 | tests/* 2 | django_fsm/tests/* -------------------------------------------------------------------------------- /django_fsm/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dist/ 3 | build/ 4 | django_fsm.egg-info/ 5 | .tox/ 6 | -------------------------------------------------------------------------------- /django_fsm/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Empty file to mark package as valid django application. 4 | """ 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length = 130 6 | ignore = D100, D105, D107, W503 7 | -------------------------------------------------------------------------------- /django_fsm/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.dispatch import Signal 3 | 4 | pre_transition = Signal() 5 | post_transition = Signal() 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | sudo: false 4 | cache: pip 5 | 6 | python: 7 | - 2.7 8 | - 3.6 9 | - 3.7 10 | - 3.8 11 | 12 | install: 13 | - pip install tox tox-travis 14 | 15 | script: 16 | - tox --skip-missing-interpreters 17 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | from django.core.management import execute_from_command_line 5 | 6 | PROJECT_ROOT = os.path.dirname(os.path.abspath(os.path.dirname(__file__))) 7 | sys.path.insert(0, PROJECT_ROOT) 8 | 9 | if __name__ == "__main__": 10 | if len(sys.argv) == 1: 11 | sys.argv += ["test"] 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 14 | execute_from_command_line(sys.argv) 15 | -------------------------------------------------------------------------------- /tests/testapp/fixtures/test_states_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "testapp.dbstate", 4 | "pk": "new", 5 | "fields": { "label": "_New"} 6 | }, 7 | { 8 | "model": "testapp.dbstate", 9 | "pk": "draft", 10 | "fields": { "label": "_Draft"} 11 | }, 12 | { 13 | "model": "testapp.dbstate", 14 | "pk": "dept", 15 | "fields": { "label": "_Dept"} 16 | }, 17 | { 18 | "model": "testapp.dbstate", 19 | "pk": "dean", 20 | "fields": { "label": "_Dean"} 21 | }, 22 | { 23 | "model": "testapp.dbstate", 24 | "pk": "done", 25 | "fields": { "label": "_Done"} 26 | } 27 | ] 28 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_mixin_support.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMField, transition 4 | 5 | 6 | class WorkflowMixin(object): 7 | @transition(field="state", source="*", target="draft") 8 | def draft(self): 9 | pass 10 | 11 | @transition(field="state", source="draft", target="published") 12 | def publish(self): 13 | pass 14 | 15 | class Meta: 16 | app_label = "testapp" 17 | 18 | 19 | class MixinSupportTestModel(WorkflowMixin, models.Model): 20 | state = FSMField(default="new") 21 | 22 | 23 | class Test(TestCase): 24 | def test_usecase(self): 25 | model = MixinSupportTestModel() 26 | 27 | model.draft() 28 | self.assertEqual(model.state, "draft") 29 | 30 | model.publish() 31 | self.assertEqual(model.state, "published") 32 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_transition_all_except_target.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMField, transition, can_proceed 4 | 5 | 6 | class TestExceptTargetTransitionShortcut(models.Model): 7 | state = FSMField(default="new") 8 | 9 | @transition(field=state, source="new", target="published") 10 | def publish(self): 11 | pass 12 | 13 | @transition(field=state, source="+", target="removed") 14 | def remove(self): 15 | pass 16 | 17 | class Meta: 18 | app_label = "testapp" 19 | 20 | 21 | class Test(TestCase): 22 | def setUp(self): 23 | self.model = TestExceptTargetTransitionShortcut() 24 | 25 | def test_usecase(self): 26 | self.assertEqual(self.model.state, "new") 27 | self.assertTrue(can_proceed(self.model.remove)) 28 | self.model.remove() 29 | 30 | self.assertEqual(self.model.state, "removed") 31 | self.assertFalse(can_proceed(self.model.remove)) 32 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_string_field_parameter.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMField, transition 4 | 5 | 6 | class BlogPostWithStringField(models.Model): 7 | state = FSMField(default="new") 8 | 9 | @transition(field="state", source="new", target="published", conditions=[]) 10 | def publish(self): 11 | pass 12 | 13 | @transition(field="state", source="published", target="destroyed") 14 | def destroy(self): 15 | pass 16 | 17 | @transition(field="state", source="published", target="review") 18 | def review(self): 19 | pass 20 | 21 | class Meta: 22 | app_label = "testapp" 23 | 24 | 25 | class StringFieldTestCase(TestCase): 26 | def setUp(self): 27 | self.model = BlogPostWithStringField() 28 | 29 | def test_initial_state(self): 30 | self.assertEqual(self.model.state, "new") 31 | self.model.publish() 32 | self.assertEqual(self.model.state, "published") 33 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_access_deferred_fsm_field.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMField, transition, can_proceed 4 | 5 | 6 | class DeferrableModel(models.Model): 7 | state = FSMField(default="new") 8 | 9 | @transition(field=state, source="new", target="published") 10 | def publish(self): 11 | pass 12 | 13 | @transition(field=state, source="+", target="removed") 14 | def remove(self): 15 | pass 16 | 17 | class Meta: 18 | app_label = "testapp" 19 | 20 | 21 | class Test(TestCase): 22 | def setUp(self): 23 | DeferrableModel.objects.create() 24 | self.model = DeferrableModel.objects.only("id").get() 25 | 26 | def test_usecase(self): 27 | self.assertEqual(self.model.state, "new") 28 | self.assertTrue(can_proceed(self.model.remove)) 29 | self.model.remove() 30 | 31 | self.assertEqual(self.model.state, "removed") 32 | self.assertFalse(can_proceed(self.model.remove)) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | copyright (c) 2010 Mikhail Podgurskiy 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | PROJECT_APPS = ( 4 | "django_fsm", 5 | "testapp", 6 | ) 7 | 8 | INSTALLED_APPS = ( 9 | "django.contrib.contenttypes", 10 | "django.contrib.auth", 11 | "guardian", 12 | ) + PROJECT_APPS 13 | 14 | AUTHENTICATION_BACKENDS = ( 15 | "django.contrib.auth.backends.ModelBackend", # this is default 16 | "guardian.backends.ObjectPermissionBackend", 17 | ) 18 | 19 | DATABASE_ENGINE = "sqlite3" 20 | SECRET_KEY = "nokey" 21 | MIDDLEWARE_CLASSES = () 22 | DATABASES = { 23 | "default": { 24 | "ENGINE": "django.db.backends.sqlite3", 25 | } 26 | } 27 | 28 | if django.VERSION < (1, 9): 29 | 30 | class DisableMigrations(object): 31 | def __contains__(self, item): 32 | return True 33 | 34 | def __getitem__(self, item): 35 | return "notmigrations" 36 | 37 | MIGRATION_MODULES = DisableMigrations() 38 | else: 39 | MIGRATION_MODULES = { 40 | "auth": None, 41 | "contenttypes": None, 42 | "guardian": None, 43 | } 44 | 45 | 46 | ANONYMOUS_USER_ID = 0 47 | 48 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 49 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_multidecorators.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMField, transition 4 | from django_fsm.signals import post_transition 5 | 6 | 7 | class TestModel(models.Model): 8 | counter = models.IntegerField(default=0) 9 | signal_counter = models.IntegerField(default=0) 10 | state = FSMField(default="SUBMITTED_BY_USER") 11 | 12 | @transition(field=state, source="SUBMITTED_BY_USER", target="REVIEW_USER") 13 | @transition(field=state, source="SUBMITTED_BY_ADMIN", target="REVIEW_ADMIN") 14 | @transition(field=state, source="SUBMITTED_BY_ANONYMOUS", target="REVIEW_ANONYMOUS") 15 | def review(self): 16 | self.counter += 1 17 | 18 | class Meta: 19 | app_label = "testapp" 20 | 21 | 22 | def count_calls(sender, instance, name, source, target, **kwargs): 23 | instance.signal_counter += 1 24 | 25 | 26 | post_transition.connect(count_calls, sender=TestModel) 27 | 28 | 29 | class TestStateProxy(TestCase): 30 | def test_transition_method_called_once(self): 31 | model = TestModel() 32 | model.review() 33 | self.assertEqual(1, model.counter) 34 | self.assertEqual(1, model.signal_counter) 35 | -------------------------------------------------------------------------------- /django_fsm/tests/test_integer_field.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMIntegerField, TransitionNotAllowed, transition 4 | 5 | 6 | class BlogPostStateEnum(object): 7 | NEW = 10 8 | PUBLISHED = 20 9 | HIDDEN = 30 10 | 11 | 12 | class BlogPostWithIntegerField(models.Model): 13 | state = FSMIntegerField(default=BlogPostStateEnum.NEW) 14 | 15 | @transition(field=state, source=BlogPostStateEnum.NEW, target=BlogPostStateEnum.PUBLISHED) 16 | def publish(self): 17 | pass 18 | 19 | @transition(field=state, source=BlogPostStateEnum.PUBLISHED, target=BlogPostStateEnum.HIDDEN) 20 | def hide(self): 21 | pass 22 | 23 | 24 | class BlogPostWithIntegerFieldTest(TestCase): 25 | def setUp(self): 26 | self.model = BlogPostWithIntegerField() 27 | 28 | def test_known_transition_should_succeed(self): 29 | self.model.publish() 30 | self.assertEqual(self.model.state, BlogPostStateEnum.PUBLISHED) 31 | 32 | self.model.hide() 33 | self.assertEqual(self.model.state, BlogPostStateEnum.HIDDEN) 34 | 35 | def test_unknow_transition_fails(self): 36 | self.assertRaises(TransitionNotAllowed, self.model.hide) 37 | -------------------------------------------------------------------------------- /django_fsm/tests/test_protected_fields.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import django 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from django_fsm import FSMField, FSMModelMixin, transition 8 | 9 | 10 | class RefreshableProtectedAccessModel(models.Model): 11 | status = FSMField(default='new', protected=True) 12 | 13 | @transition(field=status, source='new', target='published') 14 | def publish(self): 15 | pass 16 | 17 | class Meta: 18 | app_label = 'django_fsm' 19 | 20 | 21 | class RefreshableModel(FSMModelMixin, RefreshableProtectedAccessModel): 22 | pass 23 | 24 | 25 | class TestDirectAccessModels(TestCase): 26 | def test_no_direct_access(self): 27 | instance = RefreshableProtectedAccessModel() 28 | self.assertEqual(instance.status, 'new') 29 | 30 | def try_change(): 31 | instance.status = 'change' 32 | 33 | self.assertRaises(AttributeError, try_change) 34 | 35 | instance.publish() 36 | instance.save() 37 | self.assertEqual(instance.status, 'published') 38 | 39 | @unittest.skipIf(django.VERSION < (1, 8), "Django introduced refresh_from_db in 1.8") 40 | def test_refresh_from_db(self): 41 | instance = RefreshableModel() 42 | instance.save() 43 | 44 | instance.refresh_from_db() 45 | -------------------------------------------------------------------------------- /django_fsm/tests/test_protected_field.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | 4 | from django_fsm import FSMField, transition 5 | 6 | 7 | class ProtectedAccessModel(models.Model): 8 | status = FSMField(default="new", protected=True) 9 | 10 | @transition(field=status, source="new", target="published") 11 | def publish(self): 12 | pass 13 | 14 | class Meta: 15 | app_label = "django_fsm" 16 | 17 | 18 | class MultiProtectedAccessModel(models.Model): 19 | status1 = FSMField(default="new", protected=True) 20 | status2 = FSMField(default="new", protected=True) 21 | 22 | class Meta: 23 | app_label = "django_fsm" 24 | 25 | 26 | class TestDirectAccessModels(TestCase): 27 | def test_multi_protected_field_create(self): 28 | obj = MultiProtectedAccessModel.objects.create() 29 | self.assertEqual(obj.status1, "new") 30 | self.assertEqual(obj.status2, "new") 31 | 32 | def test_no_direct_access(self): 33 | instance = ProtectedAccessModel() 34 | self.assertEqual(instance.status, "new") 35 | 36 | def try_change(): 37 | instance.status = "change" 38 | 39 | self.assertRaises(AttributeError, try_change) 40 | 41 | instance.publish() 42 | instance.save() 43 | self.assertEqual(instance.status, "published") 44 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_model_create_with_generic.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.contrib.contenttypes.fields import GenericForeignKey 3 | except ImportError: 4 | # Django 1.6 5 | from django.contrib.contenttypes.generic import GenericForeignKey 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.db import models 8 | from django.test import TestCase 9 | from django_fsm import FSMField, transition 10 | 11 | 12 | class Ticket(models.Model): 13 | class Meta: 14 | app_label = "testapp" 15 | 16 | 17 | class Task(models.Model): 18 | class STATE: 19 | NEW = "new" 20 | DONE = "done" 21 | 22 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 23 | object_id = models.PositiveIntegerField() 24 | causality = GenericForeignKey("content_type", "object_id") 25 | state = FSMField(default=STATE.NEW) 26 | 27 | @transition(field=state, source=STATE.NEW, target=STATE.DONE) 28 | def do(self): 29 | pass 30 | 31 | class Meta: 32 | app_label = "testapp" 33 | 34 | 35 | class Test(TestCase): 36 | def setUp(self): 37 | self.ticket = Ticket.objects.create() 38 | 39 | def test_model_objects_create(self): 40 | """Check a model with state field can be created 41 | if one of the other fields is a property or a virtual field. 42 | """ 43 | Task.objects.create(causality=self.ticket) 44 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | persistent=yes 3 | 4 | [MESSAGES CONTROL] 5 | # C0111 = Missing docstring 6 | # I0011 = # Warning locally suppressed using disable-msg 7 | # I0012 = # Warning locally suppressed using disable-msg 8 | disable=I0011,I0012 9 | 10 | [REPORTS] 11 | output-format=parseable 12 | include-ids=no 13 | 14 | [TYPECHECK] 15 | 16 | # Tells whether missing members accessed in mixin class should be ignored. A 17 | # mixin class is detected if its name ends with "mixin" (case insensitive). 18 | ignore-mixin-members=yes 19 | 20 | # List of classes names for which member attributes should not be checked 21 | # (useful for classes with attributes dynamically set). 22 | ignored-classes=SQLObject 23 | 24 | # When zope mode is activated, add a predefined set of Zope acquired attributes 25 | # to generated-members. 26 | zope=no 27 | 28 | # List of members which are set dynamically and missed by pylint inference 29 | # system, and so shouldn't trigger E0201 when accessed. 30 | generated-members=REQUEST,acl_users,aq_parent 31 | 32 | 33 | [VARIABLES] 34 | init-import=no 35 | 36 | 37 | [SIMILARITIES] 38 | min-similarity-lines=4 39 | ignore-comments=yes 40 | ignore-docstrings=yes 41 | 42 | 43 | [MISCELLANEOUS] 44 | notes=FIXME,XXX,TODO 45 | 46 | 47 | [FORMAT] 48 | max-line-length=160 49 | max-module-lines=500 50 | indent-string=' ' 51 | 52 | 53 | [DESIGN] 54 | max-args=5 55 | max-locals=15 56 | max-returns=6 57 | max-branchs=12 58 | max-statements=50 59 | max-parents=7 60 | max-attributes=7 61 | min-public-methods=0 62 | max-public-methods=20 63 | 64 | -------------------------------------------------------------------------------- /django_fsm/tests/test_conditions.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMField, TransitionNotAllowed, transition, can_proceed 4 | 5 | 6 | def condition_func(instance): 7 | return True 8 | 9 | 10 | class BlogPostWithConditions(models.Model): 11 | state = FSMField(default="new") 12 | 13 | def model_condition(self): 14 | return True 15 | 16 | def unmet_condition(self): 17 | return False 18 | 19 | @transition(field=state, source="new", target="published", conditions=[condition_func, model_condition]) 20 | def publish(self): 21 | pass 22 | 23 | @transition(field=state, source="published", target="destroyed", conditions=[condition_func, unmet_condition]) 24 | def destroy(self): 25 | pass 26 | 27 | 28 | class ConditionalTest(TestCase): 29 | def setUp(self): 30 | self.model = BlogPostWithConditions() 31 | 32 | def test_initial_staet(self): 33 | self.assertEqual(self.model.state, "new") 34 | 35 | def test_known_transition_should_succeed(self): 36 | self.assertTrue(can_proceed(self.model.publish)) 37 | self.model.publish() 38 | self.assertEqual(self.model.state, "published") 39 | 40 | def test_unmet_condition(self): 41 | self.model.publish() 42 | self.assertEqual(self.model.state, "published") 43 | self.assertFalse(can_proceed(self.model.destroy)) 44 | self.assertRaises(TransitionNotAllowed, self.model.destroy) 45 | 46 | self.assertTrue(can_proceed(self.model.destroy, check_conditions=False)) 47 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_custom_data.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMField, transition 4 | 5 | 6 | class BlogPostWithCustomData(models.Model): 7 | state = FSMField(default="new") 8 | 9 | @transition(field=state, source="new", target="published", conditions=[], custom={"label": "Publish", "type": "*"}) 10 | def publish(self): 11 | pass 12 | 13 | @transition(field=state, source="published", target="destroyed", custom=dict(label="Destroy", type="manual")) 14 | def destroy(self): 15 | pass 16 | 17 | @transition(field=state, source="published", target="review", custom=dict(label="Periodic review", type="automated")) 18 | def review(self): 19 | pass 20 | 21 | class Meta: 22 | app_label = "testapp" 23 | 24 | 25 | class CustomTransitionDataTest(TestCase): 26 | def setUp(self): 27 | self.model = BlogPostWithCustomData() 28 | 29 | def test_initial_state(self): 30 | self.assertEqual(self.model.state, "new") 31 | transitions = list(self.model.get_available_state_transitions()) 32 | self.assertEqual(len(transitions), 1) 33 | self.assertEqual(transitions[0].target, "published") 34 | self.assertDictEqual(transitions[0].custom, {"label": "Publish", "type": "*"}) 35 | 36 | def test_all_transitions_have_custom_data(self): 37 | transitions = self.model.get_all_state_transitions() 38 | for t in transitions: 39 | self.assertIsNotNone(t.custom["label"]) 40 | self.assertIsNotNone(t.custom["type"]) 41 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_exception_transitions.py: -------------------------------------------------------------------------------- 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 | class Meta: 20 | app_label = "testapp" 21 | 22 | 23 | class FSMFieldExceptionTest(TestCase): 24 | def setUp(self): 25 | self.model = ExceptionalBlogPost() 26 | post_transition.connect(self.on_post_transition, sender=ExceptionalBlogPost) 27 | self.post_transition_data = None 28 | 29 | def on_post_transition(self, **kwargs): 30 | self.post_transition_data = kwargs 31 | 32 | def test_state_changed_after_fail(self): 33 | self.assertTrue(can_proceed(self.model.publish)) 34 | self.assertRaises(Exception, self.model.publish) 35 | self.assertEqual(self.model.state, "crashed") 36 | self.assertEqual(self.post_transition_data["target"], "crashed") 37 | self.assertTrue("exception" in self.post_transition_data) 38 | 39 | def test_state_not_changed_after_fail(self): 40 | self.assertTrue(can_proceed(self.model.delete)) 41 | self.assertRaises(Exception, self.model.delete) 42 | self.assertEqual(self.model.state, "new") 43 | self.assertIsNone(self.post_transition_data) 44 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_object_permissions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.db import models 3 | from django.test import TestCase 4 | from django.test.utils import override_settings 5 | 6 | from guardian.shortcuts import assign_perm 7 | 8 | from django_fsm import FSMField, transition, has_transition_perm 9 | 10 | 11 | class ObjectPermissionTestModel(models.Model): 12 | state = FSMField(default="new") 13 | 14 | @transition( 15 | field=state, 16 | source="new", 17 | target="published", 18 | on_error="failed", 19 | permission="testapp.can_publish_objectpermissiontestmodel", 20 | ) 21 | def publish(self): 22 | pass 23 | 24 | class Meta: 25 | app_label = "testapp" 26 | 27 | permissions = [ 28 | ("can_publish_objectpermissiontestmodel", "Can publish ObjectPermissionTestModel"), 29 | ] 30 | 31 | 32 | @override_settings( 33 | AUTHENTICATION_BACKENDS=("django.contrib.auth.backends.ModelBackend", "guardian.backends.ObjectPermissionBackend") 34 | ) 35 | class ObjectPermissionFSMFieldTest(TestCase): 36 | def setUp(self): 37 | super(ObjectPermissionFSMFieldTest, self).setUp() 38 | self.model = ObjectPermissionTestModel.objects.create() 39 | 40 | self.unprivileged = User.objects.create(username="unpriviledged") 41 | self.privileged = User.objects.create(username="object_only_privileged") 42 | assign_perm("can_publish_objectpermissiontestmodel", self.privileged, self.model) 43 | 44 | def test_object_only_access_success(self): 45 | self.assertTrue(has_transition_perm(self.model.publish, self.privileged)) 46 | self.model.publish() 47 | 48 | def test_object_only_other_access_prohibited(self): 49 | self.assertFalse(has_transition_perm(self.model.publish, self.unprivileged)) 50 | -------------------------------------------------------------------------------- /django_fsm/tests/test_proxy_inheritance.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | 4 | from django_fsm import FSMField, transition, can_proceed 5 | 6 | 7 | class BaseModel(models.Model): 8 | state = FSMField(default="new") 9 | 10 | @transition(field=state, source="new", target="published") 11 | def publish(self): 12 | pass 13 | 14 | 15 | class InheritedModel(BaseModel): 16 | @transition(field="state", source="published", target="sticked") 17 | def stick(self): 18 | pass 19 | 20 | class Meta: 21 | proxy = True 22 | 23 | 24 | class TestinheritedModel(TestCase): 25 | def setUp(self): 26 | self.model = InheritedModel() 27 | 28 | def test_known_transition_should_succeed(self): 29 | self.assertTrue(can_proceed(self.model.publish)) 30 | self.model.publish() 31 | self.assertEqual(self.model.state, "published") 32 | 33 | self.assertTrue(can_proceed(self.model.stick)) 34 | self.model.stick() 35 | self.assertEqual(self.model.state, "sticked") 36 | 37 | def test_field_available_transitions_works(self): 38 | self.model.publish() 39 | self.assertEqual(self.model.state, "published") 40 | transitions = self.model.get_available_state_transitions() 41 | self.assertEqual(["sticked"], [data.target for data in transitions]) 42 | 43 | def test_field_all_transitions_base_model(self): 44 | transitions = BaseModel().get_all_state_transitions() 45 | self.assertEqual(set([("new", "published")]), set((data.source, data.target) for data in transitions)) 46 | 47 | def test_field_all_transitions_works(self): 48 | transitions = self.model.get_all_state_transitions() 49 | self.assertEqual( 50 | set([("new", "published"), ("published", "sticked")]), set((data.source, data.target) for data in transitions) 51 | ) 52 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_state_transitions.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMField, transition 4 | 5 | 6 | class Insect(models.Model): 7 | class STATE: 8 | CATERPILLAR = "CTR" 9 | BUTTERFLY = "BTF" 10 | 11 | STATE_CHOICES = ((STATE.CATERPILLAR, "Caterpillar", "Caterpillar"), (STATE.BUTTERFLY, "Butterfly", "Butterfly")) 12 | 13 | state = FSMField(default=STATE.CATERPILLAR, state_choices=STATE_CHOICES) 14 | 15 | @transition(field=state, source=STATE.CATERPILLAR, target=STATE.BUTTERFLY) 16 | def cocoon(self): 17 | pass 18 | 19 | def fly(self): 20 | raise NotImplementedError 21 | 22 | def crawl(self): 23 | raise NotImplementedError 24 | 25 | class Meta: 26 | app_label = "testapp" 27 | 28 | 29 | class Caterpillar(Insect): 30 | def crawl(self): 31 | """ 32 | Do crawl 33 | """ 34 | 35 | class Meta: 36 | app_label = "testapp" 37 | proxy = True 38 | 39 | 40 | class Butterfly(Insect): 41 | def fly(self): 42 | """ 43 | Do fly 44 | """ 45 | 46 | class Meta: 47 | app_label = "testapp" 48 | proxy = True 49 | 50 | 51 | class TestStateProxy(TestCase): 52 | def test_initial_proxy_set_succeed(self): 53 | insect = Insect() 54 | self.assertTrue(isinstance(insect, Caterpillar)) 55 | 56 | def test_transition_proxy_set_succeed(self): 57 | insect = Insect() 58 | insect.cocoon() 59 | self.assertTrue(isinstance(insect, Butterfly)) 60 | 61 | def test_load_proxy_set(self): 62 | Insect.objects.create(state=Insect.STATE.CATERPILLAR) 63 | Insect.objects.create(state=Insect.STATE.BUTTERFLY) 64 | 65 | insects = Insect.objects.all() 66 | self.assertEqual(set([Caterpillar, Butterfly]), set(insect.__class__ for insect in insects)) 67 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User, Permission 2 | from django.test import TestCase 3 | 4 | from django_fsm import has_transition_perm 5 | from testapp.models import BlogPost 6 | 7 | 8 | class PermissionFSMFieldTest(TestCase): 9 | def setUp(self): 10 | self.model = BlogPost() 11 | self.unpriviledged = User.objects.create(username="unpriviledged") 12 | self.priviledged = User.objects.create(username="priviledged") 13 | self.staff = User.objects.create(username="staff", is_staff=True) 14 | 15 | self.priviledged.user_permissions.add(Permission.objects.get_by_natural_key("can_publish_post", "testapp", "blogpost")) 16 | self.priviledged.user_permissions.add(Permission.objects.get_by_natural_key("can_remove_post", "testapp", "blogpost")) 17 | 18 | def test_proviledged_access_succed(self): 19 | self.assertTrue(has_transition_perm(self.model.publish, self.priviledged)) 20 | self.assertTrue(has_transition_perm(self.model.remove, self.priviledged)) 21 | 22 | transitions = self.model.get_available_user_state_transitions(self.priviledged) 23 | self.assertEqual(set(["publish", "remove", "moderate"]), set(transition.name for transition in transitions)) 24 | 25 | def test_unpriviledged_access_prohibited(self): 26 | self.assertFalse(has_transition_perm(self.model.publish, self.unpriviledged)) 27 | self.assertFalse(has_transition_perm(self.model.remove, self.unpriviledged)) 28 | 29 | transitions = self.model.get_available_user_state_transitions(self.unpriviledged) 30 | self.assertEqual(set(["moderate"]), set(transition.name for transition in transitions)) 31 | 32 | def test_permission_instance_method(self): 33 | self.assertFalse(has_transition_perm(self.model.restore, self.unpriviledged)) 34 | self.assertTrue(has_transition_perm(self.model.restore, self.staff)) 35 | -------------------------------------------------------------------------------- /django_fsm/tests/test_abstract_inheritance.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | 4 | from django_fsm import FSMField, transition, can_proceed 5 | 6 | 7 | class BaseAbstractModel(models.Model): 8 | state = FSMField(default="new") 9 | 10 | class Meta: 11 | abstract = True 12 | 13 | @transition(field=state, source="new", target="published") 14 | def publish(self): 15 | pass 16 | 17 | 18 | class AnotherFromAbstractModel(BaseAbstractModel): 19 | """ 20 | This class exists to trigger a regression when multiple concrete classes 21 | inherit from a shared abstract class (example: BaseAbstractModel). 22 | Don't try to remove it. 23 | """ 24 | @transition(field="state", source="published", target="sticked") 25 | def stick(self): 26 | pass 27 | 28 | 29 | class InheritedFromAbstractModel(BaseAbstractModel): 30 | @transition(field="state", source="published", target="sticked") 31 | def stick(self): 32 | pass 33 | 34 | 35 | class TestinheritedModel(TestCase): 36 | def setUp(self): 37 | self.model = InheritedFromAbstractModel() 38 | 39 | def test_known_transition_should_succeed(self): 40 | self.assertTrue(can_proceed(self.model.publish)) 41 | self.model.publish() 42 | self.assertEqual(self.model.state, "published") 43 | 44 | self.assertTrue(can_proceed(self.model.stick)) 45 | self.model.stick() 46 | self.assertEqual(self.model.state, "sticked") 47 | 48 | def test_field_available_transitions_works(self): 49 | self.model.publish() 50 | self.assertEqual(self.model.state, "published") 51 | transitions = self.model.get_available_state_transitions() 52 | self.assertEqual(["sticked"], [data.target for data in transitions]) 53 | 54 | def test_field_all_transitions_works(self): 55 | transitions = self.model.get_all_state_transitions() 56 | self.assertEqual( 57 | set([("new", "published"), ("published", "sticked")]), set((data.source, data.target) for data in transitions) 58 | ) 59 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | # py26-dj{16} 4 | py27-dj{16,18,19,110,111} 5 | # py33-dj{16,18} 6 | py{34,35,36}-dj{18,19,110,111} 7 | py{36,37}-dj{20,21} 8 | py{37,38}-dj{22,30,31,32} 9 | py{38,310}-dj{40,41} 10 | py{310}-dj{42,50} 11 | skipsdist = True 12 | 13 | [testenv] 14 | deps = 15 | py26: ipython==2.1.0 16 | {py27,py32,py33}: ipython==5.4.1 17 | {py34,py35,py36}: ipython==6.1.0 18 | {py37}: ipython==7.4.0 19 | 20 | dj16: Django==1.6.11 21 | dj16: coverage<=3.999 22 | dj16: django-guardian==1.3.2 23 | 24 | dj18: Django==1.8.19 25 | dj18: coverage==4.1 26 | dj18: django-guardian==1.4.4 27 | 28 | dj19: Django==1.9.13 29 | dj19: coverage==4.1 30 | dj19: django-guardian==1.4.4 31 | 32 | dj110: Django==1.10.8 33 | dj110: coverage==4.1 34 | dj110: django-guardian==1.4.4 35 | 36 | dj111: Django==1.11.26 37 | dj111: coverage==4.5.4 38 | dj111: django-guardian==1.4.8 39 | 40 | dj20: Django==2.0.13 41 | dj20: coverage==4.5.4 42 | dj20: django-guardian==1.5.0 43 | 44 | dj21: Django==2.1.15 45 | dj21: coverage==4.5.4 46 | dj21: django-guardian==1.5.0 47 | 48 | dj22: Django==2.2.24 49 | dj22: coverage==4.5.4 50 | dj22: django-guardian==2.1.0 51 | 52 | dj30: Django==3.0.14 53 | dj30: coverage==4.5.4 54 | dj30: django-guardian==2.1.0 55 | 56 | dj31: Django==3.1.13 57 | dj31: coverage==5.5 58 | dj31: django-guardian==2.3.0 59 | 60 | dj32: Django==3.2.9 61 | dj32: coverage==6.1.1 62 | dj32: django-guardian==2.4.0 63 | 64 | dj40: Django==4.0.7 65 | dj40: coverage==6.4.2 66 | dj40: django-guardian==2.4.0 67 | 68 | dj41: Django==4.1 69 | dj41: coverage==6.4.3 70 | dj41: django-guardian==2.4.0 71 | 72 | dj42: Django==4.2.8 73 | dj42: coverage==7.3.4 74 | dj42: django-guardian==2.4.0 75 | 76 | dj50: Django==5.0 77 | dj50: coverage==7.3.4 78 | dj50: django-guardian==2.4.0 79 | 80 | graphviz==0.7.1 81 | pep8==1.7.1 82 | pyflakes==1.6.0 83 | commands = {posargs:python ./tests/manage.py test} 84 | 85 | 86 | [flake8] 87 | max-line-length = 130 88 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django friendly finite state machine support 2 | ============================================ 3 | 4 | Django-fsm first came out in 2010 and had a big update in 2.0 release at 2014, making it incompatible with earlier versions. Now, ten years later at 2024, it's been updated to version 3.0 and renamed viewflow.fsm. 5 | 6 | This new version has a different API that doesn't work with the old one but is better suited for today's needs. 7 | 8 | Migration guide: 9 | 10 | https://github.com/viewflow/viewflow/wiki/django%E2%80%90fsm-to-viewflow.fsm-Migration-Guide 11 | 12 | 13 | About 14 | ===== 15 | 16 | Finite state machine workflows is the declarative way to describe consecutive 17 | operation through set of states and transitions between them. 18 | 19 | 20 | :mod:`viewflow.fsm` can help you manage rules and restrictions around moving 21 | from one state to another. The package could be used to get low level 22 | db-independent fsm implementation, or to wrap existing database model, and 23 | implement simple persistent workflow process with quickly bootstrapped UI. 24 | 25 | Quick start 26 | =========== 27 | 28 | All things are buit around :class:`viewflow.fsm.State`. It is the special class 29 | slot, that can take a value only from a specific `python enum`_ or `django 30 | enumeration type`_ and that value can't be changed with simple assignement. 31 | 32 | .. code:: 33 | 34 | from enum import Enum 35 | from viewflow.fsm import State 36 | 37 | class Stage(Enum): 38 | NEW = 1 39 | DONE = 2 40 | HIDDEN = 3 41 | 42 | 43 | class MyFlow(object): 44 | state = State(Stage, default=Stage.NEW) 45 | 46 | @state.transition(source=Stage.NEW, target=Stage.DONE) 47 | def complete(): 48 | pass 49 | 50 | @state.transition(source=State.ANY, target=Stage.HIDDEN) 51 | def hide(): 52 | pass 53 | 54 | flow = MyFlow() 55 | flow.state == Stage.NEW # True 56 | flow.state = Stage.DONE # Raises AttributeError 57 | 58 | flow.complete() 59 | flow.state == Stage.DONE # True 60 | 61 | flow.complete() # Now raises TransitionNotAllowed 62 | 63 | 64 | Documentation 65 | ============= 66 | 67 | Full documentation available at https://docs.viewflow.io/fsm/index.html 68 | 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | try: 4 | long_description = open("README.rst").read() 5 | except IOError: 6 | long_description = "" 7 | 8 | setup( 9 | name="django-fsm", 10 | version="3.0.0", 11 | description="Django friendly finite state machine support.", 12 | author="Mikhail Podgurskiy", 13 | author_email="kmmbvnr@gmail.com", 14 | url="http://github.com/kmmbvnr/django-fsm", 15 | keywords="django", 16 | packages=["django_fsm", "django_fsm.management", "django_fsm.management.commands"], 17 | include_package_data=True, 18 | zip_safe=False, 19 | license="MIT License", 20 | platforms=["any"], 21 | classifiers=[ 22 | "Development Status :: 7 - Inactive", 23 | "Environment :: Web Environment", 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: MIT License", 26 | "Operating System :: OS Independent", 27 | "Framework :: Django", 28 | "Framework :: Django :: 1.6", 29 | "Framework :: Django :: 1.8", 30 | "Framework :: Django :: 1.9", 31 | "Framework :: Django :: 1.10", 32 | "Framework :: Django :: 1.11", 33 | "Framework :: Django :: 2.0", 34 | "Framework :: Django :: 2.1", 35 | "Framework :: Django :: 2.2", 36 | "Framework :: Django :: 3.1", 37 | "Framework :: Django :: 3.2", 38 | "Framework :: Django :: 4.0", 39 | "Framework :: Django :: 4.1", 40 | "Framework :: Django :: 5.0", 41 | "Programming Language :: Python", 42 | "Programming Language :: Python :: 2.6", 43 | "Programming Language :: Python :: 2.7", 44 | "Programming Language :: Python :: 3", 45 | "Programming Language :: Python :: 3.4", 46 | "Programming Language :: Python :: 3.5", 47 | "Programming Language :: Python :: 3.6", 48 | "Programming Language :: Python :: 3.7", 49 | "Programming Language :: Python :: 3.8", 50 | "Programming Language :: Python :: 3.9", 51 | "Programming Language :: Python :: 3.10", 52 | "Programming Language :: Python :: 3.11", 53 | "Programming Language :: Python :: 3.12", 54 | "Programming Language :: Python :: 3.13", 55 | "Framework :: Django", 56 | "Topic :: Software Development :: Libraries :: Python Modules", 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_multi_resultstate.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMField, transition, RETURN_VALUE, GET_STATE 4 | from django_fsm.signals import pre_transition, post_transition 5 | 6 | 7 | class MultiResultTest(models.Model): 8 | state = FSMField(default="new") 9 | 10 | @transition(field=state, source="new", target=RETURN_VALUE("for_moderators", "published")) 11 | def publish(self, is_public=False): 12 | return "published" if is_public else "for_moderators" 13 | 14 | @transition( 15 | field=state, 16 | source="for_moderators", 17 | target=GET_STATE(lambda self, allowed: "published" if allowed else "rejected", states=["published", "rejected"]), 18 | ) 19 | def moderate(self, allowed): 20 | pass 21 | 22 | class Meta: 23 | app_label = "testapp" 24 | 25 | 26 | class Test(TestCase): 27 | def test_return_state_succeed(self): 28 | instance = MultiResultTest() 29 | instance.publish(is_public=True) 30 | self.assertEqual(instance.state, "published") 31 | 32 | def test_get_state_succeed(self): 33 | instance = MultiResultTest(state="for_moderators") 34 | instance.moderate(allowed=False) 35 | self.assertEqual(instance.state, "rejected") 36 | 37 | 38 | class TestSignals(TestCase): 39 | def setUp(self): 40 | self.pre_transition_called = False 41 | self.post_transition_called = False 42 | pre_transition.connect(self.on_pre_transition, sender=MultiResultTest) 43 | post_transition.connect(self.on_post_transition, sender=MultiResultTest) 44 | 45 | def on_pre_transition(self, sender, instance, name, source, target, **kwargs): 46 | self.assertEqual(instance.state, source) 47 | self.pre_transition_called = True 48 | 49 | def on_post_transition(self, sender, instance, name, source, target, **kwargs): 50 | self.assertEqual(instance.state, target) 51 | self.post_transition_called = True 52 | 53 | def test_signals_called_with_get_state(self): 54 | instance = MultiResultTest(state="for_moderators") 55 | instance.moderate(allowed=False) 56 | self.assertTrue(self.pre_transition_called) 57 | self.assertTrue(self.post_transition_called) 58 | 59 | def test_signals_called_with_return_value(self): 60 | instance = MultiResultTest() 61 | instance.publish(is_public=True) 62 | self.assertTrue(self.pre_transition_called) 63 | self.assertTrue(self.post_transition_called) 64 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_lock_mixin.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import django 4 | from django.db import models 5 | from django.test import TestCase 6 | from django_fsm import FSMField, ConcurrentTransitionMixin, ConcurrentTransition, transition 7 | 8 | 9 | class LockedBlogPost(ConcurrentTransitionMixin, models.Model): 10 | state = FSMField(default="new") 11 | text = models.CharField(max_length=50) 12 | 13 | @transition(field=state, source="new", target="published") 14 | def publish(self): 15 | pass 16 | 17 | @transition(field=state, source="published", target="removed") 18 | def remove(self): 19 | pass 20 | 21 | class Meta: 22 | app_label = "testapp" 23 | 24 | 25 | class ExtendedBlogPost(LockedBlogPost): 26 | review_state = FSMField(default="waiting", protected=True) 27 | notes = models.CharField(max_length=50) 28 | 29 | @transition(field=review_state, source="waiting", target="rejected") 30 | def reject(self): 31 | pass 32 | 33 | class Meta: 34 | app_label = "testapp" 35 | 36 | 37 | class TestLockMixin(TestCase): 38 | def test_create_succeed(self): 39 | LockedBlogPost.objects.create(text="test_create_succeed") 40 | 41 | def test_crud_succeed(self): 42 | post = LockedBlogPost(text="test_crud_succeed") 43 | post.publish() 44 | post.save() 45 | 46 | post = LockedBlogPost.objects.get(pk=post.pk) 47 | self.assertEqual("published", post.state) 48 | post.text = "test_crud_succeed2" 49 | post.save() 50 | 51 | post = LockedBlogPost.objects.get(pk=post.pk) 52 | self.assertEqual("test_crud_succeed2", post.text) 53 | 54 | post.delete() 55 | 56 | def test_save_and_change_succeed(self): 57 | post = LockedBlogPost(text="test_crud_succeed") 58 | post.publish() 59 | post.save() 60 | 61 | post.remove() 62 | post.save() 63 | 64 | post.delete() 65 | 66 | def test_concurrent_modifications_raise_exception(self): 67 | post1 = LockedBlogPost.objects.create() 68 | post2 = LockedBlogPost.objects.get(pk=post1.pk) 69 | 70 | post1.publish() 71 | post1.save() 72 | 73 | post2.text = "aaa" 74 | post2.publish() 75 | with self.assertRaises(ConcurrentTransition): 76 | post2.save() 77 | 78 | def test_inheritance_crud_succeed(self): 79 | post = ExtendedBlogPost(text="test_inheritance_crud_succeed", notes="reject me") 80 | post.publish() 81 | post.save() 82 | 83 | post = ExtendedBlogPost.objects.get(pk=post.pk) 84 | self.assertEqual("published", post.state) 85 | post.text = "test_inheritance_crud_succeed2" 86 | post.reject() 87 | post.save() 88 | 89 | post = ExtendedBlogPost.objects.get(pk=post.pk) 90 | self.assertEqual("rejected", post.review_state) 91 | self.assertEqual("test_inheritance_crud_succeed2", post.text) 92 | 93 | @unittest.skipIf(django.VERSION[:3] < (1, 8, 0), "Available on django 1.8+") 94 | def test_concurrent_modifications_after_refresh_db_succeed(self): # bug 255 95 | post1 = LockedBlogPost.objects.create() 96 | post2 = LockedBlogPost.objects.get(pk=post1.pk) 97 | 98 | post1.publish() 99 | post1.save() 100 | 101 | post2.refresh_from_db() 102 | post2.remove() 103 | post2.save() 104 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django_fsm import FSMField, FSMKeyField, transition 3 | 4 | 5 | class Application(models.Model): 6 | """ 7 | Student application need to be approved by dept chair and dean. 8 | Test workflow 9 | """ 10 | 11 | state = FSMField(default="new") 12 | 13 | @transition(field=state, source="new", target="draft") 14 | def draft(self): 15 | pass 16 | 17 | @transition(field=state, source=["new", "draft"], target="dept") 18 | def to_approvement(self): 19 | pass 20 | 21 | @transition(field=state, source="dept", target="dean") 22 | def dept_approved(self): 23 | pass 24 | 25 | @transition(field=state, source="dept", target="new") 26 | def dept_rejected(self): 27 | pass 28 | 29 | @transition(field=state, source="dean", target="done") 30 | def dean_approved(self): 31 | pass 32 | 33 | @transition(field=state, source="dean", target="dept") 34 | def dean_rejected(self): 35 | pass 36 | 37 | 38 | class FKApplication(models.Model): 39 | """ 40 | Student application need to be approved by dept chair and dean. 41 | Test workflow for FSMKeyField 42 | """ 43 | 44 | state = FSMKeyField("testapp.DbState", default="new", on_delete=models.CASCADE) 45 | 46 | @transition(field=state, source="new", target="draft") 47 | def draft(self): 48 | pass 49 | 50 | @transition(field=state, source=["new", "draft"], target="dept") 51 | def to_approvement(self): 52 | pass 53 | 54 | @transition(field=state, source="dept", target="dean") 55 | def dept_approved(self): 56 | pass 57 | 58 | @transition(field=state, source="dept", target="new") 59 | def dept_rejected(self): 60 | pass 61 | 62 | @transition(field=state, source="dean", target="done") 63 | def dean_approved(self): 64 | pass 65 | 66 | @transition(field=state, source="dean", target="dept") 67 | def dean_rejected(self): 68 | pass 69 | 70 | 71 | class DbState(models.Model): 72 | """ 73 | States in DB 74 | """ 75 | 76 | id = models.CharField(primary_key=True, max_length=50) 77 | 78 | label = models.CharField(max_length=255) 79 | 80 | def __unicode__(self): 81 | return self.label 82 | 83 | 84 | class BlogPost(models.Model): 85 | """ 86 | Test workflow 87 | """ 88 | 89 | state = FSMField(default="new", protected=True) 90 | 91 | def can_restore(self, user): 92 | return user.is_superuser or user.is_staff 93 | 94 | @transition(field=state, source="new", target="published", on_error="failed", permission="testapp.can_publish_post") 95 | def publish(self): 96 | pass 97 | 98 | @transition(field=state, source="published") 99 | def notify_all(self): 100 | pass 101 | 102 | @transition( 103 | field=state, 104 | source="published", 105 | target="hidden", 106 | on_error="failed", 107 | ) 108 | def hide(self): 109 | pass 110 | 111 | @transition( 112 | field=state, 113 | source="new", 114 | target="removed", 115 | on_error="failed", 116 | permission=lambda self, u: u.has_perm("testapp.can_remove_post"), 117 | ) 118 | def remove(self): 119 | raise Exception("No rights to delete %s" % self) 120 | 121 | @transition(field=state, source="new", target="restored", on_error="failed", permission=can_restore) 122 | def restore(self): 123 | pass 124 | 125 | @transition(field=state, source=["published", "hidden"], target="stolen") 126 | def steal(self): 127 | pass 128 | 129 | @transition(field=state, source="*", target="moderated") 130 | def moderate(self): 131 | pass 132 | 133 | class Meta: 134 | permissions = [ 135 | ("can_publish_post", "Can publish post"), 136 | ("can_remove_post", "Can remove post"), 137 | ] 138 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | django-fsm 2.8.2 2024-04-09 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | - Fix graph_transitions commnad for Django>=4.0 8 | - Preserve chosen "using" DB in ConcurentTransitionMixin 9 | - Fix error message in GET_STATE 10 | - Implement Transition __hash__ and __eq__ for 'in' operator 11 | 12 | 13 | django-fsm 2.8.1 2022-08-15 14 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 15 | 16 | - Improve fix for get_available_FIELD_transition 17 | 18 | 19 | django-fsm 2.8.0 2021-11-05 20 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 21 | 22 | - Fix get_available_FIELD_transition on django>=3.2 23 | - Fix refresh_from_db for ConcurrentTransitionMixin 24 | 25 | 26 | django-fsm 2.7.1 2020-10-13 27 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 28 | 29 | - Fix warnings on Django 3.1+ 30 | 31 | 32 | django-fsm 2.7.0 2019-12-03 33 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 34 | 35 | - Django 3.0 support 36 | - Test on Python 3.8 37 | 38 | 39 | django-fsm 2.6.1 2019-04-19 40 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 41 | 42 | - Update pypi classifiers to latest django/python supported versions 43 | - Several fixes for graph_transition command 44 | 45 | 46 | django-fsm 2.6.0 2017-06-08 47 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 48 | 49 | - Fix django 1.11 compatibility 50 | - Fix TypeError in `graph_transitions` command when using django's lazy translations 51 | 52 | 53 | django-fsm 2.5.0 2017-03-04 54 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 55 | 56 | - graph_transition command fix for django 1.10 57 | - graph_transition command supports GET_STATE targets 58 | - signal data extended with method args/kwargs and field 59 | - sets allowed to be passed to the transition decorator 60 | 61 | 62 | django-fsm 2.4.0 2016-05-14 63 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 64 | 65 | - graph_transition commnad now works with multiple FSM's per model 66 | - Add ability to set target state from transition return value or callable 67 | 68 | 69 | django-fsm 2.3.0 2015-10-15 70 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 71 | 72 | - Add source state shortcut '+' to specify transitions from all states except the target 73 | - Add object-level permission checks 74 | - Fix translated labels for graph of FSMIntegerField 75 | - Fix multiple signals for several transition decorators 76 | 77 | 78 | django-fsm 2.2.1 2015-04-27 79 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 80 | 81 | - Improved exception message for unmet transition conditions. 82 | - Don't send post transition signal in case of no state changes on 83 | exception 84 | - Allow empty string as correct state value 85 | - Improved graphviz fsm visualisation 86 | - Clean django 1.8 warnings 87 | 88 | django-fsm 2.2.0 2014-09-03 89 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 90 | 91 | - Support for `class 92 | substitution `__ 93 | to proxy classes depending on the state 94 | - Added ConcurrentTransitionMixin with optimistic locking support 95 | - Default db\_index=True for FSMIntegerField removed 96 | - Graph transition code migrated to new graphviz library with python 3 97 | support 98 | - Ability to change state on transition exception 99 | 100 | django-fsm 2.1.0 2014-05-15 101 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 102 | 103 | - Support for attaching permission checks on model transitions 104 | 105 | django-fsm 2.0.0 2014-03-15 106 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 107 | 108 | - Backward incompatible release 109 | - All public code import moved directly to django\_fsm package 110 | - Correct support for several @transitions decorator with different 111 | source states and conditions on same method 112 | - save parameter from transition decorator removed 113 | - get\_available\_FIELD\_transitions return Transition data object 114 | instead of tuple 115 | - Models got get\_available\_FIELD\_transitions, even if field 116 | specified as string reference 117 | - New get\_all\_FIELD\_transitions method contributed to class 118 | 119 | django-fsm 1.6.0 2014-03-15 120 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 121 | 122 | - FSMIntegerField and FSMKeyField support 123 | 124 | django-fsm 1.5.1 2014-01-04 125 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 126 | 127 | - Ad-hoc support for state fields from proxy and inherited models 128 | 129 | django-fsm 1.5.0 2013-09-17 130 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 131 | 132 | - Python 3 compatibility 133 | 134 | django-fsm 1.4.0 2011-12-21 135 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 136 | 137 | - Add graph\_transition command for drawing state transition picture 138 | 139 | django-fsm 1.3.0 2011-07-28 140 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 141 | 142 | - Add direct field modification protection 143 | 144 | django-fsm 1.2.0 2011-03-23 145 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 146 | 147 | - Add pre\_transition and post\_transition signals 148 | 149 | django-fsm 1.1.0 2011-02-22 150 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 151 | 152 | - Add support for transition conditions 153 | - Allow multiple FSMField in one model 154 | - Contribute get\_available\_FIELD\_transitions for model class 155 | 156 | django-fsm 1.0.0 2010-10-12 157 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 158 | 159 | - Initial public release 160 | -------------------------------------------------------------------------------- /django_fsm/tests/test_key_field.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django_fsm import FSMKeyField, TransitionNotAllowed, transition, can_proceed 4 | 5 | 6 | FK_AVAILABLE_STATES = ( 7 | ("New", "_NEW_"), 8 | ("Published", "_PUBLISHED_"), 9 | ("Hidden", "_HIDDEN_"), 10 | ("Removed", "_REMOVED_"), 11 | ("Stolen", "_STOLEN_"), 12 | ("Moderated", "_MODERATED_"), 13 | ) 14 | 15 | 16 | class DBState(models.Model): 17 | id = models.CharField(primary_key=True, max_length=50) 18 | 19 | label = models.CharField(max_length=255) 20 | 21 | def __unicode__(self): 22 | return self.label 23 | 24 | class Meta: 25 | app_label = "django_fsm" 26 | 27 | 28 | class FKBlogPost(models.Model): 29 | state = FSMKeyField(DBState, default="new", protected=True, on_delete=models.CASCADE) 30 | 31 | @transition(field=state, source="new", target="published") 32 | def publish(self): 33 | pass 34 | 35 | @transition(field=state, source="published") 36 | def notify_all(self): 37 | pass 38 | 39 | @transition(field=state, source="published", target="hidden") 40 | def hide(self): 41 | pass 42 | 43 | @transition(field=state, source="new", target="removed") 44 | def remove(self): 45 | raise Exception("Upss") 46 | 47 | @transition(field=state, source=["published", "hidden"], target="stolen") 48 | def steal(self): 49 | pass 50 | 51 | @transition(field=state, source="*", target="moderated") 52 | def moderate(self): 53 | pass 54 | 55 | class Meta: 56 | app_label = "django_fsm" 57 | 58 | 59 | class FSMKeyFieldTest(TestCase): 60 | def setUp(self): 61 | for item in FK_AVAILABLE_STATES: 62 | DBState.objects.create(pk=item[0], label=item[1]) 63 | self.model = FKBlogPost() 64 | 65 | def test_initial_state_instatiated(self): 66 | self.assertEqual( 67 | self.model.state, 68 | "new", 69 | ) 70 | 71 | def test_known_transition_should_succeed(self): 72 | self.assertTrue(can_proceed(self.model.publish)) 73 | self.model.publish() 74 | self.assertEqual(self.model.state, "published") 75 | 76 | self.assertTrue(can_proceed(self.model.hide)) 77 | self.model.hide() 78 | self.assertEqual(self.model.state, "hidden") 79 | 80 | def test_unknow_transition_fails(self): 81 | self.assertFalse(can_proceed(self.model.hide)) 82 | self.assertRaises(TransitionNotAllowed, self.model.hide) 83 | 84 | def test_state_non_changed_after_fail(self): 85 | self.assertTrue(can_proceed(self.model.remove)) 86 | self.assertRaises(Exception, self.model.remove) 87 | self.assertEqual(self.model.state, "new") 88 | 89 | def test_allowed_null_transition_should_succeed(self): 90 | self.assertTrue(can_proceed(self.model.publish)) 91 | self.model.publish() 92 | self.model.notify_all() 93 | self.assertEqual(self.model.state, "published") 94 | 95 | def test_unknow_null_transition_should_fail(self): 96 | self.assertRaises(TransitionNotAllowed, self.model.notify_all) 97 | self.assertEqual(self.model.state, "new") 98 | 99 | def test_mutiple_source_support_path_1_works(self): 100 | self.model.publish() 101 | self.model.steal() 102 | self.assertEqual(self.model.state, "stolen") 103 | 104 | def test_mutiple_source_support_path_2_works(self): 105 | self.model.publish() 106 | self.model.hide() 107 | self.model.steal() 108 | self.assertEqual(self.model.state, "stolen") 109 | 110 | def test_star_shortcut_succeed(self): 111 | self.assertTrue(can_proceed(self.model.moderate)) 112 | self.model.moderate() 113 | self.assertEqual(self.model.state, "moderated") 114 | 115 | 116 | """ 117 | TODO FIX it 118 | class BlogPostStatus(models.Model): 119 | name = models.CharField(max_length=10, unique=True) 120 | objects = models.Manager() 121 | 122 | class Meta: 123 | app_label = 'django_fsm' 124 | 125 | 126 | class BlogPostWithFKState(models.Model): 127 | status = FSMKeyField(BlogPostStatus, default=lambda: BlogPostStatus.objects.get(name="new")) 128 | 129 | @transition(field=status, source='new', target='published') 130 | def publish(self): 131 | pass 132 | 133 | @transition(field=status, source='published', target='hidden') 134 | def hide(self): 135 | pass 136 | 137 | 138 | class BlogPostWithFKStateTest(TestCase): 139 | def setUp(self): 140 | BlogPostStatus.objects.create(name="new") 141 | BlogPostStatus.objects.create(name="published") 142 | BlogPostStatus.objects.create(name="hidden") 143 | self.model = BlogPostWithFKState() 144 | 145 | def test_known_transition_should_succeed(self): 146 | self.model.publish() 147 | self.assertEqual(self.model.state, 'published') 148 | 149 | self.model.hide() 150 | self.assertEqual(self.model.state, 'hidden') 151 | 152 | def test_unknow_transition_fails(self): 153 | self.assertRaises(TransitionNotAllowed, self.model.hide) 154 | """ 155 | -------------------------------------------------------------------------------- /django_fsm/tests/test_basic_transitions.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | 4 | from django_fsm import FSMField, TransitionNotAllowed, transition, can_proceed, Transition 5 | from django_fsm.signals import pre_transition, post_transition 6 | 7 | 8 | class BlogPost(models.Model): 9 | state = FSMField(default="new") 10 | 11 | @transition(field=state, source="new", target="published") 12 | def publish(self): 13 | pass 14 | 15 | @transition(source="published", field=state) 16 | def notify_all(self): 17 | pass 18 | 19 | @transition(source="published", target="hidden", field=state) 20 | def hide(self): 21 | pass 22 | 23 | @transition(source="new", target="removed", field=state) 24 | def remove(self): 25 | raise Exception("Upss") 26 | 27 | @transition(source=["published", "hidden"], target="stolen", field=state) 28 | def steal(self): 29 | pass 30 | 31 | @transition(source="*", target="moderated", field=state) 32 | def moderate(self): 33 | pass 34 | 35 | @transition(source="+", target="blocked", field=state) 36 | def block(self): 37 | pass 38 | 39 | @transition(source="*", target="", field=state) 40 | def empty(self): 41 | pass 42 | 43 | 44 | class FSMFieldTest(TestCase): 45 | def setUp(self): 46 | self.model = BlogPost() 47 | 48 | def test_initial_state_instantiated(self): 49 | self.assertEqual(self.model.state, "new") 50 | 51 | def test_known_transition_should_succeed(self): 52 | self.assertTrue(can_proceed(self.model.publish)) 53 | self.model.publish() 54 | self.assertEqual(self.model.state, "published") 55 | 56 | self.assertTrue(can_proceed(self.model.hide)) 57 | self.model.hide() 58 | self.assertEqual(self.model.state, "hidden") 59 | 60 | def test_unknown_transition_fails(self): 61 | self.assertFalse(can_proceed(self.model.hide)) 62 | self.assertRaises(TransitionNotAllowed, self.model.hide) 63 | 64 | def test_state_non_changed_after_fail(self): 65 | self.assertTrue(can_proceed(self.model.remove)) 66 | self.assertRaises(Exception, self.model.remove) 67 | self.assertEqual(self.model.state, "new") 68 | 69 | def test_allowed_null_transition_should_succeed(self): 70 | self.model.publish() 71 | self.model.notify_all() 72 | self.assertEqual(self.model.state, "published") 73 | 74 | def test_unknown_null_transition_should_fail(self): 75 | self.assertRaises(TransitionNotAllowed, self.model.notify_all) 76 | self.assertEqual(self.model.state, "new") 77 | 78 | def test_multiple_source_support_path_1_works(self): 79 | self.model.publish() 80 | self.model.steal() 81 | self.assertEqual(self.model.state, "stolen") 82 | 83 | def test_multiple_source_support_path_2_works(self): 84 | self.model.publish() 85 | self.model.hide() 86 | self.model.steal() 87 | self.assertEqual(self.model.state, "stolen") 88 | 89 | def test_star_shortcut_succeed(self): 90 | self.assertTrue(can_proceed(self.model.moderate)) 91 | self.model.moderate() 92 | self.assertEqual(self.model.state, "moderated") 93 | 94 | def test_plus_shortcut_succeeds_for_other_source(self): 95 | """Tests that the '+' shortcut succeeds for a source 96 | other than the target. 97 | """ 98 | self.assertTrue(can_proceed(self.model.block)) 99 | self.model.block() 100 | self.assertEqual(self.model.state, "blocked") 101 | 102 | def test_plus_shortcut_fails_for_same_source(self): 103 | """Tests that the '+' shortcut fails if the source 104 | equals the target. 105 | """ 106 | self.model.block() 107 | self.assertFalse(can_proceed(self.model.block)) 108 | self.assertRaises(TransitionNotAllowed, self.model.block) 109 | 110 | def test_empty_string_target(self): 111 | self.model.empty() 112 | self.assertEqual(self.model.state, "") 113 | 114 | 115 | class StateSignalsTests(TestCase): 116 | def setUp(self): 117 | self.model = BlogPost() 118 | self.pre_transition_called = False 119 | self.post_transition_called = False 120 | pre_transition.connect(self.on_pre_transition, sender=BlogPost) 121 | post_transition.connect(self.on_post_transition, sender=BlogPost) 122 | 123 | def on_pre_transition(self, sender, instance, name, source, target, **kwargs): 124 | self.assertEqual(instance.state, source) 125 | self.pre_transition_called = True 126 | 127 | def on_post_transition(self, sender, instance, name, source, target, **kwargs): 128 | self.assertEqual(instance.state, target) 129 | self.post_transition_called = True 130 | 131 | def test_signals_called_on_valid_transition(self): 132 | self.model.publish() 133 | self.assertTrue(self.pre_transition_called) 134 | self.assertTrue(self.post_transition_called) 135 | 136 | def test_signals_not_called_on_invalid_transition(self): 137 | self.assertRaises(TransitionNotAllowed, self.model.hide) 138 | self.assertFalse(self.pre_transition_called) 139 | self.assertFalse(self.post_transition_called) 140 | 141 | 142 | class TestFieldTransitionsInspect(TestCase): 143 | def setUp(self): 144 | self.model = BlogPost() 145 | 146 | def test_in_operator_for_available_transitions(self): 147 | # store the generator in a list, so we can reuse the generator and do multiple asserts 148 | transitions = list(self.model.get_available_state_transitions()) 149 | 150 | self.assertIn("publish", transitions) 151 | self.assertNotIn("xyz", transitions) 152 | 153 | # inline method for faking the name of the transition 154 | def publish(): 155 | pass 156 | 157 | obj = Transition( 158 | method=publish, 159 | source="", 160 | target="", 161 | on_error="", 162 | conditions="", 163 | permission="", 164 | custom="", 165 | ) 166 | 167 | self.assertTrue(obj in transitions) 168 | 169 | def test_available_conditions_from_new(self): 170 | transitions = self.model.get_available_state_transitions() 171 | actual = set((transition.source, transition.target) for transition in transitions) 172 | expected = set([("*", "moderated"), ("new", "published"), ("new", "removed"), ("*", ""), ("+", "blocked")]) 173 | self.assertEqual(actual, expected) 174 | 175 | def test_available_conditions_from_published(self): 176 | self.model.publish() 177 | transitions = self.model.get_available_state_transitions() 178 | actual = set((transition.source, transition.target) for transition in transitions) 179 | expected = set( 180 | [ 181 | ("*", "moderated"), 182 | ("published", None), 183 | ("published", "hidden"), 184 | ("published", "stolen"), 185 | ("*", ""), 186 | ("+", "blocked"), 187 | ] 188 | ) 189 | self.assertEqual(actual, expected) 190 | 191 | def test_available_conditions_from_hidden(self): 192 | self.model.publish() 193 | self.model.hide() 194 | transitions = self.model.get_available_state_transitions() 195 | actual = set((transition.source, transition.target) for transition in transitions) 196 | expected = set([("*", "moderated"), ("hidden", "stolen"), ("*", ""), ("+", "blocked")]) 197 | self.assertEqual(actual, expected) 198 | 199 | def test_available_conditions_from_stolen(self): 200 | self.model.publish() 201 | self.model.steal() 202 | transitions = self.model.get_available_state_transitions() 203 | actual = set((transition.source, transition.target) for transition in transitions) 204 | expected = set([("*", "moderated"), ("*", ""), ("+", "blocked")]) 205 | self.assertEqual(actual, expected) 206 | 207 | def test_available_conditions_from_blocked(self): 208 | self.model.block() 209 | transitions = self.model.get_available_state_transitions() 210 | actual = set((transition.source, transition.target) for transition in transitions) 211 | expected = set([("*", "moderated"), ("*", "")]) 212 | self.assertEqual(actual, expected) 213 | 214 | def test_available_conditions_from_empty(self): 215 | self.model.empty() 216 | transitions = self.model.get_available_state_transitions() 217 | actual = set((transition.source, transition.target) for transition in transitions) 218 | expected = set([("*", "moderated"), ("*", ""), ("+", "blocked")]) 219 | self.assertEqual(actual, expected) 220 | 221 | def test_all_conditions(self): 222 | transitions = self.model.get_all_state_transitions() 223 | 224 | actual = set((transition.source, transition.target) for transition in transitions) 225 | expected = set( 226 | [ 227 | ("*", "moderated"), 228 | ("new", "published"), 229 | ("new", "removed"), 230 | ("published", None), 231 | ("published", "hidden"), 232 | ("published", "stolen"), 233 | ("hidden", "stolen"), 234 | ("*", ""), 235 | ("+", "blocked"), 236 | ] 237 | ) 238 | self.assertEqual(actual, expected) 239 | -------------------------------------------------------------------------------- /django_fsm/management/commands/graph_transitions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8; mode: django -*- 2 | import graphviz 3 | from optparse import make_option 4 | from itertools import chain 5 | 6 | from django.core.management.base import BaseCommand 7 | try: 8 | from django.utils.encoding import force_text 9 | _requires_system_checks = True 10 | except ImportError: # Django >= 4.0 11 | from django.utils.encoding import force_str as force_text 12 | from django.core.management.base import ALL_CHECKS 13 | _requires_system_checks = ALL_CHECKS 14 | 15 | from django_fsm import FSMFieldMixin, GET_STATE, RETURN_VALUE 16 | 17 | try: 18 | from django.db.models import get_apps, get_app, get_models, get_model 19 | 20 | NEW_META_API = False 21 | except ImportError: 22 | from django.apps import apps 23 | 24 | NEW_META_API = True 25 | 26 | from django import VERSION 27 | 28 | HAS_ARGPARSE = VERSION >= (1, 10) 29 | 30 | 31 | def all_fsm_fields_data(model): 32 | if NEW_META_API: 33 | return [(field, model) for field in model._meta.get_fields() if isinstance(field, FSMFieldMixin)] 34 | else: 35 | return [(field, model) for field in model._meta.fields if isinstance(field, FSMFieldMixin)] 36 | 37 | 38 | def node_name(field, state): 39 | opts = field.model._meta 40 | return "%s.%s.%s.%s" % (opts.app_label, opts.verbose_name.replace(" ", "_"), field.name, state) 41 | 42 | 43 | def node_label(field, state): 44 | if type(state) == int or (type(state) == bool and hasattr(field, "choices")): 45 | return force_text(dict(field.choices).get(state)) 46 | else: 47 | return state 48 | 49 | 50 | def generate_dot(fields_data): 51 | result = graphviz.Digraph() 52 | 53 | for field, model in fields_data: 54 | sources, targets, edges, any_targets, any_except_targets = set(), set(), set(), set(), set() 55 | 56 | # dump nodes and edges 57 | for transition in field.get_all_transitions(model): 58 | if transition.source == "*": 59 | any_targets.add((transition.target, transition.name)) 60 | elif transition.source == "+": 61 | any_except_targets.add((transition.target, transition.name)) 62 | else: 63 | _targets = ( 64 | (state for state in transition.target.allowed_states) 65 | if isinstance(transition.target, (GET_STATE, RETURN_VALUE)) 66 | else (transition.target,) 67 | ) 68 | source_name_pair = ( 69 | ((state, node_name(field, state)) for state in transition.source.allowed_states) 70 | if isinstance(transition.source, (GET_STATE, RETURN_VALUE)) 71 | else ((transition.source, node_name(field, transition.source)),) 72 | ) 73 | for source, source_name in source_name_pair: 74 | if transition.on_error: 75 | on_error_name = node_name(field, transition.on_error) 76 | targets.add((on_error_name, node_label(field, transition.on_error))) 77 | edges.add((source_name, on_error_name, (("style", "dotted"),))) 78 | for target in _targets: 79 | add_transition(source, target, transition.name, source_name, field, sources, targets, edges) 80 | 81 | targets.update( 82 | set((node_name(field, target), node_label(field, target)) for target, _ in chain(any_targets, any_except_targets)) 83 | ) 84 | for target, name in any_targets: 85 | target_name = node_name(field, target) 86 | all_nodes = sources | targets 87 | for source_name, label in all_nodes: 88 | sources.add((source_name, label)) 89 | edges.add((source_name, target_name, (("label", name),))) 90 | 91 | for target, name in any_except_targets: 92 | target_name = node_name(field, target) 93 | all_nodes = sources | targets 94 | all_nodes.remove(((target_name, node_label(field, target)))) 95 | for source_name, label in all_nodes: 96 | sources.add((source_name, label)) 97 | edges.add((source_name, target_name, (("label", name),))) 98 | 99 | # construct subgraph 100 | opts = field.model._meta 101 | subgraph = graphviz.Digraph( 102 | name="cluster_%s_%s_%s" % (opts.app_label, opts.object_name, field.name), 103 | graph_attr={"label": "%s.%s.%s" % (opts.app_label, opts.object_name, field.name)}, 104 | ) 105 | 106 | final_states = targets - sources 107 | for name, label in final_states: 108 | subgraph.node(name, label=str(label), shape="doublecircle") 109 | for name, label in (sources | targets) - final_states: 110 | subgraph.node(name, label=str(label), shape="circle") 111 | if field.default: # Adding initial state notation 112 | if label == field.default: 113 | initial_name = node_name(field, "_initial") 114 | subgraph.node(name=initial_name, label="", shape="point") 115 | subgraph.edge(initial_name, name) 116 | for source_name, target_name, attrs in edges: 117 | subgraph.edge(source_name, target_name, **dict(attrs)) 118 | 119 | result.subgraph(subgraph) 120 | 121 | return result 122 | 123 | 124 | def add_transition(transition_source, transition_target, transition_name, source_name, field, sources, targets, edges): 125 | target_name = node_name(field, transition_target) 126 | sources.add((source_name, node_label(field, transition_source))) 127 | targets.add((target_name, node_label(field, transition_target))) 128 | edges.add((source_name, target_name, (("label", transition_name),))) 129 | 130 | 131 | def get_graphviz_layouts(): 132 | try: 133 | import graphviz 134 | 135 | return graphviz.backend.ENGINES 136 | except Exception: 137 | return {"sfdp", "circo", "twopi", "dot", "neato", "fdp", "osage", "patchwork"} 138 | 139 | 140 | class Command(BaseCommand): 141 | requires_system_checks = _requires_system_checks 142 | 143 | if not HAS_ARGPARSE: 144 | option_list = BaseCommand.option_list + ( 145 | make_option( 146 | "--output", 147 | "-o", 148 | action="store", 149 | dest="outputfile", 150 | help=( 151 | "Render output file. Type of output dependent on file extensions. " "Use png or jpg to render graph to image." 152 | ), 153 | ), 154 | # NOQA 155 | make_option( 156 | "--layout", 157 | "-l", 158 | action="store", 159 | dest="layout", 160 | default="dot", 161 | help=("Layout to be used by GraphViz for visualization. " "Layouts: %s." % " ".join(get_graphviz_layouts())), 162 | ), 163 | ) 164 | args = "[appname[.model[.field]]]" 165 | else: 166 | 167 | def add_arguments(self, parser): 168 | parser.add_argument( 169 | "--output", 170 | "-o", 171 | action="store", 172 | dest="outputfile", 173 | help=( 174 | "Render output file. Type of output dependent on file extensions. " "Use png or jpg to render graph to image." 175 | ), 176 | ) 177 | parser.add_argument( 178 | "--layout", 179 | "-l", 180 | action="store", 181 | dest="layout", 182 | default="dot", 183 | help=("Layout to be used by GraphViz for visualization. " "Layouts: %s." % " ".join(get_graphviz_layouts())), 184 | ) 185 | parser.add_argument("args", nargs="*", help=("[appname[.model[.field]]]")) 186 | 187 | help = "Creates a GraphViz dot file with transitions for selected fields" 188 | 189 | def render_output(self, graph, **options): 190 | filename, format = options["outputfile"].rsplit(".", 1) 191 | 192 | graph.engine = options["layout"] 193 | graph.format = format 194 | graph.render(filename) 195 | 196 | def handle(self, *args, **options): 197 | fields_data = [] 198 | if len(args) != 0: 199 | for arg in args: 200 | field_spec = arg.split(".") 201 | 202 | if len(field_spec) == 1: 203 | if NEW_META_API: 204 | app = apps.get_app(field_spec[0]) 205 | models = apps.get_models(app) 206 | else: 207 | app = get_app(field_spec[0]) 208 | models = get_models(app) 209 | for model in models: 210 | fields_data += all_fsm_fields_data(model) 211 | elif len(field_spec) == 2: 212 | if NEW_META_API: 213 | model = apps.get_model(field_spec[0], field_spec[1]) 214 | else: 215 | model = get_model(field_spec[0], field_spec[1]) 216 | fields_data += all_fsm_fields_data(model) 217 | elif len(field_spec) == 3: 218 | if NEW_META_API: 219 | model = apps.get_model(field_spec[0], field_spec[1]) 220 | else: 221 | model = get_model(field_spec[0], field_spec[1]) 222 | fields_data += all_fsm_fields_data(model) 223 | else: 224 | if NEW_META_API: 225 | for model in apps.get_models(): 226 | fields_data += all_fsm_fields_data(model) 227 | else: 228 | for app in get_apps(): 229 | for model in get_models(app): 230 | fields_data += all_fsm_fields_data(model) 231 | 232 | dotdata = generate_dot(fields_data) 233 | 234 | if options["outputfile"]: 235 | self.render_output(dotdata, **options) 236 | else: 237 | print(dotdata) 238 | -------------------------------------------------------------------------------- /django_fsm/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | State tracking functionality for django models 4 | """ 5 | import inspect 6 | import sys 7 | from functools import wraps 8 | 9 | import django 10 | from django.db import models 11 | from django.db.models import Field 12 | from django.db.models.query_utils import DeferredAttribute 13 | from django.db.models.signals import class_prepared 14 | from django_fsm.signals import pre_transition, post_transition 15 | 16 | try: 17 | from functools import partialmethod 18 | except ImportError: 19 | # python 2.7, so we are on django<=1.11 20 | from django.utils.functional import curry as partialmethod 21 | 22 | try: 23 | from django.apps import apps as django_apps 24 | 25 | def get_model(app_label, model_name): 26 | app = django_apps.get_app_config(app_label) 27 | return app.get_model(model_name) 28 | 29 | except ImportError: 30 | from django.db.models.loading import get_model 31 | 32 | 33 | __all__ = [ 34 | "TransitionNotAllowed", 35 | "ConcurrentTransition", 36 | "FSMFieldMixin", 37 | "FSMField", 38 | "FSMIntegerField", 39 | "FSMKeyField", 40 | "ConcurrentTransitionMixin", 41 | "transition", 42 | "can_proceed", 43 | "has_transition_perm", 44 | "GET_STATE", 45 | "RETURN_VALUE", 46 | ] 47 | 48 | 49 | import warnings 50 | 51 | 52 | def show_deprecation_warning(): 53 | message = ( 54 | "The 'django-fsm' package has been integrated into 'viewflow' as 'viewflow.fsm' starting from version 3.0. " 55 | "This version of 'django-fsm' is no longer maintained and will not receive further updates. " 56 | "If you require new functionality introduced in 'django-fsm' version 3.0 or later, " 57 | "please migrate to 'viewflow.fsm'. For detailed instructions on the migration process and accessing new features, " 58 | "refer to the official documentation at https://docs.viewflow.io/fsm/index.html" 59 | ) 60 | warnings.warn(message, UserWarning, stacklevel=2) 61 | 62 | 63 | # show_deprecation_warning() 64 | 65 | 66 | if sys.version_info[:2] == (2, 6): 67 | # Backport of Python 2.7 inspect.getmembers, 68 | # since Python 2.6 ships buggy implementation 69 | def __getmembers(object, predicate=None): 70 | """Return all members of an object as (name, value) pairs sorted by name. 71 | Optionally, only return members that satisfy a given predicate.""" 72 | results = [] 73 | for key in dir(object): 74 | try: 75 | value = getattr(object, key) 76 | except AttributeError: 77 | continue 78 | if not predicate or predicate(value): 79 | results.append((key, value)) 80 | results.sort() 81 | return results 82 | 83 | inspect.getmembers = __getmembers 84 | 85 | # South support; see http://south.aeracode.org/docs/tutorial/part4.html#simple-inheritance 86 | try: 87 | from south.modelsinspector import add_introspection_rules 88 | except ImportError: 89 | pass 90 | else: 91 | add_introspection_rules([], [r"^django_fsm\.FSMField"]) 92 | add_introspection_rules([], [r"^django_fsm\.FSMIntegerField"]) 93 | add_introspection_rules([], [r"^django_fsm\.FSMKeyField"]) 94 | 95 | 96 | class TransitionNotAllowed(Exception): 97 | """Raised when a transition is not allowed""" 98 | 99 | def __init__(self, *args, **kwargs): 100 | self.object = kwargs.pop("object", None) 101 | self.method = kwargs.pop("method", None) 102 | super(TransitionNotAllowed, self).__init__(*args, **kwargs) 103 | 104 | 105 | class InvalidResultState(Exception): 106 | """Raised when we got invalid result state""" 107 | 108 | 109 | class ConcurrentTransition(Exception): 110 | """ 111 | Raised when the transition cannot be executed because the 112 | object has become stale (state has been changed since it 113 | was fetched from the database). 114 | """ 115 | 116 | 117 | class Transition(object): 118 | def __init__( 119 | self, method, source, target, on_error, conditions, permission, custom 120 | ): 121 | self.method = method 122 | self.source = source 123 | self.target = target 124 | self.on_error = on_error 125 | self.conditions = conditions 126 | self.permission = permission 127 | self.custom = custom 128 | 129 | @property 130 | def name(self): 131 | return self.method.__name__ 132 | 133 | def has_perm(self, instance, user): 134 | if not self.permission: 135 | return True 136 | elif callable(self.permission): 137 | return bool(self.permission(instance, user)) 138 | elif user.has_perm(self.permission, instance): 139 | return True 140 | elif user.has_perm(self.permission): 141 | return True 142 | else: 143 | return False 144 | 145 | def __hash__(self): 146 | return hash(self.name) 147 | 148 | def __eq__(self, other): 149 | if isinstance(other, str): 150 | return other == self.name 151 | if isinstance(other, Transition): 152 | return other.name == self.name 153 | 154 | return False 155 | 156 | 157 | def get_available_FIELD_transitions(instance, field): 158 | """ 159 | List of transitions available in current model state 160 | with all conditions met 161 | """ 162 | curr_state = field.get_state(instance) 163 | transitions = field.transitions[instance.__class__] 164 | 165 | for name, transition in transitions.items(): 166 | meta = transition._django_fsm 167 | if meta.has_transition(curr_state) and meta.conditions_met( 168 | instance, curr_state 169 | ): 170 | yield meta.get_transition(curr_state) 171 | 172 | 173 | def get_all_FIELD_transitions(instance, field): 174 | """ 175 | List of all transitions available in current model state 176 | """ 177 | return field.get_all_transitions(instance.__class__) 178 | 179 | 180 | def get_available_user_FIELD_transitions(instance, user, field): 181 | """ 182 | List of transitions available in current model state 183 | with all conditions met and user have rights on it 184 | """ 185 | for transition in get_available_FIELD_transitions(instance, field): 186 | if transition.has_perm(instance, user): 187 | yield transition 188 | 189 | 190 | class FSMMeta(object): 191 | """ 192 | Models methods transitions meta information 193 | """ 194 | 195 | def __init__(self, field, method): 196 | self.field = field 197 | self.transitions = {} # source -> Transition 198 | 199 | def get_transition(self, source): 200 | transition = self.transitions.get(source, None) 201 | if transition is None: 202 | transition = self.transitions.get("*", None) 203 | if transition is None: 204 | transition = self.transitions.get("+", None) 205 | return transition 206 | 207 | def add_transition( 208 | self, 209 | method, 210 | source, 211 | target, 212 | on_error=None, 213 | conditions=[], 214 | permission=None, 215 | custom={}, 216 | ): 217 | if source in self.transitions: 218 | raise AssertionError("Duplicate transition for {0} state".format(source)) 219 | 220 | self.transitions[source] = Transition( 221 | method=method, 222 | source=source, 223 | target=target, 224 | on_error=on_error, 225 | conditions=conditions, 226 | permission=permission, 227 | custom=custom, 228 | ) 229 | 230 | def has_transition(self, state): 231 | """ 232 | Lookup if any transition exists from current model state using current method 233 | """ 234 | if state in self.transitions: 235 | return True 236 | 237 | if "*" in self.transitions: 238 | return True 239 | 240 | if "+" in self.transitions and self.transitions["+"].target != state: 241 | return True 242 | 243 | return False 244 | 245 | def conditions_met(self, instance, state): 246 | """ 247 | Check if all conditions have been met 248 | """ 249 | transition = self.get_transition(state) 250 | 251 | if transition is None: 252 | return False 253 | elif transition.conditions is None: 254 | return True 255 | else: 256 | return all( 257 | map(lambda condition: condition(instance), transition.conditions) 258 | ) 259 | 260 | def has_transition_perm(self, instance, state, user): 261 | transition = self.get_transition(state) 262 | 263 | if not transition: 264 | return False 265 | else: 266 | return transition.has_perm(instance, user) 267 | 268 | def next_state(self, current_state): 269 | transition = self.get_transition(current_state) 270 | 271 | if transition is None: 272 | raise TransitionNotAllowed("No transition from {0}".format(current_state)) 273 | 274 | return transition.target 275 | 276 | def exception_state(self, current_state): 277 | transition = self.get_transition(current_state) 278 | 279 | if transition is None: 280 | raise TransitionNotAllowed("No transition from {0}".format(current_state)) 281 | 282 | return transition.on_error 283 | 284 | 285 | class FSMFieldDescriptor(object): 286 | def __init__(self, field): 287 | self.field = field 288 | 289 | def __get__(self, instance, type=None): 290 | if instance is None: 291 | return self 292 | return self.field.get_state(instance) 293 | 294 | def __set__(self, instance, value): 295 | if self.field.protected and self.field.name in instance.__dict__: 296 | raise AttributeError( 297 | "Direct {0} modification is not allowed".format(self.field.name) 298 | ) 299 | 300 | # Update state 301 | self.field.set_proxy(instance, value) 302 | self.field.set_state(instance, value) 303 | 304 | 305 | class FSMFieldMixin(object): 306 | descriptor_class = FSMFieldDescriptor 307 | 308 | def __init__(self, *args, **kwargs): 309 | self.protected = kwargs.pop("protected", False) 310 | self.transitions = {} # cls -> (transitions name -> method) 311 | self.state_proxy = {} # state -> ProxyClsRef 312 | 313 | state_choices = kwargs.pop("state_choices", None) 314 | choices = kwargs.get("choices", None) 315 | if state_choices is not None and choices is not None: 316 | raise ValueError("Use one of choices or state_choices value") 317 | 318 | if state_choices is not None: 319 | choices = [] 320 | for state, title, proxy_cls_ref in state_choices: 321 | choices.append((state, title)) 322 | self.state_proxy[state] = proxy_cls_ref 323 | kwargs["choices"] = choices 324 | 325 | super(FSMFieldMixin, self).__init__(*args, **kwargs) 326 | 327 | def deconstruct(self): 328 | name, path, args, kwargs = super(FSMFieldMixin, self).deconstruct() 329 | if self.protected: 330 | kwargs["protected"] = self.protected 331 | return name, path, args, kwargs 332 | 333 | def get_state(self, instance): 334 | # The state field may be deferred. We delegate the logic of figuring 335 | # this out and loading the deferred field on-demand to Django's 336 | # built-in DeferredAttribute class. DeferredAttribute's instantiation 337 | # signature changed over time, so we need to check Django version 338 | # before proceeding to call DeferredAttribute. An alternative to this 339 | # would be copying the latest implementation of DeferredAttribute to 340 | # django_fsm, but this comes with the added responsibility of keeping 341 | # the copied code up to date. 342 | if django.VERSION[:3] >= (3, 0, 0): 343 | return DeferredAttribute(self).__get__(instance) 344 | elif django.VERSION[:3] >= (2, 1, 0): 345 | return DeferredAttribute(self.name).__get__(instance) 346 | elif django.VERSION[:3] >= (1, 10, 0): 347 | return DeferredAttribute(self.name, model=None).__get__(instance) 348 | else: 349 | # The field was either not deferred (in which case we can return it 350 | # right away) or ir was, but we are running on an unknown version 351 | # of Django and we do not know the appropriate DeferredAttribute 352 | # interface, and accessing the field will raise KeyError. 353 | return instance.__dict__[self.name] 354 | 355 | def set_state(self, instance, state): 356 | instance.__dict__[self.name] = state 357 | 358 | def set_proxy(self, instance, state): 359 | """ 360 | Change class 361 | """ 362 | if state in self.state_proxy: 363 | state_proxy = self.state_proxy[state] 364 | 365 | try: 366 | app_label, model_name = state_proxy.split(".") 367 | except ValueError: 368 | # If we can't split, assume a model in current app 369 | app_label = instance._meta.app_label 370 | model_name = state_proxy 371 | 372 | model = get_model(app_label, model_name) 373 | if model is None: 374 | raise ValueError("No model found {0}".format(state_proxy)) 375 | 376 | instance.__class__ = model 377 | 378 | def change_state(self, instance, method, *args, **kwargs): 379 | meta = method._django_fsm 380 | method_name = method.__name__ 381 | current_state = self.get_state(instance) 382 | 383 | if not meta.has_transition(current_state): 384 | raise TransitionNotAllowed( 385 | "Can't switch from state '{0}' using method '{1}'".format( 386 | current_state, method_name 387 | ), 388 | object=instance, 389 | method=method, 390 | ) 391 | if not meta.conditions_met(instance, current_state): 392 | raise TransitionNotAllowed( 393 | "Transition conditions have not been met for method '{0}'".format( 394 | method_name 395 | ), 396 | object=instance, 397 | method=method, 398 | ) 399 | 400 | next_state = meta.next_state(current_state) 401 | 402 | signal_kwargs = { 403 | "sender": instance.__class__, 404 | "instance": instance, 405 | "name": method_name, 406 | "field": meta.field, 407 | "source": current_state, 408 | "target": next_state, 409 | "method_args": args, 410 | "method_kwargs": kwargs, 411 | } 412 | 413 | pre_transition.send(**signal_kwargs) 414 | 415 | try: 416 | result = method(instance, *args, **kwargs) 417 | if next_state is not None: 418 | if hasattr(next_state, "get_state"): 419 | next_state = next_state.get_state( 420 | instance, transition, result, args=args, kwargs=kwargs 421 | ) 422 | signal_kwargs["target"] = next_state 423 | self.set_proxy(instance, next_state) 424 | self.set_state(instance, next_state) 425 | except Exception as exc: 426 | exception_state = meta.exception_state(current_state) 427 | if exception_state: 428 | self.set_proxy(instance, exception_state) 429 | self.set_state(instance, exception_state) 430 | signal_kwargs["target"] = exception_state 431 | signal_kwargs["exception"] = exc 432 | post_transition.send(**signal_kwargs) 433 | raise 434 | else: 435 | post_transition.send(**signal_kwargs) 436 | 437 | return result 438 | 439 | def get_all_transitions(self, instance_cls): 440 | """ 441 | Returns [(source, target, name, method)] for all field transitions 442 | """ 443 | transitions = self.transitions[instance_cls] 444 | 445 | for name, transition in transitions.items(): 446 | meta = transition._django_fsm 447 | 448 | for transition in meta.transitions.values(): 449 | yield transition 450 | 451 | def contribute_to_class(self, cls, name, **kwargs): 452 | self.base_cls = cls 453 | 454 | super(FSMFieldMixin, self).contribute_to_class(cls, name, **kwargs) 455 | setattr(cls, self.name, self.descriptor_class(self)) 456 | setattr( 457 | cls, 458 | "get_all_{0}_transitions".format(self.name), 459 | partialmethod(get_all_FIELD_transitions, field=self), 460 | ) 461 | setattr( 462 | cls, 463 | "get_available_{0}_transitions".format(self.name), 464 | partialmethod(get_available_FIELD_transitions, field=self), 465 | ) 466 | setattr( 467 | cls, 468 | "get_available_user_{0}_transitions".format(self.name), 469 | partialmethod(get_available_user_FIELD_transitions, field=self), 470 | ) 471 | 472 | class_prepared.connect(self._collect_transitions) 473 | 474 | def _collect_transitions(self, *args, **kwargs): 475 | sender = kwargs["sender"] 476 | 477 | if not issubclass(sender, self.base_cls): 478 | return 479 | 480 | def is_field_transition_method(attr): 481 | return ( 482 | (inspect.ismethod(attr) or inspect.isfunction(attr)) 483 | and hasattr(attr, "_django_fsm") 484 | and ( 485 | attr._django_fsm.field in [self, self.name] 486 | or ( 487 | isinstance(attr._django_fsm.field, Field) 488 | and attr._django_fsm.field.name == self.name 489 | and attr._django_fsm.field.creation_counter 490 | == self.creation_counter 491 | ) 492 | ) 493 | ) 494 | 495 | sender_transitions = {} 496 | transitions = inspect.getmembers(sender, predicate=is_field_transition_method) 497 | for method_name, method in transitions: 498 | method._django_fsm.field = self 499 | sender_transitions[method_name] = method 500 | 501 | self.transitions[sender] = sender_transitions 502 | 503 | 504 | class FSMField(FSMFieldMixin, models.CharField): 505 | """ 506 | State Machine support for Django model as CharField 507 | """ 508 | 509 | def __init__(self, *args, **kwargs): 510 | kwargs.setdefault("max_length", 50) 511 | super(FSMField, self).__init__(*args, **kwargs) 512 | 513 | 514 | class FSMIntegerField(FSMFieldMixin, models.IntegerField): 515 | """ 516 | Same as FSMField, but stores the state value in an IntegerField. 517 | """ 518 | 519 | pass 520 | 521 | 522 | class FSMKeyField(FSMFieldMixin, models.ForeignKey): 523 | """ 524 | State Machine support for Django model 525 | """ 526 | 527 | def get_state(self, instance): 528 | return instance.__dict__[self.attname] 529 | 530 | def set_state(self, instance, state): 531 | instance.__dict__[self.attname] = self.to_python(state) 532 | 533 | 534 | class FSMModelMixin(object): 535 | """ 536 | Mixin that allows refresh_from_db for models with fsm protected fields 537 | """ 538 | 539 | def _get_protected_fsm_fields(self): 540 | def is_fsm_and_protected(f): 541 | return isinstance(f, FSMFieldMixin) and f.protected 542 | 543 | protected_fields = filter(is_fsm_and_protected, self._meta.concrete_fields) 544 | return {f.attname for f in protected_fields} 545 | 546 | def refresh_from_db(self, *args, **kwargs): 547 | fields = kwargs.pop("fields", None) 548 | 549 | # Use provided fields, if not set then reload all non-deferred fields.0 550 | if not fields: 551 | deferred_fields = self.get_deferred_fields() 552 | protected_fields = self._get_protected_fsm_fields() 553 | skipped_fields = deferred_fields.union(protected_fields) 554 | 555 | fields = [ 556 | f.attname 557 | for f in self._meta.concrete_fields 558 | if f.attname not in skipped_fields 559 | ] 560 | 561 | kwargs["fields"] = fields 562 | super(FSMModelMixin, self).refresh_from_db(*args, **kwargs) 563 | 564 | 565 | class ConcurrentTransitionMixin(object): 566 | """ 567 | Protects a Model from undesirable effects caused by concurrently executed transitions, 568 | e.g. running the same transition multiple times at the same time, or running different 569 | transitions with the same SOURCE state at the same time. 570 | 571 | This behavior is achieved using an idea based on optimistic locking. No additional 572 | version field is required though; only the state field(s) is/are used for the tracking. 573 | This scheme is not that strict as true *optimistic locking* mechanism, it is however 574 | more lightweight - leveraging the specifics of FSM models. 575 | 576 | Instance of a model based on this Mixin will be prevented from saving into DB if any 577 | of its state fields (instances of FSMFieldMixin) has been changed since the object 578 | was fetched from the database. *ConcurrentTransition* exception will be raised in such 579 | cases. 580 | 581 | For guaranteed protection against such race conditions, make sure: 582 | * Your transitions do not have any side effects except for changes in the database, 583 | * You always run the save() method on the object within django.db.transaction.atomic() 584 | block. 585 | 586 | Following these recommendations, you can rely on ConcurrentTransitionMixin to cause 587 | a rollback of all the changes that have been executed in an inconsistent (out of sync) 588 | state, thus practically negating their effect. 589 | """ 590 | 591 | def __init__(self, *args, **kwargs): 592 | super(ConcurrentTransitionMixin, self).__init__(*args, **kwargs) 593 | self._update_initial_state() 594 | 595 | @property 596 | def state_fields(self): 597 | return filter(lambda field: isinstance(field, FSMFieldMixin), self._meta.fields) 598 | 599 | def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update): 600 | # _do_update is called once for each model class in the inheritance hierarchy. 601 | # We can only filter the base_qs on state fields (can be more than one!) present in this particular model. 602 | 603 | # Select state fields to filter on 604 | filter_on = filter( 605 | lambda field: field.model == base_qs.model, self.state_fields 606 | ) 607 | 608 | # state filter will be used to narrow down the standard filter checking only PK 609 | state_filter = dict( 610 | (field.attname, self.__initial_states[field.attname]) for field in filter_on 611 | ) 612 | 613 | updated = super(ConcurrentTransitionMixin, self)._do_update( 614 | base_qs=base_qs.filter(**state_filter), 615 | using=using, 616 | pk_val=pk_val, 617 | values=values, 618 | update_fields=update_fields, 619 | forced_update=forced_update, 620 | ) 621 | 622 | # It may happen that nothing was updated in the original _do_update method not because of unmatching state, 623 | # but because of missing PK. This codepath is possible when saving a new model instance with *preset PK*. 624 | # In this case Django does not know it has to do INSERT operation, so it tries UPDATE first and falls back to 625 | # INSERT if UPDATE fails. 626 | # Thus, we need to make sure we only catch the case when the object *is* in the DB, but with changed state; and 627 | # mimic standard _do_update behavior otherwise. Django will pick it up and execute _do_insert. 628 | if not updated and base_qs.filter(pk=pk_val).using(using).exists(): 629 | raise ConcurrentTransition( 630 | "Cannot save object! The state has been changed since fetched from the database!" 631 | ) 632 | 633 | return updated 634 | 635 | def _update_initial_state(self): 636 | self.__initial_states = dict( 637 | (field.attname, field.value_from_object(self)) 638 | for field in self.state_fields 639 | ) 640 | 641 | def refresh_from_db(self, *args, **kwargs): 642 | super(ConcurrentTransitionMixin, self).refresh_from_db(*args, **kwargs) 643 | self._update_initial_state() 644 | 645 | def save(self, *args, **kwargs): 646 | super(ConcurrentTransitionMixin, self).save(*args, **kwargs) 647 | self._update_initial_state() 648 | 649 | 650 | def transition( 651 | field, 652 | source="*", 653 | target=None, 654 | on_error=None, 655 | conditions=[], 656 | permission=None, 657 | custom={}, 658 | ): 659 | """ 660 | Method decorator to mark allowed transitions. 661 | 662 | Set target to None if current state needs to be validated and 663 | has not changed after the function call. 664 | """ 665 | 666 | def inner_transition(func): 667 | wrapper_installed, fsm_meta = True, getattr(func, "_django_fsm", None) 668 | if not fsm_meta: 669 | wrapper_installed = False 670 | fsm_meta = FSMMeta(field=field, method=func) 671 | setattr(func, "_django_fsm", fsm_meta) 672 | 673 | if isinstance(source, (list, tuple, set)): 674 | for state in source: 675 | func._django_fsm.add_transition( 676 | func, state, target, on_error, conditions, permission, custom 677 | ) 678 | else: 679 | func._django_fsm.add_transition( 680 | func, source, target, on_error, conditions, permission, custom 681 | ) 682 | 683 | @wraps(func) 684 | def _change_state(instance, *args, **kwargs): 685 | return fsm_meta.field.change_state(instance, func, *args, **kwargs) 686 | 687 | if not wrapper_installed: 688 | return _change_state 689 | 690 | return func 691 | 692 | return inner_transition 693 | 694 | 695 | def can_proceed(bound_method, check_conditions=True): 696 | """ 697 | Returns True if model in state allows to call bound_method 698 | 699 | Set ``check_conditions`` argument to ``False`` to skip checking 700 | conditions. 701 | """ 702 | if not hasattr(bound_method, "_django_fsm"): 703 | im_func = getattr(bound_method, "im_func", getattr(bound_method, "__func__")) 704 | raise TypeError("%s method is not transition" % im_func.__name__) 705 | 706 | meta = bound_method._django_fsm 707 | im_self = getattr(bound_method, "im_self", getattr(bound_method, "__self__")) 708 | current_state = meta.field.get_state(im_self) 709 | 710 | return meta.has_transition(current_state) and ( 711 | not check_conditions or meta.conditions_met(im_self, current_state) 712 | ) 713 | 714 | 715 | def has_transition_perm(bound_method, user): 716 | """ 717 | Returns True if model in state allows to call bound_method and user have rights on it 718 | """ 719 | if not hasattr(bound_method, "_django_fsm"): 720 | im_func = getattr(bound_method, "im_func", getattr(bound_method, "__func__")) 721 | raise TypeError("%s method is not transition" % im_func.__name__) 722 | 723 | meta = bound_method._django_fsm 724 | im_self = getattr(bound_method, "im_self", getattr(bound_method, "__self__")) 725 | current_state = meta.field.get_state(im_self) 726 | 727 | return ( 728 | meta.has_transition(current_state) 729 | and meta.conditions_met(im_self, current_state) 730 | and meta.has_transition_perm(im_self, current_state, user) 731 | ) 732 | 733 | 734 | class State(object): 735 | def get_state(self, model, transition, result, args=[], kwargs={}): 736 | raise NotImplementedError 737 | 738 | 739 | class RETURN_VALUE(State): 740 | def __init__(self, *allowed_states): 741 | self.allowed_states = allowed_states if allowed_states else None 742 | 743 | def get_state(self, model, transition, result, args=[], kwargs={}): 744 | if self.allowed_states is not None: 745 | if result not in self.allowed_states: 746 | raise InvalidResultState( 747 | "{} is not in list of allowed states\n{}".format( 748 | result, self.allowed_states 749 | ) 750 | ) 751 | return result 752 | 753 | 754 | class GET_STATE(State): 755 | def __init__(self, func, states=None): 756 | self.func = func 757 | self.allowed_states = states 758 | 759 | def get_state(self, model, transition, result, args=[], kwargs={}): 760 | result_state = self.func(model, *args, **kwargs) 761 | if self.allowed_states is not None: 762 | if result_state not in self.allowed_states: 763 | raise InvalidResultState( 764 | "{} is not in list of allowed states\n{}".format( 765 | result_state, self.allowed_states 766 | ) 767 | ) 768 | return result_state 769 | --------------------------------------------------------------------------------