Approvals — How to Add a New Approval Kind¶
Purpose¶
This guide explains how to add a new approval kind to the approvals app.
A new approval kind means introducing a new workflow type such as:
- expense approval
- leave request approval
- inventory transfer approval
- document sign-off approval
The approvals system is designed so new kinds can reuse the same core workflow while plugging in:
- assignee computation
- side effects
- notifications
- frontend display metadata
Big picture¶
Adding a new approval kind usually requires work in five places:
- define the new kind in the model enum
- make sure approvals can be created for the new target object
- define who the approvers/assignees are
- define what happens when the approval is approved or rejected
- add notifications, tests, and documentation
Step-by-step process¶
Step 1: add the new kind constant¶
Approval kinds are defined in ApprovalKind.
Example:
ApprovalKind.EXPENSE_SUBMIT = "expense_submit"
Guidelines¶
Use a stable slug-like value:
- good: expense_submit
- good: inventory_transfer
- avoid: expense1
- avoid: submit_approval_new
Treat approval kind values as workflow identifiers.
Step 2: decide the target model¶
Each approval points to a generic target via:
- content_type
- object_id
So you must decide:
- which model is being approved
- whether one target can have multiple approval kinds
- whether one target should only have one active pending approval of this kind
Example targets¶
- WeeklyTimesheet
- ExpenseReport
- LeaveRequest
- InventoryTransit
Step 3: ensure approval creation uses the new kind¶
Approval creation happens via serializers or services.
Make sure callers create approvals with:
- correct target_type (e.g. "expenses.expensereport")
- correct target_id
- correct kind
Recommended¶
Approval.objects.create(
org=org,
content_type=content_type,
object_id=expense.id,
kind=ApprovalKind.EXPENSE_SUBMIT,
requested_by=request.user,
status=Approval.Status.PENDING,
)
Create a dedicated service such as:
create_expense_approval(...)
This keeps creation logic centralized and consistent.
Step 4: define assignee computation¶
This is one of the most important steps.
Assignees are computed in:
approvals/services/assignees.py
Specifically:
compute_assignees_for_approval(...)
What to do¶
Extend the function to handle your new kind.
current pattern¶
Add your new kind¶
if approval.kind == ApprovalKind.EXPENSE_SUBMIT:
expense = approval.target
manager = ...
return [
(manager.id, "approver"),
]
Questions to answer¶
- who is the default approver?
- can delegation apply?
- can there be multiple approvers?
- should the requester be excluded?
- should admins also be included?
Step 5: refresh assignees when needed¶
Assignee rows are synced using:
refresh_assignees(approval)
This automatically works once your computation logic is implemented.
Step 6: add decision side effects¶
When an approval is approved or rejected, the target object must be updated.
This logic lives in:
approvals/services/handlers.py
current pattern:
def apply_decision_side_effects(approval: Approval) -> None:
if approval.kind == ApprovalKind.TIMESHEET_SUBMIT:
_apply_timesheet_submit(approval)
add a new handler¶
def apply_decision_side_effects(approval: Approval) -> None:
if approval.kind == ApprovalKind.TIMESHEET_SUBMIT:
_apply_timesheet_submit(approval)
elif approval.kind == ApprovalKind.EXPENSE_SUBMIT:
_apply_expense_submit(approval)
then implement
def _apply_expense_submit(approval: Approval) -> None:
expense = approval.target
if approval.status == Approval.Status.APPROVED:
expense.status = ExpenseReport.Status.APPROVED
expense.approved_by = approval.decided_by
expense.approved_at = approval.decided_at
expense.save(update_fields=[...])
elif approval.status == Approval.Status.REJECTED:
expense.status = ExpenseReport.Status.REJECTED
expense.rejected_by = approval.decided_by
expense.rejected_at = approval.decided_at
expense.rejection_reason = approval.decision_reason
expense.save(update_fields=[...])
What to implement¶
- what happens on approve
- what happens on reject
Typical actions¶
- update target status
- store approved_by / rejected_by
- store timestamps
- store rejection reason
Important rule¶
Keep target-specific logic in handlers, not in models or views.
Step 7: add target metadata for serializers¶
If the frontend needs rich approval cards:
- extend ApprovalSerializer
- provide target metadata
Current pattern:
- views build a meta map
- serializer reads from context
For new kinds, decide:
- simple label only
- or full serialized payload
Step 8: add notification registry entries¶
Approval workflows usually require notifications.
Add new notification keys in:
notifications/registry.py
Examples¶
- expense.approval.assigned
- expense.approved
- expense.rejected
Each should define:
- display text
- category
- channels (in_app, push, etc.)
- templates
"expense.approved": {
"display": "Expense approved",
"category": "approvals",
"severity": "info",
"pref": "approval_updates",
"default": True,
"channels": ["in_app", "push"],
"route": "expenses.detail",
"default_params": {},
"entity_type": "expense",
"entity_id_key": "expense_id",
"title_template": "Expense approved",
"body_template": "Expense {expense_code} was approved.",
"help": "Notifies when an expense is approved.",
}
Step 9: emit notifications from services¶
Trigger notifications from workflow logic.
Typical places:
- when approval is assigned
- when approval is approved
- when approval is rejected
- when approval is forwarded
Always use:
notify(...)
example¶
if approval.kind == ApprovalKind.EXPENSE_SUBMIT:
if approval.status == Approval.Status.APPROVED:
notify(
org=approval.org,
recipients=[expense.requested_by],
key="expense.approved",
actor=actor,
data={
"expense_id": expense.id,
"expense_code": expense.code,
},
)
Do not send notifications directly from views.
Step 10: verify inbox behavior¶
Inbox visibility is assignment-based and usually works automatically.
Still verify:
- direct assignee visibility
- delegated visibility
- forwarded visibility
Inbox logic lives in:
approvals/services/inbox.py
Step 11: add tests¶
You should add tests for:
Workflow¶
- approval creation
- uniqueness constraint
- assignee computation
- approval decision
- side effects
Permissions¶
- direct assignee access
- delegated access
- denied access
Notifications¶
- correct events triggered
- correct payload data
Views¶
- decision endpoint
- inbox endpoint
- serializer output
Step 12: document the new kind¶
Update documentation:
- approvals/models.md
- approvals/services.md
- feature-specific docs
- change log
If notifications are added:
- update notifications documentation
Recommended checklist¶
Required¶
- add ApprovalKind value
- ensure approval creation path
- implement assignee computation
- implement side effects
- add tests
Usually required¶
- add notifications
- add serializer enrichment
- update documentation
Sometimes required¶
- custom inbox rules
- forwarding rules
- audit payload updates
Example: adding expense_submit¶
1. Enum¶
class ApprovalKind(models.TextChoices):
TIMESHEET_SUBMIT = "timesheet_submit", "Timesheet submit"
EXPENSE_SUBMIT = "expense_submit", "Expense submit"
2. Assignee computation¶
if approval.kind == ApprovalKind.EXPENSE_SUBMIT:
expense = approval.target
manager = effective_manager(org=approval.org, user=expense.employee)
if not manager:
return []
return [(manager.id, "approver")]
3. side effect¶
def apply_decision_side_effects(approval: Approval) -> None:
if approval.kind == ApprovalKind.TIMESHEET_SUBMIT:
_apply_timesheet_submit(approval)
elif approval.kind == ApprovalKind.EXPENSE_SUBMIT:
_apply_expense_submit(approval)
4. Notifications¶
Add: - expense.approval.assigned - expense.approved - expense.rejected
- Tests
- creation
- assignee refresh
- decision side effects
- inbox visibility
- notifications
Mermaid overview¶
flowchart TD
A[Add new ApprovalKind] --> B[Create approval]
B --> C[Compute assignees]
C --> D[Refresh assignees]
D --> E[Inbox visibility]
E --> F[Decision]
F --> G[Apply side effects]
G --> H[Send notifications]
H --> I[Add tests and docs]
Design principles¶
Keep the core generic¶
Do not add target-specific logic to the Approval model.
Keep behavior in: • services • handlers • serializers • notifications
Extend only where needed¶
Most infrastructure should remain unchanged: • permissions • inbox logic • delegation handling • forwarding
Prefer services¶
All workflow logic should live in services, not in: • views • serializers
Common mistakes to avoid¶
• forgetting assignee computation
• missing side effects on decision
• putting logic in views
• missing notification registry entries
• skipping delegation tests
• modifying Approval model for specific cases
Summary¶
To add a new approval kind: 1. define ApprovalKind 2. create approvals for the target 3. compute assignees 4. implement side effects 5. add notifications 6. test everything 7. document the behavior
The approvals system is designed to be: • generic • extensible • service-driven • reusable across domains