Approval hooks
Contents
Approval hooks#
The CR system provides lifecycle hooks that run at each stage of the approval workflow. Override these hooks in a custom module to add side effects like notifications, external API calls, or additional validation.
If you have not built a CR type before, start with the Tutorial: build a transfer member CR type.
Lifecycle#
A change request moves through these states:
action state action action
┌──────────────────────────────────────────────────────────┐
│ │
▼ │
Draft ──► [Submit] ──► Pending ──► [Approve] ──► Approved ──► [Apply]
│
├──► [Reject] ──► Rejected
│
└──► [Request Revision] ──► Revision ──► [Resubmit] ──► Pending
Square brackets denote actions (button clicks); unbracketed words are states. "Submit" moves the CR from Draft to Pending — it is not a state itself.
Each transition triggers a hook method on the spp.change.request model.
Available hooks#
Hook |
When it runs |
What it does by default |
|---|---|---|
|
User submits CR from draft or revision |
Runs conflict checks (raises |
|
After |
No-op by default — override to run post-submit side effects |
|
Approver approves the CR |
Creates audit event, triggers auto-apply if |
|
Approver rejects the CR |
Logs rejection with the reason, creates audit event |
|
Approver requests changes |
Sets stage to "review", creates audit event |
|
Manager resets CR to draft |
Sets stage to "details", creates audit event |
|
Before |
Validates the CR is in a submittable state (draft or revision) |
Note
_on_approve() does not re-run conflict checks. Conflict checking happens only at submit time via _on_submit() → _run_conflict_checks(). If you need a safety net that re-checks conflicts at approval time, override _on_approve() and call self._run_conflict_checks() before super()._on_approve().
Extending a hook#
To add custom behavior, override the hook in a model that inherits spp.change.request. Always call super() to preserve the default behavior:
from odoo import models
class ChangeRequestCustom(models.Model):
_inherit = "spp.change.request"
def _on_approve(self):
# Run before the default approval logic
for rec in self:
if rec.request_type_id.code == "transfer_member":
# Custom pre-approval logic for this CR type
rec._notify_source_group_manager()
# Call the default implementation
super()._on_approve()
# Run after the default approval logic
for rec in self:
if rec.request_type_id.code == "transfer_member":
rec._notify_target_group_manager()
Filter by CR type. The hooks are called for all CR types, so check request_type_id.code if your logic only applies to a specific type.
Hook execution order#
When a CR is submitted, the system calls (in order):
_check_can_submit()— validates the CR can be submitted (raisesUserErrorif not)_on_submit()— which internally calls_run_conflict_checks()beforesuper()._on_submit()creates the audit event. If you override_on_submit()and want custom logic to run before conflict checks, place it beforesuper()._on_submit()._after_submit()— runs after submission completes
When a CR is approved:
_on_approve()— creates audit event, triggers auto-apply ifauto_apply_on_approveisTrueIf
auto_apply_on_approveis enabled on the CR type,action_apply()is called automatically
Auto-apply#
If a CR type has auto_apply_on_approve = True, the system calls action_apply() immediately after _on_approve() completes. This is useful for low-risk, pre-validated changes where a manual apply step adds no value.
Configure this on the CR type record:
<record id="cr_type_example" model="spp.change.request.type">
<!-- ... other fields ... -->
<field name="auto_apply_on_approve" eval="True" />
</record>
Dynamic approval#
Dynamic approval routes a CR to different approval workflows based on the value of a specific field. For example, a name change might require a different approval chain than an address change.
This feature uses CEL (Common Expression Language) conditions on approval definition candidates. The system evaluates each candidate's condition against the CR's field values and uses the first match.
Dynamic approval is configured on the CR type, not in code:
Field |
Purpose |
|---|---|
|
Enable dynamic approval routing |
|
Ordered list of approval definitions with CEL conditions |
When use_dynamic_approval is enabled, the CR requires the user to select which field they are modifying (via selected_field_name). The system then evaluates:
def _resolve_dynamic_approval(self):
for candidate in self.request_type_id.candidate_definition_ids:
if not candidate.cel_condition:
return candidate.approval_definition_id # Catch-all
if self._evaluate_cel_condition(candidate.cel_condition):
return candidate.approval_definition_id
return None
For details on CEL expressions, see CEL (Common Expression Language).
Conflict detection hooks#
The CR system runs conflict detection on submission and approval. By default, it checks for other active CRs that affect the same registrant, group, or fields.
To add custom conflict logic for a specific CR type, override _check_custom_conflicts() on the conflict mixin:
class ChangeRequestCustomConflicts(models.Model):
_inherit = "spp.change.request"
def _check_custom_conflicts(self, candidates, rule):
"""Filter candidates to those that actually conflict.
Args:
candidates: Recordset of potentially conflicting CRs
rule: The spp.cr.conflict.rule being evaluated
Returns:
Filtered recordset of actual conflicts
"""
if self.request_type_id.code != "transfer_member":
return super()._check_custom_conflicts(candidates, rule)
# Only flag as conflict if another CR targets the same individual
detail = self.get_detail()
if not detail or not detail.individual_id:
return self.env["spp.change.request"]
conflicting = self.env["spp.change.request"]
for candidate in candidates:
candidate_detail = candidate.get_detail()
if (candidate_detail and
hasattr(candidate_detail, "individual_id") and
candidate_detail.individual_id == detail.individual_id):
conflicting |= candidate
return conflicting
This hook is called when a conflict rule has scope = "custom". The default implementation returns all candidates unfiltered — your override narrows the match to only those that actually conflict. For configuring conflict rules through the UI, see Conflict and duplicate detection.
Conflict actions#
Each conflict rule specifies an action:
Action |
Behavior |
|---|---|
|
Prevents submission until the conflict is resolved or overridden |
|
Allows submission but displays a warning to the reviewer |
|
Records the conflict silently — no user-facing impact |
If a blocking conflict exists, the user must either resolve the conflicting CR or request an override from a user with the group_cr_conflict_approver permission.
Audit events#
The CR system automatically creates spp.event.data records for every state transition. Each event's top-level columns are standard spp.event.data fields (event_type_id, partner_id, res_id, model, create_date, etc.). The CR-specific details are stored inside the record's data_json JSON column:
Location |
Field |
Content |
|---|---|---|
Top-level column |
|
Link to the event type (e.g., |
Top-level column |
|
The registrant |
Top-level column |
|
Points to the CR record |
Top-level column |
|
When the event occurred |
Inside |
|
|
Inside |
|
State before and after the transition |
Inside |
|
Who performed the action |
Inside |
|
CR ID (also available via |
Important
Because action, old_state, etc. live inside the JSON column, querying by them requires JSON operators. For example, to find all audit events with action = "approved", use a JSON path expression against data_json rather than a direct [("action", "=", "approved")] domain.
To create a custom audit event from within a hook override:
def _on_approve(self):
super()._on_approve()
for rec in self:
if rec.request_type_id.code == "transfer_member":
rec._create_audit_event(
action="custom_notification_sent",
old_state="pending",
new_state="approved",
)
The _create_audit_event() method is provided by the base CR model. It wraps the action, old_state, new_state, and current user into data_json automatically.
openspp.org