├── 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_protected_fields.py │ │ ├── test_model_create_with_generic.py │ │ ├── test_protected_field.py │ │ ├── test_integer_field.py │ │ ├── test_graph_transitions.py │ │ ├── test_custom_data.py │ │ ├── test_conditions.py │ │ ├── test_exception_transitions.py │ │ ├── test_object_permissions.py │ │ ├── test_proxy_inheritance.py │ │ ├── test_state_transitions.py │ │ ├── test_permissions.py │ │ ├── test_abstract_inheritance.py │ │ ├── test_multi_resultstate.py │ │ ├── test_lock_mixin.py │ │ ├── test_key_field.py │ │ └── test_basic_transitions.py │ ├── apps.py │ ├── fixtures │ │ └── test_states_data.json │ └── models.py ├── urls.py ├── wsgi.py ├── manage.py └── settings.py ├── django_fsm ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── graph_transitions.py ├── signals.py └── __init__.py ├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── test.yml │ ├── coverage.yml │ └── release.yml ├── CODE_OF_CONDUCT.md ├── tox.ini ├── LICENSE ├── .pre-commit-config.yaml ├── .gitignore ├── pyproject.toml ├── CHANGELOG.rst ├── README.md └── uv.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_fsm/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_fsm/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: monthly 8 | -------------------------------------------------------------------------------- /tests/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class TestAppConfig(AppConfig): 7 | name = "tests.testapp" 8 | -------------------------------------------------------------------------------- /django_fsm/signals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db.models.signals import ModelSignal 4 | 5 | pre_transition = ModelSignal() 6 | post_transition = ModelSignal() 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Django FSM 2 Code of Conduct 2 | 3 | The django-fsm-2 project utilizes the [Django Commons Code of Conduct](https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md). 4 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.contrib import admin 4 | from django.urls import path 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls, name="admin"), 8 | ] 9 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: django-fsm linting 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v6 15 | - uses: actions/setup-python@v6 16 | with: 17 | python-version: '3.13' 18 | - uses: pre-commit/action@v3.0.1 19 | -------------------------------------------------------------------------------- /tests/wsgi.py: -------------------------------------------------------------------------------- 1 | """WSGI config for silvr project. 2 | 3 | It exposes the WSGI callable as a module-level variable named ``application``. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import os 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: django-fsm testing 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] 16 | 17 | steps: 18 | - uses: actions/checkout@v6 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v6 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | allow-prereleases: true 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install tox tox-gh-actions 28 | - name: Test with tox 29 | run: tox 30 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | from __future__ import annotations 5 | 6 | import os 7 | import sys 8 | 9 | 10 | def main(): 11 | """Run administrative tasks.""" 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 13 | try: 14 | from django.core.management import execute_from_command_line 15 | except ImportError as exc: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) from exc 21 | execute_from_command_line(sys.argv) 22 | 23 | 24 | if __name__ == "__main__": 25 | main() 26 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310,311}-dj42 4 | py{310,311,312}-dj50 5 | py{310,311,312}-dj51 6 | py{310,311,312,313,314}-dj52 7 | py{312,313,314}-dj60 8 | py{312,313,314}-djmain 9 | 10 | skipsdist = True 11 | 12 | [testenv] 13 | deps = 14 | dj42: Django==4.2 15 | dj50: Django==5.0 16 | dj51: Django==5.1 17 | dj52: Django==5.2 18 | dj60: Django==6.0 19 | djmain: https://github.com/django/django/tarball/main 20 | 21 | django-guardian 22 | graphviz 23 | pep8 24 | pyflakes 25 | pytest 26 | pytest-django 27 | pytest-cov 28 | 29 | commands = {posargs:python -m pytest} 30 | 31 | [gh-actions] 32 | python = 33 | 3.8: py38 34 | 3.9: py39 35 | 3.10: py310 36 | 3.11: py311 37 | 3.12: py312 38 | 3.13: py313 39 | 3.14: py314 40 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_mixin_support.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from django_fsm import FSMField 7 | from django_fsm import transition 8 | 9 | 10 | class WorkflowMixin: 11 | @transition(field="state", source="*", target="draft") 12 | def draft(self): 13 | pass 14 | 15 | @transition(field="state", source="draft", target="published") 16 | def publish(self): 17 | pass 18 | 19 | 20 | class MixinSupportTestModel(WorkflowMixin, models.Model): 21 | state = FSMField(default="new") 22 | 23 | 24 | class Test(TestCase): 25 | def test_usecase(self): 26 | model = MixinSupportTestModel() 27 | 28 | model.draft() 29 | assert model.state == "draft" 30 | 31 | model.publish() 32 | assert model.state == "published" 33 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | coverage: 11 | name: Check coverage 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out 15 | uses: actions/checkout@v6 16 | 17 | - name: Set up Python 3.13 18 | uses: actions/setup-python@v6 19 | with: 20 | python-version: "3.13" 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v7 24 | with: 25 | enable-cache: true 26 | 27 | - name: Install requirements 28 | run: uv sync 29 | 30 | - name: Run tests 31 | run: uv run coverage run -m pytest --cov=django_fsm --cov-report=xml 32 | 33 | - name: Upload coverage reports to Codecov 34 | uses: codecov/codecov-action@v5 35 | with: 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_transition_all_except_target.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from django_fsm import FSMField 7 | from django_fsm import can_proceed 8 | from django_fsm import transition 9 | 10 | 11 | class ExceptTargetTransition(models.Model): 12 | state = FSMField(default="new") 13 | 14 | @transition(field=state, source="new", target="published") 15 | def publish(self): 16 | pass 17 | 18 | @transition(field=state, source="+", target="removed") 19 | def remove(self): 20 | pass 21 | 22 | 23 | class Test(TestCase): 24 | def setUp(self): 25 | self.model = ExceptTargetTransition() 26 | 27 | def test_usecase(self): 28 | assert self.model.state == "new" 29 | assert can_proceed(self.model.remove) 30 | self.model.remove() 31 | 32 | assert self.model.state == "removed" 33 | assert not can_proceed(self.model.remove) 34 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_string_field_parameter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from django_fsm import FSMField 7 | from django_fsm import transition 8 | 9 | 10 | class BlogPostWithStringField(models.Model): 11 | state = FSMField(default="new") 12 | 13 | @transition(field="state", source="new", target="published", conditions=[]) 14 | def publish(self): 15 | pass 16 | 17 | @transition(field="state", source="published", target="destroyed") 18 | def destroy(self): 19 | pass 20 | 21 | @transition(field="state", source="published", target="review") 22 | def review(self): 23 | pass 24 | 25 | 26 | class StringFieldTestCase(TestCase): 27 | def setUp(self): 28 | self.model = BlogPostWithStringField() 29 | 30 | def test_initial_state(self): 31 | assert self.model.state == "new" 32 | self.model.publish() 33 | assert self.model.state == "published" 34 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_access_deferred_fsm_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from django_fsm import FSMField 7 | from django_fsm import can_proceed 8 | from django_fsm import transition 9 | 10 | 11 | class DeferrableModel(models.Model): 12 | state = FSMField(default="new") 13 | 14 | @transition(field=state, source="new", target="published") 15 | def publish(self): 16 | pass 17 | 18 | @transition(field=state, source="+", target="removed") 19 | def remove(self): 20 | pass 21 | 22 | 23 | class Test(TestCase): 24 | def setUp(self): 25 | DeferrableModel.objects.create() 26 | self.model = DeferrableModel.objects.only("id").get() 27 | 28 | def test_usecase(self): 29 | assert self.model.state == "new" 30 | assert can_proceed(self.model.remove) 31 | self.model.remove() 32 | 33 | assert self.model.state == "removed" 34 | assert not can_proceed(self.model.remove) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2010 Mikhail Podgurskiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_multidecorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from django_fsm import FSMField 7 | from django_fsm import transition 8 | from django_fsm.signals import post_transition 9 | 10 | 11 | class MultiDecoratedModel(models.Model): 12 | counter = models.IntegerField(default=0) 13 | signal_counter = models.IntegerField(default=0) 14 | state = FSMField(default="SUBMITTED_BY_USER") 15 | 16 | @transition(field=state, source="SUBMITTED_BY_USER", target="REVIEW_USER") 17 | @transition(field=state, source="SUBMITTED_BY_ADMIN", target="REVIEW_ADMIN") 18 | @transition(field=state, source="SUBMITTED_BY_ANONYMOUS", target="REVIEW_ANONYMOUS") 19 | def review(self): 20 | self.counter += 1 21 | 22 | 23 | def count_calls(sender, instance, name, source, target, **kwargs): 24 | instance.signal_counter += 1 25 | 26 | 27 | post_transition.connect(count_calls, sender=MultiDecoratedModel) 28 | 29 | 30 | class TestStateProxy(TestCase): 31 | def test_transition_method_called_once(self): 32 | model = MultiDecoratedModel() 33 | model.review() 34 | assert model.counter == 1 35 | assert model.signal_counter == 1 36 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_protected_fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from django_fsm import FSMField 8 | from django_fsm import FSMModelMixin 9 | from django_fsm import transition 10 | 11 | 12 | class RefreshableProtectedAccessModel(models.Model): 13 | status = FSMField(default="new", protected=True) 14 | 15 | @transition(field=status, source="new", target="published") 16 | def publish(self): 17 | pass 18 | 19 | 20 | class RefreshableModel(FSMModelMixin, RefreshableProtectedAccessModel): 21 | pass 22 | 23 | 24 | class TestDirectAccessModels(TestCase): 25 | def test_no_direct_access(self): 26 | instance = RefreshableProtectedAccessModel() 27 | assert instance.status == "new" 28 | 29 | def try_change(): 30 | instance.status = "change" 31 | 32 | with pytest.raises(AttributeError): 33 | try_change() 34 | 35 | instance.publish() 36 | instance.save() 37 | assert instance.status == "published" 38 | 39 | def test_refresh_from_db(self): 40 | instance = RefreshableModel() 41 | instance.save() 42 | 43 | instance.refresh_from_db() 44 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.12 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v6.0.0 7 | hooks: 8 | - id: check-added-large-files 9 | args: ["--maxkb=700"] 10 | - id: check-case-conflict 11 | - id: check-json 12 | - id: check-merge-conflict 13 | - id: check-symlinks 14 | - id: check-toml 15 | - id: check-yaml 16 | - id: debug-statements 17 | - id: end-of-file-fixer 18 | - id: mixed-line-ending 19 | #- id: no-commit-to-branch 20 | - id: trailing-whitespace 21 | 22 | - repo: https://github.com/crate-ci/typos 23 | rev: v1.39.0 24 | hooks: 25 | - id: typos 26 | args: [] 27 | types_or: 28 | - python 29 | 30 | - repo: https://github.com/asottile/pyupgrade 31 | rev: v3.21.0 32 | hooks: 33 | - id: pyupgrade 34 | args: 35 | - "--py38-plus" 36 | 37 | - repo: https://github.com/adamchainz/django-upgrade 38 | rev: 1.29.1 39 | hooks: 40 | - id: django-upgrade 41 | args: [--target-version, "4.2"] 42 | 43 | - repo: https://github.com/astral-sh/ruff-pre-commit 44 | rev: v0.14.3 45 | hooks: 46 | - id: ruff-format 47 | - id: ruff-check 48 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_model_create_with_generic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.contrib.contenttypes.fields import GenericForeignKey 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | from django.test import TestCase 7 | 8 | from django_fsm import FSMField 9 | from django_fsm import transition 10 | 11 | 12 | class Ticket(models.Model): ... 13 | 14 | 15 | class TaskState(models.TextChoices): 16 | NEW = "new", "New" 17 | DONE = "done", "Done" 18 | 19 | 20 | class Task(models.Model): 21 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 22 | object_id = models.PositiveIntegerField() 23 | causality = GenericForeignKey("content_type", "object_id") 24 | state = FSMField(default=TaskState.NEW) 25 | 26 | @transition(field=state, source=TaskState.NEW, target=TaskState.DONE) 27 | def do(self): 28 | pass 29 | 30 | 31 | class Test(TestCase): 32 | def setUp(self): 33 | self.ticket = Ticket.objects.create() 34 | 35 | def test_model_objects_create(self): 36 | """Check a model with state field can be created 37 | if one of the other fields is a property or a virtual field. 38 | """ 39 | Task.objects.create(causality=self.ticket) 40 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_protected_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from django_fsm import FSMField 8 | from django_fsm import transition 9 | 10 | 11 | class ProtectedAccessModel(models.Model): 12 | status = FSMField(default="new", protected=True) 13 | 14 | @transition(field=status, source="new", target="published") 15 | def publish(self): 16 | pass 17 | 18 | 19 | class MultiProtectedAccessModel(models.Model): 20 | status1 = FSMField(default="new", protected=True) 21 | status2 = FSMField(default="new", protected=True) 22 | 23 | 24 | class TestDirectAccessModels(TestCase): 25 | def test_multi_protected_field_create(self): 26 | obj = MultiProtectedAccessModel.objects.create() 27 | assert obj.status1 == "new" 28 | assert obj.status2 == "new" 29 | 30 | def test_no_direct_access(self): 31 | instance = ProtectedAccessModel() 32 | assert instance.status == "new" 33 | 34 | def try_change(): 35 | instance.status = "change" 36 | 37 | with pytest.raises(AttributeError): 38 | try_change() 39 | 40 | instance.publish() 41 | instance.save() 42 | assert instance.status == "published" 43 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_integer_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from django_fsm import FSMIntegerField 8 | from django_fsm import TransitionNotAllowed 9 | from django_fsm import transition 10 | 11 | 12 | class BlogPostStateEnum: 13 | NEW = 10 14 | PUBLISHED = 20 15 | HIDDEN = 30 16 | 17 | 18 | class BlogPostWithIntegerField(models.Model): 19 | state = FSMIntegerField(default=BlogPostStateEnum.NEW) 20 | 21 | @transition(field=state, source=BlogPostStateEnum.NEW, target=BlogPostStateEnum.PUBLISHED) 22 | def publish(self): 23 | pass 24 | 25 | @transition(field=state, source=BlogPostStateEnum.PUBLISHED, target=BlogPostStateEnum.HIDDEN) 26 | def hide(self): 27 | pass 28 | 29 | 30 | class BlogPostWithIntegerFieldTest(TestCase): 31 | def setUp(self): 32 | self.model = BlogPostWithIntegerField() 33 | 34 | def test_known_transition_should_succeed(self): 35 | self.model.publish() 36 | assert self.model.state == BlogPostStateEnum.PUBLISHED 37 | 38 | self.model.hide() 39 | assert self.model.state == BlogPostStateEnum.HIDDEN 40 | 41 | def test_unknown_transition_fails(self): 42 | with pytest.raises(TransitionNotAllowed): 43 | self.model.hide() 44 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_graph_transitions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.core.management import call_command 4 | from django.test import TestCase 5 | 6 | from django_fsm.management.commands.graph_transitions import get_graphviz_layouts 7 | from django_fsm.management.commands.graph_transitions import node_label 8 | from tests.testapp.models import BlogPost 9 | from tests.testapp.models import BlogPostState 10 | 11 | 12 | class GraphTransitionsCommandTest(TestCase): 13 | MODELS_TO_TEST = [ 14 | "testapp.Application", 15 | "testapp.FKApplication", 16 | ] 17 | 18 | def test_node_label(self): 19 | assert node_label(BlogPost.state.field, BlogPostState.PUBLISHED.value) == BlogPostState.PUBLISHED.label 20 | 21 | def test_app(self): 22 | call_command("graph_transitions", "testapp") 23 | 24 | def test_single_model(self): 25 | for model in self.MODELS_TO_TEST: 26 | call_command("graph_transitions", model) 27 | 28 | def test_single_model_with_layouts(self): 29 | for model in self.MODELS_TO_TEST: 30 | for layout in get_graphviz_layouts(): 31 | call_command("graph_transitions", "-l", layout, model) 32 | 33 | def test_exclude(self): 34 | for model in self.MODELS_TO_TEST: 35 | call_command("graph_transitions", "-e", "standard,no_target", model) 36 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_custom_data.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from django_fsm import FSMField 7 | from django_fsm import transition 8 | 9 | 10 | class BlogPostWithCustomData(models.Model): 11 | state = FSMField(default="new") 12 | 13 | @transition(field=state, source="new", target="published", conditions=[], custom={"label": "Publish", "type": "*"}) 14 | def publish(self): 15 | pass 16 | 17 | @transition(field=state, source="published", target="destroyed", custom={"label": "Destroy", "type": "manual"}) 18 | def destroy(self): 19 | pass 20 | 21 | @transition(field=state, source="published", target="review", custom={"label": "Periodic review", "type": "automated"}) 22 | def review(self): 23 | pass 24 | 25 | 26 | class CustomTransitionDataTest(TestCase): 27 | def setUp(self): 28 | self.model = BlogPostWithCustomData() 29 | 30 | def test_initial_state(self): 31 | assert self.model.state == "new" 32 | transitions = list(self.model.get_available_state_transitions()) 33 | assert len(transitions) == 1 34 | assert transitions[0].target == "published" 35 | assert transitions[0].custom == {"label": "Publish", "type": "*"} 36 | 37 | def test_all_transitions_have_custom_data(self): 38 | transitions = self.model.get_all_state_transitions() 39 | for t in transitions: 40 | assert t.custom["label"] is not None 41 | assert t.custom["type"] is not None 42 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_conditions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from django_fsm import FSMField 8 | from django_fsm import TransitionNotAllowed 9 | from django_fsm import can_proceed 10 | from django_fsm import transition 11 | 12 | 13 | def condition_func(instance): 14 | return True 15 | 16 | 17 | class BlogPostWithConditions(models.Model): 18 | state = FSMField(default="new") 19 | 20 | def model_condition(self): 21 | return True 22 | 23 | def unmet_condition(self): 24 | return False 25 | 26 | @transition(field=state, source="new", target="published", conditions=[condition_func, model_condition]) 27 | def publish(self): 28 | pass 29 | 30 | @transition(field=state, source="published", target="destroyed", conditions=[condition_func, unmet_condition]) 31 | def destroy(self): 32 | pass 33 | 34 | 35 | class ConditionalTest(TestCase): 36 | def setUp(self): 37 | self.model = BlogPostWithConditions() 38 | 39 | def test_initial_staet(self): 40 | assert self.model.state == "new" 41 | 42 | def test_known_transition_should_succeed(self): 43 | assert can_proceed(self.model.publish) 44 | self.model.publish() 45 | assert self.model.state == "published" 46 | 47 | def test_unmet_condition(self): 48 | self.model.publish() 49 | assert self.model.state == "published" 50 | assert not can_proceed(self.model.destroy) 51 | with pytest.raises(TransitionNotAllowed): 52 | self.model.destroy() 53 | 54 | assert can_proceed(self.model.destroy, check_conditions=False) 55 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_exception_transitions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from django_fsm import FSMField 8 | from django_fsm import can_proceed 9 | from django_fsm import transition 10 | from django_fsm.signals import post_transition 11 | 12 | 13 | class ExceptionalBlogPost(models.Model): 14 | state = FSMField(default="new") 15 | 16 | @transition(field=state, source="new", target="published", on_error="crashed") 17 | def publish(self): 18 | raise Exception("Upss") 19 | 20 | @transition(field=state, source="new", target="deleted") 21 | def delete(self): 22 | raise Exception("Upss") 23 | 24 | 25 | class FSMFieldExceptionTest(TestCase): 26 | def setUp(self): 27 | self.model = ExceptionalBlogPost() 28 | post_transition.connect(self.on_post_transition, sender=ExceptionalBlogPost) 29 | self.post_transition_data = None 30 | 31 | def on_post_transition(self, **kwargs): 32 | self.post_transition_data = kwargs 33 | 34 | def test_state_changed_after_fail(self): 35 | assert can_proceed(self.model.publish) 36 | with pytest.raises(Exception, match="Upss"): 37 | self.model.publish() 38 | assert self.model.state == "crashed" 39 | assert self.post_transition_data["target"] == "crashed" 40 | assert "exception" in self.post_transition_data 41 | 42 | def test_state_not_changed_after_fail(self): 43 | assert can_proceed(self.model.delete) 44 | with pytest.raises(Exception, match="Upss"): 45 | self.model.delete() 46 | assert self.model.state == "new" 47 | assert self.post_transition_data is None 48 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_object_permissions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.contrib.auth.models import User 4 | from django.db import models 5 | from django.test import TestCase 6 | from django.test.utils import override_settings 7 | from guardian.shortcuts import assign_perm 8 | 9 | from django_fsm import FSMField 10 | from django_fsm import has_transition_perm 11 | from django_fsm import transition 12 | 13 | 14 | class ObjectPermissionTestModel(models.Model): 15 | state = FSMField(default="new") 16 | 17 | class Meta: 18 | permissions = [ 19 | ("can_publish_objectpermissiontestmodel", "Can publish ObjectPermissionTestModel"), 20 | ] 21 | 22 | @transition( 23 | field=state, 24 | source="new", 25 | target="published", 26 | on_error="failed", 27 | permission="testapp.can_publish_objectpermissiontestmodel", 28 | ) 29 | def publish(self): 30 | pass 31 | 32 | 33 | @override_settings( 34 | AUTHENTICATION_BACKENDS=("django.contrib.auth.backends.ModelBackend", "guardian.backends.ObjectPermissionBackend") 35 | ) 36 | class ObjectPermissionFSMFieldTest(TestCase): 37 | def setUp(self): 38 | super().setUp() 39 | self.model = ObjectPermissionTestModel.objects.create() 40 | 41 | self.unprivileged = User.objects.create(username="unprivileged") 42 | self.privileged = User.objects.create(username="object_only_privileged") 43 | assign_perm("can_publish_objectpermissiontestmodel", self.privileged, self.model) 44 | 45 | def test_object_only_access_success(self): 46 | assert has_transition_perm(self.model.publish, self.privileged) 47 | self.model.publish() 48 | 49 | def test_object_only_other_access_prohibited(self): 50 | assert not has_transition_perm(self.model.publish, self.unprivileged) 51 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_proxy_inheritance.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from django_fsm import FSMField 7 | from django_fsm import can_proceed 8 | from django_fsm import transition 9 | 10 | 11 | class BaseModel(models.Model): 12 | state = FSMField(default="new") 13 | 14 | @transition(field=state, source="new", target="published") 15 | def publish(self): 16 | pass 17 | 18 | 19 | class InheritedModel(BaseModel): 20 | class Meta: 21 | proxy = True 22 | 23 | @transition(field="state", source="published", target="sticked") 24 | def stick(self): 25 | pass 26 | 27 | 28 | class TestinheritedModel(TestCase): 29 | def setUp(self): 30 | self.model = InheritedModel() 31 | 32 | def test_known_transition_should_succeed(self): 33 | assert can_proceed(self.model.publish) 34 | self.model.publish() 35 | assert self.model.state == "published" 36 | 37 | assert can_proceed(self.model.stick) 38 | self.model.stick() 39 | assert self.model.state == "sticked" 40 | 41 | def test_field_available_transitions_works(self): 42 | self.model.publish() 43 | assert self.model.state == "published" 44 | transitions = self.model.get_available_state_transitions() 45 | assert [data.target for data in transitions] == ["sticked"] 46 | 47 | def test_field_all_transitions_base_model(self): 48 | transitions = BaseModel().get_all_state_transitions() 49 | assert {("new", "published")} == {(data.source, data.target) for data in transitions} 50 | 51 | def test_field_all_transitions_works(self): 52 | transitions = self.model.get_all_state_transitions() 53 | assert {("new", "published"), ("published", "sticked")} == {(data.source, data.target) for data in transitions} 54 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_state_transitions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from django_fsm import FSMField 7 | from django_fsm import transition 8 | 9 | 10 | class Insect(models.Model): 11 | class STATE: 12 | CATERPILLAR = "CTR" 13 | BUTTERFLY = "BTF" 14 | 15 | STATE_CHOICES = ((STATE.CATERPILLAR, "Caterpillar", "Caterpillar"), (STATE.BUTTERFLY, "Butterfly", "Butterfly")) 16 | 17 | state = FSMField(default=STATE.CATERPILLAR, state_choices=STATE_CHOICES) 18 | 19 | @transition(field=state, source=STATE.CATERPILLAR, target=STATE.BUTTERFLY) 20 | def cocoon(self): 21 | pass 22 | 23 | def fly(self): 24 | raise NotImplementedError 25 | 26 | def crawl(self): 27 | raise NotImplementedError 28 | 29 | 30 | class Caterpillar(Insect): 31 | class Meta: 32 | proxy = True 33 | 34 | def crawl(self): 35 | """ 36 | Do crawl 37 | """ 38 | 39 | 40 | class Butterfly(Insect): 41 | class Meta: 42 | proxy = True 43 | 44 | def fly(self): 45 | """ 46 | Do fly 47 | """ 48 | 49 | 50 | class TestStateProxy(TestCase): 51 | def test_initial_proxy_set_succeed(self): 52 | insect = Insect() 53 | assert isinstance(insect, Caterpillar) 54 | 55 | def test_transition_proxy_set_succeed(self): 56 | insect = Insect() 57 | insect.cocoon() 58 | assert isinstance(insect, Butterfly) 59 | 60 | def test_load_proxy_set(self): 61 | Insect.objects.bulk_create( 62 | [ 63 | Insect(state=Insect.STATE.CATERPILLAR), 64 | Insect(state=Insect.STATE.BUTTERFLY), 65 | ] 66 | ) 67 | 68 | insects = Insect.objects.all() 69 | assert {Caterpillar, Butterfly} == {insect.__class__ for insect in insects} 70 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.contrib.auth.models import Permission 4 | from django.contrib.auth.models import User 5 | from django.test import TestCase 6 | 7 | from django_fsm import has_transition_perm 8 | from tests.testapp.models import BlogPost 9 | 10 | 11 | class PermissionFSMFieldTest(TestCase): 12 | def setUp(self): 13 | self.model = BlogPost() 14 | self.unprivileged = User.objects.create(username="unprivileged") 15 | self.privileged = User.objects.create(username="privileged") 16 | self.staff = User.objects.create(username="staff", is_staff=True) 17 | 18 | self.privileged.user_permissions.add(Permission.objects.get_by_natural_key("can_publish_post", "testapp", "blogpost")) 19 | self.privileged.user_permissions.add(Permission.objects.get_by_natural_key("can_remove_post", "testapp", "blogpost")) 20 | 21 | def test_privileged_access_succeed(self): 22 | assert has_transition_perm(self.model.publish, self.privileged) 23 | assert has_transition_perm(self.model.remove, self.privileged) 24 | 25 | transitions = self.model.get_available_user_state_transitions(self.privileged) 26 | assert {"publish", "remove", "moderate"} == {transition.name for transition in transitions} 27 | 28 | def test_unprivileged_access_prohibited(self): 29 | assert not has_transition_perm(self.model.publish, self.unprivileged) 30 | assert not has_transition_perm(self.model.remove, self.unprivileged) 31 | 32 | transitions = self.model.get_available_user_state_transitions(self.unprivileged) 33 | assert {"moderate"} == {transition.name for transition in transitions} 34 | 35 | def test_permission_instance_method(self): 36 | assert not has_transition_perm(self.model.restore, self.unprivileged) 37 | assert has_transition_perm(self.model.restore, self.staff) 38 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_abstract_inheritance.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from django_fsm import FSMField 7 | from django_fsm import can_proceed 8 | from django_fsm import transition 9 | 10 | 11 | class BaseAbstractModel(models.Model): 12 | state = FSMField(default="new") 13 | 14 | class Meta: 15 | abstract = True 16 | 17 | @transition(field=state, source="new", target="published") 18 | def publish(self): 19 | pass 20 | 21 | 22 | class AnotherFromAbstractModel(BaseAbstractModel): 23 | """ 24 | This class exists to trigger a regression when multiple concrete classes 25 | inherit from a shared abstract class (example: BaseAbstractModel). 26 | Don't try to remove it. 27 | """ 28 | 29 | @transition(field="state", source="published", target="sticked") 30 | def stick(self): 31 | pass 32 | 33 | 34 | class InheritedFromAbstractModel(BaseAbstractModel): 35 | @transition(field="state", source="published", target="sticked") 36 | def stick(self): 37 | pass 38 | 39 | 40 | class TestinheritedModel(TestCase): 41 | def setUp(self): 42 | self.model = InheritedFromAbstractModel() 43 | 44 | def test_known_transition_should_succeed(self): 45 | assert can_proceed(self.model.publish) 46 | self.model.publish() 47 | assert self.model.state == "published" 48 | 49 | assert can_proceed(self.model.stick) 50 | self.model.stick() 51 | assert self.model.state == "sticked" 52 | 53 | def test_field_available_transitions_works(self): 54 | self.model.publish() 55 | assert self.model.state == "published" 56 | transitions = self.model.get_available_state_transitions() 57 | assert [data.target for data in transitions] == ["sticked"] 58 | 59 | def test_field_all_transitions_works(self): 60 | transitions = self.model.get_all_state_transitions() 61 | assert {("new", "published"), ("published", "sticked")} == {(data.source, data.target) for data in transitions} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # sqlite 132 | test.db 133 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_multi_resultstate.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.test import TestCase 5 | 6 | from django_fsm import GET_STATE 7 | from django_fsm import RETURN_VALUE 8 | from django_fsm import FSMField 9 | from django_fsm import transition 10 | from django_fsm.signals import post_transition 11 | from django_fsm.signals import pre_transition 12 | 13 | 14 | class MultiResultTest(models.Model): 15 | state = FSMField(default="new") 16 | 17 | @transition(field=state, source="new", target=RETURN_VALUE("for_moderators", "published")) 18 | def publish(self, *, is_public=False): 19 | return "published" if is_public else "for_moderators" 20 | 21 | @transition( 22 | field=state, 23 | source="for_moderators", 24 | target=GET_STATE(lambda _, allowed: "published" if allowed else "rejected", states=["published", "rejected"]), 25 | ) 26 | def moderate(self, allowed): 27 | pass 28 | 29 | 30 | class Test(TestCase): 31 | def test_return_state_succeed(self): 32 | instance = MultiResultTest() 33 | instance.publish(is_public=True) 34 | assert instance.state == "published" 35 | 36 | def test_get_state_succeed(self): 37 | instance = MultiResultTest(state="for_moderators") 38 | instance.moderate(allowed=False) 39 | assert instance.state == "rejected" 40 | 41 | 42 | class TestSignals(TestCase): 43 | def setUp(self): 44 | self.pre_transition_called = False 45 | self.post_transition_called = False 46 | pre_transition.connect(self.on_pre_transition, sender=MultiResultTest) 47 | post_transition.connect(self.on_post_transition, sender=MultiResultTest) 48 | 49 | def on_pre_transition(self, sender, instance, name, source, target, **kwargs): 50 | assert instance.state == source 51 | self.pre_transition_called = True 52 | 53 | def on_post_transition(self, sender, instance, name, source, target, **kwargs): 54 | assert instance.state == target 55 | self.post_transition_called = True 56 | 57 | def test_signals_called_with_get_state(self): 58 | instance = MultiResultTest(state="for_moderators") 59 | instance.moderate(allowed=False) 60 | assert self.pre_transition_called 61 | assert self.post_transition_called 62 | 63 | def test_signals_called_with_return_value(self): 64 | instance = MultiResultTest() 65 | instance.publish(is_public=True) 66 | assert self.pre_transition_called 67 | assert self.post_transition_called 68 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_lock_mixin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from django_fsm import ConcurrentTransition 8 | from django_fsm import ConcurrentTransitionMixin 9 | from django_fsm import FSMField 10 | from django_fsm import transition 11 | 12 | 13 | class LockedBlogPost(ConcurrentTransitionMixin, models.Model): 14 | state = FSMField(default="new") 15 | text = models.CharField(max_length=50) 16 | 17 | @transition(field=state, source="new", target="published") 18 | def publish(self): 19 | pass 20 | 21 | @transition(field=state, source="published", target="removed") 22 | def remove(self): 23 | pass 24 | 25 | 26 | class ExtendedBlogPost(LockedBlogPost): 27 | review_state = FSMField(default="waiting", protected=True) 28 | notes = models.CharField(max_length=50) 29 | 30 | @transition(field=review_state, source="waiting", target="rejected") 31 | def reject(self): 32 | pass 33 | 34 | 35 | class TestLockMixin(TestCase): 36 | def test_create_succeed(self): 37 | LockedBlogPost.objects.create(text="test_create_succeed") 38 | 39 | def test_crud_succeed(self): 40 | post = LockedBlogPost(text="test_crud_succeed") 41 | post.publish() 42 | post.save() 43 | 44 | post = LockedBlogPost.objects.get(pk=post.pk) 45 | assert post.state == "published" 46 | post.text = "test_crud_succeed2" 47 | post.save() 48 | 49 | post = LockedBlogPost.objects.get(pk=post.pk) 50 | assert post.text == "test_crud_succeed2" 51 | 52 | post.delete() 53 | 54 | def test_save_and_change_succeed(self): 55 | post = LockedBlogPost(text="test_crud_succeed") 56 | post.publish() 57 | post.save() 58 | 59 | post.remove() 60 | post.save() 61 | 62 | post.delete() 63 | 64 | def test_concurrent_modifications_raise_exception(self): 65 | post1 = LockedBlogPost.objects.create() 66 | post2 = LockedBlogPost.objects.get(pk=post1.pk) 67 | 68 | post1.publish() 69 | post1.save() 70 | 71 | post2.text = "aaa" 72 | post2.publish() 73 | with pytest.raises(ConcurrentTransition): 74 | post2.save() 75 | 76 | def test_inheritance_crud_succeed(self): 77 | post = ExtendedBlogPost(text="test_inheritance_crud_succeed", notes="reject me") 78 | post.publish() 79 | post.save() 80 | 81 | post = ExtendedBlogPost.objects.get(pk=post.pk) 82 | assert post.state == "published" 83 | post.text = "test_inheritance_crud_succeed2" 84 | post.reject() 85 | post.save() 86 | 87 | post = ExtendedBlogPost.objects.get(pk=post.pk) 88 | assert post.review_state == "rejected" 89 | assert post.text == "test_inheritance_crud_succeed2" 90 | 91 | def test_concurrent_modifications_after_refresh_db_succeed(self): # bug 255 92 | post1 = LockedBlogPost.objects.create() 93 | post2 = LockedBlogPost.objects.get(pk=post1.pk) 94 | 95 | post1.publish() 96 | post1.save() 97 | 98 | post2.refresh_from_db() 99 | post2.remove() 100 | post2.save() 101 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-fsm-2" 3 | version = "4.1.0" 4 | description = "Django friendly finite state machine support." 5 | authors = [{ name = "Mikhail Podgurskiy", email = "kmmbvnr@gmail.com" }] 6 | requires-python = "~=3.8" 7 | readme = "README.md" 8 | license = "MIT" 9 | classifiers = [ 10 | "Development Status :: 5 - Production/Stable", 11 | "Environment :: Web Environment", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | "Framework :: Django", 16 | "Framework :: Django :: 4.2", 17 | "Framework :: Django :: 5.0", 18 | "Framework :: Django :: 5.1", 19 | "Framework :: Django :: 5.2", 20 | "Framework :: Django :: 6.0", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Programming Language :: Python :: 3.14", 30 | "Topic :: Software Development :: Libraries :: Python Modules", 31 | ] 32 | dependencies = ["django>=4.2"] 33 | 34 | [project.urls] 35 | Homepage = "http://github.com/django-commons/django-fsm-2" 36 | Repository = "http://github.com/django-commons/django-fsm-2" 37 | Documentation = "http://github.com/django-commons/django-fsm-2" 38 | 39 | [dependency-groups] 40 | graphviz = ["graphviz"] 41 | dev = [ 42 | "coverage", 43 | "django-guardian", 44 | "graphviz", 45 | "pre-commit", 46 | "pytest", 47 | "pytest-cov", 48 | "pytest-django", 49 | ] 50 | 51 | [tool.uv] 52 | default-groups = [ 53 | "graphviz", 54 | "dev", 55 | ] 56 | 57 | [tool.hatch.build.targets.sdist] 58 | include = ["django_fsm"] 59 | 60 | [tool.hatch.build.targets.wheel] 61 | include = ["django_fsm"] 62 | 63 | [build-system] 64 | requires = ["hatchling"] 65 | build-backend = "hatchling.build" 66 | 67 | [tool.pytest.ini_options] 68 | DJANGO_SETTINGS_MODULE = "tests.settings" 69 | 70 | [tool.ruff] 71 | line-length = 130 72 | target-version = "py38" 73 | fix = true 74 | 75 | [tool.ruff.lint] 76 | select = ["ALL"] 77 | extend-ignore = [ 78 | "COM812", # This rule may cause conflicts when used with the formatter 79 | "D", # pydocstyle 80 | "DOC", # pydoclint 81 | "B", 82 | "PTH", 83 | "ANN", # Missing type annotation 84 | "S101", # Use of `assert` detected 85 | "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` 86 | "ARG001", # Unused function argument 87 | "ARG002", # Unused method argument 88 | "TRY002", # Create your own exception 89 | "TRY003", # Avoid specifying long messages outside the exception class 90 | "EM101", # Exception must not use a string literal, assign to variable first 91 | "EM102", # Exception must not use an f-string literal, assign to variable first 92 | "SLF001", # Private member accessed 93 | "SIM103", # Return the condition directly 94 | "PLC0415", # `import` should be at the top-level of a file 95 | "PLR0913", # Too many arguments in function definition 96 | ] 97 | fixable = [ 98 | "I", # isort 99 | "RUF100", # Unused `noqa` directive 100 | ] 101 | 102 | [tool.ruff.lint.extend-per-file-ignores] 103 | "tests/*" = [ 104 | "DJ008", # Model does not define `__str__` method 105 | ] 106 | 107 | [tool.ruff.lint.isort] 108 | force-single-line = true 109 | required-imports = ["from __future__ import annotations"] 110 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | env: 9 | # Change these for your project's URLs 10 | PYPI_URL: https://pypi.org/p/django-fsm-2 11 | PYPI_TEST_URL: https://test.pypi.org/p/django-fsm-2 12 | 13 | jobs: 14 | 15 | build: 16 | name: Build distribution 📦 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v6 21 | - name: Set up Python 22 | uses: actions/setup-python@v6 23 | with: 24 | python-version: "3.x" 25 | - name: Install pypa/build 26 | run: 27 | python3 -m pip install build --user 28 | - name: Build a binary wheel and a source tarball 29 | run: python3 -m build 30 | - name: Store the distribution packages 31 | uses: actions/upload-artifact@v5 32 | with: 33 | name: python-package-distributions 34 | path: dist/ 35 | 36 | publish-to-pypi: 37 | name: >- 38 | Publish Python 🐍 distribution 📦 to PyPI 39 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 40 | needs: 41 | - build 42 | runs-on: ubuntu-latest 43 | environment: 44 | name: pypi 45 | url: ${{ env.PYPI_URL }} 46 | permissions: 47 | id-token: write # IMPORTANT: mandatory for trusted publishing 48 | steps: 49 | - name: Download all the dists 50 | uses: actions/download-artifact@v6 51 | with: 52 | name: python-package-distributions 53 | path: dist/ 54 | - name: Publish distribution 📦 to PyPI 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | 57 | github-release: 58 | name: >- 59 | Sign the Python 🐍 distribution 📦 with Sigstore 60 | and upload them to GitHub Release 61 | needs: 62 | - publish-to-pypi 63 | runs-on: ubuntu-latest 64 | 65 | permissions: 66 | contents: write # IMPORTANT: mandatory for making GitHub Releases 67 | id-token: write # IMPORTANT: mandatory for sigstore 68 | 69 | steps: 70 | - name: Download all the dists 71 | uses: actions/download-artifact@v6 72 | with: 73 | name: python-package-distributions 74 | path: dist/ 75 | - name: Sign the dists with Sigstore 76 | uses: sigstore/gh-action-sigstore-python@v3.1.0 77 | with: 78 | inputs: >- 79 | ./dist/*.tar.gz 80 | ./dist/*.whl 81 | - name: Create GitHub Release 82 | env: 83 | GITHUB_TOKEN: ${{ github.token }} 84 | run: >- 85 | gh release create 86 | '${{ github.ref_name }}' 87 | --repo '${{ github.repository }}' 88 | --notes "" 89 | - name: Upload artifact signatures to GitHub Release 90 | env: 91 | GITHUB_TOKEN: ${{ github.token }} 92 | # Upload to GitHub Release using the `gh` CLI. 93 | # `dist/` contains the built packages, and the 94 | # sigstore-produced signatures and certificates. 95 | run: >- 96 | gh release upload 97 | '${{ github.ref_name }}' dist/** 98 | --repo '${{ github.repository }}' 99 | 100 | publish-to-testpypi: 101 | name: Publish Python 🐍 distribution 📦 to TestPyPI 102 | needs: 103 | - build 104 | runs-on: ubuntu-latest 105 | 106 | environment: 107 | name: testpypi 108 | url: ${{ env.PYPI_TEST_URL }} 109 | 110 | permissions: 111 | id-token: write # IMPORTANT: mandatory for trusted publishing 112 | 113 | steps: 114 | - name: Download all the dists 115 | uses: actions/download-artifact@v6 116 | with: 117 | name: python-package-distributions 118 | path: dist/ 119 | - name: Publish distribution 📦 to TestPyPI 120 | uses: pypa/gh-action-pypi-publish@release/v1 121 | with: 122 | repository-url: https://test.pypi.org/legacy/ 123 | skip-existing: true 124 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for tests project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from __future__ import annotations 14 | 15 | from pathlib import Path 16 | 17 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 18 | BASE_DIR = Path(__file__).resolve().parent.parent 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = "nokey" # noqa: S105 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = [] 31 | 32 | # Application definition 33 | 34 | PROJECT_APPS = ( 35 | "django_fsm", 36 | "tests.testapp", 37 | ) 38 | 39 | INSTALLED_APPS = [ 40 | "django.contrib.admin", 41 | "django.contrib.auth", 42 | "django.contrib.contenttypes", 43 | "django.contrib.sessions", 44 | "django.contrib.messages", 45 | "django.contrib.staticfiles", 46 | "guardian", 47 | *PROJECT_APPS, 48 | ] 49 | 50 | MIDDLEWARE = [ 51 | "django.middleware.security.SecurityMiddleware", 52 | "django.contrib.sessions.middleware.SessionMiddleware", 53 | "django.middleware.common.CommonMiddleware", 54 | "django.middleware.csrf.CsrfViewMiddleware", 55 | "django.contrib.auth.middleware.AuthenticationMiddleware", 56 | "django.contrib.messages.middleware.MessageMiddleware", 57 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 58 | ] 59 | 60 | ROOT_URLCONF = "tests.urls" 61 | 62 | TEMPLATES = [ 63 | { 64 | "BACKEND": "django.template.backends.django.DjangoTemplates", 65 | "DIRS": [], 66 | "APP_DIRS": True, 67 | "OPTIONS": { 68 | "context_processors": [ 69 | "django.template.context_processors.request", 70 | "django.contrib.auth.context_processors.auth", 71 | "django.contrib.messages.context_processors.messages", 72 | ], 73 | }, 74 | }, 75 | ] 76 | 77 | WSGI_APPLICATION = "tests.wsgi.application" 78 | 79 | 80 | # Database 81 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 82 | 83 | DATABASES = { 84 | "default": { 85 | "ENGINE": "django.db.backends.sqlite3", 86 | "NAME": BASE_DIR / "db.sqlite3", 87 | } 88 | } 89 | 90 | # Authentication 91 | # https://docs.djangoproject.com/en/4.2/topics/auth/ 92 | 93 | AUTHENTICATION_BACKENDS = ( 94 | "django.contrib.auth.backends.ModelBackend", # this is default 95 | "guardian.backends.ObjectPermissionBackend", 96 | ) 97 | 98 | # Password validation 99 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 100 | 101 | AUTH_PASSWORD_VALIDATORS = [ 102 | { 103 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 104 | }, 105 | { 106 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 107 | }, 108 | { 109 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 110 | }, 111 | { 112 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 113 | }, 114 | ] 115 | 116 | 117 | # Internationalization 118 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 119 | 120 | LANGUAGE_CODE = "en-us" 121 | 122 | TIME_ZONE = "UTC" 123 | 124 | USE_I18N = True 125 | 126 | USE_TZ = True 127 | 128 | 129 | # Static files (CSS, JavaScript, Images) 130 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 131 | 132 | STATIC_URL = "static/" 133 | 134 | # Default primary key field type 135 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 136 | 137 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 138 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_key_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from django_fsm import FSMKeyField 8 | from django_fsm import TransitionNotAllowed 9 | from django_fsm import can_proceed 10 | from django_fsm import transition 11 | from tests.testapp.models import DbState 12 | 13 | FK_AVAILABLE_STATES = ( 14 | ("New", "_NEW_"), 15 | ("Published", "_PUBLISHED_"), 16 | ("Hidden", "_HIDDEN_"), 17 | ("Removed", "_REMOVED_"), 18 | ("Stolen", "_STOLEN_"), 19 | ("Moderated", "_MODERATED_"), 20 | ) 21 | 22 | 23 | class FKBlogPost(models.Model): 24 | state = FSMKeyField(DbState, default="new", protected=True, on_delete=models.CASCADE) 25 | 26 | @transition(field=state, source="new", target="published") 27 | def publish(self): 28 | pass 29 | 30 | @transition(field=state, source="published") 31 | def notify_all(self): 32 | pass 33 | 34 | @transition(field=state, source="published", target="hidden") 35 | def hide(self): 36 | pass 37 | 38 | @transition(field=state, source="new", target="removed") 39 | def remove(self): 40 | raise Exception("Upss") 41 | 42 | @transition(field=state, source=["published", "hidden"], target="stolen") 43 | def steal(self): 44 | pass 45 | 46 | @transition(field=state, source="*", target="moderated") 47 | def moderate(self): 48 | pass 49 | 50 | 51 | class FSMKeyFieldTest(TestCase): 52 | def setUp(self): 53 | DbState.objects.bulk_create(DbState(pk=item[0], label=item[1]) for item in FK_AVAILABLE_STATES) 54 | self.model = FKBlogPost() 55 | 56 | def test_initial_state_instantiated(self): 57 | assert self.model.state == "new" 58 | 59 | def test_known_transition_should_succeed(self): 60 | assert can_proceed(self.model.publish) 61 | self.model.publish() 62 | assert self.model.state == "published" 63 | 64 | assert can_proceed(self.model.hide) 65 | self.model.hide() 66 | assert self.model.state == "hidden" 67 | 68 | def test_unknown_transition_fails(self): 69 | assert not can_proceed(self.model.hide) 70 | with pytest.raises(TransitionNotAllowed): 71 | self.model.hide() 72 | 73 | def test_state_non_changed_after_fail(self): 74 | assert can_proceed(self.model.remove) 75 | with pytest.raises(Exception, match="Upss"): 76 | self.model.remove() 77 | assert self.model.state == "new" 78 | 79 | def test_allowed_null_transition_should_succeed(self): 80 | assert can_proceed(self.model.publish) 81 | self.model.publish() 82 | self.model.notify_all() 83 | assert self.model.state == "published" 84 | 85 | def test_unknown_null_transition_should_fail(self): 86 | with pytest.raises(TransitionNotAllowed): 87 | self.model.notify_all() 88 | assert self.model.state == "new" 89 | 90 | def test_multiple_source_support_path_1_works(self): 91 | self.model.publish() 92 | self.model.steal() 93 | assert self.model.state == "stolen" 94 | 95 | def test_multiple_source_support_path_2_works(self): 96 | self.model.publish() 97 | self.model.hide() 98 | self.model.steal() 99 | assert self.model.state == "stolen" 100 | 101 | def test_star_shortcut_succeed(self): 102 | assert can_proceed(self.model.moderate) 103 | self.model.moderate() 104 | assert self.model.state == "moderated" 105 | 106 | 107 | """ 108 | # TODO: FIX it 109 | class BlogPostStatus(models.Model): 110 | name = models.CharField(unique=True, max_length=10) 111 | objects = models.Manager() 112 | 113 | 114 | class BlogPostWithFKState(models.Model): 115 | status = FSMKeyField(BlogPostStatus, default=lambda: BlogPostStatus.objects.get(name="new")) 116 | 117 | @transition(field=status, source='new', target='published') 118 | def publish(self): 119 | pass 120 | 121 | @transition(field=status, source='published', target='hidden') 122 | def hide(self): 123 | pass 124 | 125 | 126 | class BlogPostWithFKStateTest(TestCase): 127 | def setUp(self): 128 | BlogPostStatus.objects.bulk_create([ 129 | BlogPostStatus(name="new") 130 | BlogPostStatus(name="published") 131 | BlogPostStatus(name="hidden") 132 | ]) 133 | self.model = BlogPostWithFKState() 134 | 135 | def test_known_transition_should_succeed(self): 136 | self.model.publish() 137 | self.assertEqual(self.model.state, 'published') 138 | 139 | self.model.hide() 140 | self.assertEqual(self.model.state, 'hidden') 141 | 142 | def test_unknown_transition_fails(self): 143 | with pytest.raises(TransitionNotAllowed): 144 | self.model.hide() 145 | """ 146 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | django-fsm-2 4.1.0 2025-11-03 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | - Add support for Django 6.0 8 | - Add support for Django 5.2 9 | - Add support for python 3.14 10 | - Add support for python 3.13 11 | 12 | 13 | django-fsm-2 4.0.0 2024-09-02 14 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 15 | 16 | - Add support for Django 5.1 17 | - Remove support for Django 3.2 18 | - Remove support for Django 4.0 19 | - Remove support for Django 4.1 20 | - Move the project to ``django-commons`` 21 | 22 | 23 | django-fsm-2 3.0.0 2024-03-26 24 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 25 | 26 | First release of the forked version of django-fsm 27 | 28 | - Drop support for Python < 3.8. 29 | - Add support for python 3.11 30 | - Add support for python 3.12 31 | - Drop support for django < 3.2 32 | - Add support for django 4.2 33 | - Add support for django 5.0 34 | - Enable Github actions for testing 35 | - Remove South support...if exists 36 | 37 | django-fsm 2.8.1 2022-08-15 38 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 39 | 40 | - Improve fix for get_available_FIELD_transition 41 | 42 | django-fsm 2.8.0 2021-11-05 43 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 44 | 45 | - Fix get_available_FIELD_transition on django>=3.2 46 | - Fix refresh_from_db for ConcurrentTransitionMixin 47 | 48 | 49 | django-fsm 2.7.1 2020-10-13 50 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 51 | 52 | - Fix warnings on Django 3.1+ 53 | 54 | 55 | django-fsm 2.7.0 2019-12-03 56 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 57 | 58 | - Django 3.0 support 59 | - Test on Python 3.8 60 | 61 | 62 | django-fsm 2.6.1 2019-04-19 63 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 64 | 65 | - Update pypi classifiers to latest django/python supported versions 66 | - Several fixes for graph_transition command 67 | 68 | 69 | django-fsm 2.6.0 2017-06-08 70 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 71 | 72 | - Fix django 1.11 compatibility 73 | - Fix TypeError in `graph_transitions` command when using django's lazy translations 74 | 75 | 76 | django-fsm 2.5.0 2017-03-04 77 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 78 | 79 | - graph_transition command fix for django 1.10 80 | - graph_transition command supports GET_STATE targets 81 | - signal data extended with method args/kwargs and field 82 | - sets allowed to be passed to the transition decorator 83 | 84 | 85 | django-fsm 2.4.0 2016-05-14 86 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 87 | 88 | - graph_transition command now works with multiple FSM's per model 89 | - Add ability to set target state from transition return value or callable 90 | 91 | 92 | django-fsm 2.3.0 2015-10-15 93 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 94 | 95 | - Add source state shortcut '+' to specify transitions from all states except the target 96 | - Add object-level permission checks 97 | - Fix translated labels for graph of FSMIntegerField 98 | - Fix multiple signals for several transition decorators 99 | 100 | 101 | django-fsm 2.2.1 2015-04-27 102 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 103 | 104 | - Improved exception message for unmet transition conditions. 105 | - Don't send post transition signal in case of no state changes on 106 | exception 107 | - Allow empty string as correct state value 108 | - Improved graphviz fsm visualisation 109 | - Clean django 1.8 warnings 110 | 111 | django-fsm 2.2.0 2014-09-03 112 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 113 | 114 | - Support for `class 115 | substitution `__ 116 | to proxy classes depending on the state 117 | - Added ConcurrentTransitionMixin with optimistic locking support 118 | - Default db\_index=True for FSMIntegerField removed 119 | - Graph transition code migrated to new graphviz library with python 3 120 | support 121 | - Ability to change state on transition exception 122 | 123 | django-fsm 2.1.0 2014-05-15 124 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 125 | 126 | - Support for attaching permission checks on model transitions 127 | 128 | django-fsm 2.0.0 2014-03-15 129 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 130 | 131 | - Backward incompatible release 132 | - All public code import moved directly to django\_fsm package 133 | - Correct support for several @transitions decorator with different 134 | source states and conditions on same method 135 | - save parameter from transition decorator removed 136 | - get\_available\_FIELD\_transitions return Transition data object 137 | instead of tuple 138 | - Models got get\_available\_FIELD\_transitions, even if field 139 | specified as string reference 140 | - New get\_all\_FIELD\_transitions method contributed to class 141 | 142 | django-fsm 1.6.0 2014-03-15 143 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 144 | 145 | - FSMIntegerField and FSMKeyField support 146 | 147 | django-fsm 1.5.1 2014-01-04 148 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 149 | 150 | - Ad-hoc support for state fields from proxy and inherited models 151 | 152 | django-fsm 1.5.0 2013-09-17 153 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 154 | 155 | - Python 3 compatibility 156 | 157 | django-fsm 1.4.0 2011-12-21 158 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 159 | 160 | - Add graph\_transition command for drawing state transition picture 161 | 162 | django-fsm 1.3.0 2011-07-28 163 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 164 | 165 | - Add direct field modification protection 166 | 167 | django-fsm 1.2.0 2011-03-23 168 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 169 | 170 | - Add pre\_transition and post\_transition signals 171 | 172 | django-fsm 1.1.0 2011-02-22 173 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 174 | 175 | - Add support for transition conditions 176 | - Allow multiple FSMField in one model 177 | - Contribute get\_available\_FIELD\_transitions for model class 178 | 179 | django-fsm 1.0.0 2010-10-12 180 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 181 | 182 | - Initial public release 183 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | 5 | from django_fsm import GET_STATE 6 | from django_fsm import RETURN_VALUE 7 | from django_fsm import FSMField 8 | from django_fsm import FSMKeyField 9 | from django_fsm import transition 10 | 11 | 12 | class Application(models.Model): 13 | """ 14 | Student application need to be approved by dept chair and dean. 15 | Test workflow 16 | """ 17 | 18 | state = FSMField(default="new") 19 | 20 | @transition(field=state, source="new", target="published") 21 | def standard(self): 22 | pass 23 | 24 | @transition(field=state, source="published") 25 | def no_target(self): 26 | pass 27 | 28 | @transition(field=state, source="*", target="blocked") 29 | def any_source(self): 30 | pass 31 | 32 | @transition(field=state, source="+", target="hidden") 33 | def any_source_except_target(self): 34 | pass 35 | 36 | @transition( 37 | field=state, 38 | source="new", 39 | target=GET_STATE( 40 | lambda _, allowed: "published" if allowed else "rejected", 41 | states=["published", "rejected"], 42 | ), 43 | ) 44 | def get_state(self, *, allowed: bool): 45 | pass 46 | 47 | @transition( 48 | field=state, 49 | source="*", 50 | target=GET_STATE( 51 | lambda _, allowed: "published" if allowed else "rejected", 52 | states=["published", "rejected"], 53 | ), 54 | ) 55 | def get_state_any_source(self, *, allowed: bool): 56 | pass 57 | 58 | @transition( 59 | field=state, 60 | source="+", 61 | target=GET_STATE( 62 | lambda _, allowed: "published" if allowed else "rejected", 63 | states=["published", "rejected"], 64 | ), 65 | ) 66 | def get_state_any_source_except_target(self, *, allowed: bool): 67 | pass 68 | 69 | @transition(field=state, source="new", target=RETURN_VALUE("moderated", "blocked")) 70 | def return_value(self): 71 | return "published" 72 | 73 | @transition(field=state, source="*", target=RETURN_VALUE("moderated", "blocked")) 74 | def return_value_any_source(self): 75 | return "published" 76 | 77 | @transition(field=state, source="+", target=RETURN_VALUE("moderated", "blocked")) 78 | def return_value_any_source_except_target(self): 79 | return "published" 80 | 81 | @transition(field=state, source="new", target="published", on_error="failed") 82 | def on_error(self): 83 | pass 84 | 85 | 86 | class DbState(models.Model): 87 | """ 88 | States in DB 89 | """ 90 | 91 | id = models.CharField(primary_key=True, max_length=50) 92 | 93 | label = models.CharField(max_length=255) 94 | 95 | def __str__(self): 96 | return self.label 97 | 98 | 99 | class FKApplication(models.Model): 100 | """ 101 | Student application need to be approved by dept chair and dean. 102 | Test workflow for FSMKeyField 103 | """ 104 | 105 | state = FSMKeyField(DbState, default="new", on_delete=models.CASCADE) 106 | 107 | @transition(field=state, source="new", target="published") 108 | def standard(self): 109 | pass 110 | 111 | @transition(field=state, source="published") 112 | def no_target(self): 113 | pass 114 | 115 | @transition(field=state, source="*", target="blocked") 116 | def any_source(self): 117 | pass 118 | 119 | @transition(field=state, source="+", target="hidden") 120 | def any_source_except_target(self): 121 | pass 122 | 123 | @transition( 124 | field=state, 125 | source="new", 126 | target=GET_STATE( 127 | lambda _, allowed: "published" if allowed else "rejected", 128 | states=["published", "rejected"], 129 | ), 130 | ) 131 | def get_state(self, *, allowed: bool): 132 | pass 133 | 134 | @transition( 135 | field=state, 136 | source="*", 137 | target=GET_STATE( 138 | lambda _, allowed: "published" if allowed else "rejected", 139 | states=["published", "rejected"], 140 | ), 141 | ) 142 | def get_state_any_source(self, *, allowed: bool): 143 | pass 144 | 145 | @transition( 146 | field=state, 147 | source="+", 148 | target=GET_STATE( 149 | lambda _, allowed: "published" if allowed else "rejected", 150 | states=["published", "rejected"], 151 | ), 152 | ) 153 | def get_state_any_source_except_target(self, *, allowed: bool): 154 | pass 155 | 156 | @transition(field=state, source="new", target=RETURN_VALUE("moderated", "blocked")) 157 | def return_value(self): 158 | return "published" 159 | 160 | @transition(field=state, source="*", target=RETURN_VALUE("moderated", "blocked")) 161 | def return_value_any_source(self): 162 | return "published" 163 | 164 | @transition(field=state, source="+", target=RETURN_VALUE("moderated", "blocked")) 165 | def return_value_any_source_except_target(self): 166 | return "published" 167 | 168 | @transition(field=state, source="new", target="published", on_error="failed") 169 | def on_error(self): 170 | pass 171 | 172 | 173 | class BlogPostState(models.IntegerChoices): 174 | NEW = 0, "New" 175 | PUBLISHED = 1, "Published" 176 | HIDDEN = 2, "Hidden" 177 | REMOVED = 3, "Removed" 178 | RESTORED = 4, "Restored" 179 | MODERATED = 5, "Moderated" 180 | STOLEN = 6, "Stolen" 181 | FAILED = 7, "Failed" 182 | 183 | 184 | class BlogPost(models.Model): 185 | """ 186 | Test workflow 187 | """ 188 | 189 | state = FSMField(choices=BlogPostState.choices, default=BlogPostState.NEW, protected=True) 190 | 191 | class Meta: 192 | permissions = [ 193 | ("can_publish_post", "Can publish post"), 194 | ("can_remove_post", "Can remove post"), 195 | ] 196 | 197 | def can_restore(self, user): 198 | return user.is_superuser or user.is_staff 199 | 200 | @transition( 201 | field=state, 202 | source=BlogPostState.NEW, 203 | target=BlogPostState.PUBLISHED, 204 | on_error=BlogPostState.FAILED, 205 | permission="testapp.can_publish_post", 206 | ) 207 | def publish(self): 208 | pass 209 | 210 | @transition(field=state, source=BlogPostState.PUBLISHED) 211 | def notify_all(self): 212 | pass 213 | 214 | @transition( 215 | field=state, 216 | source=BlogPostState.PUBLISHED, 217 | target=BlogPostState.HIDDEN, 218 | on_error=BlogPostState.FAILED, 219 | ) 220 | def hide(self): 221 | pass 222 | 223 | @transition( 224 | field=state, 225 | source=BlogPostState.NEW, 226 | target=BlogPostState.REMOVED, 227 | on_error=BlogPostState.FAILED, 228 | permission=lambda _, u: u.has_perm("testapp.can_remove_post"), 229 | ) 230 | def remove(self): 231 | raise Exception(f"No rights to delete {self}") 232 | 233 | @transition( 234 | field=state, 235 | source=BlogPostState.NEW, 236 | target=BlogPostState.RESTORED, 237 | on_error=BlogPostState.FAILED, 238 | permission=can_restore, 239 | ) 240 | def restore(self): 241 | pass 242 | 243 | @transition(field=state, source=[BlogPostState.PUBLISHED, BlogPostState.HIDDEN], target=BlogPostState.STOLEN) 244 | def steal(self): 245 | pass 246 | 247 | @transition(field=state, source="*", target=BlogPostState.MODERATED) 248 | def moderate(self): 249 | pass 250 | -------------------------------------------------------------------------------- /django_fsm/management/commands/graph_transitions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from itertools import chain 4 | 5 | import graphviz 6 | from django.apps import apps 7 | from django.core.management.base import BaseCommand 8 | from django.utils.encoding import force_str 9 | 10 | from django_fsm import GET_STATE 11 | from django_fsm import RETURN_VALUE 12 | from django_fsm import FSMFieldMixin 13 | 14 | 15 | def all_fsm_fields_data(model): 16 | return [(field, model) for field in model._meta.get_fields() if isinstance(field, FSMFieldMixin)] 17 | 18 | 19 | def node_name(field, state) -> str: 20 | opts = field.model._meta 21 | return "{}.{}.{}.{}".format(opts.app_label, opts.verbose_name.replace(" ", "_"), field.name, state) 22 | 23 | 24 | def node_label(field, state: str | None) -> str: 25 | if isinstance(state, (int, bool)) and hasattr(field, "choices") and field.choices: 26 | state = dict(field.choices).get(state) 27 | return force_str(state) 28 | 29 | 30 | def generate_dot(fields_data, ignore_transitions: list[str] | None = None): # noqa: C901, PLR0912 31 | ignore_transitions = ignore_transitions or [] 32 | result = graphviz.Digraph() 33 | 34 | for field, model in fields_data: 35 | sources, targets, edges, any_targets, any_except_targets = set(), set(), set(), set(), set() 36 | 37 | # dump nodes and edges 38 | for transition in field.get_all_transitions(model): 39 | if transition.name in ignore_transitions: 40 | continue 41 | 42 | _targets = list( 43 | (state for state in transition.target.allowed_states) 44 | if isinstance(transition.target, (GET_STATE, RETURN_VALUE)) 45 | else (transition.target,) 46 | ) 47 | source_name_pair = ( 48 | ((state, node_name(field, state)) for state in transition.source.allowed_states) 49 | if isinstance(transition.source, (GET_STATE, RETURN_VALUE)) 50 | else ((transition.source, node_name(field, transition.source)),) 51 | ) 52 | for source, source_name in source_name_pair: 53 | if transition.on_error: 54 | on_error_name = node_name(field, transition.on_error) 55 | targets.add((on_error_name, node_label(field, transition.on_error))) 56 | edges.add((source_name, on_error_name, (("style", "dotted"),))) 57 | 58 | for target in _targets: 59 | if transition.source == "*": 60 | any_targets.add((target, transition.name)) 61 | elif transition.source == "+": 62 | any_except_targets.add((target, transition.name)) 63 | else: 64 | add_transition(source, target, transition.name, source_name, field, sources, targets, edges) 65 | 66 | targets.update( 67 | {(node_name(field, target), node_label(field, target)) for target, _ in chain(any_targets, any_except_targets)} 68 | ) 69 | for target, name in any_targets: 70 | target_name = node_name(field, target) 71 | all_nodes = sources | targets 72 | for source_name, label in all_nodes: 73 | sources.add((source_name, label)) 74 | edges.add((source_name, target_name, (("label", name),))) 75 | 76 | for target, name in any_except_targets: 77 | target_name = node_name(field, target) 78 | all_nodes = sources | targets 79 | all_nodes.remove((target_name, node_label(field, target))) 80 | for source_name, label in all_nodes: 81 | sources.add((source_name, label)) 82 | edges.add((source_name, target_name, (("label", name),))) 83 | 84 | # construct subgraph 85 | opts = field.model._meta 86 | subgraph = graphviz.Digraph( 87 | name=f"cluster_{opts.app_label}_{opts.object_name}_{field.name}", 88 | graph_attr={"label": f"{opts.app_label}.{opts.object_name}.{field.name}"}, 89 | ) 90 | 91 | final_states = targets - sources 92 | for name, label in final_states: 93 | subgraph.node(name, label=label, shape="doublecircle") 94 | for name, label in (sources | targets) - final_states: 95 | subgraph.node(name, label=label, shape="circle") 96 | # Adding initial state notation 97 | if field.default and label == field.default: 98 | initial_name = node_name(field, "_initial") 99 | subgraph.node(name=initial_name, label="", shape="point") 100 | subgraph.edge(initial_name, name) 101 | for source_name, target_name, attrs in edges: 102 | subgraph.edge(source_name, target_name, **dict(attrs)) 103 | 104 | result.subgraph(subgraph) 105 | 106 | return result 107 | 108 | 109 | def add_transition(transition_source, transition_target, transition_name, source_name, field, sources, targets, edges): 110 | target_name = node_name(field, transition_target) 111 | sources.add((source_name, node_label(field, transition_source))) 112 | targets.add((target_name, node_label(field, transition_target))) 113 | edges.add((source_name, target_name, (("label", transition_name),))) 114 | 115 | 116 | def get_graphviz_layouts(): 117 | try: 118 | import graphviz 119 | except ModuleNotFoundError: 120 | return {"sfdp", "circo", "twopi", "dot", "neato", "fdp", "osage", "patchwork"} 121 | else: 122 | return graphviz.ENGINES 123 | 124 | 125 | class Command(BaseCommand): 126 | help = "Creates a GraphViz dot file with transitions for selected fields" 127 | 128 | def add_arguments(self, parser): 129 | parser.add_argument( 130 | "--output", 131 | "-o", 132 | action="store", 133 | dest="outputfile", 134 | help="Render output file. Type of output dependent on file extensions. Use png or jpg to render graph to image.", 135 | ) 136 | parser.add_argument( 137 | "--layout", 138 | "-l", 139 | action="store", 140 | dest="layout", 141 | default="dot", 142 | help=f"Layout to be used by GraphViz for visualization. Layouts: {get_graphviz_layouts()}.", 143 | ) 144 | parser.add_argument( 145 | "--exclude", 146 | "-e", 147 | action="store", 148 | dest="exclude", 149 | default="", 150 | help="Ignore transitions with this name.", 151 | ) 152 | parser.add_argument("args", nargs="*", help=("[appname[.model[.field]]]")) 153 | 154 | def render_output(self, graph, **options): 155 | filename, graph_format = options["outputfile"].rsplit(".", 1) 156 | 157 | graph.engine = options["layout"] 158 | graph.format = graph_format 159 | graph.render(filename) 160 | 161 | def handle(self, *args, **options): 162 | fields_data = [] 163 | if len(args) != 0: 164 | for arg in args: 165 | field_spec = arg.split(".") 166 | 167 | if len(field_spec) == 1: 168 | app = apps.get_app_config(field_spec[0]) 169 | for model in apps.get_models(app): 170 | fields_data += all_fsm_fields_data(model) 171 | if len(field_spec) == 2: # noqa: PLR2004 172 | model = apps.get_model(field_spec[0], field_spec[1]) 173 | fields_data += all_fsm_fields_data(model) 174 | if len(field_spec) == 3: # noqa: PLR2004 175 | model = apps.get_model(field_spec[0], field_spec[1]) 176 | fields_data += all_fsm_fields_data(model) 177 | else: 178 | for model in apps.get_models(): 179 | fields_data += all_fsm_fields_data(model) 180 | 181 | dotdata = generate_dot(fields_data, ignore_transitions=options["exclude"].split(",")) 182 | 183 | if options["outputfile"]: 184 | self.render_output(dotdata, **options) 185 | else: 186 | print(dotdata) # noqa: T201 187 | -------------------------------------------------------------------------------- /tests/testapp/tests/test_basic_transitions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from django_fsm import FSMField 8 | from django_fsm import Transition 9 | from django_fsm import TransitionNotAllowed 10 | from django_fsm import can_proceed 11 | from django_fsm import transition 12 | from django_fsm.signals import post_transition 13 | from django_fsm.signals import pre_transition 14 | 15 | 16 | class SimpleBlogPost(models.Model): 17 | state = FSMField(default="new") 18 | 19 | @transition(field=state, source="new", target="published") 20 | def publish(self): 21 | pass 22 | 23 | @transition(source="published", field=state) 24 | def notify_all(self): 25 | pass 26 | 27 | @transition(source="published", target="hidden", field=state) 28 | def hide(self): 29 | pass 30 | 31 | @transition(source="new", target="removed", field=state) 32 | def remove(self): 33 | raise Exception("Upss") 34 | 35 | @transition(source=["published", "hidden"], target="stolen", field=state) 36 | def steal(self): 37 | pass 38 | 39 | @transition(source="*", target="moderated", field=state) 40 | def moderate(self): 41 | pass 42 | 43 | @transition(source="+", target="blocked", field=state) 44 | def block(self): 45 | pass 46 | 47 | @transition(source="*", target="", field=state) 48 | def empty(self): 49 | pass 50 | 51 | 52 | class FSMFieldTest(TestCase): 53 | def setUp(self): 54 | self.model = SimpleBlogPost() 55 | 56 | def test_initial_state_instantiated(self): 57 | assert self.model.state == "new" 58 | 59 | def test_known_transition_should_succeed(self): 60 | assert can_proceed(self.model.publish) 61 | self.model.publish() 62 | assert self.model.state == "published" 63 | 64 | assert can_proceed(self.model.hide) 65 | self.model.hide() 66 | assert self.model.state == "hidden" 67 | 68 | def test_unknown_transition_fails(self): 69 | assert not can_proceed(self.model.hide) 70 | with pytest.raises(TransitionNotAllowed): 71 | self.model.hide() 72 | 73 | def test_state_non_changed_after_fail(self): 74 | assert can_proceed(self.model.remove) 75 | with pytest.raises(Exception, match="Upss"): 76 | self.model.remove() 77 | assert self.model.state == "new" 78 | 79 | def test_allowed_null_transition_should_succeed(self): 80 | self.model.publish() 81 | self.model.notify_all() 82 | assert self.model.state == "published" 83 | 84 | def test_unknown_null_transition_should_fail(self): 85 | with pytest.raises(TransitionNotAllowed): 86 | self.model.notify_all() 87 | assert self.model.state == "new" 88 | 89 | def test_multiple_source_support_path_1_works(self): 90 | self.model.publish() 91 | self.model.steal() 92 | assert self.model.state == "stolen" 93 | 94 | def test_multiple_source_support_path_2_works(self): 95 | self.model.publish() 96 | self.model.hide() 97 | self.model.steal() 98 | assert self.model.state == "stolen" 99 | 100 | def test_star_shortcut_succeed(self): 101 | assert can_proceed(self.model.moderate) 102 | self.model.moderate() 103 | assert self.model.state == "moderated" 104 | 105 | def test_plus_shortcut_succeeds_for_other_source(self): 106 | """Tests that the '+' shortcut succeeds for a source 107 | other than the target. 108 | """ 109 | assert can_proceed(self.model.block) 110 | self.model.block() 111 | assert self.model.state == "blocked" 112 | 113 | def test_plus_shortcut_fails_for_same_source(self): 114 | """Tests that the '+' shortcut fails if the source 115 | equals the target. 116 | """ 117 | self.model.block() 118 | assert not can_proceed(self.model.block) 119 | with pytest.raises(TransitionNotAllowed): 120 | self.model.block() 121 | 122 | def test_empty_string_target(self): 123 | self.model.empty() 124 | assert self.model.state == "" 125 | 126 | 127 | class StateSignalsTests(TestCase): 128 | def setUp(self): 129 | self.model = SimpleBlogPost() 130 | self.pre_transition_called = False 131 | self.post_transition_called = False 132 | pre_transition.connect(self.on_pre_transition, sender=SimpleBlogPost) 133 | post_transition.connect(self.on_post_transition, sender=SimpleBlogPost) 134 | 135 | def on_pre_transition(self, sender, instance, name, source, target, **kwargs): 136 | assert instance.state == source 137 | self.pre_transition_called = True 138 | 139 | def on_post_transition(self, sender, instance, name, source, target, **kwargs): 140 | assert instance.state == target 141 | self.post_transition_called = True 142 | 143 | def test_signals_called_on_valid_transition(self): 144 | self.model.publish() 145 | assert self.pre_transition_called 146 | assert self.post_transition_called 147 | 148 | def test_signals_not_called_on_invalid_transition(self): 149 | with pytest.raises(TransitionNotAllowed): 150 | self.model.hide() 151 | assert not self.pre_transition_called 152 | assert not self.post_transition_called 153 | 154 | 155 | class LazySenderTests(StateSignalsTests): 156 | def setUp(self): 157 | self.model = SimpleBlogPost() 158 | self.pre_transition_called = False 159 | self.post_transition_called = False 160 | pre_transition.connect(self.on_pre_transition, sender="testapp.SimpleBlogPost") 161 | post_transition.connect(self.on_post_transition, sender="testapp.SimpleBlogPost") 162 | 163 | 164 | class TestFieldTransitionsInspect(TestCase): 165 | def setUp(self): 166 | self.model = SimpleBlogPost() 167 | 168 | def test_in_operator_for_available_transitions(self): 169 | # store the generator in a list, so we can reuse the generator and do multiple asserts 170 | transitions = list(self.model.get_available_state_transitions()) 171 | 172 | assert "publish" in transitions 173 | assert "xyz" not in transitions 174 | 175 | # inline method for faking the name of the transition 176 | def publish(): 177 | pass 178 | 179 | obj = Transition( 180 | method=publish, 181 | source="", 182 | target="", 183 | on_error="", 184 | conditions="", 185 | permission="", 186 | custom="", 187 | ) 188 | 189 | assert obj in transitions 190 | 191 | def test_available_conditions_from_new(self): 192 | transitions = self.model.get_available_state_transitions() 193 | actual = {(transition.source, transition.target) for transition in transitions} 194 | expected = {("*", "moderated"), ("new", "published"), ("new", "removed"), ("*", ""), ("+", "blocked")} 195 | assert actual == expected 196 | 197 | def test_available_conditions_from_published(self): 198 | self.model.publish() 199 | transitions = self.model.get_available_state_transitions() 200 | actual = {(transition.source, transition.target) for transition in transitions} 201 | expected = { 202 | ("*", "moderated"), 203 | ("published", None), 204 | ("published", "hidden"), 205 | ("published", "stolen"), 206 | ("*", ""), 207 | ("+", "blocked"), 208 | } 209 | assert actual == expected 210 | 211 | def test_available_conditions_from_hidden(self): 212 | self.model.publish() 213 | self.model.hide() 214 | transitions = self.model.get_available_state_transitions() 215 | actual = {(transition.source, transition.target) for transition in transitions} 216 | expected = {("*", "moderated"), ("hidden", "stolen"), ("*", ""), ("+", "blocked")} 217 | assert actual == expected 218 | 219 | def test_available_conditions_from_stolen(self): 220 | self.model.publish() 221 | self.model.steal() 222 | transitions = self.model.get_available_state_transitions() 223 | actual = {(transition.source, transition.target) for transition in transitions} 224 | expected = {("*", "moderated"), ("*", ""), ("+", "blocked")} 225 | assert actual == expected 226 | 227 | def test_available_conditions_from_blocked(self): 228 | self.model.block() 229 | transitions = self.model.get_available_state_transitions() 230 | actual = {(transition.source, transition.target) for transition in transitions} 231 | expected = {("*", "moderated"), ("*", "")} 232 | assert actual == expected 233 | 234 | def test_available_conditions_from_empty(self): 235 | self.model.empty() 236 | transitions = self.model.get_available_state_transitions() 237 | actual = {(transition.source, transition.target) for transition in transitions} 238 | expected = {("*", "moderated"), ("*", ""), ("+", "blocked")} 239 | assert actual == expected 240 | 241 | def test_all_conditions(self): 242 | transitions = self.model.get_all_state_transitions() 243 | 244 | actual = {(transition.source, transition.target) for transition in transitions} 245 | expected = { 246 | ("*", "moderated"), 247 | ("new", "published"), 248 | ("new", "removed"), 249 | ("published", None), 250 | ("published", "hidden"), 251 | ("published", "stolen"), 252 | ("hidden", "stolen"), 253 | ("*", ""), 254 | ("+", "blocked"), 255 | } 256 | assert actual == expected 257 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django friendly finite state machine support 2 | 3 | [![CI tests](https://github.com/django-commons/django-fsm-2/actions/workflows/test.yml/badge.svg)](https://github.com/django-commons/django-fsm-2/actions/workflows/test.yml) 4 | [![codecov](https://codecov.io/github/django-commons/django-fsm-2/graph/badge.svg?token=gxsNL3cBl3)](https://codecov.io/github/django-commons/django-fsm-2) 5 | [![Documentation](https://img.shields.io/static/v1?label=Docs&message=READ&color=informational&style=plastic)](https://github.com/django-commons/django-fsm-2#settings) 6 | [![MIT License](https://img.shields.io/static/v1?label=License&message=MIT&color=informational&style=plastic)](https://github.com/django-commons/anymail-history/LICENSE) 7 | 8 | 9 | django-fsm adds simple declarative state management for django models. 10 | 11 | > [!IMPORTANT] 12 | > Django FSM-2 is a maintained fork of [Django FSM](https://github.com/viewflow/django-fsm). 13 | > 14 | > Big thanks to Mikhail Podgurskiy for starting this awesome project and maintaining it for so many years. 15 | > 16 | > Unfortunately, after 2 years without any releases, the project was brutally archived. [Viewflow](https://github.com/viewflow/viewflow) is presented as an alternative but the transition is not that easy. 17 | > 18 | > If what you need is just a simple state machine, tailor-made for Django, Django FSM-2 is the successor of Django FSM, with dependencies updates, typing (planned) 19 | 20 | ## Introduction 21 | 22 | **FSM really helps to structure the code, and centralize the lifecycle of your Models.** 23 | 24 | Instead of adding a CharField field to a django model and manage its 25 | values by hand everywhere, `FSMFields` offer the ability to declare your 26 | `transitions` once with the decorator. These methods could contain side-effects, permissions, or logic to make the lifecycle management easier. 27 | 28 | Nice introduction is available here: 29 | 30 | ## Installation 31 | 32 | First, install the package with pip. 33 | 34 | ``` bash 35 | $ pip install django-fsm-2 36 | ``` 37 | 38 | Or, for the latest git version 39 | 40 | ``` bash 41 | $ pip install -e git://github.com/django-commons/django-fsm-2.git#egg=django-fsm 42 | ``` 43 | 44 | Register django_fsm in your list of Django applications 45 | 46 | ```python 47 | INSTALLED_APPS = ( 48 | ..., 49 | 'django_fsm', 50 | ..., 51 | ) 52 | ``` 53 | 54 | ## Migration from django-fsm 55 | 56 | django-fsm-2 is a drop-in replacement, it's actually the same project but from a different source. 57 | So all you need to do is to replace `django-fsm` dependency with `django-fsm-2`. And voila! 58 | 59 | ``` bash 60 | $ pip install django-fsm-2 61 | ``` 62 | 63 | 64 | ## Usage 65 | 66 | Add FSMState field to your model 67 | 68 | ``` python 69 | from django_fsm import FSMField, transition 70 | 71 | class BlogPost(models.Model): 72 | state = FSMField(default='new') 73 | ``` 74 | 75 | Use the `transition` decorator to annotate model methods 76 | 77 | ``` python 78 | @transition(field=state, source='new', target='published') 79 | def publish(self): 80 | """ 81 | This function may contain side-effects, 82 | like updating caches, notifying users, etc. 83 | The return value will be discarded. 84 | """ 85 | ``` 86 | 87 | The `field` parameter accepts both a string attribute name or an actual 88 | field instance. 89 | 90 | If calling publish() succeeds without raising an exception, the state 91 | field will be changed, but not written to the database. 92 | 93 | ``` python 94 | from django_fsm import can_proceed 95 | 96 | def publish_view(request, post_id): 97 | post = get_object_or_404(BlogPost, pk=post_id) 98 | if not can_proceed(post.publish): 99 | raise PermissionDenied 100 | 101 | post.publish() 102 | post.save() 103 | return redirect('/') 104 | ``` 105 | 106 | If some conditions are required to be met before changing the state, use 107 | the `conditions` argument to `transition`. `conditions` must be a list 108 | of functions taking one argument, the model instance. The function must 109 | return either `True` or `False` or a value that evaluates to `True` or 110 | `False`. If all functions return `True`, all conditions are considered 111 | to be met and the transition is allowed to happen. If one of the 112 | functions returns `False`, the transition will not happen. These 113 | functions should not have any side effects. 114 | 115 | You can use ordinary functions 116 | 117 | ``` python 118 | def can_publish(instance): 119 | # No publishing after 17 hours 120 | if datetime.datetime.now().hour > 17: 121 | return False 122 | return True 123 | ``` 124 | 125 | Or model methods 126 | 127 | ``` python 128 | def can_destroy(self): 129 | return self.is_under_investigation() 130 | ``` 131 | 132 | Use the conditions like this: 133 | 134 | ``` python 135 | @transition(field=state, source='new', target='published', conditions=[can_publish]) 136 | def publish(self): 137 | """ 138 | Side effects galore 139 | """ 140 | 141 | @transition(field=state, source='*', target='destroyed', conditions=[can_destroy]) 142 | def destroy(self): 143 | """ 144 | Side effects galore 145 | """ 146 | ``` 147 | 148 | You can instantiate a field with `protected=True` option to prevent 149 | direct state field modification. 150 | 151 | ``` python 152 | class BlogPost(models.Model): 153 | state = FSMField(default='new', protected=True) 154 | 155 | model = BlogPost() 156 | model.state = 'invalid' # Raises AttributeError 157 | ``` 158 | 159 | Note that calling 160 | [refresh_from_db](https://docs.djangoproject.com/en/1.8/ref/models/instances/#django.db.models.Model.refresh_from_db) 161 | on a model instance with a protected FSMField will cause an exception. 162 | 163 | ### `source` state 164 | 165 | `source` parameter accepts a list of states, or an individual state or 166 | `django_fsm.State` implementation. 167 | 168 | You can use `*` for `source` to allow switching to `target` from any 169 | state. 170 | 171 | You can use `+` for `source` to allow switching to `target` from any 172 | state excluding `target` state. 173 | 174 | ### `target` state 175 | 176 | `target` state parameter could point to a specific state or 177 | `django_fsm.State` implementation 178 | 179 | ``` python 180 | from django_fsm import FSMField, transition, RETURN_VALUE, GET_STATE 181 | @transition(field=state, 182 | source='*', 183 | target=RETURN_VALUE('for_moderators', 'published')) 184 | def publish(self, is_public=False): 185 | return 'for_moderators' if is_public else 'published' 186 | 187 | @transition( 188 | field=state, 189 | source='for_moderators', 190 | target=GET_STATE( 191 | lambda self, allowed: 'published' if allowed else 'rejected', 192 | states=['published', 'rejected'])) 193 | def moderate(self, allowed): 194 | pass 195 | 196 | @transition( 197 | field=state, 198 | source='for_moderators', 199 | target=GET_STATE( 200 | lambda self, **kwargs: 'published' if kwargs.get("allowed", True) else 'rejected', 201 | states=['published', 'rejected'])) 202 | def moderate(self, allowed=True): 203 | pass 204 | ``` 205 | 206 | ### `custom` properties 207 | 208 | Custom properties can be added by providing a dictionary to the `custom` 209 | keyword on the `transition` decorator. 210 | 211 | ``` python 212 | @transition(field=state, 213 | source='*', 214 | target='onhold', 215 | custom=dict(verbose='Hold for legal reasons')) 216 | def legal_hold(self): 217 | """ 218 | Side effects galore 219 | """ 220 | ``` 221 | 222 | ### `on_error` state 223 | 224 | If the transition method raises an exception, you can provide a specific 225 | target state 226 | 227 | ``` python 228 | @transition(field=state, source='new', target='published', on_error='failed') 229 | def publish(self): 230 | """ 231 | Some exception could happen here 232 | """ 233 | ``` 234 | 235 | ### `state_choices` 236 | 237 | Instead of passing a two-item iterable `choices` you can instead use the 238 | three-element `state_choices`, the last element being a string reference 239 | to a model proxy class. 240 | 241 | The base class instance would be dynamically changed to the 242 | corresponding Proxy class instance, depending on the state. Even for 243 | queryset results, you will get Proxy class instances, even if the 244 | QuerySet is executed on the base class. 245 | 246 | Check the [test 247 | case](https://github.com/kmmbvnr/django-fsm/blob/master/tests/testapp/tests/test_state_transitions.py) 248 | for example usage. Or read about [implementation 249 | internals](http://schinckel.net/2013/06/13/django-proxy-model-state-machine/) 250 | 251 | ### Permissions 252 | 253 | It is common to have permissions attached to each model transition. 254 | `django-fsm` handles this with `permission` keyword on the `transition` 255 | decorator. `permission` accepts a permission string, or callable that 256 | expects `instance` and `user` arguments and returns True if the user can 257 | perform the transition. 258 | 259 | ``` python 260 | @transition(field=state, source='*', target='published', 261 | permission=lambda instance, user: not user.has_perm('myapp.can_make_mistakes')) 262 | def publish(self): 263 | pass 264 | 265 | @transition(field=state, source='*', target='removed', 266 | permission='myapp.can_remove_post') 267 | def remove(self): 268 | pass 269 | ``` 270 | 271 | You can check permission with `has_transition_permission` method 272 | 273 | ``` python 274 | from django_fsm import has_transition_perm 275 | def publish_view(request, post_id): 276 | post = get_object_or_404(BlogPost, pk=post_id) 277 | if not has_transition_perm(post.publish, request.user): 278 | raise PermissionDenied 279 | 280 | post.publish() 281 | post.save() 282 | return redirect('/') 283 | ``` 284 | 285 | ### Model methods 286 | 287 | `get_all_FIELD_transitions` Enumerates all declared transitions 288 | 289 | `get_available_FIELD_transitions` Returns all transitions data available 290 | in current state 291 | 292 | `get_available_user_FIELD_transitions` Enumerates all transitions data 293 | available in current state for provided user 294 | 295 | ### Foreign Key constraints support 296 | 297 | If you store the states in the db table you could use FSMKeyField to 298 | ensure Foreign Key database integrity. 299 | 300 | In your model : 301 | 302 | ``` python 303 | class DbState(models.Model): 304 | id = models.CharField(primary_key=True) 305 | label = models.CharField() 306 | 307 | def __str__(self): 308 | return self.label 309 | 310 | 311 | class BlogPost(models.Model): 312 | state = FSMKeyField(DbState, default='new') 313 | 314 | @transition(field=state, source='new', target='published') 315 | def publish(self): 316 | pass 317 | ``` 318 | 319 | In your fixtures/initial_data.json : 320 | 321 | ``` json 322 | [ 323 | { 324 | "pk": "new", 325 | "model": "myapp.dbstate", 326 | "fields": { 327 | "label": "_NEW_" 328 | } 329 | }, 330 | { 331 | "pk": "published", 332 | "model": "myapp.dbstate", 333 | "fields": { 334 | "label": "_PUBLISHED_" 335 | } 336 | } 337 | ] 338 | ``` 339 | 340 | Note : source and target parameters in \@transition decorator use pk 341 | values of DBState model as names, even if field \"real\" name is used, 342 | without \_id postfix, as field parameter. 343 | 344 | ### Integer Field support 345 | 346 | You can also use `FSMIntegerField`. This is handy when you want to use 347 | enum style constants. 348 | 349 | ``` python 350 | class BlogPostStateEnum(object): 351 | NEW = 10 352 | PUBLISHED = 20 353 | HIDDEN = 30 354 | 355 | class BlogPostWithIntegerField(models.Model): 356 | state = FSMIntegerField(default=BlogPostStateEnum.NEW) 357 | 358 | @transition(field=state, source=BlogPostStateEnum.NEW, target=BlogPostStateEnum.PUBLISHED) 359 | def publish(self): 360 | pass 361 | ``` 362 | 363 | ### Signals 364 | 365 | `django_fsm.signals.pre_transition` and 366 | `django_fsm.signals.post_transition` are called before and after allowed 367 | transition. No signals on invalid transition are called. 368 | 369 | Arguments sent with these signals: 370 | 371 | **sender** The model class. 372 | 373 | **instance** The actual instance being processed 374 | 375 | **name** Transition name 376 | 377 | **source** Source model state 378 | 379 | **target** Target model state 380 | 381 | ## Optimistic locking 382 | 383 | `django-fsm` provides optimistic locking mixin, to avoid concurrent 384 | model state changes. If model state was changed in database 385 | `django_fsm.ConcurrentTransition` exception would be raised on 386 | model.save() 387 | 388 | ``` python 389 | from django_fsm import FSMField, ConcurrentTransitionMixin 390 | 391 | class BlogPost(ConcurrentTransitionMixin, models.Model): 392 | state = FSMField(default='new') 393 | ``` 394 | 395 | For guaranteed protection against race conditions caused by concurrently 396 | executed transitions, make sure: 397 | 398 | - Your transitions do not have any side effects except for changes in 399 | the database, 400 | - You always run the save() method on the object within 401 | `django.db.transaction.atomic()` block. 402 | 403 | Following these recommendations, you can rely on 404 | ConcurrentTransitionMixin to cause a rollback of all the changes that 405 | have been executed in an inconsistent (out of sync) state, thus 406 | practically negating their effect. 407 | 408 | ## Drawing transitions 409 | 410 | Renders a graphical overview of your models states transitions 411 | 412 | 1. You need `pip install "graphviz>=0.4"` library 413 | 414 | 2. Make sure `django_fsm` is in your `INSTALLED_APPS` settings: 415 | 416 | ``` python 417 | INSTALLED_APPS = ( 418 | ... 419 | 'django_fsm', 420 | ... 421 | ) 422 | ``` 423 | 424 | 3. Then you can use `graph_transitions` command: 425 | 426 | ``` bash 427 | # Create a dot file 428 | $ ./manage.py graph_transitions > transitions.dot 429 | 430 | # Create a PNG image file only for specific model 431 | $ ./manage.py graph_transitions -o blog_transitions.png myapp.Blog 432 | 433 | # Exclude some transitions 434 | $ ./manage.py graph_transitions -e transition_1,transition_2 myapp.Blog 435 | ``` 436 | 437 | ## Extensions 438 | 439 | You may also take a look at django-fsm-2-admin project containing a mixin 440 | and template tags to integrate django-fsm-2 state transitions into the 441 | django admin. 442 | 443 | 444 | 445 | Transition logging support could be achieved with help of django-fsm-log 446 | package 447 | 448 | 449 | -------------------------------------------------------------------------------- /django_fsm/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | State tracking functionality for django models 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import inspect 8 | from functools import partialmethod 9 | from functools import wraps 10 | 11 | from django import VERSION as DJANGO_VERSION 12 | from django.apps import apps as django_apps 13 | from django.db import models 14 | from django.db.models import Field 15 | from django.db.models.query_utils import DeferredAttribute 16 | from django.db.models.signals import class_prepared 17 | 18 | from django_fsm.signals import post_transition 19 | from django_fsm.signals import pre_transition 20 | 21 | __all__ = [ 22 | "GET_STATE", 23 | "RETURN_VALUE", 24 | "ConcurrentTransition", 25 | "ConcurrentTransitionMixin", 26 | "FSMField", 27 | "FSMFieldMixin", 28 | "FSMIntegerField", 29 | "FSMKeyField", 30 | "TransitionNotAllowed", 31 | "can_proceed", 32 | "has_transition_perm", 33 | "transition", 34 | ] 35 | 36 | 37 | class TransitionNotAllowed(Exception): # noqa: N818 38 | """Raised when a transition is not allowed""" 39 | 40 | def __init__(self, *args, **kwargs): 41 | self.object = kwargs.pop("object", None) 42 | self.method = kwargs.pop("method", None) 43 | super().__init__(*args, **kwargs) 44 | 45 | 46 | class InvalidResultState(Exception): # noqa: N818 47 | """Raised when we got invalid result state""" 48 | 49 | 50 | class ConcurrentTransition(Exception): # noqa: N818 51 | """ 52 | Raised when the transition cannot be executed because the 53 | object has become stale (state has been changed since it 54 | was fetched from the database). 55 | """ 56 | 57 | 58 | class Transition: 59 | def __init__(self, method, source, target, on_error, conditions, permission, custom): 60 | self.method = method 61 | self.source = source 62 | self.target = target 63 | self.on_error = on_error 64 | self.conditions = conditions 65 | self.permission = permission 66 | self.custom = custom 67 | 68 | @property 69 | def name(self): 70 | return self.method.__name__ 71 | 72 | def has_perm(self, instance, user): 73 | if not self.permission: 74 | return True 75 | if callable(self.permission): 76 | return bool(self.permission(instance, user)) 77 | if user.has_perm(self.permission, instance): 78 | return True 79 | if user.has_perm(self.permission): 80 | return True 81 | return False 82 | 83 | def __hash__(self): 84 | return hash(self.name) 85 | 86 | def __eq__(self, other): 87 | if isinstance(other, str): 88 | return other == self.name 89 | if isinstance(other, Transition): 90 | return other.name == self.name 91 | 92 | return False 93 | 94 | 95 | def get_available_FIELD_transitions(instance, field): # noqa: N802 96 | """ 97 | List of transitions available in current model state 98 | with all conditions met 99 | """ 100 | curr_state = field.get_state(instance) 101 | transitions = field.transitions[instance.__class__] 102 | 103 | for transition in transitions.values(): 104 | meta = transition._django_fsm 105 | if meta.has_transition(curr_state) and meta.conditions_met(instance, curr_state): 106 | yield meta.get_transition(curr_state) 107 | 108 | 109 | def get_all_FIELD_transitions(instance, field): # noqa: N802 110 | """ 111 | List of all transitions available in current model state 112 | """ 113 | return field.get_all_transitions(instance.__class__) 114 | 115 | 116 | def get_available_user_FIELD_transitions(instance, user, field): # noqa: N802 117 | """ 118 | List of transitions available in current model state 119 | with all conditions met and user have rights on it 120 | """ 121 | for transition in get_available_FIELD_transitions(instance, field): 122 | if transition.has_perm(instance, user): 123 | yield transition 124 | 125 | 126 | class FSMMeta: 127 | """ 128 | Models methods transitions meta information 129 | """ 130 | 131 | def __init__(self, field, method): 132 | self.field = field 133 | self.transitions = {} # source -> Transition 134 | 135 | def get_transition(self, source): 136 | transition = self.transitions.get(source, None) 137 | if transition is None: 138 | transition = self.transitions.get("*", None) 139 | if transition is None: 140 | transition = self.transitions.get("+", None) 141 | return transition 142 | 143 | def add_transition(self, method, source, target, on_error=None, conditions=[], permission=None, custom={}): 144 | if source in self.transitions: 145 | raise AssertionError(f"Duplicate transition for {source} state") 146 | 147 | self.transitions[source] = Transition( 148 | method=method, 149 | source=source, 150 | target=target, 151 | on_error=on_error, 152 | conditions=conditions, 153 | permission=permission, 154 | custom=custom, 155 | ) 156 | 157 | def has_transition(self, state): 158 | """ 159 | Lookup if any transition exists from current model state using current method 160 | """ 161 | if state in self.transitions: 162 | return True 163 | 164 | if "*" in self.transitions: 165 | return True 166 | 167 | if "+" in self.transitions and self.transitions["+"].target != state: 168 | return True 169 | 170 | return False 171 | 172 | def conditions_met(self, instance, state): 173 | """ 174 | Check if all conditions have been met 175 | """ 176 | transition = self.get_transition(state) 177 | 178 | if transition is None: 179 | return False 180 | 181 | if transition.conditions is None: 182 | return True 183 | 184 | return all(condition(instance) for condition in transition.conditions) 185 | 186 | def has_transition_perm(self, instance, state, user): 187 | transition = self.get_transition(state) 188 | 189 | if not transition: 190 | return False 191 | 192 | return transition.has_perm(instance, user) 193 | 194 | def next_state(self, current_state): 195 | transition = self.get_transition(current_state) 196 | 197 | if transition is None: 198 | raise TransitionNotAllowed(f"No transition from {current_state}") 199 | 200 | return transition.target 201 | 202 | def exception_state(self, current_state): 203 | transition = self.get_transition(current_state) 204 | 205 | if transition is None: 206 | raise TransitionNotAllowed(f"No transition from {current_state}") 207 | 208 | return transition.on_error 209 | 210 | 211 | class FSMFieldDescriptor: 212 | def __init__(self, field): 213 | self.field = field 214 | 215 | def __get__(self, instance, instance_type=None): 216 | if instance is None: 217 | return self 218 | return self.field.get_state(instance) 219 | 220 | def __set__(self, instance, value): 221 | if self.field.protected and self.field.name in instance.__dict__: 222 | raise AttributeError(f"Direct {self.field.name} modification is not allowed") 223 | 224 | # Update state 225 | self.field.set_proxy(instance, value) 226 | self.field.set_state(instance, value) 227 | 228 | 229 | class FSMFieldMixin: 230 | descriptor_class = FSMFieldDescriptor 231 | 232 | def __init__(self, *args, **kwargs): 233 | self.protected = kwargs.pop("protected", False) 234 | self.transitions = {} # cls -> (transitions name -> method) 235 | self.state_proxy = {} # state -> ProxyClsRef 236 | 237 | state_choices = kwargs.pop("state_choices", None) 238 | choices = kwargs.get("choices") 239 | if state_choices is not None and choices is not None: 240 | raise ValueError("Use one of choices or state_choices value") 241 | 242 | if state_choices is not None: 243 | choices = [] 244 | for state, title, proxy_cls_ref in state_choices: 245 | choices.append((state, title)) 246 | self.state_proxy[state] = proxy_cls_ref 247 | kwargs["choices"] = choices 248 | 249 | super().__init__(*args, **kwargs) 250 | 251 | def deconstruct(self): 252 | name, path, args, kwargs = super().deconstruct() 253 | if self.protected: 254 | kwargs["protected"] = self.protected 255 | return name, path, args, kwargs 256 | 257 | def get_state(self, instance): 258 | # The state field may be deferred. We delegate the logic of figuring this out 259 | # and loading the deferred field on-demand to Django's built-in DeferredAttribute class. 260 | return DeferredAttribute(self).__get__(instance) 261 | 262 | def set_state(self, instance, state): 263 | instance.__dict__[self.name] = state 264 | 265 | def set_proxy(self, instance, state): 266 | """ 267 | Change class 268 | """ 269 | if state in self.state_proxy: 270 | state_proxy = self.state_proxy[state] 271 | 272 | try: 273 | app_label, model_name = state_proxy.split(".") 274 | except ValueError: 275 | # If we can't split, assume a model in current app 276 | app_label = instance._meta.app_label 277 | model_name = state_proxy 278 | 279 | model = django_apps.get_app_config(app_label).get_model(model_name) 280 | 281 | if model is None: 282 | raise ValueError(f"No model found {state_proxy}") 283 | 284 | instance.__class__ = model 285 | 286 | def change_state(self, instance, method, *args, **kwargs): 287 | meta = method._django_fsm 288 | method_name = method.__name__ 289 | current_state = self.get_state(instance) 290 | 291 | if not meta.has_transition(current_state): 292 | raise TransitionNotAllowed( 293 | f"Can't switch from state '{current_state}' using method '{method_name}'", 294 | object=instance, 295 | method=method, 296 | ) 297 | if not meta.conditions_met(instance, current_state): 298 | raise TransitionNotAllowed( 299 | f"Transition conditions have not been met for method '{method_name}'", object=instance, method=method 300 | ) 301 | 302 | next_state = meta.next_state(current_state) 303 | 304 | signal_kwargs = { 305 | "sender": instance.__class__, 306 | "instance": instance, 307 | "name": method_name, 308 | "field": meta.field, 309 | "source": current_state, 310 | "target": next_state, 311 | "method_args": args, 312 | "method_kwargs": kwargs, 313 | } 314 | 315 | pre_transition.send(**signal_kwargs) 316 | 317 | try: 318 | result = method(instance, *args, **kwargs) 319 | if next_state is not None: 320 | if hasattr(next_state, "get_state"): 321 | next_state = next_state.get_state(instance, transition, result, args=args, kwargs=kwargs) 322 | signal_kwargs["target"] = next_state 323 | self.set_proxy(instance, next_state) 324 | self.set_state(instance, next_state) 325 | except Exception as exc: 326 | exception_state = meta.exception_state(current_state) 327 | if exception_state: 328 | self.set_proxy(instance, exception_state) 329 | self.set_state(instance, exception_state) 330 | signal_kwargs["target"] = exception_state 331 | signal_kwargs["exception"] = exc 332 | post_transition.send(**signal_kwargs) 333 | raise 334 | else: 335 | post_transition.send(**signal_kwargs) 336 | 337 | return result 338 | 339 | def get_all_transitions(self, instance_cls): 340 | """ 341 | Returns [(source, target, name, method)] for all field transitions 342 | """ 343 | transitions = self.transitions[instance_cls] 344 | 345 | for transition in transitions.values(): 346 | meta = transition._django_fsm 347 | 348 | yield from meta.transitions.values() 349 | 350 | def contribute_to_class(self, cls, name, **kwargs): 351 | self.base_cls = cls 352 | 353 | super().contribute_to_class(cls, name, **kwargs) 354 | setattr(cls, self.name, self.descriptor_class(self)) 355 | setattr(cls, f"get_all_{self.name}_transitions", partialmethod(get_all_FIELD_transitions, field=self)) 356 | setattr(cls, f"get_available_{self.name}_transitions", partialmethod(get_available_FIELD_transitions, field=self)) 357 | setattr( 358 | cls, 359 | f"get_available_user_{self.name}_transitions", 360 | partialmethod(get_available_user_FIELD_transitions, field=self), 361 | ) 362 | 363 | class_prepared.connect(self._collect_transitions) 364 | 365 | def _collect_transitions(self, *args, **kwargs): 366 | sender = kwargs["sender"] 367 | 368 | if not issubclass(sender, self.base_cls): 369 | return 370 | 371 | def is_field_transition_method(attr): 372 | return ( 373 | (inspect.ismethod(attr) or inspect.isfunction(attr)) 374 | and hasattr(attr, "_django_fsm") 375 | and ( 376 | attr._django_fsm.field in [self, self.name] 377 | or ( 378 | isinstance(attr._django_fsm.field, Field) 379 | and attr._django_fsm.field.name == self.name 380 | and attr._django_fsm.field.creation_counter == self.creation_counter 381 | ) 382 | ) 383 | ) 384 | 385 | sender_transitions = {} 386 | transitions = inspect.getmembers(sender, predicate=is_field_transition_method) 387 | for method_name, method in transitions: 388 | method._django_fsm.field = self 389 | sender_transitions[method_name] = method 390 | 391 | self.transitions[sender] = sender_transitions 392 | 393 | 394 | class FSMField(FSMFieldMixin, models.CharField): 395 | """ 396 | State Machine support for Django model as CharField 397 | """ 398 | 399 | def __init__(self, *args, **kwargs): 400 | kwargs.setdefault("max_length", 50) 401 | super().__init__(*args, **kwargs) 402 | 403 | 404 | class FSMIntegerField(FSMFieldMixin, models.IntegerField): 405 | """ 406 | Same as FSMField, but stores the state value in an IntegerField. 407 | """ 408 | 409 | 410 | class FSMKeyField(FSMFieldMixin, models.ForeignKey): 411 | """ 412 | State Machine support for Django model 413 | """ 414 | 415 | def get_state(self, instance): 416 | return instance.__dict__[self.attname] 417 | 418 | def set_state(self, instance, state): 419 | instance.__dict__[self.attname] = self.to_python(state) 420 | 421 | 422 | class FSMModelMixin: 423 | """ 424 | Mixin that allows refresh_from_db for models with fsm protected fields 425 | """ 426 | 427 | def _get_protected_fsm_fields(self): 428 | def is_fsm_and_protected(f): 429 | return isinstance(f, FSMFieldMixin) and f.protected 430 | 431 | protected_fields = filter(is_fsm_and_protected, self._meta.concrete_fields) 432 | return {f.attname for f in protected_fields} 433 | 434 | def refresh_from_db(self, *args, **kwargs): 435 | fields = kwargs.pop("fields", None) 436 | 437 | # Use provided fields, if not set then reload all non-deferred fields.0 438 | if not fields: 439 | deferred_fields = self.get_deferred_fields() 440 | protected_fields = self._get_protected_fsm_fields() 441 | skipped_fields = deferred_fields.union(protected_fields) 442 | 443 | fields = [f.attname for f in self._meta.concrete_fields if f.attname not in skipped_fields] 444 | 445 | kwargs["fields"] = fields 446 | super().refresh_from_db(*args, **kwargs) 447 | 448 | 449 | class ConcurrentTransitionMixin: 450 | """ 451 | Protects a Model from undesirable effects caused by concurrently executed transitions, 452 | e.g. running the same transition multiple times at the same time, or running different 453 | transitions with the same SOURCE state at the same time. 454 | 455 | This behavior is achieved using an idea based on optimistic locking. No additional 456 | version field is required though; only the state field(s) is/are used for the tracking. 457 | This scheme is not that strict as true *optimistic locking* mechanism, it is however 458 | more lightweight - leveraging the specifics of FSM models. 459 | 460 | Instance of a model based on this Mixin will be prevented from saving into DB if any 461 | of its state fields (instances of FSMFieldMixin) has been changed since the object 462 | was fetched from the database. *ConcurrentTransition* exception will be raised in such 463 | cases. 464 | 465 | For guaranteed protection against such race conditions, make sure: 466 | * Your transitions do not have any side effects except for changes in the database, 467 | * You always run the save() method on the object within django.db.transaction.atomic() 468 | block. 469 | 470 | Following these recommendations, you can rely on ConcurrentTransitionMixin to cause 471 | a rollback of all the changes that have been executed in an inconsistent (out of sync) 472 | state, thus practically negating their effect. 473 | """ 474 | 475 | def __init__(self, *args, **kwargs): 476 | super().__init__(*args, **kwargs) 477 | self._update_initial_state() 478 | 479 | @property 480 | def state_fields(self): 481 | return filter(lambda field: isinstance(field, FSMFieldMixin), self._meta.fields) 482 | 483 | def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update, returning_fields=None): 484 | # _do_update is called once for each model class in the inheritance hierarchy. 485 | # We can only filter the base_qs on state fields (can be more than one!) present in this particular model. 486 | 487 | # Select state fields to filter on 488 | filter_on = filter(lambda field: field.model == base_qs.model, self.state_fields) 489 | 490 | # state filter will be used to narrow down the standard filter checking only PK 491 | state_filter = {field.attname: self.__initial_states[field.attname] for field in filter_on} 492 | 493 | # Django 6.0+ added returning_fields parameter to _do_update 494 | if DJANGO_VERSION >= (6, 0): 495 | updated = super()._do_update( 496 | base_qs=base_qs.filter(**state_filter), 497 | using=using, 498 | pk_val=pk_val, 499 | values=values, 500 | update_fields=update_fields, 501 | forced_update=forced_update, 502 | returning_fields=returning_fields, 503 | ) 504 | else: 505 | updated = super()._do_update( 506 | base_qs=base_qs.filter(**state_filter), 507 | using=using, 508 | pk_val=pk_val, 509 | values=values, 510 | update_fields=update_fields, 511 | forced_update=forced_update, 512 | ) 513 | 514 | # It may happen that nothing was updated in the original _do_update method not because of unmatching state, 515 | # but because of missing PK. This codepath is possible when saving a new model instance with *preset PK*. 516 | # In this case Django does not know it has to do INSERT operation, so it tries UPDATE first and falls back to 517 | # INSERT if UPDATE fails. 518 | # Thus, we need to make sure we only catch the case when the object *is* in the DB, but with changed state; and 519 | # mimic standard _do_update behavior otherwise. Django will pick it up and execute _do_insert. 520 | if not updated and base_qs.filter(pk=pk_val).using(using).exists(): 521 | raise ConcurrentTransition("Cannot save object! The state has been changed since fetched from the database!") 522 | 523 | return updated 524 | 525 | def _update_initial_state(self): 526 | self.__initial_states = {field.attname: field.value_from_object(self) for field in self.state_fields} 527 | 528 | def refresh_from_db(self, *args, **kwargs): 529 | super().refresh_from_db(*args, **kwargs) 530 | self._update_initial_state() 531 | 532 | def save(self, *args, **kwargs): 533 | super().save(*args, **kwargs) 534 | self._update_initial_state() 535 | 536 | 537 | def transition(field, source="*", target=None, on_error=None, conditions=[], permission=None, custom={}): 538 | """ 539 | Method decorator to mark allowed transitions. 540 | 541 | Set target to None if current state needs to be validated and 542 | has not changed after the function call. 543 | """ 544 | 545 | def inner_transition(func): 546 | wrapper_installed, fsm_meta = True, getattr(func, "_django_fsm", None) 547 | if not fsm_meta: 548 | wrapper_installed = False 549 | fsm_meta = FSMMeta(field=field, method=func) 550 | setattr(func, "_django_fsm", fsm_meta) 551 | 552 | if isinstance(source, (list, tuple, set)): 553 | for state in source: 554 | func._django_fsm.add_transition(func, state, target, on_error, conditions, permission, custom) 555 | else: 556 | func._django_fsm.add_transition(func, source, target, on_error, conditions, permission, custom) 557 | 558 | @wraps(func) 559 | def _change_state(instance, *args, **kwargs): 560 | return fsm_meta.field.change_state(instance, func, *args, **kwargs) 561 | 562 | if not wrapper_installed: 563 | return _change_state 564 | 565 | return func 566 | 567 | return inner_transition 568 | 569 | 570 | def can_proceed(bound_method, check_conditions=True): # noqa: FBT002 571 | """ 572 | Returns True if model in state allows to call bound_method 573 | 574 | Set ``check_conditions`` argument to ``False`` to skip checking 575 | conditions. 576 | """ 577 | if not hasattr(bound_method, "_django_fsm"): 578 | raise TypeError(f"{bound_method.__func__.__name__} method is not transition") 579 | 580 | meta = bound_method._django_fsm 581 | self = bound_method.__self__ 582 | current_state = meta.field.get_state(self) 583 | 584 | return meta.has_transition(current_state) and (not check_conditions or meta.conditions_met(self, current_state)) 585 | 586 | 587 | def has_transition_perm(bound_method, user): 588 | """ 589 | Returns True if model in state allows to call bound_method and user have rights on it 590 | """ 591 | if not hasattr(bound_method, "_django_fsm"): 592 | raise TypeError(f"{bound_method.__func__.__name__} method is not transition") 593 | 594 | meta = bound_method._django_fsm 595 | self = bound_method.__self__ 596 | current_state = meta.field.get_state(self) 597 | 598 | return ( 599 | meta.has_transition(current_state) 600 | and meta.conditions_met(self, current_state) 601 | and meta.has_transition_perm(self, current_state, user) 602 | ) 603 | 604 | 605 | class State: 606 | def get_state(self, model, transition, result, args=[], kwargs={}): 607 | raise NotImplementedError 608 | 609 | 610 | class RETURN_VALUE(State): # noqa: N801 611 | def __init__(self, *allowed_states): 612 | self.allowed_states = allowed_states if allowed_states else None 613 | 614 | def get_state(self, model, transition, result, args=[], kwargs={}): 615 | if self.allowed_states is not None and result not in self.allowed_states: 616 | raise InvalidResultState(f"{result} is not in list of allowed states\n{self.allowed_states}") 617 | return result 618 | 619 | 620 | class GET_STATE(State): # noqa: N801 621 | def __init__(self, func, states=None): 622 | self.func = func 623 | self.allowed_states = states 624 | 625 | def get_state(self, model, transition, result, args=[], kwargs={}): 626 | result_state = self.func(model, *args, **kwargs) 627 | if self.allowed_states is not None and result_state not in self.allowed_states: 628 | raise InvalidResultState(f"{result_state} is not in list of allowed states\n{self.allowed_states}") 629 | return result_state 630 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.8, <4" 3 | resolution-markers = [ 4 | "python_full_version >= '3.10'", 5 | "python_full_version < '3.10'", 6 | ] 7 | 8 | [[package]] 9 | name = "asgiref" 10 | version = "3.8.1" 11 | source = { registry = "https://pypi.org/simple" } 12 | dependencies = [ 13 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 14 | ] 15 | sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } 16 | wheels = [ 17 | { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, 18 | ] 19 | 20 | [[package]] 21 | name = "backports-zoneinfo" 22 | version = "0.2.1" 23 | source = { registry = "https://pypi.org/simple" } 24 | sdist = { url = "https://files.pythonhosted.org/packages/ad/85/475e514c3140937cf435954f78dedea1861aeab7662d11de232bdaa90655/backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2", size = 74098 } 25 | wheels = [ 26 | { url = "https://files.pythonhosted.org/packages/4a/6d/eca004eeadcbf8bd64cc96feb9e355536147f0577420b44d80c7cac70767/backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987", size = 35816 }, 27 | { url = "https://files.pythonhosted.org/packages/c1/8f/9b1b920a6a95652463143943fa3b8c000cb0b932ab463764a6f2a2416560/backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1", size = 72147 }, 28 | { url = "https://files.pythonhosted.org/packages/1a/ab/3e941e3fcf1b7d3ab3d0233194d99d6a0ed6b24f8f956fc81e47edc8c079/backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9", size = 74033 }, 29 | { url = "https://files.pythonhosted.org/packages/c0/34/5fdb0a3a28841d215c255be8fc60b8666257bb6632193c86fd04b63d4a31/backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328", size = 36803 }, 30 | { url = "https://files.pythonhosted.org/packages/78/cc/e27fd6493bbce8dbea7e6c1bc861fe3d3bc22c4f7c81f4c3befb8ff5bfaf/backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6", size = 38967 }, 31 | ] 32 | 33 | [[package]] 34 | name = "cfgv" 35 | version = "3.4.0" 36 | source = { registry = "https://pypi.org/simple" } 37 | sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } 38 | wheels = [ 39 | { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, 40 | ] 41 | 42 | [[package]] 43 | name = "colorama" 44 | version = "0.4.6" 45 | source = { registry = "https://pypi.org/simple" } 46 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 47 | wheels = [ 48 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 49 | ] 50 | 51 | [[package]] 52 | name = "coverage" 53 | version = "7.5.4" 54 | source = { registry = "https://pypi.org/simple" } 55 | sdist = { url = "https://files.pythonhosted.org/packages/ef/05/31553dc038667012853d0a248b57987d8d70b2d67ea885605f87bcb1baba/coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353", size = 793238 } 56 | wheels = [ 57 | { url = "https://files.pythonhosted.org/packages/38/3d/9f9469f445789a170cb5bef3ad02ae9084ddd689f938797aa8ee793db404/coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99", size = 205105 }, 58 | { url = "https://files.pythonhosted.org/packages/b0/d8/b7bde23a5e94cfc1a45effad2dd4c45dc111c515f71c522986dd8ded31a1/coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47", size = 205537 }, 59 | { url = "https://files.pythonhosted.org/packages/99/49/0e8c8e8f9f7ea87ed94ddce70cdfe49224b13857ef3cbdb65a5eb29bba6f/coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e", size = 234067 }, 60 | { url = "https://files.pythonhosted.org/packages/a9/9a/79381c5dbc118b5cc0aac637352f65078766527ab0d23031d5421f2fb144/coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d", size = 232015 }, 61 | { url = "https://files.pythonhosted.org/packages/a2/78/d457df19baefbe3d38ef63cddfbda0f443d6546f3f56fa95cd884d612e8e/coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3", size = 233145 }, 62 | { url = "https://files.pythonhosted.org/packages/ef/48/fccbf1b4ab5943e1b5bf5e29892531341b4c2731c448c6970349b0bb2f3b/coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016", size = 232230 }, 63 | { url = "https://files.pythonhosted.org/packages/44/ab/1ce64d6d01486b7e307ce0b25565b2337b9883d409fdb7655c94b0e80ae7/coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136", size = 230741 }, 64 | { url = "https://files.pythonhosted.org/packages/fd/a2/4db4030508e3f7267151155e2221487e1515eda167262d0aa88bce8d4b57/coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9", size = 231831 }, 65 | { url = "https://files.pythonhosted.org/packages/a6/90/3e1a9e003f3bc35cde1b1082f740e3c0ad90595caf31df0e49473c3f230a/coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8", size = 207727 }, 66 | { url = "https://files.pythonhosted.org/packages/a5/0f/d56b6b9c2e900b9e51b8dae6b46aa15eb43a6a41342c9b0faca2a6c9890a/coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f", size = 208519 }, 67 | { url = "https://files.pythonhosted.org/packages/48/92/f56bf17b10efdb21311b7aa6853afc39eb962af0f9595a24408f7df3f694/coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5", size = 205211 }, 68 | { url = "https://files.pythonhosted.org/packages/b8/69/a3bdace4d667f592b7730c0d636ac9ff9195f678fb4e61b5469b91e49919/coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba", size = 205664 }, 69 | { url = "https://files.pythonhosted.org/packages/cd/bd/8515e955724baab11e8220a3872dc3d1c895b841b281ac8865834257ae2e/coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b", size = 237695 }, 70 | { url = "https://files.pythonhosted.org/packages/41/d5/f4f9d2d86e3bd0c3ae761e2511c4033abcdce1de8f1926f8e7c98952540d/coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080", size = 235270 }, 71 | { url = "https://files.pythonhosted.org/packages/1e/62/e33595d35c9fa7cbcca5df2c3745b595532ec94b68c49ca2877629c4aca1/coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c", size = 236966 }, 72 | { url = "https://files.pythonhosted.org/packages/62/ea/e5ae9c845bef94369a3b9b66eb1e0857289c0a769b20078fcf5a5e6021be/coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da", size = 235891 }, 73 | { url = "https://files.pythonhosted.org/packages/33/7f/068a5d05ca6c89295bc8b7ae7ad5ed9d7b0286305a2444eb4d1eb42cb902/coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0", size = 234548 }, 74 | { url = "https://files.pythonhosted.org/packages/15/a6/bbeeb4c0447a0ae8993e7d9b7ac8c8538ffb1a4210d106573238233f58c8/coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078", size = 235329 }, 75 | { url = "https://files.pythonhosted.org/packages/01/54/e009827b234225815743303d002a146183ea25e011c088dfa7a87f895fdf/coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806", size = 207728 }, 76 | { url = "https://files.pythonhosted.org/packages/cd/48/8b929edd540634d8e7ed50d78e86790613e8733edf7eb21c2c217bf25176/coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d", size = 208614 }, 77 | { url = "https://files.pythonhosted.org/packages/6d/96/58bcb3417c2fd38fae862704599f7088451bb6c8786f5cec6887366e78d9/coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233", size = 205392 }, 78 | { url = "https://files.pythonhosted.org/packages/2c/63/4f781db529b585a6ef3860ea01390951b006dbea9ada4ea3a3d830e325f4/coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747", size = 205634 }, 79 | { url = "https://files.pythonhosted.org/packages/57/50/c5aadf036078072f31d8f1ae1a6000cc70f3f6cf652939c2d77551174d77/coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638", size = 238754 }, 80 | { url = "https://files.pythonhosted.org/packages/eb/a6/57c42994b1686461c7b0b29de3b6d3d60c5f23a656f96460f9c755a31506/coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e", size = 235783 }, 81 | { url = "https://files.pythonhosted.org/packages/88/52/7054710a881b09d295e93b9889ac204c241a6847a8c05555fc6e1d8799d5/coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555", size = 237865 }, 82 | { url = "https://files.pythonhosted.org/packages/a0/c3/57ef08c70483b83feb4e0d22345010aaf0afbe442dba015da3b173076c36/coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f", size = 237340 }, 83 | { url = "https://files.pythonhosted.org/packages/d8/44/465fa8f8edc11a18cbb83673f29b1af20ccf5139a66fbe2768ff67527ff0/coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c", size = 235663 }, 84 | { url = "https://files.pythonhosted.org/packages/ef/e5/829ddcfb29ad41661ba8e9cac7dc52100fd2c4853bb93d668a3ebde64862/coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805", size = 237309 }, 85 | { url = "https://files.pythonhosted.org/packages/98/f6/f9c96fbf9b36be3f4d8c252ab2b4944420d99425f235f492784498804182/coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b", size = 207988 }, 86 | { url = "https://files.pythonhosted.org/packages/0e/c1/2b7c7dcf4c273aac7676f12fb2b5524b133671d731ab91bd9a41c21675b9/coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7", size = 208756 }, 87 | { url = "https://files.pythonhosted.org/packages/f2/82/5ddb436de663abe2ec566461fa106f9f344afae339f0f56963f020fd94b4/coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882", size = 205088 }, 88 | { url = "https://files.pythonhosted.org/packages/71/d3/751c6dc673211a5cad695a59f782013e3f0f466d16ecaf9a34f0167f5e98/coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d", size = 205510 }, 89 | { url = "https://files.pythonhosted.org/packages/83/46/5020dadddbcef1c8f0bf7869a117c4558ff59b2a163b008868a5fb78fc68/coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53", size = 234970 }, 90 | { url = "https://files.pythonhosted.org/packages/e4/9d/6f415813b10ca2927da0b4c948d25fcbd3559f8cd2c04b1aac49ca223131/coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4", size = 232860 }, 91 | { url = "https://files.pythonhosted.org/packages/35/b3/27fbdf02d2e561d68a4e53522c83f4f2756aea5886c73880a96b8afdeaae/coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4", size = 234305 }, 92 | { url = "https://files.pythonhosted.org/packages/d1/dd/b29cc90e643af35acd9ddc99c520b7bcd34ec5c13830f5ef956dd6d6b6a2/coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9", size = 233563 }, 93 | { url = "https://files.pythonhosted.org/packages/8a/da/a3dbe8d7bfa6da354ed63691d2a1c3ec3afb058125ed578647fdf8396aa5/coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f", size = 232224 }, 94 | { url = "https://files.pythonhosted.org/packages/86/d4/b3863e938d1b95b3f34bcf7fa9772b66f40fff0819193749e92a528ebfba/coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f", size = 233071 }, 95 | { url = "https://files.pythonhosted.org/packages/c3/65/4a3ae9bfb1eaec6898e34a8b283c7cc36d07c034f9abf40e494db616a090/coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f", size = 207683 }, 96 | { url = "https://files.pythonhosted.org/packages/ec/55/f38b087d950693b90034abfeefe825f9fda142d3c7469750d5141ab28a9b/coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633", size = 208521 }, 97 | { url = "https://files.pythonhosted.org/packages/57/1f/b6c0725454c49b88c0229cdbb22435a90e94521149ea1d068da1d17906d7/coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088", size = 205100 }, 98 | { url = "https://files.pythonhosted.org/packages/13/f5/3e13e18a4e42fbb7734c1919255841b7fda188babc57e0fcad3c2e5a080e/coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4", size = 205536 }, 99 | { url = "https://files.pythonhosted.org/packages/c6/1c/bd6d46e44ddb2affc73271d22cba263d9e5d8a0afd593a5de62dbd1380cd/coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7", size = 233667 }, 100 | { url = "https://files.pythonhosted.org/packages/01/ee/87e1285608cb69c44a0da9864434818fc53a0a95ec45f112f623a6578ee9/coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8", size = 231650 }, 101 | { url = "https://files.pythonhosted.org/packages/c4/b4/0cbc18998613f8caaec793ad5878d2450382dfac80e65d352fb7cd9cc1dc/coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d", size = 232731 }, 102 | { url = "https://files.pythonhosted.org/packages/41/03/3968f2d60208c4332bb12c2d25fdfdbbd9a5c7a2a64b4ed1050b20a9dc3f/coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029", size = 231874 }, 103 | { url = "https://files.pythonhosted.org/packages/68/d0/1e2bae9d17c7c8757b75a9b9c7bf35083a84fcf5361802bb6da911aa7089/coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c", size = 230372 }, 104 | { url = "https://files.pythonhosted.org/packages/0d/93/9eabf10ab089b9b9dcb026d84e70a3114054b75f2d37fd7e61642da775a1/coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7", size = 231359 }, 105 | { url = "https://files.pythonhosted.org/packages/9d/f9/8a6cf39d151765f6adaa2808032fda07f57fe4ba658deed11bf1b6c65f11/coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace", size = 207741 }, 106 | { url = "https://files.pythonhosted.org/packages/a0/3a/e75878173e3f5ef21c04b96c535b5e0d10195e5fa28a842b781d339c3df9/coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d", size = 208542 }, 107 | { url = "https://files.pythonhosted.org/packages/7a/c3/a5b06a07b68795018f47b5d69b523ad473ac9ee66be3c22c4d3e5eadd91e/coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5", size = 197342 }, 108 | ] 109 | 110 | [package.optional-dependencies] 111 | toml = [ 112 | { name = "tomli", marker = "python_full_version <= '3.11'" }, 113 | ] 114 | 115 | [[package]] 116 | name = "distlib" 117 | version = "0.3.8" 118 | source = { registry = "https://pypi.org/simple" } 119 | sdist = { url = "https://files.pythonhosted.org/packages/c4/91/e2df406fb4efacdf46871c25cde65d3c6ee5e173b7e5a4547a47bae91920/distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64", size = 609931 } 120 | wheels = [ 121 | { url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 }, 122 | ] 123 | 124 | [[package]] 125 | name = "django" 126 | version = "4.2.16" 127 | source = { registry = "https://pypi.org/simple" } 128 | dependencies = [ 129 | { name = "asgiref" }, 130 | { name = "backports-zoneinfo", marker = "python_full_version < '3.9'" }, 131 | { name = "sqlparse" }, 132 | { name = "tzdata", marker = "sys_platform == 'win32'" }, 133 | ] 134 | sdist = { url = "https://files.pythonhosted.org/packages/65/d8/a607ee443b54a4db4ad28902328b906ae6218aa556fb9b3ac45c0bcb313d/Django-4.2.16.tar.gz", hash = "sha256:6f1616c2786c408ce86ab7e10f792b8f15742f7b7b7460243929cb371e7f1dad", size = 10436023 } 135 | wheels = [ 136 | { url = "https://files.pythonhosted.org/packages/94/2c/6b6c7e493d5ea789416918658ebfa16be7a64c77610307497ed09a93c8c4/Django-4.2.16-py3-none-any.whl", hash = "sha256:1ddc333a16fc139fd253035a1606bb24261951bbc3a6ca256717fa06cc41a898", size = 7992936 }, 137 | ] 138 | 139 | [[package]] 140 | name = "django-fsm-2" 141 | version = "4.1.0" 142 | source = { editable = "." } 143 | dependencies = [ 144 | { name = "django" }, 145 | ] 146 | 147 | [package.dev-dependencies] 148 | dev = [ 149 | { name = "coverage" }, 150 | { name = "django-guardian" }, 151 | { name = "graphviz" }, 152 | { name = "pre-commit" }, 153 | { name = "pytest" }, 154 | { name = "pytest-cov" }, 155 | { name = "pytest-django" }, 156 | ] 157 | graphviz = [ 158 | { name = "graphviz" }, 159 | ] 160 | 161 | [package.metadata] 162 | requires-dist = [{ name = "django", specifier = ">=4.2" }] 163 | 164 | [package.metadata.requires-dev] 165 | dev = [ 166 | { name = "coverage" }, 167 | { name = "django-guardian" }, 168 | { name = "graphviz" }, 169 | { name = "pre-commit" }, 170 | { name = "pytest" }, 171 | { name = "pytest-cov" }, 172 | { name = "pytest-django" }, 173 | ] 174 | graphviz = [{ name = "graphviz" }] 175 | 176 | [[package]] 177 | name = "django-guardian" 178 | version = "2.4.0" 179 | source = { registry = "https://pypi.org/simple" } 180 | dependencies = [ 181 | { name = "django" }, 182 | ] 183 | sdist = { url = "https://files.pythonhosted.org/packages/6f/4c/d1f6923a0ad7f16c403a54c09e94acb76ac6c3765e02523fb09b2b03e1a8/django-guardian-2.4.0.tar.gz", hash = "sha256:c58a68ae76922d33e6bdc0e69af1892097838de56e93e78a8361090bcd9f89a0", size = 159008 } 184 | wheels = [ 185 | { url = "https://files.pythonhosted.org/packages/a2/25/869df12e544b51f583254aadbba6c1a95e11d2d08edeb9e58dd715112db5/django_guardian-2.4.0-py3-none-any.whl", hash = "sha256:440ca61358427e575323648b25f8384739e54c38b3d655c81d75e0cd0d61b697", size = 106107 }, 186 | ] 187 | 188 | [[package]] 189 | name = "exceptiongroup" 190 | version = "1.2.1" 191 | source = { registry = "https://pypi.org/simple" } 192 | sdist = { url = "https://files.pythonhosted.org/packages/a0/65/d66b7fbaef021b3c954b3bbb196d21d8a4b97918ea524f82cfae474215af/exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16", size = 28717 } 193 | wheels = [ 194 | { url = "https://files.pythonhosted.org/packages/01/90/79fe92dd413a9cab314ef5c591b5aa9b9ba787ae4cadab75055b0ae00b33/exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad", size = 16458 }, 195 | ] 196 | 197 | [[package]] 198 | name = "filelock" 199 | version = "3.15.4" 200 | source = { registry = "https://pypi.org/simple" } 201 | sdist = { url = "https://files.pythonhosted.org/packages/08/dd/49e06f09b6645156550fb9aee9cc1e59aba7efbc972d665a1bd6ae0435d4/filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", size = 18007 } 202 | wheels = [ 203 | { url = "https://files.pythonhosted.org/packages/ae/f0/48285f0262fe47103a4a45972ed2f9b93e4c80b8fd609fa98da78b2a5706/filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7", size = 16159 }, 204 | ] 205 | 206 | [[package]] 207 | name = "graphviz" 208 | version = "0.20.3" 209 | source = { registry = "https://pypi.org/simple" } 210 | sdist = { url = "https://files.pythonhosted.org/packages/fa/83/5a40d19b8347f017e417710907f824915fba411a9befd092e52746b63e9f/graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d", size = 256455 } 211 | wheels = [ 212 | { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126 }, 213 | ] 214 | 215 | [[package]] 216 | name = "identify" 217 | version = "2.5.36" 218 | source = { registry = "https://pypi.org/simple" } 219 | sdist = { url = "https://files.pythonhosted.org/packages/aa/9a/83775a4e09de8b9d774a2217bfe03038c488778e58561e6970daa39b4801/identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d", size = 99049 } 220 | wheels = [ 221 | { url = "https://files.pythonhosted.org/packages/f7/d3/d31b7fe744a3b2e6c51ea04af6575d1583deb09eb33cecfc99fa7644a725/identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa", size = 98970 }, 222 | ] 223 | 224 | [[package]] 225 | name = "iniconfig" 226 | version = "2.0.0" 227 | source = { registry = "https://pypi.org/simple" } 228 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 229 | wheels = [ 230 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 231 | ] 232 | 233 | [[package]] 234 | name = "nodeenv" 235 | version = "1.9.1" 236 | source = { registry = "https://pypi.org/simple" } 237 | sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } 238 | wheels = [ 239 | { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, 240 | ] 241 | 242 | [[package]] 243 | name = "packaging" 244 | version = "24.1" 245 | source = { registry = "https://pypi.org/simple" } 246 | sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } 247 | wheels = [ 248 | { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, 249 | ] 250 | 251 | [[package]] 252 | name = "platformdirs" 253 | version = "4.2.2" 254 | source = { registry = "https://pypi.org/simple" } 255 | sdist = { url = "https://files.pythonhosted.org/packages/f5/52/0763d1d976d5c262df53ddda8d8d4719eedf9594d046f117c25a27261a19/platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3", size = 20916 } 256 | wheels = [ 257 | { url = "https://files.pythonhosted.org/packages/68/13/2aa1f0e1364feb2c9ef45302f387ac0bd81484e9c9a4c5688a322fbdfd08/platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", size = 18146 }, 258 | ] 259 | 260 | [[package]] 261 | name = "pluggy" 262 | version = "1.5.0" 263 | source = { registry = "https://pypi.org/simple" } 264 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 265 | wheels = [ 266 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 267 | ] 268 | 269 | [[package]] 270 | name = "pre-commit" 271 | version = "3.5.0" 272 | source = { registry = "https://pypi.org/simple" } 273 | dependencies = [ 274 | { name = "cfgv" }, 275 | { name = "identify" }, 276 | { name = "nodeenv" }, 277 | { name = "pyyaml" }, 278 | { name = "virtualenv" }, 279 | ] 280 | sdist = { url = "https://files.pythonhosted.org/packages/04/b3/4ae08d21eb097162f5aad37f4585f8069a86402ed7f5362cc9ae097f9572/pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32", size = 177079 } 281 | wheels = [ 282 | { url = "https://files.pythonhosted.org/packages/6c/75/526915fedf462e05eeb1c75ceaf7e3f9cde7b5ce6f62740fe5f7f19a0050/pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660", size = 203698 }, 283 | ] 284 | 285 | [[package]] 286 | name = "pytest" 287 | version = "8.2.2" 288 | source = { registry = "https://pypi.org/simple" } 289 | dependencies = [ 290 | { name = "colorama", marker = "sys_platform == 'win32'" }, 291 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 292 | { name = "iniconfig" }, 293 | { name = "packaging" }, 294 | { name = "pluggy" }, 295 | { name = "tomli", marker = "python_full_version < '3.11'" }, 296 | ] 297 | sdist = { url = "https://files.pythonhosted.org/packages/a6/58/e993ca5357553c966b9e73cb3475d9c935fe9488746e13ebdf9b80fae508/pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977", size = 1427980 } 298 | wheels = [ 299 | { url = "https://files.pythonhosted.org/packages/4e/e7/81ebdd666d3bff6670d27349b5053605d83d55548e6bd5711f3b0ae7dd23/pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343", size = 339873 }, 300 | ] 301 | 302 | [[package]] 303 | name = "pytest-cov" 304 | version = "4.1.0" 305 | source = { registry = "https://pypi.org/simple" } 306 | dependencies = [ 307 | { name = "coverage", extra = ["toml"] }, 308 | { name = "pytest" }, 309 | ] 310 | sdist = { url = "https://files.pythonhosted.org/packages/7a/15/da3df99fd551507694a9b01f512a2f6cf1254f33601605843c3775f39460/pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", size = 63245 } 311 | wheels = [ 312 | { url = "https://files.pythonhosted.org/packages/a7/4b/8b78d126e275efa2379b1c2e09dc52cf70df16fc3b90613ef82531499d73/pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a", size = 21949 }, 313 | ] 314 | 315 | [[package]] 316 | name = "pytest-django" 317 | version = "4.8.0" 318 | source = { registry = "https://pypi.org/simple" } 319 | dependencies = [ 320 | { name = "pytest" }, 321 | ] 322 | sdist = { url = "https://files.pythonhosted.org/packages/bd/cf/44510ac5479f281d6663a08dff0d93f56b21f4ee091980ea4d4b64491ad6/pytest-django-4.8.0.tar.gz", hash = "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90", size = 83291 } 323 | wheels = [ 324 | { url = "https://files.pythonhosted.org/packages/93/5b/29555191e903881d05e1f7184205ec534c7021e0ee077d1e6a1ee8f1b1eb/pytest_django-4.8.0-py3-none-any.whl", hash = "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7", size = 23432 }, 325 | ] 326 | 327 | [[package]] 328 | name = "pyyaml" 329 | version = "6.0.1" 330 | source = { registry = "https://pypi.org/simple" } 331 | sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/af35f7ea75cf72f2cd079c95ee16797de7cd71f29ea7c68ae5ce7be1eda0/PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", size = 125201 } 332 | wheels = [ 333 | { url = "https://files.pythonhosted.org/packages/96/06/4beb652c0fe16834032e54f0956443d4cc797fe645527acee59e7deaa0a2/PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", size = 189447 }, 334 | { url = "https://files.pythonhosted.org/packages/5b/07/10033a403b23405a8fc48975444463d3d10a5c2736b7eb2550b07b367429/PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f", size = 169264 }, 335 | { url = "https://files.pythonhosted.org/packages/f1/26/55e4f21db1f72eaef092015d9017c11510e7e6301c62a6cfee91295d13c6/PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", size = 677003 }, 336 | { url = "https://files.pythonhosted.org/packages/ba/91/090818dfa62e85181f3ae23dd1e8b7ea7f09684864a900cab72d29c57346/PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", size = 699070 }, 337 | { url = "https://files.pythonhosted.org/packages/29/61/bf33c6c85c55bc45a29eee3195848ff2d518d84735eb0e2d8cb42e0d285e/PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", size = 705525 }, 338 | { url = "https://files.pythonhosted.org/packages/07/91/45dfd0ef821a7f41d9d0136ea3608bb5b1653e42fd56a7970532cb5c003f/PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", size = 707514 }, 339 | { url = "https://files.pythonhosted.org/packages/b6/a0/b6700da5d49e9fed49dc3243d3771b598dad07abb37cc32e524607f96adc/PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", size = 130488 }, 340 | { url = "https://files.pythonhosted.org/packages/24/97/9b59b43431f98d01806b288532da38099cc6f2fea0f3d712e21e269c0279/PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", size = 145338 }, 341 | { url = "https://files.pythonhosted.org/packages/ec/0d/26fb23e8863e0aeaac0c64e03fd27367ad2ae3f3cccf3798ee98ce160368/PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", size = 187867 }, 342 | { url = "https://files.pythonhosted.org/packages/28/09/55f715ddbf95a054b764b547f617e22f1d5e45d83905660e9a088078fe67/PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", size = 167530 }, 343 | { url = "https://files.pythonhosted.org/packages/5e/94/7d5ee059dfb92ca9e62f4057dcdec9ac08a9e42679644854dc01177f8145/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", size = 732244 }, 344 | { url = "https://files.pythonhosted.org/packages/06/92/e0224aa6ebf9dc54a06a4609da37da40bb08d126f5535d81bff6b417b2ae/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", size = 752871 }, 345 | { url = "https://files.pythonhosted.org/packages/7b/5e/efd033ab7199a0b2044dab3b9f7a4f6670e6a52c089de572e928d2873b06/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", size = 757729 }, 346 | { url = "https://files.pythonhosted.org/packages/03/5c/c4671451b2f1d76ebe352c0945d4cd13500adb5d05f5a51ee296d80152f7/PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", size = 748528 }, 347 | { url = "https://files.pythonhosted.org/packages/73/9c/766e78d1efc0d1fca637a6b62cea1b4510a7fb93617eb805223294fef681/PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", size = 130286 }, 348 | { url = "https://files.pythonhosted.org/packages/b3/34/65bb4b2d7908044963ebf614fe0fdb080773fc7030d7e39c8d3eddcd4257/PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", size = 144699 }, 349 | { url = "https://files.pythonhosted.org/packages/bc/06/1b305bf6aa704343be85444c9d011f626c763abb40c0edc1cad13bfd7f86/PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", size = 178692 }, 350 | { url = "https://files.pythonhosted.org/packages/84/02/404de95ced348b73dd84f70e15a41843d817ff8c1744516bf78358f2ffd2/PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", size = 165622 }, 351 | { url = "https://files.pythonhosted.org/packages/c7/4c/4a2908632fc980da6d918b9de9c1d9d7d7e70b2672b1ad5166ed27841ef7/PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", size = 696937 }, 352 | { url = "https://files.pythonhosted.org/packages/b4/33/720548182ffa8344418126017aa1d4ab4aeec9a2275f04ce3f3573d8ace8/PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", size = 724969 }, 353 | { url = "https://files.pythonhosted.org/packages/4f/78/77b40157b6cb5f2d3d31a3d9b2efd1ba3505371f76730d267e8b32cf4b7f/PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", size = 712604 }, 354 | { url = "https://files.pythonhosted.org/packages/2e/97/3e0e089ee85e840f4b15bfa00e4e63d84a3691ababbfea92d6f820ea6f21/PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", size = 126098 }, 355 | { url = "https://files.pythonhosted.org/packages/2b/9f/fbade56564ad486809c27b322d0f7e6a89c01f6b4fe208402e90d4443a99/PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", size = 138675 }, 356 | { url = "https://files.pythonhosted.org/packages/7f/5d/2779ea035ba1e533c32ed4a249b4e0448f583ba10830b21a3cddafe11a4e/PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", size = 191734 }, 357 | { url = "https://files.pythonhosted.org/packages/e1/a1/27bfac14b90adaaccf8c8289f441e9f76d94795ec1e7a8f134d9f2cb3d0b/PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", size = 723767 }, 358 | { url = "https://files.pythonhosted.org/packages/c1/39/47ed4d65beec9ce07267b014be85ed9c204fa373515355d3efa62d19d892/PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", size = 749067 }, 359 | { url = "https://files.pythonhosted.org/packages/c8/6b/6600ac24725c7388255b2f5add93f91e58a5d7efaf4af244fdbcc11a541b/PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", size = 736569 }, 360 | { url = "https://files.pythonhosted.org/packages/0d/46/62ae77677e532c0af6c81ddd6f3dbc16bdcc1208b077457354442d220bfb/PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", size = 787738 }, 361 | { url = "https://files.pythonhosted.org/packages/d6/6a/439d1a6f834b9a9db16332ce16c4a96dd0e3970b65fe08cbecd1711eeb77/PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", size = 139797 }, 362 | { url = "https://files.pythonhosted.org/packages/29/0f/9782fa5b10152abf033aec56a601177ead85ee03b57781f2d9fced09eefc/PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", size = 157350 }, 363 | { url = "https://files.pythonhosted.org/packages/57/c5/5d09b66b41d549914802f482a2118d925d876dc2a35b2d127694c1345c34/PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", size = 197846 }, 364 | { url = "https://files.pythonhosted.org/packages/0e/88/21b2f16cb2123c1e9375f2c93486e35fdc86e63f02e274f0e99c589ef153/PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", size = 174396 }, 365 | { url = "https://files.pythonhosted.org/packages/ac/6c/967d91a8edf98d2b2b01d149bd9e51b8f9fb527c98d80ebb60c6b21d60c4/PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", size = 731824 }, 366 | { url = "https://files.pythonhosted.org/packages/4a/4b/c71ef18ef83c82f99e6da8332910692af78ea32bd1d1d76c9787dfa36aea/PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", size = 754777 }, 367 | { url = "https://files.pythonhosted.org/packages/7d/39/472f2554a0f1e825bd7c5afc11c817cd7a2f3657460f7159f691fbb37c51/PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", size = 738883 }, 368 | { url = "https://files.pythonhosted.org/packages/40/da/a175a35cf5583580e90ac3e2a3dbca90e43011593ae62ce63f79d7b28d92/PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", size = 750294 }, 369 | { url = "https://files.pythonhosted.org/packages/24/62/7fcc372442ec8ea331da18c24b13710e010c5073ab851ef36bf9dacb283f/PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", size = 136936 }, 370 | { url = "https://files.pythonhosted.org/packages/84/4d/82704d1ab9290b03da94e6425f5e87396b999fd7eb8e08f3a92c158402bf/PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", size = 152751 }, 371 | ] 372 | 373 | [[package]] 374 | name = "sqlparse" 375 | version = "0.5.0" 376 | source = { registry = "https://pypi.org/simple" } 377 | sdist = { url = "https://files.pythonhosted.org/packages/50/26/5da251cd090ccd580f5cfaa7d36cdd8b2471e49fffce60ed520afc27f4bc/sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93", size = 83475 } 378 | wheels = [ 379 | { url = "https://files.pythonhosted.org/packages/43/5d/a0fdd88fd486b39ae1fd1a75ff75b4e29a0df96c0304d462fd407b82efe0/sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663", size = 43971 }, 380 | ] 381 | 382 | [[package]] 383 | name = "tomli" 384 | version = "2.0.1" 385 | source = { registry = "https://pypi.org/simple" } 386 | sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } 387 | wheels = [ 388 | { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, 389 | ] 390 | 391 | [[package]] 392 | name = "typing-extensions" 393 | version = "4.12.2" 394 | source = { registry = "https://pypi.org/simple" } 395 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 396 | wheels = [ 397 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 398 | ] 399 | 400 | [[package]] 401 | name = "tzdata" 402 | version = "2024.1" 403 | source = { registry = "https://pypi.org/simple" } 404 | sdist = { url = "https://files.pythonhosted.org/packages/74/5b/e025d02cb3b66b7b76093404392d4b44343c69101cc85f4d180dd5784717/tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", size = 190559 } 405 | wheels = [ 406 | { url = "https://files.pythonhosted.org/packages/65/58/f9c9e6be752e9fcb8b6a0ee9fb87e6e7a1f6bcab2cdc73f02bb7ba91ada0/tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252", size = 345370 }, 407 | ] 408 | 409 | [[package]] 410 | name = "virtualenv" 411 | version = "20.26.3" 412 | source = { registry = "https://pypi.org/simple" } 413 | dependencies = [ 414 | { name = "distlib" }, 415 | { name = "filelock" }, 416 | { name = "platformdirs" }, 417 | ] 418 | sdist = { url = "https://files.pythonhosted.org/packages/68/60/db9f95e6ad456f1872486769c55628c7901fb4de5a72c2f7bdd912abf0c1/virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a", size = 9057588 } 419 | wheels = [ 420 | { url = "https://files.pythonhosted.org/packages/07/4d/410156100224c5e2f0011d435e477b57aed9576fc7fe137abcf14ec16e11/virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589", size = 5684792 }, 421 | ] 422 | --------------------------------------------------------------------------------