Workflow Hooks Reference

Developer guide for the Enezaframework workflow engine. Part of the Workflow series.

Table of Contents

  1. Event Reference
  2. WorkflowHookContext Reference
  3. Filter Semantics
  4. Ordering and De-duplication
  5. Settings
  6. Failure History Rows
  7. check_workflow_hooks
  8. WorkflowHookRegistry API
  9. Troubleshooting
  10. Related Guides
  11. A Note on Testing

Event Reference

WorkflowHookEvent (enezaworkflowengine/models/choices.py) lists the valid event keys.

Event Kind Emitted from Context highlights
submitted both services/workflow_submission.py submit_workflow actor_id, to_state (initial state for state-based), step_name
step_entered approval services/approval_step.py enter_approval_step step_name, metadata.approver_ids
approved approval services/step_handlers.py ApprovalStepHandler._on_approved actor_id, step_name
rejected approval services/step_handlers.py ApprovalStepHandler._on_rejected actor_id, step_name, comment
changes_requested approval services/step_handlers.py ApprovalStepHandler._on_rejected actor_id, step_name, comment
state_entered state services/state_workflow.py start_state_workflow, _move_branch_to_state, _fire_fork to_state, from_state, trigger, metadata.branch_id
state_changed state services/state_workflow.py transition_branch from_state, to_state, comment, metadata.to_state_type
completed both state: _finalize_if_done; approval: EndStepHandler.handle, StepDispatcher._finalize_workflow, process_end_step, complete_workflow metadata.template_name
cancelled both state: cancel_state_workflow; approval: cancel_workflow actor_id, comment, metadata.reason

Notes:

  • state_entered carries trigger = start, transition, fork or join.
  • state_changed fires exactly once per successful transition_branch call. to_state is the resolved landing state code, or '' when the branch landed on a FORK, JOIN or END node — read metadata.to_state_type to tell which.
  • Blocked transitions emit nothing. A rejected/changes-requested step emits only its own event, never completed or cancelled.

WorkflowHookContext Reference

WorkflowHookContext (enezaworkflowengine/services/workflow_hooks.py) is a frozen, JSON-serializable dataclass passed to every handler.

Field Type Notes
event str One of the WorkflowHookEvent values.
workflowname str WorkflowTemplate.workflowname, e.g. IncidentWorkflow.
workflow_kind str approval or state_based.
workflow_instance_id str UUID as text.
document_model str app_label.ModelName, e.g. frameworkapp.IncidentReport.
document_id str Primary key as text.
actor_id str User id who caused the event, or ''.
from_state str Previous state code, or ''.
to_state str Landing state code, or ''.
step_name str Step/state display name.
comment str Transition/approval comment, or ''.
trigger str start/transition/fork/join for state_entered, else ''.
metadata dict Event-specific extras.

Methods (return None if the row no longer exists):

Method Returns
get_document() The workflow's document via document_model + document_id.
get_document_id() The document's primary key — the live pk (int/UUID) when the row exists, otherwise the raw document_id string, or None.
get_workflow_instance() The WorkflowInstance.
get_actor() The user via get_user_model().

To identify the record the workflow is attached to, read document_model and document_id directly (no query), or call get_document_id() for a native-typed pk:

python
def on_submitted(context):
    model = context.document_model   # 'frameworkapp.IncidentReport'
    pk = context.document_id         # '42' (no query)
    pk = context.get_document_id()   # 42 (int/UUID; queries the row)

to_dict() / from_dict() convert to and from the primitive payload used by the async task.

Filter Semantics

A dict entry's filters are compared as strings against the matching context field:

python
'state_entered': [
    {'handler': 'myapp.hooks.on_closed', 'to_state': 'closed'},
    {'handler': 'myapp.hooks.on_reopen', 'from_state': 'closed', 'to_state': 'investigation'},
]
  • Every present, non-empty filter must match exactly (logical AND).
  • Absent or empty filters match anything.
  • Valid filter names: from_state, to_state, step_name, document_model.
  • Unknown keys are ignored.

Ordering and De-duplication

Handlers run sequentially, in this order:

  1. the '*' workflow bucket, in list order,
  2. the matching workflowname bucket, in list order,
  3. runtime hooks added with register(), in registration order.

An identical (handler, filters) pair is executed once per event, even if declared twice.

Settings

Setting Default Purpose
MY_PROJECT_WORKFLOWS_CONFIG framework.workflowconfig.WorkflowTypeConfig (framework/settings.py:32) Dotted path to the class holding WORKFLOW_HOOKS.
WORKFLOW_HOOKS_ASYNC unset (False) True enqueues hooks through django_tasks (requires python manage.py db_worker); falls back to inline if enqueueing fails.
WORKFLOW_HOOKS_MAX_DEPTH 10 Re-entrancy guard; hooks beyond this nesting depth are skipped with a warning.

Failure History Rows

A hook that raises is logged and written to the document's workflow history:

Field Value
action_type hook (WorkflowActionType.HOOK)
step_name context.step_name
message Hook '<dotted path>' failed for event '<event>': <error>
metadata hook, event, error_type, error, from_state, to_state

Successes are not recorded in history, to keep the timeline readable. The history templates render the unknown type through the generic get_action_type_display fallback.

check_workflow_hooks

Validate the registry without running a workflow:

bash
python manage.py check_workflow_hooks
python manage.py check_workflow_hooks --workflowname IncidentWorkflow
python manage.py check_workflow_hooks --json

The report prints MY_PROJECT_WORKFLOWS_CONFIG, the raw WORKFLOW_HOOKS mapping, the valid events, and one row per entry with its status:

Status Meaning
resolved Dotted path imports to a callable.
unresolvable Import failed or the target is not callable.
unknown_event The inner dict key is not a WorkflowHookEvent.
invalid_bucket The workflowname value is not a dict.
invalid_entry The entry is neither a string, a callable, nor a handler dict.

The command exits non-zero when any entry is not resolved, so it can gate CI.

WorkflowHookRegistry API

python
from enezaworkflowengine.services.workflow_hooks import WorkflowHookRegistry

WorkflowHookRegistry.register('InvoiceWorkflow', 'approved', my_callable)
WorkflowHookRegistry.register('*', 'completed', my_callable, document_model='app.Model')
  • register(workflowname, event, handler, **filters) — adds a runtime handler for apps that cannot edit the project config; workflowname='*' matches every workflow. Raises TypeError when the handler is not callable.
  • iter_handlers(workflowname, event, context=None) — yields the resolved callables in execution order.
  • describe() — the flat diagnostic view used by the command.

Runtime hooks live in process memory and are lost on restart; prefer the config registry for anything permanent.

Troubleshooting

Symptom Likely cause Fix
Hooks never run WORKFLOW_HOOKS empty, wrong MY_PROJECT_WORKFLOWS_CONFIG path, or workflowname mismatch python manage.py check_workflow_hooks --json
A single hook never runs Filter value does not match (values are strings), or the dotted path is wrong Check the entry's status and filters in the command output
Approval hooks never run but state hooks do Approval events fire in background tasks; no worker running Start python manage.py db_worker
Hooks do not run in tests transaction.on_commit callbacks are not executed inside a wrapping TestCase transaction Use django.test.TestCase.captureOnCommitCallbacks(), or TransactionTestCase
Hooks run twice Handler registered in both '*' and the workflowname bucket, or duplicate declarations Remove one declaration; de-duplication only catches identical (handler, filters) pairs
Transition succeeds but a hook history row appears A handler raised after commit Read metadata.error; hooks never fail the workflow by design
Recursive hook storm A hook triggers more workflow work Lower WORKFLOW_HOOKS_MAX_DEPTH or guard the hook by state
Async hooks never run WORKFLOW_HOOKS_ASYNC = True without a running db_worker Start the worker, or unset the setting

A Note on Testing

Per project rules, the developer or agent does not run or claim test results. The user is solely responsible for all testing and validation of the output described in this guide.