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