
Frappe Core Workflow
- 24 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with automation & workflows tasks.
About
frappe-core-workflow is a Claude Code skill for automation & workflows. It helps solo builders move faster with AI-assisted development.
- frappe-core-workflow
- Automation & Workflows
- AI-coding skill
Frappe Core Workflow by the numbers
- 24 all-time installs (skills.sh)
- Ranked #1,254 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-core-workflowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with automation & workflows tasks.
Files
Workflow Engine
The Frappe Workflow engine is a state machine that controls document lifecycle through configurable states, transitions, and role-based permissions. It governs when and how documents change status, who can perform actions, and what side effects occur on each transition.
Quick Reference
Workflow DocType → Defines the state machine for a specific DocType
├── states (child table) → Workflow Document State rows
│ ├── state → Link to Workflow State
│ ├── doc_status → 0 (Draft), 1 (Submitted), 2 (Cancelled)
│ ├── allow_edit → Role that can edit in this state
│ ├── update_field → Field to update when entering state
│ ├── update_value → Value to set (literal or expression)
│ └── next_action_email_template → Email Template link
└── transitions (child table) → Workflow Transition rows
├── state → Source state (Link to Workflow State)
├── action → Link to Workflow Action Master
├── next_state → Target state (Link to Workflow State)
├── allowed → Role that can perform this action
├── allow_self_approval → Check (default: 1)
├── condition → Python expression (optional)
└── transition_tasks → Link to Workflow Transition TasksKey Fields on Workflow DocType
| Field | Type | Purpose |
|---|---|---|
workflow_name | Data | Unique identifier |
document_type | Link → DocType | Target DocType |
is_active | Check | Only ONE workflow per DocType can be active |
workflow_state_field | Data | Default: workflow_state |
override_status | Check | Prevent workflow from overriding list view status |
send_email_alert | Check | Email notifications with next possible actions |
How the Engine Works
1. Activation and Field Creation
When a Workflow is saved with is_active = 1:
- All other workflows for the same DocType are deactivated automatically
- A hidden Custom Field (
workflow_state_field, defaultworkflow_state) is created on the target DocType if it does not exist - The field is type
LinktoWorkflow State, withhidden=1,allow_on_submit=1,no_copy=1 - Existing documents with empty workflow state get their state set based on their current
docstatus
2. State Resolution
Every document under a workflow has a workflow_state field. The engine resolves available transitions by:
1. Reading current workflow_state from the document 2. Filtering workflow.transitions where transition.state == current_state 3. Filtering by user roles: transition.allowed in frappe.get_roles() 4. Evaluating transition.condition via frappe.safe_eval() (if set) 5. Returning matching transitions as available actions
3. Applying a Transition
When apply_workflow(doc, action) is called:
1. Load document from DB (fresh read) 2. Get available transitions for current user 3. Find transition matching the requested action 4. Check self-approval: blocked if allow_self_approval=0 AND user is document owner 5. Set workflow_state_field to transition.next_state 6. If update_field is set on the target state, update that field 7. Execute transition tasks (sync first, then async via frappe.enqueue) 8. Handle docstatus change based on source/target state doc_status values 9. Save/Submit/Cancel document accordingly 10. Add workflow comment
Workflow and DocStatus Interaction
CRITICAL: The workflow engine controls docstatus transitions. You NEVER call doc.submit() or doc.cancel() directly on a workflow-controlled document. The workflow does it.
DocStatus Transition Rules
| Source doc_status | Target doc_status | Engine Action | Valid? |
|---|---|---|---|
| 0 (Draft) | 0 (Draft) | doc.save() | YES |
| 0 (Draft) | 1 (Submitted) | doc.submit() | YES |
| 1 (Submitted) | 1 (Submitted) | doc.save() | YES |
| 1 (Submitted) | 2 (Cancelled) | doc.cancel() | YES |
| 2 (Cancelled) | ANY | BLOCKED | NO |
| 1 (Submitted) | 0 (Draft) | BLOCKED | NO |
| 0 (Draft) | 2 (Cancelled) | BLOCKED | NO |
ALWAYS define your states so that docstatus only moves forward: 0→0, 0→1, 1→1, 1→2. NEVER create a transition from a cancelled state or from submitted back to draft.
Non-Submittable DocTypes
If the target DocType is NOT submittable, ALL states MUST have doc_status = 0. The engine validates this and throws an error if any state has doc_status = 1 or 2 on a non-submittable DocType.
Workflow States
Workflow State is a separate DocType used as a master list. Each state has:
| Field | Purpose |
|---|---|
state | Display name of the state |
style | CSS class for badge display (Primary, Success, Warning, Danger, Info, Inverse) |
icon | Font Awesome icon class |
State Row Fields (Workflow Document State)
| Field | Purpose |
|---|---|
state | Link to Workflow State |
doc_status | Select: 0, 1, or 2 |
allow_edit | Link to Role — ONLY this role can edit the document in this state |
update_field | Field to update when document enters this state |
update_value | Value to set (string or Python expression if evaluate_as_expression=1) |
is_optional_state | Check — optional states are skipped in get_next_possible_transitions |
send_email | Check (default 1) — send email notification on entering this state |
next_action_email_template | Link to Email Template |
message | Text message for the email notification |
Workflow Transitions
Each transition row defines one possible action:
| Field | Purpose |
|---|---|
state | Source state (MUST exist in states table) |
action | Link to Workflow Action Master (e.g., "Approve", "Reject", "Review") |
next_state | Target state (MUST exist in states table) |
allowed | Link to Role — ONLY users with this role see this action |
allow_self_approval | Check (default 1) — if 0, document owner cannot perform this action |
condition | Python expression evaluated with frappe.safe_eval() |
transition_tasks | Link to Workflow Transition Tasks (v15+) |
Condition Expressions
Conditions are Python expressions evaluated in a sandboxed environment. Available globals:
# Available in condition expressions:
frappe.db.get_value(doctype, name, fieldname)
frappe.db.get_list(doctype, filters, fields)
frappe.session.user
frappe.session.roles # NOT available — use frappe.get_roles() outside conditions
frappe.utils.now_datetime()
frappe.utils.add_to_date(date, **kwargs)
frappe.utils.get_datetime(datetime_str)
frappe.utils.now()
doc.fieldname # Access any field on the document (as dict)Example conditions:
doc.grand_total > 50000
doc.department == "HR"
doc.grand_total > 50000 and doc.department != "Finance"Workflow Actions
Workflow Action Master
Simple DocType with just a workflow_action_name field. Common actions: Approve, Reject, Review, Send Back, Cancel. Create these first before defining transitions.
Workflow Action DocType
Tracks pending actions for users. Created automatically when a document enters a state with outgoing transitions.
| Field | Purpose |
|---|---|
status | Open or Completed |
reference_doctype | The DocType of the document |
reference_name | The document name |
workflow_state | Current workflow state |
user | Assigned user |
permitted_roles | Table MultiSelect of roles that can act |
completed_by | User who completed the action |
completed_by_role | Role used to complete |
Workflow Actions appear in the user's "Workflow Action" list and can be acted on via email links.
Self-Approval Control
def has_approval_access(user, doc, transition):
return (user == "Administrator"
or transition.get("allow_self_approval")
or user != doc.get("owner"))- Administrator ALWAYS has approval access regardless of settings
- If
allow_self_approval = 1(default): document owner CAN approve - If
allow_self_approval = 0: document owner CANNOT approve their own document
Decision Tree
Need workflow on a DocType?
├── Is DocType submittable?
│ ├── YES → States can use doc_status 0, 1, 2
│ └── NO → ALL states MUST have doc_status = 0
├── Define states → Create Workflow State records first
├── Define transitions → Need Workflow Action Master records first
├── Who can edit in each state? → Set allow_edit per state
├── Need conditional transitions?
│ └── Use Python expressions with doc.field access
├── Need to block self-approval?
│ └── Set allow_self_approval = 0 on specific transitions
└── Need email notifications?
└── Set send_email_alert on Workflow + email templates on statesCommon Errors
| Error | Cause | Fix |
|---|---|---|
WorkflowStateError | Document has no workflow_state set | Ensure workflow sets initial state on creation |
WorkflowTransitionError | Action not valid for current state/role | Verify transitions table covers all needed paths |
WorkflowPermissionError | User lacks role for transition, or self-approval blocked | Check allowed role and allow_self_approval |
| "Illegal Document Status" | Invalid docstatus transition (e.g., 0→2) | Fix state doc_status values |
| "Cannot cancel before submitting" | Transition from draft (0) to cancelled (2) | Add intermediate submitted (1) state |
See Also
- API Reference — Complete workflow Python API
- Examples — Workflow configuration examples
- Anti-Patterns — Common mistakes and how to avoid them
frappe-impl-workflow— Step-by-step implementation guide
Workflow Engine Anti-Patterns
Anti-Pattern 1: Calling doc.submit() on Workflow-Controlled Documents
NEVER call doc.submit() or doc.cancel() directly when a workflow is active.
# WRONG — bypasses workflow validation
doc = frappe.get_doc("Purchase Order", "PO-00001")
doc.submit() # Raises WorkflowPermissionError or creates inconsistent state
# CORRECT — use the workflow engine
from frappe.model.workflow import apply_workflow
apply_workflow(doc, "Approve") # Engine handles submit/cancel internallyWhy: The workflow engine manages docstatus transitions. Calling submit/cancel directly bypasses state validation, self-approval checks, and transition tasks.
Anti-Pattern 2: DocStatus Going Backwards
NEVER create transitions where docstatus decreases.
# WRONG — submitted (1) back to draft (0)
{"state": "Approved", "doc_status": "1"}, # source
{"state": "Revision", "doc_status": "0"}, # target — INVALID
# CORRECT — keep submitted documents at docstatus 1
{"state": "Approved", "doc_status": "1"},
{"state": "Under Revision", "doc_status": "1"}, # Still submitted, just different stateWhy: Frappe enforces 1 → 0 is illegal. The engine will throw "Submitted Document cannot be converted back to draft."
Anti-Pattern 3: Skipping DocStatus 1 (Draft to Cancelled)
NEVER create a transition from doc_status = 0 to doc_status = 2.
# WRONG — draft directly to cancelled
{"state": "Draft", "doc_status": "0"},
{"state": "Cancelled", "doc_status": "2"}, # Cannot cancel before submitting
# CORRECT — go through submitted first
{"state": "Draft", "doc_status": "0"},
{"state": "Approved", "doc_status": "1"},
{"state": "Cancelled", "doc_status": "2"},Anti-Pattern 4: Transitions from Cancelled State
NEVER add outgoing transitions from a state with doc_status = 2.
# WRONG — cannot transition from cancelled
{"state": "Cancelled", "action": "Reopen", "next_state": "Draft"}
# Throws: "Cannot change state of Cancelled Document"
# CORRECT — use Amend (creates a new document) instead of reopenAnti-Pattern 5: Non-Submittable DocType with DocStatus > 0
NEVER set doc_status = 1 or doc_status = 2 on a non-submittable DocType.
# WRONG — Task is not submittable
{"state": "Completed", "doc_status": "1"}
# Throws: "DocType 'Task' is not submittable. Only Document Status 0 is allowed"
# CORRECT
{"state": "Completed", "doc_status": "0"}Anti-Pattern 6: Dead-End States Without Intent
NEVER leave a state with no outgoing transitions unless it is a deliberate terminal state.
# WRONG — "On Hold" has no way out
states = [
{"state": "Draft", "doc_status": "0"},
{"state": "On Hold", "doc_status": "0"}, # Dead end!
{"state": "Approved", "doc_status": "1"},
]
transitions = [
{"state": "Draft", "action": "Hold", "next_state": "On Hold", "allowed": "Manager"},
{"state": "Draft", "action": "Approve", "next_state": "Approved", "allowed": "Manager"},
]
# Document gets stuck in "On Hold" forever
# CORRECT — add a way back
transitions = [
{"state": "Draft", "action": "Hold", "next_state": "On Hold", "allowed": "Manager"},
{"state": "On Hold", "action": "Resume", "next_state": "Draft", "allowed": "Manager"},
{"state": "Draft", "action": "Approve", "next_state": "Approved", "allowed": "Manager"},
]Anti-Pattern 7: Missing Self-Approval Check on Approval Transitions
ALWAYS explicitly set allow_self_approval = 0 on approval transitions in financial/compliance workflows.
# WRONG — creator can approve their own expense claim (default is allow_self_approval=1)
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "Expense Approver"}
# CORRECT — block self-approval
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "Expense Approver", "allow_self_approval": 0}Anti-Pattern 8: Overlapping Conditions Without Full Coverage
ALWAYS ensure conditions cover all cases when using conditional transitions.
# WRONG — gap: what if grand_total == 50000 exactly?
{"condition": "doc.grand_total < 50000", ...},
{"condition": "doc.grand_total > 50000", ...},
# Document with grand_total == 50000 has NO valid transition
# CORRECT — no gaps
{"condition": "doc.grand_total <= 50000", ...},
{"condition": "doc.grand_total > 50000", ...},Anti-Pattern 9: Using frappe.get_roles() in Condition Expressions
NEVER use frappe.get_roles() inside condition expressions — it is not available in the safe eval context.
# WRONG — frappe.get_roles() not in safe globals
{"condition": "'Manager' in frappe.get_roles()"}
# CORRECT — role filtering is done by the transition's "allowed" field
# Use conditions only for document-field-based logic
{"condition": "doc.department == 'Finance'", "allowed": "Manager"}Anti-Pattern 10: Multiple Active Workflows for Same DocType
NEVER try to have two active workflows for the same DocType. Frappe automatically deactivates previous ones.
# If you activate "Workflow B" for Purchase Order,
# "Workflow A" for Purchase Order is automatically deactivated.
# There is ALWAYS exactly 0 or 1 active workflow per DocType.Anti-Pattern 11: Setting workflow_state Directly
NEVER set the workflow_state field directly on a document.
# WRONG — bypasses all validation
doc.workflow_state = "Approved"
doc.save() # Will trigger validate_workflow and likely fail
# CORRECT
from frappe.model.workflow import apply_workflow
apply_workflow(doc, "Approve")Anti-Pattern 12: Forgetting to Create Workflow State Records
ALWAYS create Workflow State records before creating the Workflow.
# WRONG — referencing states that don't exist in Workflow State DocType
# This will fail with LinkValidationError
# CORRECT — create states first
for state_name in ["Draft", "Pending", "Approved"]:
if not frappe.db.exists("Workflow State", state_name):
frappe.get_doc({"doctype": "Workflow State",
"workflow_state_name": state_name}).insert()Workflow API Reference
Complete Python API for Frappe's workflow engine. Source: frappe/model/workflow.py and frappe/workflow/doctype/workflow_action/workflow_action.py.
Core Functions
get_workflow_name(doctype) → str | None
Returns the name of the active workflow for a DocType. Uses cache (frappe.cache.hget("workflow", doctype)).
from frappe.model.workflow import get_workflow_name
workflow_name = get_workflow_name("Purchase Order")
# Returns: "Purchase Order Approval" or NoneALWAYS check for None return — not every DocType has an active workflow.
get_workflow(doctype) → Workflow
Returns the cached Workflow document for a DocType. Calls frappe.get_cached_doc("Workflow", get_workflow_name(doctype)).
from frappe.model.workflow import get_workflow
workflow = get_workflow("Purchase Order")
# Returns: Workflow document with .states and .transitionsget_transitions(doc, workflow=None, raise_exception=False) → list[dict]
Whitelisted. Returns available transitions for the given document based on current user's roles and transition conditions.
from frappe.model.workflow import get_transitions
doc = frappe.get_doc("Purchase Order", "PO-00001")
transitions = get_transitions(doc)
# Returns: [{"state": "Draft", "action": "Approve", "next_state": "Approved", "allowed": "Manager", ...}]Parameters:
doc— Document instance, JSON string, or dict. If not a Document, it is parsed and loaded from DB.workflow— Optional Workflow document (avoids re-fetching).raise_exception— IfTrue, raisesWorkflowStateErrorwhen workflow state is not set. Default:False(usesfrappe.throw).
Logic: 1. Returns [] for new (unsaved) documents 2. Checks doc.check_permission("read") 3. Filters transitions by: transition.state == current_state 4. Filters by: transition.allowed in frappe.get_roles() 5. Evaluates transition.condition via frappe.safe_eval() 6. Returns matching transitions as list of dicts
apply_workflow(doc, action) → Document
Whitelisted. Applies a workflow action to a document — the primary entry point for state changes.
from frappe.model.workflow import apply_workflow
doc = frappe.get_doc("Purchase Order", "PO-00001")
updated_doc = apply_workflow(doc, "Approve")Parameters:
doc— Document instance, JSON string, or dictaction— String matching a Workflow Action Master name (e.g., "Approve")
Execution flow: 1. Load document fresh from DB 2. Get available transitions via get_transitions() 3. Find transition matching action 4. Validate self-approval access 5. Set workflow_state_field to transition.next_state 6. Execute update_field/update_value if configured on target state 7. Execute transition tasks (sync, then async) 8. Handle docstatus transition:
- Draft→Draft:
doc.save() - Draft→Submitted:
doc.submit()(or queue ifqueue_in_background) - Submitted→Submitted:
doc.save() - Submitted→Cancelled:
doc.cancel()
9. Add workflow comment 10. Return updated document
Raises:
WorkflowTransitionError— Action not valid for current state/userfrappe.throw— Self-approval blocked
validate_workflow(doc)
Called automatically during document save. Validates that any manual workflow state change is a valid transition.
# This is called internally — you do NOT call it directly
# It runs during doc.save() / doc.submit() when a workflow is activeLogic: 1. Gets current state from doc._doc_before_save 2. Gets next state from current document 3. If states differ, validates that a transition exists for the change 4. Raises WorkflowPermissionError if no valid transition found
has_approval_access(user, doc, transition) → bool
Checks if a user can approve a document through a specific transition.
from frappe.model.workflow import has_approval_access
can_approve = has_approval_access("user@example.com", doc, transition)Returns `True` if ANY of:
user == "Administrator"transition.allow_self_approval == 1user != doc.owner(user is not the document creator)
is_transition_condition_satisfied(transition, doc) → bool
Evaluates a transition's Python condition expression.
from frappe.model.workflow import is_transition_condition_satisfied
satisfied = is_transition_condition_satisfied(transition, doc)Uses frappe.safe_eval() with restricted globals (see get_workflow_safe_globals()).
can_cancel_document(doctype) → bool
Checks if documents of this DocType can be cancelled outside the workflow (e.g., via Amend).
from frappe.model.workflow import can_cancel_document
can_cancel = can_cancel_document("Purchase Order")Returns True if there are no cancelling states OR if no transitions lead to cancelling states.
bulk_workflow_approval(docnames, doctype, action)
Whitelisted. Applies workflow action to multiple documents at once.
import json
from frappe.model.workflow import bulk_workflow_approval
docnames = json.dumps(["PO-00001", "PO-00002", "PO-00003"])
bulk_workflow_approval(docnames, "Purchase Order", "Approve")Behavior:
- < 20 documents: processed synchronously
- 20-500 documents: enqueued as background job
- > 500 documents: raises error ("Too Many Documents")
get_workflow_state_count(doctype, workflow_state_field, states) → list[dict]
Whitelisted. Returns count of documents grouped by workflow state, excluding specified states.
from frappe.workflow.doctype.workflow.workflow import get_workflow_state_count
counts = get_workflow_state_count("Purchase Order", "workflow_state", ["Draft"])
# Returns: [{"workflow_state": "Pending Approval", "count": 5}, ...]Condition Expression Globals
The get_workflow_safe_globals() function provides these objects inside condition expressions:
{
"frappe": {
"db": {
"get_value": frappe.db.get_value,
"get_list": frappe.db.get_list,
},
"session": frappe.session, # .user, .roles, etc.
"utils": {
"now_datetime": frappe.utils.now_datetime,
"add_to_date": frappe.utils.add_to_date,
"get_datetime": frappe.utils.get_datetime,
"now": frappe.utils.now,
},
}
}The document is available as doc (a dict from doc.as_dict()).
Workflow Action Functions
process_workflow_actions(doc, state)
Called automatically after document save. Creates/updates Workflow Action records for pending approvals.
get_next_possible_transitions(workflow_name, state, doc=None) → list
Returns transitions from current state, filtering by condition satisfaction and skipping optional states.
clear_workflow_actions(doctype, name)
Removes all Workflow Action records for a deleted document.
update_completed_workflow_actions(doc, user, workflow, workflow_state)
Marks matching Workflow Actions as "Completed" after a transition.
Exception Classes
from frappe.model.workflow import (
WorkflowStateError, # Workflow state not set or invalid
WorkflowTransitionError, # Invalid action for current state
WorkflowPermissionError, # User lacks permission for transition
)All three inherit from frappe.ValidationError.
Hooks
workflow_methods
Register custom task methods for Workflow Transition Tasks:
# hooks.py
workflow_methods = [
{"name": "Custom Task", "method": "myapp.utils.custom_workflow_task"}
]Built-in Transition Tasks (v15+)
- Webhook — Executes a Webhook document
- Server Script — Executes a Server Script via
execute_workflow_task
Client-Side API
// Get available transitions for current document
frappe.xcall("frappe.model.workflow.get_transitions", {doc: cur_frm.doc})
.then(transitions => console.log(transitions));
// Apply a workflow action
frappe.xcall("frappe.model.workflow.apply_workflow", {
doc: cur_frm.doc,
action: "Approve"
}).then(doc => cur_frm.reload_doc());
// Bulk approval
frappe.xcall("frappe.model.workflow.bulk_workflow_approval", {
docnames: JSON.stringify(["PO-001", "PO-002"]),
doctype: "Purchase Order",
action: "Approve"
});Workflow Engine Examples
Example 1: Minimal Two-State Workflow
The simplest possible workflow — Draft to Approved.
# Prerequisites: Workflow State "Draft" and "Approved" must exist
# Workflow Action Master "Approve" must exist
workflow = frappe.get_doc({
"doctype": "Workflow",
"workflow_name": "Simple Approval",
"document_type": "ToDo", # Non-submittable
"is_active": 1,
"states": [
{"state": "Draft", "doc_status": "0", "allow_edit": "All"},
{"state": "Approved", "doc_status": "0", "allow_edit": "System Manager"},
],
"transitions": [
{
"state": "Draft",
"action": "Approve",
"next_state": "Approved",
"allowed": "System Manager",
},
],
})
workflow.insert()Note: Non-submittable DocType — ALL states MUST have doc_status = 0.
Example 2: Submittable DocType with Full Lifecycle
workflow = frappe.get_doc({
"doctype": "Workflow",
"workflow_name": "Invoice Approval",
"document_type": "Sales Invoice",
"is_active": 1,
"send_email_alert": 1,
"states": [
{"state": "Draft", "doc_status": "0", "allow_edit": "Accounts User"},
{"state": "Pending Approval", "doc_status": "0", "allow_edit": "Accounts Manager"},
{"state": "Approved", "doc_status": "1", "allow_edit": "Accounts Manager"},
{"state": "Cancelled", "doc_status": "2"},
],
"transitions": [
{"state": "Draft", "action": "Submit for Review", "next_state": "Pending Approval",
"allowed": "Accounts User", "allow_self_approval": 1},
{"state": "Pending Approval", "action": "Approve", "next_state": "Approved",
"allowed": "Accounts Manager", "allow_self_approval": 0},
{"state": "Pending Approval", "action": "Reject", "next_state": "Draft",
"allowed": "Accounts Manager"},
{"state": "Approved", "action": "Cancel", "next_state": "Cancelled",
"allowed": "Accounts Manager"},
],
})
workflow.insert()Example 3: Conditional Transitions Based on Amount
transitions = [
# Low value — Team Lead can approve
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "Team Lead", "condition": "doc.grand_total <= 10000"},
# Medium value — Manager required
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "Manager", "condition": "doc.grand_total > 10000 and doc.grand_total <= 100000"},
# High value — Director required
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "Director", "condition": "doc.grand_total > 100000"},
]Example 4: Querying Workflow State Programmatically
from frappe.model.workflow import get_workflow_name, get_transitions, apply_workflow
# Check if a DocType has an active workflow
workflow_name = get_workflow_name("Purchase Order")
if workflow_name:
print(f"Active workflow: {workflow_name}")
# Get available transitions for a document
doc = frappe.get_doc("Purchase Order", "PO-00001")
transitions = get_transitions(doc)
for t in transitions:
print(f"Action: {t['action']} → {t['next_state']} (role: {t['allowed']})")
# Apply a workflow action
try:
updated_doc = apply_workflow(doc, "Approve")
print(f"New state: {updated_doc.workflow_state}")
except Exception as e:
print(f"Cannot apply: {e}")Example 5: Bulk Workflow Approval
import json
from frappe.model.workflow import bulk_workflow_approval
# Approve multiple documents at once
docnames = json.dumps(["PO-00001", "PO-00002", "PO-00003"])
bulk_workflow_approval(docnames, "Purchase Order", "Approve")
# < 20 docs: synchronous
# 20-500 docs: background job
# > 500 docs: errorExample 6: Update Field on State Change
# State that sets a custom field when entered
{"state": "Approved", "doc_status": "1",
"allow_edit": "Manager",
"update_field": "custom_approved_by",
"update_value": "frappe.session.user",
"evaluate_as_expression": 1}
# State that sets a static value
{"state": "On Hold", "doc_status": "0",
"allow_edit": "Manager",
"update_field": "status",
"update_value": "On Hold"}Example 7: Email Notification Configuration
# On the Workflow
{"send_email_alert": 1}
# On each state row
{"state": "Pending Approval", "doc_status": "0",
"send_email": 1,
"next_action_email_template": "Approval Request Template",
"message": "Please review and approve this document."}Example 8: Client-Side Workflow Interaction
// Get transitions and show custom dialog
frappe.xcall("frappe.model.workflow.get_transitions", {doc: cur_frm.doc})
.then(transitions => {
if (transitions.length === 0) {
frappe.msgprint("No actions available");
return;
}
let actions = transitions.map(t => t.action);
frappe.prompt({
fieldtype: "Select",
label: "Action",
fieldname: "action",
options: actions.join("\n"),
reqd: 1
}, (values) => {
frappe.xcall("frappe.model.workflow.apply_workflow", {
doc: cur_frm.doc,
action: values.action
}).then(() => cur_frm.reload_doc());
});
});Example 9: Checking Workflow Status in Reports
# Get document counts by workflow state
from frappe.workflow.doctype.workflow.workflow import get_workflow_state_count
counts = get_workflow_state_count(
"Purchase Order",
"workflow_state",
json.dumps(["Cancelled"]) # Exclude cancelled
)
# Returns: [{"workflow_state": "Draft", "count": 12}, ...]Example 10: Testing Workflow with frappe.set_user
# Test that only Manager can approve
frappe.set_user("manager@example.com")
doc = frappe.get_doc("Purchase Order", "PO-00001")
transitions = get_transitions(doc)
assert any(t["action"] == "Approve" for t in transitions)
# Test that regular user cannot approve
frappe.set_user("user@example.com")
doc = frappe.get_doc("Purchase Order", "PO-00001")
transitions = get_transitions(doc)
assert not any(t["action"] == "Approve" for t in transitions)
frappe.set_user("Administrator") # ALWAYS reset user after testing