Skip to content

Commit 382838e

Browse files
committed
Add admin
1 parent 28d4257 commit 382838e

File tree

7 files changed

+589
-8
lines changed

7 files changed

+589
-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: 77 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,73 @@ 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"="My awesome transition"}`` 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+
458+
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.
459+
Then one must explicitly allow that a transition method shows up in the admin interface.
460+
461+
``` python
462+
@admin.register(AdminBlogPost)
463+
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
464+
default_disallow_transition = False
465+
...
466+
```
467+
396468
## Drawing transitions
397469

398470
Renders a graphical overview of your models states transitions
399471

400-
You need `pip install "graphviz>=0.4"` library and add `django_fsm` to
401-
your `INSTALLED_APPS`:
472+
1. You need `pip install "graphviz>=0.4"` library
473+
474+
2. Make sure `django_fsm` is in your `INSTALLED_APPS` settings:
402475

403476
``` python
404477
INSTALLED_APPS = (
@@ -408,6 +481,8 @@ INSTALLED_APPS = (
408481
)
409482
```
410483

484+
3. Then you can use `graph_transitions` command:
485+
411486
``` bash
412487
# Create a dot file
413488
$ ./manage.py graph_transitions > transitions.dot
@@ -418,12 +493,6 @@ $ ./manage.py graph_transitions -o blog_transitions.png myapp.Blog
418493

419494
## Extensions
420495

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-
427496
Transition logging support could be achieved with help of django-fsm-log
428497
package
429498

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+
]

0 commit comments

Comments
 (0)