Workflow Hooks

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

Table of Contents

  1. What It Is
  2. The Events
  3. Registering Hooks
  4. Quick Start
  5. When Hooks Run
  6. Sync vs Async
  7. Rules and Guardrails
  8. Related Guides
  9. A Note on Testing

What It Is

Workflow hooks are the engine's internal extension point: a developer registers a plain Python callable that runs when the engine executes, then uses it to update other records or run business logic. They work for both workflow kinds:

  • approval workflows (workflow_kind='approval'), and
  • state-based workflows (workflow_kind='state_based').

The interface is a single project-level registry, WorkflowTypeConfig.WORKFLOW_HOOKS, declared in the project config class reached through settings.MY_PROJECT_WORKFLOWS_CONFIG (framework/settings.py:32, framework/workflowconfig/hooks.py). It follows the same pattern as WORKFLOW_TYPE_REGISTRY and WORKFLOW_STATE_CHOICES.

flowchart LR
    A["Workflow engine event"] --> B["emit_workflow_event()"]
    B --> C["transaction.on_commit"]
    C --> D["WorkflowHookRegistry.iter_handlers()"]
    D --> E["WORKFLOW_HOOKS config"]
    D --> F["register() runtime hooks"]
    E --> G["handler(context)"]
    F --> G
    G --> H{"raised?"}
    H -->|no| I["done"]
    H -->|yes| J["logger + WorkflowTransaction(action_type='hook')"]

The Events

Event Kind Fires when
submitted both A document is submitted and the workflow instance starts.
step_entered approval An approval step is entered and its approvers are assigned.
approved approval An approval step reaches its approval threshold.
rejected approval A step is rejected (terminal).
changes_requested approval Changes are requested on a step (terminal until resubmission).
state_entered state A branch arrives at a business state (start, move, fork child, join continuation).
state_changed state A user successfully moves a branch (once per transition).
completed both The workflow finishes with no active work left.
cancelled both The workflow is cancelled through a cancel service.

state_entered and state_changed are complementary: state_entered is "the document is now in state X" (including automatic arrivals), and state_changed is "a user moved the document" (even when the destination is a FORK/END node). A plain user move fires both. See the reference for the exact context of each event.

Registering Hooks

Add WORKFLOW_HOOKS to the project config. The recommended package form puts the dict in the workflowconfig/hooks.py submodule:

python
# workflowconfig/hooks.py
WORKFLOW_HOOKS = {
    '*': {
        'submitted': ['myapp.hooks.audit_submit'],
    },
    'IncidentWorkflow': {
        'state_entered': [
            {'handler': 'frameworkapp.workflow_hooks.on_incident_closed',
             'to_state': 'closed'},
        ],
        'completed': ['frameworkapp.workflow_hooks.on_incident_completed'],
    },
    'CreditCardWorkflow': {
        'approved': ['frameworkapp.workflow_hooks.on_credit_card_approved'],
    },
    'InvoiceWorkflow': {
        'approved': ['myapp.hooks.post_invoice'],
    },
}

With the single-file form (workflowconfig.py), put the same dict inside class WorkflowTypeConfig:.

This repository ships two complete, active samples in frameworkapp/workflow_hooks.py, registered in framework/workflowconfig/hooks.py:

Workflow Kind Sample events
IncidentWorkflow state-based submitted, state_changed, state_entered (filtered to closed), completed
CreditCardWorkflow approval submitted, approved, rejected, completed

The credit-card handlers show the "change another record after approval" pattern; because the accounting.CreditCardTransaction model is not part of this repository, they guard get_document() and log instead of writing.

Each entry is either:

  • a dotted path to a callable (runs for every matching event), or
  • a dict {'handler': <dotted path>, ...filters} with optional exact-match filters (from_state, to_state, step_name, document_model).

The '*' workflow key runs for every workflowname. A handler is any callable that takes one context object and returns None:

python
# myapp/hooks.py
def post_invoice(context):
    if context.to_state != 'approved':
        return
    invoice = context.get_document()
    # ... update other records ...

Identifying the record the workflow is attached to

Every event carries the document's identity, so a hook can locate it without loading it:

Access Value Query
context.document_model app_label.ModelName, e.g. frameworkapp.IncidentReport no
context.document_id The primary key as text, e.g. '42' no
context.get_document_id() Native pk (int/UUID), or the raw string if the row is gone yes
context.get_document() The document instance, or None yes
python
from django.apps import apps

def on_approved(context):
    model, record_id = context.document_model, context.get_document_id()
    # update the record without loading it:
    apps.get_model(model).objects.filter(pk=record_id).update(is_active=False)

Quick Start

  1. Write the handler in a module your project already imports (for example myapp/hooks.py):

python def on_incident_submitted(context): incident = context.get_document() if incident is None: return # ... business logic ...

  1. Register it in WorkflowTypeConfig.WORKFLOW_HOOKS (see above). The live samples in this repo are frameworkapp/workflow_hooks.py (IncidentWorkflow and CreditCardWorkflow), registered in framework/workflowconfig/hooks.py.

  2. Verify the registry resolves:

bash python manage.py check_workflow_hooks --json

  1. python manage.py migrate once, to apply the hook history action type (enezaworkflowengine/migrations/0006_alter_workflowtransaction_action_type.py).

When Hooks Run

Hooks are scheduled with transaction.on_commit, so they run after the engine's own writes are committed. That gives two guarantees:

  • A hook can never roll back or veto a workflow transition.
  • A hook failure is caught, logged, and recorded as a hook history entry (WorkflowTransaction, enezaworkflowengine/services/workflow_hooks.py); the remaining hooks still run and the document still moves.

The trade-off is that a hook failure is invisible to the user's HTTP response — inspect the document's workflow history or the server log.

Approval events (step_entered, approved, rejected, changes_requested, completed) and completion run inside background tasks, so they require the task worker (db_worker) to be running. State-based events run in the request thread.

Sync vs Async

By default hooks run inline after commit. Set WORKFLOW_HOOKS_ASYNC = True to enqueue them through django_tasks instead (the same shape as WORKFLOW_STATE_ASYNC); it requires python manage.py db_worker. If enqueueing fails, the hooks fall back to inline execution.

A context variable depth guard (WORKFLOW_HOOKS_MAX_DEPTH, default 10) stops runaway recursion when a hook triggers more workflow work.

Rules and Guardrails

  • Do not write the engine-owned fields (workflowstate, Approvalstatus). The engine keeps them in sync; writing them from a hook races the engine.
  • Hooks are keyed by workflowname, not template version, so they run for whichever template version each instance is pinned to.
  • A hook that cannot be imported is skipped with a warning; use check_workflow_hooks to catch this.
  • Keep handlers idempotent. completed and cancelled fire once per instance, but a state can be re-entered and a document can be resubmitted.

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.