pax_global_header00006660000000000000000000000064146653576130014532gustar00rootroot0000000000000052 comment=a1836f44cbd72f4b10016f0298b89917e32f7212 django-fsm-2-4.0.0/000077500000000000000000000000001466535761300137175ustar00rootroot00000000000000django-fsm-2-4.0.0/.github/000077500000000000000000000000001466535761300152575ustar00rootroot00000000000000django-fsm-2-4.0.0/.github/dependabot.yml000066400000000000000000000001621466535761300201060ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: monthly django-fsm-2-4.0.0/.github/workflows/000077500000000000000000000000001466535761300173145ustar00rootroot00000000000000django-fsm-2-4.0.0/.github/workflows/coverage.yml000066400000000000000000000013771466535761300216420ustar00rootroot00000000000000name: Coverage on: pull_request: push: branches: - master jobs: coverage: name: Check coverage runs-on: ubuntu-latest steps: - name: Check out uses: actions/checkout@v4 - uses: snok/install-poetry@v1 with: version: 1.3.2 virtualenvs-create: true virtualenvs-in-project: true - name: Set up Python 3.11 uses: actions/setup-python@v5 with: python-version: "3.11" cache: poetry - name: Install requirements run: poetry install - name: Run tests run: poetry run coverage run -m pytest --cov=django_fsm --cov-report=xml - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4 django-fsm-2-4.0.0/.github/workflows/lint.yml000066400000000000000000000004341466535761300210060ustar00rootroot00000000000000name: django-fsm linting on: pull_request: push: branches: - main jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.11' - uses: pre-commit/action@v3.0.1 django-fsm-2-4.0.0/.github/workflows/release.yml000066400000000000000000000063761466535761300214730ustar00rootroot00000000000000name: Release on: push: tags: - '*.*.*' env: # Change these for your project's URLs PYPI_URL: https://pypi.org/p/django-fsm-2 PYPI_TEST_URL: https://test.pypi.org/p/django-fsm-2 jobs: build: name: Build distribution 📦 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.x" - name: Install pypa/build run: python3 -m pip install build --user - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v3 with: name: python-package-distributions path: dist/ publish-to-pypi: name: >- Publish Python 🐍 distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build runs-on: ubuntu-latest environment: name: pypi url: ${{ env.PYPI_URL }} permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists uses: actions/download-artifact@v3 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 github-release: name: >- Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub Release needs: - publish-to-pypi runs-on: ubuntu-latest permissions: contents: write # IMPORTANT: mandatory for making GitHub Releases id-token: write # IMPORTANT: mandatory for sigstore steps: - name: Download all the dists uses: actions/download-artifact@v3 with: name: python-package-distributions path: dist/ - name: Sign the dists with Sigstore uses: sigstore/gh-action-sigstore-python@v1.2.3 with: inputs: >- ./dist/*.tar.gz ./dist/*.whl - name: Create GitHub Release env: GITHUB_TOKEN: ${{ github.token }} run: >- gh release create '${{ github.ref_name }}' --repo '${{ github.repository }}' --notes "" - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} # Upload to GitHub Release using the `gh` CLI. # `dist/` contains the built packages, and the # sigstore-produced signatures and certificates. run: >- gh release upload '${{ github.ref_name }}' dist/** --repo '${{ github.repository }}' publish-to-testpypi: name: Publish Python 🐍 distribution 📦 to TestPyPI needs: - build runs-on: ubuntu-latest environment: name: testpypi url: ${{ env.PYPI_TEST_URL }} permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists uses: actions/download-artifact@v3 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ skip-existing: true django-fsm-2-4.0.0/.github/workflows/test.yml000066400000000000000000000011161466535761300210150ustar00rootroot00000000000000name: django-fsm testing on: pull_request: push: branches: [main] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install tox tox-gh-actions - name: Test with tox run: tox django-fsm-2-4.0.0/.gitignore000066400000000000000000000034311466535761300157100ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # sqlite test.db django-fsm-2-4.0.0/.pre-commit-config.yaml000066400000000000000000000023121466535761300201760ustar00rootroot00000000000000default_language_version: python: python3.11 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-added-large-files args: ["--maxkb=700"] - id: check-case-conflict - id: check-json - id: check-merge-conflict - id: check-symlinks - id: check-toml - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade rev: v3.16.0 hooks: - id: pyupgrade args: - "--py38-plus" - repo: https://github.com/adamchainz/django-upgrade rev: 1.18.0 hooks: - id: django-upgrade args: [--target-version, "4.2"] - repo: https://github.com/python-poetry/poetry rev: 1.8.0 hooks: - id: poetry-check additional_dependencies: - poetry-plugin-sort==0.2.0 - poetry-plugin-export==1.8.0 # FIXME: poetry lock export more platform on the CI # - id: poetry-lock # args: ["--no-update"] - id: poetry-export - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.4.8 hooks: - id: ruff-format - id: ruff django-fsm-2-4.0.0/CHANGELOG.rst000066400000000000000000000107321466535761300157430ustar00rootroot00000000000000Changelog ========= django-fsm-2 4.0.0 2024-09-02 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add support for Django 5.1 - Remove support for Django 3.2 - Remove support for Django 4.0 - Remove support for Django 4.1 - Move the project to ``django-commons`` django-fsm-2 3.0.0 2024-03-26 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ First release of the forked version of django-fsm - Drop support for Python < 3.8. - Add support for python 3.11 - Add support for python 3.12 - Drop support for django < 3.2 - Add support for django 4.2 - Add support for django 5.0 - Enable Github actions for testing - Remove South support...if exists django-fsm 2.8.1 2022-08-15 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Improve fix for get_available_FIELD_transition django-fsm 2.8.0 2021-11-05 ~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix get_available_FIELD_transition on django>=3.2 - Fix refresh_from_db for ConcurrentTransitionMixin django-fsm 2.7.1 2020-10-13 ~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix warnings on Django 3.1+ django-fsm 2.7.0 2019-12-03 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Django 3.0 support - Test on Python 3.8 django-fsm 2.6.1 2019-04-19 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Update pypi classifiers to latest django/python supported versions - Several fixes for graph_transition command django-fsm 2.6.0 2017-06-08 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix django 1.11 compatibility - Fix TypeError in `graph_transitions` command when using django's lazy translations django-fsm 2.5.0 2017-03-04 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - graph_transition command fix for django 1.10 - graph_transition command supports GET_STATE targets - signal data extended with method args/kwargs and field - sets allowed to be passed to the transition decorator django-fsm 2.4.0 2016-05-14 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - graph_transition commnad now works with multiple FSM's per model - Add ability to set target state from transition return value or callable django-fsm 2.3.0 2015-10-15 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add source state shortcut '+' to specify transitions from all states except the target - Add object-level permission checks - Fix translated labels for graph of FSMIntegerField - Fix multiple signals for several transition decorators django-fsm 2.2.1 2015-04-27 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Improved exception message for unmet transition conditions. - Don't send post transition signal in case of no state changes on exception - Allow empty string as correct state value - Improved graphviz fsm visualisation - Clean django 1.8 warnings django-fsm 2.2.0 2014-09-03 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Support for `class substitution `__ to proxy classes depending on the state - Added ConcurrentTransitionMixin with optimistic locking support - Default db\_index=True for FSMIntegerField removed - Graph transition code migrated to new graphviz library with python 3 support - Ability to change state on transition exception django-fsm 2.1.0 2014-05-15 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Support for attaching permission checks on model transitions django-fsm 2.0.0 2014-03-15 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Backward incompatible release - All public code import moved directly to django\_fsm package - Correct support for several @transitions decorator with different source states and conditions on same method - save parameter from transition decorator removed - get\_available\_FIELD\_transitions return Transition data object instead of tuple - Models got get\_available\_FIELD\_transitions, even if field specified as string reference - New get\_all\_FIELD\_transitions method contributed to class django-fsm 1.6.0 2014-03-15 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - FSMIntegerField and FSMKeyField support django-fsm 1.5.1 2014-01-04 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Ad-hoc support for state fields from proxy and inherited models django-fsm 1.5.0 2013-09-17 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Python 3 compatibility django-fsm 1.4.0 2011-12-21 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add graph\_transition command for drawing state transition picture django-fsm 1.3.0 2011-07-28 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add direct field modification protection django-fsm 1.2.0 2011-03-23 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add pre\_transition and post\_transition signals django-fsm 1.1.0 2011-02-22 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add support for transition conditions - Allow multiple FSMField in one model - Contribute get\_available\_FIELD\_transitions for model class django-fsm 1.0.0 2010-10-12 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Initial public release django-fsm-2-4.0.0/LICENSE000066400000000000000000000020751466535761300147300ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2010 Mikhail Podgurskiy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. django-fsm-2-4.0.0/README.md000066400000000000000000000274761466535761300152160ustar00rootroot00000000000000# Django friendly finite state machine support [![CI tests](https://github.com/django-commons/django-fsm-2/actions/workflows/test.yml/badge.svg)](https://github.com/django-commons/django-fsm-2/actions/workflows/test.yml) [![codecov](https://codecov.io/github/django-commons/django-fsm-2/branch/master/graph/badge.svg?token=GWGDR6AR6D)](https://codecov.io/github/django-commons/django-fsm-2) [![Documentation](https://img.shields.io/static/v1?label=Docs&message=READ&color=informational&style=plastic)](https://github.com/django-commons/django-fsm-2#settings) [![MIT License](https://img.shields.io/static/v1?label=License&message=MIT&color=informational&style=plastic)](https://github.com/django-commons/anymail-history/LICENSE) django-fsm adds simple declarative state management for django models. > [!IMPORTANT] > Django FSM-2 started as a fork of [Django FSM](https://github.com/viewflow/django-fsm). > > Big thanks to Mikhail Podgurskiy for starting this awesome project and maintaining it for so many years. > > Unfortunately, development has stalled for almost 2 years and it was officially announced there will be no new releases. [Viewflow](https://github.com/viewflow/viewflow) is presented as an alternative but the transition is not that easy. > > 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) ## Introduction **FSM really helps to structure the code, and centralize the lifecycle of your Models.** Instead of adding a CharField field to a django model and manage its values by hand everywhere, `FSMFields` offer the ability to declare your `transitions` once with the decorator. These methods could contain side-effects, permissions, or logic to make the lifecycle management easier. Nice introduction is available here: ## Installation ``` bash $ pip install django-fsm-2 ``` Or, for the latest git version ``` bash $ pip install -e git://github.com/django-commons/django-fsm-2.git#egg=django-fsm ``` ## Usage Add FSMState field to your model ``` python from django_fsm import FSMField, transition class BlogPost(models.Model): state = FSMField(default='new') ``` Use the `transition` decorator to annotate model methods ``` python @transition(field=state, source='new', target='published') def publish(self): """ This function may contain side-effects, like updating caches, notifying users, etc. The return value will be discarded. """ ``` The `field` parameter accepts both a string attribute name or an actual field instance. If calling publish() succeeds without raising an exception, the state field will be changed, but not written to the database. ``` python from django_fsm import can_proceed def publish_view(request, post_id): post = get_object_or_404(BlogPost, pk=post_id) if not can_proceed(post.publish): raise PermissionDenied post.publish() post.save() return redirect('/') ``` If some conditions are required to be met before changing the state, use the `conditions` argument to `transition`. `conditions` must be a list of functions taking one argument, the model instance. The function must return either `True` or `False` or a value that evaluates to `True` or `False`. If all functions return `True`, all conditions are considered to be met and the transition is allowed to happen. If one of the functions returns `False`, the transition will not happen. These functions should not have any side effects. You can use ordinary functions ``` python def can_publish(instance): # No publishing after 17 hours if datetime.datetime.now().hour > 17: return False return True ``` Or model methods ``` python def can_destroy(self): return self.is_under_investigation() ``` Use the conditions like this: ``` python @transition(field=state, source='new', target='published', conditions=[can_publish]) def publish(self): """ Side effects galore """ @transition(field=state, source='*', target='destroyed', conditions=[can_destroy]) def destroy(self): """ Side effects galore """ ``` You can instantiate a field with `protected=True` option to prevent direct state field modification. ``` python class BlogPost(models.Model): state = FSMField(default='new', protected=True) model = BlogPost() model.state = 'invalid' # Raises AttributeError ``` Note that calling [refresh_from_db](https://docs.djangoproject.com/en/1.8/ref/models/instances/#django.db.models.Model.refresh_from_db) on a model instance with a protected FSMField will cause an exception. ### `source` state `source` parameter accepts a list of states, or an individual state or `django_fsm.State` implementation. You can use `*` for `source` to allow switching to `target` from any state. You can use `+` for `source` to allow switching to `target` from any state excluding `target` state. ### `target` state `target` state parameter could point to a specific state or `django_fsm.State` implementation ``` python from django_fsm import FSMField, transition, RETURN_VALUE, GET_STATE @transition(field=state, source='*', target=RETURN_VALUE('for_moderators', 'published')) def publish(self, is_public=False): return 'for_moderators' if is_public else 'published' @transition( field=state, source='for_moderators', target=GET_STATE( lambda self, allowed: 'published' if allowed else 'rejected', states=['published', 'rejected'])) def moderate(self, allowed): pass @transition( field=state, source='for_moderators', target=GET_STATE( lambda self, **kwargs: 'published' if kwargs.get("allowed", True) else 'rejected', states=['published', 'rejected'])) def moderate(self, allowed=True): pass ``` ### `custom` properties Custom properties can be added by providing a dictionary to the `custom` keyword on the `transition` decorator. ``` python @transition(field=state, source='*', target='onhold', custom=dict(verbose='Hold for legal reasons')) def legal_hold(self): """ Side effects galore """ ``` ### `on_error` state If the transition method raises an exception, you can provide a specific target state ``` python @transition(field=state, source='new', target='published', on_error='failed') def publish(self): """ Some exception could happen here """ ``` ### `state_choices` Instead of passing a two-item iterable `choices` you can instead use the three-element `state_choices`, the last element being a string reference to a model proxy class. The base class instance would be dynamically changed to the corresponding Proxy class instance, depending on the state. Even for queryset results, you will get Proxy class instances, even if the QuerySet is executed on the base class. Check the [test case](https://github.com/kmmbvnr/django-fsm/blob/master/tests/testapp/tests/test_state_transitions.py) for example usage. Or read about [implementation internals](http://schinckel.net/2013/06/13/django-proxy-model-state-machine/) ### Permissions It is common to have permissions attached to each model transition. `django-fsm` handles this with `permission` keyword on the `transition` decorator. `permission` accepts a permission string, or callable that expects `instance` and `user` arguments and returns True if the user can perform the transition. ``` python @transition(field=state, source='*', target='published', permission=lambda instance, user: not user.has_perm('myapp.can_make_mistakes')) def publish(self): pass @transition(field=state, source='*', target='removed', permission='myapp.can_remove_post') def remove(self): pass ``` You can check permission with `has_transition_permission` method ``` python from django_fsm import has_transition_perm def publish_view(request, post_id): post = get_object_or_404(BlogPost, pk=post_id) if not has_transition_perm(post.publish, request.user): raise PermissionDenied post.publish() post.save() return redirect('/') ``` ### Model methods `get_all_FIELD_transitions` Enumerates all declared transitions `get_available_FIELD_transitions` Returns all transitions data available in current state `get_available_user_FIELD_transitions` Enumerates all transitions data available in current state for provided user ### Foreign Key constraints support If you store the states in the db table you could use FSMKeyField to ensure Foreign Key database integrity. In your model : ``` python class DbState(models.Model): id = models.CharField(primary_key=True, max_length=50) label = models.CharField(max_length=255) def __unicode__(self): return self.label class BlogPost(models.Model): state = FSMKeyField(DbState, default='new') @transition(field=state, source='new', target='published') def publish(self): pass ``` In your fixtures/initial_data.json : ``` json [ { "pk": "new", "model": "myapp.dbstate", "fields": { "label": "_NEW_" } }, { "pk": "published", "model": "myapp.dbstate", "fields": { "label": "_PUBLISHED_" } } ] ``` Note : source and target parameters in \@transition decorator use pk values of DBState model as names, even if field \"real\" name is used, without \_id postfix, as field parameter. ### Integer Field support You can also use `FSMIntegerField`. This is handy when you want to use enum style constants. ``` python class BlogPostStateEnum(object): NEW = 10 PUBLISHED = 20 HIDDEN = 30 class BlogPostWithIntegerField(models.Model): state = FSMIntegerField(default=BlogPostStateEnum.NEW) @transition(field=state, source=BlogPostStateEnum.NEW, target=BlogPostStateEnum.PUBLISHED) def publish(self): pass ``` ### Signals `django_fsm.signals.pre_transition` and `django_fsm.signals.post_transition` are called before and after allowed transition. No signals on invalid transition are called. Arguments sent with these signals: **sender** The model class. **instance** The actual instance being processed **name** Transition name **source** Source model state **target** Target model state ## Optimistic locking `django-fsm` provides optimistic locking mixin, to avoid concurrent model state changes. If model state was changed in database `django_fsm.ConcurrentTransition` exception would be raised on model.save() ``` python from django_fsm import FSMField, ConcurrentTransitionMixin class BlogPost(ConcurrentTransitionMixin, models.Model): state = FSMField(default='new') ``` For guaranteed protection against race conditions caused by concurrently executed transitions, make sure: - Your transitions do not have any side effects except for changes in the database, - You always run the save() method on the object within `django.db.transaction.atomic()` block. Following these recommendations, you can rely on ConcurrentTransitionMixin to cause a rollback of all the changes that have been executed in an inconsistent (out of sync) state, thus practically negating their effect. ## Drawing transitions Renders a graphical overview of your models states transitions You need `pip install "graphviz>=0.4"` library and add `django_fsm` to your `INSTALLED_APPS`: ``` python INSTALLED_APPS = ( ... 'django_fsm', ... ) ``` ``` bash # Create a dot file $ ./manage.py graph_transitions > transitions.dot # Create a PNG image file only for specific model $ ./manage.py graph_transitions -o blog_transitions.png myapp.Blog ``` ## Extensions You may also take a look at django-fsm-admin project containing a mixin and template tags to integrate django-fsm state transitions into the django admin. Transition logging support could be achieved with help of django-fsm-log package django-fsm-2-4.0.0/django_fsm/000077500000000000000000000000001466535761300160265ustar00rootroot00000000000000django-fsm-2-4.0.0/django_fsm/__init__.py000066400000000000000000000517331466535761300201500ustar00rootroot00000000000000""" State tracking functionality for django models """ from __future__ import annotations import inspect from functools import partialmethod from functools import wraps from django.apps import apps as django_apps from django.db import models from django.db.models import Field from django.db.models.query_utils import DeferredAttribute from django.db.models.signals import class_prepared from django_fsm.signals import post_transition from django_fsm.signals import pre_transition __all__ = [ "TransitionNotAllowed", "ConcurrentTransition", "FSMFieldMixin", "FSMField", "FSMIntegerField", "FSMKeyField", "ConcurrentTransitionMixin", "transition", "can_proceed", "has_transition_perm", "GET_STATE", "RETURN_VALUE", ] class TransitionNotAllowed(Exception): """Raised when a transition is not allowed""" def __init__(self, *args, **kwargs): self.object = kwargs.pop("object", None) self.method = kwargs.pop("method", None) super().__init__(*args, **kwargs) class InvalidResultState(Exception): """Raised when we got invalid result state""" class ConcurrentTransition(Exception): """ Raised when the transition cannot be executed because the object has become stale (state has been changed since it was fetched from the database). """ class Transition: def __init__(self, method, source, target, on_error, conditions, permission, custom): self.method = method self.source = source self.target = target self.on_error = on_error self.conditions = conditions self.permission = permission self.custom = custom @property def name(self): return self.method.__name__ def has_perm(self, instance, user): if not self.permission: return True if callable(self.permission): return bool(self.permission(instance, user)) if user.has_perm(self.permission, instance): return True if user.has_perm(self.permission): return True return False def __hash__(self): return hash(self.name) def __eq__(self, other): if isinstance(other, str): return other == self.name if isinstance(other, Transition): return other.name == self.name return False def get_available_FIELD_transitions(instance, field): """ List of transitions available in current model state with all conditions met """ curr_state = field.get_state(instance) transitions = field.transitions[instance.__class__] for transition in transitions.values(): meta = transition._django_fsm if meta.has_transition(curr_state) and meta.conditions_met(instance, curr_state): yield meta.get_transition(curr_state) def get_all_FIELD_transitions(instance, field): """ List of all transitions available in current model state """ return field.get_all_transitions(instance.__class__) def get_available_user_FIELD_transitions(instance, user, field): """ List of transitions available in current model state with all conditions met and user have rights on it """ for transition in get_available_FIELD_transitions(instance, field): if transition.has_perm(instance, user): yield transition class FSMMeta: """ Models methods transitions meta information """ def __init__(self, field, method): self.field = field self.transitions = {} # source -> Transition def get_transition(self, source): transition = self.transitions.get(source, None) if transition is None: transition = self.transitions.get("*", None) if transition is None: transition = self.transitions.get("+", None) return transition def add_transition(self, method, source, target, on_error=None, conditions=[], permission=None, custom={}): if source in self.transitions: raise AssertionError(f"Duplicate transition for {source} state") self.transitions[source] = Transition( method=method, source=source, target=target, on_error=on_error, conditions=conditions, permission=permission, custom=custom, ) def has_transition(self, state): """ Lookup if any transition exists from current model state using current method """ if state in self.transitions: return True if "*" in self.transitions: return True if "+" in self.transitions and self.transitions["+"].target != state: return True return False def conditions_met(self, instance, state): """ Check if all conditions have been met """ transition = self.get_transition(state) if transition is None: return False if transition.conditions is None: return True return all(condition(instance) for condition in transition.conditions) def has_transition_perm(self, instance, state, user): transition = self.get_transition(state) if not transition: return False return transition.has_perm(instance, user) def next_state(self, current_state): transition = self.get_transition(current_state) if transition is None: raise TransitionNotAllowed(f"No transition from {current_state}") return transition.target def exception_state(self, current_state): transition = self.get_transition(current_state) if transition is None: raise TransitionNotAllowed(f"No transition from {current_state}") return transition.on_error class FSMFieldDescriptor: def __init__(self, field): self.field = field def __get__(self, instance, type=None): if instance is None: return self return self.field.get_state(instance) def __set__(self, instance, value): if self.field.protected and self.field.name in instance.__dict__: raise AttributeError(f"Direct {self.field.name} modification is not allowed") # Update state self.field.set_proxy(instance, value) self.field.set_state(instance, value) class FSMFieldMixin: descriptor_class = FSMFieldDescriptor def __init__(self, *args, **kwargs): self.protected = kwargs.pop("protected", False) self.transitions = {} # cls -> (transitions name -> method) self.state_proxy = {} # state -> ProxyClsRef state_choices = kwargs.pop("state_choices", None) choices = kwargs.get("choices", None) if state_choices is not None and choices is not None: raise ValueError("Use one of choices or state_choices value") if state_choices is not None: choices = [] for state, title, proxy_cls_ref in state_choices: choices.append((state, title)) self.state_proxy[state] = proxy_cls_ref kwargs["choices"] = choices super().__init__(*args, **kwargs) def deconstruct(self): name, path, args, kwargs = super().deconstruct() if self.protected: kwargs["protected"] = self.protected return name, path, args, kwargs def get_state(self, instance): # The state field may be deferred. We delegate the logic of figuring this out # and loading the deferred field on-demand to Django's built-in DeferredAttribute class. return DeferredAttribute(self).__get__(instance) def set_state(self, instance, state): instance.__dict__[self.name] = state def set_proxy(self, instance, state): """ Change class """ if state in self.state_proxy: state_proxy = self.state_proxy[state] try: app_label, model_name = state_proxy.split(".") except ValueError: # If we can't split, assume a model in current app app_label = instance._meta.app_label model_name = state_proxy model = django_apps.get_app_config(app_label).get_model(model_name) if model is None: raise ValueError(f"No model found {state_proxy}") instance.__class__ = model def change_state(self, instance, method, *args, **kwargs): meta = method._django_fsm method_name = method.__name__ current_state = self.get_state(instance) if not meta.has_transition(current_state): raise TransitionNotAllowed( f"Can't switch from state '{current_state}' using method '{method_name}'", object=instance, method=method, ) if not meta.conditions_met(instance, current_state): raise TransitionNotAllowed( f"Transition conditions have not been met for method '{method_name}'", object=instance, method=method ) next_state = meta.next_state(current_state) signal_kwargs = { "sender": instance.__class__, "instance": instance, "name": method_name, "field": meta.field, "source": current_state, "target": next_state, "method_args": args, "method_kwargs": kwargs, } pre_transition.send(**signal_kwargs) try: result = method(instance, *args, **kwargs) if next_state is not None: if hasattr(next_state, "get_state"): next_state = next_state.get_state(instance, transition, result, args=args, kwargs=kwargs) signal_kwargs["target"] = next_state self.set_proxy(instance, next_state) self.set_state(instance, next_state) except Exception as exc: exception_state = meta.exception_state(current_state) if exception_state: self.set_proxy(instance, exception_state) self.set_state(instance, exception_state) signal_kwargs["target"] = exception_state signal_kwargs["exception"] = exc post_transition.send(**signal_kwargs) raise else: post_transition.send(**signal_kwargs) return result def get_all_transitions(self, instance_cls): """ Returns [(source, target, name, method)] for all field transitions """ transitions = self.transitions[instance_cls] for transition in transitions.values(): meta = transition._django_fsm for transition in meta.transitions.values(): yield transition def contribute_to_class(self, cls, name, **kwargs): self.base_cls = cls super().contribute_to_class(cls, name, **kwargs) setattr(cls, self.name, self.descriptor_class(self)) setattr(cls, f"get_all_{self.name}_transitions", partialmethod(get_all_FIELD_transitions, field=self)) setattr(cls, f"get_available_{self.name}_transitions", partialmethod(get_available_FIELD_transitions, field=self)) setattr( cls, f"get_available_user_{self.name}_transitions", partialmethod(get_available_user_FIELD_transitions, field=self), ) class_prepared.connect(self._collect_transitions) def _collect_transitions(self, *args, **kwargs): sender = kwargs["sender"] if not issubclass(sender, self.base_cls): return def is_field_transition_method(attr): return ( (inspect.ismethod(attr) or inspect.isfunction(attr)) and hasattr(attr, "_django_fsm") and ( attr._django_fsm.field in [self, self.name] or ( isinstance(attr._django_fsm.field, Field) and attr._django_fsm.field.name == self.name and attr._django_fsm.field.creation_counter == self.creation_counter ) ) ) sender_transitions = {} transitions = inspect.getmembers(sender, predicate=is_field_transition_method) for method_name, method in transitions: method._django_fsm.field = self sender_transitions[method_name] = method self.transitions[sender] = sender_transitions class FSMField(FSMFieldMixin, models.CharField): """ State Machine support for Django model as CharField """ def __init__(self, *args, **kwargs): kwargs.setdefault("max_length", 50) super().__init__(*args, **kwargs) class FSMIntegerField(FSMFieldMixin, models.IntegerField): """ Same as FSMField, but stores the state value in an IntegerField. """ pass class FSMKeyField(FSMFieldMixin, models.ForeignKey): """ State Machine support for Django model """ def get_state(self, instance): return instance.__dict__[self.attname] def set_state(self, instance, state): instance.__dict__[self.attname] = self.to_python(state) class FSMModelMixin: """ Mixin that allows refresh_from_db for models with fsm protected fields """ def _get_protected_fsm_fields(self): def is_fsm_and_protected(f): return isinstance(f, FSMFieldMixin) and f.protected protected_fields = filter(is_fsm_and_protected, self._meta.concrete_fields) return {f.attname for f in protected_fields} def refresh_from_db(self, *args, **kwargs): fields = kwargs.pop("fields", None) # Use provided fields, if not set then reload all non-deferred fields.0 if not fields: deferred_fields = self.get_deferred_fields() protected_fields = self._get_protected_fsm_fields() skipped_fields = deferred_fields.union(protected_fields) fields = [f.attname for f in self._meta.concrete_fields if f.attname not in skipped_fields] kwargs["fields"] = fields super().refresh_from_db(*args, **kwargs) class ConcurrentTransitionMixin: """ Protects a Model from undesirable effects caused by concurrently executed transitions, e.g. running the same transition multiple times at the same time, or running different transitions with the same SOURCE state at the same time. This behavior is achieved using an idea based on optimistic locking. No additional version field is required though; only the state field(s) is/are used for the tracking. This scheme is not that strict as true *optimistic locking* mechanism, it is however more lightweight - leveraging the specifics of FSM models. Instance of a model based on this Mixin will be prevented from saving into DB if any of its state fields (instances of FSMFieldMixin) has been changed since the object was fetched from the database. *ConcurrentTransition* exception will be raised in such cases. For guaranteed protection against such race conditions, make sure: * Your transitions do not have any side effects except for changes in the database, * You always run the save() method on the object within django.db.transaction.atomic() block. Following these recommendations, you can rely on ConcurrentTransitionMixin to cause a rollback of all the changes that have been executed in an inconsistent (out of sync) state, thus practically negating their effect. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._update_initial_state() @property def state_fields(self): return filter(lambda field: isinstance(field, FSMFieldMixin), self._meta.fields) def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update): # _do_update is called once for each model class in the inheritance hierarchy. # We can only filter the base_qs on state fields (can be more than one!) present in this particular model. # Select state fields to filter on filter_on = filter(lambda field: field.model == base_qs.model, self.state_fields) # state filter will be used to narrow down the standard filter checking only PK state_filter = {field.attname: self.__initial_states[field.attname] for field in filter_on} updated = super()._do_update( base_qs=base_qs.filter(**state_filter), using=using, pk_val=pk_val, values=values, update_fields=update_fields, forced_update=forced_update, ) # It may happen that nothing was updated in the original _do_update method not because of unmatching state, # but because of missing PK. This codepath is possible when saving a new model instance with *preset PK*. # In this case Django does not know it has to do INSERT operation, so it tries UPDATE first and falls back to # INSERT if UPDATE fails. # Thus, we need to make sure we only catch the case when the object *is* in the DB, but with changed state; and # mimic standard _do_update behavior otherwise. Django will pick it up and execute _do_insert. if not updated and base_qs.filter(pk=pk_val).using(using).exists(): raise ConcurrentTransition("Cannot save object! The state has been changed since fetched from the database!") return updated def _update_initial_state(self): self.__initial_states = {field.attname: field.value_from_object(self) for field in self.state_fields} def refresh_from_db(self, *args, **kwargs): super().refresh_from_db(*args, **kwargs) self._update_initial_state() def save(self, *args, **kwargs): super().save(*args, **kwargs) self._update_initial_state() def transition(field, source="*", target=None, on_error=None, conditions=[], permission=None, custom={}): """ Method decorator to mark allowed transitions. Set target to None if current state needs to be validated and has not changed after the function call. """ def inner_transition(func): wrapper_installed, fsm_meta = True, getattr(func, "_django_fsm", None) if not fsm_meta: wrapper_installed = False fsm_meta = FSMMeta(field=field, method=func) setattr(func, "_django_fsm", fsm_meta) if isinstance(source, (list, tuple, set)): for state in source: func._django_fsm.add_transition(func, state, target, on_error, conditions, permission, custom) else: func._django_fsm.add_transition(func, source, target, on_error, conditions, permission, custom) @wraps(func) def _change_state(instance, *args, **kwargs): return fsm_meta.field.change_state(instance, func, *args, **kwargs) if not wrapper_installed: return _change_state return func return inner_transition def can_proceed(bound_method, check_conditions=True): """ Returns True if model in state allows to call bound_method Set ``check_conditions`` argument to ``False`` to skip checking conditions. """ if not hasattr(bound_method, "_django_fsm"): raise TypeError(f"{bound_method.__func__.__name__} method is not transition") meta = bound_method._django_fsm self = bound_method.__self__ current_state = meta.field.get_state(self) return meta.has_transition(current_state) and (not check_conditions or meta.conditions_met(self, current_state)) def has_transition_perm(bound_method, user): """ Returns True if model in state allows to call bound_method and user have rights on it """ if not hasattr(bound_method, "_django_fsm"): raise TypeError(f"{bound_method.__func__.__name__} method is not transition") meta = bound_method._django_fsm self = bound_method.__self__ current_state = meta.field.get_state(self) return ( meta.has_transition(current_state) and meta.conditions_met(self, current_state) and meta.has_transition_perm(self, current_state, user) ) class State: def get_state(self, model, transition, result, args=[], kwargs={}): raise NotImplementedError class RETURN_VALUE(State): def __init__(self, *allowed_states): self.allowed_states = allowed_states if allowed_states else None def get_state(self, model, transition, result, args=[], kwargs={}): if self.allowed_states is not None: if result not in self.allowed_states: raise InvalidResultState(f"{result} is not in list of allowed states\n{self.allowed_states}") return result class GET_STATE(State): def __init__(self, func, states=None): self.func = func self.allowed_states = states def get_state(self, model, transition, result, args=[], kwargs={}): result_state = self.func(model, *args, **kwargs) if self.allowed_states is not None: if result_state not in self.allowed_states: raise InvalidResultState(f"{result_state} is not in list of allowed states\n{self.allowed_states}") return result_state django-fsm-2-4.0.0/django_fsm/management/000077500000000000000000000000001466535761300201425ustar00rootroot00000000000000django-fsm-2-4.0.0/django_fsm/management/__init__.py000066400000000000000000000000001466535761300222410ustar00rootroot00000000000000django-fsm-2-4.0.0/django_fsm/management/commands/000077500000000000000000000000001466535761300217435ustar00rootroot00000000000000django-fsm-2-4.0.0/django_fsm/management/commands/__init__.py000066400000000000000000000000001466535761300240420ustar00rootroot00000000000000django-fsm-2-4.0.0/django_fsm/management/commands/graph_transitions.py000066400000000000000000000155101466535761300260550ustar00rootroot00000000000000from __future__ import annotations from itertools import chain import graphviz from django.apps import apps from django.core.management.base import BaseCommand from django.utils.encoding import force_str from django_fsm import GET_STATE from django_fsm import RETURN_VALUE from django_fsm import FSMFieldMixin def all_fsm_fields_data(model): return [(field, model) for field in model._meta.get_fields() if isinstance(field, FSMFieldMixin)] def node_name(field, state): opts = field.model._meta return "{}.{}.{}.{}".format(opts.app_label, opts.verbose_name.replace(" ", "_"), field.name, state) def node_label(field, state): if isinstance(state, int) or (isinstance(state, bool) and hasattr(field, "choices")): return force_str(dict(field.choices).get(state)) return state def generate_dot(fields_data): # noqa: C901 result = graphviz.Digraph() for field, model in fields_data: sources, targets, edges, any_targets, any_except_targets = set(), set(), set(), set(), set() # dump nodes and edges for transition in field.get_all_transitions(model): if transition.source == "*": any_targets.add((transition.target, transition.name)) elif transition.source == "+": any_except_targets.add((transition.target, transition.name)) else: _targets = ( (state for state in transition.target.allowed_states) if isinstance(transition.target, (GET_STATE, RETURN_VALUE)) else (transition.target,) ) source_name_pair = ( ((state, node_name(field, state)) for state in transition.source.allowed_states) if isinstance(transition.source, (GET_STATE, RETURN_VALUE)) else ((transition.source, node_name(field, transition.source)),) ) for source, source_name in source_name_pair: if transition.on_error: on_error_name = node_name(field, transition.on_error) targets.add((on_error_name, node_label(field, transition.on_error))) edges.add((source_name, on_error_name, (("style", "dotted"),))) for target in _targets: add_transition(source, target, transition.name, source_name, field, sources, targets, edges) targets.update( {(node_name(field, target), node_label(field, target)) for target, _ in chain(any_targets, any_except_targets)} ) for target, name in any_targets: target_name = node_name(field, target) all_nodes = sources | targets for source_name, label in all_nodes: sources.add((source_name, label)) edges.add((source_name, target_name, (("label", name),))) for target, name in any_except_targets: target_name = node_name(field, target) all_nodes = sources | targets all_nodes.remove((target_name, node_label(field, target))) for source_name, label in all_nodes: sources.add((source_name, label)) edges.add((source_name, target_name, (("label", name),))) # construct subgraph opts = field.model._meta subgraph = graphviz.Digraph( name=f"cluster_{opts.app_label}_{opts.object_name}_{field.name}", graph_attr={"label": f"{opts.app_label}.{opts.object_name}.{field.name}"}, ) final_states = targets - sources for name, label in final_states: subgraph.node(name, label=label, shape="doublecircle") for name, label in (sources | targets) - final_states: subgraph.node(name, label=label, shape="circle") if field.default: # Adding initial state notation if label == field.default: initial_name = node_name(field, "_initial") subgraph.node(name=initial_name, label="", shape="point") subgraph.edge(initial_name, name) for source_name, target_name, attrs in edges: subgraph.edge(source_name, target_name, **dict(attrs)) result.subgraph(subgraph) return result def add_transition(transition_source, transition_target, transition_name, source_name, field, sources, targets, edges): target_name = node_name(field, transition_target) sources.add((source_name, node_label(field, transition_source))) targets.add((target_name, node_label(field, transition_target))) edges.add((source_name, target_name, (("label", transition_name),))) def get_graphviz_layouts(): try: import graphviz return graphviz.backend.ENGINES except Exception: return {"sfdp", "circo", "twopi", "dot", "neato", "fdp", "osage", "patchwork"} class Command(BaseCommand): help = "Creates a GraphViz dot file with transitions for selected fields" def add_arguments(self, parser): parser.add_argument( "--output", "-o", action="store", dest="outputfile", help=("Render output file. Type of output dependent on file extensions. " "Use png or jpg to render graph to image."), ) parser.add_argument( "--layout", "-l", action="store", dest="layout", default="dot", help=f"Layout to be used by GraphViz for visualization. Layouts: {get_graphviz_layouts()}.", ) parser.add_argument("args", nargs="*", help=("[appname[.model[.field]]]")) def render_output(self, graph, **options): filename, format = options["outputfile"].rsplit(".", 1) graph.engine = options["layout"] graph.format = format graph.render(filename) def handle(self, *args, **options): fields_data = [] if len(args) != 0: for arg in args: field_spec = arg.split(".") if len(field_spec) == 1: app = apps.get_app(field_spec[0]) models = apps.get_models(app) for model in models: fields_data += all_fsm_fields_data(model) elif len(field_spec) == 2: model = apps.get_model(field_spec[0], field_spec[1]) fields_data += all_fsm_fields_data(model) elif len(field_spec) == 3: model = apps.get_model(field_spec[0], field_spec[1]) fields_data += all_fsm_fields_data(model) else: for model in apps.get_models(): fields_data += all_fsm_fields_data(model) dotdata = generate_dot(fields_data) if options["outputfile"]: self.render_output(dotdata, **options) else: print(dotdata) django-fsm-2-4.0.0/django_fsm/models.py000066400000000000000000000001001466535761300176520ustar00rootroot00000000000000""" Empty file to mark package as valid django application. """ django-fsm-2-4.0.0/django_fsm/signals.py000066400000000000000000000001751466535761300200430ustar00rootroot00000000000000from __future__ import annotations from django.dispatch import Signal pre_transition = Signal() post_transition = Signal() django-fsm-2-4.0.0/django_fsm/tests/000077500000000000000000000000001466535761300171705ustar00rootroot00000000000000django-fsm-2-4.0.0/django_fsm/tests/__init__.py000066400000000000000000000000001466535761300212670ustar00rootroot00000000000000django-fsm-2-4.0.0/django_fsm/tests/test_abstract_inheritance.py000066400000000000000000000035411466535761300247600ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import can_proceed from django_fsm import transition class BaseAbstractModel(models.Model): state = FSMField(default="new") class Meta: abstract = True @transition(field=state, source="new", target="published") def publish(self): pass class AnotherFromAbstractModel(BaseAbstractModel): """ This class exists to trigger a regression when multiple concrete classes inherit from a shared abstract class (example: BaseAbstractModel). Don't try to remove it. """ @transition(field="state", source="published", target="sticked") def stick(self): pass class InheritedFromAbstractModel(BaseAbstractModel): @transition(field="state", source="published", target="sticked") def stick(self): pass class TestinheritedModel(TestCase): def setUp(self): self.model = InheritedFromAbstractModel() def test_known_transition_should_succeed(self): self.assertTrue(can_proceed(self.model.publish)) self.model.publish() self.assertEqual(self.model.state, "published") self.assertTrue(can_proceed(self.model.stick)) self.model.stick() self.assertEqual(self.model.state, "sticked") def test_field_available_transitions_works(self): self.model.publish() self.assertEqual(self.model.state, "published") transitions = self.model.get_available_state_transitions() self.assertEqual(["sticked"], [data.target for data in transitions]) def test_field_all_transitions_works(self): transitions = self.model.get_all_state_transitions() self.assertEqual({("new", "published"), ("published", "sticked")}, {(data.source, data.target) for data in transitions}) django-fsm-2-4.0.0/django_fsm/tests/test_basic_transitions.py000066400000000000000000000204641466535761300243250ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import Transition from django_fsm import TransitionNotAllowed from django_fsm import can_proceed from django_fsm import transition from django_fsm.signals import post_transition from django_fsm.signals import pre_transition class BlogPost(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published") def publish(self): pass @transition(source="published", field=state) def notify_all(self): pass @transition(source="published", target="hidden", field=state) def hide(self): pass @transition(source="new", target="removed", field=state) def remove(self): raise Exception("Upss") @transition(source=["published", "hidden"], target="stolen", field=state) def steal(self): pass @transition(source="*", target="moderated", field=state) def moderate(self): pass @transition(source="+", target="blocked", field=state) def block(self): pass @transition(source="*", target="", field=state) def empty(self): pass class FSMFieldTest(TestCase): def setUp(self): self.model = BlogPost() def test_initial_state_instantiated(self): self.assertEqual(self.model.state, "new") def test_known_transition_should_succeed(self): self.assertTrue(can_proceed(self.model.publish)) self.model.publish() self.assertEqual(self.model.state, "published") self.assertTrue(can_proceed(self.model.hide)) self.model.hide() self.assertEqual(self.model.state, "hidden") def test_unknown_transition_fails(self): self.assertFalse(can_proceed(self.model.hide)) self.assertRaises(TransitionNotAllowed, self.model.hide) def test_state_non_changed_after_fail(self): self.assertTrue(can_proceed(self.model.remove)) self.assertRaises(Exception, self.model.remove) self.assertEqual(self.model.state, "new") def test_allowed_null_transition_should_succeed(self): self.model.publish() self.model.notify_all() self.assertEqual(self.model.state, "published") def test_unknown_null_transition_should_fail(self): self.assertRaises(TransitionNotAllowed, self.model.notify_all) self.assertEqual(self.model.state, "new") def test_multiple_source_support_path_1_works(self): self.model.publish() self.model.steal() self.assertEqual(self.model.state, "stolen") def test_multiple_source_support_path_2_works(self): self.model.publish() self.model.hide() self.model.steal() self.assertEqual(self.model.state, "stolen") def test_star_shortcut_succeed(self): self.assertTrue(can_proceed(self.model.moderate)) self.model.moderate() self.assertEqual(self.model.state, "moderated") def test_plus_shortcut_succeeds_for_other_source(self): """Tests that the '+' shortcut succeeds for a source other than the target. """ self.assertTrue(can_proceed(self.model.block)) self.model.block() self.assertEqual(self.model.state, "blocked") def test_plus_shortcut_fails_for_same_source(self): """Tests that the '+' shortcut fails if the source equals the target. """ self.model.block() self.assertFalse(can_proceed(self.model.block)) self.assertRaises(TransitionNotAllowed, self.model.block) def test_empty_string_target(self): self.model.empty() self.assertEqual(self.model.state, "") class StateSignalsTests(TestCase): def setUp(self): self.model = BlogPost() self.pre_transition_called = False self.post_transition_called = False pre_transition.connect(self.on_pre_transition, sender=BlogPost) post_transition.connect(self.on_post_transition, sender=BlogPost) def on_pre_transition(self, sender, instance, name, source, target, **kwargs): self.assertEqual(instance.state, source) self.pre_transition_called = True def on_post_transition(self, sender, instance, name, source, target, **kwargs): self.assertEqual(instance.state, target) self.post_transition_called = True def test_signals_called_on_valid_transition(self): self.model.publish() self.assertTrue(self.pre_transition_called) self.assertTrue(self.post_transition_called) def test_signals_not_called_on_invalid_transition(self): self.assertRaises(TransitionNotAllowed, self.model.hide) self.assertFalse(self.pre_transition_called) self.assertFalse(self.post_transition_called) class TestFieldTransitionsInspect(TestCase): def setUp(self): self.model = BlogPost() def test_in_operator_for_available_transitions(self): # store the generator in a list, so we can reuse the generator and do multiple asserts transitions = list(self.model.get_available_state_transitions()) self.assertIn("publish", transitions) self.assertNotIn("xyz", transitions) # inline method for faking the name of the transition def publish(): pass obj = Transition( method=publish, source="", target="", on_error="", conditions="", permission="", custom="", ) self.assertTrue(obj in transitions) def test_available_conditions_from_new(self): transitions = self.model.get_available_state_transitions() actual = {(transition.source, transition.target) for transition in transitions} expected = {("*", "moderated"), ("new", "published"), ("new", "removed"), ("*", ""), ("+", "blocked")} self.assertEqual(actual, expected) def test_available_conditions_from_published(self): self.model.publish() transitions = self.model.get_available_state_transitions() actual = {(transition.source, transition.target) for transition in transitions} expected = { ("*", "moderated"), ("published", None), ("published", "hidden"), ("published", "stolen"), ("*", ""), ("+", "blocked"), } self.assertEqual(actual, expected) def test_available_conditions_from_hidden(self): self.model.publish() self.model.hide() transitions = self.model.get_available_state_transitions() actual = {(transition.source, transition.target) for transition in transitions} expected = {("*", "moderated"), ("hidden", "stolen"), ("*", ""), ("+", "blocked")} self.assertEqual(actual, expected) def test_available_conditions_from_stolen(self): self.model.publish() self.model.steal() transitions = self.model.get_available_state_transitions() actual = {(transition.source, transition.target) for transition in transitions} expected = {("*", "moderated"), ("*", ""), ("+", "blocked")} self.assertEqual(actual, expected) def test_available_conditions_from_blocked(self): self.model.block() transitions = self.model.get_available_state_transitions() actual = {(transition.source, transition.target) for transition in transitions} expected = {("*", "moderated"), ("*", "")} self.assertEqual(actual, expected) def test_available_conditions_from_empty(self): self.model.empty() transitions = self.model.get_available_state_transitions() actual = {(transition.source, transition.target) for transition in transitions} expected = {("*", "moderated"), ("*", ""), ("+", "blocked")} self.assertEqual(actual, expected) def test_all_conditions(self): transitions = self.model.get_all_state_transitions() actual = {(transition.source, transition.target) for transition in transitions} expected = { ("*", "moderated"), ("new", "published"), ("new", "removed"), ("published", None), ("published", "hidden"), ("published", "stolen"), ("hidden", "stolen"), ("*", ""), ("+", "blocked"), } self.assertEqual(actual, expected) django-fsm-2-4.0.0/django_fsm/tests/test_conditions.py000066400000000000000000000027211466535761300227540ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import TransitionNotAllowed from django_fsm import can_proceed from django_fsm import transition def condition_func(instance): return True class BlogPostWithConditions(models.Model): state = FSMField(default="new") def model_condition(self): return True def unmet_condition(self): return False @transition(field=state, source="new", target="published", conditions=[condition_func, model_condition]) def publish(self): pass @transition(field=state, source="published", target="destroyed", conditions=[condition_func, unmet_condition]) def destroy(self): pass class ConditionalTest(TestCase): def setUp(self): self.model = BlogPostWithConditions() def test_initial_staet(self): self.assertEqual(self.model.state, "new") def test_known_transition_should_succeed(self): self.assertTrue(can_proceed(self.model.publish)) self.model.publish() self.assertEqual(self.model.state, "published") def test_unmet_condition(self): self.model.publish() self.assertEqual(self.model.state, "published") self.assertFalse(can_proceed(self.model.destroy)) self.assertRaises(TransitionNotAllowed, self.model.destroy) self.assertTrue(can_proceed(self.model.destroy, check_conditions=False)) django-fsm-2-4.0.0/django_fsm/tests/test_integer_field.py000066400000000000000000000021631466535761300234030ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMIntegerField from django_fsm import TransitionNotAllowed from django_fsm import transition class BlogPostStateEnum: NEW = 10 PUBLISHED = 20 HIDDEN = 30 class BlogPostWithIntegerField(models.Model): state = FSMIntegerField(default=BlogPostStateEnum.NEW) @transition(field=state, source=BlogPostStateEnum.NEW, target=BlogPostStateEnum.PUBLISHED) def publish(self): pass @transition(field=state, source=BlogPostStateEnum.PUBLISHED, target=BlogPostStateEnum.HIDDEN) def hide(self): pass class BlogPostWithIntegerFieldTest(TestCase): def setUp(self): self.model = BlogPostWithIntegerField() def test_known_transition_should_succeed(self): self.model.publish() self.assertEqual(self.model.state, BlogPostStateEnum.PUBLISHED) self.model.hide() self.assertEqual(self.model.state, BlogPostStateEnum.HIDDEN) def test_unknow_transition_fails(self): self.assertRaises(TransitionNotAllowed, self.model.hide) django-fsm-2-4.0.0/django_fsm/tests/test_key_field.py000066400000000000000000000107671466535761300225470ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMKeyField from django_fsm import TransitionNotAllowed from django_fsm import can_proceed from django_fsm import transition FK_AVAILABLE_STATES = ( ("New", "_NEW_"), ("Published", "_PUBLISHED_"), ("Hidden", "_HIDDEN_"), ("Removed", "_REMOVED_"), ("Stolen", "_STOLEN_"), ("Moderated", "_MODERATED_"), ) class DBState(models.Model): id = models.CharField(primary_key=True, max_length=50) label = models.CharField(max_length=255) def __unicode__(self): return self.label class Meta: app_label = "django_fsm" class FKBlogPost(models.Model): state = FSMKeyField(DBState, default="new", protected=True, on_delete=models.CASCADE) @transition(field=state, source="new", target="published") def publish(self): pass @transition(field=state, source="published") def notify_all(self): pass @transition(field=state, source="published", target="hidden") def hide(self): pass @transition(field=state, source="new", target="removed") def remove(self): raise Exception("Upss") @transition(field=state, source=["published", "hidden"], target="stolen") def steal(self): pass @transition(field=state, source="*", target="moderated") def moderate(self): pass class Meta: app_label = "django_fsm" class FSMKeyFieldTest(TestCase): def setUp(self): for item in FK_AVAILABLE_STATES: DBState.objects.create(pk=item[0], label=item[1]) self.model = FKBlogPost() def test_initial_state_instatiated(self): self.assertEqual( self.model.state, "new", ) def test_known_transition_should_succeed(self): self.assertTrue(can_proceed(self.model.publish)) self.model.publish() self.assertEqual(self.model.state, "published") self.assertTrue(can_proceed(self.model.hide)) self.model.hide() self.assertEqual(self.model.state, "hidden") def test_unknow_transition_fails(self): self.assertFalse(can_proceed(self.model.hide)) self.assertRaises(TransitionNotAllowed, self.model.hide) def test_state_non_changed_after_fail(self): self.assertTrue(can_proceed(self.model.remove)) self.assertRaises(Exception, self.model.remove) self.assertEqual(self.model.state, "new") def test_allowed_null_transition_should_succeed(self): self.assertTrue(can_proceed(self.model.publish)) self.model.publish() self.model.notify_all() self.assertEqual(self.model.state, "published") def test_unknow_null_transition_should_fail(self): self.assertRaises(TransitionNotAllowed, self.model.notify_all) self.assertEqual(self.model.state, "new") def test_mutiple_source_support_path_1_works(self): self.model.publish() self.model.steal() self.assertEqual(self.model.state, "stolen") def test_mutiple_source_support_path_2_works(self): self.model.publish() self.model.hide() self.model.steal() self.assertEqual(self.model.state, "stolen") def test_star_shortcut_succeed(self): self.assertTrue(can_proceed(self.model.moderate)) self.model.moderate() self.assertEqual(self.model.state, "moderated") """ TODO FIX it class BlogPostStatus(models.Model): name = models.CharField(max_length=10, unique=True) objects = models.Manager() class Meta: app_label = 'django_fsm' class BlogPostWithFKState(models.Model): status = FSMKeyField(BlogPostStatus, default=lambda: BlogPostStatus.objects.get(name="new")) @transition(field=status, source='new', target='published') def publish(self): pass @transition(field=status, source='published', target='hidden') def hide(self): pass class BlogPostWithFKStateTest(TestCase): def setUp(self): BlogPostStatus.objects.create(name="new") BlogPostStatus.objects.create(name="published") BlogPostStatus.objects.create(name="hidden") self.model = BlogPostWithFKState() def test_known_transition_should_succeed(self): self.model.publish() self.assertEqual(self.model.state, 'published') self.model.hide() self.assertEqual(self.model.state, 'hidden') def test_unknow_transition_fails(self): self.assertRaises(TransitionNotAllowed, self.model.hide) """ django-fsm-2-4.0.0/django_fsm/tests/test_protected_field.py000066400000000000000000000022771466535761300237450ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class ProtectedAccessModel(models.Model): status = FSMField(default="new", protected=True) @transition(field=status, source="new", target="published") def publish(self): pass class Meta: app_label = "django_fsm" class MultiProtectedAccessModel(models.Model): status1 = FSMField(default="new", protected=True) status2 = FSMField(default="new", protected=True) class Meta: app_label = "django_fsm" class TestDirectAccessModels(TestCase): def test_multi_protected_field_create(self): obj = MultiProtectedAccessModel.objects.create() self.assertEqual(obj.status1, "new") self.assertEqual(obj.status2, "new") def test_no_direct_access(self): instance = ProtectedAccessModel() self.assertEqual(instance.status, "new") def try_change(): instance.status = "change" self.assertRaises(AttributeError, try_change) instance.publish() instance.save() self.assertEqual(instance.status, "published") django-fsm-2-4.0.0/django_fsm/tests/test_protected_fields.py000066400000000000000000000022711466535761300241220ustar00rootroot00000000000000from __future__ import annotations import unittest import django from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import FSMModelMixin from django_fsm import transition class RefreshableProtectedAccessModel(models.Model): status = FSMField(default="new", protected=True) @transition(field=status, source="new", target="published") def publish(self): pass class Meta: app_label = "django_fsm" class RefreshableModel(FSMModelMixin, RefreshableProtectedAccessModel): pass class TestDirectAccessModels(TestCase): def test_no_direct_access(self): instance = RefreshableProtectedAccessModel() self.assertEqual(instance.status, "new") def try_change(): instance.status = "change" self.assertRaises(AttributeError, try_change) instance.publish() instance.save() self.assertEqual(instance.status, "published") @unittest.skipIf(django.VERSION < (1, 8), "Django introduced refresh_from_db in 1.8") def test_refresh_from_db(self): instance = RefreshableModel() instance.save() instance.refresh_from_db() django-fsm-2-4.0.0/django_fsm/tests/test_proxy_inheritance.py000066400000000000000000000032641466535761300243400ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import can_proceed from django_fsm import transition class BaseModel(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published") def publish(self): pass class InheritedModel(BaseModel): @transition(field="state", source="published", target="sticked") def stick(self): pass class Meta: proxy = True class TestinheritedModel(TestCase): def setUp(self): self.model = InheritedModel() def test_known_transition_should_succeed(self): self.assertTrue(can_proceed(self.model.publish)) self.model.publish() self.assertEqual(self.model.state, "published") self.assertTrue(can_proceed(self.model.stick)) self.model.stick() self.assertEqual(self.model.state, "sticked") def test_field_available_transitions_works(self): self.model.publish() self.assertEqual(self.model.state, "published") transitions = self.model.get_available_state_transitions() self.assertEqual(["sticked"], [data.target for data in transitions]) def test_field_all_transitions_base_model(self): transitions = BaseModel().get_all_state_transitions() self.assertEqual({("new", "published")}, {(data.source, data.target) for data in transitions}) def test_field_all_transitions_works(self): transitions = self.model.get_all_state_transitions() self.assertEqual({("new", "published"), ("published", "sticked")}, {(data.source, data.target) for data in transitions}) django-fsm-2-4.0.0/poetry.lock000066400000000000000000000743421466535761300161250ustar00rootroot00000000000000# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "asgiref" version = "3.8.1" description = "ASGI specs, helper code, and adapters" optional = false python-versions = ">=3.8" files = [ {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, ] [package.dependencies] typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] [[package]] name = "backports-zoneinfo" version = "0.2.1" description = "Backport of the standard library zoneinfo module" optional = false python-versions = ">=3.6" files = [ {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, ] [package.extras] tzdata = ["tzdata"] [[package]] name = "cfgv" version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.8" files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] name = "coverage" version = "7.5.4" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"}, {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"}, {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"}, {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"}, {file = "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"}, {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"}, {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"}, {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"}, {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"}, {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"}, {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"}, {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"}, {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"}, {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"}, {file = "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"}, {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"}, {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"}, {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"}, {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"}, {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"}, {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"}, {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"}, {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"}, {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"}, {file = "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"}, {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"}, {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"}, {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"}, {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"}, {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"}, {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"}, {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"}, {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"}, {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"}, {file = "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"}, {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"}, {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"}, {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"}, {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"}, {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"}, {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"}, {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"}, {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"}, {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"}, {file = "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"}, {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"}, {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"}, {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"}, {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"}, {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"}, {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"}, {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"}, ] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] toml = ["tomli"] [[package]] name = "distlib" version = "0.3.8" description = "Distribution utilities" optional = false python-versions = "*" files = [ {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] [[package]] name = "django" version = "4.2.13" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.8" files = [ {file = "Django-4.2.13-py3-none-any.whl", hash = "sha256:a17fcba2aad3fc7d46fdb23215095dbbd64e6174bf4589171e732b18b07e426a"}, {file = "Django-4.2.13.tar.gz", hash = "sha256:837e3cf1f6c31347a1396a3f6b65688f2b4bb4a11c580dcb628b5afe527b68a5"}, ] [package.dependencies] asgiref = ">=3.6.0,<4" "backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} sqlparse = ">=0.3.1" tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] [[package]] name = "django-guardian" version = "2.4.0" description = "Implementation of per object permissions for Django." optional = false python-versions = ">=3.5" files = [ {file = "django-guardian-2.4.0.tar.gz", hash = "sha256:c58a68ae76922d33e6bdc0e69af1892097838de56e93e78a8361090bcd9f89a0"}, {file = "django_guardian-2.4.0-py3-none-any.whl", hash = "sha256:440ca61358427e575323648b25f8384739e54c38b3d655c81d75e0cd0d61b697"}, ] [package.dependencies] Django = ">=2.2" [[package]] name = "exceptiongroup" version = "1.2.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, ] [package.extras] test = ["pytest (>=6)"] [[package]] name = "filelock" version = "3.15.4" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, ] [package.extras] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] typing = ["typing-extensions (>=4.8)"] [[package]] name = "graphviz" version = "0.20.3" description = "Simple Python interface for Graphviz" optional = false python-versions = ">=3.8" files = [ {file = "graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5"}, {file = "graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d"}, ] [package.extras] dev = ["flake8", "pep8-naming", "tox (>=3)", "twine", "wheel"] docs = ["sphinx (>=5,<7)", "sphinx-autodoc-typehints", "sphinx-rtd-theme"] test = ["coverage", "pytest (>=7,<8.1)", "pytest-cov", "pytest-mock (>=3)"] [[package]] name = "identify" version = "2.5.36" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, ] [package.extras] license = ["ukkonen"] [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] [[package]] name = "nodeenv" version = "1.9.1" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] [[package]] name = "packaging" version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] name = "platformdirs" version = "4.2.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, ] [package.extras] docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] type = ["mypy (>=1.8)"] [[package]] name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" version = "3.5.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.8" files = [ {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, ] [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" [[package]] name = "pytest" version = "8.2.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2.0" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.7" files = [ {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, ] [package.dependencies] coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "pytest-django" version = "4.8.0" description = "A Django plugin for pytest." optional = false python-versions = ">=3.8" files = [ {file = "pytest-django-4.8.0.tar.gz", hash = "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90"}, {file = "pytest_django-4.8.0-py3-none-any.whl", hash = "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7"}, ] [package.dependencies] pytest = ">=7.0.0" [package.extras] docs = ["sphinx", "sphinx-rtd-theme"] testing = ["Django", "django-configurations (>=2.0)"] [[package]] name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.6" files = [ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] [[package]] name = "sqlparse" version = "0.5.0" description = "A non-validating SQL parser." optional = false python-versions = ">=3.8" files = [ {file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"}, {file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"}, ] [package.extras] dev = ["build", "hatch"] doc = ["sphinx"] [[package]] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] [[package]] name = "typing-extensions" version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] name = "tzdata" version = "2024.1" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" files = [ {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, ] [[package]] name = "virtualenv" version = "20.26.3" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = ">=3.12.2,<4" platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [metadata] lock-version = "2.0" python-versions = "^3.8" content-hash = "f46b8a5b9ffa4e59610be81ead75f64886b8138740c3538a88bf5912b140a652" django-fsm-2-4.0.0/poetry.toml000066400000000000000000000000561466535761300161370ustar00rootroot00000000000000[virtualenvs] create = true in-project = true django-fsm-2-4.0.0/pyproject.toml000066400000000000000000000037441466535761300166430ustar00rootroot00000000000000[tool.poetry] name = "django-fsm-2" version = "4.0.0" description = "Django friendly finite state machine support." authors = [ "Mikhail Podgurskiy ", ] license = "MIT License" readme = "README.md" homepage = "http://github.com/django-commons/django-fsm-2" repository = "http://github.com/django-commons/django-fsm-2" documentation = "http://github.com/django-commons/django-fsm-2" classifiers = [ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Topic :: Software Development :: Libraries :: Python Modules', ] packages = [{ include = "django_fsm" }] [tool.poetry.dependencies] python = "^3.8" django = ">=4.2" [tool.poetry.group.graphviz.dependencies] graphviz = "*" [tool.poetry.group.dev.dependencies] coverage = "*" django-guardian = "*" graphviz = "*" pre-commit = "*" pytest = "*" pytest-cov = "^4.1.0" pytest-django = "*" [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "tests.settings" [tool.ruff] line-length = 130 target-version = "py38" fix = true [tool.ruff.lint] # select = ["ALL"] extend-select = [ "F", # Pyflakes "E", # pycodestyle "W", # pycodestyle "UP", # pyupgrade "I", # isort "PERF", "RET", "C", # "B", ] fixable = ["I"] [tool.ruff.lint.isort] force-single-line = true required-imports = ["from __future__ import annotations"] [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" django-fsm-2-4.0.0/requirements.txt000066400000000000000000000051561466535761300172120ustar00rootroot00000000000000asgiref==3.8.1 ; python_version >= "3.8" and python_version < "4.0" \ --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \ --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590 backports-zoneinfo==0.2.1 ; python_version >= "3.8" and python_version < "3.9" \ --hash=sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf \ --hash=sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328 \ --hash=sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546 \ --hash=sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6 \ --hash=sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570 \ --hash=sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9 \ --hash=sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7 \ --hash=sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987 \ --hash=sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722 \ --hash=sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582 \ --hash=sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc \ --hash=sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b \ --hash=sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1 \ --hash=sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08 \ --hash=sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac \ --hash=sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2 django==4.2.13 ; python_version >= "3.8" and python_version < "4.0" \ --hash=sha256:837e3cf1f6c31347a1396a3f6b65688f2b4bb4a11c580dcb628b5afe527b68a5 \ --hash=sha256:a17fcba2aad3fc7d46fdb23215095dbbd64e6174bf4589171e732b18b07e426a sqlparse==0.5.0 ; python_version >= "3.8" and python_version < "4.0" \ --hash=sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93 \ --hash=sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663 typing-extensions==4.12.2 ; python_version >= "3.8" and python_version < "3.11" \ --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 tzdata==2024.1 ; python_version >= "3.8" and python_version < "4.0" and sys_platform == "win32" \ --hash=sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd \ --hash=sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252 django-fsm-2-4.0.0/tests/000077500000000000000000000000001466535761300150615ustar00rootroot00000000000000django-fsm-2-4.0.0/tests/__init__.py000066400000000000000000000000001466535761300171600ustar00rootroot00000000000000django-fsm-2-4.0.0/tests/manage.py000066400000000000000000000006471466535761300166720ustar00rootroot00000000000000from __future__ import annotations import os import sys from django.core.management import execute_from_command_line PROJECT_ROOT = os.path.dirname(os.path.abspath(os.path.dirname(__file__))) sys.path.insert(0, PROJECT_ROOT) if __name__ == "__main__": if len(sys.argv) == 1: sys.argv += ["test"] os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") execute_from_command_line(sys.argv) django-fsm-2-4.0.0/tests/settings.py000066400000000000000000000012761466535761300173010ustar00rootroot00000000000000from __future__ import annotations USE_TZ = True PROJECT_APPS = ( "django_fsm", "tests.testapp", ) INSTALLED_APPS = ( "django.contrib.contenttypes", "django.contrib.auth", "guardian", *PROJECT_APPS, ) AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.ModelBackend", # this is default "guardian.backends.ObjectPermissionBackend", ) DATABASE_ENGINE = "sqlite3" SECRET_KEY = "nokey" MIDDLEWARE_CLASSES = () DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", } } MIGRATION_MODULES = { "auth": None, "contenttypes": None, "guardian": None, } ANONYMOUS_USER_ID = 0 DEFAULT_AUTO_FIELD = "django.db.models.AutoField" django-fsm-2-4.0.0/tests/testapp/000077500000000000000000000000001466535761300165415ustar00rootroot00000000000000django-fsm-2-4.0.0/tests/testapp/__init__.py000066400000000000000000000000001466535761300206400ustar00rootroot00000000000000django-fsm-2-4.0.0/tests/testapp/apps.py000066400000000000000000000002031466535761300200510ustar00rootroot00000000000000from __future__ import annotations from django.apps import AppConfig class TestAppConfig(AppConfig): name = "tests.testapp" django-fsm-2-4.0.0/tests/testapp/fixtures/000077500000000000000000000000001466535761300204125ustar00rootroot00000000000000django-fsm-2-4.0.0/tests/testapp/fixtures/test_states_data.json000066400000000000000000000010561466535761300246420ustar00rootroot00000000000000[ { "model": "testapp.dbstate", "pk": "new", "fields": { "label": "_New"} }, { "model": "testapp.dbstate", "pk": "draft", "fields": { "label": "_Draft"} }, { "model": "testapp.dbstate", "pk": "dept", "fields": { "label": "_Dept"} }, { "model": "testapp.dbstate", "pk": "dean", "fields": { "label": "_Dean"} }, { "model": "testapp.dbstate", "pk": "done", "fields": { "label": "_Done"} } ] django-fsm-2-4.0.0/tests/testapp/models.py000066400000000000000000000065371466535761300204110ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django_fsm import FSMField from django_fsm import FSMKeyField from django_fsm import transition class Application(models.Model): """ Student application need to be approved by dept chair and dean. Test workflow """ state = FSMField(default="new") @transition(field=state, source="new", target="draft") def draft(self): pass @transition(field=state, source=["new", "draft"], target="dept") def to_approvement(self): pass @transition(field=state, source="dept", target="dean") def dept_approved(self): pass @transition(field=state, source="dept", target="new") def dept_rejected(self): pass @transition(field=state, source="dean", target="done") def dean_approved(self): pass @transition(field=state, source="dean", target="dept") def dean_rejected(self): pass class FKApplication(models.Model): """ Student application need to be approved by dept chair and dean. Test workflow for FSMKeyField """ state = FSMKeyField("testapp.DbState", default="new", on_delete=models.CASCADE) @transition(field=state, source="new", target="draft") def draft(self): pass @transition(field=state, source=["new", "draft"], target="dept") def to_approvement(self): pass @transition(field=state, source="dept", target="dean") def dept_approved(self): pass @transition(field=state, source="dept", target="new") def dept_rejected(self): pass @transition(field=state, source="dean", target="done") def dean_approved(self): pass @transition(field=state, source="dean", target="dept") def dean_rejected(self): pass class DbState(models.Model): """ States in DB """ id = models.CharField(primary_key=True, max_length=50) label = models.CharField(max_length=255) def __unicode__(self): return self.label class BlogPost(models.Model): """ Test workflow """ state = FSMField(default="new", protected=True) def can_restore(self, user): return user.is_superuser or user.is_staff @transition(field=state, source="new", target="published", on_error="failed", permission="testapp.can_publish_post") def publish(self): pass @transition(field=state, source="published") def notify_all(self): pass @transition( field=state, source="published", target="hidden", on_error="failed", ) def hide(self): pass @transition( field=state, source="new", target="removed", on_error="failed", permission=lambda self, u: u.has_perm("testapp.can_remove_post"), ) def remove(self): raise Exception(f"No rights to delete {self}") @transition(field=state, source="new", target="restored", on_error="failed", permission=can_restore) def restore(self): pass @transition(field=state, source=["published", "hidden"], target="stolen") def steal(self): pass @transition(field=state, source="*", target="moderated") def moderate(self): pass class Meta: permissions = [ ("can_publish_post", "Can publish post"), ("can_remove_post", "Can remove post"), ] django-fsm-2-4.0.0/tests/testapp/tests/000077500000000000000000000000001466535761300177035ustar00rootroot00000000000000django-fsm-2-4.0.0/tests/testapp/tests/__init__.py000066400000000000000000000000001466535761300220020ustar00rootroot00000000000000django-fsm-2-4.0.0/tests/testapp/tests/test_access_deferred_fsm_field.py000066400000000000000000000016541466535761300264330ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import can_proceed from django_fsm import transition class DeferrableModel(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published") def publish(self): pass @transition(field=state, source="+", target="removed") def remove(self): pass class Meta: app_label = "testapp" class Test(TestCase): def setUp(self): DeferrableModel.objects.create() self.model = DeferrableModel.objects.only("id").get() def test_usecase(self): self.assertEqual(self.model.state, "new") self.assertTrue(can_proceed(self.model.remove)) self.model.remove() self.assertEqual(self.model.state, "removed") self.assertFalse(can_proceed(self.model.remove)) django-fsm-2-4.0.0/tests/testapp/tests/test_custom_data.py000066400000000000000000000027031466535761300236210ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class BlogPostWithCustomData(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published", conditions=[], custom={"label": "Publish", "type": "*"}) def publish(self): pass @transition(field=state, source="published", target="destroyed", custom={"label": "Destroy", "type": "manual"}) def destroy(self): pass @transition(field=state, source="published", target="review", custom={"label": "Periodic review", "type": "automated"}) def review(self): pass class Meta: app_label = "testapp" class CustomTransitionDataTest(TestCase): def setUp(self): self.model = BlogPostWithCustomData() def test_initial_state(self): self.assertEqual(self.model.state, "new") transitions = list(self.model.get_available_state_transitions()) self.assertEqual(len(transitions), 1) self.assertEqual(transitions[0].target, "published") self.assertDictEqual(transitions[0].custom, {"label": "Publish", "type": "*"}) def test_all_transitions_have_custom_data(self): transitions = self.model.get_all_state_transitions() for t in transitions: self.assertIsNotNone(t.custom["label"]) self.assertIsNotNone(t.custom["type"]) django-fsm-2-4.0.0/tests/testapp/tests/test_exception_transitions.py000066400000000000000000000030351466535761300257500ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import can_proceed from django_fsm import transition from django_fsm.signals import post_transition class ExceptionalBlogPost(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published", on_error="crashed") def publish(self): raise Exception("Upss") @transition(field=state, source="new", target="deleted") def delete(self): raise Exception("Upss") class Meta: app_label = "testapp" class FSMFieldExceptionTest(TestCase): def setUp(self): self.model = ExceptionalBlogPost() post_transition.connect(self.on_post_transition, sender=ExceptionalBlogPost) self.post_transition_data = None def on_post_transition(self, **kwargs): self.post_transition_data = kwargs def test_state_changed_after_fail(self): self.assertTrue(can_proceed(self.model.publish)) self.assertRaises(Exception, self.model.publish) self.assertEqual(self.model.state, "crashed") self.assertEqual(self.post_transition_data["target"], "crashed") self.assertTrue("exception" in self.post_transition_data) def test_state_not_changed_after_fail(self): self.assertTrue(can_proceed(self.model.delete)) self.assertRaises(Exception, self.model.delete) self.assertEqual(self.model.state, "new") self.assertIsNone(self.post_transition_data) django-fsm-2-4.0.0/tests/testapp/tests/test_lock_mixin.py000066400000000000000000000056071466535761300234600ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import ConcurrentTransition from django_fsm import ConcurrentTransitionMixin from django_fsm import FSMField from django_fsm import transition class LockedBlogPost(ConcurrentTransitionMixin, models.Model): state = FSMField(default="new") text = models.CharField(max_length=50) @transition(field=state, source="new", target="published") def publish(self): pass @transition(field=state, source="published", target="removed") def remove(self): pass class Meta: app_label = "testapp" class ExtendedBlogPost(LockedBlogPost): review_state = FSMField(default="waiting", protected=True) notes = models.CharField(max_length=50) @transition(field=review_state, source="waiting", target="rejected") def reject(self): pass class Meta: app_label = "testapp" class TestLockMixin(TestCase): def test_create_succeed(self): LockedBlogPost.objects.create(text="test_create_succeed") def test_crud_succeed(self): post = LockedBlogPost(text="test_crud_succeed") post.publish() post.save() post = LockedBlogPost.objects.get(pk=post.pk) self.assertEqual("published", post.state) post.text = "test_crud_succeed2" post.save() post = LockedBlogPost.objects.get(pk=post.pk) self.assertEqual("test_crud_succeed2", post.text) post.delete() def test_save_and_change_succeed(self): post = LockedBlogPost(text="test_crud_succeed") post.publish() post.save() post.remove() post.save() post.delete() def test_concurrent_modifications_raise_exception(self): post1 = LockedBlogPost.objects.create() post2 = LockedBlogPost.objects.get(pk=post1.pk) post1.publish() post1.save() post2.text = "aaa" post2.publish() with self.assertRaises(ConcurrentTransition): post2.save() def test_inheritance_crud_succeed(self): post = ExtendedBlogPost(text="test_inheritance_crud_succeed", notes="reject me") post.publish() post.save() post = ExtendedBlogPost.objects.get(pk=post.pk) self.assertEqual("published", post.state) post.text = "test_inheritance_crud_succeed2" post.reject() post.save() post = ExtendedBlogPost.objects.get(pk=post.pk) self.assertEqual("rejected", post.review_state) self.assertEqual("test_inheritance_crud_succeed2", post.text) def test_concurrent_modifications_after_refresh_db_succeed(self): # bug 255 post1 = LockedBlogPost.objects.create() post2 = LockedBlogPost.objects.get(pk=post1.pk) post1.publish() post1.save() post2.refresh_from_db() post2.remove() post2.save() django-fsm-2-4.0.0/tests/testapp/tests/test_mixin_support.py000066400000000000000000000013761466535761300242430ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class WorkflowMixin: @transition(field="state", source="*", target="draft") def draft(self): pass @transition(field="state", source="draft", target="published") def publish(self): pass class Meta: app_label = "testapp" class MixinSupportTestModel(WorkflowMixin, models.Model): state = FSMField(default="new") class Test(TestCase): def test_usecase(self): model = MixinSupportTestModel() model.draft() self.assertEqual(model.state, "draft") model.publish() self.assertEqual(model.state, "published") django-fsm-2-4.0.0/tests/testapp/tests/test_model_create_with_generic.py000066400000000000000000000023551466535761300264730ustar00rootroot00000000000000from __future__ import annotations try: from django.contrib.contenttypes.fields import GenericForeignKey except ImportError: # Django 1.6 from django.contrib.contenttypes.generic import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class Ticket(models.Model): class Meta: app_label = "testapp" class Task(models.Model): class STATE: NEW = "new" DONE = "done" content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() causality = GenericForeignKey("content_type", "object_id") state = FSMField(default=STATE.NEW) @transition(field=state, source=STATE.NEW, target=STATE.DONE) def do(self): pass class Meta: app_label = "testapp" class Test(TestCase): def setUp(self): self.ticket = Ticket.objects.create() def test_model_objects_create(self): """Check a model with state field can be created if one of the other fields is a property or a virtual field. """ Task.objects.create(causality=self.ticket) django-fsm-2-4.0.0/tests/testapp/tests/test_multi_resultstate.py000066400000000000000000000045721466535761300251150ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import GET_STATE from django_fsm import RETURN_VALUE from django_fsm import FSMField from django_fsm import transition from django_fsm.signals import post_transition from django_fsm.signals import pre_transition class MultiResultTest(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target=RETURN_VALUE("for_moderators", "published")) def publish(self, is_public=False): return "published" if is_public else "for_moderators" @transition( field=state, source="for_moderators", target=GET_STATE(lambda self, allowed: "published" if allowed else "rejected", states=["published", "rejected"]), ) def moderate(self, allowed): pass class Meta: app_label = "testapp" class Test(TestCase): def test_return_state_succeed(self): instance = MultiResultTest() instance.publish(is_public=True) self.assertEqual(instance.state, "published") def test_get_state_succeed(self): instance = MultiResultTest(state="for_moderators") instance.moderate(allowed=False) self.assertEqual(instance.state, "rejected") class TestSignals(TestCase): def setUp(self): self.pre_transition_called = False self.post_transition_called = False pre_transition.connect(self.on_pre_transition, sender=MultiResultTest) post_transition.connect(self.on_post_transition, sender=MultiResultTest) def on_pre_transition(self, sender, instance, name, source, target, **kwargs): self.assertEqual(instance.state, source) self.pre_transition_called = True def on_post_transition(self, sender, instance, name, source, target, **kwargs): self.assertEqual(instance.state, target) self.post_transition_called = True def test_signals_called_with_get_state(self): instance = MultiResultTest(state="for_moderators") instance.moderate(allowed=False) self.assertTrue(self.pre_transition_called) self.assertTrue(self.post_transition_called) def test_signals_called_with_return_value(self): instance = MultiResultTest() instance.publish(is_public=True) self.assertTrue(self.pre_transition_called) self.assertTrue(self.post_transition_called) django-fsm-2-4.0.0/tests/testapp/tests/test_multidecorators.py000066400000000000000000000021441466535761300245350ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition from django_fsm.signals import post_transition class TestModel(models.Model): counter = models.IntegerField(default=0) signal_counter = models.IntegerField(default=0) state = FSMField(default="SUBMITTED_BY_USER") @transition(field=state, source="SUBMITTED_BY_USER", target="REVIEW_USER") @transition(field=state, source="SUBMITTED_BY_ADMIN", target="REVIEW_ADMIN") @transition(field=state, source="SUBMITTED_BY_ANONYMOUS", target="REVIEW_ANONYMOUS") def review(self): self.counter += 1 class Meta: app_label = "testapp" def count_calls(sender, instance, name, source, target, **kwargs): instance.signal_counter += 1 post_transition.connect(count_calls, sender=TestModel) class TestStateProxy(TestCase): def test_transition_method_called_once(self): model = TestModel() model.review() self.assertEqual(1, model.counter) self.assertEqual(1, model.signal_counter) django-fsm-2-4.0.0/tests/testapp/tests/test_object_permissions.py000066400000000000000000000032151466535761300252160ustar00rootroot00000000000000from __future__ import annotations from django.contrib.auth.models import User from django.db import models from django.test import TestCase from django.test.utils import override_settings from guardian.shortcuts import assign_perm from django_fsm import FSMField from django_fsm import has_transition_perm from django_fsm import transition class ObjectPermissionTestModel(models.Model): state = FSMField(default="new") @transition( field=state, source="new", target="published", on_error="failed", permission="testapp.can_publish_objectpermissiontestmodel", ) def publish(self): pass class Meta: app_label = "testapp" permissions = [ ("can_publish_objectpermissiontestmodel", "Can publish ObjectPermissionTestModel"), ] @override_settings( AUTHENTICATION_BACKENDS=("django.contrib.auth.backends.ModelBackend", "guardian.backends.ObjectPermissionBackend") ) class ObjectPermissionFSMFieldTest(TestCase): def setUp(self): super().setUp() self.model = ObjectPermissionTestModel.objects.create() self.unprivileged = User.objects.create(username="unpriviledged") self.privileged = User.objects.create(username="object_only_privileged") assign_perm("can_publish_objectpermissiontestmodel", self.privileged, self.model) def test_object_only_access_success(self): self.assertTrue(has_transition_perm(self.model.publish, self.privileged)) self.model.publish() def test_object_only_other_access_prohibited(self): self.assertFalse(has_transition_perm(self.model.publish, self.unprivileged)) django-fsm-2-4.0.0/tests/testapp/tests/test_permissions.py000066400000000000000000000034641466535761300236760ustar00rootroot00000000000000from __future__ import annotations from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.test import TestCase from django_fsm import has_transition_perm from tests.testapp.models import BlogPost class PermissionFSMFieldTest(TestCase): def setUp(self): self.model = BlogPost() self.unpriviledged = User.objects.create(username="unpriviledged") self.priviledged = User.objects.create(username="priviledged") self.staff = User.objects.create(username="staff", is_staff=True) self.priviledged.user_permissions.add(Permission.objects.get_by_natural_key("can_publish_post", "testapp", "blogpost")) self.priviledged.user_permissions.add(Permission.objects.get_by_natural_key("can_remove_post", "testapp", "blogpost")) def test_proviledged_access_succed(self): self.assertTrue(has_transition_perm(self.model.publish, self.priviledged)) self.assertTrue(has_transition_perm(self.model.remove, self.priviledged)) transitions = self.model.get_available_user_state_transitions(self.priviledged) self.assertEqual({"publish", "remove", "moderate"}, {transition.name for transition in transitions}) def test_unpriviledged_access_prohibited(self): self.assertFalse(has_transition_perm(self.model.publish, self.unpriviledged)) self.assertFalse(has_transition_perm(self.model.remove, self.unpriviledged)) transitions = self.model.get_available_user_state_transitions(self.unpriviledged) self.assertEqual({"moderate"}, {transition.name for transition in transitions}) def test_permission_instance_method(self): self.assertFalse(has_transition_perm(self.model.restore, self.unpriviledged)) self.assertTrue(has_transition_perm(self.model.restore, self.staff)) django-fsm-2-4.0.0/tests/testapp/tests/test_state_transitions.py000066400000000000000000000031671466535761300251000ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class Insect(models.Model): class STATE: CATERPILLAR = "CTR" BUTTERFLY = "BTF" STATE_CHOICES = ((STATE.CATERPILLAR, "Caterpillar", "Caterpillar"), (STATE.BUTTERFLY, "Butterfly", "Butterfly")) state = FSMField(default=STATE.CATERPILLAR, state_choices=STATE_CHOICES) @transition(field=state, source=STATE.CATERPILLAR, target=STATE.BUTTERFLY) def cocoon(self): pass def fly(self): raise NotImplementedError def crawl(self): raise NotImplementedError class Meta: app_label = "testapp" class Caterpillar(Insect): def crawl(self): """ Do crawl """ class Meta: app_label = "testapp" proxy = True class Butterfly(Insect): def fly(self): """ Do fly """ class Meta: app_label = "testapp" proxy = True class TestStateProxy(TestCase): def test_initial_proxy_set_succeed(self): insect = Insect() self.assertTrue(isinstance(insect, Caterpillar)) def test_transition_proxy_set_succeed(self): insect = Insect() insect.cocoon() self.assertTrue(isinstance(insect, Butterfly)) def test_load_proxy_set(self): Insect.objects.create(state=Insect.STATE.CATERPILLAR) Insect.objects.create(state=Insect.STATE.BUTTERFLY) insects = Insect.objects.all() self.assertEqual({Caterpillar, Butterfly}, {insect.__class__ for insect in insects}) django-fsm-2-4.0.0/tests/testapp/tests/test_string_field_parameter.py000066400000000000000000000016051466535761300260270ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class BlogPostWithStringField(models.Model): state = FSMField(default="new") @transition(field="state", source="new", target="published", conditions=[]) def publish(self): pass @transition(field="state", source="published", target="destroyed") def destroy(self): pass @transition(field="state", source="published", target="review") def review(self): pass class Meta: app_label = "testapp" class StringFieldTestCase(TestCase): def setUp(self): self.model = BlogPostWithStringField() def test_initial_state(self): self.assertEqual(self.model.state, "new") self.model.publish() self.assertEqual(self.model.state, "published") django-fsm-2-4.0.0/tests/testapp/tests/test_transition_all_except_target.py000066400000000000000000000016221466535761300272550ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import can_proceed from django_fsm import transition class TestExceptTargetTransitionShortcut(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published") def publish(self): pass @transition(field=state, source="+", target="removed") def remove(self): pass class Meta: app_label = "testapp" class Test(TestCase): def setUp(self): self.model = TestExceptTargetTransitionShortcut() def test_usecase(self): self.assertEqual(self.model.state, "new") self.assertTrue(can_proceed(self.model.remove)) self.model.remove() self.assertEqual(self.model.state, "removed") self.assertFalse(can_proceed(self.model.remove)) django-fsm-2-4.0.0/tests/testapp/views.py000066400000000000000000000000321466535761300202430ustar00rootroot00000000000000# Create your views here. django-fsm-2-4.0.0/tox.ini000066400000000000000000000006621466535761300152360ustar00rootroot00000000000000[tox] envlist = py{38,39,310,311}-dj42 py{310,311,312}-dj50 py{310,311,312}-dj51 skipsdist = True [testenv] deps = dj42: Django==4.2 dj50: Django==5.0 dj51: Django==5.1b1 django-guardian graphviz pep8 pyflakes pytest pytest-django pytest-cov commands = {posargs:python -m pytest} [gh-actions] python = 3.8: py38 3.9: py39 3.10: py310 3.11: py311 3.12: py312