pax_global_header00006660000000000000000000000064151361162170014515gustar00rootroot0000000000000052 comment=138e3a3fc9f967a546110faa497ddef482638d41 django-fsm-log-5.0.2/000077500000000000000000000000001513611621700143255ustar00rootroot00000000000000django-fsm-log-5.0.2/.github/000077500000000000000000000000001513611621700156655ustar00rootroot00000000000000django-fsm-log-5.0.2/.github/dependabot.yml000066400000000000000000000001651513611621700205170ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" django-fsm-log-5.0.2/.github/workflows/000077500000000000000000000000001513611621700177225ustar00rootroot00000000000000django-fsm-log-5.0.2/.github/workflows/release.yml000066400000000000000000000014121513611621700220630ustar00rootroot00000000000000name: Release on: push: tags: - "*" jobs: build: if: github.repository == 'jazzband/django-fsm-log' runs-on: ubuntu-latest environment: pypi permissions: id-token: write steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true - name: Build package run: | uv build uvx twine check dist/* - name: Upload packages to Pypi if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 django-fsm-log-5.0.2/.github/workflows/test_suite.yml000066400000000000000000000057361513611621700226500ustar00rootroot00000000000000name: test suite on: push: branches: - master pull_request: jobs: pytest: runs-on: ubuntu-latest env: COVERAGE_FILE: coverage-py${{ matrix.python-version }}-dj${{ matrix.django-version }}.coverage strategy: matrix: python-version: - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" django-version: - "4.2" - "5.0" - "5.1" - "5.2" - "6.0" exclude: - django-version: 4.2 python-version: 3.12 - django-version: 4.2 python-version: 3.13 - django-version: 4.2 python-version: 3.14 - django-version: 5.0 python-version: 3.14 - django-version: 5.1 python-version: 3.14 - django-version: 6.0 python-version: 3.10 - django-version: 6.0 python-version: 3.11 steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "pyproject.toml" cache-suffix: ${{ matrix.python-version }} - name: Install Python run: uv python install ${{ matrix.python-version }} env: UV_PYTHON_PREFERENCE: only-managed - run: uv sync --all-groups - run: uv run --with 'django~=${{ matrix.django-version }}.0' pytest --cov env: DJANGO_SETTINGS_MODULE: tests.settings PYTHONPATH: "." - name: Save coverage file uses: actions/upload-artifact@v6 with: name: ${{ env.COVERAGE_FILE }} path: ${{ env.COVERAGE_FILE }} include-hidden-files: true codecov: needs: pytest runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v7 with: enable-cache: true - run: uv python install 3.13 - uses: actions/download-artifact@v7 with: pattern: "*.coverage" merge-multiple: true - name: Combine coverage run: | uv run coverage combine --keep ./*.coverage uv run coverage report uv run coverage xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 lint: # redundant with pre-commit-ci, but we want to make the linters a part of strict status checks. runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v7 with: enable-cache: true - run: uv python install 3.13 - run: uv sync --group dev - run: uvx ruff check - run: uvx ruff format --check check: runs-on: ubuntu-latest if: always() needs: - pytest - lint steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} django-fsm-log-5.0.2/.gitignore000066400000000000000000000001671513611621700163210ustar00rootroot00000000000000*.log *.pot *.pyc local_settings.py .tox/ .coverage coverage.xml build/ dist/ *.egg-info docs/_build uv.lock .envrc django-fsm-log-5.0.2/.markdownlint.yaml000066400000000000000000000000331513611621700177740ustar00rootroot00000000000000{ "line-length": false } django-fsm-log-5.0.2/.pre-commit-config.yaml000066400000000000000000000012021513611621700206010ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace types: [python] - id: check-added-large-files - id: check-case-conflict - id: check-json - id: check-merge-conflict - id: check-symlinks - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.43.0 hooks: - id: markdownlint - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: "v0.10.0" hooks: - id: ruff - id: ruff-format django-fsm-log-5.0.2/.readthedocs.yaml000066400000000000000000000005631513611621700175600ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.11" commands: - asdf plugin add uv - asdf install uv latest - asdf global uv latest - mkdir -p $READTHEDOCS_OUTPUT/html/ - uv sync --group docs - uv run -m sphinx -T -b html -d docs/_build/doctrees -D language=en docs $READTHEDOCS_OUTPUT/html sphinx: configuration: docs/conf.py django-fsm-log-5.0.2/CONTRIBUTING.md000066400000000000000000000005041513611621700165550ustar00rootroot00000000000000# Contributing [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 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). django-fsm-log-5.0.2/LICENSE000066400000000000000000000020611513611621700153310ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2013 Gizmag 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-log-5.0.2/README.md000066400000000000000000000205331513611621700156070ustar00rootroot00000000000000# Django Finite State Machine Log [![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) [![codecov](https://codecov.io/gh/jazzband/django-fsm-log/branch/master/graph/badge.svg)](https://codecov.io/gh/jazzband/django-fsm-log) [![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) [![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) [![Documentation Status](https://readthedocs.org/projects/django-fsm-log/badge/?version=latest)](https://django-fsm-log.readthedocs.io/en/latest/?badge=latest) Provides persistence of the transitions of your fsm's models. Backed by the excellent [Django FSM](https://github.com/viewflow/django-fsm) package. Logs can be accessed before a transition occurs and before they are persisted to the database by enabling a cached backend. See [Advanced Usage](#advanced-usage) ## Changelog ## 5.0.2 ( 2026-01-27 ) - Try to publish straight to pypi thanks to oicd trusted publishing process ## 5.0.1 ( :x: ) - switch from setuptools to hatchling ## 5.0.0 ( :x: ) - ⚠️ Drop python3.9 support - Add python3.14 support - Add django 5.2, 6.0 support ## 4.0.5 ( :x: ) - Switch to uv ## 4.0.4 ( :x: ) ## 4.0.3 ( :x: ) ## 4.0.2 ( :x: ) - Upgrade pypa/gh-action-pypi-publish as an attempt to fix publication of releases to pypi. ## 4.0.1 (2025-01-07) Same as 4.0.0 with a fix on setup.py version ## 4.0.0 (2025-01-07) - ⚠️ remove support for django 2.2 & 4.0 - ⚠️ remove support for python 3.7 & 3.8 - add support for django 5.1 and python 3.13 - Switch to django-fsm-2 Discontinued supported versions will most likely continue to work. ## 3.1.0 (2023-03-23) - `fsm_log_description` now accepts a default description parameter - Document `fsm_log_description` decorator - Add support for Django 4.1 - Add compatibility for python 3.11 ### 3.0.0 (2022-01-14) - Switch to github actions (from travis-ci) - Test against django 3.2 and 4.0, then python 3.9 and 3.10 - Drop support for django 1.11, 2.0, 2.1, 3.0, 3.1 - Drop support for python 3.4, 3.5, 3.6 - allow using StateLogManager in migrations [#95](https://github.com/jazzband/django-fsm-log/pull/95) ### 2.0.1 (2020-03-26) - Add support for django3.0 - Drop support for python2 ### 1.6.2 (2019-01-06) - Address Migration history breakage added in 1.6.1 ### 1.6.1 (2018-12-02) - Make StateLog.description field nullable ### 1.6.0 (2018-11-14) - Add source state on transitions - Fixed `get_state_display` with FSMIntegerField (#63) - Fixed handling of transitions if target is None (#71) - Added `fsm_log_description` decorator (#1, #67) - Dropped support for Django 1.10 (#64) ### 1.5.0 (2017-11-29) - cleanup deprecated code. - add codecov support. - switch to pytest. - add Admin integration to visualize past transitions. ### 1.4.0 (2017-11-09) - Bring compatibility with Django 2.0 and drop support of unsupported versions of Django: `1.6`, `1.7`, `1.9`. ### Compatibility - Python 2.7 and 3.4+ - Django 1.8+ - Django-FSM 2+ ## Installation First, install the package with pip. This will automatically install any dependencies you may be missing ```bash pip install django-fsm-log ``` Register django_fsm_log in your list of Django applications: ```python INSTALLED_APPS = ( ..., 'django_fsm_log', ..., ) ``` Then migrate the app to create the database table ```bash python manage.py migrate django_fsm_log ``` ## Usage The app listens for the `django_fsm.signals.post_transition` signal and creates a new record for each transition. To query the log: ```python from django_fsm_log.models import StateLog StateLog.objects.all() # ...all recorded logs... ``` ### Disabling logging for specific models By default transitions get recorded for all models. Logging can be disabled for specific models by adding their fully qualified name to `DJANGO_FSM_LOG_IGNORED_MODELS`. ```python DJANGO_FSM_LOG_IGNORED_MODELS = ('poll.models.Vote',) ``` ### `for_` Manager Method For convenience there is a custom `for_` manager method to easily filter on the generic foreign key: ```python from my_app.models import Article from django_fsm_log.models import StateLog article = Article.objects.all()[0] StateLog.objects.for_(article) # ...logs for article... ``` ### `by` Decorator We found that our transitions are commonly called by a user, so we've added a decorator to make logging this easy: ```python from django.db import models from django_fsm import FSMField, transition from django_fsm_log.decorators import fsm_log_by class Article(models.Model): state = FSMField(default='draft', protected=True) @fsm_log_by @transition(field=state, source='draft', target='submitted') def submit(self, by=None): pass ``` With this the transition gets logged when the `by` kwarg is present. ```python article = Article.objects.create() article.submit(by=some_user) # StateLog.by will be some_user ``` ### `description` Decorator Decorator that allows to set a custom description (saved on database) to a transitions. ```python from django.db import models from django_fsm import FSMField, transition from django_fsm_log.decorators import fsm_log_description class Article(models.Model): state = FSMField(default='draft', protected=True) @fsm_log_description(description='Article submitted') # description param is NOT required @transition(field=state, source='draft', target='submitted') def submit(self, description=None): pass article = Article.objects.create() article.submit() # logged with "Article submitted" description article.submit(description="Article reviewed and submitted") # logged with "Article reviewed and submitted" description ``` .. TIP:: The "description" argument passed when calling ".submit" has precedence over the default description set in the decorator The decorator also accepts a `allow_inline` boolean argument that allows to set the description inside the transition method. ```python from django.db import models from django_fsm import FSMField, transition from django_fsm_log.decorators import fsm_log_description class Article(models.Model): state = FSMField(default='draft', protected=True) @fsm_log_description(allow_inline=True) @transition(field=state, source='draft', target='submitted') def submit(self, description=None): description.set("Article submitted") article = Article.objects.create() article.submit() # logged with "Article submitted" description ``` ### Admin integration There is an InlineForm available that can be used to display the history of changes. To use it expand your own `AdminModel` by adding `StateLogInline` to its inlines: ```python from django.contrib import admin from django_fsm_log.admin import StateLogInline @admin.register(FSMModel) class FSMModelAdmin(admin.ModelAdmin): inlines = [StateLogInline] ``` ### Advanced Usage You can change the behaviour of this app by turning on caching for StateLog records. Simply add `DJANGO_FSM_LOG_STORAGE_METHOD = 'django_fsm_log.backends.CachedBackend'` to your project's settings file. It will use your project's default cache backend by default. If you wish to use a specific cache backend, you can add to your project's settings: ```python DJANGO_FSM_LOG_CACHE_BACKEND = 'some_other_cache_backend' ``` The StateLog object is now available after the `django_fsm.signals.pre_transition` signal is fired, but is deleted from the cache and persisted to the database after `django_fsm.signals.post_transition` is fired. This is useful if: - you need immediate access to StateLog details, and cannot wait until `django_fsm.signals.post_transition` has been fired - at any stage, you need to verify whether or not the StateLog has been written to the database Access to the pending StateLog record is available via the `pending_objects` manager ```python from django_fsm_log.models import StateLog article = Article.objects.get(...) pending_state_log = StateLog.pending_objects.get_for_object(article) ``` ## Contributing ### Running tests ```bash pip install tox tox ``` ### Linting with pre-commit We use ruff, black and more, all configured and check via [pre-commit](https://pre-commit.com/). Before committing, run the following: ```bash pip install pre-commit pre-commit install ``` django-fsm-log-5.0.2/django_fsm_log/000077500000000000000000000000001513611621700172755ustar00rootroot00000000000000django-fsm-log-5.0.2/django_fsm_log/__init__.py000066400000000000000000000000001513611621700213740ustar00rootroot00000000000000django-fsm-log-5.0.2/django_fsm_log/admin.py000066400000000000000000000013451513611621700207420ustar00rootroot00000000000000from django.contrib.contenttypes.admin import GenericTabularInline from django.db.models import F from .models import StateLog __all__ = ("StateLogInline",) class StateLogInline(GenericTabularInline): model = StateLog can_delete = False def has_add_permission(self, request, obj=None): return False def has_change_permission(self, request, obj=None): return True fields = ( "transition", "source_state", "state", "by", "description", "timestamp", ) def get_readonly_fields(self, request, obj=None): return self.fields def get_queryset(self, request): return super().get_queryset(request).order_by(F("timestamp").desc()) django-fsm-log-5.0.2/django_fsm_log/apps.py000066400000000000000000000012161513611621700206120ustar00rootroot00000000000000from django.apps import AppConfig from django.conf import settings from django.utils.module_loading import import_string from django_fsm.signals import post_transition, pre_transition class DjangoFSMLogAppConfig(AppConfig): name = "django_fsm_log" verbose_name = "Django FSM Log" default_auto_field = "django.db.models.AutoField" def ready(self): backend = import_string(settings.DJANGO_FSM_LOG_STORAGE_METHOD) StateLog = self.get_model("StateLog") backend.setup_model(StateLog) pre_transition.connect(backend.pre_transition_callback) post_transition.connect(backend.post_transition_callback) django-fsm-log-5.0.2/django_fsm_log/backends.py000066400000000000000000000047171513611621700214320ustar00rootroot00000000000000from django_fsm_log.conf import settings from .helpers import FSMLogDescriptor def _pre_transition_callback(sender, instance, name, source, target, manager, **kwargs): if BaseBackend._get_model_qualified_name__(sender) in settings.DJANGO_FSM_LOG_IGNORED_MODELS: return if target is None: return values = { "source_state": source, "state": target, "transition": name, "content_object": instance, } try: values["by"] = FSMLogDescriptor(instance, "by").get() except AttributeError: pass try: values["description"] = FSMLogDescriptor(instance, "description").get() except AttributeError: pass manager.create(**values) class BaseBackend: @staticmethod def setup_model(model): raise NotImplementedError @staticmethod def pre_transition_callback(*args, **kwargs): raise NotImplementedError @staticmethod def post_transition_callback(*args, **kwargs): raise NotImplementedError @staticmethod def _get_model_qualified_name__(sender): return "{}.{}".format(sender.__module__, getattr(sender, "__qualname__", sender.__name__)) class CachedBackend(BaseBackend): @staticmethod def setup_model(model): from .managers import PendingStateLogManager model.add_to_class("pending_objects", PendingStateLogManager()) @staticmethod def pre_transition_callback(sender, instance, name, source, target, **kwargs): from .models import StateLog return _pre_transition_callback(sender, instance, name, source, target, StateLog.pending_objects, **kwargs) @staticmethod def post_transition_callback(sender, instance, name, source, target, **kwargs): from .models import StateLog StateLog.pending_objects.commit_for_object(instance) class SimpleBackend(BaseBackend): @staticmethod def setup_model(model): pass @staticmethod def pre_transition_callback(sender, **kwargs): pass @staticmethod def post_transition_callback(sender, instance, name, source, target, **kwargs): from .models import StateLog return _pre_transition_callback(sender, instance, name, source, target, StateLog.objects, **kwargs) if settings.DJANGO_FSM_LOG_STORAGE_METHOD == "django_fsm_log.backends.CachedBackend": from django.core.cache import caches cache = caches[settings.DJANGO_FSM_LOG_CACHE_BACKEND] else: cache = None django-fsm-log-5.0.2/django_fsm_log/conf.py000066400000000000000000000003411513611621700205720ustar00rootroot00000000000000from appconf import AppConf from django.conf import settings # noqa: F401 class DjangoFSMLogConf(AppConf): STORAGE_METHOD = "django_fsm_log.backends.SimpleBackend" CACHE_BACKEND = "default" IGNORED_MODELS = [] django-fsm-log-5.0.2/django_fsm_log/decorators.py000066400000000000000000000030041513611621700220110ustar00rootroot00000000000000from functools import partial, wraps from .helpers import FSMLogDescriptor def fsm_log_by(func): """Set the "by" field of a transition. :param func: transition method :type func: function """ @wraps(func) def wrapped(instance, *args, **kwargs): try: by = kwargs["by"] except KeyError: return func(instance, *args, **kwargs) with FSMLogDescriptor(instance, "by", by): return func(instance, *args, **kwargs) return wrapped def fsm_log_description(func=None, allow_inline=False, description=None): """Set the "description" field of a transition. :param func: transition method, defaults to None :type func: function, optional :param allow_inline: allow to set the description inside the transition method, defaults to False :type allow_inline: bool, optional :param description: default description, defaults to None :type description: str, optional """ if func is None: return partial(fsm_log_description, allow_inline=allow_inline, description=description) @wraps(func) def wrapped(instance, *args, **kwargs): with FSMLogDescriptor(instance, "description") as descriptor: if kwargs.get("description"): descriptor.set(kwargs["description"]) elif allow_inline: kwargs["description"] = descriptor else: descriptor.set(description) return func(instance, *args, **kwargs) return wrapped django-fsm-log-5.0.2/django_fsm_log/helpers.py000066400000000000000000000012451513611621700213130ustar00rootroot00000000000000NOTSET = object() class FSMLogDescriptor: ATTR_PREFIX = "__django_fsm_log_attr_" def __init__(self, instance, attribute, value=NOTSET): self.instance = instance self.attribute = attribute if value is not NOTSET: self.set(value) def get(self): return getattr(self.instance, self.ATTR_PREFIX + self.attribute) def set(self, value): setattr(self.instance, self.ATTR_PREFIX + self.attribute, value) def __enter__(self): return self def __exit__(self, *args): try: delattr(self.instance, self.ATTR_PREFIX + self.attribute) except AttributeError: pass django-fsm-log-5.0.2/django_fsm_log/managers.py000066400000000000000000000026551513611621700214540ustar00rootroot00000000000000from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models.query import QuerySet from django_fsm_log.backends import cache class StateLogQuerySet(QuerySet): def _get_content_type(self, obj): return ContentType.objects.get_for_model(obj) def for_(self, obj): return self.filter(content_type=self._get_content_type(obj), object_id=obj.pk) class StateLogManager(models.Manager): use_in_migrations = True def get_queryset(self): return StateLogQuerySet(self.model) def __getattr__(self, attr, *args): # see https://code.djangoproject.com/ticket/15062 for details if attr.startswith("_"): raise AttributeError return getattr(self.get_queryset(), attr, *args) class PendingStateLogManager(models.Manager): def _get_cache_key_for_object(self, obj): return f"StateLog:{obj.__class__.__name__}:{obj.pk}" def create(self, *args, **kwargs): log = self.model(**kwargs) key = self._get_cache_key_for_object(kwargs["content_object"]) cache.set(key, log, 10) return log def commit_for_object(self, obj): key = self._get_cache_key_for_object(obj) log = self.get_for_object(obj) log.save() cache.delete(key) return log def get_for_object(self, obj): key = self._get_cache_key_for_object(obj) return cache.get(key) django-fsm-log-5.0.2/django_fsm_log/migrations/000077500000000000000000000000001513611621700214515ustar00rootroot00000000000000django-fsm-log-5.0.2/django_fsm_log/migrations/0001_initial.py000066400000000000000000000021631513611621700241160ustar00rootroot00000000000000from django.db import models, migrations import django.utils.timezone from django.conf import settings class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('contenttypes', '0001_initial'), ] operations = [ migrations.CreateModel( name='StateLog', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), ('state', models.CharField(max_length=255, db_index=True)), ('transition', models.CharField(max_length=255)), ('object_id', models.PositiveIntegerField(db_index=True)), ('by', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)), ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), ], options={ }, bases=(models.Model,), ), ] django-fsm-log-5.0.2/django_fsm_log/migrations/0002_auto_20151207_1521.py000066400000000000000000000005351513611621700250700ustar00rootroot00000000000000# Generated by Django 1.9 on 2015-12-07 15:21 from django.db import migrations class Migration(migrations.Migration): dependencies = [ ('django_fsm_log', '0001_initial'), ] operations = [ migrations.AlterModelOptions( name='statelog', options={'get_latest_by': 'timestamp'}, ), ] django-fsm-log-5.0.2/django_fsm_log/migrations/0003_statelog_description.py000066400000000000000000000006121513611621700267110ustar00rootroot00000000000000# Generated by Django 1.11 on 2017-12-22 00:19 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('django_fsm_log', '0002_auto_20151207_1521'), ] operations = [ migrations.AddField( model_name='statelog', name='description', field=models.TextField(blank=True), ), ] 0003_statelog_description_squashed_0005_description_null.py000066400000000000000000000017151513611621700346350ustar00rootroot00000000000000django-fsm-log-5.0.2/django_fsm_log/migrations# Generated by Django 2.1.4 on 2018-12-13 10:54 from django.db import migrations, models class Migration(migrations.Migration): replaces = [('django_fsm_log', '0003_statelog_description'), ('django_fsm_log', '0004_add_source_state'), ('django_fsm_log', '0005_description_null')] dependencies = [ ('django_fsm_log', '0002_auto_20151207_1521'), ] operations = [ migrations.AddField( model_name='statelog', name='description', field=models.TextField(blank=True, null=True), ), migrations.AddField( model_name='statelog', name='source_state', field=models.CharField(blank=True, db_index=True, default=None, max_length=255, null=True), ), migrations.AlterField( model_name='statelog', name='state', field=models.CharField(db_index=True, max_length=255, verbose_name='Target state'), ), ] django-fsm-log-5.0.2/django_fsm_log/migrations/0004_add_source_state.py000066400000000000000000000012151513611621700257750ustar00rootroot00000000000000# Generated by Django 2.1.1 on 2018-09-08 18:42 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('django_fsm_log', '0003_statelog_description'), ] operations = [ migrations.AddField( model_name='statelog', name='source_state', field=models.CharField(blank=True, db_index=True, default=None, max_length=255, null=True), ), migrations.AlterField( model_name='statelog', name='state', field=models.CharField(db_index=True, max_length=255, verbose_name='Target state'), ), ] django-fsm-log-5.0.2/django_fsm_log/migrations/0004_auto_20190131_0341.py000066400000000000000000000007451513611621700250730ustar00rootroot00000000000000# Generated by Django 2.1.5 on 2019-01-31 03:41 from django.db import migrations import django_fsm_log.managers class Migration(migrations.Migration): dependencies = [ ('django_fsm_log', '0003_statelog_description_squashed_0005_description_null'), ] operations = [ migrations.AlterModelManagers( name='statelog', managers=[ ('objects', django_fsm_log.managers.StateLogManager()), ], ), ] django-fsm-log-5.0.2/django_fsm_log/migrations/0005_description_null.py000066400000000000000000000006261513611621700260500ustar00rootroot00000000000000# Generated by Django 2.1.3 on 2018-11-16 12:57 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('django_fsm_log', '0004_add_source_state'), ] operations = [ migrations.AlterField( model_name='statelog', name='description', field=models.TextField(blank=True, null=True), ), ] django-fsm-log-5.0.2/django_fsm_log/migrations/__init__.py000066400000000000000000000000001513611621700235500ustar00rootroot00000000000000django-fsm-log-5.0.2/django_fsm_log/models.py000066400000000000000000000035511513611621700211360ustar00rootroot00000000000000from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.utils.timezone import now from django_fsm import FSMFieldMixin, FSMIntegerField from .conf import settings from .managers import StateLogManager class StateLog(models.Model): timestamp = models.DateTimeField(default=now) by = models.ForeignKey( getattr(settings, "AUTH_USER_MODEL", "auth.User"), blank=True, null=True, on_delete=models.SET_NULL, ) source_state = models.CharField(max_length=255, db_index=True, null=True, blank=True, default=None) # noqa:DJ001 state = models.CharField("Target state", max_length=255, db_index=True) transition = models.CharField(max_length=255) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField(db_index=True) content_object = GenericForeignKey("content_type", "object_id") description = models.TextField(blank=True, null=True) # noqa:DJ001 objects = StateLogManager() class Meta: get_latest_by = "timestamp" def __str__(self): return f"{self.timestamp} - {self.content_object} - {self.transition}" def get_state_display(self, field_name="state"): fsm_cls = self.content_type.model_class() for field in fsm_cls._meta.fields: state = getattr(self, field_name) if isinstance(field, FSMIntegerField): state_display = dict(field.flatchoices).get(int(state), state) return str(state_display) elif isinstance(field, FSMFieldMixin): state_display = dict(field.flatchoices).get(state, state) return str(state_display) def get_source_state_display(self): return self.get_state_display("source_state") django-fsm-log-5.0.2/docs/000077500000000000000000000000001513611621700152555ustar00rootroot00000000000000django-fsm-log-5.0.2/docs/Makefile000066400000000000000000000011721513611621700167160ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) django-fsm-log-5.0.2/docs/conf.py000066400000000000000000000035531513611621700165620ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) # -- Project information ----------------------------------------------------- project = 'django-fsm-log' copyright = '2022, Various Contributors' author = 'Various Contributors' # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'myst_parser', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'alabaster' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] django-fsm-log-5.0.2/docs/index.rst000066400000000000000000000007611513611621700171220ustar00rootroot00000000000000.. django-fsm-log documentation master file, created by sphinx-quickstart on Fri Jan 14 15:59:49 2022. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to django-fsm-log's documentation! ========================================== .. toctree:: :maxdepth: 2 :caption: Contents: .. include:: ../README.md :parser: myst_parser.sphinx_ Indices and tables ================== * :ref:`genindex` * :ref:`search` django-fsm-log-5.0.2/docs/make.bat000066400000000000000000000014401513611621700166610ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd django-fsm-log-5.0.2/pyproject.toml000066400000000000000000000044451513611621700172500ustar00rootroot00000000000000[build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] license = "MIT" license-files = ["LICENSE"] description = "Transition's persistence for django-fsm" name = "django-fsm-log" dynamic = ["version"] readme = "README.md" requires-python = ">=3.9" authors = [ { name = "Gizmag", email = "tech@gizmag.com" }, { name = "Various Contributors" }, ] keywords = ["django", "django-fsm-2"] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", "Framework :: Django :: 6.0", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = ["django>=4.2", "django-fsm-2", "django_appconf"] [dependency-groups] dev = [ "pytest", "pytest-cov", "pytest-django", "pytest-mock", "tox", "tox-uv>=1.17.0", "twine", ] ci = ["codecov>=2.1.13"] docs = ["sphinx", "sphinx_rtd_theme", "myst-parser"] [project.urls] Documentation = "https://django-fsm-log.readthedocs.io/en/latest/" Homepage = "https://github.com/jazzband/django-fsm-log" [tool.hatch.version] source = "vcs" [tool.hatch.build.targets.sdist] include = ["django_fsm_log"] [tool.hatch.build.targets.wheel] packages = ["django_fsm_log"] [tool.ruff] line-length = 119 target-version = "py310" extend-exclude = ["django_fsm_log/migrations/", ".tox/", "build/", "docs/"] [tool.ruff.lint] select = ["E", "F", "I", "B", "C4", "T20", "TID", "UP", "DJ"] [tool.pytest.ini_options] markers = [ "ignore_article: Configure the settings DJANGO_FSM_LOG_IGNORED_MODELS to ignore Article Model.", "pending_objects: Install PendingStateLogManager on StateLog", ] testpaths = ["tests"] pythonpath = ["."] DJANGO_SETTINGS_MODULE = "tests.settings" [tool.coverage.run] source = ["django_fsm_log"] [tool.coverage.report] show_missing = true django-fsm-log-5.0.2/tests/000077500000000000000000000000001513611621700154675ustar00rootroot00000000000000django-fsm-log-5.0.2/tests/__init__.py000066400000000000000000000000001513611621700175660ustar00rootroot00000000000000django-fsm-log-5.0.2/tests/admin.py000066400000000000000000000003171513611621700171320ustar00rootroot00000000000000from django.contrib import admin from django_fsm_log.admin import StateLogInline from .models import Article @admin.register(Article) class ArticleAdmin(admin.ModelAdmin): inlines = [StateLogInline] django-fsm-log-5.0.2/tests/conftest.py000066400000000000000000000017501513611621700176710ustar00rootroot00000000000000import pytest from django.contrib.auth import get_user_model from django_fsm_log.managers import PendingStateLogManager from django_fsm_log.models import StateLog from .models import Article, ArticleInteger @pytest.fixture def article(db, request, settings): if "ignore_article" in request.keywords: settings.DJANGO_FSM_LOG_IGNORED_MODELS = ["tests.models.Article"] return Article.objects.create(state="draft") @pytest.fixture def article_integer(db, request, settings): return ArticleInteger.objects.create() @pytest.fixture def article2(db): return Article.objects.create(state="draft") @pytest.fixture def user(db): User = get_user_model() return User.objects.create_user(username="jacob", password="password") @pytest.fixture(autouse=True) def pending_objects(db, request): if "pending_objects" in request.keywords: if not hasattr(StateLog, "pending_objects"): StateLog.add_to_class("pending_objects", PendingStateLogManager()) django-fsm-log-5.0.2/tests/models.py000066400000000000000000000043651513611621700173340ustar00rootroot00000000000000from django.db import models from django_fsm import FSMField, FSMIntegerField, transition from django_fsm_log.decorators import fsm_log_by, fsm_log_description class Article(models.Model): STATES = ( ("draft", "Draft"), ("submitted", "Article submitted"), ("published", "Article published"), ("temporary", "Article published (temporary)"), ("deleted", "Article deleted"), ) state = FSMField(choices=STATES, default="draft", protected=True) def __str__(self): return f"pk={self.pk}" @fsm_log_by @fsm_log_description @transition(field=state, source="draft", target="submitted") def submit(self, description=None, by=None): pass @fsm_log_by @transition(field=state, source="submitted", target="published") def publish(self, by=None): pass @fsm_log_by @transition(field=state, source="*", target="deleted") def delete(self, using=None): pass @fsm_log_by @fsm_log_description(description="Article restored") @transition(field=state, source="deleted", target="draft") def restore(self, description=None, by=None): pass @fsm_log_by @fsm_log_description(allow_inline=True, description="Article published as temporary") @transition(field=state, source="draft", target="temporary") def publish_as_temporary(self, description=None, by=None): if not isinstance(description, str): description.set("Article published (temporary)") @fsm_log_by @fsm_log_description(allow_inline=True) @transition(field=state, source="draft", target="submitted") def submit_inline_description_change(self, change_to, description=None, by=None): description.set(change_to) @fsm_log_by @transition(field=state, source="draft", target=None) def validate_draft(self, by=None): pass class ArticleInteger(models.Model): STATE_ONE = 1 STATE_TWO = 2 STATES = ( (STATE_ONE, "one"), (STATE_TWO, "two"), ) state = FSMIntegerField(choices=STATES, default=STATE_ONE) def __str__(self): return f"pk={self.pk}" @fsm_log_by @transition(field=state, source=STATE_ONE, target=STATE_TWO) def change_to_two(self, by=None): pass django-fsm-log-5.0.2/tests/settings.py000066400000000000000000000016011513611621700176770ustar00rootroot00000000000000import os BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3"), } } INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django_fsm_log", "tests", ] MIDDLEWARE = MIDDLEWARE_CLASSES = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", ] SECRET_KEY = "abc123" ROOT_URLCONF = "tests.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.contrib.auth.context_processors.auth", ], }, }, ] USE_TZ = True django-fsm-log-5.0.2/tests/test_admin.py000066400000000000000000000006721513611621700201750ustar00rootroot00000000000000from django.urls import reverse def test_state_log_inline_django2(article, admin_client, admin_user): article.submit(by=admin_user) article.publish(by=admin_user) url = reverse("admin:tests_article_change", args=(article.pk,)) response = admin_client.get(url) assert response.status_code == 200 assert f"{article} - submit".encode() in response.content assert f"{article} - publish".encode() in response.content django-fsm-log-5.0.2/tests/test_manager.py000066400000000000000000000064151513611621700205200ustar00rootroot00000000000000import pytest from django_fsm_log.models import StateLog def test_for_queryset_method_returns_only_logs_for_provided_object(article, article2): article2.submit() article.submit() article.publish() assert len(StateLog.objects.for_(article)) == 2 for log in StateLog.objects.for_(article): assert article == log.content_object @pytest.fixture def create_kwargs(user, article): return { "by": user, "state": "submitted", "transition": "submit", "content_object": article, } @pytest.mark.pending_objects def test_get_cache_key_for_object_returns_correctly_formatted_string(article): expected_result = f"StateLog:{article.__class__.__name__}:{article.pk}" result = StateLog.pending_objects._get_cache_key_for_object(article) assert result == expected_result @pytest.mark.pending_objects def test_create_pending_sets_cache_item(article, create_kwargs, mocker): mock_cache = mocker.patch("django_fsm_log.managers.cache") expected_cache_key = StateLog.pending_objects._get_cache_key_for_object(article) StateLog.pending_objects.create(**create_kwargs) cache_key = mock_cache.set.call_args_list[0][0][0] cache_object = mock_cache.set.call_args_list[0][0][1] assert cache_key == expected_cache_key assert cache_object.state == create_kwargs["state"] assert cache_object.transition == create_kwargs["transition"] assert cache_object.content_object == create_kwargs["content_object"] assert cache_object.by == create_kwargs["by"] @pytest.mark.pending_objects def test_create_returns_correct_state_log(mocker, create_kwargs): mocker.patch("django_fsm_log.managers.cache") log = StateLog.pending_objects.create(**create_kwargs) assert log.state == create_kwargs["state"] assert log.transition == create_kwargs["transition"] assert log.content_object == create_kwargs["content_object"] assert log.by == create_kwargs["by"] @pytest.mark.pending_objects def test_commit_for_object_saves_log(mocker, article, create_kwargs): mock_cache = mocker.patch("django_fsm_log.managers.cache") log = StateLog.objects.create(**create_kwargs) mock_cache.get.return_value = log StateLog.pending_objects.commit_for_object(article) persisted_log = StateLog.objects.order_by("-pk").all()[0] assert log.state == persisted_log.state assert log.transition == persisted_log.transition assert log.content_object == persisted_log.content_object assert log.by == persisted_log.by @pytest.mark.pending_objects def test_commit_for_object_deletes_pending_log_from_cache(mocker, article, create_kwargs): mock_cache = mocker.patch("django_fsm_log.managers.cache") StateLog.pending_objects.create(**create_kwargs) StateLog.pending_objects.commit_for_object(article) mock_cache.delete.assert_called_once_with(StateLog.pending_objects._get_cache_key_for_object(article)) @pytest.mark.pending_objects def test_get_for_object_calls_cache_get_with_correct_key(mocker, create_kwargs): mock_cache = mocker.patch("django_fsm_log.managers.cache") cache_key = StateLog.pending_objects._get_cache_key_for_object(create_kwargs["content_object"]) StateLog.pending_objects.get_for_object(create_kwargs["content_object"]) mock_cache.get.assert_called_once_with(cache_key) django-fsm-log-5.0.2/tests/test_model.py000066400000000000000000000102331513611621700201770ustar00rootroot00000000000000import pytest from django_fsm import TransitionNotAllowed from django_fsm_log.models import StateLog from .models import Article, ArticleInteger def test_get_available_state_transitions(article): assert len(list(article.get_available_state_transitions())) == 5 def test_get_all_state_transitions(article): assert len(list(article.get_all_state_transitions())) == 7 def test_log_created_on_transition(article): assert len(StateLog.objects.all()) == 0 article.submit() article.save() assert len(StateLog.objects.all()) == 1 def test_log_not_created_if_transition_fails(article): assert len(StateLog.objects.all()) == 0 with pytest.raises(TransitionNotAllowed): article.publish() assert len(StateLog.objects.all()) == 0 def test_log_not_created_if_target_is_none(article): assert len(StateLog.objects.all()) == 0 article.validate_draft() assert len(StateLog.objects.all()) == 0 def test_by_is_set_when_passed_into_transition(article, user): article.submit(by=user) log = StateLog.objects.all()[0] assert user == log.by with pytest.raises(AttributeError): _ = article.__django_fsm_log_attr_by def test_by_is_none_when_not_set_in_transition(article): article.submit() log = StateLog.objects.all()[0] assert log.by is None def test_description_is_set_when_passed_into_transition(article): description = "Lorem ipsum" article.submit(description=description) log = StateLog.objects.all()[0] assert description == log.description with pytest.raises(AttributeError): _ = article.__django_fsm_log_attr_description def test_description_is_none_when_not_set_in_transition(article): article.submit() log = StateLog.objects.all()[0] assert log.description is None def test_description_can_be_mutated_by_the_transition(article): description = "Sed egestas dui" article.submit_inline_description_change(change_to=description) log = StateLog.objects.all()[0] assert description == log.description with pytest.raises(AttributeError): article.__django_fsm_log_attr_description # noqa: B018 def test_default_description(article): article.delete() article.save() article.restore() article.save() log = StateLog.objects.all()[1] assert log.description == "Article restored" def test_default_description_call_priority(article): article.delete() article.save() article.restore(description="Restored because of mistake") article.save() log = StateLog.objects.all()[1] assert log.description == "Restored because of mistake" def test_default_description_inline_priority(article): article.publish_as_temporary() article.save() log = StateLog.objects.all()[0] assert log.description == "Article published (temporary)" def test_logged_state_is_new_state(article): article.submit() log = StateLog.objects.all()[0] assert log.state == "submitted" def test_logged_transition_is_name_of_transition_method(article): article.submit() log = StateLog.objects.all()[0] assert log.transition == "submit" def test_logged_content_object_is_instance_being_transitioned(article): article.submit() log = StateLog.objects.all()[0] assert log.content_object == article def test_get_display_state(article): article.submit() article.save() log = StateLog.objects.latest() article = Article.objects.get(pk=article.pk) prev_state = article.get_state_display() assert log.get_state_display() == prev_state article.publish() article.save() log = StateLog.objects.latest() article = Article.objects.get(pk=article.pk) assert log.get_state_display() == article.get_state_display() assert log.get_source_state_display() == prev_state def test_get_display_state_with_integer(article_integer): article_integer.change_to_two() article_integer.save() log = StateLog.objects.latest() article_integer = ArticleInteger.objects.get(pk=article_integer.pk) assert log.get_state_display() == article_integer.get_state_display() # only to appease code coverage assert str(article_integer) == f"pk={article_integer.pk}" django-fsm-log-5.0.2/tests/test_settings.py000066400000000000000000000011211513611621700207330ustar00rootroot00000000000000import pytest from django.conf import settings from django_fsm_log.models import StateLog pytestmark = pytest.mark.ignore_article def test_log_not_created_if_model_ignored(article): assert len(StateLog.objects.all()) == 0 article.submit() article.save() assert len(StateLog.objects.all()) == 0 def test_log_created_on_transition_when_model_not_ignored(article): settings.DJANGO_FSM_LOG_IGNORED_MODELS = ["tests.models.SomeOtherModel"] assert len(StateLog.objects.all()) == 0 article.submit() article.save() assert len(StateLog.objects.all()) == 1 django-fsm-log-5.0.2/tests/urls.py000066400000000000000000000001561513611621700170300ustar00rootroot00000000000000from django.contrib import admin from django.urls import path urlpatterns = [path("admin", admin.site.urls)] django-fsm-log-5.0.2/tox.ini000066400000000000000000000013411513611621700156370ustar00rootroot00000000000000[tox] envlist = py{310,311}-dj-4.2 py{310,311,312,313}-dj-5.0 py{310,311,312,313}-dj-5.1 py{310,311,312,313,314}-dj-5.2 py{312,313,314}-dj-6.0 py{312,313,314}-dj-main lint [gh-actions] python = 3.10: py310 3.11: py311 3.12: py312 3.13: py313 3.14: py314 [testenv] usedevelop = true commands = pytest --cov=django_fsm_log --cov=tests {posargs} setenv= DJANGO_SETTINGS_MODULE = tests.settings deps = dj-4.2: Django~=4.2 dj-5.0: Django~=5.0 dj-5.1: Django~=5.1 dj-5.2: Django~=5.2 dj-6.0: Django~=6.0 dj-main: https://github.com/django/django/archive/main.tar.gz [testenv:lint] basepython = python3 deps = ruff commands = ruff check ruff format --check