Skip to content

Commit 928d72e

Browse files
committed
Add admin forms support
1 parent cb996c1 commit 928d72e

File tree

8 files changed

+222
-83
lines changed

8 files changed

+222
-83
lines changed

django_fsm/__init__.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,12 +339,8 @@ def get_all_transitions(self, instance_cls):
339339
"""
340340
Returns [(source, target, name, method)] for all field transitions
341341
"""
342-
transitions = self.transitions[instance_cls]
343-
344-
for transition in transitions.values():
345-
meta = transition._django_fsm
346-
347-
yield from meta.transitions.values()
342+
for transition in self.transitions[instance_cls].values():
343+
yield from transition._django_fsm.transitions.values()
348344

349345
def contribute_to_class(self, cls, name, **kwargs):
350346
self.base_cls = cls

django_fsm/admin.py

Lines changed: 156 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
from __future__ import annotations
22

3+
import typing
34
from dataclasses import dataclass
45
from functools import partial
5-
from typing import Any
66

77
from django.conf import settings
8+
from django.contrib import admin
89
from django.contrib import messages
910
from django.contrib.admin.options import BaseModelAdmin
1011
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
1112
from django.core.exceptions import FieldDoesNotExist
1213
from django.http import HttpRequest
1314
from django.http import HttpResponse
15+
from django.http import HttpResponseBadRequest
1416
from django.http import HttpResponseRedirect
17+
from django.shortcuts import redirect
18+
from django.shortcuts import render
19+
from django.urls import path
20+
from django.urls import reverse
21+
from django.utils.module_loading import import_string
1522
from django.utils.translation import gettext_lazy as _
1623

1724
import django_fsm as fsm
1825

26+
if typing.TYPE_CHECKING:
27+
from django.forms import Form
28+
1929
try:
2030
import django_fsm_log # noqa: F401
2131
except ModuleNotFoundError:
@@ -27,7 +37,6 @@
2737
@dataclass
2838
class FSMObjectTransition:
2939
fsm_field: str
30-
block_label: str
3140
available_transitions: list[fsm.Transition]
3241

3342

@@ -42,55 +51,48 @@ class FSMAdminMixin(BaseModelAdmin):
4251
fsm_context_key = "fsm_object_transitions"
4352
fsm_post_param = "_fsm_transition_to"
4453
default_disallow_transition = not getattr(settings, "FSM_ADMIN_FORCE_PERMIT", False)
54+
fsm_transition_form_template = "django_fsm/fsm_admin_transition_form.html"
4555

46-
def get_fsm_field_instance(self, fsm_field_name: str) -> fsm.FSMField | None:
47-
try:
48-
return self.model._meta.get_field(fsm_field_name)
49-
except FieldDoesNotExist:
50-
return None
56+
def get_urls(self):
57+
meta = self.model._meta
58+
return [
59+
path(
60+
"<path:object_id>/transition/<str:transition_name>/",
61+
self.admin_site.admin_view(self.fsm_transition_view),
62+
name=f"{meta.app_label}_{meta.model_name}_transition",
63+
),
64+
*super().get_urls(),
65+
]
66+
67+
def get_readonly_fields(self, request: HttpRequest, obj: typing.Any = None) -> tuple[str]:
68+
"""Add FSM fields to readonly fields if they are protected."""
5169

52-
def get_readonly_fields(self, request: HttpRequest, obj: Any = None) -> tuple[str]:
5370
read_only_fields = super().get_readonly_fields(request, obj)
5471

5572
for fsm_field_name in self.fsm_fields:
5673
if fsm_field_name in read_only_fields:
5774
continue
58-
field = self.get_fsm_field_instance(fsm_field_name=fsm_field_name)
59-
if field and getattr(field, "protected", False):
60-
read_only_fields += (fsm_field_name,)
75+
try:
76+
field = self.model._meta.get_field(fsm_field_name)
77+
except FieldDoesNotExist:
78+
pass
79+
else:
80+
if getattr(field, "protected", False):
81+
read_only_fields += (fsm_field_name,)
6182

6283
return read_only_fields
6384

64-
@staticmethod
65-
def get_fsm_block_label(fsm_field_name: str) -> str:
66-
return f"Transition ({fsm_field_name})"
67-
68-
def get_fsm_object_transitions(self, request: HttpRequest, obj: Any) -> list[FSMObjectTransition]:
69-
fsm_object_transitions = []
70-
71-
for field_name in sorted(self.fsm_fields):
72-
if func := getattr(obj, f"get_available_user_{field_name}_transitions"):
73-
fsm_object_transitions.append( # noqa: PERF401
74-
FSMObjectTransition(
75-
fsm_field=field_name,
76-
block_label=self.get_fsm_block_label(fsm_field_name=field_name),
77-
available_transitions=[
78-
t for t in func(user=request.user) if t.custom.get("admin", self.default_disallow_transition)
79-
],
80-
)
81-
)
82-
83-
return fsm_object_transitions
84-
8585
def change_view(
8686
self,
8787
request: HttpRequest,
8888
object_id: str,
8989
form_url: str = "",
90-
extra_context: dict[str, Any] | None = None,
90+
extra_context: dict[str, typing.Any] | None = None,
9191
) -> HttpResponse:
92+
"""Override the change view to add FSM transitions to the context."""
93+
9294
_context = extra_context or {}
93-
_context[self.fsm_context_key] = self.get_fsm_object_transitions(
95+
_context[self.fsm_context_key] = self._get_fsm_object_transitions(
9496
request=request,
9597
obj=self.get_object(request=request, object_id=object_id),
9698
)
@@ -102,24 +104,19 @@ def change_view(
102104
extra_context=_context,
103105
)
104106

105-
def get_fsm_redirect_url(self, request: HttpRequest, obj: Any) -> str:
106-
return request.path
107-
108-
def get_fsm_response(self, request: HttpRequest, obj: Any) -> HttpResponse:
109-
redirect_url = self.get_fsm_redirect_url(request=request, obj=obj)
110-
redirect_url = add_preserved_filters(
111-
context={
112-
"preserved_filters": self.get_preserved_filters(request),
113-
"opts": self.model._meta,
114-
},
115-
url=redirect_url,
116-
)
117-
return HttpResponseRedirect(redirect_to=redirect_url)
107+
def _get_fsm_object_transitions(self, request: HttpRequest, obj: typing.Any) -> list[FSMObjectTransition]:
108+
for field_name in sorted(self.fsm_fields):
109+
if func := getattr(obj, f"get_available_user_{field_name}_transitions"):
110+
yield FSMObjectTransition(
111+
fsm_field=field_name,
112+
available_transitions=[
113+
t for t in func(user=request.user) if t.custom.get("admin", self.default_disallow_transition)
114+
],
115+
)
118116

119-
def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse:
120-
if self.fsm_post_param in request.POST:
117+
def response_change(self, request: HttpRequest, obj: typing.Any) -> HttpResponse: # noqa: C901
118+
if transition_name := request.POST.get(self.fsm_post_param):
121119
try:
122-
transition_name = request.POST[self.fsm_post_param]
123120
transition_func = getattr(obj, transition_name)
124121
except AttributeError:
125122
self.message_user(
@@ -129,9 +126,18 @@ def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse:
129126
),
130127
level=messages.ERROR,
131128
)
132-
return self.get_fsm_response(
133-
request=request,
134-
obj=obj,
129+
return self.get_fsm_response(request=request, obj=obj)
130+
131+
# NOTE: if a form is defined in the transition.custom, we redirect to the form view
132+
if self.get_fsm_transition_custom(instance=obj, transition_func=transition_func).get("form"):
133+
return redirect(
134+
reverse(
135+
f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_transition",
136+
kwargs={
137+
"object_id": obj.pk,
138+
"transition_name": transition_name,
139+
},
140+
)
135141
)
136142

137143
try:
@@ -173,9 +179,102 @@ def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse:
173179
level=messages.INFO,
174180
)
175181

176-
return self.get_fsm_response(
177-
request=request,
178-
obj=obj,
179-
)
182+
return self.get_fsm_response(request=request, obj=obj)
180183

181184
return super().response_change(request=request, obj=obj)
185+
186+
def get_fsm_response(self, request: HttpRequest, obj: typing.Any) -> HttpResponse:
187+
redirect_url = add_preserved_filters(
188+
context={
189+
"preserved_filters": self.get_preserved_filters(request),
190+
"opts": self.model._meta,
191+
},
192+
url=self.get_fsm_redirect_url(request=request, obj=obj),
193+
)
194+
return HttpResponseRedirect(redirect_to=redirect_url)
195+
196+
def get_fsm_redirect_url(self, request: HttpRequest, obj: typing.Any) -> str:
197+
return request.path
198+
199+
def get_fsm_transition_custom(self, instance, transition_func):
200+
"""Helper function to get custom attributes for the current transition"""
201+
return getattr(self.get_fsm_transition(instance, transition_func), "custom", {})
202+
203+
def get_fsm_transition(self, instance, transition_func) -> fsm.Transition | None:
204+
"""
205+
Extract custom attributes from a transition function for the current state.
206+
"""
207+
if not hasattr(transition_func, "_django_fsm"):
208+
return None
209+
210+
fsm_meta = transition_func._django_fsm
211+
current_state = fsm_meta.field.get_state(instance)
212+
return fsm_meta.get_transition(current_state)
213+
214+
def get_fsm_transition_form(self, transition: fsm.Transition) -> Form | None:
215+
form = transition.custom.get("form")
216+
if isinstance(form, str):
217+
form = import_string(form)
218+
return form
219+
220+
def fsm_transition_view(self, request, *args, **kwargs):
221+
transition_name = kwargs["transition_name"]
222+
obj = self.get_object(request, kwargs["object_id"])
223+
224+
transition_method = getattr(obj, transition_name)
225+
if not hasattr(transition_method, "_django_fsm"):
226+
return HttpResponseBadRequest(f"{transition_name} is not a transition method")
227+
228+
transitions = transition_method._django_fsm.transitions
229+
if isinstance(transitions, dict):
230+
transitions = list(transitions.values())
231+
transition = transitions[0]
232+
233+
if TransitionForm := self.get_fsm_transition_form(transition):
234+
if request.method == "POST":
235+
transition_form = TransitionForm(data=request.POST, instance=obj)
236+
if transition_form.is_valid():
237+
transition_method(**transition_form.cleaned_data)
238+
obj.save()
239+
else:
240+
return render(
241+
request,
242+
self.fsm_transition_form_template,
243+
context=admin.site.each_context(request)
244+
| {
245+
"opts": self.model._meta,
246+
"original": obj,
247+
"transition": transition,
248+
"transition_form": transition_form,
249+
},
250+
)
251+
else:
252+
transition_form = TransitionForm(instance=obj)
253+
return render(
254+
request,
255+
self.fsm_transition_form_template,
256+
context=admin.site.each_context(request)
257+
| {
258+
"opts": self.model._meta,
259+
"original": obj,
260+
"transition": transition,
261+
"transition_form": transition_form,
262+
},
263+
)
264+
else:
265+
try:
266+
transition_method()
267+
except fsm.TransitionNotAllowed:
268+
self.message_user(
269+
request,
270+
self.fsm_transition_not_allowed_msg.format(transition_name=transition_name),
271+
messages.ERROR,
272+
)
273+
else:
274+
obj.save()
275+
self.message_user(
276+
request,
277+
self.fsm_transition_success_msg.format(transition_name=transition_name),
278+
messages.SUCCESS,
279+
)
280+
return redirect(f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_change", object_id=obj.id)

django_fsm/templates/django_fsm/fsm_admin_change_form.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
{% for fsm_object_transition in fsm_object_transitions %}
66
<div class="submit-row">
7-
<label>{{ fsm_object_transition.block_label }}</label>
7+
<label>Transition ({{ fsm_object_transition.fsm_field }})</label>
88
{% for transition in fsm_object_transition.available_transitions %}
9-
<input type="submit" value="{{ transition.custom.label|default:transition.name }}" name="_fsm_transition_to">
9+
<button type="submit" class="button" name="_fsm_transition_to" value="{{ transition.name }}">
10+
{{ transition.custom.label|default:transition.name }}
11+
</button>
1012
{% endfor %}
1113
</div>
1214
{% endfor %}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{% extends 'admin/change_form.html' %}
2+
3+
{% load i18n admin_urls static admin_modify %}
4+
5+
{% block breadcrumbs %}
6+
<div class="breadcrumbs">
7+
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
8+
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
9+
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
10+
&rsaquo; <a href="{% url opts|admin_urlname:'change' object_id=original.pk %}">{{ original|truncatewords:"18" }}</a>
11+
&rsaquo; {{ transition.custom.short_description|default:transition.name }}
12+
</div>
13+
{% endblock %}
14+
15+
{% block content %}
16+
<h1>{{ transition.custom.short_description|default:transition.name }}</h1>
17+
18+
<form method="post">
19+
{% csrf_token %}
20+
{{ transition_form.as_p }}
21+
<input type="submit" value="{% translate 'Apply' %}">
22+
</form>
23+
24+
{% endblock %}

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ extend-ignore = [
6464
"COM812", # This rule may cause conflicts when used with the formatter
6565
"D", # pydocstyle
6666
"DOC", # pydoclint
67+
"N806", # Variable in function should be lowercase
6768
"B",
6869
"PTH",
6970
"ANN", # Missing type annotation

tests/testapp/admin_forms.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from __future__ import annotations
2+
3+
from django import forms
4+
5+
from .models import AdminBlogPost
6+
7+
8+
class AdminBlogPostRenameForm(forms.ModelForm):
9+
"""
10+
This form is used to test the admin form renaming functionality.
11+
It should not be used in production.
12+
"""
13+
14+
class Meta:
15+
model = AdminBlogPost
16+
fields = ["title"] # Do not try to update the state field, especially if it's "protected" in the model.

0 commit comments

Comments
 (0)