Skip to content

Conditional Actions ​

Status: Implemented. The engine, schema, admin and copy/backup support are in place.

select_responsible_conditional is the first action to use a reference param (responsible, type user) and is the worked example of the pattern.

Still to follow: converting the older actions to reference params (assign_reviewer.reviewer / add_form_to_event.form), and dropping the Transition.actions column once this path has run in production.

Problem ​

Actions today are fire-and-forget: pick an action on a transition, optionally fill a flat set of extra_params, and the engine calls the function. There is no way to make an action behave differently depending on the event.

The behaviour we need, configured per workflow definition:

if expr_A  → assign to person X
if expr_B  → assign to person Y
otherwise  → assign to person Z   (or: assign to no one and log)

So execution stops being "run the action's code" and becomes "evaluate an expression, then produce an outcome."

Core idea ​

An outcome is just the extra_params bundle the action already accepts. A configured action holds an ordered list of rules; each rule is a Condition plus a bundle. The engine walks the rules in order, takes the first whose condition matches, and calls the action with that bundle.

ActionRule(position=1, condition=expr_A, extra_params={"responsible": <id X>})
ActionRule(position=2, condition=expr_B, extra_params={"responsible": <id Y>})
ActionRule(position=3, condition=None,   extra_params={"responsible": <id Z>})

This keeps action signatures unchanged (fn(event, **kwargs)) and needs no new "outcome" vocabulary. A rule with no condition always matches — ConditionEvaluator already treats a null condition as True.

Decisions ​

#DecisionRationale
1Outcome ≡ the extra_params bundleNo new concept; action signatures and existing param handling stay as they are
2The engine evaluates rules; the action stays unawareOne generic rule engine reusable by every action; admin can validate and preview
3Rules are ordered; first match wins, evaluation short-circuitsOne bundle per action per transition, so multiple matches would mean competing or merged bundles
4One configured action per action name per transitionConfirmed requirement; enforced by a DB constraint
5No engine-level fallback. The action is always called; no match means an empty bundle, plus rule_matched=FalseMissing-param handling already lives in the actions (assign_reviewer logs "No reviewer found", assign_responsible logs on an inactive user) and stays there. See decision 12 for why the flag is needed on top
6Config moves out of Transition.actions (JSON) into modelsA rule must reference a Condition row; JSON cannot hold a foreign key
7Condition gains an explicit workflow FKConditions are already conceptually scoped to a workflow definition, but that scope is currently inferred backwards through Transition.condition
8Param types gain reference types (user, form_definition), stored and passed as idsConsistent with async actions already receiving event_id and re-fetching; the JSON stays the single source of truth
9Reference pickers use a per-type default queryset with optional per-param narrowingassign_reviewer needs a narrower set than "all company members"
10Reference params are configurable only on company-owned workflowsA global (owner=None) template has no company to scope a picker to. Copy it to a company, then fill them in
11WorkflowDef.copy() clears reference params rather than carrying themAny def→company copy can carry a user who isn't a member of the target. Carrying it yields a workflow that looks configured and silently assigns someone from the wrong company on the first matching event
12Every call additionally carries a rule_matched booleanAn action taking no params cannot tell a non-match from a match: both hand it {}. assign_responsible_from_site_conditional reads the person off the site and so declares nothing, and without the flag it would assign on every transition with its conditions decorative. Actions driven by their params ignore it

Rejected: run-all-matching rules (produces competing bundles for a single call, and merging them means arbitrary precedence and half-populated params).

Also rejected: skipping the action when no rule matches, as the alternative to decision 12. It reads cleanly for the two conditional assignment actions, but it silently changes what a non-matching rule set means for every other action, and it removes the ability to write an action that does something deliberate on a non-match. Passing the fact down and letting each action decide keeps one execution path and keeps the choice where the behaviour is.

Deferred: named derivation strategies as outcomes. Derived behaviour stays inside the action — assign_responsible with an empty bundle still falls back to site.responsible_person. Revisit only if two competing derivations are needed within one rule list.

Schema ​

python
class TransitionAction(models.Model):
    transition   = FK(Transition, related_name="action_configs", on_delete=CASCADE)
    action_name  = CharField(max_length=255)   # gated by registry + owner_name, as today
    position     = PositiveIntegerField(default=0)

    class Meta:
        unique_together = ("transition", "action_name")   # decision 4
        ordering = ("position", "id")


class ActionRule(models.Model):
    transition_action = FK(TransitionAction, related_name="rules", on_delete=CASCADE)
    condition         = FK(Condition, null=True, blank=True, on_delete=CASCADE)
    extra_params      = JSONField(default=dict)
    position          = PositiveIntegerField(default=0)

    class Meta:
        ordering = ("position", "id")

Plus, on Condition:

python
workflow = FK(WorkflowDef, null=True, blank=True, on_delete=CASCADE, related_name="conditions")

Two notes on the details:

  • on_delete=CASCADE on ActionRule.condition, not SET_NULL. Transition.condition uses SET_NULL, which is harmless there because a null condition already means "always true". On a rule, nulling the condition silently promotes it to an always-match rule that shadows every rule below it.

    The cost is that deleting a condition takes its rules with it, so removing a condition from a rule while keeping the rule needs an explicit operation. The editor API's POST /api/v1/admin/workflow-conditions/<id>/detach/ is it: inside one transaction it clears the references, deletes the condition, and returns the ids of the rules it detached so the caller can say which rules just became unconditional. Nulling a rule's condition is therefore still reachable — the CASCADE is what stops it happening by accident, as a side effect of a delete elsewhere.

  • ordering = ("position", "id"). ConditionGroup/ConditionExpression order on position alone, with values assigned by the max+1 logic in ConditionAdmin.save_formset — which skips anything already positioned, so ties are reachable. A tie between transitions is harmless; a tie between rules makes it nondeterministic which bundle wins.

Execution ​

execute_actions() replaces its JSON walk with:

for config in transition.action_configs.all():          # ordered
    fn = registered_actions.get(config.action_name)
    if not fn: log error; continue

    bundle, matched = {}, False
    for rule in config.rules.all():                     # ordered
        if ConditionEvaluator(rule.condition).evaluate(event):
            bundle, matched = rule.extra_params, True
            break

    bundle = {**bundle, "rule_matched": matched}

    if fn.action["is_async"]:
        fn.delay(event.id, **bundle)
    else:
        fn(event, **bundle)

A config with no rules at all counts as matched: it carries no condition to fail, so it is unconditional rather than never-matching. (The migration below means that state does not arise from existing data, but the admin and the API both allow deleting a config's last rule.)

Async dispatch is unchanged — reference params are already ids, so they pass through .delay(event.id, **bundle) without any serialisation work.

Parameter spec ​

Declared on the decorator, extending the existing extra_params list:

python
@workflow_action(
    "assign_reviewer",
    title="Assign Reviewer",
    extra_params=[
        {
            "name": "reviewer",
            "type": "user",
            "scope": lambda owner: User.objects.filter(
                companymembership__company=owner,
                companymembership__permissions__emission_event_approve=True,
            ),
        },
    ],
)
TypeStored valueAdmin widget
stringstringtext input (unchanged)
booleanbooleancheckbox (unchanged)
useruser idpicker, scoped to the workflow owner's members by default
form_definitionform definition idpicker, scoped to Q(owner__isnull=True) | Q(owner=owner)

scope is optional and takes the workflow's owner, returning a queryset. When absent, the per-type default applies. A string naming a centrally registered filter was considered and rejected — it adds a second registry to keep in sync with the actions.

Optional "deprecated": True on a param spec renders it only when a value is already stored, keeping legacy params out of new configs without breaking old ones.

Config-time validation ​

  • Run ConditionEvaluator.validate() on every rule's condition. This matters much more for rules than for transitions because of two existing quirks: an empty ConditionGroup evaluates true (helpers.py:96) and a condition with no root groups evaluates false (helpers.py:29). A half-built condition therefore either shadows every rule below it or never fires — both silently.
  • position must be unique within a TransitionAction.
  • Reference params must resolve to an object inside the param's scope for that owner.

Admin ​

The existing pattern carries over. nested_admin is already a dependency (Condition → ConditionGroup → ConditionExpression/SubConditionGroup is a three-level nest), and where nesting runs out the code links out to a separate page: TransitionInline.condition_link opens the Condition page with ?transition=<pk>&redirect_to_workflow_on_save=1 and redirects back.

So: TransitionAction gets its own NestedModelAdmin page with ActionRule inlines, and each rule links out to its Condition page with ?action_rule=<pk>, mirroring ?transition=.

To keep the extra hops from hurting, the transition row renders a read-only summary of the configured rules, the same way condition_link already dumps condition.to_html() into a <pre>. The full configuration is then visible at a glance and you only navigate to edit.

This admin is deliberately minimal — a React interface for workflow configuration is planned as follow-up work, so admin UX here is a stopgap and not worth heavy investment.

Condition.workflow (decision 7) is what makes this affordable. Six places currently resolve a condition's owner by reverse lookup through Transition, and all of them would return None for a rule's condition — meaning the expression editor would offer global meta fields instead of the company's, and every rule condition would display as an orphan:

  • ConditionAdmin._cache_meta_fields (admin.py:504)
  • ConditionExpressionInline._retrieve_meta_fields / _retrieve_site_meta_fields (admin.py:198, 223)
  • ConditionAdmin.response_change (admin.py:428)
  • ConditionAdmin.delete_view (admin.py:471)
  • ConditionAdmin.orphan (admin.py:312)

All six collapse to condition.workflow.owner. (ConditionAdmin.save_model already attempts obj.owner = transition.workflow.owner on a model with no such field — a silent no-op, which suggests the field was wanted already.)

Migration and backward compatibility ​

  • Existing configs — a data migration turns each entry in Transition.actions into one TransitionAction plus a single ActionRule with condition=None and the entry's existing extra_params. Behaviour is identical and there is only ever one execution path; "a configured action with no rules" never exists.
  • Transition.actions is kept, not dropped (expand/contract). The column stays populated and the new code stops reading and writing it; a follow-up PR drops it once the new path has run in production. Dropping it in the same migration would make a code rollback fatal — the old code reads that column — and the data migration is one-way otherwise.
  • string / boolean params — untouched.
  • assign_reviewer.reviewer_email (follow-up, not in the engine PR) — declare both params. The action reads reviewer (id) first and falls back to reviewer_email, logging a deprecation warning when the email path is taken. Old configs work with no backfill; mark the old param deprecated so it disappears from new configs.
  • WorkflowDef.copy() — must additionally copy TransitionAction rows, their ActionRule rows, and each rule's Condition (via the existing Condition.copy()), setting Condition.workflow to the new definition.
  • backups — export_company_data.py / import_company_data.py enumerate workflow models explicitly and need the two new models added.

Performance ​

ConditionEvaluator issues several queries per call, which is why WorkflowManager._execute_auto_transitions prefetches condition__groups__expressions today. N rules per action multiplies that, and WorkflowManager.run() already logs an error when a cycle exceeds 60 seconds (services.py:46). Rule conditions need equivalent prefetching in that queryset.

Docs to update on implementation ​

actions.md is already inaccurate against the current code and should be corrected alongside this work: it documents the action signature as (event, transition) (actually (event, **kwargs)), lists only two of the five real actions, and does not mention extra_params at all.