Skip to content

Django ORM Planned

Planned — not implemented yet

open_fsm.contrib.django does not exist. This page describes the API being designed for it, so the shape can be reviewed before it ships. Nothing on this page is executable, and no code block here is run by the test suite.

For what works today, see Binding state to storage — the integration is a wrapper over that pattern, not a replacement for it.

The shape: a flow class wrapping the model

This follows Viewflow's model integration, which open-fsm is a fork of. The workflow does not go on the model. The model keeps a plain field, and a separate flow class wraps an instance of it:

models.py — works today
class Report(models.Model):
    text = models.TextField()
    state_field = models.CharField(
        max_length=150,
        choices=ReportState.choices,
        default=ReportState.NEW,
    )
flows.py — works today
class ReportFlow:
    state = State(ReportState, default=ReportState.NEW)

    def __init__(self, report):
        self.report = report

    @state.getter()
    def _get_state(self):
        return self.report.state_field

    @state.setter()
    def _set_state(self, value):
        self.report.state_field = value

    @state.on_success()
    def _on_success(self, transition, source, target, **kwargs):
        self.report.save()

    @state.transition(source=ReportState.NEW, target=ReportState.APPROVED)
    def approve(self):
        ...

That already works, with no integration code at all. Keeping the machine out of models.py is the point of the pattern, and it is why one record can carry several independent workflows.

What the integration would remove

Of the flow above, only two things are actually about this workflow — the State declaration and the transitions. The rest is the same four methods in every flow anyone writes:

Written by hand today What the integration should do
__init__ storing the instance Provide it
A getter over the model field Derive it from the field name
A setter over the model field Derive it from the field name
save() in on_success Save with update_fields, so a transition writes one column

Proposed API

flows.py — proposed, not yet available
from open_fsm.contrib.django import ModelFlow, ModelState


class ReportFlow(ModelFlow):
    state = ModelState(ReportState, default=ReportState.NEW, field='state_field')

    @state.transition(source=ReportState.NEW, target=ReportState.APPROVED)
    def approve(self):
        Approval.objects.create(report=self.instance)

    @state.transition(source=ReportState.NEW, target=ReportState.REJECTED)
    def reject(self):
        ...

    @state.transition(source=State.ANY, target=ReportState.ARCHIVED)
    def archive(self):
        ...

Two pieces:

  • ModelState — a State subclass. field='state_field' installs the getter, the setter, and an on_success that calls instance.save(update_fields=['state_field']).
  • ModelFlow — supplies __init__(self, instance), storing it as self.instance, and inherits StateEngine so get_available_transitions() is there.

Everything else is unchanged, because ModelState is a Statestate.transition, State.ANY, conditions, State.RETURN_VALUE, State.GET_STATE all behave exactly as documented in the guides.

views.py — proposed, not yet available
def approve_report(request, pk):
    flow = ReportFlow(get_object_or_404(Report, pk=pk))

    try:
        with transaction.atomic():
            flow.approve()
    except TransitionNotAllowed as error:
        return JsonResponse({'detail': str(error)}, status=409)

    return JsonResponse({'state': flow.state})

Under discussion

Opinions are welcome on the issue tracker.

  • Saving policy. Saving inside on_success is convenient and wrong for a view that wants one save() at the end. A ModelState(..., save=False) option, or an explicit flow.commit(), may be better than doing it implicitly.
  • Concurrency. Two workers reading NEW and both calling approve() will both succeed. A conditional UPDATE ... WHERE state_field = %s that fails the transition when no row matched would close that. This is the most valuable thing the integration could add, and the hardest to get right.
  • Transactions. Whether ModelFlow should open an atomic() block around each transition, or leave it to the caller as above. Doing it implicitly hides a decision that belongs to the view.
  • Permissions. Viewflow has flow.approve.has_perm(request.user), driven by a permission= argument that this fork dropped. Whether to reintroduce it here — where a User actually exists — rather than in the dependency-free core.
  • Where the flow lives. ReportFlow(report) is explicit. A report.flow accessor reads better and puts a workflow reference back on the model, which is what the pattern was avoiding.
  • Queryset helpers. Report.objects.in_state(ReportState.NEW) reads well; whether it earns a custom manager over a plain filter(state_field=...) is less clear.

What to do today

Write the four methods. The Order example is the same machine against a plain object, and porting it to a Django model means changing only _on_success to call self.instance.save(update_fields=[...]).