A separate flow class is an alternative to model state fields.
Use it when a model already has a state column or the transition logic should
live outside the model. State.getter() and State.setter() connect the
flow to the existing column; State.on_success() can persist a completed
transition.
# models.py
from django.db import models
class ReportState(models.TextChoices):
NEW = 'NEW', 'New'
APPROVED = 'APPROVED', 'Approved'
REJECTED = 'REJECTED', 'Rejected'
PUBLISHED = 'PUBLISHED', 'Published'
class Report(models.Model):
text = models.TextField()
state_field = models.CharField(
max_length=150,
choices=ReportState.choices,
default=ReportState.NEW,
)
# flow.py
from django_fsm import State, this
from .models import ReportState
class ReportFlow:
state_field = State(ReportState, default=ReportState.NEW)
def __init__(self, report):
self.report = report
@state_field.setter()
def _set_report_state(self, value):
self.report.state_field = value
@state_field.getter()
def _get_report_state(self):
return self.report.state_field
def can_manage(self, user):
permission = f'{self.report._meta.app_label}.change_report'
return user.has_perm(permission, self.report) or user.has_perm(permission)
@state_field.transition(
source=ReportState.NEW, target=ReportState.APPROVED,
permission=this.can_manage,
)
def approve(self):
pass
@state_field.transition(
source=ReportState.NEW, target=ReportState.REJECTED,
permission=this.can_manage,
)
def reject(self):
pass
@state_field.transition(
source=ReportState.APPROVED, target=ReportState.PUBLISHED,
permission=this.can_manage,
)
def publish(self):
pass
@state_field.on_success()
def _save_report(self, descriptor, source, target, **kwargs):
self.report.save()
The model’s state column remains an ordinary CharField. The flow guards
calls through its transition methods; it does not prevent direct writes to
that column. Use FSMField(protected=True) when assignment protection on
the model is required.
A direct transition call does not check the user. Perform permission and
state checks in the view. The transaction covers the transition and the
on_success database write:
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.http import require_POST
from .flow import ReportFlow
from .models import Report
@require_POST
@transaction.atomic
def approve(request, report_pk):
report = get_object_or_404(Report.objects.select_for_update(), pk=report_pk)
flow = ReportFlow(report)
if not flow.approve.has_perm(request.user) or not flow.approve.can_proceed():
raise PermissionDenied
flow.approve()
return redirect('../')
A state has one on_success hook. Replace the save hook with one that saves
and records the change together:
# models.py
class ReportChangeLog(models.Model):
report = models.ForeignKey(Report, on_delete=models.CASCADE)
changed = models.DateTimeField(auto_now_add=True)
source = models.CharField(max_length=150)
target = models.CharField(max_length=150)
# flow.py, after the ReportFlow definition
from django.db import transaction
from django_fsm.utils import DEFAULT
from .models import ReportChangeLog
@ReportFlow.state_field.on_success()
def save_and_log(flow, descriptor, source, target, **kwargs):
with transaction.atomic():
flow.report.save()
if target is not DEFAULT:
ReportChangeLog.objects.create(
report=flow.report,
source=str(source),
target=str(target),
)
TextChoices values may be plain strings after database loading, so the
log uses str() rather than assuming every state has a .value attribute.
For model-backed FSMField transitions, signals provide
another way to record changes.