11from __future__ import annotations
22
3+ import typing
34from dataclasses import dataclass
45from functools import partial
56from typing import Any
67
78from django .conf import settings
9+ from django .contrib import admin
810from django .contrib import messages
911from django .contrib .admin .options import BaseModelAdmin
1012from django .contrib .admin .templatetags .admin_urls import add_preserved_filters
1113from django .core .exceptions import FieldDoesNotExist
1214from django .http import HttpRequest
1315from django .http import HttpResponse
16+ from django .http import HttpResponseBadRequest
1417from django .http import HttpResponseRedirect
18+ from django .shortcuts import redirect
19+ from django .shortcuts import render
20+ from django .urls import path
21+ from django .urls import reverse
22+ from django .utils .module_loading import import_string
1523from django .utils .translation import gettext_lazy as _
1624
1725import django_fsm as fsm
1826
27+ if typing .TYPE_CHECKING :
28+ from django .forms import Form
29+
1930try :
2031 import django_fsm_log # noqa: F401
2132except ModuleNotFoundError :
2435 FSM_LOG_ENABLED = True
2536
2637
38+
39+
2740@dataclass
2841class FSMObjectTransition :
2942 fsm_field : str
@@ -42,14 +55,26 @@ class FSMAdminMixin(BaseModelAdmin):
4255 fsm_context_key = "fsm_object_transitions"
4356 fsm_post_param = "_fsm_transition_to"
4457 default_disallow_transition = not getattr (settings , "FSM_ADMIN_FORCE_PERMIT" , False )
58+ fsm_transition_form_template = "django_fsm/fsm_admin_transition_form.html"
59+
60+ def get_urls (self ):
61+ meta = self .model ._meta
62+ return [
63+ path (
64+ "<path:object_id>/transition/<str:transition_name>/" ,
65+ self .admin_site .admin_view (self .fsm_transition_view ),
66+ name = f"{ meta .app_label } _{ meta .model_name } _transition" ,
67+ ),
68+ * super ().get_urls (),
69+ ]
4570
4671 def get_fsm_field_instance (self , fsm_field_name : str ) -> fsm .FSMField | None :
4772 try :
4873 return self .model ._meta .get_field (fsm_field_name )
4974 except FieldDoesNotExist :
5075 return None
5176
52- def get_readonly_fields (self , request : HttpRequest , obj : Any = None ) -> tuple [str ]:
77+ def get_readonly_fields (self , request : HttpRequest , obj : typing . Any = None ) -> tuple [str ]:
5378 read_only_fields = super ().get_readonly_fields (request , obj )
5479
5580 for fsm_field_name in self .fsm_fields :
@@ -65,7 +90,7 @@ def get_readonly_fields(self, request: HttpRequest, obj: Any = None) -> tuple[st
6590 def get_fsm_block_label (fsm_field_name : str ) -> str :
6691 return f"Transition ({ fsm_field_name } )"
6792
68- def get_fsm_object_transitions (self , request : HttpRequest , obj : Any ) -> list [FSMObjectTransition ]:
93+ def get_fsm_object_transitions (self , request : HttpRequest , obj : typing . Any ) -> list [FSMObjectTransition ]:
6994 fsm_object_transitions = []
7095
7196 for field_name in sorted (self .fsm_fields ):
@@ -82,12 +107,18 @@ def get_fsm_object_transitions(self, request: HttpRequest, obj: Any) -> list[FSM
82107
83108 return fsm_object_transitions
84109
110+ def get_fsm_transition_form (self , transition : fsm .Transition ) -> Form | None :
111+ form = transition .custom .get ("form" )
112+ if isinstance (form , str ):
113+ form = import_string (form )
114+ return form
115+
85116 def change_view (
86117 self ,
87118 request : HttpRequest ,
88119 object_id : str ,
89120 form_url : str = "" ,
90- extra_context : dict [str , Any ] | None = None ,
121+ extra_context : dict [str , typing . Any ] | None = None ,
91122 ) -> HttpResponse :
92123 _context = extra_context or {}
93124 _context [self .fsm_context_key ] = self .get_fsm_object_transitions (
@@ -102,10 +133,10 @@ def change_view(
102133 extra_context = _context ,
103134 )
104135
105- def get_fsm_redirect_url (self , request : HttpRequest , obj : Any ) -> str :
136+ def get_fsm_redirect_url (self , request : HttpRequest , obj : typing . Any ) -> str :
106137 return request .path
107138
108- def get_fsm_response (self , request : HttpRequest , obj : Any ) -> HttpResponse :
139+ def get_fsm_response (self , request : HttpRequest , obj : typing . Any ) -> HttpResponse :
109140 redirect_url = self .get_fsm_redirect_url (request = request , obj = obj )
110141 redirect_url = add_preserved_filters (
111142 context = {
@@ -116,7 +147,7 @@ def get_fsm_response(self, request: HttpRequest, obj: Any) -> HttpResponse:
116147 )
117148 return HttpResponseRedirect (redirect_to = redirect_url )
118149
119- def response_change (self , request : HttpRequest , obj : Any ) -> HttpResponse :
150+ def response_change (self , request : HttpRequest , obj : typing . Any ) -> HttpResponse :
120151 if self .fsm_post_param in request .POST :
121152 try :
122153 transition_name = request .POST [self .fsm_post_param ]
@@ -134,6 +165,20 @@ def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse:
134165 obj = obj ,
135166 )
136167
168+ # NOTE: if a form is defined on the transition, we redirect to the form view
169+ if transition_func ._django_fsm .get_transition (
170+ source = transition_func ._django_fsm .field .get_state (obj ),
171+ ).custom .get ("form" ):
172+ return redirect (
173+ reverse (
174+ f"admin:{ self .model ._meta .app_label } _{ self .model ._meta .model_name } _transition" ,
175+ kwargs = {
176+ "object_id" : obj .pk ,
177+ "transition_name" : transition_name ,
178+ },
179+ )
180+ )
181+
137182 try :
138183 if FSM_LOG_ENABLED :
139184 for fn in [
@@ -179,3 +224,69 @@ def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse:
179224 )
180225
181226 return super ().response_change (request = request , obj = obj )
227+
228+ @staticmethod
229+ def _get_transition_title (transition ):
230+ return getattr (transition .custom , "label" , None ) or transition .name
231+
232+ def fsm_transition_view (self , request , * args , ** kwargs ):
233+ transition_name = kwargs ["transition_name" ]
234+ obj = self .get_object (request , kwargs ["object_id" ])
235+
236+ transition_method = getattr (obj , transition_name )
237+ if not hasattr (transition_method , "_django_fsm" ):
238+ return HttpResponseBadRequest (f"{ transition_name } is not a transition method" )
239+
240+ transitions = transition_method ._django_fsm .transitions
241+ if isinstance (transitions , dict ):
242+ transitions = list (transitions .values ())
243+ transition = transitions [0 ]
244+
245+ if TransitionForm := self .get_fsm_transition_form (transition ):
246+ if request .method == "POST" :
247+ transition_form = TransitionForm (data = request .POST , instance = obj )
248+ if transition_form .is_valid ():
249+ transition_method (** transition_form .cleaned_data )
250+ obj .save ()
251+ else :
252+ return render (
253+ request ,
254+ self .fsm_transition_form_template ,
255+ context = admin .site .each_context (request )
256+ | {
257+ "opts" : self .model ._meta ,
258+ "original" : obj ,
259+ "transition" : transition ,
260+ "transition_form" : transition_form ,
261+ },
262+ )
263+ else :
264+ transition_form = TransitionForm (instance = obj )
265+ return render (
266+ request ,
267+ self .fsm_transition_form_template ,
268+ context = admin .site .each_context (request )
269+ | {
270+ "opts" : self .model ._meta ,
271+ "original" : obj ,
272+ "transition" : transition ,
273+ "transition_form" : transition_form ,
274+ },
275+ )
276+ else :
277+ try :
278+ transition_method ()
279+ except fsm .TransitionNotAllowed :
280+ self .message_user (
281+ request ,
282+ _ ("Transition %(transition)s is not allowed" ) % {"transition" : self ._get_transition_title (transition )},
283+ messages .ERROR ,
284+ )
285+ else :
286+ obj .save ()
287+ self .message_user (
288+ request ,
289+ _ ("Transition %(transition)s applied" ) % {"transition" : self ._get_transition_title (transition )},
290+ messages .SUCCESS ,
291+ )
292+ return redirect (f"admin:{ self .model ._meta .app_label } _{ self .model ._meta .model_name } _change" , object_id = obj .id )
0 commit comments