Transition Options

Source

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

Target

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

Dynamic Target

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.

Label

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")

Conditions

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

Permissions

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

Why permissions are not enforced inside transitions

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:

  • Authorization belongs in the view. Management commands, Celery tasks, and system transitions have no user but still change state.
  • A failed check is 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.

Custom Properties

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