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
| # | Decision | Rationale |
|---|---|---|
| 1 | Outcome ≡ the extra_params bundle | No new concept; action signatures and existing param handling stay as they are |
| 2 | The engine evaluates rules; the action stays unaware | One generic rule engine reusable by every action; admin can validate and preview |
| 3 | Rules are ordered; first match wins, evaluation short-circuits | One bundle per action per transition, so multiple matches would mean competing or merged bundles |
| 4 | One configured action per action name per transition | Confirmed requirement; enforced by a DB constraint |
| 5 | No engine-level fallback. The action is always called; no match means an empty bundle, plus rule_matched=False | Missing-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 |
| 6 | Config moves out of Transition.actions (JSON) into models | A rule must reference a Condition row; JSON cannot hold a foreign key |
| 7 | Condition gains an explicit workflow FK | Conditions are already conceptually scoped to a workflow definition, but that scope is currently inferred backwards through Transition.condition |
| 8 | Param types gain reference types (user, form_definition), stored and passed as ids | Consistent with async actions already receiving event_id and re-fetching; the JSON stays the single source of truth |
| 9 | Reference pickers use a per-type default queryset with optional per-param narrowing | assign_reviewer needs a narrower set than "all company members" |
| 10 | Reference params are configurable only on company-owned workflows | A global (owner=None) template has no company to scope a picker to. Copy it to a company, then fill them in |
| 11 | WorkflowDef.copy() clears reference params rather than carrying them | Any 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 |
| 12 | Every call additionally carries a rule_matched boolean | An 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
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:
workflow = FK(WorkflowDef, null=True, blank=True, on_delete=CASCADE, related_name="conditions")Two notes on the details:
on_delete=CASCADEonActionRule.condition, notSET_NULL.Transition.conditionusesSET_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 — theCASCADEis what stops it happening by accident, as a side effect of a delete elsewhere.ordering = ("position", "id").ConditionGroup/ConditionExpressionorder onpositionalone, with values assigned by the max+1 logic inConditionAdmin.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:
@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,
),
},
],
)| Type | Stored value | Admin widget |
|---|---|---|
string | string | text input (unchanged) |
boolean | boolean | checkbox (unchanged) |
user | user id | picker, scoped to the workflow owner's members by default |
form_definition | form definition id | picker, 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 emptyConditionGroupevaluates 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. positionmust be unique within aTransitionAction.- 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.actionsinto oneTransitionActionplus a singleActionRulewithcondition=Noneand the entry's existingextra_params. Behaviour is identical and there is only ever one execution path; "a configured action with no rules" never exists. Transition.actionsis 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/booleanparams — untouched.assign_reviewer.reviewer_email(follow-up, not in the engine PR) — declare both params. The action readsreviewer(id) first and falls back toreviewer_email, logging a deprecation warning when the email path is taken. Old configs work with no backfill; mark the old paramdeprecatedso it disappears from new configs.WorkflowDef.copy()— must additionally copyTransitionActionrows, theirActionRulerows, and each rule'sCondition(via the existingCondition.copy()), settingCondition.workflowto the new definition.backups—export_company_data.py/import_company_data.pyenumerate 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.