Skip to content

Commit 4c59cdd

Browse files
committed
Add admin
1 parent 9a9cb02 commit 4c59cdd

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
@@ -436,12 +498,6 @@ $ ./manage.py graph_transitions -e transition_1,transition_2 myapp.Blog
436498

437499
## Extensions
438500

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

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
@@ -247,3 +247,107 @@ def steal(self):
247247
@transition(field=state, source="*", target=BlogPostState.MODERATED)
248248
def moderate(self):
249249
pass
250+
251+
252+
class AdminBlogPostState(models.TextChoices):
253+
CREATED = "created", "Created"
254+
REVIEWED = "reviewed", "Reviewed"
255+
PUBLISHED = "published", "Published"
256+
HIDDEN = "hidden", "Hidden"
257+
258+
259+
class AdminBlogPostStep(models.TextChoices):
260+
STEP_1 = "step1", "Step one"
261+
STEP_2 = "step2", "Step two"
262+
STEP_3 = "step3", "Step three"
263+
264+
265+
class AdminBlogPost(models.Model):
266+
title = models.CharField(max_length=50)
267+
268+
state = FSMField(
269+
choices=AdminBlogPostState.choices,
270+
default=AdminBlogPostState.CREATED,
271+
protected=True,
272+
)
273+
274+
step = FSMField(
275+
choices=AdminBlogPostStep.choices,
276+
default=AdminBlogPostStep.STEP_1,
277+
protected=False,
278+
)
279+
280+
# state transitions
281+
282+
@transition(
283+
field=state,
284+
source="*",
285+
target=AdminBlogPostState.HIDDEN,
286+
custom={
287+
"admin": False,
288+
},
289+
)
290+
def secret_transition(self):
291+
pass
292+
293+
@transition(
294+
field=state,
295+
source=[AdminBlogPostState.CREATED],
296+
target=AdminBlogPostState.REVIEWED,
297+
)
298+
def moderate(self):
299+
pass
300+
301+
@transition(
302+
field=state,
303+
source=[
304+
AdminBlogPostState.REVIEWED,
305+
AdminBlogPostState.HIDDEN,
306+
],
307+
target=AdminBlogPostState.PUBLISHED,
308+
)
309+
def publish(self):
310+
pass
311+
312+
@transition(
313+
field=state,
314+
source=[
315+
AdminBlogPostState.REVIEWED,
316+
AdminBlogPostState.PUBLISHED,
317+
],
318+
target=AdminBlogPostState.HIDDEN,
319+
)
320+
def hide(self):
321+
pass
322+
323+
# step transitions
324+
325+
@transition(
326+
field=step,
327+
source=[AdminBlogPostStep.STEP_1],
328+
target=AdminBlogPostStep.STEP_2,
329+
custom={
330+
"label": "Go to Step 2",
331+
},
332+
)
333+
def step_two(self):
334+
pass
335+
336+
@transition(
337+
field=step,
338+
source=[AdminBlogPostStep.STEP_2],
339+
target=AdminBlogPostStep.STEP_3,
340+
)
341+
def step_three(self):
342+
pass
343+
344+
@transition(
345+
field=step,
346+
source=[
347+
AdminBlogPostStep.STEP_2,
348+
AdminBlogPostStep.STEP_3,
349+
],
350+
target=AdminBlogPostStep.STEP_1,
351+
)
352+
def step_reset(self):
353+
pass

0 commit comments

Comments
 (0)