Workflow Actions
Actions are functions that execute when a transition fires. They allow workflows to trigger side effects — sending notifications, assigning users, updating records, or calling external services.
Built-in Actions
The following actions are available out of the box:
| Name | Title | Type | Parameters | Description |
|---|---|---|---|---|
assign_responsible | Assign Responsible | Sync | — | Assigns the site's responsible person to the event and queues a pending assignment notification. Does nothing if the site has no responsible person, or if that user is inactive. Unconditional — it runs on every transition it is configured on |
select_responsible_conditional | Select Responsible (Conditional) | Sync | responsible (user) | Assigns the user picked on the rule that matched. No matching rule, or a rule that picked nobody, assigns nobody |
assign_responsible_from_site_conditional | Assign Responsible from Site (Conditional) | Sync | — | Assigns the site's responsible person, but only when one of its rules matched. Does nothing if the site has no responsible person, or if that user is inactive. |
assign_reviewer | Assign Reviewer | Sync | reviewer_email | Assigns a reviewer with approval permission. With no parameter, picks at random from everyone in the company who can approve |
add_form_to_event | Auto-add form to events | Sync | form_id | Attaches a form definition to the event |
send_emission_notification_async | Send emission notification | Async | — | Queues a notification for the event record (used to notify users of new emissions) |
sensorup_integration | SensorUp Integration | Sync | — | Queues the company's EMISSION_RECORD_ACCEPTED integration config for the event's emission record |
dummy_action, dummy_action_async and dummy_action_a100 are also registered, for testing the engine.
The two conditional assignment actions are meant to be used together on one transition: route the events you care about to named people with select_responsible_conditional, and give the rest to whoever owns the site with assign_responsible_from_site_conditional. They replaced a single action that did both jobs, where "assign the site's person" was spelled "configure no user" — a config that looked unfinished and behaved deliberately.
How Actions Work
When a transition fires, the system executes each action configured on it, in order:
- Synchronous actions run inline during the workflow manager cycle, called as
fn(event, **extra_params). - Asynchronous actions are dispatched to Celery as
fn.delay(event.id, **extra_params). The workflow manager does not wait for them to complete. See Scheduled Jobs for Celery queue configuration.
Every action takes the event plus keyword arguments — its parameters, configured per workflow. An action must handle its parameters being absent: that is where the fallback behaviour lives. assign_reviewer with no reviewer_email picks from the whole pool.
Which parameters an action receives can depend on the event. See Conditional Actions for how rules choose between parameter bundles.
rule_matched
Alongside the configured parameters, every call carries a rule_matched boolean saying whether one of the action's rules actually matched this event. The action is called either way — the engine has no fallback of its own — so this is what separates "a rule matched and chose these parameters" from "nothing matched".
Most actions can ignore it. If the whole behaviour comes from the parameters, an empty bundle already means "do nothing", which is the right answer for a non-match.
An action that takes no parameters has no such signal, and must guard on rule_matched explicitly. Without the guard it would run on every transition and its configured conditions would do nothing at all:
from workflows.actions import RULE_MATCHED_PARAM, workflow_action
@workflow_action("my_conditional_action", title="My Conditional Action")
def my_conditional_action(event, **kwargs):
if not kwargs.get(RULE_MATCHED_PARAM, True):
return
...It defaults to True when absent, so calling an action directly — from a test, a shell, or another action — behaves as though it matched. rule_matched is reserved: declaring an extra_params entry by that name raises a WorkflowException at import.
Registering a New Action
Actions are registered using the @workflow_action() decorator. Place the decorator in any actions.py or tasks.py file inside an app listed in OUR_APPS — the registry auto-discovers decorated functions on first use.
Synchronous Action
from workflows.actions import workflow_action
@workflow_action(
"my_action",
title="My Action",
)
def my_action(event, **kwargs):
# perform work here
passAsynchronous Action
Async actions must be Celery shared_task functions. The @workflow_action decorator must be placed above @shared_task.
from celery import shared_task
from workflows.actions import workflow_action
@workflow_action(
"my_async_action",
title="My Async Action",
is_async=True,
)
@shared_task(queue="longrunning")
def my_async_action(event_id, **kwargs):
# perform async work here
passImportant: The decorator order matters.
@workflow_actionmust come before@shared_taskin the source file (i.e., listed first, applied last). An async action receives the event id, not the instance, and re-fetches it.
Action Parameters
| Parameter | Required | Description |
|---|---|---|
name | Yes | Unique identifier for this action. Used in transition configuration |
title | No | Human-readable display name shown in Django Admin. Defaults to name |
owner_name | No | Restrict this action to a specific company by company name. If omitted, the action is available to all companies |
is_async | No | Set to True if the function is a Celery task. Default: False |
extra_params | No | The parameters this action accepts, declared so the admin can offer them. Default: none |
Declaring extra_params
extra_params is a list of param specs. Each needs a name and a type:
@workflow_action(
"my_action",
title="My Action",
extra_params=[
{"name": "assignee", "type": "user"},
{"name": "note", "type": "string"},
{"name": "assignee_email", "type": "string", "deprecated": True},
],
)
def my_action(event, **kwargs):
...| Type | Stored value | Admin widget |
|---|---|---|
string | string | text input |
boolean | boolean | checkbox |
user | user id | picker, scoped to the workflow owner's members |
form_definition | form definition id | picker, scoped to the owner's forms plus the global ones |
select_responsible_conditional is the first built-in action to use one — a responsible param of type user, narrowed with a scope to the company's active members:
@workflow_action(
"select_responsible_conditional",
title="Select Responsible (Conditional)",
extra_params=[
{
"name": "responsible",
"type": "user",
"scope": lambda owner: User.objects.filter(
companymembership__company=owner, is_active=True
),
},
],
)
def select_responsible_conditional(event, **kwargs):
...assign_reviewer and add_form_to_event still take reviewer_email and form_id as strings; converting them is follow-up work.
The reference types (user, form_definition) store an id and are only configurable on company-owned workflow definitions — a global definition has no company to scope the picker to. Copy it to a company, then fill them in.
A stored reference is only as good as the config it came from, so an action should re-check it at runtime: the referenced object may have been deleted, deactivated, or moved to another company since it was configured. Treat that as a misconfiguration and log it rather than silently falling back — a quiet fallback hides a broken config indefinitely. select_responsible_conditional logs and assigns nobody when its responsible user is no longer an active member of the event's company.
Two optional keys:
scope— a callable taking the workflow's owner and returning the queryset the picker offers, when the per-type default is too broad:python{ "name": "reviewer", "type": "user", "scope": lambda owner: User.objects.filter( companymembership__company=owner, companymembership__permissions__emission_event_approve=True, ), }deprecated— renders the param only when a value is already stored, keeping a legacy param out of new configs without breaking existing ones.
Company-Specific Actions
If owner_name is set, the action only appears in the transition editor for that company's workflow definition. Use this to implement custom logic that applies to a single operator.
Action Registry
The action registry is discovered lazily on first use by scanning all actions.py and tasks.py modules in OUR_APPS. Duplicate action names raise a WorkflowException.
To see all registered actions and their company assignments, go to Django Admin → Workflows → Workflow Action Registry.
From there you can also assign or remove company access to individual actions. An action must have a registry row to be configurable at all: no row means no company can use it.
Adding an Action to a Transition
- Open the Workflow Definition in Django Admin.
- In the transition inline, find the Actions column and follow Add Action.
- Pick the action and save. Only actions available to the workflow's company are listed.
- Add one or more rules to say which parameters the action runs with. A rule with no condition always matches; see Conditional Actions.
The transition row then shows a read-only summary of every configured action and its rules, so the whole configuration is visible without navigating.