├── tests ├── __init__.py ├── urls.py ├── admin.py ├── test_admin.py ├── test_settings.py ├── settings.py ├── conftest.py ├── models.py ├── test_manager.py └── test_model.py ├── django_fsm_log ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_auto_20151207_1521.py │ ├── 0003_statelog_description.py │ ├── 0005_description_null.py │ ├── 0004_auto_20190131_0341.py │ ├── 0004_add_source_state.py │ ├── 0003_statelog_description_squashed_0005_description_null.py │ └── 0001_initial.py ├── conf.py ├── apps.py ├── helpers.py ├── admin.py ├── managers.py ├── decorators.py ├── models.py └── backends.py ├── .markdownlint.yaml ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── test_suite.yml ├── .gitignore ├── CONTRIBUTING.md ├── .readthedocs.yaml ├── docs ├── index.rst ├── Makefile ├── make.bat └── conf.py ├── .pre-commit-config.yaml ├── tox.ini ├── LICENSE ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_fsm_log/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_fsm_log/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | { 2 | "line-length": false 3 | } 4 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | urlpatterns = [path("admin", admin.site.urls)] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | local_settings.py 5 | .tox/ 6 | .coverage 7 | 8 | build/ 9 | dist/ 10 | *.egg-info 11 | docs/_build 12 | 13 | uv.lock 14 | 15 | .envrc 16 | -------------------------------------------------------------------------------- /tests/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from django_fsm_log.admin import StateLogInline 4 | 5 | from .models import Article 6 | 7 | 8 | @admin.register(Article) 9 | class ArticleAdmin(admin.ModelAdmin): 10 | inlines = [StateLogInline] 11 | -------------------------------------------------------------------------------- /django_fsm_log/conf.py: -------------------------------------------------------------------------------- 1 | from appconf import AppConf 2 | from django.conf import settings # noqa: F401 3 | 4 | 5 | class DjangoFSMLogConf(AppConf): 6 | STORAGE_METHOD = "django_fsm_log.backends.SimpleBackend" 7 | CACHE_BACKEND = "default" 8 | IGNORED_MODELS = [] 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 4 | 5 | This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). 6 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | commands: 9 | - asdf plugin add uv 10 | - asdf install uv latest 11 | - asdf global uv latest 12 | - mkdir -p $READTHEDOCS_OUTPUT/html/ 13 | - uv sync --group docs 14 | - uv run -m sphinx -T -b html -d docs/_build/doctrees -D language=en docs $READTHEDOCS_OUTPUT/html 15 | 16 | sphinx: 17 | configuration: docs/conf.py 18 | -------------------------------------------------------------------------------- /django_fsm_log/migrations/0002_auto_20151207_1521.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9 on 2015-12-07 15:21 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('django_fsm_log', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='statelog', 15 | options={'get_latest_by': 'timestamp'}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | 4 | def test_state_log_inline_django2(article, admin_client, admin_user): 5 | article.submit(by=admin_user) 6 | article.publish(by=admin_user) 7 | url = reverse("admin:tests_article_change", args=(article.pk,)) 8 | response = admin_client.get(url) 9 | assert response.status_code == 200 10 | assert f"{article} - submit".encode() in response.content 11 | assert f"{article} - publish".encode() in response.content 12 | -------------------------------------------------------------------------------- /django_fsm_log/migrations/0003_statelog_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11 on 2017-12-22 00:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('django_fsm_log', '0002_auto_20151207_1521'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='statelog', 15 | name='description', 16 | field=models.TextField(blank=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /django_fsm_log/migrations/0005_description_null.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.3 on 2018-11-16 12:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('django_fsm_log', '0004_add_source_state'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='statelog', 15 | name='description', 16 | field=models.TextField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-fsm-log documentation master file, created by 2 | sphinx-quickstart on Fri Jan 14 15:59:49 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-fsm-log's documentation! 7 | ========================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | .. include:: ../README.md 14 | :parser: myst_parser.sphinx_ 15 | 16 | Indices and tables 17 | ================== 18 | 19 | * :ref:`genindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /django_fsm_log/migrations/0004_auto_20190131_0341.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-01-31 03:41 2 | 3 | from django.db import migrations 4 | import django_fsm_log.managers 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('django_fsm_log', '0003_statelog_description_squashed_0005_description_null'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelManagers( 15 | name='statelog', 16 | managers=[ 17 | ('objects', django_fsm_log.managers.StateLogManager()), 18 | ], 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | 4 | from django_fsm_log.models import StateLog 5 | 6 | pytestmark = pytest.mark.ignore_article 7 | 8 | 9 | def test_log_not_created_if_model_ignored(article): 10 | assert len(StateLog.objects.all()) == 0 11 | 12 | article.submit() 13 | article.save() 14 | 15 | assert len(StateLog.objects.all()) == 0 16 | 17 | 18 | def test_log_created_on_transition_when_model_not_ignored(article): 19 | settings.DJANGO_FSM_LOG_IGNORED_MODELS = ["tests.models.SomeOtherModel"] 20 | assert len(StateLog.objects.all()) == 0 21 | 22 | article.submit() 23 | article.save() 24 | 25 | assert len(StateLog.objects.all()) == 1 26 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /django_fsm_log/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | from django.utils.module_loading import import_string 4 | from django_fsm.signals import post_transition, pre_transition 5 | 6 | 7 | class DjangoFSMLogAppConfig(AppConfig): 8 | name = "django_fsm_log" 9 | verbose_name = "Django FSM Log" 10 | default_auto_field = "django.db.models.AutoField" 11 | 12 | def ready(self): 13 | backend = import_string(settings.DJANGO_FSM_LOG_STORAGE_METHOD) 14 | StateLog = self.get_model("StateLog") 15 | 16 | backend.setup_model(StateLog) 17 | 18 | pre_transition.connect(backend.pre_transition_callback) 19 | post_transition.connect(backend.post_transition_callback) 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | types: [python] 7 | - id: check-added-large-files 8 | - id: check-case-conflict 9 | - id: check-json 10 | - id: check-merge-conflict 11 | - id: check-symlinks 12 | - id: check-toml 13 | - id: end-of-file-fixer 14 | - id: trailing-whitespace 15 | - repo: https://github.com/igorshubovych/markdownlint-cli 16 | rev: v0.43.0 17 | hooks: 18 | - id: markdownlint 19 | - repo: https://github.com/astral-sh/ruff-pre-commit 20 | # Ruff version. 21 | rev: "v0.10.0" 22 | hooks: 23 | - id: ruff 24 | - id: ruff-format 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{39,310,311}-dj-4.2 4 | py{310,311,312,313}-dj-5.0 5 | py{310,311,312,313}-dj-5.1 6 | py{312,313}-dj-main 7 | lint 8 | 9 | [gh-actions] 10 | python = 11 | 3.9: py39 12 | 3.10: py310 13 | 3.11: py311 14 | 3.12: py312 15 | 3.13: py313 16 | 17 | [testenv] 18 | usedevelop = true 19 | commands = pytest --cov=django_fsm_log --cov=tests {posargs} 20 | setenv= 21 | DJANGO_SETTINGS_MODULE = tests.settings 22 | deps = 23 | dj-4.2: Django~=4.2 24 | dj-5.0: Django~=5.0 25 | dj-5.1: Django~=5.1 26 | dj-main: https://github.com/django/django/archive/main.tar.gz 27 | 28 | [testenv:lint] 29 | basepython = python3 30 | deps = ruff 31 | commands = 32 | ruff check 33 | ruff format --check 34 | -------------------------------------------------------------------------------- /django_fsm_log/migrations/0004_add_source_state.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.1 on 2018-09-08 18:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('django_fsm_log', '0003_statelog_description'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='statelog', 15 | name='source_state', 16 | field=models.CharField(blank=True, db_index=True, default=None, max_length=255, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='statelog', 20 | name='state', 21 | field=models.CharField(db_index=True, max_length=255, verbose_name='Target state'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /django_fsm_log/helpers.py: -------------------------------------------------------------------------------- 1 | NOTSET = object() 2 | 3 | 4 | class FSMLogDescriptor: 5 | ATTR_PREFIX = "__django_fsm_log_attr_" 6 | 7 | def __init__(self, instance, attribute, value=NOTSET): 8 | self.instance = instance 9 | self.attribute = attribute 10 | if value is not NOTSET: 11 | self.set(value) 12 | 13 | def get(self): 14 | return getattr(self.instance, self.ATTR_PREFIX + self.attribute) 15 | 16 | def set(self, value): 17 | setattr(self.instance, self.ATTR_PREFIX + self.attribute, value) 18 | 19 | def __enter__(self): 20 | return self 21 | 22 | def __exit__(self, *args): 23 | try: 24 | delattr(self.instance, self.ATTR_PREFIX + self.attribute) 25 | except AttributeError: 26 | pass 27 | -------------------------------------------------------------------------------- /django_fsm_log/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.admin import GenericTabularInline 2 | from django.db.models import F 3 | 4 | from .models import StateLog 5 | 6 | __all__ = ("StateLogInline",) 7 | 8 | 9 | class StateLogInline(GenericTabularInline): 10 | model = StateLog 11 | can_delete = False 12 | 13 | def has_add_permission(self, request, obj=None): 14 | return False 15 | 16 | def has_change_permission(self, request, obj=None): 17 | return True 18 | 19 | fields = ( 20 | "transition", 21 | "source_state", 22 | "state", 23 | "by", 24 | "description", 25 | "timestamp", 26 | ) 27 | 28 | def get_readonly_fields(self, request, obj=None): 29 | return self.fields 30 | 31 | def get_queryset(self, request): 32 | return super().get_queryset(request).order_by(F("timestamp").desc()) 33 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 4 | DATABASES = { 5 | "default": { 6 | "ENGINE": "django.db.backends.sqlite3", 7 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 8 | } 9 | } 10 | INSTALLED_APPS = [ 11 | "django.contrib.admin", 12 | "django.contrib.auth", 13 | "django.contrib.contenttypes", 14 | "django.contrib.sessions", 15 | "django_fsm_log", 16 | "tests", 17 | ] 18 | MIDDLEWARE = MIDDLEWARE_CLASSES = [ 19 | "django.contrib.sessions.middleware.SessionMiddleware", 20 | "django.contrib.auth.middleware.AuthenticationMiddleware", 21 | ] 22 | SECRET_KEY = "abc123" 23 | ROOT_URLCONF = "tests.urls" 24 | TEMPLATES = [ 25 | { 26 | "BACKEND": "django.template.backends.django.DjangoTemplates", 27 | "APP_DIRS": True, 28 | "OPTIONS": { 29 | "context_processors": [ 30 | "django.contrib.auth.context_processors.auth", 31 | ], 32 | }, 33 | }, 34 | ] 35 | 36 | USE_TZ = True 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | 7 | jobs: 8 | build: 9 | if: github.repository == 'jazzband/django-fsm-log' 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.11" 21 | 22 | - name: Install dependencies 23 | run: | 24 | curl -LsSf https://astral.sh/uv/install.sh | sh 25 | 26 | - name: Build package 27 | run: | 28 | uv build 29 | uvx twine check dist/* 30 | 31 | - name: Upload packages to Jazzband 32 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 33 | uses: pypa/gh-action-pypi-publish@release/v1 34 | with: 35 | user: jazzband 36 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 37 | repository-url: https://jazzband.co/projects/django-fsm-log/upload 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Gizmag 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth import get_user_model 3 | 4 | from django_fsm_log.managers import PendingStateLogManager 5 | from django_fsm_log.models import StateLog 6 | 7 | from .models import Article, ArticleInteger 8 | 9 | 10 | @pytest.fixture 11 | def article(db, request, settings): 12 | if "ignore_article" in request.keywords: 13 | settings.DJANGO_FSM_LOG_IGNORED_MODELS = ["tests.models.Article"] 14 | return Article.objects.create(state="draft") 15 | 16 | 17 | @pytest.fixture 18 | def article_integer(db, request, settings): 19 | return ArticleInteger.objects.create() 20 | 21 | 22 | @pytest.fixture 23 | def article2(db): 24 | return Article.objects.create(state="draft") 25 | 26 | 27 | @pytest.fixture 28 | def user(db): 29 | User = get_user_model() 30 | return User.objects.create_user(username="jacob", password="password") 31 | 32 | 33 | @pytest.fixture(autouse=True) 34 | def pending_objects(db, request): 35 | if "pending_objects" in request.keywords: 36 | if not hasattr(StateLog, "pending_objects"): 37 | StateLog.add_to_class("pending_objects", PendingStateLogManager()) 38 | -------------------------------------------------------------------------------- /django_fsm_log/migrations/0003_statelog_description_squashed_0005_description_null.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2018-12-13 10:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | replaces = [('django_fsm_log', '0003_statelog_description'), ('django_fsm_log', '0004_add_source_state'), ('django_fsm_log', '0005_description_null')] 9 | 10 | dependencies = [ 11 | ('django_fsm_log', '0002_auto_20151207_1521'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='statelog', 17 | name='description', 18 | field=models.TextField(blank=True, null=True), 19 | ), 20 | migrations.AddField( 21 | model_name='statelog', 22 | name='source_state', 23 | field=models.CharField(blank=True, db_index=True, default=None, max_length=255, null=True), 24 | ), 25 | migrations.AlterField( 26 | model_name='statelog', 27 | name='state', 28 | field=models.CharField(db_index=True, max_length=255, verbose_name='Target state'), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /django_fsm_log/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | import django.utils.timezone 3 | from django.conf import settings 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 10 | ('contenttypes', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='StateLog', 16 | fields=[ 17 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 18 | ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), 19 | ('state', models.CharField(max_length=255, db_index=True)), 20 | ('transition', models.CharField(max_length=255)), 21 | ('object_id', models.PositiveIntegerField(db_index=True)), 22 | ('by', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)), 23 | ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), 24 | ], 25 | options={ 26 | }, 27 | bases=(models.Model,), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /django_fsm_log/managers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.db import models 3 | from django.db.models.query import QuerySet 4 | 5 | from django_fsm_log.backends import cache 6 | 7 | 8 | class StateLogQuerySet(QuerySet): 9 | def _get_content_type(self, obj): 10 | return ContentType.objects.get_for_model(obj) 11 | 12 | def for_(self, obj): 13 | return self.filter(content_type=self._get_content_type(obj), object_id=obj.pk) 14 | 15 | 16 | class StateLogManager(models.Manager): 17 | use_in_migrations = True 18 | 19 | def get_queryset(self): 20 | return StateLogQuerySet(self.model) 21 | 22 | def __getattr__(self, attr, *args): 23 | # see https://code.djangoproject.com/ticket/15062 for details 24 | if attr.startswith("_"): 25 | raise AttributeError 26 | return getattr(self.get_queryset(), attr, *args) 27 | 28 | 29 | class PendingStateLogManager(models.Manager): 30 | def _get_cache_key_for_object(self, obj): 31 | return f"StateLog:{obj.__class__.__name__}:{obj.pk}" 32 | 33 | def create(self, *args, **kwargs): 34 | log = self.model(**kwargs) 35 | key = self._get_cache_key_for_object(kwargs["content_object"]) 36 | cache.set(key, log, 10) 37 | return log 38 | 39 | def commit_for_object(self, obj): 40 | key = self._get_cache_key_for_object(obj) 41 | log = self.get_for_object(obj) 42 | log.save() 43 | cache.delete(key) 44 | return log 45 | 46 | def get_for_object(self, obj): 47 | key = self._get_cache_key_for_object(obj) 48 | return cache.get(key) 49 | -------------------------------------------------------------------------------- /django_fsm_log/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import partial, wraps 2 | 3 | from .helpers import FSMLogDescriptor 4 | 5 | 6 | def fsm_log_by(func): 7 | """Set the "by" field of a transition. 8 | 9 | :param func: transition method 10 | :type func: function 11 | """ 12 | 13 | @wraps(func) 14 | def wrapped(instance, *args, **kwargs): 15 | try: 16 | by = kwargs["by"] 17 | except KeyError: 18 | return func(instance, *args, **kwargs) 19 | with FSMLogDescriptor(instance, "by", by): 20 | return func(instance, *args, **kwargs) 21 | 22 | return wrapped 23 | 24 | 25 | def fsm_log_description(func=None, allow_inline=False, description=None): 26 | """Set the "description" field of a transition. 27 | 28 | :param func: transition method, defaults to None 29 | :type func: function, optional 30 | :param allow_inline: allow to set the description inside the transition method, defaults to False 31 | :type allow_inline: bool, optional 32 | :param description: default description, defaults to None 33 | :type description: str, optional 34 | """ 35 | if func is None: 36 | return partial(fsm_log_description, allow_inline=allow_inline, description=description) 37 | 38 | @wraps(func) 39 | def wrapped(instance, *args, **kwargs): 40 | with FSMLogDescriptor(instance, "description") as descriptor: 41 | if kwargs.get("description"): 42 | descriptor.set(kwargs["description"]) 43 | elif allow_inline: 44 | kwargs["description"] = descriptor 45 | else: 46 | descriptor.set(description) 47 | return func(instance, *args, **kwargs) 48 | 49 | return wrapped 50 | -------------------------------------------------------------------------------- /django_fsm_log/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericForeignKey 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | from django.utils.timezone import now 5 | from django_fsm import FSMFieldMixin, FSMIntegerField 6 | 7 | from .conf import settings 8 | from .managers import StateLogManager 9 | 10 | 11 | class StateLog(models.Model): 12 | timestamp = models.DateTimeField(default=now) 13 | by = models.ForeignKey( 14 | getattr(settings, "AUTH_USER_MODEL", "auth.User"), 15 | blank=True, 16 | null=True, 17 | on_delete=models.SET_NULL, 18 | ) 19 | source_state = models.CharField(max_length=255, db_index=True, null=True, blank=True, default=None) # noqa:DJ001 20 | state = models.CharField("Target state", max_length=255, db_index=True) 21 | transition = models.CharField(max_length=255) 22 | 23 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 24 | object_id = models.PositiveIntegerField(db_index=True) 25 | content_object = GenericForeignKey("content_type", "object_id") 26 | 27 | description = models.TextField(blank=True, null=True) # noqa:DJ001 28 | 29 | objects = StateLogManager() 30 | 31 | class Meta: 32 | get_latest_by = "timestamp" 33 | 34 | def __str__(self): 35 | return f"{self.timestamp} - {self.content_object} - {self.transition}" 36 | 37 | def get_state_display(self, field_name="state"): 38 | fsm_cls = self.content_type.model_class() 39 | for field in fsm_cls._meta.fields: 40 | state = getattr(self, field_name) 41 | if isinstance(field, FSMIntegerField): 42 | state_display = dict(field.flatchoices).get(int(state), state) 43 | return str(state_display) 44 | elif isinstance(field, FSMFieldMixin): 45 | state_display = dict(field.flatchoices).get(state, state) 46 | return str(state_display) 47 | 48 | def get_source_state_display(self): 49 | return self.get_state_display("source_state") 50 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'django-fsm-log' 21 | copyright = '2022, Various Contributors' 22 | author = 'Various Contributors' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | 'myst_parser', 32 | ] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ['_templates'] 36 | 37 | # List of patterns, relative to source directory, that match files and 38 | # directories to ignore when looking for source files. 39 | # This pattern also affects html_static_path and html_extra_path. 40 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 41 | 42 | 43 | # -- Options for HTML output ------------------------------------------------- 44 | 45 | # The theme to use for HTML and HTML Help pages. See the documentation for 46 | # a list of builtin themes. 47 | # 48 | html_theme = 'alabaster' 49 | 50 | # Add any paths that contain custom static files (such as style sheets) here, 51 | # relative to this directory. They are copied after the builtin static files, 52 | # so a file named "default.css" will overwrite the builtin "default.css". 53 | html_static_path = ['_static'] 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | license = { file = "LICENSE" } 3 | description = "Transition's persistence for django-fsm" 4 | name = "django-fsm-log" 5 | dynamic = ["version"] 6 | readme = "README.md" 7 | requires-python = ">=3.9" 8 | authors = [ 9 | { name = "Gizmag", email = "tech@gizmag.com" }, 10 | { name = "Various Contributors" }, 11 | ] 12 | keywords = ["django", "django-fsm-2"] 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "Environment :: Web Environment", 16 | "Framework :: Django", 17 | "Framework :: Django :: 4.2", 18 | "Framework :: Django :: 5.0", 19 | "Framework :: Django :: 5.1", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | ] 31 | dependencies = ["django>=4.2", "django-fsm-2", "django_appconf"] 32 | 33 | [dependency-groups] 34 | dev = [ 35 | "pytest", 36 | "pytest-cov", 37 | "pytest-django", 38 | "pytest-mock", 39 | "tox", 40 | "tox-uv>=1.17.0", 41 | "twine", 42 | ] 43 | ci = ["codecov>=2.1.13"] 44 | docs = ["sphinx", "sphinx_rtd_theme", "myst-parser"] 45 | 46 | [project.urls] 47 | Documentation = "https://django-fsm-log.readthedocs.io/en/latest/" 48 | Homepage = "https://github.com/jazzband/django-fsm-log" 49 | 50 | [build-system] 51 | requires = ["setuptools>=64", "setuptools_scm>=8"] 52 | build-backend = "setuptools.build_meta" 53 | 54 | [tool.setuptools] 55 | include-package-data = true 56 | 57 | [tool.setuptools_scm] 58 | 59 | [tool.ruff] 60 | line-length = 119 61 | target-version = "py39" 62 | extend-exclude = ["django_fsm_log/migrations/", ".tox/", "build/", "docs/"] 63 | 64 | [tool.ruff.lint] 65 | select = ["E", "F", "I", "B", "C4", "T20", "TID", "UP", "DJ"] 66 | 67 | [tool.pytest.ini_options] 68 | markers = [ 69 | "ignore_article: Configure the settings DJANGO_FSM_LOG_IGNORED_MODELS to ignore Article Model.", 70 | "pending_objects: Install PendingStateLogManager on StateLog", 71 | ] 72 | testpaths = ["tests"] 73 | pythonpath = ["."] 74 | DJANGO_SETTINGS_MODULE = "tests.settings" 75 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django_fsm import FSMField, FSMIntegerField, transition 3 | 4 | from django_fsm_log.decorators import fsm_log_by, fsm_log_description 5 | 6 | 7 | class Article(models.Model): 8 | STATES = ( 9 | ("draft", "Draft"), 10 | ("submitted", "Article submitted"), 11 | ("published", "Article published"), 12 | ("temporary", "Article published (temporary)"), 13 | ("deleted", "Article deleted"), 14 | ) 15 | 16 | state = FSMField(choices=STATES, default="draft", protected=True) 17 | 18 | def __str__(self): 19 | return f"pk={self.pk}" 20 | 21 | @fsm_log_by 22 | @fsm_log_description 23 | @transition(field=state, source="draft", target="submitted") 24 | def submit(self, description=None, by=None): 25 | pass 26 | 27 | @fsm_log_by 28 | @transition(field=state, source="submitted", target="published") 29 | def publish(self, by=None): 30 | pass 31 | 32 | @fsm_log_by 33 | @transition(field=state, source="*", target="deleted") 34 | def delete(self, using=None): 35 | pass 36 | 37 | @fsm_log_by 38 | @fsm_log_description(description="Article restored") 39 | @transition(field=state, source="deleted", target="draft") 40 | def restore(self, description=None, by=None): 41 | pass 42 | 43 | @fsm_log_by 44 | @fsm_log_description(allow_inline=True, description="Article published as temporary") 45 | @transition(field=state, source="draft", target="temporary") 46 | def publish_as_temporary(self, description=None, by=None): 47 | if not isinstance(description, str): 48 | description.set("Article published (temporary)") 49 | 50 | @fsm_log_by 51 | @fsm_log_description(allow_inline=True) 52 | @transition(field=state, source="draft", target="submitted") 53 | def submit_inline_description_change(self, change_to, description=None, by=None): 54 | description.set(change_to) 55 | 56 | @fsm_log_by 57 | @transition(field=state, source="draft", target=None) 58 | def validate_draft(self, by=None): 59 | pass 60 | 61 | 62 | class ArticleInteger(models.Model): 63 | STATE_ONE = 1 64 | STATE_TWO = 2 65 | 66 | STATES = ( 67 | (STATE_ONE, "one"), 68 | (STATE_TWO, "two"), 69 | ) 70 | 71 | state = FSMIntegerField(choices=STATES, default=STATE_ONE) 72 | 73 | def __str__(self): 74 | return f"pk={self.pk}" 75 | 76 | @fsm_log_by 77 | @transition(field=state, source=STATE_ONE, target=STATE_TWO) 78 | def change_to_two(self, by=None): 79 | pass 80 | -------------------------------------------------------------------------------- /django_fsm_log/backends.py: -------------------------------------------------------------------------------- 1 | from django_fsm_log.conf import settings 2 | 3 | from .helpers import FSMLogDescriptor 4 | 5 | 6 | def _pre_transition_callback(sender, instance, name, source, target, manager, **kwargs): 7 | if BaseBackend._get_model_qualified_name__(sender) in settings.DJANGO_FSM_LOG_IGNORED_MODELS: 8 | return 9 | 10 | if target is None: 11 | return 12 | 13 | values = { 14 | "source_state": source, 15 | "state": target, 16 | "transition": name, 17 | "content_object": instance, 18 | } 19 | try: 20 | values["by"] = FSMLogDescriptor(instance, "by").get() 21 | except AttributeError: 22 | pass 23 | try: 24 | values["description"] = FSMLogDescriptor(instance, "description").get() 25 | except AttributeError: 26 | pass 27 | 28 | manager.create(**values) 29 | 30 | 31 | class BaseBackend: 32 | @staticmethod 33 | def setup_model(model): 34 | raise NotImplementedError 35 | 36 | @staticmethod 37 | def pre_transition_callback(*args, **kwargs): 38 | raise NotImplementedError 39 | 40 | @staticmethod 41 | def post_transition_callback(*args, **kwargs): 42 | raise NotImplementedError 43 | 44 | @staticmethod 45 | def _get_model_qualified_name__(sender): 46 | return "{}.{}".format(sender.__module__, getattr(sender, "__qualname__", sender.__name__)) 47 | 48 | 49 | class CachedBackend(BaseBackend): 50 | @staticmethod 51 | def setup_model(model): 52 | from .managers import PendingStateLogManager 53 | 54 | model.add_to_class("pending_objects", PendingStateLogManager()) 55 | 56 | @staticmethod 57 | def pre_transition_callback(sender, instance, name, source, target, **kwargs): 58 | from .models import StateLog 59 | 60 | return _pre_transition_callback(sender, instance, name, source, target, StateLog.pending_objects, **kwargs) 61 | 62 | @staticmethod 63 | def post_transition_callback(sender, instance, name, source, target, **kwargs): 64 | from .models import StateLog 65 | 66 | StateLog.pending_objects.commit_for_object(instance) 67 | 68 | 69 | class SimpleBackend(BaseBackend): 70 | @staticmethod 71 | def setup_model(model): 72 | pass 73 | 74 | @staticmethod 75 | def pre_transition_callback(sender, **kwargs): 76 | pass 77 | 78 | @staticmethod 79 | def post_transition_callback(sender, instance, name, source, target, **kwargs): 80 | from .models import StateLog 81 | 82 | return _pre_transition_callback(sender, instance, name, source, target, StateLog.objects, **kwargs) 83 | 84 | 85 | if settings.DJANGO_FSM_LOG_STORAGE_METHOD == "django_fsm_log.backends.CachedBackend": 86 | from django.core.cache import caches 87 | 88 | cache = caches[settings.DJANGO_FSM_LOG_CACHE_BACKEND] 89 | else: 90 | cache = None 91 | -------------------------------------------------------------------------------- /.github/workflows/test_suite.yml: -------------------------------------------------------------------------------- 1 | name: test suite 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | jobs: 8 | pytest: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: 13 | - "3.9" 14 | - "3.10" 15 | - "3.11" 16 | - "3.12" 17 | - "3.13" 18 | django-version: 19 | - "4.2" 20 | - "5.0" 21 | - "5.1" 22 | exclude: 23 | - django-version: 4.2 24 | python-version: 3.12 25 | - django-version: 4.2 26 | python-version: 3.13 27 | - django-version: 5.0 28 | python-version: 3.9 29 | - django-version: 5.1 30 | python-version: 3.9 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Set up Python ${{ matrix.python-version }} 34 | uses: astral-sh/setup-uv@v5 35 | with: 36 | enable-cache: true 37 | cache-dependency-glob: "pyproject.toml" 38 | cache-suffix: ${{ matrix.python-version }} 39 | - name: Install Python 40 | run: uv python install ${{ matrix.python-version }} 41 | env: 42 | UV_PYTHON_PREFERENCE: only-managed 43 | - run: uv sync --all-groups 44 | - run: uv run --with 'django~=${{ matrix.django-version }}.0' pytest --cov --cov-report= 45 | env: 46 | DJANGO_SETTINGS_MODULE: tests.settings 47 | PYTHONPATH: "." 48 | - name: Rename coverage file 49 | run: mv .coverage .coverage.py${{ matrix.python-version }}.dj${{ matrix.django-version }} 50 | - name: Save coverage file 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: .coverage.py${{ matrix.python-version }}.dj${{ matrix.django-version }} 54 | path: .coverage.py${{ matrix.python-version }}.dj${{ matrix.django-version }} 55 | include-hidden-files: true 56 | 57 | codecov: 58 | needs: pytest 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: astral-sh/setup-uv@v5 63 | - run: uv python install 3.13 64 | - uses: actions/download-artifact@v4 65 | with: 66 | pattern: .coverage.* 67 | merge-multiple: true 68 | - name: Combine coverage 69 | run: | 70 | uv run coverage combine 71 | uv run coverage xml 72 | - name: Upload coverage to Codecov 73 | uses: codecov/codecov-action@v5 74 | 75 | lint: # redundant with pre-commit-ci, but we want to make the linters a part of strict status checks. 76 | runs-on: ubuntu-latest 77 | steps: 78 | - uses: actions/checkout@v4 79 | - uses: astral-sh/setup-uv@v5 80 | - run: uv python install 3.13 81 | - run: uv sync --group dev 82 | - run: uvx ruff check 83 | - run: uvx ruff format --check 84 | 85 | check: 86 | runs-on: ubuntu-latest 87 | if: always() 88 | needs: 89 | - pytest 90 | - lint 91 | steps: 92 | - name: Decide whether the needed jobs succeeded or failed 93 | uses: re-actors/alls-green@release/v1 94 | with: 95 | jobs: ${{ toJSON(needs) }} 96 | -------------------------------------------------------------------------------- /tests/test_manager.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_fsm_log.models import StateLog 4 | 5 | 6 | def test_for_queryset_method_returns_only_logs_for_provided_object(article, article2): 7 | article2.submit() 8 | 9 | article.submit() 10 | article.publish() 11 | 12 | assert len(StateLog.objects.for_(article)) == 2 13 | for log in StateLog.objects.for_(article): 14 | assert article == log.content_object 15 | 16 | 17 | @pytest.fixture 18 | def create_kwargs(user, article): 19 | return { 20 | "by": user, 21 | "state": "submitted", 22 | "transition": "submit", 23 | "content_object": article, 24 | } 25 | 26 | 27 | @pytest.mark.pending_objects 28 | def test_get_cache_key_for_object_returns_correctly_formatted_string(article): 29 | expected_result = f"StateLog:{article.__class__.__name__}:{article.pk}" 30 | result = StateLog.pending_objects._get_cache_key_for_object(article) 31 | assert result == expected_result 32 | 33 | 34 | @pytest.mark.pending_objects 35 | def test_create_pending_sets_cache_item(article, create_kwargs, mocker): 36 | mock_cache = mocker.patch("django_fsm_log.managers.cache") 37 | expected_cache_key = StateLog.pending_objects._get_cache_key_for_object(article) 38 | StateLog.pending_objects.create(**create_kwargs) 39 | cache_key = mock_cache.set.call_args_list[0][0][0] 40 | cache_object = mock_cache.set.call_args_list[0][0][1] 41 | assert cache_key == expected_cache_key 42 | assert cache_object.state == create_kwargs["state"] 43 | assert cache_object.transition == create_kwargs["transition"] 44 | assert cache_object.content_object == create_kwargs["content_object"] 45 | assert cache_object.by == create_kwargs["by"] 46 | 47 | 48 | @pytest.mark.pending_objects 49 | def test_create_returns_correct_state_log(mocker, create_kwargs): 50 | mocker.patch("django_fsm_log.managers.cache") 51 | log = StateLog.pending_objects.create(**create_kwargs) 52 | assert log.state == create_kwargs["state"] 53 | assert log.transition == create_kwargs["transition"] 54 | assert log.content_object == create_kwargs["content_object"] 55 | assert log.by == create_kwargs["by"] 56 | 57 | 58 | @pytest.mark.pending_objects 59 | def test_commit_for_object_saves_log(mocker, article, create_kwargs): 60 | mock_cache = mocker.patch("django_fsm_log.managers.cache") 61 | log = StateLog.objects.create(**create_kwargs) 62 | mock_cache.get.return_value = log 63 | StateLog.pending_objects.commit_for_object(article) 64 | persisted_log = StateLog.objects.order_by("-pk").all()[0] 65 | assert log.state == persisted_log.state 66 | assert log.transition == persisted_log.transition 67 | assert log.content_object == persisted_log.content_object 68 | assert log.by == persisted_log.by 69 | 70 | 71 | @pytest.mark.pending_objects 72 | def test_commit_for_object_deletes_pending_log_from_cache(mocker, article, create_kwargs): 73 | mock_cache = mocker.patch("django_fsm_log.managers.cache") 74 | StateLog.pending_objects.create(**create_kwargs) 75 | StateLog.pending_objects.commit_for_object(article) 76 | mock_cache.delete.assert_called_once_with(StateLog.pending_objects._get_cache_key_for_object(article)) 77 | 78 | 79 | @pytest.mark.pending_objects 80 | def test_get_for_object_calls_cache_get_with_correct_key(mocker, create_kwargs): 81 | mock_cache = mocker.patch("django_fsm_log.managers.cache") 82 | cache_key = StateLog.pending_objects._get_cache_key_for_object(create_kwargs["content_object"]) 83 | StateLog.pending_objects.get_for_object(create_kwargs["content_object"]) 84 | mock_cache.get.assert_called_once_with(cache_key) 85 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django_fsm import TransitionNotAllowed 3 | 4 | from django_fsm_log.models import StateLog 5 | 6 | from .models import Article, ArticleInteger 7 | 8 | 9 | def test_get_available_state_transitions(article): 10 | assert len(list(article.get_available_state_transitions())) == 5 11 | 12 | 13 | def test_get_all_state_transitions(article): 14 | assert len(list(article.get_all_state_transitions())) == 7 15 | 16 | 17 | def test_log_created_on_transition(article): 18 | assert len(StateLog.objects.all()) == 0 19 | 20 | article.submit() 21 | article.save() 22 | 23 | assert len(StateLog.objects.all()) == 1 24 | 25 | 26 | def test_log_not_created_if_transition_fails(article): 27 | assert len(StateLog.objects.all()) == 0 28 | 29 | with pytest.raises(TransitionNotAllowed): 30 | article.publish() 31 | 32 | assert len(StateLog.objects.all()) == 0 33 | 34 | 35 | def test_log_not_created_if_target_is_none(article): 36 | assert len(StateLog.objects.all()) == 0 37 | 38 | article.validate_draft() 39 | 40 | assert len(StateLog.objects.all()) == 0 41 | 42 | 43 | def test_by_is_set_when_passed_into_transition(article, user): 44 | article.submit(by=user) 45 | 46 | log = StateLog.objects.all()[0] 47 | assert user == log.by 48 | with pytest.raises(AttributeError): 49 | _ = article.__django_fsm_log_attr_by 50 | 51 | 52 | def test_by_is_none_when_not_set_in_transition(article): 53 | article.submit() 54 | 55 | log = StateLog.objects.all()[0] 56 | assert log.by is None 57 | 58 | 59 | def test_description_is_set_when_passed_into_transition(article): 60 | description = "Lorem ipsum" 61 | article.submit(description=description) 62 | 63 | log = StateLog.objects.all()[0] 64 | assert description == log.description 65 | with pytest.raises(AttributeError): 66 | _ = article.__django_fsm_log_attr_description 67 | 68 | 69 | def test_description_is_none_when_not_set_in_transition(article): 70 | article.submit() 71 | 72 | log = StateLog.objects.all()[0] 73 | assert log.description is None 74 | 75 | 76 | def test_description_can_be_mutated_by_the_transition(article): 77 | description = "Sed egestas dui" 78 | article.submit_inline_description_change(change_to=description) 79 | 80 | log = StateLog.objects.all()[0] 81 | assert description == log.description 82 | with pytest.raises(AttributeError): 83 | article.__django_fsm_log_attr_description # noqa: B018 84 | 85 | 86 | def test_default_description(article): 87 | article.delete() 88 | article.save() 89 | article.restore() 90 | article.save() 91 | 92 | log = StateLog.objects.all()[1] 93 | assert log.description == "Article restored" 94 | 95 | 96 | def test_default_description_call_priority(article): 97 | article.delete() 98 | article.save() 99 | article.restore(description="Restored because of mistake") 100 | article.save() 101 | 102 | log = StateLog.objects.all()[1] 103 | assert log.description == "Restored because of mistake" 104 | 105 | 106 | def test_default_description_inline_priority(article): 107 | article.publish_as_temporary() 108 | article.save() 109 | 110 | log = StateLog.objects.all()[0] 111 | assert log.description == "Article published (temporary)" 112 | 113 | 114 | def test_logged_state_is_new_state(article): 115 | article.submit() 116 | 117 | log = StateLog.objects.all()[0] 118 | assert log.state == "submitted" 119 | 120 | 121 | def test_logged_transition_is_name_of_transition_method(article): 122 | article.submit() 123 | 124 | log = StateLog.objects.all()[0] 125 | assert log.transition == "submit" 126 | 127 | 128 | def test_logged_content_object_is_instance_being_transitioned(article): 129 | article.submit() 130 | 131 | log = StateLog.objects.all()[0] 132 | assert log.content_object == article 133 | 134 | 135 | def test_get_display_state(article): 136 | article.submit() 137 | article.save() 138 | 139 | log = StateLog.objects.latest() 140 | article = Article.objects.get(pk=article.pk) 141 | prev_state = article.get_state_display() 142 | 143 | assert log.get_state_display() == prev_state 144 | 145 | article.publish() 146 | article.save() 147 | 148 | log = StateLog.objects.latest() 149 | 150 | article = Article.objects.get(pk=article.pk) 151 | 152 | assert log.get_state_display() == article.get_state_display() 153 | assert log.get_source_state_display() == prev_state 154 | 155 | 156 | def test_get_display_state_with_integer(article_integer): 157 | article_integer.change_to_two() 158 | article_integer.save() 159 | 160 | log = StateLog.objects.latest() 161 | article_integer = ArticleInteger.objects.get(pk=article_integer.pk) 162 | 163 | assert log.get_state_display() == article_integer.get_state_display() 164 | # only to appease code coverage 165 | assert str(article_integer) == f"pk={article_integer.pk}" 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Finite State Machine Log 2 | 3 | [![test suite](https://github.com/jazzband/django-fsm-log/actions/workflows/test_suite.yml/badge.svg)](https://github.com/jazzband/django-fsm-log/actions/workflows/test_suite.yml) 4 | [![codecov](https://codecov.io/gh/jazzband/django-fsm-log/branch/master/graph/badge.svg)](https://codecov.io/gh/jazzband/django-fsm-log) 5 | [![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) 6 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/jazzband/django-fsm-log/master.svg)](https://results.pre-commit.ci/latest/github/jazzband/django-fsm-log/master) 7 | [![Documentation Status](https://readthedocs.org/projects/django-fsm-log/badge/?version=latest)](https://django-fsm-log.readthedocs.io/en/latest/?badge=latest) 8 | 9 | Provides persistence of the transitions of your fsm's models. Backed by the excellent [Django FSM](https://github.com/viewflow/django-fsm) 10 | package. 11 | 12 | Logs can be accessed before a transition occurs and before they are persisted to the database 13 | by enabling a cached backend. See [Advanced Usage](#advanced-usage) 14 | 15 | ## Changelog 16 | 17 | ## 4.0.5 (2025-06-11) 18 | 19 | - Switch to uv 20 | 21 | ## 4.0.4 ( :x: ) 22 | 23 | ## 4.0.3 ( :x: ) 24 | 25 | ## 4.0.2 ( :x: ) 26 | 27 | - Upgrade pypa/gh-action-pypi-publish as an attempt to fix publication of releases to pypi. 28 | 29 | ## 4.0.1 (2025-01-07) 30 | 31 | Same as 4.0.0 with a fix on setup.py version 32 | 33 | ## 4.0.0 (2025-01-07) 34 | 35 | - ⚠️ remove support for django 2.2 & 4.0 36 | - ⚠️ remove support for python 3.7 & 3.8 37 | - add support for django 5.1 and python 3.13 38 | - Switch to django-fsm-2 39 | 40 | Discontinued supported versions will most likely continue to work. 41 | 42 | ## 3.1.0 (2023-03-23) 43 | 44 | - `fsm_log_description` now accepts a default description parameter 45 | - Document `fsm_log_description` decorator 46 | - Add support for Django 4.1 47 | - Add compatibility for python 3.11 48 | 49 | ### 3.0.0 (2022-01-14) 50 | 51 | - Switch to github actions (from travis-ci) 52 | - Test against django 3.2 and 4.0, then python 3.9 and 3.10 53 | - Drop support for django 1.11, 2.0, 2.1, 3.0, 3.1 54 | - Drop support for python 3.4, 3.5, 3.6 55 | - allow using StateLogManager in migrations [#95](https://github.com/jazzband/django-fsm-log/pull/95) 56 | 57 | ### 2.0.1 (2020-03-26) 58 | 59 | - Add support for django3.0 60 | - Drop support for python2 61 | 62 | ### 1.6.2 (2019-01-06) 63 | 64 | - Address Migration history breakage added in 1.6.1 65 | 66 | ### 1.6.1 (2018-12-02) 67 | 68 | - Make StateLog.description field nullable 69 | 70 | ### 1.6.0 (2018-11-14) 71 | 72 | - Add source state on transitions 73 | - Fixed `get_state_display` with FSMIntegerField (#63) 74 | - Fixed handling of transitions if target is None (#71) 75 | - Added `fsm_log_description` decorator (#1, #67) 76 | - Dropped support for Django 1.10 (#64) 77 | 78 | ### 1.5.0 (2017-11-29) 79 | 80 | - cleanup deprecated code. 81 | - add codecov support. 82 | - switch to pytest. 83 | - add Admin integration to visualize past transitions. 84 | 85 | ### 1.4.0 (2017-11-09) 86 | 87 | - Bring compatibility with Django 2.0 and drop support of unsupported versions 88 | of Django: `1.6`, `1.7`, `1.9`. 89 | 90 | ### Compatibility 91 | 92 | - Python 2.7 and 3.4+ 93 | - Django 1.8+ 94 | - Django-FSM 2+ 95 | 96 | ## Installation 97 | 98 | First, install the package with pip. This will automatically install any 99 | dependencies you may be missing 100 | 101 | ```bash 102 | pip install django-fsm-log 103 | ``` 104 | 105 | Register django_fsm_log in your list of Django applications: 106 | 107 | ```python 108 | INSTALLED_APPS = ( 109 | ..., 110 | 'django_fsm_log', 111 | ..., 112 | ) 113 | ``` 114 | 115 | Then migrate the app to create the database table 116 | 117 | ```bash 118 | python manage.py migrate django_fsm_log 119 | ``` 120 | 121 | ## Usage 122 | 123 | The app listens for the `django_fsm.signals.post_transition` signal and 124 | creates a new record for each transition. 125 | 126 | To query the log: 127 | 128 | ```python 129 | from django_fsm_log.models import StateLog 130 | StateLog.objects.all() 131 | # ...all recorded logs... 132 | ``` 133 | 134 | ### Disabling logging for specific models 135 | 136 | By default transitions get recorded for all models. Logging can be disabled for 137 | specific models by adding their fully qualified name to `DJANGO_FSM_LOG_IGNORED_MODELS`. 138 | 139 | ```python 140 | DJANGO_FSM_LOG_IGNORED_MODELS = ('poll.models.Vote',) 141 | ``` 142 | 143 | ### `for_` Manager Method 144 | 145 | For convenience there is a custom `for_` manager method to easily filter on the generic foreign key: 146 | 147 | ```python 148 | from my_app.models import Article 149 | from django_fsm_log.models import StateLog 150 | 151 | article = Article.objects.all()[0] 152 | 153 | StateLog.objects.for_(article) 154 | # ...logs for article... 155 | ``` 156 | 157 | ### `by` Decorator 158 | 159 | We found that our transitions are commonly called by a user, so we've added a 160 | decorator to make logging this easy: 161 | 162 | ```python 163 | from django.db import models 164 | from django_fsm import FSMField, transition 165 | from django_fsm_log.decorators import fsm_log_by 166 | 167 | class Article(models.Model): 168 | 169 | state = FSMField(default='draft', protected=True) 170 | 171 | @fsm_log_by 172 | @transition(field=state, source='draft', target='submitted') 173 | def submit(self, by=None): 174 | pass 175 | ``` 176 | 177 | With this the transition gets logged when the `by` kwarg is present. 178 | 179 | ```python 180 | article = Article.objects.create() 181 | article.submit(by=some_user) # StateLog.by will be some_user 182 | ``` 183 | 184 | ### `description` Decorator 185 | 186 | Decorator that allows to set a custom description (saved on database) to a transitions. 187 | 188 | ```python 189 | from django.db import models 190 | from django_fsm import FSMField, transition 191 | from django_fsm_log.decorators import fsm_log_description 192 | 193 | class Article(models.Model): 194 | 195 | state = FSMField(default='draft', protected=True) 196 | 197 | @fsm_log_description(description='Article submitted') # description param is NOT required 198 | @transition(field=state, source='draft', target='submitted') 199 | def submit(self, description=None): 200 | pass 201 | 202 | article = Article.objects.create() 203 | article.submit() # logged with "Article submitted" description 204 | article.submit(description="Article reviewed and submitted") # logged with "Article reviewed and submitted" description 205 | ``` 206 | 207 | .. TIP:: 208 | The "description" argument passed when calling ".submit" has precedence over the default description set in the decorator 209 | 210 | The decorator also accepts a `allow_inline` boolean argument that allows to set the description inside the transition method. 211 | 212 | ```python 213 | from django.db import models 214 | from django_fsm import FSMField, transition 215 | from django_fsm_log.decorators import fsm_log_description 216 | 217 | class Article(models.Model): 218 | 219 | state = FSMField(default='draft', protected=True) 220 | 221 | @fsm_log_description(allow_inline=True) 222 | @transition(field=state, source='draft', target='submitted') 223 | def submit(self, description=None): 224 | description.set("Article submitted") 225 | 226 | article = Article.objects.create() 227 | article.submit() # logged with "Article submitted" description 228 | ``` 229 | 230 | ### Admin integration 231 | 232 | There is an InlineForm available that can be used to display the history of changes. 233 | 234 | To use it expand your own `AdminModel` by adding `StateLogInline` to its inlines: 235 | 236 | ```python 237 | from django.contrib import admin 238 | from django_fsm_log.admin import StateLogInline 239 | 240 | 241 | @admin.register(FSMModel) 242 | class FSMModelAdmin(admin.ModelAdmin): 243 | inlines = [StateLogInline] 244 | ``` 245 | 246 | ### Advanced Usage 247 | 248 | You can change the behaviour of this app by turning on caching for StateLog records. 249 | Simply add `DJANGO_FSM_LOG_STORAGE_METHOD = 'django_fsm_log.backends.CachedBackend'` to your project's settings file. 250 | It will use your project's default cache backend by default. If you wish to use a specific cache backend, you can add to 251 | your project's settings: 252 | 253 | ```python 254 | DJANGO_FSM_LOG_CACHE_BACKEND = 'some_other_cache_backend' 255 | ``` 256 | 257 | The StateLog object is now available after the `django_fsm.signals.pre_transition` 258 | signal is fired, but is deleted from the cache and persisted to the database after `django_fsm.signals.post_transition` 259 | is fired. 260 | 261 | This is useful if: 262 | 263 | - you need immediate access to StateLog details, and cannot wait until `django_fsm.signals.post_transition` 264 | has been fired 265 | - at any stage, you need to verify whether or not the StateLog has been written to the database 266 | 267 | Access to the pending StateLog record is available via the `pending_objects` manager 268 | 269 | ```python 270 | from django_fsm_log.models import StateLog 271 | article = Article.objects.get(...) 272 | pending_state_log = StateLog.pending_objects.get_for_object(article) 273 | ``` 274 | 275 | ## Contributing 276 | 277 | ### Running tests 278 | 279 | ```bash 280 | pip install tox 281 | tox 282 | ``` 283 | 284 | ### Linting with pre-commit 285 | 286 | We use ruff, black and more, all configured and check via [pre-commit](https://pre-commit.com/). 287 | Before committing, run the following: 288 | 289 | ```bash 290 | pip install pre-commit 291 | pre-commit install 292 | ``` 293 | --------------------------------------------------------------------------------