Specify one or multiple source states:
@state_field.transition(source={States.NEW, States.DONE}, target=States.CANCELED)
def cancel(self):
pass
Use State.ANY to allow transition from any state except the target:
@state_field.transition(source=State.ANY, target=States.CANCELED)
def cancel(self):
pass
The state changes before the method runs. This lets you chain transitions:
@state_field.transition(source=States.NEW, target=States.IN_PROCESS)
def pay(self):
try:
self.perform_payment()
except:
self.error()
@state_field.transition(source=States.IN_PROCESS, target=States.DONE)
def perform_payment(self):
pass
@state_field.transition(source=States.IN_PROCESS, target=States.ERROR)
def payment_error(self):
pass
If you omit the target, no state change happens:
@state_field.transition(source=States.NEW)
def notify(self):
mail_admins('New flow is waiting', self.text)
Stack multiple decorators for different source/target combinations:
@state_field.transition(source=States.NEW, target=States.DONE)
@state_field.transition(source=States.DONE, target=States.NEW)
def toggle(self):
pass
For a target only known at call time, use State.RETURN_VALUE or
State.GET_STATE instead of a fixed state.
State.RETURN_VALUE(*allowed_states) takes the target from the method’s
own return value – resolved only once the method returns, so it can’t
change the state before its own side effects run:
@state_field.transition(
source=States.NEW,
target=State.RETURN_VALUE(States.PUBLISHED, States.FOR_MODERATION),
)
def publish(self, is_public):
return States.PUBLISHED if is_public else States.FOR_MODERATION
State.GET_STATE(func, states=[...]) computes the target from the call’s
own arguments via func(instance, *args, **kwargs), resolved before the
method runs – so, unlike RETURN_VALUE, the method body already observes
the new state:
def get_review_target(instance, approved):
return States.APPROVED if approved else States.REJECTED
@state_field.transition(
source=States.NEW,
target=State.GET_STATE(get_review_target, states=[States.APPROVED, States.REJECTED]),
)
def review(self, approved):
pass
allowed_states/states are optional but recommended: when given, an
out-of-set resolved target raises InvalidTargetState (a
TransitionNotAllowed subclass) instead of silently landing on an
undeclared state, and chart() draws an edge for each.
Omit them only when the destinations genuinely can’t be enumerated ahead of
time – the chart then shows no outgoing edge for that transition.
Coming from django-fsm: GET_STATE there resolves after the method
body runs, same as RETURN_VALUE. Here it resolves before, so the
method body already observes the new state – matching how a plain,
non-dynamic target= already behaves in viewflow.fsm. If your
GET_STATE function reads state the method body itself mutates, port it
to RETURN_VALUE instead, since only that one sees the method’s own
effects.
source=State.ANY combined with a dynamic target interacts with
get_outgoing_transitions()/get_available_transitions() (used to
build transition UIs in the admin, REST API, and custom views): a
transition is left out of a state’s outgoing list only when that state is
one of its declared allowed_states/states. An undeclared dynamic
target can’t be checked this way, so it is listed as outgoing from every
state – including one it might, at runtime, just return you to:
@state_field.transition(source=State.ANY, target=State.RETURN_VALUE())
def force(self, next_state):
return next_state
# force() is always in get_outgoing_transitions(), for every state --
# declare allowed_states/states if you need it excluded from its own
# possible targets.
Add a human-readable label:
@state_field.transition(source=States.NEW, target=States.DONE)
@state_field.transition(source=States.DONE, target=States.NEW, label=_("Toggle back to New"))
def toggle(self):
pass
toggle.label = _("Toggle report state")
Require conditions to be met before a transition can happen. Conditions are functions that return True or False. They should not have side effects.
def can_publish(instance):
# No publishing after 17 hours
if datetime.datetime.now().hour > 17:
return False
return True
Or use a method on the flow class:
def can_destroy(self):
return self.is_under_investigation()
Apply conditions:
@state_field.transition(States.NEW, target=States.DONE, conditions=[this.can_publish])
def publish(self):
pass
Attach permission checks to transitions. Use a callable that takes the flow instance and user:
@state_field.transition(
source=States.NEW,
target=States.DONE,
permission=lambda flow, user: user.has_perm('myapp.delete_review', obj=flow.report)
)
def remove(self):
pass
@state_field.transition(source=States.NEW, target=States.DONE, permission=this.is_owner)
def hide(self):
pass
def is_owner(self, user):
return self.author == user
Check permissions in your code:
if not flow.remove.has_perm(request.user):
raise PermissionDenied
A transition permission is a query, not a gate. flow.remove() runs whether
or not the user has permission. Check has_perm() yourself in the view, as
shown above.
This is on purpose:
PermissionDenied (403), not TransitionNotAllowed,
which means the transition is impossible from the current state.get_available_transitions() lets a view list the allowed transitions and
build the UI before any call. A call-time check would duplicate it.The workflow layer wires this up for you.
Add custom metadata to transitions:
@state.transition(
field=state,
source=STATE.ANY,
target=States.ON_HOLD,
custom=dict(verbose='Hold for legal reasons'))
def legal_hold(self):
"""
Side effects galore
"""