Skip to content

Approval workflow

fastapi-react-admin ships an approval workflow plugin (plugin/workflow): an admin designs a form and a flow tree in the browser, publishes it, and any signed-in user can submit requests against it. Approvers work from one Approval center (to approve / handled / my requests / CC’d to me), and every action — approve, reject, transfer, CC, send back, take back, add approvers, remind, withdraw, terminate, reassign — is checked by the same server-side function (capabilities()) that tells the UI which buttons to show, so the buttons and the API cannot drift apart.

A definition is two JSON documents: a form schema and a flow model. Both are validated by Pydantic shapes in engine/schema.py on publish, and stored as raw JSON.

  • Form fields (13 types): text, textarea, number, amount, date, date range, single / multi select, user, department, attachment, description and detail table. A detail table is one level deep (no table inside a table).
  • Conditional visibility (visibleWhen): a field that is not visible is not validated and its value is not saved — for example a sick-note attachment that only appears when the leave type is “sick”.
  • Calculated fields (calc): days in a date range (inclusive, 11-02 → 11-06 = 5) or the sum of a table column. The server recomputes them; submitted values are ignored.
  • Flow nodes: start, approval, CC and condition. Conditions are structured all / any trees with leaves like {"var": "f_days", "op": "gt", "value": 3} — no string expressions to parse or inject into. Publishing fails if a condition node does not have exactly one default branch.

This is an excerpt of the seeded “请假” (leave) sample from sql/postgresql/init_snowflake.sql: direct manager first, an upper manager only for leave longer than 3 days, then HR.

{"type": "approval", "id": "n_leader", "name": "直属主管", "mode": "any",
"assignee": {"kind": "dept_leader", "level": 1}, "onEmpty": "admin",
"next": {"type": "condition", "id": "c_days", "branches": [
{"id": "b_long", "when": {"all": [{"var": "f_days", "op": "gt", "value": 3}]},
"child": {"type": "approval", "id": "n_upper", "assignee": {"kind": "dept_leader", "level": 2}, "onEmpty": "skip"}},
{"id": "b_default", "default": true, "child": null}]}}

The designer edits a draft (saved without validation); publish validates it and copies it into a new version row. In-flight requests keep the snapshot they started with, so editing or disabling a definition never changes them. A definition that already has requests cannot be deleted, only disabled.

Each approval node has an assignee rule (engine/assignee.py):

RuleResolves to
userspecific users
rolemembers of roles, optionally limited to the initiator’s department or department tree
dept_leaderthe leader N levels up from the initiator’s department; levels with no leader, a disabled leader, or where the leader is the initiator are skipped
initiatorthe initiator (for “confirm” steps)
var_userthe person picked in a user field of the form
select_on_startchosen by the initiator at submission — asked only for nodes the request will actually reach

Except for the initiator rule, the initiator is always removed from the result, so nobody approves their own request. If a rule resolves to nobody, the node’s onEmpty decides: reject (default, with the reason in the history), skip, or admin (superusers, again minus the initiator). Nothing is silently approved.

How do or-sign, co-sign and sequential approval differ?

Section titled “How do or-sign, co-sign and sequential approval differ?”

mode: any (or-sign) passes on the first approval and cancels the other tasks. mode: all (co-sign) needs everyone; one rejection ends the request. mode: seq goes one person at a time; the order is fixed when the request enters the node, so later role changes do not affect it.

What can an approver do besides approve and reject?

Section titled “What can an approver do besides approve and reject?”
ActionBehaviour
TransferHands the task to someone else. Refused if that person already approved this step in the current round.
CCCopies people in; they see it under “CC’d to me”.
Send backReturns the request to the initiator or to an earlier approval step that is still on the path for the current form data. Optionally “come straight back to me” after it is re-handled — the jump only happens if this step is still on the path after the initiator’s edits.
Add approverBefore me (they all approve, then it comes back to me) or after me (I approve now; the step moves on once they all approve). Added people can only approve / reject / transfer / CC; one rejection from them rejects the request.
Take backAfter you approve, until anyone on the next step acts. Restores the instance variables your approval consumed, not just your task.

The initiator can withdraw a running request and remind the current approvers at most once every 5 minutes. Users with workflow:instance:terminate can terminate and users with workflow:instance:reassign can reassign a stuck task. A node’s actions list narrows the optional set: null inherits transfer / CC / send back / add approver, [] leaves only approve and reject.

An approval node can set timeout: {hours, action} with hours from 1 to 720 and action one of remind, approve or reject. The clock starts when the task reaches that person, so a transfer or the next person in a sequential step starts a fresh clock. Hours are calendar hours; there is no working-day calendar.

The Celery task workflow.handle_timeouts scans every 5 minutes (seeded as cron */5 * * * *). remind notifies at most 3 times. Auto-approve falls back to a reminder if the step has required editable fields that are still empty, and both auto-actions claim the request the same way a human click does — if someone is acting on it at that moment, it is retried on the next scan.

  • Drafts are stored server-side (wf_draft, one per user per workflow, up to 200 KB, not validated). The submit drawer autosaves 1.5 s after you stop typing; the draft is deleted in the same transaction that creates the request, so a failed submission keeps it.
  • Urgent only changes sort order: urgent requests come first in the approver’s to-do list. The flow is unchanged.
  • Read: the first time a current approver opens the detail is recorded, and the initiator sees it.

How does handover work when someone leaves?

Section titled “How does handover work when someone leaves?”

Instance monitor → Handover moves every pending task from one person to another. The “from” list is built from people who currently hold tasks — including disabled and deleted accounts — because the normal user picker only lists active users, which would hide exactly the person who left. Each task is reassigned in its own transaction, the result lists a reason for any task that could not move, and a repeated click with the same idempotency key does not move anything twice.

Each node sets hidden / readonly / editable per field (start node defaults to editable, approval nodes to read-only). This is enforced on the server: the detail API removes hidden fields from the schema, the data and the before/after values in the history. Hidden values are also kept out of list summaries, branch records (“took the large-amount branch” reveals the amount range) and the path preview; publishing a condition on a hidden field gives a CONDITION_VAR_HIDDEN warning.

Every state change first claims the request with UPDATE wf_instance SET version = version + 1 WHERE id = ? AND version = ? AND status = 'running' and retries the whole transaction up to 3 times. Each action carries an idempotency key, replayed only for the same user. Rejected actions are still recorded, in a separate transaction.

Form approvals need no code. For a business table, implement WorkflowBizHandler in handler.py, call register_handler(), and start the request with runtime_service.start_for_biz() inside your own transaction. on_finished runs inside the approval transaction and should only update business state. Five webhook events (workflow.instance.started / approved / rejected / withdrawn / terminated) are available through the open API and deliberately omit form contents. Approval notifications are routed like any other kind — see Notifications.

The plugin also has optional AI features (pre-review, generating a definition from one sentence, filling the form from a document, approving from chat, and data-gated auto-approval on or-sign nodes); see AI assistant.

  • No parallel branches: the flow is a tree, so a request is always at exactly one node.
  • The designer’s condition editor edits one level of “all”; hand-written nested any conditions are kept but not editable in the UI.
  • Form fields are reordered with up / down buttons, not drag and drop.
  • Timeouts count calendar hours, not working days.

plugin/workflow/tests/ collects 167 pytest cases, most of them running against a real test database and Redis (88 for the engine and actions, 79 for the AI features, of which 14 call a real model and are skipped without credentials). Some examples:

  • test_concurrency.py runs real concurrent sessions: co-sign approvals at the same time, or-sign at the same time, approve racing withdraw, and two co-signers editing different fields — the last one fails if the version check is removed.
  • test_preview_matches_what_actually_happens previews a path, then walks it for real and compares approvers step by step.
  • test_timeouts.py controls time (handle_timeouts(..., now=...)) instead of waiting.

The notes record which tests were mutation-checked (fix reverted, test turns red). The browser suite workflow.spec.ts has 14 Playwright tests covering submit → approve, send back and resubmit, reassign, take back, add approver, drafts, urgent and read receipts. CI runs the backend suite on PostgreSQL and SQL Server; MySQL has no CI job.

Sign in at fra.wubunan.com with admin / 123456. Approvals → Workflow definitions opens the designer (Basics → Form → Workflow designer) with the seeded “请假” leave sample, Approvals → Approval center is where requests are submitted and handled, and Approvals → Instance monitor has terminate, reassign and handover.