diff --git a/.gitignore b/.gitignore index b2d05d8..24bb1ec 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ dmypy.json # sqlite test.db +*.sqlite3 + +tests/testapp/migrations/ diff --git a/README.md b/README.md index e5febd6..61aeabd 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,68 @@ ConcurrentTransitionMixin to cause a rollback of all the changes that have been executed in an inconsistent (out of sync) state, thus practically negating their effect. +## Admin Integration + +1. Make sure `django_fsm` is in your `INSTALLED_APPS` settings: + +``` python +INSTALLED_APPS = ( + ... + 'django_fsm', + ... +) +``` + +NB: If you're migrating from [django-fsm-admin](https://github.com/gadventures/django-fsm-admin) (or any alternative), make sure it's not installed anymore to avoid installing the old django-fsm. + + +2. In your admin.py file, use FSMAdminMixin to add behaviour to your ModelAdmin. FSMAdminMixin should be before ModelAdmin, the order is important. + +``` python +from django_fsm.admin import FSMAdminMixin + +@admin.register(AdminBlogPost) +class MyAdmin(FSMAdminMixin, admin.ModelAdmin): + fsm_field = ['my_fsm_field'] + ... +``` + +3. You can customize the label by adding ``custom={"label": "My awesome transition"}`` to the transition decorator + +``` python +@transition( + field='state', + source=['startstate'], + target='finalstate', + custom={"label": False}, +) +def do_something(self, param): + ... +``` + +4. By adding ``custom={"admin": False}`` to the transition decorator, one can disallow a transition to show up in the admin interface. + +``` python + @transition( + field='state', + source=['startstate'], + target='finalstate', + custom={"admin": False}, + ) + def do_something(self, param): + # will not add a button "Do Something" to your admin model interface +``` + +By adding `FSM_ADMIN_FORCE_PERMIT = True` to your configuration settings (or `default_disallow_transition = False` to your admin), the above restriction becomes the default. +Then one must explicitly allow that a transition method shows up in the admin interface. + +``` python +@admin.register(AdminBlogPost) +class MyAdmin(FSMAdminMixin, admin.ModelAdmin): + default_disallow_transition = False + ... +``` + ## Drawing transitions Renders a graphical overview of your models states transitions @@ -436,12 +498,6 @@ $ ./manage.py graph_transitions -e transition_1,transition_2 myapp.Blog ## Extensions -You may also take a look at django-fsm-2-admin project containing a mixin -and template tags to integrate django-fsm-2 state transitions into the -django admin. - - - Transition logging support could be achieved with help of django-fsm-log package diff --git a/django_fsm/admin.py b/django_fsm/admin.py new file mode 100644 index 0000000..183b7cb --- /dev/null +++ b/django_fsm/admin.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from typing import Any + +from django.conf import settings +from django.contrib import messages +from django.contrib.admin.options import BaseModelAdmin +from django.contrib.admin.templatetags.admin_urls import add_preserved_filters +from django.core.exceptions import FieldDoesNotExist +from django.http import HttpRequest +from django.http import HttpResponse +from django.http import HttpResponseRedirect +from django.utils.translation import gettext_lazy as _ + +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: + fsm_field: str + block_label: str + available_transitions: list[fsm.Transition] + + +class FSMAdminMixin(BaseModelAdmin): + change_form_template: str = "django_fsm/fsm_admin_change_form.html" + + fsm_fields: list[str] = [] + fsm_transition_success_msg = _("FSM transition '{transition_name}' succeeded.") + fsm_transition_error_msg = _("FSM transition '{transition_name}' failed: {error}.") + fsm_transition_not_allowed_msg = _("FSM transition '{transition_name}' is not allowed.") + fsm_transition_not_valid_msg = _("FSM transition '{transition_name}' is not a valid.") + fsm_context_key = "fsm_object_transitions" + fsm_post_param = "_fsm_transition_to" + default_disallow_transition = not getattr(settings, "FSM_ADMIN_FORCE_PERMIT", False) + + def get_fsm_field_instance(self, fsm_field_name: str) -> fsm.FSMField | None: + try: + return self.model._meta.get_field(fsm_field_name) + except FieldDoesNotExist: + return None + + def get_readonly_fields(self, request: HttpRequest, obj: Any = None) -> tuple[str]: + read_only_fields = super().get_readonly_fields(request, obj) + + for fsm_field_name in self.fsm_fields: + if fsm_field_name in read_only_fields: + continue + field = self.get_fsm_field_instance(fsm_field_name=fsm_field_name) + if field and getattr(field, "protected", False): + read_only_fields += (fsm_field_name,) + + return read_only_fields + + @staticmethod + def get_fsm_block_label(fsm_field_name: str) -> str: + return f"Transition ({fsm_field_name})" + + def get_fsm_object_transitions(self, request: HttpRequest, obj: Any) -> list[FSMObjectTransition]: + fsm_object_transitions = [] + + for field_name in sorted(self.fsm_fields): + if func := getattr(obj, f"get_available_user_{field_name}_transitions"): + fsm_object_transitions.append( # noqa: PERF401 + FSMObjectTransition( + fsm_field=field_name, + block_label=self.get_fsm_block_label(fsm_field_name=field_name), + available_transitions=[ + t for t in func(user=request.user) if t.custom.get("admin", self.default_disallow_transition) + ], + ) + ) + + return fsm_object_transitions + + def change_view( + self, + request: HttpRequest, + object_id: str, + form_url: str = "", + extra_context: dict[str, Any] | None = None, + ) -> HttpResponse: + _context = extra_context or {} + _context[self.fsm_context_key] = self.get_fsm_object_transitions( + request=request, + obj=self.get_object(request=request, object_id=object_id), + ) + + return super().change_view( + request=request, + object_id=object_id, + form_url=form_url, + extra_context=_context, + ) + + def get_fsm_redirect_url(self, request: HttpRequest, obj: Any) -> str: + return request.path + + def get_fsm_response(self, request: HttpRequest, obj: Any) -> HttpResponse: + redirect_url = self.get_fsm_redirect_url(request=request, obj=obj) + redirect_url = add_preserved_filters( + context={ + "preserved_filters": self.get_preserved_filters(request), + "opts": self.model._meta, + }, + url=redirect_url, + ) + return HttpResponseRedirect(redirect_to=redirect_url) + + def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse: + if self.fsm_post_param in request.POST: + try: + transition_name = request.POST[self.fsm_post_param] + transition_func = getattr(obj, transition_name) + except AttributeError: + self.message_user( + request=request, + message=self.fsm_transition_not_valid_msg.format( + transition_name=transition_name, + ), + level=messages.ERROR, + ) + return self.get_fsm_response( + request=request, + obj=obj, + ) + + try: + 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, + message=self.fsm_transition_not_allowed_msg.format( + transition_name=transition_name, + ), + level=messages.ERROR, + ) + except fsm.ConcurrentTransition as err: + self.message_user( + request=request, + message=self.fsm_transition_error_msg.format(transition_name=transition_name, error=str(err)), + level=messages.ERROR, + ) + else: + obj.save() + self.message_user( + request=request, + message=self.fsm_transition_success_msg.format( + transition_name=transition_name, + ), + level=messages.INFO, + ) + + return self.get_fsm_response( + request=request, + obj=obj, + ) + + return super().response_change(request=request, obj=obj) diff --git a/django_fsm/templates/django_fsm/fsm_admin_change_form.html b/django_fsm/templates/django_fsm/fsm_admin_change_form.html new file mode 100644 index 0000000..7a33d80 --- /dev/null +++ b/django_fsm/templates/django_fsm/fsm_admin_change_form.html @@ -0,0 +1,15 @@ +{% extends 'admin/change_form.html' %} + +{% block submit_buttons_bottom %} + + {% for fsm_object_transition in fsm_object_transitions %} +
+ + {% for transition in fsm_object_transition.available_transitions %} + + {% endfor %} +
+ {% endfor %} + + {{ block.super }} +{% endblock %} diff --git a/poetry.lock b/poetry.lock index 7d11658..c651f43 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 = "092f3ae7c4a31bb2f69f516ae5a635f67668e6f29e4c35257422bdaf2c9615ce" +content-hash = "8d45c2549f33bdb28ec900ecb897c250eb25ac1aae66258947f5739d4111419a" diff --git a/pyproject.toml b/pyproject.toml index cdc6195..2bb87ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ pre-commit = "*" pytest = "*" pytest-cov = "*" 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..5d19981 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.ExceptTargetTransition", + "tests.testapp.tests.test_key_field.FKBlogPost", +) diff --git a/tests/testapp/admin.py b/tests/testapp/admin.py new file mode 100644 index 0000000..05b1dcc --- /dev/null +++ b/tests/testapp/admin.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from django.contrib import admin +from django_fsm_log.admin import StateLogInline + +from django_fsm.admin import FSMAdminMixin + +from .models import AdminBlogPost + + +@admin.register(AdminBlogPost) +class AdminBlogPostAdmin(FSMAdminMixin, admin.ModelAdmin): + list_display = ( + "id", + "title", + "state", + "step", + ) + + fsm_fields = [ + "state", + "step", + ] + + inlines = [StateLogInline] diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 0d5d216..af6744a 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 GET_STATE from django_fsm import RETURN_VALUE @@ -247,3 +249,121 @@ def steal(self): @transition(field=state, source="*", target=BlogPostState.MODERATED) def moderate(self): pass + + +class AdminBlogPostState(models.TextChoices): + CREATED = "created", "Created" + REVIEWED = "reviewed", "Reviewed" + PUBLISHED = "published", "Published" + HIDDEN = "hidden", "Hidden" + + +class AdminBlogPostStep(models.TextChoices): + STEP_1 = "step1", "Step one" + STEP_2 = "step2", "Step two" + STEP_3 = "step3", "Step three" + + +class AdminBlogPost(models.Model): + title = models.CharField(max_length=50) + + state = FSMField( + choices=AdminBlogPostState.choices, + default=AdminBlogPostState.CREATED, + protected=True, + ) + + step = FSMField( + choices=AdminBlogPostStep.choices, + default=AdminBlogPostStep.STEP_1, + protected=False, + ) + + # state transitions + + @fsm_log_by + @fsm_log_description + @transition( + field=state, + source="*", + target=AdminBlogPostState.HIDDEN, + custom={ + "admin": False, + }, + ) + 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, by=None, description=None): + pass + + @fsm_log_by + @fsm_log_description + @transition( + field=state, + source=[ + AdminBlogPostState.REVIEWED, + AdminBlogPostState.HIDDEN, + ], + target=AdminBlogPostState.PUBLISHED, + ) + def publish(self, by=None, description=None): + pass + + @fsm_log_by + @fsm_log_description + @transition( + field=state, + source=[ + AdminBlogPostState.REVIEWED, + AdminBlogPostState.PUBLISHED, + ], + target=AdminBlogPostState.HIDDEN, + ) + def hide(self, by=None, description=None): + pass + + # step transitions + + @fsm_log_by + @fsm_log_description + @transition( + field=step, + source=[AdminBlogPostStep.STEP_1], + target=AdminBlogPostStep.STEP_2, + custom={ + "label": "Go to Step 2", + }, + ) + 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, by=None, description=None): + pass + + @fsm_log_by + @fsm_log_description + @transition( + field=step, + source=[ + AdminBlogPostStep.STEP_2, + AdminBlogPostStep.STEP_3, + ], + target=AdminBlogPostStep.STEP_1, + ) + 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 new file mode 100644 index 0000000..dac5797 --- /dev/null +++ b/tests/testapp/tests/test_admin.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from unittest.mock import patch + +from django.contrib import messages +from django.contrib.admin.sites import AdminSite +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 +from tests.testapp.admin import AdminBlogPostAdmin +from tests.testapp.models import AdminBlogPost +from tests.testapp.models import AdminBlogPostState + + +class ModelAdminTest(TestCase): + @classmethod + def setUpTestData(cls): + blog_post = AdminBlogPost.objects.create(title="Article name") + blog_post.moderate() + blog_post.save() + cls.blog_post = blog_post + + cls.request = RequestFactory().get(path="/path") + cls.request.user = get_user_model().objects.create_user(username="jacob", password="password", is_staff=True) # noqa: S106 + + def setUp(self): + self.model_admin = AdminBlogPostAdmin(AdminBlogPost, AdminSite()) + + def test_get_fsm_field_instance(self): + assert self.model_admin.get_fsm_field_instance(fsm_field_name="dummy_name") is None + fsm_field = self.model_admin.get_fsm_field_instance(fsm_field_name="state") + assert fsm_field is not None + assert isinstance(fsm_field, FSMField) + + def test_readonly_fields(self): + assert self.model_admin.get_readonly_fields(request=self.request) == ("state",) + + def test_get_fsm_block_label(self): + assert self.model_admin.get_fsm_block_label(fsm_field_name="MyField") == "Transition (MyField)" + + def test_get_fsm_object_transitions(self): + fsm_object_transitions = self.model_admin.get_fsm_object_transitions(request=self.request, obj=self.blog_post) + + assert len(fsm_object_transitions) == 2 # noqa: PLR2004 + state_transition, step_transition = fsm_object_transitions + + assert state_transition.fsm_field == "state" + assert state_transition.block_label == "Transition (state)" + assert sorted([t.name for t in state_transition.available_transitions]) == [ + "hide", + "publish", + ] + + assert step_transition.fsm_field == "step" + assert step_transition.block_label == "Transition (step)" + assert sorted([t.name for t in step_transition.available_transitions]) == ["step_two"] + + def test_get_fsm_redirect_url(self): + assert self.model_admin.get_fsm_redirect_url(request=self.request, obj=None) == "/path" + + @patch("django.contrib.admin.ModelAdmin.change_view") + @patch("django_fsm.admin.FSMAdminMixin.get_fsm_object_transitions") + def test_change_view_context( + self, + mock_get_fsm_object_transitions, + mock_super_change_view, + ): + mock_get_fsm_object_transitions.return_value = "object transitions" + + self.model_admin.change_view( + request=self.request, + form_url="/test", + object_id=self.blog_post.pk, + extra_context={ + "existing_context": "existing context", + }, + ) + + mock_get_fsm_object_transitions.assert_called_once_with( + request=self.request, + obj=self.blog_post, + ) + + mock_super_change_view.assert_called_once_with( + request=self.request, + object_id=self.blog_post.pk, + form_url="/test", + extra_context={ + "existing_context": "existing context", + "fsm_object_transitions": "object transitions", + }, + ) + + +@patch("django.contrib.admin.options.ModelAdmin.message_user") +class ResponseChangeTest(TestCase): + def setUp(self): + self.model_admin = AdminBlogPostAdmin(AdminBlogPost, AdminSite()) + + @classmethod + 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"}, + ) + + blog_post = AdminBlogPost.objects.create(title="Article name") + assert blog_post.state == AdminBlogPostState.CREATED + + self.model_admin.response_change( + request=request, + obj=blog_post, + ) + + mock_message_user.assert_called_once_with( + request=request, + message="FSM transition 'unknown_transition' is not a valid.", + level=messages.ERROR, + ) + + 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"}, + ) + request.user = self.user + + blog_post = AdminBlogPost.objects.create(title="Article name") + assert blog_post.state == AdminBlogPostState.CREATED + + self.model_admin.response_change( + request=request, + obj=blog_post, + ) + + mock_message_user.assert_called_once_with( + request=request, + message="FSM transition 'moderate' succeeded.", + level=messages.INFO, + ) + + 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"}, + ) + request.user = self.user + + blog_post = AdminBlogPost.objects.create(title="Article name") + assert blog_post.state == AdminBlogPostState.CREATED + + self.model_admin.response_change( + request=request, + obj=blog_post, + ) + + mock_message_user.assert_called_once_with( + request=request, + message="FSM transition 'publish' is not allowed.", + level=messages.ERROR, + ) + + 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"}, + ) + request.user = self.user + + blog_post = AdminBlogPost.objects.create(title="Article name") + assert blog_post.state == AdminBlogPostState.CREATED + + with patch( + "tests.testapp.models.AdminBlogPost.moderate", + side_effect=ConcurrentTransition("error message"), + ): + self.model_admin.response_change( + request=request, + obj=blog_post, + ) + + mock_message_user.assert_called_once_with( + request=request, + message="FSM transition 'moderate' failed: error message.", + level=messages.ERROR, + ) + + 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 63baedb..7b2f412 100644 --- a/tests/testapp/tests/test_transition_all_except_target.py +++ b/tests/testapp/tests/test_transition_all_except_target.py @@ -20,7 +20,7 @@ def remove(self): pass -class Test(TestCase): +class TestExceptTargetTransition(TestCase): def setUp(self): self.model = ExceptTargetTransition() diff --git a/tox.ini b/tox.ini index ba66b74..a5ad5b7 100644 --- a/tox.ini +++ b/tox.ini @@ -16,6 +16,7 @@ deps = dj52: Django==5.2 djmain: https://github.com/django/django/tarball/main + django-fsm-log django-guardian graphviz pep8