Skip to content

Commit 668b3ba

Browse files
committed
Add admin
1 parent 1b948b4 commit 668b3ba

File tree

7 files changed

+491
-0
lines changed

7 files changed

+491
-0
lines changed

django_fsm/admin.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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+
func = getattr(obj, f"get_available_user_{field_name}_transitions")
63+
if func:
64+
fsm_object_transitions.append(
65+
FSMObjectTransition(
66+
fsm_field=field_name,
67+
block_label=self.get_fsm_block_label(fsm_field_name=field_name),
68+
available_transitions=list(func(request.user)),
69+
)
70+
)
71+
72+
return fsm_object_transitions
73+
74+
def change_view(
75+
self,
76+
request: HttpRequest,
77+
object_id: str,
78+
form_url: str = "",
79+
extra_context: dict[str, Any] | None = None,
80+
) -> HttpResponse:
81+
_context = extra_context or {}
82+
_context[self.fsm_context_key] = self.get_fsm_object_transitions(
83+
request=request,
84+
obj=self.get_object(request=request, object_id=object_id),
85+
)
86+
87+
return super().change_view(
88+
request=request,
89+
object_id=object_id,
90+
form_url=form_url,
91+
extra_context=_context,
92+
)
93+
94+
def get_fsm_redirect_url(self, request: HttpRequest, obj: Any) -> str:
95+
return request.path
96+
97+
def get_fsm_response(self, request: HttpRequest, obj: Any) -> HttpResponse:
98+
redirect_url = self.get_fsm_redirect_url(request=request, obj=obj)
99+
redirect_url = add_preserved_filters(
100+
context={
101+
"preserved_filters": self.get_preserved_filters(request),
102+
"opts": self.model._meta,
103+
},
104+
url=redirect_url,
105+
)
106+
return HttpResponseRedirect(redirect_to=redirect_url)
107+
108+
def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse:
109+
if self.fsm_post_param in request.POST:
110+
try:
111+
transition_name = request.POST[self.fsm_post_param]
112+
transition_func = getattr(obj, transition_name)
113+
except AttributeError:
114+
self.message_user(
115+
request=request,
116+
message=self.fsm_transition_not_valid_msg.format(
117+
transition_name=transition_name,
118+
),
119+
level=messages.ERROR,
120+
)
121+
return self.get_fsm_response(
122+
request=request,
123+
obj=obj,
124+
)
125+
126+
try:
127+
transition_func()
128+
except fsm.TransitionNotAllowed:
129+
self.message_user(
130+
request=request,
131+
message=self.fsm_transition_not_allowed_msg.format(
132+
transition_name=transition_name,
133+
),
134+
level=messages.ERROR,
135+
)
136+
except fsm.ConcurrentTransition as err:
137+
self.message_user(
138+
request=request,
139+
message=self.fsm_transition_error_msg.format(transition_name=transition_name, error=str(err)),
140+
level=messages.ERROR,
141+
)
142+
else:
143+
obj.save()
144+
self.message_user(
145+
request=request,
146+
message=self.fsm_transition_success_msg.format(
147+
transition_name=transition_name,
148+
),
149+
level=messages.INFO,
150+
)
151+
152+
return self.get_fsm_response(
153+
request=request,
154+
obj=obj,
155+
)
156+
157+
return super().response_change(request=request, obj=obj)

django_fsm/templates/django_fsm/fsm_admin_change_form.html

Whitespace-only changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{% extends 'admin/base_site.html' %}
2+
3+
{% load i18n %}
4+
5+
{% block content %}
6+
<h1>{{ transition.custom.short_description|default: transition.name }}</h1>
7+
<form method="post">
8+
{% csrf_token %}
9+
{{ form.as_p }}
10+
<input type="submit" value="{% translate 'Apply' %}">
11+
</form>
12+
13+
{% endblock %}

tests/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
INSTALLED_APPS = (
1111
"django.contrib.contenttypes",
1212
"django.contrib.auth",
13+
"django.contrib.admin",
1314
"guardian",
1415
*PROJECT_APPS,
1516
)

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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,93 @@ 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(default="")
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=[AdminBlogPostState.CREATED],
178+
target=AdminBlogPostState.REVIEWED,
179+
)
180+
def moderate(self):
181+
pass
182+
183+
@transition(
184+
field=state,
185+
source=[
186+
AdminBlogPostState.REVIEWED,
187+
AdminBlogPostState.HIDDEN,
188+
],
189+
target=AdminBlogPostState.PUBLISHED,
190+
)
191+
def publish(self):
192+
pass
193+
194+
@transition(
195+
field=state,
196+
source=[
197+
AdminBlogPostState.REVIEWED,
198+
AdminBlogPostState.PUBLISHED,
199+
],
200+
target=AdminBlogPostState.HIDDEN,
201+
)
202+
def hide(self):
203+
pass
204+
205+
# step transitions
206+
207+
@transition(
208+
field=step,
209+
source=[AdminBlogPostStep.STEP_1],
210+
target=AdminBlogPostStep.STEP_2,
211+
)
212+
def step_two(self):
213+
pass
214+
215+
@transition(
216+
field=step,
217+
source=[AdminBlogPostStep.STEP_2],
218+
target=AdminBlogPostStep.STEP_3,
219+
)
220+
def step_three(self):
221+
pass
222+
223+
@transition(
224+
field=step,
225+
source=[
226+
AdminBlogPostStep.STEP_2,
227+
AdminBlogPostStep.STEP_3,
228+
],
229+
target=AdminBlogPostStep.STEP_1,
230+
)
231+
def step_reset(self):
232+
pass

0 commit comments

Comments
 (0)