Skip to content

Commit 31d4f44

Browse files
committed
Add AdminMixin: Form management (#59)
* Add admin Form management
1 parent 9a54dea commit 31d4f44

File tree

13 files changed

+680
-207
lines changed

13 files changed

+680
-207
lines changed

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Unreleased
55
~~~~~~~~~~
66

77
- Add typing
8+
- Add Admin Integration (with custom form management)
89

910

1011
django-fsm-2 4.1.0 2025-11-03

README.md

Lines changed: 86 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class BlogPost(models.Model):
3434
state = FSMField(default='new')
3535

3636
@transition(field=state, source='new', target='published')
37-
def publish(self):
37+
def publish(self, **kwargs):
3838
pass
3939
```
4040

@@ -61,7 +61,7 @@ Or install from git:
6161
uv pip install -e git://github.com/django-commons/django-fsm-2.git#egg=django-fsm
6262
```
6363

64-
Add `django_fsm` to your Django apps:
64+
Add `django_fsm` to your Django apps (Only needed to [graph transitions](#drawing-transitions)):
6565

6666
```python
6767
INSTALLED_APPS = (
@@ -104,7 +104,7 @@ class BlogPost(models.Model):
104104
from django_fsm import transition
105105

106106
@transition(field=state, source='new', target='published')
107-
def publish(self):
107+
def publish(self, **kwargs):
108108
"""
109109
This function may contain side effects,
110110
like updating caches, notifying users, etc.
@@ -119,7 +119,7 @@ changes in memory. **You must call `save()` to persist it**.
119119
```python
120120
from django_fsm import can_proceed
121121

122-
def publish_view(request, post_id):
122+
def publish_view(request, post_id, **kwargs):
123123
post = get_object_or_404(BlogPost, pk=post_id)
124124
if not can_proceed(post.publish):
125125
raise PermissionDenied
@@ -136,13 +136,18 @@ instance and must return truthy/falsey. The functions should not have
136136
side effects.
137137

138138
```python
139-
def can_publish(instance):
139+
def can_publish(instance, **kwargs):
140140
# No publishing after 17 hours
141141
return datetime.datetime.now().hour <= 17
142142

143143
class XXX()
144-
@transition(field=state, source='new', target='published', conditions=[can_publish])
145-
def publish(self):
144+
@transition(
145+
field=state,
146+
source='new',
147+
target='published',
148+
conditions=[can_publish]
149+
)
150+
def publish(self, **kwargs):
146151
pass
147152
```
148153

@@ -153,8 +158,13 @@ class XXX()
153158
def can_destroy(self):
154159
return self.is_under_investigation()
155160

156-
@transition(field=state, source='*', target='destroyed', conditions=[can_destroy])
157-
def destroy(self):
161+
@transition(
162+
field=state,
163+
source='*',
164+
target='destroyed',
165+
conditions=[can_destroy]
166+
)
167+
def destroy(self, **kwargs):
158168
pass
159169
```
160170

@@ -205,7 +215,7 @@ from django_fsm import FSMField, transition, RETURN_VALUE, GET_STATE
205215
source='*',
206216
target=RETURN_VALUE('for_moderators', 'published'),
207217
)
208-
def publish(self, is_public=False):
218+
def publish(self, is_public=False, **kwargs):
209219
return 'for_moderators' if is_public else 'published'
210220

211221
@transition(
@@ -216,7 +226,7 @@ def publish(self, is_public=False):
216226
states=['published', 'rejected'],
217227
),
218228
)
219-
def moderate(self, allowed):
229+
def moderate(self, allowed, **kwargs):
220230
pass
221231

222232
@transition(
@@ -227,7 +237,7 @@ def moderate(self, allowed):
227237
states=['published', 'rejected'],
228238
),
229239
)
230-
def moderate(self, allowed=True):
240+
def moderate(self, allowed=True, **kwargs):
231241
pass
232242
```
233243

@@ -242,7 +252,7 @@ Use `custom` to attach arbitrary data to a transition.
242252
target='onhold',
243253
custom=dict(verbose='Hold for legal reasons'),
244254
)
245-
def legal_hold(self):
255+
def legal_hold(self, **kwargs):
246256
pass
247257
```
248258

@@ -253,7 +263,7 @@ state.
253263

254264
```python
255265
@transition(field=state, source='new', target='published', on_error='failed')
256-
def publish(self):
266+
def publish(self, **kwargs):
257267
"""
258268
Some exception could happen here
259269
"""
@@ -271,7 +281,7 @@ accepts a permission string or a callable that receives `(instance, user)`.
271281
target='published',
272282
permission=lambda instance, user: not user.has_perm('myapp.can_make_mistakes'),
273283
)
274-
def publish(self):
284+
def publish(self, **kwargs):
275285
pass
276286

277287
@transition(
@@ -280,7 +290,7 @@ def publish(self):
280290
target='removed',
281291
permission='myapp.can_remove_post',
282292
)
283-
def remove(self):
293+
def remove(self, **kwargs):
284294
pass
285295
```
286296

@@ -289,7 +299,7 @@ Check permission with `has_transition_perm`:
289299
```python
290300
from django_fsm import has_transition_perm
291301

292-
def publish_view(request, post_id):
302+
def publish_view(request, post_id, **kwargs):
293303
post = get_object_or_404(BlogPost, pk=post_id)
294304
if not has_transition_perm(post.publish, request.user):
295305
raise PermissionDenied
@@ -335,7 +345,7 @@ class BlogPost(models.Model):
335345
state = FSMKeyField(DbState, default='new')
336346

337347
@transition(field=state, source='new', target='published')
338-
def publish(self):
348+
def publish(self, **kwargs):
339349
pass
340350
```
341351

@@ -378,7 +388,7 @@ class BlogPostWithIntegerField(models.Model):
378388
source=BlogPostStateEnum.NEW,
379389
target=BlogPostStateEnum.PUBLISHED,
380390
)
381-
def publish(self):
391+
def publish(self, **kwargs):
382392
pass
383393
```
384394

@@ -420,20 +430,14 @@ rollback of all changes executed in an inconsistent state.
420430

421431
## Admin Integration
422432

423-
1. Make sure `django_fsm` is in your `INSTALLED_APPS` settings:
433+
> 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.
424434
425435
``` python
426-
INSTALLED_APPS = (
427-
...
428-
'django_fsm',
429-
...
430-
)
436+
- from django_fsm_admin.mixins import FSMTransitionMixin
437+
+ from django_fsm.admin import FSMTransitionMixin
431438
```
432439

433-
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.
434-
435-
436-
2. In your admin.py file, use FSMTransitionMixin to add behaviour to your ModelAdmin. FSMTransitionMixin should be before ModelAdmin, the order is important.
440+
1. In your admin.py file, use FSMTransitionMixin to add behaviour to your ModelAdmin. FSMTransitionMixin should be before ModelAdmin, the order is important.
437441

438442
``` python
439443
from django_fsm.admin import FSMTransitionMixin
@@ -444,34 +448,37 @@ class MyAdmin(FSMTransitionMixin, admin.ModelAdmin):
444448
...
445449
```
446450

447-
3. You can customize the label by adding ``custom={"label": "My awesome transition"}`` to the transition decorator
451+
2. You can customize the button by adding `label` and `short_description` to the `custom` attribute of the transition decorator
448452

449453
``` python
450454
@transition(
451455
field='state',
452456
source=['startstate'],
453457
target='finalstate',
454-
custom={"label": False},
458+
custom={
459+
"label": "My awesome transition", # this
460+
"short_description": "Rename blog post", # and this
461+
},
455462
)
456-
def do_something(self, param):
463+
def do_something(self, **kwargs):
457464
...
458465
```
459466

460-
4. By adding ``custom={"admin": False}`` to the transition decorator, one can disallow a transition to show up in the admin interface.
467+
4. Hiding a transition is possible by adding ``custom={"admin": False}`` to the transition decorator:
461468

462469
``` python
463470
@transition(
464471
field='state',
465472
source=['startstate'],
466473
target='finalstate',
467-
custom={"admin": False},
474+
custom={"admin": False}, # this
468475
)
469-
def do_something(self, param):
476+
def do_something(self, **kwargs):
470477
# will not add a button "Do Something" to your admin model interface
471478
```
472479

473-
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.
474-
Then one must explicitly allow that a transition method shows up in the admin interface.
480+
NB: 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.
481+
Then one must explicitly allow that a transition method shows up in the admin interface using `custom={"admin": True}`
475482

476483
``` python
477484
@admin.register(AdminBlogPost)
@@ -480,6 +487,48 @@ class MyAdmin(FSMTransitionMixin, admin.ModelAdmin):
480487
...
481488
```
482489

490+
### Custom Forms
491+
492+
You can attach a custom form to a transition so the admin prompts for input
493+
before the transition runs. Add a `form` entry to `custom` on the transition.
494+
It can be a `forms.Form`/`forms.ModelForm` class or a dotted import path.
495+
496+
```python
497+
from django import forms
498+
from django_fsm import transition
499+
500+
class RenameForm(forms.Form): # DO NOT USE ModelForm
501+
new_title = forms.CharField(max_length=255)
502+
503+
class BlogPost(models.Model):
504+
title = models.CharField(max_length=255)
505+
state = FSMField(default="created")
506+
507+
@transition(
508+
field=state,
509+
source="*",
510+
target="created",
511+
custom={
512+
"label": "Rename",
513+
"short_description": "Rename blog post",
514+
"form": "path.to.RenameForm",
515+
},
516+
)
517+
def rename(self, new_title, **kwargs):
518+
self.title = new_title
519+
```
520+
521+
Behavior details:
522+
523+
- When `form` is set, the transition button redirects to a form view instead of
524+
executing immediately.
525+
- On submit, `cleaned_data` is passed to the transition method as keyword
526+
arguments and the object is saved.
527+
- `RenameForm` receives the current instance automatically.
528+
- You can override the transition form template by setting
529+
`fsm_transition_form_template` on your `ModelAdmin` (or override globally `templates/django_fsm/fsm_admin_transition_form.html`).
530+
531+
483532
## Drawing transitions
484533

485534
Render a graphical overview of your model transitions.

django_fsm/__init__.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,9 +412,7 @@ def get_all_transitions(self, instance_cls: type[_FSMModel]) -> Generator[Transi
412412
"""
413413
Returns [(source, target, name, method)] for all field transitions
414414
"""
415-
transitions = self.transitions[instance_cls]
416-
417-
for transition in transitions.values():
415+
for transition in self.transitions[instance_cls].values():
418416
yield from transition._django_fsm.transitions.values()
419417

420418
@override

0 commit comments

Comments
 (0)