diff --git a/django_fsm/admin.py b/django_fsm/admin.py index 7514425..183b7cb 100644 --- a/django_fsm/admin.py +++ b/django_fsm/admin.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from functools import partial from typing import Any from django.conf import settings @@ -15,6 +16,13 @@ import django_fsm as fsm +try: + import django_fsm_log # noqa: F401 +except ModuleNotFoundError: + FSM_LOG_ENABLED = False +else: + FSM_LOG_ENABLED = True + @dataclass class FSMObjectTransition: @@ -127,7 +135,20 @@ def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse: ) try: - transition_func() + if FSM_LOG_ENABLED: + for fn in [ + partial(transition_func, request=request, by=request.user), + partial(transition_func, by=request.user), + transition_func, + ]: + try: + fn() + except TypeError: # noqa: PERF203 + pass + else: + break + else: + transition_func() except fsm.TransitionNotAllowed: self.message_user( request=request, diff --git a/poetry.lock b/poetry.lock index 4dd2469..76710fa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -175,6 +175,54 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django-appconf" +version = "1.0.6" +description = "A helper class for handling configuration defaults of packaged apps gracefully." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "django-appconf-1.0.6.tar.gz", hash = "sha256:cfe87ea827c4ee04b9a70fab90b86d704cb02f2981f89da8423cb0fabf88efbf"}, + {file = "django_appconf-1.0.6-py3-none-any.whl", hash = "sha256:c3ae442fba1ff7ec830412c5184b17169a7a1e71cf0864a4c3f93cf4c98a1993"}, +] + +[package.dependencies] +django = "*" + +[[package]] +name = "django-fsm" +version = "3.0.0" +description = "Django friendly finite state machine support." +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "django-fsm-3.0.0.tar.gz", hash = "sha256:0112bcac573ad14051cf8ebe73bf296b6d5409f093e5f1677eb16e2196e263b3"}, + {file = "django_fsm-3.0.0-py2.py3-none-any.whl", hash = "sha256:fa28f84f47eae7ce9247585ac6c1895e4ada08efff93fb243a59e9ff77b2d4ec"}, +] + +[[package]] +name = "django-fsm-log" +version = "3.1.0" +description = "Transition's persistence for django-fsm" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "django-fsm-log-3.1.0.tar.gz", hash = "sha256:9ef766f5e6d7c573d1953cf91df73538a611373cc1ef97488eff19a3f71d6ed6"}, + {file = "django_fsm_log-3.1.0-py3-none-any.whl", hash = "sha256:ac4394f22659e7fb8e5ac42d1cc075490cd5a2af37202377ab2a1cb221c5f3db"}, +] + +[package.dependencies] +django = ">=1.8" +django-appconf = "*" +django-fsm = ">=2" + +[package.extras] +docs = ["myst-parser", "sphinx", "sphinx-rtd-theme"] +testing = ["pytest", "pytest-cov", "pytest-django", "pytest-mock"] + [[package]] name = "django-guardian" version = "2.4.0" @@ -533,4 +581,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = "^3.8" -content-hash = "f46b8a5b9ffa4e59610be81ead75f64886b8138740c3538a88bf5912b140a652" +content-hash = "e53ebee9eba0675216211fa93dda8b3e5704334f6beb218d8b080419b8315b50" diff --git a/pyproject.toml b/pyproject.toml index 557d507..fddbe85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ pre-commit = "*" pytest = "*" pytest-cov = "^4.1.0" pytest-django = "*" +django_fsm_log = "*" [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "tests.settings" diff --git a/tests/settings.py b/tests/settings.py index 295ae84..334ae99 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -43,6 +43,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django_fsm_log", "guardian", *PROJECT_APPS, ] @@ -135,3 +136,35 @@ # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +# Django FSM-log settings +DJANGO_FSM_LOG_IGNORED_MODELS = ( + # "tests.testapp.models.AdminBlogPost", + "tests.testapp.models.Application", + "tests.testapp.models.BlogPost", + "tests.testapp.models.DbState", + "tests.testapp.models.FKApplication", + "tests.testapp.tests.SimpleBlogPost", + "tests.testapp.tests.test_abstract_inheritance.BaseAbstractModel", + "tests.testapp.tests.test_abstract_inheritance.InheritedFromAbstractModel", + "tests.testapp.tests.test_access_deferred_fsm_field.DeferrableModel", + "tests.testapp.tests.test_basic_transitions.SimpleBlogPost", + "tests.testapp.tests.test_conditions.BlogPostWithConditions", + "tests.testapp.tests.test_custom_data.BlogPostWithCustomData", + "tests.testapp.tests.test_exception_transitions.ExceptionalBlogPost", + "tests.testapp.tests.test_graph_transitions.VisualBlogPost", + "tests.testapp.tests.test_integer_field.BlogPostWithIntegerField", + "tests.testapp.tests.test_lock_mixin.ExtendedBlogPost", + "tests.testapp.tests.test_lock_mixin.LockedBlogPost", + "tests.testapp.tests.test_mixin_support.MixinSupportTestModel", + "tests.testapp.tests.test_multi_resultstate.MultiResultTest", + "tests.testapp.tests.test_multidecorators.MultiDecoratedModel", + "tests.testapp.tests.test_protected_field.ProtectedAccessModel", + "tests.testapp.tests.test_protected_fields.RefreshableProtectedAccessModel", + "tests.testapp.tests.test_proxy_inheritance.InheritedModel", + "tests.testapp.tests.test_state_transitions.Caterpillar", + "tests.testapp.tests.test_string_field_parameter.BlogPostWithStringField", + "tests.testapp.tests.test_transition_all_except_target.ExceptTargetTransitionShortcut", + "tests.testapp.tests.test_key_field.FKBlogPost", +) diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py index f4566ca..05b1dcc 100644 --- a/tests/testapp/admin.py +++ b/tests/testapp/admin.py @@ -1,6 +1,7 @@ from __future__ import annotations from django.contrib import admin +from django_fsm_log.admin import StateLogInline from django_fsm.admin import FSMAdminMixin @@ -20,3 +21,5 @@ class AdminBlogPostAdmin(FSMAdminMixin, admin.ModelAdmin): "state", "step", ] + + inlines = [StateLogInline] diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 4fc1949..0b025c4 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -1,6 +1,8 @@ from __future__ import annotations from django.db import models +from django_fsm_log.decorators import fsm_log_by +from django_fsm_log.decorators import fsm_log_description from django_fsm import FSMField from django_fsm import FSMKeyField @@ -172,6 +174,8 @@ class AdminBlogPost(models.Model): # state transitions + @fsm_log_by + @fsm_log_description @transition( field=state, source="*", @@ -180,17 +184,21 @@ class AdminBlogPost(models.Model): "admin": False, }, ) - def secret_transition(self): + def secret_transition(self, by=None, description=None): pass + @fsm_log_by + @fsm_log_description @transition( field=state, source=[AdminBlogPostState.CREATED], target=AdminBlogPostState.REVIEWED, ) - def moderate(self): + def moderate(self, by=None, description=None): pass + @fsm_log_by + @fsm_log_description @transition( field=state, source=[ @@ -199,9 +207,11 @@ def moderate(self): ], target=AdminBlogPostState.PUBLISHED, ) - def publish(self): + def publish(self, by=None, description=None): pass + @fsm_log_by + @fsm_log_description @transition( field=state, source=[ @@ -210,11 +220,13 @@ def publish(self): ], target=AdminBlogPostState.HIDDEN, ) - def hide(self): + def hide(self, by=None, description=None): pass # step transitions + @fsm_log_by + @fsm_log_description @transition( field=step, source=[AdminBlogPostStep.STEP_1], @@ -223,17 +235,21 @@ def hide(self): "label": "Go to Step 2", }, ) - def step_two(self): + def step_two(self, by=None, description=None): pass + @fsm_log_by + @fsm_log_description @transition( field=step, source=[AdminBlogPostStep.STEP_2], target=AdminBlogPostStep.STEP_3, ) - def step_three(self): + def step_three(self, by=None, description=None): pass + @fsm_log_by + @fsm_log_description @transition( field=step, source=[ @@ -242,5 +258,5 @@ def step_three(self): ], target=AdminBlogPostStep.STEP_1, ) - def step_reset(self): + def step_reset(self, by=None, description=None): pass diff --git a/tests/testapp/tests/test_admin.py b/tests/testapp/tests/test_admin.py index 423f5c7..dac5797 100644 --- a/tests/testapp/tests/test_admin.py +++ b/tests/testapp/tests/test_admin.py @@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.test.client import RequestFactory +from django_fsm_log.models import StateLog from django_fsm import ConcurrentTransition from django_fsm import FSMField @@ -105,6 +106,7 @@ def setUpTestData(cls): cls.user = get_user_model().objects.create_user(username="jacob", password="password", is_staff=True) # noqa: S106 def test_unknown_transition(self, mock_message_user): + assert StateLog.objects.count() == 0 request = RequestFactory().post( path="/", data={"_fsm_transition_to": "unknown_transition"}, @@ -126,8 +128,10 @@ def test_unknown_transition(self, mock_message_user): updated_blog_post = AdminBlogPost.objects.get(pk=blog_post.pk) assert updated_blog_post.state == AdminBlogPostState.CREATED + assert StateLog.objects.count() == 0 def test_transition_applied(self, mock_message_user): + assert StateLog.objects.count() == 0 request = RequestFactory().post( path="/", data={"_fsm_transition_to": "moderate"}, @@ -150,8 +154,11 @@ def test_transition_applied(self, mock_message_user): updated_blog_post = AdminBlogPost.objects.get(pk=blog_post.pk) assert updated_blog_post.state == AdminBlogPostState.REVIEWED + assert StateLog.objects.count() == 1 + assert StateLog.objects.get().by == self.user def test_transition_not_allowed_exception(self, mock_message_user): + assert StateLog.objects.count() == 0 request = RequestFactory().post( path="/", data={"_fsm_transition_to": "publish"}, @@ -174,8 +181,10 @@ def test_transition_not_allowed_exception(self, mock_message_user): updated_blog_post = AdminBlogPost.objects.get(pk=blog_post.pk) assert updated_blog_post.state == AdminBlogPostState.CREATED + assert StateLog.objects.count() == 0 def test_concurrent_transition_exception(self, mock_message_user): + assert StateLog.objects.count() == 0 request = RequestFactory().post( path="/", data={"_fsm_transition_to": "moderate"}, @@ -202,3 +211,4 @@ def test_concurrent_transition_exception(self, mock_message_user): updated_blog_post = AdminBlogPost.objects.get(pk=blog_post.pk) assert updated_blog_post.state == AdminBlogPostState.CREATED + assert StateLog.objects.count() == 0 diff --git a/tests/testapp/tests/test_transition_all_except_target.py b/tests/testapp/tests/test_transition_all_except_target.py index be36204..045c627 100644 --- a/tests/testapp/tests/test_transition_all_except_target.py +++ b/tests/testapp/tests/test_transition_all_except_target.py @@ -8,7 +8,7 @@ from django_fsm import transition -class TestExceptTargetTransitionShortcut(models.Model): +class ExceptTargetTransitionShortcut(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published") @@ -20,9 +20,9 @@ def remove(self): pass -class Test(TestCase): +class TestExceptTargetTransitionShortcut(TestCase): def setUp(self): - self.model = TestExceptTargetTransitionShortcut() + self.model = ExceptTargetTransitionShortcut() def test_usecase(self): assert self.model.state == "new" diff --git a/tox.ini b/tox.ini index 51da166..5227862 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,7 @@ deps = dj51: Django==5.1 dj52: Django==5.2 + django-fsm-log django-guardian graphviz pep8