Skip to content

Commit 1316f4c

Browse files
committed
Add admin forms support
1 parent 80a8246 commit 1316f4c

File tree

6 files changed

+175
-8
lines changed

6 files changed

+175
-8
lines changed

django_fsm/admin.py

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
11
from __future__ import annotations
22

3+
import typing
34
from dataclasses import dataclass
45
from functools import partial
56
from typing import Any
67

78
from django.conf import settings
9+
from django.contrib import admin
810
from django.contrib import messages
911
from django.contrib.admin.options import BaseModelAdmin
1012
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
1113
from django.core.exceptions import FieldDoesNotExist
1214
from django.http import HttpRequest
1315
from django.http import HttpResponse
16+
from django.http import HttpResponseBadRequest
1417
from 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
1523
from django.utils.translation import gettext_lazy as _
1624

1725
import django_fsm as fsm
1826

27+
if typing.TYPE_CHECKING:
28+
from django.forms import Form
29+
1930
try:
2031
import django_fsm_log # noqa: F401
2132
except ModuleNotFoundError:
@@ -24,6 +35,8 @@
2435
FSM_LOG_ENABLED = True
2536

2637

38+
39+
2740
@dataclass
2841
class 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)

django_fsm/templates/django_fsm/fsm_admin_change_form.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
<div class="submit-row">
77
<label>{{ fsm_object_transition.block_label }}</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.

tests/testapp/models.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,8 @@ class AdminBlogPost(models.Model):
279279
protected=False,
280280
)
281281

282-
# state transitions
282+
def __str__(self):
283+
return f"{self.title} ({self.state})"
283284

284285
@fsm_log_by
285286
@fsm_log_description
@@ -330,6 +331,18 @@ def publish(self, by=None, description=None):
330331
def hide(self, by=None, description=None):
331332
pass
332333

334+
@transition(
335+
field=state,
336+
source="*",
337+
target=AdminBlogPostState.CREATED,
338+
custom={
339+
"label": "Rename *",
340+
"form": "tests.testapp.admin_forms.AdminBlogPostRenameForm",
341+
},
342+
)
343+
def complex_transition(self, new_title: str):
344+
self.title = new_title
345+
333346
# step transitions
334347

335348
@fsm_log_by

0 commit comments

Comments
 (0)