Separate Flow Classes

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.

Model and State Values

# 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 Class

# 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.

Django Views

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('../')

Logging Changes

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.