Skip to content

Commit b40b3c0

Browse files
committed
Add admin
1 parent 987a11e commit b40b3c0

File tree

7 files changed

+570
-6
lines changed

7 files changed

+570
-6
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,6 @@ dmypy.json
130130

131131
# sqlite
132132
test.db
133+
*.sqlite3
134+
135+
tests/testapp/migrations/

README.md

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,68 @@ ConcurrentTransitionMixin to cause a rollback of all the changes that
405405
have been executed in an inconsistent (out of sync) state, thus
406406
practically negating their effect.
407407

408+
## Admin Integration
409+
410+
1. Make sure `django_fsm` is in your `INSTALLED_APPS` settings:
411+
412+
``` python
413+
INSTALLED_APPS = (
414+
...
415+
'django_fsm',
416+
...
417+
)
418+
```
419+
420+
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.
421+
422+
423+
2. In your admin.py file, use FSMAdminMixin to add behaviour to your ModelAdmin. FSMAdminMixin should be before ModelAdmin, the order is important.
424+
425+
``` python
426+
from django_fsm.admin import FSMAdminMixin
427+
428+
@admin.register(AdminBlogPost)
429+
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
430+
fsm_field = ['my_fsm_field',]
431+
...
432+
```
433+
434+
3. You can customize the label by adding ``custom={"label"="My awesome transition"}`` to the transition decorator
435+
436+
``` python
437+
@transition(
438+
field='state',
439+
source=['startstate'],
440+
target='finalstate',
441+
custom={"label"=False},
442+
)
443+
def do_something(self, param):
444+
...
445+
```
446+
447+
4. By adding ``custom={"admin"=False}`` to the transition decorator, one can disallow a transition to show up in the admin interface.
448+
449+
``` python
450+
@transition(
451+
field='state',
452+
source=['startstate'],
453+
target='finalstate',
454+
custom={"admin"=False},
455+
)
456+
def do_something(self, param):
457+
# will not add a button "Do Something" to your admin model interface
458+
```
459+
460+
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.
461+
Then one must explicitly allow that a transition method shows up in the admin interface.
462+
463+
``` python
464+
@admin.register(AdminBlogPost)
465+
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
466+
default_disallow_transition = False
467+
...
468+
```
469+
408470
## Drawing transitions
409471

410472
Renders a graphical overview of your models states transitions
@@ -433,12 +495,6 @@ $ ./manage.py graph_transitions -o blog_transitions.png myapp.Blog
433495

434496
## Extensions
435497

436-
You may also take a look at django-fsm-2-admin project containing a mixin
437-
and template tags to integrate django-fsm-2 state transitions into the
438-
django admin.
439-
440-
<https://github.com/coral-li/django-fsm-2-admin>
441-
442498
Transition logging support could be achieved with help of django-fsm-log
443499
package
444500

django_fsm/admin.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from django.conf import settings
7+
from django.contrib import messages
8+
from django.contrib.admin.options import BaseModelAdmin
9+
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
10+
from django.core.exceptions import FieldDoesNotExist
11+
from django.http import HttpRequest
12+
from django.http import HttpResponse
13+
from django.http import HttpResponseRedirect
14+
from django.utils.translation import gettext_lazy as _
15+
16+
import django_fsm as fsm
17+
18+
19+
@dataclass
20+
class FSMObjectTransition:
21+
fsm_field: str
22+
block_label: str
23+
available_transitions: list[fsm.Transition]
24+
25+
26+
class FSMAdminMixin(BaseModelAdmin):
27+
change_form_template: str = "django_fsm/fsm_admin_change_form.html"
28+
29+
fsm_fields: list[str] = []
30+
fsm_transition_success_msg = _("FSM transition '{transition_name}' succeeded.")
31+
fsm_transition_error_msg = _("FSM transition '{transition_name}' failed: {error}.")
32+
fsm_transition_not_allowed_msg = _("FSM transition '{transition_name}' is not allowed.")
33+
fsm_transition_not_valid_msg = _("FSM transition '{transition_name}' is not a valid.")
34+
fsm_context_key = "fsm_object_transitions"
35+
fsm_post_param = "_fsm_transition_to"
36+
default_disallow_transition = not getattr(settings, "FSM_ADMIN_FORCE_PERMIT", False)
37+
38+
def get_fsm_field_instance(self, fsm_field_name: str) -> fsm.FSMField | None:
39+
try:
40+
return self.model._meta.get_field(fsm_field_name)
41+
except FieldDoesNotExist:
42+
return None
43+
44+
def get_readonly_fields(self, request: HttpRequest, obj: Any = None) -> tuple[str]:
45+
read_only_fields = super().get_readonly_fields(request, obj)
46+
47+
for fsm_field_name in self.fsm_fields:
48+
if fsm_field_name in read_only_fields:
49+
continue
50+
field = self.get_fsm_field_instance(fsm_field_name=fsm_field_name)
51+
if field and getattr(field, "protected", False):
52+
read_only_fields += (fsm_field_name,)
53+
54+
return read_only_fields
55+
56+
@staticmethod
57+
def get_fsm_block_label(fsm_field_name: str) -> str:
58+
return f"Transition ({fsm_field_name})"
59+
60+
def get_fsm_object_transitions(self, request: HttpRequest, obj: Any) -> list[FSMObjectTransition]:
61+
fsm_object_transitions = []
62+
63+
for field_name in sorted(self.fsm_fields):
64+
if func := getattr(obj, f"get_available_user_{field_name}_transitions"):
65+
fsm_object_transitions.append( # noqa: PERF401
66+
FSMObjectTransition(
67+
fsm_field=field_name,
68+
block_label=self.get_fsm_block_label(fsm_field_name=field_name),
69+
available_transitions=[
70+
t for t in func(user=request.user) if t.custom.get("admin", self.default_disallow_transition)
71+
],
72+
)
73+
)
74+
75+
return fsm_object_transitions
76+
77+
def change_view(
78+
self,
79+
request: HttpRequest,
80+
object_id: str,
81+
form_url: str = "",
82+
extra_context: dict[str, Any] | None = None,
83+
) -> HttpResponse:
84+
_context = extra_context or {}
85+
_context[self.fsm_context_key] = self.get_fsm_object_transitions(
86+
request=request,
87+
obj=self.get_object(request=request, object_id=object_id),
88+
)
89+
90+
return super().change_view(
91+
request=request,
92+
object_id=object_id,
93+
form_url=form_url,
94+
extra_context=_context,
95+
)
96+
97+
def get_fsm_redirect_url(self, request: HttpRequest, obj: Any) -> str:
98+
return request.path
99+
100+
def get_fsm_response(self, request: HttpRequest, obj: Any) -> HttpResponse:
101+
redirect_url = self.get_fsm_redirect_url(request=request, obj=obj)
102+
redirect_url = add_preserved_filters(
103+
context={
104+
"preserved_filters": self.get_preserved_filters(request),
105+
"opts": self.model._meta,
106+
},
107+
url=redirect_url,
108+
)
109+
return HttpResponseRedirect(redirect_to=redirect_url)
110+
111+
def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse:
112+
if self.fsm_post_param in request.POST:
113+
try:
114+
transition_name = request.POST[self.fsm_post_param]
115+
transition_func = getattr(obj, transition_name)
116+
except AttributeError:
117+
self.message_user(
118+
request=request,
119+
message=self.fsm_transition_not_valid_msg.format(
120+
transition_name=transition_name,
121+
),
122+
level=messages.ERROR,
123+
)
124+
return self.get_fsm_response(
125+
request=request,
126+
obj=obj,
127+
)
128+
129+
try:
130+
transition_func()
131+
except fsm.TransitionNotAllowed:
132+
self.message_user(
133+
request=request,
134+
message=self.fsm_transition_not_allowed_msg.format(
135+
transition_name=transition_name,
136+
),
137+
level=messages.ERROR,
138+
)
139+
except fsm.ConcurrentTransition as err:
140+
self.message_user(
141+
request=request,
142+
message=self.fsm_transition_error_msg.format(transition_name=transition_name, error=str(err)),
143+
level=messages.ERROR,
144+
)
145+
else:
146+
obj.save()
147+
self.message_user(
148+
request=request,
149+
message=self.fsm_transition_success_msg.format(
150+
transition_name=transition_name,
151+
),
152+
level=messages.INFO,
153+
)
154+
155+
return self.get_fsm_response(
156+
request=request,
157+
obj=obj,
158+
)
159+
160+
return super().response_change(request=request, obj=obj)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{% extends 'admin/change_form.html' %}
2+
3+
{% block submit_buttons_bottom %}
4+
5+
{% for fsm_object_transition in fsm_object_transitions %}
6+
<div class="submit-row">
7+
<label>{{ fsm_object_transition.block_label }}</label>
8+
{% for transition in fsm_object_transition.available_transitions %}
9+
<input type="submit" value="{{ transition.custom.label|default:transition.name }}" name="_fsm_transition_to">
10+
{% endfor %}
11+
</div>
12+
{% endfor %}
13+
14+
{{ block.super }}
15+
{% endblock %}

tests/testapp/admin.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
from django.contrib import admin
4+
5+
from django_fsm.admin import FSMAdminMixin
6+
7+
from .models import AdminBlogPost
8+
9+
10+
@admin.register(AdminBlogPost)
11+
class AdminBlogPostAdmin(FSMAdminMixin, admin.ModelAdmin):
12+
list_display = (
13+
"id",
14+
"title",
15+
"state",
16+
"step",
17+
)
18+
19+
fsm_fields = [
20+
"state",
21+
"step",
22+
]

tests/testapp/models.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,107 @@ def steal(self):
140140
@transition(field=state, source="*", target="moderated")
141141
def moderate(self):
142142
pass
143+
144+
145+
class AdminBlogPostState(models.TextChoices):
146+
CREATED = "created", "Created"
147+
REVIEWED = "reviewed", "Reviewed"
148+
PUBLISHED = "published", "Published"
149+
HIDDEN = "hidden", "Hidden"
150+
151+
152+
class AdminBlogPostStep(models.TextChoices):
153+
STEP_1 = "step1", "Step one"
154+
STEP_2 = "step2", "Step two"
155+
STEP_3 = "step3", "Step three"
156+
157+
158+
class AdminBlogPost(models.Model):
159+
title = models.CharField(max_length=50)
160+
161+
state = FSMField(
162+
choices=AdminBlogPostState.choices,
163+
default=AdminBlogPostState.CREATED,
164+
protected=True,
165+
)
166+
167+
step = FSMField(
168+
choices=AdminBlogPostStep.choices,
169+
default=AdminBlogPostStep.STEP_1,
170+
protected=False,
171+
)
172+
173+
# state transitions
174+
175+
@transition(
176+
field=state,
177+
source="*",
178+
target=AdminBlogPostState.HIDDEN,
179+
custom={
180+
"admin": False,
181+
},
182+
)
183+
def secret_transition(self):
184+
pass
185+
186+
@transition(
187+
field=state,
188+
source=[AdminBlogPostState.CREATED],
189+
target=AdminBlogPostState.REVIEWED,
190+
)
191+
def moderate(self):
192+
pass
193+
194+
@transition(
195+
field=state,
196+
source=[
197+
AdminBlogPostState.REVIEWED,
198+
AdminBlogPostState.HIDDEN,
199+
],
200+
target=AdminBlogPostState.PUBLISHED,
201+
)
202+
def publish(self):
203+
pass
204+
205+
@transition(
206+
field=state,
207+
source=[
208+
AdminBlogPostState.REVIEWED,
209+
AdminBlogPostState.PUBLISHED,
210+
],
211+
target=AdminBlogPostState.HIDDEN,
212+
)
213+
def hide(self):
214+
pass
215+
216+
# step transitions
217+
218+
@transition(
219+
field=step,
220+
source=[AdminBlogPostStep.STEP_1],
221+
target=AdminBlogPostStep.STEP_2,
222+
custom={
223+
"label": "Go to Step 2",
224+
},
225+
)
226+
def step_two(self):
227+
pass
228+
229+
@transition(
230+
field=step,
231+
source=[AdminBlogPostStep.STEP_2],
232+
target=AdminBlogPostStep.STEP_3,
233+
)
234+
def step_three(self):
235+
pass
236+
237+
@transition(
238+
field=step,
239+
source=[
240+
AdminBlogPostStep.STEP_2,
241+
AdminBlogPostStep.STEP_3,
242+
],
243+
target=AdminBlogPostStep.STEP_1,
244+
)
245+
def step_reset(self):
246+
pass

0 commit comments

Comments
 (0)