Django friendly finite state machine support for models through FSMField and the @transition decorator.
—
Model mixins for enhanced FSM functionality including refresh_from_db support for protected fields and optimistic locking protection against concurrent transitions.
Mixin that allows refresh_from_db for models with fsm protected fields. This mixin ensures that protected FSM fields are properly handled during database refresh operations.
class FSMModelMixin(object):
def refresh_from_db(self, *args, **kwargs):
"""
Refresh model instance from database while respecting protected FSM fields.
Protected FSM fields are excluded from refresh to prevent corruption
of the finite state machine's integrity.
"""
def _get_protected_fsm_fields(self):
"""
Get set of protected FSM field attribute names.
Returns:
set: Attribute names of protected FSM fields
"""Usage example:
from django.db import models
from django_fsm import FSMField, FSMModelMixin, transition
class Order(FSMModelMixin, models.Model):
state = FSMField(default='pending', protected=True)
amount = models.DecimalField(max_digits=10, decimal_places=2)
@transition(field=state, source='pending', target='confirmed')
def confirm(self):
pass
# Protected fields are automatically excluded during refresh
order = Order.objects.get(pk=1)
order.refresh_from_db() # state field is preserved if protectedProtects models from undesirable effects caused by concurrently executed transitions using optimistic locking. This mixin prevents race conditions where multiple processes try to modify the same object's state simultaneously.
class ConcurrentTransitionMixin(object):
def __init__(self, *args, **kwargs):
"""Initialize concurrent transition protection."""
def save(self, *args, **kwargs):
"""
Save model with concurrent transition protection.
Raises:
ConcurrentTransition: If state has changed since object was fetched
"""
def refresh_from_db(self, *args, **kwargs):
"""Refresh from database and update internal state tracking."""
@property
def state_fields(self):
"""
Get all FSM fields in the model.
Returns:
filter: FSM field instances
"""
def _update_initial_state(self):
"""Update internal tracking of initial state values."""
def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update):
"""
Internal method that performs the actual database update with state validation.
This method is called by Django's save mechanism and adds concurrent
transition protection by filtering the update query on the original
state values.
Parameters:
- base_qs: Base queryset for the update
- using: Database alias to use
- pk_val: Primary key value
- values: Values to update
- update_fields: Fields to update
- forced_update: Whether update is forced
Returns:
bool: True if update was successful
Raises:
ConcurrentTransition: If state has changed since object was fetched
"""Usage example:
from django.db import transaction
from django_fsm import FSMField, ConcurrentTransitionMixin, transition, ConcurrentTransition
class BankAccount(ConcurrentTransitionMixin, models.Model):
state = FSMField(default='active')
balance = models.DecimalField(max_digits=10, decimal_places=2)
@transition(field=state, source='active', target='frozen')
def freeze_account(self):
pass
# Safe concurrent usage pattern
def transfer_money(account_id, amount):
try:
with transaction.atomic():
account = BankAccount.objects.get(pk=account_id)
if account.balance >= amount:
account.balance -= amount
account.save() # Will raise ConcurrentTransition if state changed
except ConcurrentTransition:
# Handle concurrent modification
raise ValueError("Account was modified by another process")You can use both mixins together for maximum protection:
class SafeDocument(FSMModelMixin, ConcurrentTransitionMixin, models.Model):
state = FSMField(default='draft', protected=True)
workflow_state = FSMField(default='new')
content = models.TextField()
@transition(field=state, source='draft', target='published')
def publish(self):
pass
@transition(field=workflow_state, source='new', target='processing')
def start_workflow(self):
passCustomize which fields are used for concurrent protection:
class CustomProtectedModel(ConcurrentTransitionMixin, models.Model):
primary_state = FSMField(default='new')
secondary_state = FSMField(default='inactive')
version = models.PositiveIntegerField(default=1)
def save(self, *args, **kwargs):
# Custom logic before concurrent protection
if self.primary_state == 'published':
self.version += 1
# Call parent save with concurrent protection
super().save(*args, **kwargs)Proper exception handling for concurrent modifications:
from django_fsm import ConcurrentTransition
import time
import random
def safe_state_change(model_instance, transition_method, max_retries=3):
"""
Safely execute state transition with retry logic for concurrent conflicts.
"""
for attempt in range(max_retries):
try:
with transaction.atomic():
# Refresh to get latest state
model_instance.refresh_from_db()
# Execute transition
transition_method()
model_instance.save()
return True
except ConcurrentTransition:
if attempt == max_retries - 1:
raise
# Wait before retry with exponential backoff
time.sleep(0.1 * (2 ** attempt) + random.uniform(0, 0.1))
return False
# Usage
try:
safe_state_change(order, order.confirm)
except ConcurrentTransition:
# Final failure after retries
logger.error(f"Failed to confirm order {order.id} after retries")Combining mixins with Django FSM signals for comprehensive state management:
from django_fsm.signals import post_transition
from django.dispatch import receiver
class AuditedOrder(FSMModelMixin, ConcurrentTransitionMixin, models.Model):
state = FSMField(default='pending', protected=True)
audit_trail = models.JSONField(default=list)
@transition(field=state, source='pending', target='confirmed')
def confirm(self):
pass
@receiver(post_transition, sender=AuditedOrder)
def log_state_change(sender, instance, name, source, target, **kwargs):
"""Log all state changes for audit purposes."""
instance.audit_trail.append({
'timestamp': timezone.now().isoformat(),
'transition': name,
'from_state': source,
'to_state': target,
'user_id': getattr(kwargs.get('user'), 'id', None)
})
# Save without triggering state machine (direct field update)
sender.objects.filter(pk=instance.pk).update(
audit_trail=instance.audit_trail
)When using FSMModelMixin with many protected fields:
class OptimizedModel(FSMModelMixin, models.Model):
state = FSMField(default='new', protected=True)
workflow = FSMField(default='pending', protected=True)
# Use select_related/prefetch_related for efficient queries
@classmethod
def get_with_relations(cls, pk):
return cls.objects.select_related('related_field').get(pk=pk)Handle bulk operations safely:
def bulk_state_update(queryset, transition_method_name):
"""
Safely update multiple objects with concurrent protection.
"""
updated_count = 0
for obj in queryset:
try:
with transaction.atomic():
obj.refresh_from_db()
method = getattr(obj, transition_method_name)
method()
obj.save()
updated_count += 1
except ConcurrentTransition:
# Log failure but continue with other objects
logger.warning(f"Concurrent update conflict for {obj.pk}")
return updated_countEnsure database-level consistency:
class ConstrainedOrder(ConcurrentTransitionMixin, models.Model):
state = FSMField(default='pending')
payment_confirmed = models.BooleanField(default=False)
class Meta:
constraints = [
models.CheckConstraint(
check=~(models.Q(state='shipped') & models.Q(payment_confirmed=False)),
name='shipped_orders_must_be_paid'
)
]
@transition(field=state, source='confirmed', target='shipped')
def ship(self):
if not self.payment_confirmed:
raise ValueError("Cannot ship unpaid order")Install with Tessl CLI
npx tessl i tessl/pypi-django-fsm