Usage Guide¶
Basic Transitions¶
Add an FSMField to your model and use the transition decorator:
from django_fsm_rx import FSMField, transition
class BlogPost(models.Model):
state = FSMField(default='draft')
@transition(field=state, source='draft', target='published')
def publish(self):
"""This method may contain side effects."""
pass
Call the transition method to change state:
post = BlogPost()
post.publish()
post.save() # State change is not persisted until save()
Checking if Transition is Allowed¶
from django_fsm_rx import can_proceed
if can_proceed(post.publish):
post.publish()
post.save()
Conditions¶
Add conditions that must be met before a transition can occur:
def is_business_hours(instance):
return 9 <= datetime.now().hour < 17
@transition(field=state, source='draft', target='published', conditions=[is_business_hours])
def publish(self):
pass
Protected Fields¶
Prevent direct state assignment:
class BlogPost(FSMModelMixin, models.Model):
state = FSMField(default='draft', protected=True)
post = BlogPost()
post.state = 'published' # Raises AttributeError
Source State Options¶
# From any state
@transition(field=state, source='*', target='cancelled')
def cancel(self):
pass
# From any state except target
@transition(field=state, source='+', target='reset')
def reset(self):
pass
# From multiple specific states
@transition(field=state, source=['draft', 'review'], target='published')
def publish(self):
pass
Dynamic Target State¶
from django_fsm_rx import RETURN_VALUE, GET_STATE
@transition(field=state, source='review', target=RETURN_VALUE('published', 'rejected'))
def moderate(self, approved):
return 'published' if approved else 'rejected'
@transition(
field=state,
source='review',
target=GET_STATE(
lambda self, approved: 'published' if approved else 'rejected',
states=['published', 'rejected']
)
)
def moderate(self, approved):
pass
Permissions¶
@transition(field=state, source='draft', target='published', permission='blog.can_publish')
def publish(self):
pass
@transition(
field=state,
source='*',
target='deleted',
permission=lambda instance, user: user.is_superuser
)
def delete(self):
pass
Check permissions:
from django_fsm_rx import has_transition_perm
if has_transition_perm(post.publish, user):
post.publish()
post.save()
Error Handling¶
Specify a fallback state if transition raises an exception:
@transition(field=state, source='processing', target='complete', on_error='failed')
def process(self):
# If this raises, state becomes 'failed'
do_risky_operation()
Signals¶
from django_fsm_rx.signals import pre_transition, post_transition
@receiver(pre_transition)
def on_pre_transition(sender, instance, name, source, target, **kwargs):
print(f"{instance} transitioning from {source} to {target}")
@receiver(post_transition)
def on_post_transition(sender, instance, name, source, target, **kwargs):
print(f"{instance} transitioned to {target}")
Optimistic Locking¶
Prevent concurrent state changes:
from django_fsm_rx import ConcurrentTransitionMixin
class BlogPost(ConcurrentTransitionMixin, models.Model):
state = FSMField(default='draft')
Integer States¶
class OrderStatus:
PENDING = 10
PROCESSING = 20
SHIPPED = 30
class Order(models.Model):
status = FSMIntegerField(default=OrderStatus.PENDING)
@transition(field=status, source=OrderStatus.PENDING, target=OrderStatus.PROCESSING)
def process(self):
pass
Foreign Key States¶
class OrderState(models.Model):
id = models.CharField(primary_key=True, max_length=50)
label = models.CharField(max_length=100)
class Order(models.Model):
state = FSMKeyField(OrderState, default='pending', on_delete=models.PROTECT)
Model Methods¶
# Get all declared transitions
post.get_all_state_transitions()
# Get transitions available from current state
post.get_available_state_transitions()
# Get transitions available for a specific user
post.get_available_user_state_transitions(user)
Database Migrations for FSM Fields¶
Understanding when Django migrations are required:
Adding a New FSMField¶
When adding a new FSM field to a model, a migration is required (just like any new field):
# Adding a new field - migration required
class Order(models.Model):
status = FSMField(default='pending') # New field
Run python manage.py makemigrations to create the migration.
Converting an Existing CharField to FSMField¶
Converting from CharField to FSMField requires no database schema changes because FSMField inherits directly from CharField:
# Before
status = models.CharField(max_length=50, default='pending')
# After - same database column, just Python-side FSM behavior added
status = FSMField(max_length=50, default='pending')
However, Django’s migration system will detect the field class change and generate a migration. This migration is safe to run - it updates Django’s internal state but makes no database changes (the column remains a VARCHAR).
You can either:
Run the migration (recommended) - It’s a no-op at the database level
Fake it -
python manage.py migrate --fakeif you want to skip execution
Converting Other Field Types¶
Original Field |
Target FSM Field |
Migration Impact |
|---|---|---|
|
|
✅ No schema change (same column type) |
|
|
✅ No schema change (same column type) |
|
|
⚠️ Schema change required (VARCHAR → INTEGER) |
|
|
⚠️ Schema change required (INTEGER → VARCHAR) |
|
|
✅ No schema change (same column type) |
Any other type |
Any FSM field |
⚠️ Check if base types match |
Rule of thumb: If the base Django field type matches, no schema migration is needed.
The protected Parameter¶
The protected=True parameter is Python-only and has no database impact:
# protected=False (default) - allows direct assignment for backward compatibility
status = FSMField(default='pending', protected=False)
instance.status = 'approved' # Works
# protected=True - enforces transitions only
status = FSMField(default='pending', protected=True)
instance.status = 'approved' # Raises AttributeError
Use protected=False when converting existing code that assigns directly to the field, then gradually migrate to using transitions.