Coming from django-fsm: FSMField

The pattern on the previous page – a plain Django field plus State.setter/State.getter on a separate flow class – is how viewflow.fsm is meant to be used, and it’s the one this documentation builds on elsewhere.

If you’re porting a model straight out of django-fsm, viewflow.fsm.FSMField is a same-column drop-in for django-fsm’s own FSMField: swap the import, and the @transition(field=..., source=..., target=...) decorator style keeps working unchanged.

# Before: django-fsm
from django_fsm import FSMField, transition

# After: viewflow.fsm
from viewflow.fsm import FSMField, transition


class Job(models.Model):
    status = FSMField(default='new')

    @transition(field=status, source='new', target='running')
    def start(self):
        pass

status.transition(...) (calling the method directly on the field) works the same way, since transition(field=status, ...) is a thin wrapper over it:

class Job(models.Model):
    status = FSMField(default='new')

    @status.transition(source='new', target='running')
    def start(self):
        pass

The value lives in the model’s own column – no setter/getter pair to write, Job.objects.filter(status='running') and serialization work as they would for any CharField, and the column type is unchanged from django-fsm’s, so a migration generated after the swap is a same-column AlterField at most, never a schema change.

Everything past this point – conditions=, permission=, State.ANY, State.RETURN_VALUE/State.GET_STATE, on_success, NoTransition/TransitionConditionsUnmet – is the same machinery described throughout these docs; FSMField only changes where the value is stored.

Two Opt-in Guarantees

FSMField defaults to django-fsm’s own behavior on two points where django-fsm was permissive. Both default to False so a ported model behaves identically to begin with; turn either on once you’re ready for viewflow’s stricter guarantee.

protected=True

Blocks direct assignment (job.status = 'running') after the instance’s first value is set, the same way a plain State field already blocks flow.state_field = x unconditionally. Transitions are unaffected – they write through State.set(), not attribute assignment.

class Job(models.Model):
    status = FSMField(default='new', protected=True)

    @status.transition(source='new', target='running')
    def start(self):
        pass

job = Job.objects.create()
job.status = 'running'  # AttributeError
job.start()              # fine

Known limitation: protected=True also blocks a plain instance.refresh_from_db(), since that reassigns the field through the same guarded path and the slot is already populated by then. There’s no workaround yet – leave protected off for a model that relies on refresh_from_db().

enforce_initial=True

Closes the gap tracked as django-fsm#218: without it, nothing stops Job.objects.create(status='running') from inserting a row that never went through start(). With it, creating a row (an INSERT, not an UPDATE) at any value other than the field’s default raises NonInitialStateOnCreate, a TransitionNotAllowed subclass – before the row is written.

class Job(models.Model):
    status = FSMField(default='new', enforce_initial=True)

    @status.transition(source='new', target='running')
    def start(self):
        pass

Job.objects.create()                    # fine -- status='new'
Job.objects.create(status='running')    # NonInitialStateOnCreate

The check runs from the field’s pre_save, which Django calls for .save(), .create(), and get_or_create()/update_or_create(). It does not run for bulk_create() or a raw SQL INSERT – both skip pre_save entirely, so neither is guarded by this.