Tutorial: build a transfer member CR type
Contents
Tutorial: build a transfer member CR type#
This tutorial walks you through building a complete change request type from scratch. By the end, you will have a working Odoo module that lets users request transferring a person from one household to another, with approval workflow, validation, and tests.
Tip
Want to skip ahead? Download the complete module: spp_cr_transfer_member.zip
Prerequisites#
Python and Odoo model inheritance
A working OpenSPP development environment
The
spp_change_request_v2module installed
What you will build#
The Transfer Member CR type allows a user to select a member of one group and request their transfer to a different group. When approved and applied, the system ends the membership in the source group and creates a new membership in the target group.
This requires a custom apply strategy because the built-in field mapping strategy can only copy field values — it cannot create or end membership records.
Module structure#
Create the following directory structure:
spp_cr_transfer_member/
├── __init__.py
├── __manifest__.py
├── details/
│ ├── __init__.py
│ └── transfer_member.py
├── strategies/
│ ├── __init__.py
│ └── transfer_member.py
├── views/
│ └── detail_transfer_member_views.xml
├── data/
│ └── cr_type.xml
├── security/
│ └── ir.model.access.csv
└── tests/
├── __init__.py
└── test_transfer_member.py
__manifest__.py#
{
"name": "CR Type: Transfer Member",
"version": "19.0.1.0.0",
"category": "OpenSPP",
"depends": ["spp_change_request_v2"],
"data": [
"security/ir.model.access.csv",
"views/detail_transfer_member_views.xml",
"data/cr_type.xml",
],
"installable": True,
"license": "LGPL-3",
}
The only required dependency is spp_change_request_v2, which provides the base models, approval mixin, and CR infrastructure.
The details/ and strategies/ directory structure follows the convention used in spp_change_request_v2. For simpler modules, a flat models/ directory works too.
__init__.py files#
Root __init__.py:
from . import details
from . import strategies
details/__init__.py:
from . import transfer_member
strategies/__init__.py:
from . import transfer_member
tests/__init__.py:
from . import test_transfer_member
Odoo auto-discovers the tests/ directory — no import is needed in the root __init__.py.
Step 1: define the detail model#
The detail model captures the data for the transfer request. It inherits from spp.cr.detail.base (which links it to the parent change request) and mail.thread (which enables the message log / chatter).
details/transfer_member.py#
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class SPPCRDetailTransferMember(models.Model):
_name = "spp.cr.detail.transfer_member"
_description = "CR Detail: Transfer Member"
_inherit = ["spp.cr.detail.base", "mail.thread"]
# Source group comes from the parent change request's registrant
source_group_id = fields.Many2one(
"res.partner",
string="Source Group",
related="change_request_id.registrant_id",
store=True,
readonly=True,
)
# Computed list of transferable members (excludes head of household)
available_individual_ids = fields.Many2many(
"res.partner",
string="Available Individuals",
compute="_compute_available_individuals",
help="Active members excluding head of household",
)
individual_id = fields.Many2one(
"res.partner",
string="Member to Transfer",
tracking=True,
domain="[('is_group', '=', False), ('is_registrant', '=', True)]",
)
# Set automatically when individual_id changes
membership_id = fields.Many2one(
"spp.group.membership",
string="Current Membership",
readonly=True,
)
target_group_id = fields.Many2one(
"res.partner",
string="Target Group",
tracking=True,
domain="[('is_group', '=', True), ('is_registrant', '=', True),"
" ('id', '!=', registrant_id)]",
)
new_role_id = fields.Many2one(
"spp.vocabulary.code",
string="Role in New Group",
domain="[('vocabulary_id.namespace_uri', '=',"
" 'urn:openspp:vocab:group-membership-type'),"
" ('code', '!=', 'head')]",
tracking=True,
)
transfer_reason = fields.Selection(
[
("marriage", "Marriage"),
("separation", "Separation/Divorce"),
("relocation", "Relocation"),
("household_split", "Household Split"),
("correction", "Data Correction"),
("other", "Other"),
],
string="Transfer Reason",
tracking=True,
)
transfer_date = fields.Date(
string="Transfer Date",
default=fields.Date.today,
tracking=True,
)
remarks = fields.Text(string="Remarks", tracking=True)
# Computed display fields
member_name = fields.Char(related="individual_id.name", readonly=True)
source_group_name = fields.Char(related="source_group_id.name", readonly=True)
target_group_name = fields.Char(related="target_group_id.name", readonly=True)
@api.depends("change_request_id.registrant_id")
def _compute_available_individuals(self):
"""Compute transferable members, excluding the head of household."""
head_kind = self.env["spp.vocabulary.code"].get_code(
"urn:openspp:vocab:group-membership-type", "head"
)
for rec in self:
group = rec.change_request_id.registrant_id
if not group:
rec.available_individual_ids = self.env["res.partner"]
continue
memberships = self.env["spp.group.membership"].search([
("group", "=", group.id),
("status", "=", "active"),
])
if head_kind:
memberships = memberships.filtered(
lambda m: head_kind not in m.membership_type_ids
)
rec.available_individual_ids = memberships.mapped("individual")
@api.onchange("individual_id")
def _onchange_individual_id(self):
"""Look up the active membership when the user selects a member."""
self.membership_id = False
if self.individual_id and self.change_request_id.registrant_id:
membership = self.env["spp.group.membership"].search([
("group", "=", self.change_request_id.registrant_id.id),
("individual", "=", self.individual_id.id),
("status", "=", "active"),
], limit=1)
if membership:
self.membership_id = membership
@api.constrains("target_group_id", "source_group_id")
def _check_different_groups(self):
"""Prevent transferring to the same group."""
for rec in self:
if rec.target_group_id and rec.source_group_id:
if rec.target_group_id == rec.source_group_id:
raise ValidationError(
"Target group must be different from source group."
)
Key patterns to notice:
source_group_idis arelatedfield — it reads directly from the parent CR's registrant, so the user never has to set it manually.available_individual_idsis a computed Many2many that filters out the head of household. The form view uses it as a domain filter onindividual_id.membership_idis set automatically via@api.onchangewhen the user picks an individual. Thereadonly=Truekeeps it hidden from direct editing.@api.constrainsenforces that source and target groups differ — this validation runs on every write.tracking=Trueon key fields enables the chatter audit trail.
Step 2: create the form view#
The form view follows the standard CR detail pattern: a header with navigation buttons and a stage statusbar, and a sheet with grouped fields.
views/detail_transfer_member_views.xml#
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="spp_cr_detail_transfer_member_form" model="ir.ui.view">
<field name="name">spp.cr.detail.transfer_member.form</field>
<field name="model">spp.cr.detail.transfer_member</field>
<field name="arch" type="xml">
<form
string="Transfer Member Details"
readonly="not is_cr_manager or approval_state not in ('draft', 'revision')"
>
<header>
<field name="is_cr_manager" invisible="1" />
<button
name="action_next_documents"
string="Next: Upload Documents"
type="object"
class="btn-primary"
icon="fa-arrow-right"
invisible="approval_state not in ('draft', 'revision')"
groups="spp_change_request_v2.group_cr_manager"
/>
<button
name="action_skip_to_review"
string="Review & Submit"
type="object"
class="btn-success"
icon="fa-check-circle"
invisible="approval_state not in ('draft', 'revision')"
groups="spp_change_request_v2.group_cr_manager"
/>
<button
name="action_save_and_go_to_list"
string="Save as Draft"
type="object"
class="btn-outline-secondary"
invisible="approval_state not in ('draft', 'revision')"
groups="spp_change_request_v2.group_cr_manager"
/>
<field
name="stage"
widget="statusbar"
statusbar_visible="details,documents,review"
/>
</header>
<sheet>
<group>
<group string="Source">
<field
name="source_group_id"
options="{'no_create': True, 'no_open': True}"
/>
<field name="available_individual_ids" invisible="1" />
<field
name="individual_id"
options="{'no_create': True}"
domain="[('id', 'in', available_individual_ids)]"
required="1"
/>
<field name="member_name" />
<field name="membership_id" invisible="1" force_save="1" />
</group>
<group string="Target">
<field
name="target_group_id"
options="{'no_create': True}"
required="1"
/>
<field
name="new_role_id"
options="{'no_create': True, 'no_open': True}"
/>
</group>
</group>
<group>
<group string="Transfer Details">
<field name="transfer_reason" />
<field name="transfer_date" />
</group>
</group>
<group string="Additional Information">
<field
name="remarks"
placeholder="Enter any additional notes..."
/>
</group>
</sheet>
</form>
</field>
</record>
</odoo>
Key patterns to notice:
The top-level
<form>tag has areadonlyattribute that locks the entire form when the user is not a CR manager or the CR is past the draft/revision stage. This is the standard pattern for all CR detail views.available_individual_idsis declaredinvisible="1"— it exists only to provide the domain filter forindividual_id.membership_idusesforce_save="1"because it isreadonlyin Python but needs to persist when set by the onchange handler.The header buttons (
action_next_documents,action_skip_to_review,action_save_and_go_to_list) are inherited fromspp.cr.detail.base— you do not need to implement them.
Step 3: build the apply strategy#
The apply strategy contains the business logic that executes when an approved CR is applied. It inherits from spp.cr.strategy.base and must implement the apply() method.
strategies/transfer_member.py#
import logging
from odoo import Command, _, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class SPPCRApplyTransferMember(models.AbstractModel):
_name = "spp.cr.apply.transfer_member"
_inherit = "spp.cr.strategy.base"
_description = "CR Apply: Transfer Member"
def apply(self, change_request):
"""Transfer a member from the source group to the target group."""
source_group = change_request.registrant_id
if not source_group.is_group:
raise UserError(_("Source registrant must be a group."))
detail = change_request.get_detail()
if not detail:
raise UserError(_("No detail record found."))
if not detail.membership_id:
raise UserError(_("No member selected for transfer."))
if not detail.target_group_id:
raise UserError(_("No target group selected."))
if not detail.target_group_id.is_group:
raise UserError(_("Target must be a group."))
membership = detail.membership_id
individual = membership.individual
target_group = detail.target_group_id
# Verify the membership is still active
if membership.status != "active":
raise UserError(_("Membership is already inactive."))
# Prevent duplicate membership in target group
existing = self.env["spp.group.membership"].search([
("group", "=", target_group.id),
("individual", "=", individual.id),
("status", "=", "active"),
], limit=1)
if existing:
raise UserError(
_("Individual is already a member of the target group.")
)
# End membership in source group
transfer_datetime = fields.Datetime.to_datetime(detail.transfer_date)
if membership.start_date and transfer_datetime < membership.start_date:
transfer_datetime = membership.start_date
membership.write({"ended_date": transfer_datetime, "active": False})
# Create membership in target group
new_membership_vals = {
"group": target_group.id,
"individual": individual.id,
"start_date": transfer_datetime,
}
if detail.new_role_id:
new_membership_vals["membership_type_ids"] = [
Command.link(detail.new_role_id.id)
]
self.env["spp.group.membership"].create(new_membership_vals)
_logger.info(
"Transferred member partner_id=%s from group partner_id=%s "
"to group partner_id=%s via CR %s",
individual.id,
source_group.id,
target_group.id,
change_request.name,
)
return True
def preview(self, change_request):
"""Return a summary of what the transfer will do."""
detail = change_request.get_detail()
if not detail:
return {}
return {
"_action": "transfer_member",
"member_name": detail.member_name,
"source_group": detail.source_group_name,
"target_group": detail.target_group_name,
"new_role": (
detail.new_role_id.display if detail.new_role_id else None
),
"transfer_date": str(detail.transfer_date),
"reason": detail.transfer_reason,
}
Key patterns to notice:
The strategy is an
AbstractModel, not a regularModel— it has no database table. It exists only to provide theapply()andpreview()methods.Validate first, then act. The method checks every precondition before making any changes. If any check fails, it raises
UserErrorwith a translatable message.change_request.get_detail()returns the linked detail record. This is a helper from the base CR model.The strategy runs with
sudo()privileges (the CR framework calls it that way), so the approval workflow is the security gate — not the strategy itself.Command.link()adds a Many2many relation without replacing existing values.The
preview()method returns a dict describing the planned changes. The CR UI displays this to reviewers before they approve.
Step 4: register the CR type#
The CR type record tells the system about your new type — its name, which detail model to use, and which strategy to apply.
data/cr_type.xml#
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data noupdate="1">
<record id="cr_type_transfer_member" model="spp.change.request.type">
<field name="name">Transfer Member</field>
<field name="code">transfer_member</field>
<field name="description">Transfer a member from one group to another</field>
<field name="target_type">group</field>
<field name="detail_model">spp.cr.detail.transfer_member</field>
<field
name="detail_form_view_id"
ref="spp_cr_detail_transfer_member_form"
/>
<field name="apply_strategy">custom</field>
<field name="apply_model">spp.cr.apply.transfer_member</field>
<field name="icon">fa-exchange-alt</field>
<field name="sequence">60</field>
</record>
</data>
</odoo>
Field reference:
Field |
Purpose |
|---|---|
|
Unique identifier used in API calls and tests |
|
|
|
The |
|
Reference to the form view XML record |
|
|
|
The |
|
Font Awesome icon class for the CR type selector |
|
Display order in the CR type list |
The noupdate="1" wrapper means this record is created on install but not overwritten on module upgrade, allowing administrators to customize it after installation.
Step 5: set up security#
Every detail model needs access rules for the CR security groups. The pattern is the same for all CR types: users, validators, and HQ validators get read/write/create; managers also get delete.
security/ir.model.access.csv#
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_transfer_member_user,spp.cr.detail.transfer_member user,model_spp_cr_detail_transfer_member,spp_change_request_v2.group_cr_user,1,1,1,0
access_transfer_member_validator,spp.cr.detail.transfer_member validator,model_spp_cr_detail_transfer_member,spp_change_request_v2.group_cr_validator,1,1,1,0
access_transfer_member_validator_hq,spp.cr.detail.transfer_member validator hq,model_spp_cr_detail_transfer_member,spp_change_request_v2.group_cr_validator_hq,1,1,1,0
access_transfer_member_manager,spp.cr.detail.transfer_member manager,model_spp_cr_detail_transfer_member,spp_change_request_v2.group_cr_manager,1,1,1,1
The base module (spp_change_request_v2) already defines record rules that restrict which CRs a user can see based on their role. Your detail model inherits this protection through its link to the parent CR record.
Step 6: write tests#
Tests verify that the detail model validates correctly, the strategy applies as expected, and error cases are handled.
tests/test_transfer_member.py#
from odoo import fields
from odoo.exceptions import UserError, ValidationError
from odoo.tests import TransactionCase
class TestTransferMember(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
Partner = cls.env["res.partner"]
Membership = cls.env["spp.group.membership"]
# Create source and target groups
cls.source_group = Partner.create({
"name": "Source Household",
"is_registrant": True,
"is_group": True,
})
cls.target_group = Partner.create({
"name": "Target Household",
"is_registrant": True,
"is_group": True,
})
# Create a test individual and add to source group
cls.individual = Partner.create({
"name": "Test Person",
"is_registrant": True,
"is_group": False,
})
cls.membership = Membership.create({
"group": cls.source_group.id,
"individual": cls.individual.id,
"start_date": fields.Datetime.now(),
})
# Look up or create the CR type
cls.cr_type = cls.env["spp.change.request.type"].search(
[("code", "=", "transfer_member")], limit=1
)
if not cls.cr_type:
cls.cr_type = cls.env["spp.change.request.type"].create({
"name": "Transfer Member",
"code": "transfer_member",
"target_type": "group",
"detail_model": "spp.cr.detail.transfer_member",
"apply_strategy": "custom",
"apply_model": "spp.cr.apply.transfer_member",
})
def _create_cr(self, **detail_vals):
"""Helper: create a CR and populate its detail record."""
cr = self.env["spp.change.request"].create({
"request_type_id": self.cr_type.id,
"registrant_id": self.source_group.id,
})
detail = cr.get_detail()
detail.write(detail_vals)
return cr
def test_successful_transfer(self):
"""Applying an approved transfer ends old membership,
creates new one."""
cr = self._create_cr(
membership_id=self.membership.id,
target_group_id=self.target_group.id,
transfer_reason="marriage",
transfer_date=fields.Date.today(),
)
cr.approval_state = "approved"
cr.action_apply()
self.assertTrue(cr.is_applied)
# Old membership is ended
self.assertTrue(self.membership.ended_date)
self.assertEqual(self.membership.status, "inactive")
# New membership exists in target group
new_membership = self.env["spp.group.membership"].search([
("group", "=", self.target_group.id),
("individual", "=", self.individual.id),
("status", "=", "active"),
])
self.assertTrue(new_membership)
def test_transfer_with_role(self):
"""Transferred member receives the assigned role."""
# Create a fresh individual for this test
individual = self.env["res.partner"].create({
"name": "Role Test",
"is_registrant": True,
"is_group": False,
})
membership = self.env["spp.group.membership"].create({
"group": self.source_group.id,
"individual": individual.id,
"start_date": fields.Datetime.now(),
})
role = self.env["spp.vocabulary.code"].search([
("vocabulary_id.namespace_uri", "=",
"urn:openspp:vocab:group-membership-type"),
("code", "!=", "head"),
], limit=1)
cr = self._create_cr(
membership_id=membership.id,
target_group_id=self.target_group.id,
new_role_id=role.id if role else False,
transfer_reason="relocation",
transfer_date=fields.Date.today(),
)
cr.approval_state = "approved"
cr.action_apply()
new_membership = self.env["spp.group.membership"].search([
("group", "=", self.target_group.id),
("individual", "=", individual.id),
("status", "=", "active"),
])
if role:
self.assertIn(role, new_membership.membership_type_ids)
def test_same_group_raises_validation_error(self):
"""Cannot set target group to the same as source group."""
cr = self.env["spp.change.request"].create({
"request_type_id": self.cr_type.id,
"registrant_id": self.source_group.id,
})
detail = cr.get_detail()
with self.assertRaises(ValidationError):
detail.write({
"target_group_id": self.source_group.id,
})
def test_duplicate_membership_raises_user_error(self):
"""Cannot transfer if individual is already in target group."""
individual = self.env["res.partner"].create({
"name": "Duplicate Test",
"is_registrant": True,
"is_group": False,
})
# Membership in source
source_membership = self.env["spp.group.membership"].create({
"group": self.source_group.id,
"individual": individual.id,
"start_date": fields.Datetime.now(),
})
# Membership already in target
self.env["spp.group.membership"].create({
"group": self.target_group.id,
"individual": individual.id,
"start_date": fields.Datetime.now(),
})
cr = self._create_cr(
membership_id=source_membership.id,
target_group_id=self.target_group.id,
transfer_reason="other",
transfer_date=fields.Date.today(),
)
cr.approval_state = "approved"
with self.assertRaises(UserError):
cr.action_apply()
def test_inactive_membership_raises_user_error(self):
"""Cannot transfer an already-ended membership."""
individual = self.env["res.partner"].create({
"name": "Inactive Test",
"is_registrant": True,
"is_group": False,
})
membership = self.env["spp.group.membership"].create({
"group": self.source_group.id,
"individual": individual.id,
"start_date": fields.Datetime.now(),
"ended_date": fields.Datetime.now(),
})
cr = self._create_cr(
membership_id=membership.id,
target_group_id=self.target_group.id,
transfer_reason="other",
transfer_date=fields.Date.today(),
)
cr.approval_state = "approved"
with self.assertRaises(UserError):
cr.action_apply()
def test_available_individuals_excludes_head(self):
"""Head of household is not in the available individuals list."""
head_kind = self.env["spp.vocabulary.code"].search([
("vocabulary_id.namespace_uri", "=",
"urn:openspp:vocab:group-membership-type"),
("code", "=", "head"),
], limit=1)
if not head_kind:
return # Skip if vocabulary not installed
# Make the individual the head of household
self.membership.write({
"membership_type_ids": [(4, head_kind.id)],
})
cr = self.env["spp.change.request"].create({
"request_type_id": self.cr_type.id,
"registrant_id": self.source_group.id,
})
detail = cr.get_detail()
self.assertNotIn(
self.individual, detail.available_individual_ids,
"Head of household should be excluded from available individuals",
)
def test_preview_returns_expected_structure(self):
"""Preview returns a dict describing the planned changes."""
cr = self._create_cr(
membership_id=self.membership.id,
target_group_id=self.target_group.id,
transfer_reason="marriage",
transfer_date=fields.Date.today(),
)
preview = cr.action_preview_changes()
self.assertEqual(preview["_action"], "transfer_member")
self.assertIn("source_group", preview)
self.assertIn("target_group", preview)
Key patterns to notice:
setUpClasscreates all shared test data once. Each test method creates its own CR so tests are independent._create_crhelper reduces boilerplate — create the CR and populate the detail in one call.TransactionCaserolls back after each test method, so shared data likeself.membershipis restored between tests.Happy path tests verify the end state (membership ended, new membership created), not just that no error was raised.
Error case tests use
assertRaisesand verify the specific exception type (ValidationErrorfor constraint violations,UserErrorfor strategy failures).The CR type is looked up first and only created if not found, so the tests work whether or not the XML data has been loaded.
Testing approval hooks and conflict detection#
If you override approval hooks (see Approval hooks), test that your custom logic runs:
def test_on_approve_triggers_custom_logic(self):
cr = self._create_cr(field_a=value_a)
cr.approval_state = "approved"
# Verify your custom side effect occurred
To test conflict detection, create two CRs for the same registrant and verify the conflict is detected:
def test_conflict_detected(self):
cr1 = self._create_cr(field_a=value_a)
cr1.approval_state = "pending"
cr2 = self._create_cr(field_a=value_b)
result = cr2._run_conflict_checks()
# Note: has_warning is inside result["conflict_result"], not top-level.
conflict = result.get("conflict_result") or {}
self.assertTrue(result.get("needs_override") or conflict.get("has_warning"))
Note
Apply strategies run with sudo() in production — the CR framework calls them that way. In tests, action_apply() also uses sudo() internally, so your tests exercise the same code path. You do not need to call sudo() explicitly.
Test checklist#
Use this checklist when writing tests for a custom CR type:
[ ] Detail model creation succeeds with valid data
[ ] Each
@api.constrainsraisesValidationErrorfor invalid data[ ] Apply strategy succeeds with valid, approved CR
[ ] Apply strategy raises
UserErrorfor each invalid state (one test per validation)[ ] Applied CR has
is_applied = Trueandapplied_dateset[ ] The registrant is actually modified as expected after apply
[ ] Preview returns the expected dict structure
[ ] Each selection value works (e.g., transfer reasons)
[ ] Conflict detection finds conflicting CRs (if conflict rules are configured)
Common test pitfalls#
Forgetting to set approval_state = "approved" — action_apply() only works on approved CRs. If you skip this step, the apply will fail with an unclear error.
Creating duplicate CR types — If your module's XML data creates the CR type and your test also creates it, you get a unique constraint error on the code field. Always search before creating in setUpClass.
Testing onchange in unit tests — @api.onchange handlers do not run during write() calls in tests. If your test depends on onchange behavior, call the onchange method directly or test the constraint that backs it up instead.
Missing vocabulary codes — Some detail models depend on vocabulary codes (e.g., gender, relationship types) that might not exist in the test database. Use a helper that looks up or creates the vocabulary code:
@classmethod
def _get_or_create_vocab_code(cls, namespace_uri, code, display):
"""Get a vocabulary code, creating it if not found."""
existing = cls.env["spp.vocabulary.code"].get_code(
namespace_uri, code
)
if existing:
return existing
vocab = cls.env["spp.vocabulary"].search(
[("namespace_uri", "=", namespace_uri)], limit=1
)
return cls.env["spp.vocabulary.code"].create({
"vocabulary_id": vocab.id,
"code": code,
"display": display,
"is_local": True,
})
Verify it works#
Install the module and test it manually:
Go to Change Requests in the menu
Click New and select Transfer Member
Pick a source group (household) as the registrant
Select a member to transfer and a target group
Submit the CR for approval
Approve the CR — the transfer applies automatically
Verify the member now appears in the target group's member list
Note
The auto_apply_on_approve field on spp.change.request.type defaults to True. With the CR type XML shown above (which doesn't override it), approving the CR will automatically call action_apply() — no separate Apply click needed. To require a manual apply step, add <field name="auto_apply_on_approve" eval="False" /> to the CR type record.
What's next#
You now have a working CR type. To go further:
Detail models — learn about pre-filling from the registrant, additional validation patterns, and the full base class API
Apply strategies — understand when to use field mapping vs. custom strategies, and see how other built-in strategies handle record creation and relationship changes
Approval hooks — hook into the approval lifecycle to add custom behavior on submit, approve, or reject
See also#
Change request types — configuring CR types through the UI (no code required)
Field mappings — the field mapping apply strategy (for simple field-copy CR types)
Conflict and duplicate detection — configuring conflict and duplicate detection rules
openspp.org