├── 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 | [](https://github.com/django-commons/django-fsm-2/actions/workflows/test.yml)
4 | [](https://codecov.io/github/django-commons/django-fsm-2)
5 | [](https://github.com/django-commons/django-fsm-2#settings)
6 | [](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 |
--------------------------------------------------------------------------------