Skip to content

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:

  1. define the new kind in the model enum
  2. make sure approvals can be created for the new target object
  3. define who the approvers/assignees are
  4. define what happens when the approval is approved or rejected
  5. 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
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

if approval.kind == ApprovalKind.TIMESHEET_SUBMIT:
    ...

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

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

  1. Tests
  2. creation
  3. assignee refresh
  4. decision side effects
  5. inbox visibility
  6. 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