Skip to content

Commit 9f36a9c

Browse files
committed
Add admin
1 parent 28d4257 commit 9f36a9c

File tree

7 files changed

+579
-8
lines changed

7 files changed

+579
-8
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: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Nice introduction is available here: <https://gist.github.com/Nagyman/9502133>
2929

3030
## Installation
3131

32+
First, install the package with pip.
33+
3234
``` bash
3335
$ pip install django-fsm-2
3436
```
@@ -39,6 +41,16 @@ Or, for the latest git version
3941
$ pip install -e git://github.com/django-commons/django-fsm-2.git#egg=django-fsm
4042
```
4143

44+
Register django_fsm in your list of Django applications
45+
46+
```python
47+
INSTALLED_APPS = (
48+
...,
49+
'django_fsm',
50+
...,
51+
)
52+
```
53+
4254
## Migration from django-fsm
4355

4456
django-fsm-2 is a drop-in replacement, it's actually the same project but from a different source.
@@ -393,12 +405,67 @@ ConcurrentTransitionMixin to cause a rollback of all the changes that
393405
have been executed in an inconsistent (out of sync) state, thus
394406
practically negating their effect.
395407

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+
421+
2. In your admin.py file, use FSMAdminMixin to add behaviour to your ModelAdmin. FSMAdminMixin should be before ModelAdmin, the order is important.
422+
423+
``` python
424+
from django_fsm.admin import FSMAdminMixin
425+
426+
@admin.register(AdminBlogPost)
427+
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
428+
fsm_field = ['my_fsm_field',]
429+
...
430+
```
431+
432+
3. You can customize the label by adding ``custom={"label"=False}`` to the transition decorator
433+
434+
``` python
435+
@transition(
436+
field='state',
437+
source=['startstate'],
438+
target='finalstate',
439+
custom={"label"=False},
440+
)
441+
def do_something(self, param):
442+
...
443+
```
444+
445+
4. By adding ``custom={"admin"=False}`` to the transition decorator, one can disallow a transition to show up in the admin interface.
446+
447+
``` python
448+
@transition(
449+
field='state',
450+
source=['startstate'],
451+
target='finalstate',
452+
custom={"admin"=False},
453+
)
454+
def do_something(self, param):
455+
# will not add a button "Do Something" to your admin model interface
456+
```
457+
By adding ``FSM_ADMIN_FORCE_PERMIT = True`` to your configuration settings, the
458+
above restriction becomes the default. Then one must explicitly allow that a
459+
transition method shows up in the admin interface.
460+
461+
396462
## Drawing transitions
397463

398464
Renders a graphical overview of your models states transitions
399465

400-
You need `pip install "graphviz>=0.4"` library and add `django_fsm` to
401-
your `INSTALLED_APPS`:
466+
1. You need `pip install "graphviz>=0.4"` library
467+
468+
2. Make sure `django_fsm` is in your `INSTALLED_APPS` settings:
402469

403470
``` python
404471
INSTALLED_APPS = (
@@ -408,6 +475,8 @@ INSTALLED_APPS = (
408475
)
409476
```
410477

478+
3. Then you can use `graph_transitions` command:
479+
411480
``` bash
412481
# Create a dot file
413482
$ ./manage.py graph_transitions > transitions.dot
@@ -418,12 +487,6 @@ $ ./manage.py graph_transitions -o blog_transitions.png myapp.Blog
418487

419488
## Extensions
420489

421-
You may also take a look at django-fsm-2-admin project containing a mixin
422-
and template tags to integrate django-fsm-2 state transitions into the
423-
django admin.
424-
425-
<https://github.com/coral-li/django-fsm-2-admin>
426-
427490
Transition logging support could be achieved with help of django-fsm-log
428491
package
429492

django_fsm/admin.py

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

0 commit comments

Comments
 (0)