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:
class Report(models.Model):
text = models.TextField()
state_field = models.CharField(
max_length=150,
choices=ReportState.choices,
default=ReportState.NEW,
)
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¶
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— aStatesubclass.field='state_field'installs the getter, the setter, and anon_successthat callsinstance.save(update_fields=['state_field']).ModelFlow— supplies__init__(self, instance), storing it asself.instance, and inheritsStateEnginesoget_available_transitions()is there.
Everything else is unchanged, because ModelState is a State —
state.transition, State.ANY, conditions, State.RETURN_VALUE,
State.GET_STATE all behave exactly as documented in the guides.
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_successis convenient and wrong for a view that wants onesave()at the end. AModelState(..., save=False)option, or an explicitflow.commit(), may be better than doing it implicitly. - Concurrency. Two workers reading
NEWand both callingapprove()will both succeed. A conditionalUPDATE ... WHERE state_field = %sthat 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
ModelFlowshould open anatomic()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 apermission=argument that this fork dropped. Whether to reintroduce it here — where aUseractually exists — rather than in the dependency-free core. - Where the flow lives.
ReportFlow(report)is explicit. Areport.flowaccessor 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 plainfilter(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=[...]).
Related pages¶
- Binding state to storage — the pattern that works now
- Credits and Attribution — the Viewflow lineage
- Tortoise ORM and SQLAlchemy — the other planned integrations