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 match any source state, including the target itself.
For the model-level decorator, source='*' has this behavior and
source='+' excludes the target; see Django Model State Fields.
@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 Exception:
self.payment_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.
If target selection depends on changes made by the method body, return the
state through RETURN_VALUE. GET_STATE runs before those changes.
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.
import datetime
def can_publish(instance):
# No publishing after 17:00
if datetime.datetime.now().hour > 17:
return False
return True
Or use a method on the flow class with a forward reference:
from django_fsm import this
def can_publish(self):
return not 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.Native State.transition denies permission checks when permission is
omitted. Set permission=None to allow any user, or supply a callable.
The model-level transition(field=...) decorator allows any user by default.
Neither form checks user permissions on a direct method call. The Django Admin Support,
Frontend Viewset and Expose REST Interface integrations perform these checks before running
a user-selected transition.
Add custom metadata to transitions:
@state_field.transition(
source=State.ANY,
target=States.ON_HOLD,
custom=dict(verbose='Hold for legal reasons'))
def legal_hold(self):
pass