diff --git a/django_fsm/__init__.py b/django_fsm/__init__.py index 77c3234..8d53000 100644 --- a/django_fsm/__init__.py +++ b/django_fsm/__init__.py @@ -339,12 +339,8 @@ def get_all_transitions(self, instance_cls): """ Returns [(source, target, name, method)] for all field transitions """ - transitions = self.transitions[instance_cls] - - for transition in transitions.values(): - meta = transition._django_fsm - - yield from meta.transitions.values() + for transition in self.transitions[instance_cls].values(): + yield from transition._django_fsm.transitions.values() def contribute_to_class(self, cls, name, **kwargs): self.base_cls = cls diff --git a/django_fsm/admin.py b/django_fsm/admin.py index 183b7cb..c6b0877 100644 --- a/django_fsm/admin.py +++ b/django_fsm/admin.py @@ -1,21 +1,31 @@ from __future__ import annotations +import typing from dataclasses import dataclass from functools import partial -from typing import Any from django.conf import settings +from django.contrib import admin 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 HttpResponseBadRequest from django.http import HttpResponseRedirect +from django.shortcuts import redirect +from django.shortcuts import render +from django.urls import path +from django.urls import reverse +from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ import django_fsm as fsm +if typing.TYPE_CHECKING: + from django.forms import Form + try: import django_fsm_log # noqa: F401 except ModuleNotFoundError: @@ -27,7 +37,6 @@ @dataclass class FSMObjectTransition: fsm_field: str - block_label: str available_transitions: list[fsm.Transition] @@ -42,55 +51,48 @@ class FSMAdminMixin(BaseModelAdmin): fsm_context_key = "fsm_object_transitions" fsm_post_param = "_fsm_transition_to" default_disallow_transition = not getattr(settings, "FSM_ADMIN_FORCE_PERMIT", False) + fsm_transition_form_template = "django_fsm/fsm_admin_transition_form.html" - 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_urls(self): + meta = self.model._meta + return [ + path( + "/transition//", + self.admin_site.admin_view(self.fsm_transition_view), + name=f"{meta.app_label}_{meta.model_name}_transition", + ), + *super().get_urls(), + ] + + def get_readonly_fields(self, request: HttpRequest, obj: typing.Any = None) -> tuple[str]: + """Add FSM fields to readonly fields if they are protected.""" - 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,) + try: + field = self.model._meta.get_field(fsm_field_name) + except FieldDoesNotExist: + pass + else: + if 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, + extra_context: dict[str, typing.Any] | None = None, ) -> HttpResponse: + """Override the change view to add FSM transitions to the context.""" + _context = extra_context or {} - _context[self.fsm_context_key] = self.get_fsm_object_transitions( + _context[self.fsm_context_key] = self._get_fsm_object_transitions( request=request, obj=self.get_object(request=request, object_id=object_id), ) @@ -102,24 +104,19 @@ def change_view( 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 _get_fsm_object_transitions(self, request: HttpRequest, obj: typing.Any) -> list[FSMObjectTransition]: + for field_name in sorted(self.fsm_fields): + if func := getattr(obj, f"get_available_user_{field_name}_transitions"): + yield FSMObjectTransition( + fsm_field=field_name, + available_transitions=[ + t for t in func(user=request.user) if t.custom.get("admin", self.default_disallow_transition) + ], + ) - def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse: - if self.fsm_post_param in request.POST: + def response_change(self, request: HttpRequest, obj: typing.Any) -> HttpResponse: # noqa: C901 + if transition_name := request.POST.get(self.fsm_post_param): try: - transition_name = request.POST[self.fsm_post_param] transition_func = getattr(obj, transition_name) except AttributeError: self.message_user( @@ -129,9 +126,18 @@ def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse: ), level=messages.ERROR, ) - return self.get_fsm_response( - request=request, - obj=obj, + return self.get_fsm_response(request=request, obj=obj) + + # NOTE: if a form is defined in the transition.custom, we redirect to the form view + if self.get_fsm_transition_custom(instance=obj, transition_func=transition_func).get("form"): + return redirect( + reverse( + f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_transition", + kwargs={ + "object_id": obj.pk, + "transition_name": transition_name, + }, + ) ) try: @@ -173,9 +179,102 @@ def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse: level=messages.INFO, ) - return self.get_fsm_response( - request=request, - obj=obj, - ) + return self.get_fsm_response(request=request, obj=obj) return super().response_change(request=request, obj=obj) + + def get_fsm_response(self, request: HttpRequest, obj: typing.Any) -> HttpResponse: + redirect_url = add_preserved_filters( + context={ + "preserved_filters": self.get_preserved_filters(request), + "opts": self.model._meta, + }, + url=self.get_fsm_redirect_url(request=request, obj=obj), + ) + return HttpResponseRedirect(redirect_to=redirect_url) + + def get_fsm_redirect_url(self, request: HttpRequest, obj: typing.Any) -> str: + return request.path + + def get_fsm_transition_custom(self, instance, transition_func): + """Helper function to get custom attributes for the current transition""" + return getattr(self.get_fsm_transition(instance, transition_func), "custom", {}) + + def get_fsm_transition(self, instance, transition_func) -> fsm.Transition | None: + """ + Extract custom attributes from a transition function for the current state. + """ + if not hasattr(transition_func, "_django_fsm"): + return None + + fsm_meta = transition_func._django_fsm + current_state = fsm_meta.field.get_state(instance) + return fsm_meta.get_transition(current_state) + + def get_fsm_transition_form(self, transition: fsm.Transition) -> Form | None: + form = transition.custom.get("form") + if isinstance(form, str): + form = import_string(form) + return form + + def fsm_transition_view(self, request, *args, **kwargs): + transition_name = kwargs["transition_name"] + obj = self.get_object(request, kwargs["object_id"]) + + transition_method = getattr(obj, transition_name) + if not hasattr(transition_method, "_django_fsm"): + return HttpResponseBadRequest(f"{transition_name} is not a transition method") + + transitions = transition_method._django_fsm.transitions + if isinstance(transitions, dict): + transitions = list(transitions.values()) + transition = transitions[0] + + if TransitionForm := self.get_fsm_transition_form(transition): + if request.method == "POST": + transition_form = TransitionForm(data=request.POST, instance=obj) + if transition_form.is_valid(): + transition_method(**transition_form.cleaned_data) + obj.save() + else: + return render( + request, + self.fsm_transition_form_template, + context=admin.site.each_context(request) + | { + "opts": self.model._meta, + "original": obj, + "transition": transition, + "transition_form": transition_form, + }, + ) + else: + transition_form = TransitionForm(instance=obj) + return render( + request, + self.fsm_transition_form_template, + context=admin.site.each_context(request) + | { + "opts": self.model._meta, + "original": obj, + "transition": transition, + "transition_form": transition_form, + }, + ) + else: + try: + transition_method() + except fsm.TransitionNotAllowed: + self.message_user( + request, + self.fsm_transition_not_allowed_msg.format(transition_name=transition_name), + messages.ERROR, + ) + else: + obj.save() + self.message_user( + request, + self.fsm_transition_success_msg.format(transition_name=transition_name), + messages.SUCCESS, + ) + return redirect(f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_change", object_id=obj.id) diff --git a/django_fsm/templates/django_fsm/fsm_admin_change_form.html b/django_fsm/templates/django_fsm/fsm_admin_change_form.html index 7a33d80..78df1da 100644 --- a/django_fsm/templates/django_fsm/fsm_admin_change_form.html +++ b/django_fsm/templates/django_fsm/fsm_admin_change_form.html @@ -4,9 +4,11 @@ {% for fsm_object_transition in fsm_object_transitions %}
- + {% for transition in fsm_object_transition.available_transitions %} - + {% endfor %}
{% endfor %} diff --git a/django_fsm/templates/django_fsm/fsm_admin_transition_form.html b/django_fsm/templates/django_fsm/fsm_admin_transition_form.html new file mode 100644 index 0000000..1a33046 --- /dev/null +++ b/django_fsm/templates/django_fsm/fsm_admin_transition_form.html @@ -0,0 +1,24 @@ +{% extends 'admin/change_form.html' %} + +{% load i18n admin_urls static admin_modify %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

{{ transition.custom.short_description|default:transition.name }}

+ +
+ {% csrf_token %} + {{ transition_form.as_p }} + +
+ +{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 2bb87ea..a02a3f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ extend-ignore = [ "COM812", # This rule may cause conflicts when used with the formatter "D", # pydocstyle "DOC", # pydoclint + "N806", # Variable in function should be lowercase "B", "PTH", "ANN", # Missing type annotation diff --git a/tests/testapp/admin_forms.py b/tests/testapp/admin_forms.py new file mode 100644 index 0000000..df18808 --- /dev/null +++ b/tests/testapp/admin_forms.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from django import forms + +from .models import AdminBlogPost + + +class AdminBlogPostRenameForm(forms.ModelForm): + """ + This form is used to test the admin form renaming functionality. + It should not be used in production. + """ + + class Meta: + model = AdminBlogPost + fields = ["title"] # Do not try to update the state field, especially if it's "protected" in the model. diff --git a/tests/testapp/models.py b/tests/testapp/models.py index af6744a..4a96efe 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -279,7 +279,8 @@ class AdminBlogPost(models.Model): protected=False, ) - # state transitions + def __str__(self): + return f"{self.title} ({self.state})" @fsm_log_by @fsm_log_description @@ -298,7 +299,7 @@ def secret_transition(self, by=None, description=None): @fsm_log_description @transition( field=state, - source=[AdminBlogPostState.CREATED], + source=AdminBlogPostState.CREATED, target=AdminBlogPostState.REVIEWED, ) def moderate(self, by=None, description=None): @@ -330,6 +331,18 @@ def publish(self, by=None, description=None): def hide(self, by=None, description=None): pass + @transition( + field=state, + source="*", + target=AdminBlogPostState.CREATED, + custom={ + "label": "Rename *", + "form": "tests.testapp.admin_forms.AdminBlogPostRenameForm", + }, + ) + def complex_transition(self, new_title: str): + self.title = new_title + # step transitions @fsm_log_by diff --git a/tests/testapp/tests/test_admin.py b/tests/testapp/tests/test_admin.py index dac5797..ca4cc22 100644 --- a/tests/testapp/tests/test_admin.py +++ b/tests/testapp/tests/test_admin.py @@ -10,7 +10,6 @@ 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 @@ -30,40 +29,29 @@ def setUpTestData(cls): 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) + 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]) == [ + "complex_transition", "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") + @patch("django_fsm.admin.FSMAdminMixin._get_fsm_object_transitions") def test_change_view_context( self, mock_get_fsm_object_transitions, @@ -197,7 +185,7 @@ def test_concurrent_transition_exception(self, mock_message_user): with patch( "tests.testapp.models.AdminBlogPost.moderate", side_effect=ConcurrentTransition("error message"), - ): + ), patch("django_fsm.admin.FSMAdminMixin.get_fsm_transition_custom", return_value={}): self.model_admin.response_change( request=request, obj=blog_post,