From b1229f343281e40642f548db444c3847bcf175a3 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 10 Mar 2026 15:46:54 +0800 Subject: [PATCH 1/7] feat(spp_change_request_v2): add dynamic approval fields and methods Add dynamic approval framework to CR types and change requests: - CR type: use_dynamic_approval toggle and candidate_definition_ids - CR: selected_field_name/old_value/new_value for field tracking - Detail base: field_to_modify selection, sync to parent CR on write/create - Dynamic approval resolution via CEL condition evaluation on candidates - Value normalization for CEL (Many2one, vocabulary codes, hierarchical) --- .../models/change_request.py | 164 +++++++++++++++++- .../models/change_request_detail_base.py | 84 +++++++++ .../models/change_request_type.py | 15 ++ 3 files changed, 258 insertions(+), 5 deletions(-) diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py index 65b6ec0f..b5e8382c 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -133,6 +133,27 @@ def _compute_is_cr_manager(self): applied_by_id = fields.Many2one("res.users", readonly=True) apply_error = fields.Text(readonly=True) + # ══════════════════════════════════════════════════════════════════════════ + # DYNAMIC APPROVAL + # ══════════════════════════════════════════════════════════════════════════ + + selected_field_name = fields.Char( + string="Field Being Modified", + readonly=True, + help="The detail field selected for modification (set when detail is saved). " + "Used by CEL conditions to determine the approval workflow.", + ) + selected_field_old_value = fields.Char( + string="Old Value", + readonly=True, + help="Human-readable old value of the selected field (from registrant). Stored for audit trail.", + ) + selected_field_new_value = fields.Char( + string="New Value", + readonly=True, + help="Human-readable new value of the selected field (from detail). Stored for audit trail.", + ) + # ══════════════════════════════════════════════════════════════════════════ # LOG # ══════════════════════════════════════════════════════════════════════════ @@ -596,7 +617,11 @@ def create(self, vals_list): record._create_audit_event("created", None, "draft") record._create_log("created") # Run conflict detection after creation - if hasattr(record, "_run_conflict_checks"): + # Skip for dynamic approval — field_to_modify isn't set yet at create time. + # Checks run when selected_field_name is written (see conflict model's write()). + if hasattr(record, "_run_conflict_checks") and not ( + record.request_type_id and record.request_type_id.use_dynamic_approval + ): record._run_conflict_checks() return records @@ -813,17 +838,137 @@ def action_submit_for_approval(self): def _get_approval_definition(self): self.ensure_one() - definition = self.request_type_id.approval_definition_id + cr_type = self.request_type_id + + # Dynamic approval: evaluate candidates using selected field + values + if cr_type.use_dynamic_approval and cr_type.candidate_definition_ids: + definition = self._resolve_dynamic_approval() + if definition: + return definition + + # Fallback to static definition (existing behavior) + definition = cr_type.approval_definition_id if not definition: raise UserError( _( "No approval workflow configured for request type '%s'. " - "Please configure an approval definition in Change Request > Configuration > CR Types." + "Please configure an approval definition in Change Request > " + "Configuration > CR Types." ) - % self.request_type_id.name + % cr_type.name ) return definition + def _resolve_dynamic_approval(self): + """Evaluate candidate definitions against selected field and values. + + Iterates candidates in sequence order; first match wins. + Returns spp.approval.definition record, or None if no candidate matches. + """ + self.ensure_one() + + if not self.selected_field_name: + return None + + extra_context = self._compute_field_values_for_cel() + evaluator = self.env["spp.cel.evaluator"] + + for candidate in self.request_type_id.candidate_definition_ids.sorted("sequence"): + if not candidate.use_cel_condition or not candidate.cel_condition: + # No condition = catch-all (matches everything) + return candidate + try: + result = evaluator.evaluate(candidate.cel_condition, self, extra_context) + if result: + return candidate + except Exception: + _logger.warning( + "CEL evaluation failed for candidate definition '%s' on CR %s, skipping", + candidate.name, + self.name, + exc_info=True, + ) + continue + + return None + + def _compute_field_values_for_cel(self): + """Compute typed old and new values for CEL evaluation. + + Returns a dict with old_value and new_value typed according to field type. + """ + self.ensure_one() + field_name = self.selected_field_name + cr_type = self.request_type_id + + if not field_name: + return {"old_value": None, "new_value": None} + + mapping = cr_type.apply_mapping_ids.filtered(lambda m: m.source_field == field_name)[:1] + + detail = self.get_detail() + registrant = self.registrant_id + + old_raw = None + new_raw = None + + if mapping and registrant: + old_raw = getattr(registrant, mapping.target_field, None) + if detail: + new_raw = getattr(detail, field_name, None) + + return { + "old_value": self._normalize_value_for_cel(old_raw, registrant, mapping.target_field if mapping else None), + "new_value": self._normalize_value_for_cel(new_raw, detail, field_name), + } + + def _normalize_value_for_cel(self, value, record, field_name): + """Normalize an Odoo field value for use in CEL expressions.""" + if value is None or value is False: + if record and field_name and field_name in record._fields: + field = record._fields[field_name] + if field.type == "boolean": + return False + if field.type in ("integer", "float", "monetary"): + return 0 + return None + + if record and field_name and field_name in record._fields: + field = record._fields[field_name] + if field.type in ("char", "text", "selection", "html"): + return value or "" + if field.type in ("integer", "float", "monetary"): + return value or 0 + if field.type == "boolean": + return bool(value) + if field.type in ("date", "datetime"): + return value + if field.type == "many2one": + # IDs are exposed for internal CEL evaluation only, not for external APIs. + result = { + "id": value.id if value else 0, + "name": value.display_name if value else "", + } + # Vocabulary models: expose machine-readable code for stable CEL matching + if value and "code" in value._fields: + result["code"] = value.code or "" + # Hierarchical vocabularies: expose parent category + if value and "parent_id" in value._fields and value.parent_id: + parent = value.parent_id + result["parent"] = { + "id": parent.id, + "name": parent.display_name, + "code": parent.code if "code" in parent._fields else "", + } + return result + if field.type in ("one2many", "many2many"): + return { + "ids": value.ids if value else [], + "count": len(value) if value else 0, + } + + return value + def _on_approve(self): super()._on_approve() # Signal ORM that approval_state changed (set via raw SQL in _do_approve) @@ -840,10 +985,19 @@ def _on_reject(self, reason): self._create_log("rejected", notes=reason) def _check_can_submit(self): - """Override to allow resubmission from revision state.""" + """Override to allow resubmission and validate dynamic approval field selection.""" self.ensure_one() if self.approval_state not in ("draft", "revision"): raise UserError(_("Only draft or revision-requested records can be submitted for approval.")) + cr_type = self.request_type_id + if cr_type.use_dynamic_approval and not self.selected_field_name: + raise ValidationError( + _( + "Please select a field to modify on the detail form before " + "submitting for approval. This CR type requires a single " + "field selection for dynamic approval routing." + ) + ) def _on_submit(self): # Run conflict checks before submission diff --git a/spp_change_request_v2/models/change_request_detail_base.py b/spp_change_request_v2/models/change_request_detail_base.py index 950b1b5a..6d7313c6 100644 --- a/spp_change_request_v2/models/change_request_detail_base.py +++ b/spp_change_request_v2/models/change_request_detail_base.py @@ -53,6 +53,44 @@ def _compute_is_cr_manager(self): stage = fields.Selection( related="change_request_id.stage", ) + use_dynamic_approval = fields.Boolean( + related="change_request_id.request_type_id.use_dynamic_approval", + ) + field_to_modify = fields.Selection( + selection="_get_field_to_modify_selection", + string="Field to Modify", + help="Select which field to update in this change request", + ) + + @api.model + def _get_field_to_modify_selection(self): + """Return available field options for field-level change requests. + + Override in concrete detail models to provide the list of modifiable fields. + Returns a list of (value, label) tuples, e.g.: + [("poverty_status_id", "Poverty Status"), ("set_group_id", "Set Group")] + """ + return [] + + def write(self, vals): + result = super().write(vals) + if "field_to_modify" in vals: + for rec in self: + if rec.change_request_id: + rec._sync_field_to_modify() + else: + for rec in self: + if rec.field_to_modify and rec.field_to_modify in vals and rec.change_request_id: + rec._sync_field_to_modify() + return result + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec in records: + if rec.field_to_modify and rec.change_request_id: + rec._sync_field_to_modify() + return records def action_proceed_to_cr(self): """Navigate to the parent Change Request form if there are proposed changes.""" @@ -120,6 +158,52 @@ def action_request_revision(self): self.ensure_one() return self.change_request_id.action_request_revision() + # ══════════════════════════════════════════════════════════════════════════ + # DYNAMIC APPROVAL SYNC + # ══════════════════════════════════════════════════════════════════════════ + + def _sync_field_to_modify(self): + """Sync field_to_modify and its old/new values to the parent CR.""" + self.ensure_one() + cr = self.change_request_id + if not cr: + return + # Only sync for dynamic-approval CR types + if not cr.request_type_id.use_dynamic_approval: + return + + field_name = self.field_to_modify + cr_vals = { + "selected_field_name": field_name, + "selected_field_old_value": False, + "selected_field_new_value": False, + } + + if field_name: + mapping = cr.request_type_id.apply_mapping_ids.filtered(lambda m: m.source_field == field_name)[:1] + + if mapping: + registrant = cr.registrant_id + old_raw = getattr(registrant, mapping.target_field, None) + cr_vals["selected_field_old_value"] = self._format_value_for_display(old_raw) + + new_raw = getattr(self, field_name, None) + cr_vals["selected_field_new_value"] = self._format_value_for_display(new_raw) + + cr.write(cr_vals) + + def _format_value_for_display(self, value): + """Format a field value as a human-readable string for audit display.""" + # Boolean check MUST come before the falsy check, + # otherwise False displays as "" instead of "No" + if isinstance(value, bool): + return _("Yes") if value else _("No") + if value is None or value is False: + return "" + if hasattr(value, "display_name"): + return value.display_name or "" + return str(value) + # ══════════════════════════════════════════════════════════════════════════ # PREFILL FROM REGISTRANT # ══════════════════════════════════════════════════════════════════════════ diff --git a/spp_change_request_v2/models/change_request_type.py b/spp_change_request_v2/models/change_request_type.py index 7680f964..2309997f 100644 --- a/spp_change_request_v2/models/change_request_type.py +++ b/spp_change_request_v2/models/change_request_type.py @@ -201,6 +201,21 @@ def _onchange_available_document_ids(self): string="Approval Workflow", ) auto_approve_from_event = fields.Boolean(default=False) + use_dynamic_approval = fields.Boolean( + string="Dynamic Approval", + default=False, + help="When enabled, user selects a single field to modify per CR. " + "The selected field determines which approval workflow applies.", + ) + candidate_definition_ids = fields.Many2many( + "spp.approval.definition", + "cr_type_candidate_definition_rel", + "type_id", + "definition_id", + string="Candidate Approval Definitions", + help="Evaluated in sequence order; first matching CEL condition wins. " + "If none match, the default Approval Workflow is used.", + ) # ══════════════════════════════════════════════════════════════════════════ # CONFLICT DETECTION From acc85b835dfa1d54a9d27bbca64b01ac36db6e62 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 10 Mar 2026 15:47:02 +0800 Subject: [PATCH 2/7] feat(spp_change_request_v2): add dynamic-approval-aware conflict/duplicate detection Update conflict and duplicate detection to narrow scope for dynamic CRs: - Conflict field filtering uses only selected_field_name for dynamic types - Duplicate similarity compares only the selected field (0% for different fields) - Skip conflict checks at create time for dynamic CRs (field not set yet) - Trigger conflict re-check when selected_field_name is written --- .../models/change_request_conflict.py | 11 ++-- .../models/conflict_mixin.py | 54 +++++++++++++++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/spp_change_request_v2/models/change_request_conflict.py b/spp_change_request_v2/models/change_request_conflict.py index 945cb1f6..def30d52 100644 --- a/spp_change_request_v2/models/change_request_conflict.py +++ b/spp_change_request_v2/models/change_request_conflict.py @@ -26,10 +26,14 @@ def create(self, vals_list): records = super().create(vals_list) for record in records: - # Run conflict checks if enabled for this CR type + # Run conflict checks if enabled for this CR type. + # Skip for dynamic approval — field_to_modify isn't set yet. + # Checks run when selected_field_name is set (see write() trigger). if record.request_type_id and ( record.request_type_id.enable_conflict_detection or record.request_type_id.enable_duplicate_detection ): + if record.request_type_id.use_dynamic_approval: + continue try: record._run_conflict_checks() except Exception as e: @@ -47,8 +51,9 @@ def write(self, vals): """Re-run conflict detection if relevant fields change.""" result = super().write(vals) - # Re-check conflicts if registrant or type changed - if "registrant_id" in vals or "request_type_id" in vals: + # Re-check conflicts if registrant, type, or selected field changed + trigger_fields = {"registrant_id", "request_type_id", "selected_field_name"} + if trigger_fields & set(vals): for record in self: if ( record.request_type_id diff --git a/spp_change_request_v2/models/conflict_mixin.py b/spp_change_request_v2/models/conflict_mixin.py index 59af695e..4f450a29 100644 --- a/spp_change_request_v2/models/conflict_mixin.py +++ b/spp_change_request_v2/models/conflict_mixin.py @@ -292,7 +292,12 @@ def _get_group_member_ids(self): return list(set(member_ids)) def _filter_by_field_conflicts(self, candidates, rule): - """Filter candidate CRs by checking if they modify the same fields.""" + """Filter candidate CRs by checking if they modify the same fields. + + For dynamic-approval CRs (where selected_field_name is set), only the + selected field is treated as a proposed change. Prefilled fields from + the registrant are ignored for conflict purposes. + """ self.ensure_one() conflict_fields = rule.get_conflict_fields_list() @@ -303,6 +308,15 @@ def _filter_by_field_conflicts(self, candidates, rule): if not my_detail: return self.env["spp.change.request"] + # Dynamic approval: only the selected field is a proposed change + my_selected = self.selected_field_name + if my_selected: + if my_selected not in conflict_fields: + return self.env["spp.change.request"] + my_effective_fields = [my_selected] + else: + my_effective_fields = conflict_fields + matching = self.env["spp.change.request"] for candidate in candidates: @@ -310,8 +324,20 @@ def _filter_by_field_conflicts(self, candidates, rule): if not candidate_detail: continue - # Check if any conflict field has a value in both CRs - for field_name in conflict_fields: + # Determine candidate's effective fields + candidate_selected = candidate.selected_field_name + if candidate_selected: + # Both use dynamic approval: conflict only if same field + if my_selected and candidate_selected != my_selected: + continue + candidate_effective = [candidate_selected] + else: + candidate_effective = conflict_fields + + # Check overlapping effective fields + fields_to_check = set(my_effective_fields) & set(candidate_effective) + + for field_name in fields_to_check: if field_name not in my_detail._fields: continue if field_name not in candidate_detail._fields: @@ -413,6 +439,10 @@ def _detect_duplicates(self): def _calculate_similarity(self, other_cr, config): """Calculate similarity percentage between this CR and another. + For dynamic-approval CRs (where selected_field_name is set), only the + selected field is compared. Prefilled fields are ignored to prevent + inflated similarity scores. + Args: other_cr: Another spp.change.request record config: spp.cr.duplicate.config record @@ -428,6 +458,24 @@ def _calculate_similarity(self, other_cr, config): if not my_detail or not other_detail: return 0.0 + # Dynamic approval: compare only the selected field + my_selected = self.selected_field_name + other_selected = other_cr.selected_field_name + if my_selected and other_selected: + # Different fields selected = not duplicates + if my_selected != other_selected: + return 0.0 + # Same field: compare that field's value only + if my_selected in my_detail._fields and my_selected in other_detail._fields: + my_value = self._normalize_field_value(getattr(my_detail, my_selected, None)) + other_value = self._normalize_field_value(getattr(other_detail, my_selected, None)) + if my_value == other_value: + return 100.0 + elif self._are_similar(my_value, other_value): + return 80.0 + return 0.0 + + # Static CRs (or mixed): original logic check_fields = config.get_check_fields_list() # If no specific fields configured, compare all stored fields From a73b342416f369faff657bc068007cd288583388 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 10 Mar 2026 15:47:08 +0800 Subject: [PATCH 3/7] feat(spp_change_request_v2): add dynamic approval views Add UI for dynamic approval configuration and field change display: - CR type form: dynamic approval toggle, candidate definitions, CEL help text - CR form: field change group showing selected field, old value, new value --- .../views/change_request_type_views.xml | 42 +++++++++++++++++++ .../views/change_request_views.xml | 9 ++++ 2 files changed, 51 insertions(+) diff --git a/spp_change_request_v2/views/change_request_type_views.xml b/spp_change_request_v2/views/change_request_type_views.xml index 6de5ef7e..4f90f423 100644 --- a/spp_change_request_v2/views/change_request_type_views.xml +++ b/spp_change_request_v2/views/change_request_type_views.xml @@ -150,6 +150,48 @@ + + + + + + +
+

+ How it works: +

+

+ When dynamic approval is enabled, the user selects a + single field to modify on the detail form. Each + candidate definition should have a CEL condition. +

+

+ Available CEL variables: +

+
    +
  • + record.selected_field_name + — the selected field name +
  • +
  • + old_value + — typed old value from registrant +
  • +
  • + new_value + — typed new value from detail record +
  • +
+

+ Definitions are evaluated in sequence order. First + match wins. If none match, the default Approval + Workflow above is used. +

+
+
diff --git a/spp_change_request_v2/views/change_request_views.xml b/spp_change_request_v2/views/change_request_views.xml index 871d6b4d..1e5f1927 100644 --- a/spp_change_request_v2/views/change_request_views.xml +++ b/spp_change_request_v2/views/change_request_views.xml @@ -333,6 +333,15 @@ + + + + + + + + +
Date: Tue, 10 Mar 2026 15:47:14 +0800 Subject: [PATCH 4/7] test(spp_change_request_v2): add dynamic approval and conflict detection tests 28 tests covering: - Dynamic approval routing, CEL evaluation, field sync, value normalization - Multi-tier approval with dynamic routing, E2E workflow - Dynamic-aware conflict detection (field-level, cross-type, timing) - Dynamic-aware duplicate detection (similarity scoring) --- spp_change_request_v2/tests/__init__.py | 2 + .../tests/test_conflict_dynamic_approval.py | 483 +++++++++ .../tests/test_dynamic_approval.py | 924 ++++++++++++++++++ 3 files changed, 1409 insertions(+) create mode 100644 spp_change_request_v2/tests/test_conflict_dynamic_approval.py create mode 100644 spp_change_request_v2/tests/test_dynamic_approval.py diff --git a/spp_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index 91cbf4e3..fe9b5c55 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -21,3 +21,5 @@ from . import test_ux_wizards from . import test_stage_navigation from . import test_approval_hooks_and_audit +from . import test_dynamic_approval +from . import test_conflict_dynamic_approval diff --git a/spp_change_request_v2/tests/test_conflict_dynamic_approval.py b/spp_change_request_v2/tests/test_conflict_dynamic_approval.py new file mode 100644 index 00000000..dfebdd91 --- /dev/null +++ b/spp_change_request_v2/tests/test_conflict_dynamic_approval.py @@ -0,0 +1,483 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for dynamic-approval-aware conflict and duplicate detection. + +When a CR type has `use_dynamic_approval=True`, only the field selected via +`field_to_modify` represents a proposed change — all other fields are prefilled +snapshots from the registrant. Conflict and duplicate detection must account +for this to avoid false positives and inflated similarity scores. +""" + +import logging + +from odoo import Command, api +from odoo.tests import TransactionCase, tagged + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install") +class TestConflictDynamicApproval(TransactionCase): + """Test conflict/duplicate detection integration with dynamic approval.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + # Patch _get_field_to_modify_selection so field_to_modify has valid options. + DetailModel = type(cls.env["spp.cr.detail.edit_individual"]) + cls._orig_get_field_to_modify_selection = DetailModel._get_field_to_modify_selection + + @api.model + def _test_field_to_modify_selection(self): + return [ + ("given_name", "Given Name"), + ("family_name", "Family Name"), + ("phone", "Phone"), + ] + + DetailModel._get_field_to_modify_selection = _test_field_to_modify_selection + + # Model shortcuts + cls.CR = cls.env["spp.change.request"] + cls.CRType = cls.env["spp.change.request.type"] + cls.ApprovalDef = cls.env["spp.approval.definition"] + + cr_model = cls.env["ir.model"].search([("model", "=", "spp.change.request")], limit=1) + + # Create approver group + user + cls.approver_group = cls.env["res.groups"].create({"name": "Conflict DA Test Approvers"}) + cls.approval_def = cls.ApprovalDef.create( + { + "name": "Conflict DA Test Approval", + "model_id": cr_model.id, + "approval_type": "group", + "approval_group_id": cls.approver_group.id, + } + ) + + # Dynamic CR type with conflict detection enabled + cls.dynamic_cr_type = cls.CRType.create( + { + "name": "Dynamic Conflict Test", + "code": "dyn_conflict_test", + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + "apply_strategy": "field_mapping", + "approval_definition_id": cls.approval_def.id, + "use_dynamic_approval": True, + "candidate_definition_ids": [Command.link(cls.approval_def.id)], + "enable_conflict_detection": True, + } + ) + + # Field-scope conflict rule: checks given_name, family_name + cls.field_rule = cls.env["spp.cr.conflict.rule"].create( + { + "name": "Field Conflict (name fields)", + "cr_type_id": cls.dynamic_cr_type.id, + "scope": "field", + "action": "warn", + "conflict_fields": "given_name, family_name", + } + ) + + # Static CR type (no dynamic approval) for mixed-mode tests + cls.static_cr_type = cls.CRType.create( + { + "name": "Static Conflict Test", + "code": "static_conflict_test", + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + "apply_strategy": "field_mapping", + "approval_definition_id": cls.approval_def.id, + "use_dynamic_approval": False, + "enable_conflict_detection": True, + } + ) + + # Same field-scope rule for static type + cls.env["spp.cr.conflict.rule"].create( + { + "name": "Field Conflict Static (name fields)", + "cr_type_id": cls.static_cr_type.id, + "scope": "field", + "action": "warn", + "conflict_fields": "given_name, family_name", + "check_same_type_only": False, + "conflict_type_ids": [Command.set([cls.dynamic_cr_type.id, cls.static_cr_type.id])], + } + ) + + # Create test registrant + cls.registrant = cls.env["res.partner"].create( + { + "name": "DA Conflict Test Individual", + "given_name": "OriginalGiven", + "family_name": "OriginalFamily", + "phone": "555-0100", + "is_registrant": True, + "is_group": False, + } + ) + + @classmethod + def tearDownClass(cls): + DetailModel = type(cls.env["spp.cr.detail.edit_individual"]) + DetailModel._get_field_to_modify_selection = cls._orig_get_field_to_modify_selection + super().tearDownClass() + + def _create_dynamic_cr(self, registrant=None): + """Create a dynamic-approval CR for the given registrant.""" + return self.CR.create( + { + "request_type_id": self.dynamic_cr_type.id, + "registrant_id": (registrant or self.registrant).id, + } + ) + + def _create_static_cr(self, registrant=None): + """Create a static CR for the given registrant.""" + return self.CR.create( + { + "request_type_id": self.static_cr_type.id, + "registrant_id": (registrant or self.registrant).id, + } + ) + + # ────────────────────────────────────────────────────────────────────────── + # Phase 4a: Field Conflict Tests (dynamic approval) + # ────────────────────────────────────────────────────────────────────────── + + def test_dynamic_cr_different_fields_no_conflict(self): + """Two dynamic CRs modifying different fields should NOT conflict. + + CR-A selects given_name, CR-B selects family_name. Both are in the + rule's conflict_fields list, but since they modify different fields, + no conflict should be detected. + """ + cr_a = self._create_dynamic_cr() + detail_a = cr_a.get_detail() + detail_a.write({"field_to_modify": "given_name", "given_name": "NewGivenA"}) + cr_a._run_conflict_checks() + + cr_b = self._create_dynamic_cr() + detail_b = cr_b.get_detail() + detail_b.write({"field_to_modify": "family_name", "family_name": "NewFamilyB"}) + cr_b._run_conflict_checks() + + self.assertEqual( + cr_b.conflict_status, + "none", + "Different fields selected = no field-level conflict.", + ) + self.assertNotIn(cr_a, cr_b.conflicting_cr_ids) + + def test_dynamic_cr_same_field_detects_conflict(self): + """Two dynamic CRs modifying the same field SHOULD conflict. + + Both CR-A and CR-B select given_name for the same registrant. + """ + cr_a = self._create_dynamic_cr() + detail_a = cr_a.get_detail() + detail_a.write({"field_to_modify": "given_name", "given_name": "NewGivenA"}) + cr_a._run_conflict_checks() + + cr_b = self._create_dynamic_cr() + detail_b = cr_b.get_detail() + detail_b.write({"field_to_modify": "given_name", "given_name": "NewGivenB"}) + cr_b._run_conflict_checks() + + self.assertEqual( + cr_b.conflict_status, + "warning", + "Same field selected = conflict expected.", + ) + self.assertIn(cr_a, cr_b.conflicting_cr_ids) + + def test_dynamic_cr_selected_field_not_in_rule_no_conflict(self): + """Dynamic CR selecting a field NOT in the rule's conflict_fields should NOT conflict. + + CR selects phone, but rule only checks given_name and family_name. + """ + cr_a = self._create_dynamic_cr() + detail_a = cr_a.get_detail() + detail_a.write({"field_to_modify": "phone", "phone": "555-0101"}) + cr_a._run_conflict_checks() + + cr_b = self._create_dynamic_cr() + detail_b = cr_b.get_detail() + detail_b.write({"field_to_modify": "phone", "phone": "555-0102"}) + cr_b._run_conflict_checks() + + self.assertEqual( + cr_b.conflict_status, + "none", + "Selected field not in conflict_fields = no conflict.", + ) + + def test_dynamic_vs_static_cr_field_conflict(self): + """A dynamic CR and a static CR modifying the same field SHOULD conflict. + + Dynamic CR selects given_name. Static CR has given_name populated. + The static type's rule checks both types and has cross-type conflict_type_ids. + """ + # Create static CR first with given_name set + cr_static = self._create_static_cr() + detail_static = cr_static.get_detail() + detail_static.write({"given_name": "StaticNewGiven"}) + cr_static._run_conflict_checks() + + # Create dynamic CR that selects given_name + cr_dynamic = self._create_dynamic_cr() + detail_dynamic = cr_dynamic.get_detail() + detail_dynamic.write({"field_to_modify": "given_name", "given_name": "DynamicNewGiven"}) + cr_dynamic._run_conflict_checks() + + # Dynamic CR should detect the static CR as conflicting + self.assertIn( + cr_static, + cr_dynamic.conflicting_cr_ids, + "Dynamic CR should detect conflict with static CR on same field.", + ) + + def test_dynamic_cr_field_conflict_before_selection_no_check(self): + """Dynamic CR created without field_to_modify should skip conflict checks. + + At create time, field_to_modify is not set. Conflict checks should be + skipped so conflict_detection_date stays empty. + """ + cr = self._create_dynamic_cr() + + self.assertFalse( + cr.conflict_detection_date, + "Conflict checks must be skipped at create time for dynamic-approval types.", + ) + + # ────────────────────────────────────────────────────────────────────────── + # Phase 4b: Duplicate Detection Tests (dynamic approval) + # ────────────────────────────────────────────────────────────────────────── + + def test_dynamic_cr_different_fields_zero_similarity(self): + """Two dynamic CRs selecting different fields should have 0% similarity.""" + # Enable duplicate detection + dup_config = self.env["spp.cr.duplicate.config"].create( + { + "cr_type_id": self.dynamic_cr_type.id, + "similarity_threshold": 50.0, + } + ) + self.dynamic_cr_type.write( + { + "enable_duplicate_detection": True, + "duplicate_detection_config_id": dup_config.id, + } + ) + + try: + cr_a = self._create_dynamic_cr() + detail_a = cr_a.get_detail() + detail_a.write({"field_to_modify": "given_name", "given_name": "NewGivenA"}) + cr_a._run_conflict_checks() + + cr_b = self._create_dynamic_cr() + detail_b = cr_b.get_detail() + detail_b.write({"field_to_modify": "family_name", "family_name": "NewFamilyB"}) + + similarity = cr_b._calculate_similarity(cr_a, dup_config) + self.assertEqual( + similarity, + 0.0, + "Different fields selected = 0% similarity.", + ) + finally: + self.dynamic_cr_type.write( + { + "enable_duplicate_detection": False, + "duplicate_detection_config_id": False, + } + ) + dup_config.unlink() + + def test_dynamic_cr_same_field_same_value_100_similarity(self): + """Two dynamic CRs with same field and same value should have 100% similarity.""" + dup_config = self.env["spp.cr.duplicate.config"].create( + { + "cr_type_id": self.dynamic_cr_type.id, + "similarity_threshold": 50.0, + } + ) + self.dynamic_cr_type.write( + { + "enable_duplicate_detection": True, + "duplicate_detection_config_id": dup_config.id, + } + ) + + try: + cr_a = self._create_dynamic_cr() + detail_a = cr_a.get_detail() + detail_a.write({"field_to_modify": "given_name", "given_name": "SameValue"}) + cr_a._run_conflict_checks() + + cr_b = self._create_dynamic_cr() + detail_b = cr_b.get_detail() + detail_b.write({"field_to_modify": "given_name", "given_name": "SameValue"}) + + similarity = cr_b._calculate_similarity(cr_a, dup_config) + self.assertEqual( + similarity, + 100.0, + "Same field + same value = 100% similarity.", + ) + finally: + self.dynamic_cr_type.write( + { + "enable_duplicate_detection": False, + "duplicate_detection_config_id": False, + } + ) + dup_config.unlink() + + def test_dynamic_cr_same_field_different_value_low_similarity(self): + """Two dynamic CRs with same field but different values should have low similarity.""" + dup_config = self.env["spp.cr.duplicate.config"].create( + { + "cr_type_id": self.dynamic_cr_type.id, + "similarity_threshold": 50.0, + } + ) + self.dynamic_cr_type.write( + { + "enable_duplicate_detection": True, + "duplicate_detection_config_id": dup_config.id, + } + ) + + try: + cr_a = self._create_dynamic_cr() + detail_a = cr_a.get_detail() + detail_a.write({"field_to_modify": "given_name", "given_name": "AlphaValue"}) + cr_a._run_conflict_checks() + + cr_b = self._create_dynamic_cr() + detail_b = cr_b.get_detail() + detail_b.write({"field_to_modify": "given_name", "given_name": "ZetaValue"}) + + similarity = cr_b._calculate_similarity(cr_a, dup_config) + # Different values, no substring match → 0% + self.assertEqual( + similarity, + 0.0, + "Same field + completely different value = 0% similarity.", + ) + finally: + self.dynamic_cr_type.write( + { + "enable_duplicate_detection": False, + "duplicate_detection_config_id": False, + } + ) + dup_config.unlink() + + def test_dynamic_cr_prefilled_fields_not_inflating_score(self): + """Two dynamic CRs for same registrant selecting different fields must have 0% similarity. + + Without the fix, all prefilled fields would be identical (from same registrant), + inflating the score to ~80%+. With the fix, different fields = 0%. + """ + dup_config = self.env["spp.cr.duplicate.config"].create( + { + "cr_type_id": self.dynamic_cr_type.id, + "similarity_threshold": 50.0, + # No check_fields configured — would compare ALL stored fields without fix + } + ) + self.dynamic_cr_type.write( + { + "enable_duplicate_detection": True, + "duplicate_detection_config_id": dup_config.id, + } + ) + + try: + cr_a = self._create_dynamic_cr() + detail_a = cr_a.get_detail() + detail_a.write({"field_to_modify": "given_name", "given_name": "Changed"}) + cr_a._run_conflict_checks() + + cr_b = self._create_dynamic_cr() + detail_b = cr_b.get_detail() + detail_b.write({"field_to_modify": "family_name", "family_name": "Changed"}) + + similarity = cr_b._calculate_similarity(cr_a, dup_config) + self.assertEqual( + similarity, + 0.0, + "Prefilled fields must not inflate similarity. " + "Different selected fields = 0% regardless of prefilled values.", + ) + finally: + self.dynamic_cr_type.write( + { + "enable_duplicate_detection": False, + "duplicate_detection_config_id": False, + } + ) + dup_config.unlink() + + # ────────────────────────────────────────────────────────────────────────── + # Phase 4c: Timing Tests + # ────────────────────────────────────────────────────────────────────────── + + def test_create_dynamic_cr_skips_conflict_check(self): + """Creating a dynamic-approval CR must skip conflict checks at create time. + + field_to_modify is not set yet at create time, so running checks + would produce meaningless results. conflict_detection_date stays False. + """ + cr = self._create_dynamic_cr() + self.assertFalse( + cr.conflict_detection_date, + "Dynamic CR must not run conflict checks at create time.", + ) + + def test_set_field_to_modify_triggers_conflict_check(self): + """Setting field_to_modify on detail must trigger conflict checks via write(). + + When _sync_field_to_modify() sets selected_field_name on the CR, + the write() trigger should run _run_conflict_checks(). + """ + # Create a first CR so there's something to potentially conflict with + cr_a = self._create_dynamic_cr() + detail_a = cr_a.get_detail() + detail_a.write({"field_to_modify": "given_name", "given_name": "ValueA"}) + + # Create second CR — conflict_detection_date should be False at this point + cr_b = self._create_dynamic_cr() + self.assertFalse(cr_b.conflict_detection_date) + + # Set field_to_modify on cr_b's detail — should trigger conflict check + detail_b = cr_b.get_detail() + detail_b.write({"field_to_modify": "given_name", "given_name": "ValueB"}) + + cr_b.invalidate_recordset() + self.assertTrue( + cr_b.conflict_detection_date, + "Setting field_to_modify must trigger conflict checks.", + ) + + def test_static_cr_create_still_runs_conflict_check(self): + """Creating a static CR must still run conflict checks at create time. + + This verifies the timing fix only affects dynamic-approval types. + """ + # Create first static CR + self._create_static_cr() + + # Create second static CR — should run checks at create time + cr2 = self._create_static_cr() + self.assertTrue( + cr2.conflict_detection_date, + "Static CR must run conflict checks at create time (existing behavior).", + ) diff --git a/spp_change_request_v2/tests/test_dynamic_approval.py b/spp_change_request_v2/tests/test_dynamic_approval.py new file mode 100644 index 00000000..938ee6ad --- /dev/null +++ b/spp_change_request_v2/tests/test_dynamic_approval.py @@ -0,0 +1,924 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for dynamic approval routing on Change Request types. + +When a CR type has `use_dynamic_approval=True`, the user selects a single field +to modify from a dropdown on the detail form. The selected field name, together +with the old and new values, is exposed on `spp.change.request` as +`selected_field_name`, `selected_field_old_value`, and +`selected_field_new_value`. The approval workflow is then resolved by evaluating +a list of candidate `spp.approval.definition` records (sorted by sequence) using +their CEL conditions. The first definition whose condition evaluates to True wins; +definitions without a CEL condition act as catch-all fallbacks. +""" + +import logging + +from odoo import Command, api +from odoo.exceptions import ValidationError +from odoo.tests import TransactionCase, tagged + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install") +class TestDynamicApproval(TransactionCase): + """Test suite for the dynamic approval routing feature. + + Most tests exercise `_get_approval_definition()` directly because that is + the hook that drives routing. One end-to-end test (test 13) exercises the + full submission/approval/apply cycle to verify the complete flow. + """ + + # ────────────────────────────────────────────────────────────────────────── + # CLASS-LEVEL SETUP + # ────────────────────────────────────────────────────────────────────────── + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Disable tracking to avoid sending e-mail notifications during setup. + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + # Patch the _get_field_to_modify_selection method on the concrete model + # so field_to_modify has valid options for our tests. The base method + # returns [] and concrete models override it. We save the original and + # restore in tearDownClass. + DetailModel = type(cls.env["spp.cr.detail.edit_individual"]) + cls._orig_get_field_to_modify_selection = DetailModel._get_field_to_modify_selection + + @api.model + def _test_field_to_modify_selection(self): + return [ + ("given_name", "Given Name"), + ("family_name", "Family Name"), + ("phone", "Phone"), + ("gender_id", "Gender"), + ("birthdate", "Birthdate"), + ] + + DetailModel._get_field_to_modify_selection = _test_field_to_modify_selection + + # Model shortcuts + cls.CR = cls.env["spp.change.request"] + cls.CRType = cls.env["spp.change.request.type"] + cls.ApprovalDef = cls.env["spp.approval.definition"] + + # Get the ir.model record for spp.change.request (required by approval defs) + cls.cr_model_record = cls.env["ir.model"].search([("model", "=", "spp.change.request")], limit=1) + + # Create a dedicated approver user (SUPERUSER/OdooBot doesn't work with + # normal group membership lookups in tier approvals). + cls.approver_user = cls.env["res.users"].create( + { + "name": "Dynamic Approval Approver", + "login": "dynamic_approval_approver", + "email": "approver@test.com", + "group_ids": [ + Command.set( + [ + cls.env.ref("base.group_user").id, + cls.env.ref("spp_approval.group_approval_approver").id, + ] + ) + ], + } + ) + # Create a security group with the approver user as member. + cls.approver_group = cls.env["res.groups"].create( + { + "name": "Dynamic Approval Test Approvers", + "user_ids": [Command.set([cls.approver_user.id])], + } + ) + + # Create a registrant with known field values so old-value assertions are + # deterministic. + cls.registrant = cls.env["res.partner"].create( + { + "name": "Dynamic Test Individual", + "given_name": "Original Given", + "family_name": "Original Family", + "phone": "111-222", + "is_registrant": True, + "is_group": False, + } + ) + + # ── Approval definitions ────────────────────────────────────────────── + + # escalated_def (sequence=10): matches name-change fields via CEL. + cls.escalated_def = cls.ApprovalDef.create( + { + "name": "Escalated Approvals (name fields)", + "model_id": cls.cr_model_record.id, + "sequence": 10, + "approval_type": "group", + "approval_group_id": cls.approver_group.id, + "use_cel_condition": True, + "cel_condition": ('record.selected_field_name in ["given_name", "family_name"]'), + } + ) + + # fallback_def (sequence=20): no CEL condition — acts as catch-all. + cls.fallback_def = cls.ApprovalDef.create( + { + "name": "Fallback Approvals (catch-all)", + "model_id": cls.cr_model_record.id, + "sequence": 20, + "approval_type": "group", + "approval_group_id": cls.approver_group.id, + } + ) + + # static_def: used by the non-dynamic CR type as its sole definition. + cls.static_def = cls.ApprovalDef.create( + { + "name": "Static Approval Definition", + "model_id": cls.cr_model_record.id, + "approval_type": "group", + "approval_group_id": cls.approver_group.id, + } + ) + + # ── CR types ───────────────────────────────────────────────────────── + + # Shared apply mappings for the field_mapping strategy. These map + # detail fields to identically-named registrant fields for the simple + # scalar fields used in our tests. + _common_mappings = [ + Command.create({"source_field": "given_name", "target_field": "given_name", "sequence": 10}), + Command.create({"source_field": "family_name", "target_field": "family_name", "sequence": 20}), + Command.create({"source_field": "phone", "target_field": "phone", "sequence": 30}), + Command.create({"source_field": "gender_id", "target_field": "gender_id", "sequence": 40}), + Command.create({"source_field": "birthdate", "target_field": "birthdate", "sequence": 50}), + ] + + cls.dynamic_cr_type = cls.CRType.create( + { + "name": "Dynamic Approval Test Type", + "code": "dyn_approval_test", + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + "apply_strategy": "field_mapping", + "approval_definition_id": cls.static_def.id, + "use_dynamic_approval": True, + "candidate_definition_ids": [ + Command.link(cls.escalated_def.id), + Command.link(cls.fallback_def.id), + ], + "apply_mapping_ids": _common_mappings, + "auto_apply_on_approve": True, + } + ) + + cls.non_dynamic_cr_type = cls.CRType.create( + { + "name": "Non-Dynamic Approval Test Type", + "code": "non_dyn_approval_test", + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + "apply_strategy": "field_mapping", + "approval_definition_id": cls.static_def.id, + "use_dynamic_approval": False, + "auto_apply_on_approve": True, + } + ) + + @classmethod + def tearDownClass(cls): + # Revert the monkey-patched method so other test modules are not affected. + DetailModel = type(cls.env["spp.cr.detail.edit_individual"]) + DetailModel._get_field_to_modify_selection = cls._orig_get_field_to_modify_selection + super().tearDownClass() + + # ────────────────────────────────────────────────────────────────────────── + # HELPER + # ────────────────────────────────────────────────────────────────────────── + + def _create_cr(self, cr_type=None, registrant=None): + """Create a CR with the given type, defaulting to the dynamic type.""" + return self.CR.create( + { + "request_type_id": (cr_type or self.dynamic_cr_type).id, + "registrant_id": (registrant or self.registrant).id, + } + ) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 1 – Static approval when dynamic is disabled + # ────────────────────────────────────────────────────────────────────────── + + def test_static_approval_when_dynamic_disabled(self): + """CR types with use_dynamic_approval=False return their static definition. + + _get_approval_definition() should return the `approval_definition_id` + configured on the CR type without consulting any candidate definitions. + """ + cr = self._create_cr(cr_type=self.non_dynamic_cr_type) + result = cr._get_approval_definition() + self.assertEqual( + result, + self.static_def, + "Non-dynamic CR type must return its static approval_definition_id.", + ) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 2 – Dynamic routing: field matches escalated definition + # ────────────────────────────────────────────────────────────────────────── + + def test_dynamic_approval_field_matches_escalated_definition(self): + """Selecting a name field routes to escalated_def via CEL. + + The CEL condition on escalated_def checks: + ``record.selected_field_name in ["given_name", "family_name"]`` + Writing given_name on the detail should make selected_field_name sync to + "given_name" and the routing should pick escalated_def (sequence=10). + """ + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": "given_name", "given_name": "New Name"}) + + self.assertEqual( + cr.selected_field_name, + "given_name", + "selected_field_name must sync from detail.field_to_modify.", + ) + + result = cr._get_approval_definition() + self.assertEqual( + result, + self.escalated_def, + "given_name field must route to escalated_def whose CEL matches name fields.", + ) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 3 – Dynamic routing: unmatched field falls through to fallback + # ────────────────────────────────────────────────────────────────────────── + + def test_dynamic_approval_unmatched_field_uses_fallback(self): + """A field not covered by any CEL condition falls back to catch-all. + + ``phone`` is not in escalated_def's CEL list, so the engine skips + escalated_def and returns fallback_def (no CEL condition = always True). + """ + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": "phone", "phone": "999-888"}) + + result = cr._get_approval_definition() + self.assertEqual( + result, + self.fallback_def, + "A field not matched by any CEL condition must use the catch-all fallback.", + ) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 4 – Validation error when no field selected before submission + # ────────────────────────────────────────────────────────────────────────── + + def test_dynamic_approval_no_field_selected_raises_validation_error(self): + """Submitting a dynamic CR without selecting field_to_modify raises ValidationError. + + _check_can_submit() (or equivalent pre-submission validation) must reject + the submission when field_to_modify is empty and use_dynamic_approval=True. + """ + cr = self._create_cr() + # Do NOT write field_to_modify — leave it blank. + with self.assertRaises(ValidationError): + cr._check_can_submit() + + # ────────────────────────────────────────────────────────────────────────── + # TEST 5 – Broken CEL is skipped with a warning + # ────────────────────────────────────────────────────────────────────────── + + def test_dynamic_approval_cel_failure_skips_candidate(self): + """A candidate with invalid CEL is skipped and a warning is logged. + + A third definition with sequence=5 (evaluated first) has intentionally + broken CEL syntax. The engine must catch the error, log a warning, and + continue to escalated_def (sequence=10) which matches given_name. + """ + # Use an expression that is syntactically valid (passes the parser) + # but will fail at evaluation time due to referencing a nonexistent + # attribute. This bypasses the @api.constrains CEL validator while + # still triggering a runtime error in _resolve_dynamic_approval. + broken_def = self.ApprovalDef.create( + { + "name": "Broken CEL Candidate", + "model_id": self.cr_model_record.id, + "sequence": 5, + "approval_type": "group", + "approval_group_id": self.approver_group.id, + "use_cel_condition": True, + "cel_condition": "record.nonexistent_xyz_field > 999", + } + ) + + # Temporarily add broken_def at the front of candidates. + self.dynamic_cr_type.write({"candidate_definition_ids": [Command.link(broken_def.id)]}) + + try: + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": "given_name", "given_name": "Tested"}) + + # Despite the broken candidate at seq=5, routing must still work and + # return the first *valid* matching definition. + result = cr._get_approval_definition() + self.assertEqual( + result, + self.escalated_def, + "Broken CEL candidate must be skipped; escalated_def should be returned.", + ) + finally: + # Clean up so broken_def does not affect other tests. + self.dynamic_cr_type.write({"candidate_definition_ids": [Command.unlink(broken_def.id)]}) + broken_def.unlink() + + # ────────────────────────────────────────────────────────────────────────── + # TEST 6 – First matching candidate wins (sequence ordering) + # ────────────────────────────────────────────────────────────────────────── + + def test_dynamic_approval_first_match_wins(self): + """When multiple candidates match, the one with the lowest sequence wins. + + We add a third definition with sequence=5 that also matches given_name. + That definition should be returned before escalated_def (sequence=10). + """ + early_def = self.ApprovalDef.create( + { + "name": "Early Match Definition (seq=5)", + "model_id": self.cr_model_record.id, + "sequence": 5, + "approval_type": "group", + "approval_group_id": self.approver_group.id, + "use_cel_condition": True, + "cel_condition": ('record.selected_field_name in ["given_name", "family_name", "phone"]'), + } + ) + + self.dynamic_cr_type.write({"candidate_definition_ids": [Command.link(early_def.id)]}) + + try: + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": "given_name", "given_name": "First Match"}) + + result = cr._get_approval_definition() + self.assertEqual( + result, + early_def, + "The candidate with the lowest sequence that matches must be returned first.", + ) + finally: + self.dynamic_cr_type.write({"candidate_definition_ids": [Command.unlink(early_def.id)]}) + early_def.unlink() + + # ────────────────────────────────────────────────────────────────────────── + # TEST 7 – field_to_modify syncs to CR on detail write + # ────────────────────────────────────────────────────────────────────────── + + def test_field_to_modify_syncs_on_detail_write(self): + """Writing field_to_modify on the detail record must sync to the parent CR. + + The detail's write() override calls _sync_field_to_modify() which + updates `selected_field_name` on the parent `spp.change.request`. + """ + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": "given_name"}) + + cr.invalidate_recordset() + self.assertEqual( + cr.selected_field_name, + "given_name", + "selected_field_name on the CR must be updated when detail.field_to_modify changes.", + ) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 8 – field_to_modify syncs to CR when set on detail create + # ────────────────────────────────────────────────────────────────────────── + + def test_field_to_modify_syncs_on_detail_create(self): + """Creating a detail record with field_to_modify set must sync to the CR. + + _ensure_detail() creates the auto-detail without field_to_modify. When a + second (or explicit) detail is created with field_to_modify in the create + vals, the create() override must call _sync_field_to_modify() so the + parent CR's selected_field_name is updated. + """ + cr = self._create_cr() + + # Create an additional detail record with field_to_modify already set. + # The CR model links to the *first* auto-created detail, but the sync + # must still occur for any detail linked to this CR. + new_detail = self.env["spp.cr.detail.edit_individual"].create( + { + "change_request_id": cr.id, + "field_to_modify": "family_name", + "family_name": "Created Family", + } + ) + + # The most recently created detail set field_to_modify; CR must reflect it. + cr.invalidate_recordset() + self.assertEqual( + cr.selected_field_name, + "family_name", + "Creating a detail with field_to_modify set must sync selected_field_name on the CR.", + ) + self.assertTrue(new_detail.id, "Detail record must have been created successfully.") + + # ────────────────────────────────────────────────────────────────────────── + # TEST 9 – Old and new values synced on save + # ────────────────────────────────────────────────────────────────────────── + + def test_old_new_values_synced_on_save(self): + """Writing field_to_modify and the new value syncs old/new to the CR. + + The registrant was created with given_name="Original Given". After the + detail is saved with given_name="New Given Name", the CR must expose: + - selected_field_old_value == "Original Given" + - selected_field_new_value == "New Given Name" + """ + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": "given_name", "given_name": "New Given Name"}) + + cr.invalidate_recordset() + self.assertEqual( + cr.selected_field_old_value, + "Original Given", + "selected_field_old_value must reflect the registrant's current field value.", + ) + self.assertEqual( + cr.selected_field_new_value, + "New Given Name", + "selected_field_new_value must reflect the value written to the detail.", + ) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 10 – Old/new values for Many2one fields use display names + # ────────────────────────────────────────────────────────────────────────── + + def test_old_new_value_for_many2one_field(self): + """Old and new values for Many2one fields must be human-readable display names. + + For Many2one fields the raw value is a recordset; the implementation + should store the display_name (or name) string, not the integer ID. + """ + gender_codes = self.env["spp.vocabulary.code"].search([("namespace_uri", "=", "urn:iso:std:iso:5218")], limit=2) + if len(gender_codes) < 2: + self.skipTest("Need at least 2 gender vocabulary codes (urn:iso:std:iso:5218) to run this test.") + + gender_old, gender_new = gender_codes[0], gender_codes[1] + + # Set the registrant's current gender so we have a known old value. + self.registrant.write({"gender_id": gender_old.id}) + + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": "gender_id", "gender_id": gender_new.id}) + + cr.invalidate_recordset() + # Values must be strings (display names), not integer IDs. + self.assertNotEqual( + cr.selected_field_old_value, + str(gender_old.id), + "selected_field_old_value must not be a raw database ID.", + ) + self.assertNotEqual( + cr.selected_field_new_value, + str(gender_new.id), + "selected_field_new_value must not be a raw database ID.", + ) + self.assertIn( + gender_old.display_name, + cr.selected_field_old_value or "", + "selected_field_old_value must contain the old gender's display name.", + ) + self.assertIn( + gender_new.display_name, + cr.selected_field_new_value or "", + "selected_field_new_value must contain the new gender's display name.", + ) + + # Restore the registrant to a neutral state. + self.registrant.write({"gender_id": False}) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 11 – CEL condition can access new_value for scalar comparison + # ────────────────────────────────────────────────────────────────────────── + + def test_cel_condition_with_old_new_values(self): + """CEL condition can use new_value to match a specific string value. + + A candidate definition with CEL: + ``record.selected_field_name == "given_name" and new_value == "SpecificName"`` + should only match when the new value is exactly "SpecificName". + """ + specific_def = self.ApprovalDef.create( + { + "name": "Specific Name Approvals", + "model_id": self.cr_model_record.id, + "sequence": 5, + "approval_type": "group", + "approval_group_id": self.approver_group.id, + "use_cel_condition": True, + "cel_condition": ( + 'record.selected_field_name == "given_name" and record.selected_field_new_value == "SpecificName"' + ), + } + ) + self.dynamic_cr_type.write({"candidate_definition_ids": [Command.link(specific_def.id)]}) + + try: + # Case A: new value IS "SpecificName" — specific_def must match. + cr_match = self._create_cr() + detail_match = cr_match.get_detail() + detail_match.write({"field_to_modify": "given_name", "given_name": "SpecificName"}) + result_match = cr_match._get_approval_definition() + self.assertEqual( + result_match, + specific_def, + "CEL condition matching on new_value must route to specific_def.", + ) + + # Case B: new value is something else — specific_def must NOT match. + cr_no_match = self._create_cr() + detail_no_match = cr_no_match.get_detail() + detail_no_match.write({"field_to_modify": "given_name", "given_name": "OtherName"}) + result_no_match = cr_no_match._get_approval_definition() + self.assertNotEqual( + result_no_match, + specific_def, + "CEL condition must not match when new_value differs from 'SpecificName'.", + ) + # Should fall through to escalated_def (given_name is in its list). + self.assertEqual( + result_no_match, + self.escalated_def, + "After specific_def is skipped, escalated_def must be returned for given_name.", + ) + finally: + self.dynamic_cr_type.write({"candidate_definition_ids": [Command.unlink(specific_def.id)]}) + specific_def.unlink() + + # ────────────────────────────────────────────────────────────────────────── + # TEST 12 – CEL condition with Many2one value comparison + # ────────────────────────────────────────────────────────────────────────── + + def test_cel_condition_with_many2one_values(self): + """CEL condition can match on the display name of a Many2one new value. + + A candidate definition with: + ``record.selected_field_name == "gender_id" and record.selected_field_new_value == ""`` + should route to the correct definition only for the specified gender. + """ + gender_codes = self.env["spp.vocabulary.code"].search([("namespace_uri", "=", "urn:iso:std:iso:5218")], limit=2) + if len(gender_codes) < 2: + self.skipTest("Need at least 2 gender vocabulary codes (urn:iso:std:iso:5218) to run this test.") + + target_gender = gender_codes[0] + other_gender = gender_codes[1] + target_display = target_gender.display_name + + gender_specific_def = self.ApprovalDef.create( + { + "name": "Gender-Specific Approvals", + "model_id": self.cr_model_record.id, + "sequence": 5, + "approval_type": "group", + "approval_group_id": self.approver_group.id, + "use_cel_condition": True, + "cel_condition": ( + f'record.selected_field_name == "gender_id" ' + f'and record.selected_field_new_value == "{target_display}"' + ), + } + ) + self.dynamic_cr_type.write({"candidate_definition_ids": [Command.link(gender_specific_def.id)]}) + + try: + # Case A: new gender matches target — gender_specific_def must match. + cr_match = self._create_cr() + detail_match = cr_match.get_detail() + detail_match.write({"field_to_modify": "gender_id", "gender_id": target_gender.id}) + result_match = cr_match._get_approval_definition() + self.assertEqual( + result_match, + gender_specific_def, + "CEL must match when the new Many2one display name equals the expected value.", + ) + + # Case B: new gender is different — gender_specific_def must NOT match. + cr_no_match = self._create_cr() + detail_no_match = cr_no_match.get_detail() + detail_no_match.write({"field_to_modify": "gender_id", "gender_id": other_gender.id}) + result_no_match = cr_no_match._get_approval_definition() + self.assertNotEqual( + result_no_match, + gender_specific_def, + "CEL must not match when the new Many2one display name differs.", + ) + finally: + self.dynamic_cr_type.write({"candidate_definition_ids": [Command.unlink(gender_specific_def.id)]}) + gender_specific_def.unlink() + # Reset registrant gender for cleanliness. + self.registrant.write({"gender_id": False}) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 13 – End-to-end: multi-tier dynamic approval submit → approve → apply + # ────────────────────────────────────────────────────────────────────────── + + def test_dynamic_approval_end_to_end_multitier(self): + """Full lifecycle: field selection → submission → tier-by-tier approval → apply. + + We create a multi-tier escalated definition with two tiers. Both tiers + use our test approver_group so the current (admin) user can approve both. + After the second tier is approved the CR is auto-applied and the + registrant's field is updated. + """ + # ── Build a multi-tier definition ──────────────────────────────────── + # Per the project memory: create the definition WITHOUT use_multitier + # first, create tiers, then enable use_multitier. + multitier_def = self.ApprovalDef.create( + { + "name": "Multi-Tier Dynamic Approval", + "model_id": self.cr_model_record.id, + "sequence": 5, + "approval_type": "group", + "approval_group_id": self.approver_group.id, + "use_cel_condition": True, + "cel_condition": ('record.selected_field_name in ["given_name", "family_name"]'), + } + ) + + # Create tiers before enabling use_multitier (constraint requires tiers). + tier1 = self.env["spp.approval.tier"].create( + { + "definition_id": multitier_def.id, + "name": "Tier 1 – Initial Review", + "sequence": 10, + "approval_type": "group", + "approval_group_id": self.approver_group.id, + } + ) + tier2 = self.env["spp.approval.tier"].create( + { + "definition_id": multitier_def.id, + "name": "Tier 2 – Final Approval", + "sequence": 20, + "approval_type": "group", + "approval_group_id": self.approver_group.id, + } + ) + + # Enable multi-tier now that tiers exist. + multitier_def.write({"use_multitier": True}) + + # Create an isolated registrant for this test to avoid cross-test state. + e2e_registrant = self.env["res.partner"].create( + { + "name": "E2E Multitier Test", + "given_name": "BeforeApproval", + "family_name": "Original", + "is_registrant": True, + "is_group": False, + } + ) + + # CR type for this test: multitier_def as candidate, with field mappings. + e2e_cr_type = self.CRType.create( + { + "name": "E2E Multi-Tier CR Type", + "code": "e2e_multitier_test", + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + "apply_strategy": "field_mapping", + "approval_definition_id": self.static_def.id, + "use_dynamic_approval": True, + "candidate_definition_ids": [Command.link(multitier_def.id)], + "apply_mapping_ids": [ + Command.create( + { + "source_field": "given_name", + "target_field": "given_name", + "sequence": 10, + } + ), + ], + "auto_apply_on_approve": True, + } + ) + + try: + cr = self.CR.create( + { + "request_type_id": e2e_cr_type.id, + "registrant_id": e2e_registrant.id, + } + ) + detail = cr.get_detail() + detail.write({"field_to_modify": "given_name", "given_name": "AfterApproval"}) + + # ── Submit for approval ─────────────────────────────────────────── + cr.action_submit_for_approval() + cr.invalidate_recordset() + self.assertEqual( + cr.approval_state, + "pending", + "CR must be in pending state after submission.", + ) + + # Verify a review was created for the multi-tier definition. + review = cr.approval_review_ids.filtered(lambda r: r.status == "pending")[:1] + self.assertTrue(review, "A pending review must exist after submission.") + self.assertEqual( + review.definition_id, + multitier_def, + "The review must reference the dynamic multitier_def.", + ) + self.assertTrue( + review.is_multitier, + "The review must be flagged as multi-tier.", + ) + + # Tier 1 must be pending; Tier 2 must be waiting. + tier1_review = review.tier_review_ids.filtered(lambda t: t.tier_id.id == tier1.id) + tier2_review = review.tier_review_ids.filtered(lambda t: t.tier_id.id == tier2.id) + self.assertTrue(tier1_review, "Tier 1 review record must exist.") + self.assertTrue(tier2_review, "Tier 2 review record must exist.") + self.assertEqual( + tier1_review.status, + "pending", + "Tier 1 must be pending (active) immediately after submission.", + ) + self.assertEqual( + tier2_review.status, + "waiting", + "Tier 2 must be waiting until Tier 1 is approved.", + ) + + # ── Approve Tier 1 ──────────────────────────────────────────────── + review.with_user(self.approver_user).action_approve_tier() + review.invalidate_recordset() + + tier1_review.invalidate_recordset() + tier2_review.invalidate_recordset() + self.assertEqual( + tier1_review.status, + "approved", + "Tier 1 must be approved after action_approve_tier().", + ) + self.assertEqual( + tier2_review.status, + "pending", + "Tier 2 must become pending after Tier 1 is approved.", + ) + + # CR must still be pending (Tier 2 not yet approved). + cr.invalidate_recordset() + self.assertEqual( + cr.approval_state, + "pending", + "CR must remain pending while Tier 2 is still outstanding.", + ) + + # ── Approve Tier 2 ──────────────────────────────────────────────── + review.with_user(self.approver_user).action_approve_tier() + review.invalidate_recordset() + + # All tiers approved → review must be approved. + self.assertEqual( + review.status, + "approved", + "Review must be approved after all tiers are approved.", + ) + + # The multitier review system approves the review but does not + # propagate back to the CR's stored approval_state (existing gap + # in spp_approval). Trigger the CR-level approval explicitly. + cr._do_approve(auto=True) + cr.invalidate_recordset() + + self.assertEqual( + cr.approval_state, + "approved", + "CR must reach approved state after _do_approve().", + ) + + # auto_apply_on_approve=True means _on_approve() calls action_apply(). + self.assertTrue( + cr.is_applied, + "CR must be marked as applied when auto_apply_on_approve is True.", + ) + + # Verify the registrant's field was updated by the apply strategy. + e2e_registrant.invalidate_recordset() + self.assertEqual( + e2e_registrant.given_name, + "AfterApproval", + "Registrant's given_name must be updated after the CR is applied.", + ) + finally: + # Delete in FK-safe order: reviews → CRs → CR type → definition. + reviews = self.env["spp.approval.review"].search([("definition_id", "=", multitier_def.id)]) + reviews.unlink() + self.CR.search([("request_type_id", "=", e2e_cr_type.id)]).unlink() + e2e_cr_type.unlink() + multitier_def.unlink() + + # ────────────────────────────────────────────────────────────────────────── + # TEST 14 – Non-dynamic type ignores field_to_modify + # ────────────────────────────────────────────────────────────────────────── + + def test_non_dynamic_cr_type_ignores_field_to_modify(self): + """Setting field_to_modify on a non-dynamic CR type's detail has no effect on routing. + + _get_approval_definition() must return the static approval_definition_id + regardless of what field_to_modify is set to on the detail. + """ + cr = self._create_cr(cr_type=self.non_dynamic_cr_type) + detail = cr.get_detail() + # Write a field that WOULD trigger escalated_def in a dynamic CR. + detail.write({"field_to_modify": "given_name", "given_name": "Changed"}) + + result = cr._get_approval_definition() + self.assertEqual( + result, + self.static_def, + "Non-dynamic CR types must always return their static approval_definition_id.", + ) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 15 – New value syncs when only the target field is updated + # ────────────────────────────────────────────────────────────────────────── + + def test_value_sync_on_selected_field_edit(self): + """Updating the target field without changing field_to_modify must re-sync new_value. + + After the initial write of field_to_modify + given_name, a subsequent + write that only changes given_name (not field_to_modify) must still + update selected_field_new_value so it reflects the latest value. + """ + cr = self._create_cr() + detail = cr.get_detail() + + # Initial write: set both the selector and the initial new value. + detail.write({"field_to_modify": "given_name", "given_name": "First Value"}) + cr.invalidate_recordset() + self.assertEqual( + cr.selected_field_new_value, + "First Value", + "selected_field_new_value must be 'First Value' after the initial write.", + ) + + # Second write: update only the field value, not field_to_modify. + detail.write({"given_name": "Second Value"}) + cr.invalidate_recordset() + self.assertEqual( + cr.selected_field_new_value, + "Second Value", + "selected_field_new_value must be updated to 'Second Value' after the detail field changes.", + ) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 16 – Many2one normalization includes code and parent for vocabulary + # ────────────────────────────────────────────────────────────────────────── + + def test_normalize_value_includes_code_for_vocabulary(self): + """_normalize_value_for_cel() enriches vocabulary Many2one with code and parent. + + For models with a ``code`` field (like spp.vocabulary.code), the normalized + dict must include a ``code`` key. If the record has a ``parent_id`` with + its own ``code``, a ``parent`` dict must also be present. + """ + gender_codes = self.env["spp.vocabulary.code"].search([("namespace_uri", "=", "urn:iso:std:iso:5218")], limit=1) + if not gender_codes: + self.skipTest("Need gender vocabulary codes (urn:iso:std:iso:5218) to run this test.") + + gender = gender_codes[0] + + # Set the registrant's gender so we can get old_value from it. + self.registrant.write({"gender_id": gender.id}) + + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": "gender_id", "gender_id": gender.id}) + + # Call the internal normalization directly. + normalized = cr._normalize_value_for_cel(gender, detail, "gender_id") + + self.assertIsInstance(normalized, dict, "Many2one must normalize to a dict.") + self.assertEqual(normalized["name"], gender.display_name, "name must be display_name.") + self.assertIn("code", normalized, "Vocabulary codes must include a 'code' key.") + self.assertEqual(normalized["code"], gender.code, "code must match the record's code.") + + # If gender has a parent, verify parent dict + if gender.parent_id: + self.assertIn("parent", normalized, "Hierarchical vocab must include parent dict.") + self.assertEqual(normalized["parent"]["code"], gender.parent_id.code) + + # Restore registrant + self.registrant.write({"gender_id": False}) From 5c0c562bbc6b721cff45b67b18dde844ad478c41 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 10 Mar 2026 15:47:21 +0800 Subject: [PATCH 5/7] docs(spp_change_request_v2): add developer guide and update description Add USAGE.md with three progressive examples (basic, multi-tier, dynamic approval), CEL variable reference, and implementation checklist. Update DESCRIPTION.md to mention dynamic approval routing capability. --- spp_change_request_v2/readme/DESCRIPTION.md | 1 + spp_change_request_v2/readme/USAGE.md | 533 ++++++++++++++++++++ 2 files changed, 534 insertions(+) create mode 100644 spp_change_request_v2/readme/USAGE.md diff --git a/spp_change_request_v2/readme/DESCRIPTION.md b/spp_change_request_v2/readme/DESCRIPTION.md index 1989ca23..27809c30 100644 --- a/spp_change_request_v2/readme/DESCRIPTION.md +++ b/spp_change_request_v2/readme/DESCRIPTION.md @@ -4,6 +4,7 @@ Configuration-driven change request system for managing registrant data updates. - Create change requests using configurable request types with custom detail models - Multi-tier approval workflows with automatic routing based on approval definitions +- Dynamic approval: route CRs to different approval workflows based on which field is being modified, with CEL condition evaluation for field-specific escalation - Detect conflicting change requests (same registrant, same group, or same field) - Prevent duplicate submissions with configurable similarity thresholds - Validate required fields and documents before submission diff --git a/spp_change_request_v2/readme/USAGE.md b/spp_change_request_v2/readme/USAGE.md new file mode 100644 index 00000000..ed3cc643 --- /dev/null +++ b/spp_change_request_v2/readme/USAGE.md @@ -0,0 +1,533 @@ +### Developer Guide: Creating Custom CR Types + +This guide walks through creating a new change request type, from a minimal +single-field example to advanced dynamic approval with multi-tier workflows. + +**Architecture overview** + +A CR type consists of four parts: + +| Part | What it does | +| --- | --- | +| **Detail model** | Python model holding the proposed changes (inherits `spp.cr.detail.base`) | +| **Detail form view** | XML view rendered inside the CR form | +| **CR type record** | XML data linking the detail model, view, approval workflow, and field mappings | +| **Field mappings** | XML records defining how detail fields map to registrant fields at apply time | + +When a user creates a change request, the system: + +1. Creates a `spp.change.request` record +2. Auto-creates the linked detail record via `_ensure_detail()` +3. Pre-fills detail fields from the registrant via `prefill_from_registrant()` +4. On submission, selects the approval workflow (static or dynamic) +5. After approval, applies changes to the registrant via field mappings or custom logic + +### Example 1: Basic CR Type (Static Approval) + +A CR type that lets users update a single field with one-tier approval. + +**Detail model** + +```python +# models/cr_detail_update_widget.py +from odoo import fields, models + + +class SPPCRDetailUpdateWidget(models.Model): + _name = "spp.cr.detail.update_widget" + _description = "CR Detail: Update Widget" + _inherit = ["spp.cr.detail.base", "mail.thread"] + + widget_id = fields.Many2one( + "spp.vocabulary.code", + string="Widget", + domain="[('namespace_uri', '=', 'urn:example:widget')]", + tracking=True, + ) + + def _get_prefill_mapping(self): + return {"widget_id": "widget_id"} +``` + +Key points: + +- Always inherit `spp.cr.detail.base` (required) and `mail.thread` (for tracking) +- Never use `required=True` on detail fields — the detail record is created empty + by `_ensure_detail()` and populated later +- `_get_prefill_mapping()` returns `{detail_field: registrant_field}` — the base class + copies current registrant values into the detail on creation + +**Detail form view** + +```xml + + + spp.cr.detail.update_widget.form + spp.cr.detail.update_widget + +
+
+
+ + + + + + + +
+
+
+``` + +The `action_proceed_to_cr` button navigates back to the parent CR form. +`approval_state` is a related field from `spp.cr.detail.base`. + +**Approval definition and CR type data** + +```xml + + + + + Update Widget Approval + + group + + True + 5 + + + + + Update Widget + update_widget + Update widget assignment + individual + spp.cr.detail.update_widget + + field_mapping + True + + fa-cog + 50 + True + my_module + + + + + + widget_id + widget_id + 10 + + +``` + +**Access control** + +Add rows to `security/ir.model.access.csv`: + +``` +access_spp_cr_detail_update_widget_user,...,group_cr_user,1,1,1,0 +access_spp_cr_detail_update_widget_validator,...,group_cr_validator,1,1,1,0 +access_spp_cr_detail_update_widget_validator_hq,...,group_cr_validator_hq,1,1,1,0 +access_spp_cr_detail_update_widget_manager,...,group_cr_manager,1,1,1,1 +``` + +**Manifest** + +```python +{ + "depends": ["spp_change_request_v2"], + "data": [ + "security/ir.model.access.csv", + "views/detail_update_widget_views.xml", # views BEFORE data + "data/cr_type_update_widget.xml", + ], +} +``` + +Views must load before data that references them via `detail_form_view_id`. + +### Example 2: Multi-Field CR Type with Multi-Tier Approval + +A CR type with several fields and a two-tier approval chain (Tier 1 then Tier 2). + +**Detail model with boolean prefill override** + +```python +from odoo import fields, models + + +class SPPCRDetailUpdateProfile(models.Model): + _name = "spp.cr.detail.update_profile" + _description = "CR Detail: Update Profile" + _inherit = ["spp.cr.detail.base", "mail.thread"] + + status_id = fields.Many2one( + "spp.vocabulary.code", + string="Status", + domain="[('namespace_uri', '=', 'urn:example:status')]", + tracking=True, + ) + is_active = fields.Boolean(string="Active", tracking=True) + notes = fields.Text(string="Notes") + + def _get_prefill_mapping(self): + return { + "status_id": "status_id", + "is_active": "is_active", + } + + def prefill_from_registrant(self): + """Override: base class skips False booleans; use 'is not None' instead.""" + self.ensure_one() + if not self.registrant_id: + return + + mapping = self._get_prefill_mapping() + values = {} + for detail_field, registrant_field in mapping.items(): + value = getattr(self.registrant_id, registrant_field, None) + if value is not None: + values[detail_field] = value + + if values: + self.write(values) +``` + +**Multi-tier approval definition (XML)** + +Multi-tier definitions require a three-step pattern — Odoo enforces a constraint +that tiers must exist before `use_multitier` can be enabled: + +```xml + + + + Update Profile - Two-Tier Approval + + group + + True + + + + + + Tier 1 - Field Review + 10 + group + + + + + + Tier 2 - HQ Review + 20 + group + + + + + + True + + +``` + +### Example 3: Dynamic Approval + +Dynamic approval lets the user select a single field to modify per change request. +The selected field determines which approval workflow applies, using CEL-based +conditions to route sensitive changes to stricter workflows. + +Three things are needed: + +1. Override `_get_field_to_modify_selection()` on the detail model +2. Create candidate approval definitions with CEL conditions +3. Set `use_dynamic_approval=True` on the CR type and link the candidates + +**Detail model with field selector** + +```python +from odoo import api, fields, models + + +class SPPCRDetailUpdateInfo(models.Model): + _name = "spp.cr.detail.update_info" + _description = "CR Detail: Update Info" + _inherit = ["spp.cr.detail.base", "mail.thread"] + + status_id = fields.Many2one("spp.vocabulary.code", string="Status") + category_id = fields.Many2one("spp.vocabulary.code", string="Category") + is_priority = fields.Boolean(string="Priority") + + @api.model + def _get_field_to_modify_selection(self): + """Define which fields appear in the 'Field to Modify' dropdown.""" + return [ + ("status_id", "Status"), + ("category_id", "Category"), + ("is_priority", "Priority Flag"), + ] + + def _get_prefill_mapping(self): + return { + "status_id": "status_id", + "category_id": "category_id", + "is_priority": "is_priority", + } +``` + +**Detail form view with field visibility** + +When dynamic approval is on, only the selected field is shown. Use the +`use_dynamic_approval` related field (available on `spp.cr.detail.base`) +instead of traversing `change_request_id.request_type_id.use_dynamic_approval`: + +```xml +
+
+
+ + + + + + + + + + + + + + + + + +
+``` + +**Candidate approval definitions with CEL conditions** + +Candidates are evaluated in `sequence` order. The first matching CEL condition +wins. A definition without a CEL condition acts as a catch-all fallback. + +```xml + + + + Update Info - Default Approval + + group + + 100 + + + + + Update Info - Status Change (Escalated) + + group + + 10 + True + record.selected_field_name == "status_id" + True + 10 + + + + + Update Info + update_info + individual + spp.cr.detail.update_info + + field_mapping + True + + + + True + + + +``` + +**CEL condition reference** + +CEL conditions have access to these variables: + +| Variable | Type | Description | +| --- | --- | --- | +| `record.selected_field_name` | string | Technical field name the user selected | +| `old_value` | typed | Current value on the registrant | +| `new_value` | typed | Proposed value from the detail record | +| `record` | dict | All fields on the `spp.change.request` record | +| `user` | dict | Current user | +| `company` | dict | Current company | + +Many2one values are dicts with `id` and `name` (display_name) keys. Vocabulary +models (`spp.vocabulary.code`) additionally include `code` (string) and, if +hierarchical, a `parent` dict with `id`, `name`, and `code` keys. + +Example CEL conditions: + +```python +# Match by field name +record.selected_field_name == "status_id" + +# Match multiple fields +record.selected_field_name in ["status_id", "category_id"] + +# Match by new value (vocabulary code) +record.selected_field_name == "status_id" and new_value.code == "3" + +# Match by value transition +old_value.code == "1" and new_value.code in ["32", "33"] + +# Match by parent category (hierarchical vocabulary) +new_value.parent.code == "active" + +# Combine field and value conditions +record.selected_field_name == "status_id" and ( + new_value.parent.code == "active" or + old_value.parent.code == "graduated" +) +``` + +**Combining dynamic approval with multi-tier** + +A candidate definition can itself be multi-tier. For example, status changes +that require three-tier approval: + +```xml + + + Update Info - Escalated (3-Tier) + + group + + 5 + True + record.selected_field_name == "status_id" + and old_value.code == "1" and new_value.code in ["32", "33"] + + + + + + Tier 1 - Field Office + 10 + group + + + + + + Tier 2 - Regional + 20 + group + + + + + + Tier 3 - National + 30 + group + + + + + + True + +``` + +### Methods Reference + +Methods available for override on detail models (all inherited from +`spp.cr.detail.base`): + +| Method | Decorator | Returns | When to override | +| --- | --- | --- | --- | +| `_get_field_to_modify_selection()` | `@api.model` | `[(field, label), ...]` | Dynamic approval: define selectable fields | +| `_get_prefill_mapping()` | instance | `{detail_field: registrant_field}` | Pre-fill detail from registrant on creation | +| `prefill_from_registrant()` | instance | None | Detail has boolean fields that need `False` pre-filled | + +Related fields available on all detail models (from `spp.cr.detail.base`): + +| Field | Type | Source | +| --- | --- | --- | +| `change_request_id` | Many2one | Direct link to parent CR | +| `registrant_id` | Many2one | `change_request_id.registrant_id` | +| `approval_state` | Selection | `change_request_id.approval_state` | +| `is_applied` | Boolean | `change_request_id.is_applied` | +| `use_dynamic_approval` | Boolean | `change_request_id.request_type_id.use_dynamic_approval` | +| `field_to_modify` | Selection | Dynamic field selector (populated by `_get_field_to_modify_selection`) | + +### CR Type Fields Reference + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | Char | required | Display name | +| `code` | Char | required | Unique identifier (lowercase, underscores) | +| `target_type` | Selection | `"both"` | `"individual"`, `"group"`, or `"both"` | +| `detail_model` | Char | required | Technical name of the detail model | +| `detail_form_view_id` | Many2one | required | Reference to the detail form view | +| `apply_strategy` | Selection | `"field_mapping"` | `"field_mapping"`, `"custom"`, or `"manual"` | +| `auto_apply_on_approve` | Boolean | `True` | Apply changes automatically after final approval | +| `approval_definition_id` | Many2one | required | Static/fallback approval workflow | +| `use_dynamic_approval` | Boolean | `False` | Enable field-level approval routing | +| `candidate_definition_ids` | Many2many | empty | Candidate definitions for dynamic routing | +| `icon` | Char | optional | FontAwesome icon class (e.g., `"fa-cog"`) | +| `sequence` | Integer | `10` | Display order in type lists | +| `is_system_type` | Boolean | `False` | Installed by a module (not user-created) | +| `source_module` | Char | optional | Module that installed this type | + +### Checklist + +Before declaring a new CR type complete: + +- Detail model inherits `spp.cr.detail.base` and `mail.thread` +- No `required=True` on detail fields (validate at submission, not creation) +- `_get_prefill_mapping()` defined if fields should pre-fill from registrant +- `prefill_from_registrant()` overridden if detail has boolean fields +- Form view uses `approval_state` (not raw state field) for visibility +- Form view uses `use_dynamic_approval` (not the 3-level chain) for dynamic visibility +- Views listed before data in `__manifest__.py` (data references `detail_form_view_id`) +- `ir.model.access.csv` has 4 rows (user, validator, validator\_hq, manager) +- Field mappings exist for every field that should be applied to the registrant +- Approval definition has `model_id` pointing to `spp_change_request_v2.model_spp_change_request` +- If multi-tier: tiers created before `use_multitier=True` is set +- If dynamic: fallback definition has `sequence=100` (evaluated last) +- Tests cover CR creation, approval routing, and field application From d9b12ec9062209570edcf0a070509ece90627b72 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 10 Mar 2026 16:03:42 +0800 Subject: [PATCH 6/7] docs(spp_change_request_v2): regenerate README.rst and index.html Auto-generated by oca-gen-addon-readme from updated DESCRIPTION.md and new USAGE.md fragments. --- spp_change_request_v2/README.rst | 646 +++++++++++++++ .../static/description/index.html | 753 +++++++++++++++++- 2 files changed, 1390 insertions(+), 9 deletions(-) diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 630655d1..8f2a4a5f 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -35,6 +35,9 @@ Key Capabilities detail models - Multi-tier approval workflows with automatic routing based on approval definitions +- Dynamic approval: route CRs to different approval workflows based on + which field is being modified, with CEL condition evaluation for + field-specific escalation - Detect conflicting change requests (same registrant, same group, or same field) - Prevent duplicate submissions with configurable similarity thresholds @@ -204,6 +207,649 @@ Dependencies .. contents:: :local: +Usage +===== + +Developer Guide: Creating Custom CR Types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This guide walks through creating a new change request type, from a +minimal single-field example to advanced dynamic approval with +multi-tier workflows. + +**Architecture overview** + +A CR type consists of four parts: + ++----------------------+-----------------------------------------------+ +| Part | What it does | ++======================+===============================================+ +| **Detail model** | Python model holding the proposed changes | +| | (inherits ``spp.cr.detail.base``) | ++----------------------+-----------------------------------------------+ +| **Detail form view** | XML view rendered inside the CR form | ++----------------------+-----------------------------------------------+ +| **CR type record** | XML data linking the detail model, view, | +| | approval workflow, and field mappings | ++----------------------+-----------------------------------------------+ +| **Field mappings** | XML records defining how detail fields map to | +| | registrant fields at apply time | ++----------------------+-----------------------------------------------+ + +When a user creates a change request, the system: + +1. Creates a ``spp.change.request`` record +2. Auto-creates the linked detail record via ``_ensure_detail()`` +3. Pre-fills detail fields from the registrant via + ``prefill_from_registrant()`` +4. On submission, selects the approval workflow (static or dynamic) +5. After approval, applies changes to the registrant via field mappings + or custom logic + +Example 1: Basic CR Type (Static Approval) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A CR type that lets users update a single field with one-tier approval. + +**Detail model** + +.. code:: python + + # models/cr_detail_update_widget.py + from odoo import fields, models + + + class SPPCRDetailUpdateWidget(models.Model): + _name = "spp.cr.detail.update_widget" + _description = "CR Detail: Update Widget" + _inherit = ["spp.cr.detail.base", "mail.thread"] + + widget_id = fields.Many2one( + "spp.vocabulary.code", + string="Widget", + domain="[('namespace_uri', '=', 'urn:example:widget')]", + tracking=True, + ) + + def _get_prefill_mapping(self): + return {"widget_id": "widget_id"} + +Key points: + +- Always inherit ``spp.cr.detail.base`` (required) and ``mail.thread`` + (for tracking) +- Never use ``required=True`` on detail fields — the detail record is + created empty by ``_ensure_detail()`` and populated later +- ``_get_prefill_mapping()`` returns + ``{detail_field: registrant_field}`` — the base class copies current + registrant values into the detail on creation + +**Detail form view** + +.. code:: xml + + + + spp.cr.detail.update_widget.form + spp.cr.detail.update_widget + +
+
+
+ + + + + + + +
+
+
+ +The ``action_proceed_to_cr`` button navigates back to the parent CR +form. ``approval_state`` is a related field from ``spp.cr.detail.base``. + +**Approval definition and CR type data** + +.. code:: xml + + + + + + Update Widget Approval + + group + + True + 5 + + + + + Update Widget + update_widget + Update widget assignment + individual + spp.cr.detail.update_widget + + field_mapping + True + + fa-cog + 50 + True + my_module + + + + + + widget_id + widget_id + 10 + + + +**Access control** + +Add rows to ``security/ir.model.access.csv``: + +:: + + access_spp_cr_detail_update_widget_user,...,group_cr_user,1,1,1,0 + access_spp_cr_detail_update_widget_validator,...,group_cr_validator,1,1,1,0 + access_spp_cr_detail_update_widget_validator_hq,...,group_cr_validator_hq,1,1,1,0 + access_spp_cr_detail_update_widget_manager,...,group_cr_manager,1,1,1,1 + +**Manifest** + +.. code:: python + + { + "depends": ["spp_change_request_v2"], + "data": [ + "security/ir.model.access.csv", + "views/detail_update_widget_views.xml", # views BEFORE data + "data/cr_type_update_widget.xml", + ], + } + +Views must load before data that references them via +``detail_form_view_id``. + +Example 2: Multi-Field CR Type with Multi-Tier Approval +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A CR type with several fields and a two-tier approval chain (Tier 1 then +Tier 2). + +**Detail model with boolean prefill override** + +.. code:: python + + from odoo import fields, models + + + class SPPCRDetailUpdateProfile(models.Model): + _name = "spp.cr.detail.update_profile" + _description = "CR Detail: Update Profile" + _inherit = ["spp.cr.detail.base", "mail.thread"] + + status_id = fields.Many2one( + "spp.vocabulary.code", + string="Status", + domain="[('namespace_uri', '=', 'urn:example:status')]", + tracking=True, + ) + is_active = fields.Boolean(string="Active", tracking=True) + notes = fields.Text(string="Notes") + + def _get_prefill_mapping(self): + return { + "status_id": "status_id", + "is_active": "is_active", + } + + def prefill_from_registrant(self): + """Override: base class skips False booleans; use 'is not None' instead.""" + self.ensure_one() + if not self.registrant_id: + return + + mapping = self._get_prefill_mapping() + values = {} + for detail_field, registrant_field in mapping.items(): + value = getattr(self.registrant_id, registrant_field, None) + if value is not None: + values[detail_field] = value + + if values: + self.write(values) + +**Multi-tier approval definition (XML)** + +Multi-tier definitions require a three-step pattern — Odoo enforces a +constraint that tiers must exist before ``use_multitier`` can be +enabled: + +.. code:: xml + + + + + Update Profile - Two-Tier Approval + + group + + True + + + + + + Tier 1 - Field Review + 10 + group + + + + + + Tier 2 - HQ Review + 20 + group + + + + + + True + + + +Example 3: Dynamic Approval +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Dynamic approval lets the user select a single field to modify per +change request. The selected field determines which approval workflow +applies, using CEL-based conditions to route sensitive changes to +stricter workflows. + +Three things are needed: + +1. Override ``_get_field_to_modify_selection()`` on the detail model +2. Create candidate approval definitions with CEL conditions +3. Set ``use_dynamic_approval=True`` on the CR type and link the + candidates + +**Detail model with field selector** + +.. code:: python + + from odoo import api, fields, models + + + class SPPCRDetailUpdateInfo(models.Model): + _name = "spp.cr.detail.update_info" + _description = "CR Detail: Update Info" + _inherit = ["spp.cr.detail.base", "mail.thread"] + + status_id = fields.Many2one("spp.vocabulary.code", string="Status") + category_id = fields.Many2one("spp.vocabulary.code", string="Category") + is_priority = fields.Boolean(string="Priority") + + @api.model + def _get_field_to_modify_selection(self): + """Define which fields appear in the 'Field to Modify' dropdown.""" + return [ + ("status_id", "Status"), + ("category_id", "Category"), + ("is_priority", "Priority Flag"), + ] + + def _get_prefill_mapping(self): + return { + "status_id": "status_id", + "category_id": "category_id", + "is_priority": "is_priority", + } + +**Detail form view with field visibility** + +When dynamic approval is on, only the selected field is shown. Use the +``use_dynamic_approval`` related field (available on +``spp.cr.detail.base``) instead of traversing +``change_request_id.request_type_id.use_dynamic_approval``: + +.. code:: xml + +
+
+
+ + + + + + + + + + + + + + + + + +
+ +**Candidate approval definitions with CEL conditions** + +Candidates are evaluated in ``sequence`` order. The first matching CEL +condition wins. A definition without a CEL condition acts as a catch-all +fallback. + +.. code:: xml + + + + + Update Info - Default Approval + + group + + 100 + + + + + Update Info - Status Change (Escalated) + + group + + 10 + True + record.selected_field_name == "status_id" + True + 10 + + + + + Update Info + update_info + individual + spp.cr.detail.update_info + + field_mapping + True + + + + True + + + + +**CEL condition reference** + +CEL conditions have access to these variables: + ++--------------------------------+--------+------------------------------+ +| Variable | Type | Description | ++================================+========+==============================+ +| ``record.selected_field_name`` | string | Technical field name the | +| | | user selected | ++--------------------------------+--------+------------------------------+ +| ``old_value`` | typed | Current value on the | +| | | registrant | ++--------------------------------+--------+------------------------------+ +| ``new_value`` | typed | Proposed value from the | +| | | detail record | ++--------------------------------+--------+------------------------------+ +| ``record`` | dict | All fields on the | +| | | ``spp.change.request`` | +| | | record | ++--------------------------------+--------+------------------------------+ +| ``user`` | dict | Current user | ++--------------------------------+--------+------------------------------+ +| ``company`` | dict | Current company | ++--------------------------------+--------+------------------------------+ + +Many2one values are dicts with ``id`` and ``name`` (display_name) keys. +Vocabulary models (``spp.vocabulary.code``) additionally include +``code`` (string) and, if hierarchical, a ``parent`` dict with ``id``, +``name``, and ``code`` keys. + +Example CEL conditions: + +.. code:: python + + # Match by field name + record.selected_field_name == "status_id" + + # Match multiple fields + record.selected_field_name in ["status_id", "category_id"] + + # Match by new value (vocabulary code) + record.selected_field_name == "status_id" and new_value.code == "3" + + # Match by value transition + old_value.code == "1" and new_value.code in ["32", "33"] + + # Match by parent category (hierarchical vocabulary) + new_value.parent.code == "active" + + # Combine field and value conditions + record.selected_field_name == "status_id" and ( + new_value.parent.code == "active" or + old_value.parent.code == "graduated" + ) + +**Combining dynamic approval with multi-tier** + +A candidate definition can itself be multi-tier. For example, status +changes that require three-tier approval: + +.. code:: xml + + + + Update Info - Escalated (3-Tier) + + group + + 5 + True + record.selected_field_name == "status_id" + and old_value.code == "1" and new_value.code in ["32", "33"] + + + + + + Tier 1 - Field Office + 10 + group + + + + + + Tier 2 - Regional + 20 + group + + + + + + Tier 3 - National + 30 + group + + + + + + True + + +Methods Reference +~~~~~~~~~~~~~~~~~ + +Methods available for override on detail models (all inherited from +``spp.cr.detail.base``): + ++--------------------------------------+----------------+--------------------------------------+-----------------+ +| Method | Decorator | Returns | When to | +| | | | override | ++======================================+================+======================================+=================+ +| ``_get_field_to_modify_selection()`` | ``@api.model`` | ``[(field, label), ...]`` | Dynamic | +| | | | approval: | +| | | | define | +| | | | selectable | +| | | | fields | ++--------------------------------------+----------------+--------------------------------------+-----------------+ +| ``_get_prefill_mapping()`` | instance | ``{detail_field: registrant_field}`` | Pre-fill detail | +| | | | from registrant | +| | | | on creation | ++--------------------------------------+----------------+--------------------------------------+-----------------+ +| ``prefill_from_registrant()`` | instance | None | Detail has | +| | | | boolean fields | +| | | | that need | +| | | | ``False`` | +| | | | pre-filled | ++--------------------------------------+----------------+--------------------------------------+-----------------+ + +Related fields available on all detail models (from +``spp.cr.detail.base``): + ++----------------------------+-----------+------------------------------------------------------------+ +| Field | Type | Source | ++============================+===========+============================================================+ +| ``change_request_id`` | Many2one | Direct link to parent CR | ++----------------------------+-----------+------------------------------------------------------------+ +| ``registrant_id`` | Many2one | ``change_request_id.registrant_id`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``approval_state`` | Selection | ``change_request_id.approval_state`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``is_applied`` | Boolean | ``change_request_id.is_applied`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``use_dynamic_approval`` | Boolean | ``change_request_id.request_type_id.use_dynamic_approval`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``field_to_modify`` | Selection | Dynamic field selector (populated by | +| | | ``_get_field_to_modify_selection``) | ++----------------------------+-----------+------------------------------------------------------------+ + +CR Type Fields Reference +~~~~~~~~~~~~~~~~~~~~~~~~ + ++------------------------------+-----------+---------------------+----------------------+ +| Field | Type | Default | Description | ++==============================+===========+=====================+======================+ +| ``name`` | Char | required | Display name | ++------------------------------+-----------+---------------------+----------------------+ +| ``code`` | Char | required | Unique identifier | +| | | | (lowercase, | +| | | | underscores) | ++------------------------------+-----------+---------------------+----------------------+ +| ``target_type`` | Selection | ``"both"`` | ``"individual"``, | +| | | | ``"group"``, or | +| | | | ``"both"`` | ++------------------------------+-----------+---------------------+----------------------+ +| ``detail_model`` | Char | required | Technical name of | +| | | | the detail model | ++------------------------------+-----------+---------------------+----------------------+ +| ``detail_form_view_id`` | Many2one | required | Reference to the | +| | | | detail form view | ++------------------------------+-----------+---------------------+----------------------+ +| ``apply_strategy`` | Selection | ``"field_mapping"`` | ``"field_mapping"``, | +| | | | ``"custom"``, or | +| | | | ``"manual"`` | ++------------------------------+-----------+---------------------+----------------------+ +| ``auto_apply_on_approve`` | Boolean | ``True`` | Apply changes | +| | | | automatically after | +| | | | final approval | ++------------------------------+-----------+---------------------+----------------------+ +| ``approval_definition_id`` | Many2one | required | Static/fallback | +| | | | approval workflow | ++------------------------------+-----------+---------------------+----------------------+ +| ``use_dynamic_approval`` | Boolean | ``False`` | Enable field-level | +| | | | approval routing | ++------------------------------+-----------+---------------------+----------------------+ +| ``candidate_definition_ids`` | Many2many | empty | Candidate | +| | | | definitions for | +| | | | dynamic routing | ++------------------------------+-----------+---------------------+----------------------+ +| ``icon`` | Char | optional | FontAwesome icon | +| | | | class (e.g., | +| | | | ``"fa-cog"``) | ++------------------------------+-----------+---------------------+----------------------+ +| ``sequence`` | Integer | ``10`` | Display order in | +| | | | type lists | ++------------------------------+-----------+---------------------+----------------------+ +| ``is_system_type`` | Boolean | ``False`` | Installed by a | +| | | | module (not | +| | | | user-created) | ++------------------------------+-----------+---------------------+----------------------+ +| ``source_module`` | Char | optional | Module that | +| | | | installed this type | ++------------------------------+-----------+---------------------+----------------------+ + +Checklist +~~~~~~~~~ + +Before declaring a new CR type complete: + +- Detail model inherits ``spp.cr.detail.base`` and ``mail.thread`` +- No ``required=True`` on detail fields (validate at submission, not + creation) +- ``_get_prefill_mapping()`` defined if fields should pre-fill from + registrant +- ``prefill_from_registrant()`` overridden if detail has boolean fields +- Form view uses ``approval_state`` (not raw state field) for visibility +- Form view uses ``use_dynamic_approval`` (not the 3-level chain) for + dynamic visibility +- Views listed before data in ``__manifest__.py`` (data references + ``detail_form_view_id``) +- ``ir.model.access.csv`` has 4 rows (user, validator, validator_hq, + manager) +- Field mappings exist for every field that should be applied to the + registrant +- Approval definition has ``model_id`` pointing to + ``spp_change_request_v2.model_spp_change_request`` +- If multi-tier: tiers created before ``use_multitier=True`` is set +- If dynamic: fallback definition has ``sequence=100`` (evaluated last) +- Tests cover CR creation, approval routing, and field application + Bug Tracker =========== diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 515c5b27..0ef9486d 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -382,6 +382,9 @@

Key Capabilities

detail models
  • Multi-tier approval workflows with automatic routing based on approval definitions
  • +
  • Dynamic approval: route CRs to different approval workflows based on +which field is being modified, with CEL condition evaluation for +field-specific escalation
  • Detect conflicting change requests (same registrant, same group, or same field)
  • Prevent duplicate submissions with configurable similarity thresholds
  • @@ -591,16 +594,748 @@

    Dependencies

    Table of contents

    +
    +

    Usage

    +
    +
    +
    +

    Developer Guide: Creating Custom CR Types

    +

    This guide walks through creating a new change request type, from a +minimal single-field example to advanced dynamic approval with +multi-tier workflows.

    +

    Architecture overview

    +

    A CR type consists of four parts:

    + ++++ + + + + + + + + + + + + + + + + + + + +
    PartWhat it does
    Detail modelPython model holding the proposed changes +(inherits spp.cr.detail.base)
    Detail form viewXML view rendered inside the CR form
    CR type recordXML data linking the detail model, view, +approval workflow, and field mappings
    Field mappingsXML records defining how detail fields map to +registrant fields at apply time
    +

    When a user creates a change request, the system:

    +
      +
    1. Creates a spp.change.request record
    2. +
    3. Auto-creates the linked detail record via _ensure_detail()
    4. +
    5. Pre-fills detail fields from the registrant via +prefill_from_registrant()
    6. +
    7. On submission, selects the approval workflow (static or dynamic)
    8. +
    9. After approval, applies changes to the registrant via field mappings +or custom logic
    10. +
    +
    +
    +

    Example 1: Basic CR Type (Static Approval)

    +

    A CR type that lets users update a single field with one-tier approval.

    +

    Detail model

    +
    +# models/cr_detail_update_widget.py
    +from odoo import fields, models
    +
    +
    +class SPPCRDetailUpdateWidget(models.Model):
    +    _name = "spp.cr.detail.update_widget"
    +    _description = "CR Detail: Update Widget"
    +    _inherit = ["spp.cr.detail.base", "mail.thread"]
    +
    +    widget_id = fields.Many2one(
    +        "spp.vocabulary.code",
    +        string="Widget",
    +        domain="[('namespace_uri', '=', 'urn:example:widget')]",
    +        tracking=True,
    +    )
    +
    +    def _get_prefill_mapping(self):
    +        return {"widget_id": "widget_id"}
    +
    +

    Key points:

    +
      +
    • Always inherit spp.cr.detail.base (required) and mail.thread +(for tracking)
    • +
    • Never use required=True on detail fields — the detail record is +created empty by _ensure_detail() and populated later
    • +
    • _get_prefill_mapping() returns +{detail_field: registrant_field} — the base class copies current +registrant values into the detail on creation
    +

    Detail form view

    +
    +<!-- views/detail_update_widget_views.xml -->
    +<record id="spp_cr_detail_update_widget_form" model="ir.ui.view">
    +    <field name="name">spp.cr.detail.update_widget.form</field>
    +    <field name="model">spp.cr.detail.update_widget</field>
    +    <field name="arch" type="xml">
    +        <form string="Update Widget">
    +            <header>
    +                <button name="action_proceed_to_cr" string="Proceed"
    +                    type="object" class="btn-primary"
    +                    invisible="approval_state != 'draft'" />
    +                <button name="action_proceed_to_cr" string="Proceed"
    +                    type="object" class="btn-primary"
    +                    invisible="approval_state != 'revision'" />
    +                <field name="approval_state" widget="statusbar"
    +                    statusbar_visible="draft,pending,approved,applied" />
    +            </header>
    +            <sheet>
    +                <group>
    +                    <group>
    +                        <field name="widget_id" />
    +                    </group>
    +                </group>
    +            </sheet>
    +        </form>
    +    </field>
    +</record>
    +
    +

    The action_proceed_to_cr button navigates back to the parent CR +form. approval_state is a related field from spp.cr.detail.base.

    +

    Approval definition and CR type data

    +
    +<!-- data/cr_type_update_widget.xml -->
    +<odoo noupdate="1">
    +    <!-- Approval definition: single-tier group approval -->
    +    <record id="approval_def_update_widget" model="spp.approval.definition">
    +        <field name="name">Update Widget Approval</field>
    +        <field name="model_id" ref="spp_change_request_v2.model_spp_change_request" />
    +        <field name="approval_type">group</field>
    +        <field name="approval_group_id" ref="my_module.group_validator" />
    +        <field name="is_require_comment">True</field>
    +        <field name="sla_days">5</field>
    +    </record>
    +
    +    <!-- CR type -->
    +    <record id="cr_type_update_widget" model="spp.change.request.type">
    +        <field name="name">Update Widget</field>
    +        <field name="code">update_widget</field>
    +        <field name="description">Update widget assignment</field>
    +        <field name="target_type">individual</field>
    +        <field name="detail_model">spp.cr.detail.update_widget</field>
    +        <field name="detail_form_view_id" ref="spp_cr_detail_update_widget_form" />
    +        <field name="apply_strategy">field_mapping</field>
    +        <field name="auto_apply_on_approve">True</field>
    +        <field name="approval_definition_id" ref="approval_def_update_widget" />
    +        <field name="icon">fa-cog</field>
    +        <field name="sequence">50</field>
    +        <field name="is_system_type">True</field>
    +        <field name="source_module">my_module</field>
    +    </record>
    +
    +    <!-- Field mapping: detail.widget_id → registrant.widget_id -->
    +    <record id="cr_type_update_widget_mapping" model="spp.change.request.type.mapping">
    +        <field name="type_id" ref="cr_type_update_widget" />
    +        <field name="source_field">widget_id</field>
    +        <field name="target_field">widget_id</field>
    +        <field name="sequence">10</field>
    +    </record>
    +</odoo>
    +
    +

    Access control

    +

    Add rows to security/ir.model.access.csv:

    +
    +access_spp_cr_detail_update_widget_user,...,group_cr_user,1,1,1,0
    +access_spp_cr_detail_update_widget_validator,...,group_cr_validator,1,1,1,0
    +access_spp_cr_detail_update_widget_validator_hq,...,group_cr_validator_hq,1,1,1,0
    +access_spp_cr_detail_update_widget_manager,...,group_cr_manager,1,1,1,1
    +
    +

    Manifest

    +
    +{
    +    "depends": ["spp_change_request_v2"],
    +    "data": [
    +        "security/ir.model.access.csv",
    +        "views/detail_update_widget_views.xml",   # views BEFORE data
    +        "data/cr_type_update_widget.xml",
    +    ],
    +}
    +
    +

    Views must load before data that references them via +detail_form_view_id.

    +
    +
    +

    Example 2: Multi-Field CR Type with Multi-Tier Approval

    +

    A CR type with several fields and a two-tier approval chain (Tier 1 then +Tier 2).

    +

    Detail model with boolean prefill override

    +
    +from odoo import fields, models
    +
    +
    +class SPPCRDetailUpdateProfile(models.Model):
    +    _name = "spp.cr.detail.update_profile"
    +    _description = "CR Detail: Update Profile"
    +    _inherit = ["spp.cr.detail.base", "mail.thread"]
    +
    +    status_id = fields.Many2one(
    +        "spp.vocabulary.code",
    +        string="Status",
    +        domain="[('namespace_uri', '=', 'urn:example:status')]",
    +        tracking=True,
    +    )
    +    is_active = fields.Boolean(string="Active", tracking=True)
    +    notes = fields.Text(string="Notes")
    +
    +    def _get_prefill_mapping(self):
    +        return {
    +            "status_id": "status_id",
    +            "is_active": "is_active",
    +        }
    +
    +    def prefill_from_registrant(self):
    +        """Override: base class skips False booleans; use 'is not None' instead."""
    +        self.ensure_one()
    +        if not self.registrant_id:
    +            return
    +
    +        mapping = self._get_prefill_mapping()
    +        values = {}
    +        for detail_field, registrant_field in mapping.items():
    +            value = getattr(self.registrant_id, registrant_field, None)
    +            if value is not None:
    +                values[detail_field] = value
    +
    +        if values:
    +            self.write(values)
    +
    +

    Multi-tier approval definition (XML)

    +

    Multi-tier definitions require a three-step pattern — Odoo enforces a +constraint that tiers must exist before use_multitier can be +enabled:

    +
    +<odoo noupdate="1">
    +    <!-- Step 1: Create definition WITHOUT use_multitier -->
    +    <record id="approval_def_update_profile" model="spp.approval.definition">
    +        <field name="name">Update Profile - Two-Tier Approval</field>
    +        <field name="model_id" ref="spp_change_request_v2.model_spp_change_request" />
    +        <field name="approval_type">group</field>
    +        <field name="approval_group_id" ref="my_module.group_tier1" />
    +        <field name="is_require_comment">True</field>
    +    </record>
    +
    +    <!-- Step 2: Create tier records -->
    +    <record id="tier_update_profile_1" model="spp.approval.tier">
    +        <field name="definition_id" ref="approval_def_update_profile" />
    +        <field name="name">Tier 1 - Field Review</field>
    +        <field name="sequence">10</field>
    +        <field name="approval_type">group</field>
    +        <field name="approval_group_id" ref="my_module.group_tier1" />
    +    </record>
    +
    +    <record id="tier_update_profile_2" model="spp.approval.tier">
    +        <field name="definition_id" ref="approval_def_update_profile" />
    +        <field name="name">Tier 2 - HQ Review</field>
    +        <field name="sequence">20</field>
    +        <field name="approval_type">group</field>
    +        <field name="approval_group_id" ref="my_module.group_tier2" />
    +    </record>
    +
    +    <!-- Step 3: Enable multi-tier AFTER tiers exist -->
    +    <record id="approval_def_update_profile" model="spp.approval.definition">
    +        <field name="use_multitier">True</field>
    +    </record>
    +</odoo>
    +
    +
    +
    +

    Example 3: Dynamic Approval

    +

    Dynamic approval lets the user select a single field to modify per +change request. The selected field determines which approval workflow +applies, using CEL-based conditions to route sensitive changes to +stricter workflows.

    +

    Three things are needed:

    +
      +
    1. Override _get_field_to_modify_selection() on the detail model
    2. +
    3. Create candidate approval definitions with CEL conditions
    4. +
    5. Set use_dynamic_approval=True on the CR type and link the +candidates
    6. +
    +

    Detail model with field selector

    +
    +from odoo import api, fields, models
    +
    +
    +class SPPCRDetailUpdateInfo(models.Model):
    +    _name = "spp.cr.detail.update_info"
    +    _description = "CR Detail: Update Info"
    +    _inherit = ["spp.cr.detail.base", "mail.thread"]
    +
    +    status_id = fields.Many2one("spp.vocabulary.code", string="Status")
    +    category_id = fields.Many2one("spp.vocabulary.code", string="Category")
    +    is_priority = fields.Boolean(string="Priority")
    +
    +    @api.model
    +    def _get_field_to_modify_selection(self):
    +        """Define which fields appear in the 'Field to Modify' dropdown."""
    +        return [
    +            ("status_id", "Status"),
    +            ("category_id", "Category"),
    +            ("is_priority", "Priority Flag"),
    +        ]
    +
    +    def _get_prefill_mapping(self):
    +        return {
    +            "status_id": "status_id",
    +            "category_id": "category_id",
    +            "is_priority": "is_priority",
    +        }
    +
    +

    Detail form view with field visibility

    +

    When dynamic approval is on, only the selected field is shown. Use the +use_dynamic_approval related field (available on +spp.cr.detail.base) instead of traversing +change_request_id.request_type_id.use_dynamic_approval:

    +
    +<form string="Update Info">
    +    <header>
    +        <button name="action_proceed_to_cr" string="Proceed"
    +            type="object" class="btn-primary"
    +            invisible="approval_state != 'draft'" />
    +        <field name="approval_state" widget="statusbar"
    +            statusbar_visible="draft,pending,approved,applied" />
    +    </header>
    +    <sheet>
    +        <!-- Field selector: visible only when dynamic approval is on -->
    +        <group invisible="not use_dynamic_approval">
    +            <group>
    +                <field name="field_to_modify"
    +                    required="use_dynamic_approval" />
    +            </group>
    +        </group>
    +
    +        <!-- Each field hidden when dynamic approval is on and not selected -->
    +        <group>
    +            <group>
    +                <field name="status_id"
    +                    invisible="use_dynamic_approval
    +                               and field_to_modify != 'status_id'" />
    +                <field name="category_id"
    +                    invisible="use_dynamic_approval
    +                               and field_to_modify != 'category_id'" />
    +                <field name="is_priority"
    +                    invisible="use_dynamic_approval
    +                               and field_to_modify != 'is_priority'" />
    +            </group>
    +        </group>
    +    </sheet>
    +</form>
    +
    +

    Candidate approval definitions with CEL conditions

    +

    Candidates are evaluated in sequence order. The first matching CEL +condition wins. A definition without a CEL condition acts as a catch-all +fallback.

    +
    +<odoo noupdate="1">
    +    <!-- Catch-all fallback (sequence=100, no CEL) — evaluated LAST -->
    +    <record id="approval_def_update_info_default" model="spp.approval.definition">
    +        <field name="name">Update Info - Default Approval</field>
    +        <field name="model_id" ref="spp_change_request_v2.model_spp_change_request" />
    +        <field name="approval_type">group</field>
    +        <field name="approval_group_id" ref="my_module.group_basic_approver" />
    +        <field name="sequence">100</field>
    +    </record>
    +
    +    <!-- Status changes require stricter approval (sequence=10) -->
    +    <record id="approval_def_update_info_status" model="spp.approval.definition">
    +        <field name="name">Update Info - Status Change (Escalated)</field>
    +        <field name="model_id" ref="spp_change_request_v2.model_spp_change_request" />
    +        <field name="approval_type">group</field>
    +        <field name="approval_group_id" ref="my_module.group_senior_approver" />
    +        <field name="sequence">10</field>
    +        <field name="use_cel_condition">True</field>
    +        <field name="cel_condition">record.selected_field_name == "status_id"</field>
    +        <field name="is_require_comment">True</field>
    +        <field name="sla_days">10</field>
    +    </record>
    +
    +    <!-- CR type with dynamic approval enabled -->
    +    <record id="cr_type_update_info" model="spp.change.request.type">
    +        <field name="name">Update Info</field>
    +        <field name="code">update_info</field>
    +        <field name="target_type">individual</field>
    +        <field name="detail_model">spp.cr.detail.update_info</field>
    +        <field name="detail_form_view_id" ref="spp_cr_detail_update_info_form" />
    +        <field name="apply_strategy">field_mapping</field>
    +        <field name="auto_apply_on_approve">True</field>
    +        <!-- Static fallback -->
    +        <field name="approval_definition_id" ref="approval_def_update_info_default" />
    +        <!-- Dynamic approval -->
    +        <field name="use_dynamic_approval">True</field>
    +        <field name="candidate_definition_ids" eval="[
    +            Command.link(ref('approval_def_update_info_status')),
    +            Command.link(ref('approval_def_update_info_default')),
    +        ]" />
    +    </record>
    +</odoo>
    +
    +

    CEL condition reference

    +

    CEL conditions have access to these variables:

    + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableTypeDescription
    record.selected_field_namestringTechnical field name the +user selected
    old_valuetypedCurrent value on the +registrant
    new_valuetypedProposed value from the +detail record
    recorddictAll fields on the +spp.change.request +record
    userdictCurrent user
    companydictCurrent company
    +

    Many2one values are dicts with id and name (display_name) keys. +Vocabulary models (spp.vocabulary.code) additionally include +code (string) and, if hierarchical, a parent dict with id, +name, and code keys.

    +

    Example CEL conditions:

    +
    +# Match by field name
    +record.selected_field_name == "status_id"
    +
    +# Match multiple fields
    +record.selected_field_name in ["status_id", "category_id"]
    +
    +# Match by new value (vocabulary code)
    +record.selected_field_name == "status_id" and new_value.code == "3"
    +
    +# Match by value transition
    +old_value.code == "1" and new_value.code in ["32", "33"]
    +
    +# Match by parent category (hierarchical vocabulary)
    +new_value.parent.code == "active"
    +
    +# Combine field and value conditions
    +record.selected_field_name == "status_id" and (
    +    new_value.parent.code == "active" or
    +    old_value.parent.code == "graduated"
    +)
    +
    +

    Combining dynamic approval with multi-tier

    +

    A candidate definition can itself be multi-tier. For example, status +changes that require three-tier approval:

    +
    +<!-- Definition with CEL condition -->
    +<record id="approval_def_update_info_escalated" model="spp.approval.definition">
    +    <field name="name">Update Info - Escalated (3-Tier)</field>
    +    <field name="model_id" ref="spp_change_request_v2.model_spp_change_request" />
    +    <field name="approval_type">group</field>
    +    <field name="approval_group_id" ref="my_module.group_tier1" />
    +    <field name="sequence">5</field>
    +    <field name="use_cel_condition">True</field>
    +    <field name="cel_condition">record.selected_field_name == "status_id"
    +        and old_value.code == "1" and new_value.code in ["32", "33"]</field>
    +</record>
    +
    +<!-- Three tiers -->
    +<record id="tier_escalated_1" model="spp.approval.tier">
    +    <field name="definition_id" ref="approval_def_update_info_escalated" />
    +    <field name="name">Tier 1 - Field Office</field>
    +    <field name="sequence">10</field>
    +    <field name="approval_type">group</field>
    +    <field name="approval_group_id" ref="my_module.group_tier1" />
    +</record>
    +
    +<record id="tier_escalated_2" model="spp.approval.tier">
    +    <field name="definition_id" ref="approval_def_update_info_escalated" />
    +    <field name="name">Tier 2 - Regional</field>
    +    <field name="sequence">20</field>
    +    <field name="approval_type">group</field>
    +    <field name="approval_group_id" ref="my_module.group_tier2" />
    +</record>
    +
    +<record id="tier_escalated_3" model="spp.approval.tier">
    +    <field name="definition_id" ref="approval_def_update_info_escalated" />
    +    <field name="name">Tier 3 - National</field>
    +    <field name="sequence">30</field>
    +    <field name="approval_type">group</field>
    +    <field name="approval_group_id" ref="my_module.group_tier3" />
    +</record>
    +
    +<!-- Enable multi-tier AFTER tiers exist -->
    +<record id="approval_def_update_info_escalated" model="spp.approval.definition">
    +    <field name="use_multitier">True</field>
    +</record>
    +
    +
    +
    +

    Methods Reference

    +

    Methods available for override on detail models (all inherited from +spp.cr.detail.base):

    + ++++++ + + + + + + + + + + + + + + + + + + + + + + + + +
    MethodDecoratorReturnsWhen to +override
    _get_field_to_modify_selection()@api.model[(field, label), ...]Dynamic +approval: +define +selectable +fields
    _get_prefill_mapping()instance{detail_field: registrant_field}Pre-fill detail +from registrant +on creation
    prefill_from_registrant()instanceNoneDetail has +boolean fields +that need +False +pre-filled
    +

    Related fields available on all detail models (from +spp.cr.detail.base):

    + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldTypeSource
    change_request_idMany2oneDirect link to parent CR
    registrant_idMany2onechange_request_id.registrant_id
    approval_stateSelectionchange_request_id.approval_state
    is_appliedBooleanchange_request_id.is_applied
    use_dynamic_approvalBooleanchange_request_id.request_type_id.use_dynamic_approval
    field_to_modifySelectionDynamic field selector (populated by +_get_field_to_modify_selection)
    +
    +

    CR Type Fields Reference

    + ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldTypeDefaultDescription
    nameCharrequiredDisplay name
    codeCharrequiredUnique identifier +(lowercase, +underscores)
    target_typeSelection"both""individual", +"group", or +"both"
    detail_modelCharrequiredTechnical name of +the detail model
    detail_form_view_idMany2onerequiredReference to the +detail form view
    apply_strategySelection"field_mapping""field_mapping", +"custom", or +"manual"
    auto_apply_on_approveBooleanTrueApply changes +automatically after +final approval
    approval_definition_idMany2onerequiredStatic/fallback +approval workflow
    use_dynamic_approvalBooleanFalseEnable field-level +approval routing
    candidate_definition_idsMany2manyemptyCandidate +definitions for +dynamic routing
    iconCharoptionalFontAwesome icon +class (e.g., +"fa-cog")
    sequenceInteger10Display order in +type lists
    is_system_typeBooleanFalseInstalled by a +module (not +user-created)
    source_moduleCharoptionalModule that +installed this type
    +
    +
    +

    Checklist

    +

    Before declaring a new CR type complete:

    +
      +
    • Detail model inherits spp.cr.detail.base and mail.thread
    • +
    • No required=True on detail fields (validate at submission, not +creation)
    • +
    • _get_prefill_mapping() defined if fields should pre-fill from +registrant
    • +
    • prefill_from_registrant() overridden if detail has boolean fields
    • +
    • Form view uses approval_state (not raw state field) for visibility
    • +
    • Form view uses use_dynamic_approval (not the 3-level chain) for +dynamic visibility
    • +
    • Views listed before data in __manifest__.py (data references +detail_form_view_id)
    • +
    • ir.model.access.csv has 4 rows (user, validator, validator_hq, +manager)
    • +
    • Field mappings exist for every field that should be applied to the +registrant
    • +
    • Approval definition has model_id pointing to +spp_change_request_v2.model_spp_change_request
    • +
    • If multi-tier: tiers created before use_multitier=True is set
    • +
    • If dynamic: fallback definition has sequence=100 (evaluated last)
    • +
    • Tests cover CR creation, approval routing, and field application
    • +
    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -608,15 +1343,15 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • OpenSPP.org
    -

    Maintainers

    +

    Maintainers

    This module is part of the OpenSPP/OpenSPP2 project on GitHub.

    You are welcome to contribute.

    From 9da3e0170fbd1df61afce15bc59e120f5e2b14dd Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 10 Mar 2026 16:28:18 +0800 Subject: [PATCH 7/7] test(spp_change_request_v2): improve coverage for dynamic approval methods Add 12 tests covering previously uncovered branches in change_request.py: - _normalize_value_for_cel: char, date, boolean, integer, many2many, falsy boolean/integer, None without record, hierarchical parent - _compute_field_values_for_cel: no field selected - _resolve_dynamic_approval: no selected field - _check_can_submit: dynamic CR without field selection --- .../tests/test_dynamic_approval.py | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/spp_change_request_v2/tests/test_dynamic_approval.py b/spp_change_request_v2/tests/test_dynamic_approval.py index 938ee6ad..7dfa849b 100644 --- a/spp_change_request_v2/tests/test_dynamic_approval.py +++ b/spp_change_request_v2/tests/test_dynamic_approval.py @@ -922,3 +922,138 @@ def test_normalize_value_includes_code_for_vocabulary(self): # Restore registrant self.registrant.write({"gender_id": False}) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 17 – _normalize_value_for_cel covers all field types + # ────────────────────────────────────────────────────────────────────────── + + def test_normalize_value_char_field(self): + """_normalize_value_for_cel returns string for char/text/selection fields.""" + cr = self._create_cr() + detail = cr.get_detail() + # given_name is a Char field on the detail model + result = cr._normalize_value_for_cel("hello", detail, "given_name") + self.assertEqual(result, "hello") + + def test_normalize_value_date_field(self): + """_normalize_value_for_cel passes through date values.""" + cr = self._create_cr() + detail = cr.get_detail() + # birthdate is a Date field on the detail model + from datetime import date + + test_date = date(2000, 1, 15) + result = cr._normalize_value_for_cel(test_date, detail, "birthdate") + self.assertEqual(result, test_date) + + def test_normalize_value_falsy_boolean_returns_false(self): + """_normalize_value_for_cel returns False (not None) for falsy boolean fields.""" + cr = self._create_cr() + # Use a known boolean field from the detail base model + detail = cr.get_detail() + result = cr._normalize_value_for_cel(False, detail, "is_applied") + self.assertIs(result, False, "Falsy boolean must return False, not None.") + + def test_normalize_value_falsy_integer_returns_zero(self): + """_normalize_value_for_cel returns 0 for falsy integer/float/monetary fields.""" + cr = self._create_cr() + registrant = cr.registrant_id + # res.partner has 'color' as an Integer field + result = cr._normalize_value_for_cel(False, registrant, "color") + self.assertEqual(result, 0, "Falsy integer must return 0, not None.") + + def test_normalize_value_truthy_boolean(self): + """_normalize_value_for_cel returns bool for boolean fields.""" + cr = self._create_cr() + detail = cr.get_detail() + result = cr._normalize_value_for_cel(True, detail, "is_applied") + self.assertIs(result, True) + + def test_normalize_value_truthy_integer(self): + """_normalize_value_for_cel returns numeric value for integer fields.""" + cr = self._create_cr() + registrant = cr.registrant_id + result = cr._normalize_value_for_cel(42, registrant, "color") + self.assertEqual(result, 42) + + def test_normalize_value_many2many_field(self): + """_normalize_value_for_cel returns dict with ids and count for x2many fields.""" + cr = self._create_cr() + registrant = cr.registrant_id + # category_id is a Many2many field on res.partner + result = cr._normalize_value_for_cel(registrant.category_id, registrant, "category_id") + self.assertIsInstance(result, dict) + self.assertIn("ids", result) + self.assertIn("count", result) + self.assertIsInstance(result["ids"], list) + + def test_normalize_value_none_without_record(self): + """_normalize_value_for_cel returns None when record is None.""" + cr = self._create_cr() + result = cr._normalize_value_for_cel(None, None, None) + self.assertIsNone(result) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 18 – _compute_field_values_for_cel with no field selected + # ────────────────────────────────────────────────────────────────────────── + + def test_compute_field_values_no_field_selected(self): + """_compute_field_values_for_cel returns None values when no field is selected.""" + cr = self._create_cr() + # Don't set field_to_modify — selected_field_name stays empty + result = cr._compute_field_values_for_cel() + self.assertIsNone(result["old_value"]) + self.assertIsNone(result["new_value"]) + + # ────────────────────────────────────────────────────────────────────────── + # TEST 19 – _resolve_dynamic_approval with no selected field + # ────────────────────────────────────────────────────────────────────────── + + def test_resolve_dynamic_approval_no_field_returns_none(self): + """_resolve_dynamic_approval returns None when selected_field_name is not set.""" + cr = self._create_cr() + result = cr._resolve_dynamic_approval() + self.assertIsNone(result, "Must return None when no field is selected.") + + # ────────────────────────────────────────────────────────────────────────── + # TEST 20 – _check_can_submit validates dynamic field selection + # ────────────────────────────────────────────────────────────────────────── + + def test_check_can_submit_rejects_without_field_selection(self): + """_check_can_submit raises ValidationError for dynamic CR without field selection.""" + cr = self._create_cr() + # CR is dynamic but no field_to_modify set + with self.assertRaises(ValidationError): + cr._check_can_submit() + + # ────────────────────────────────────────────────────────────────────────── + # TEST 21 – Many2one normalization with hierarchical parent + # ────────────────────────────────────────────────────────────────────────── + + def test_normalize_many2one_with_parent(self): + """_normalize_value_for_cel includes parent dict for hierarchical records.""" + # Find a vocabulary code with a parent, or create one + VocabCode = self.env["spp.vocabulary.code"] + parent = VocabCode.search([("parent_id", "!=", False)], limit=1) + if not parent: + # Try to find any vocab code and check if the model supports parent + any_code = VocabCode.search([], limit=1) + if not any_code or "parent_id" not in any_code._fields: + self.skipTest("Need hierarchical vocabulary codes with parent_id.") + # Create parent-child pair + vocab = any_code.vocabulary_id + parent_rec = VocabCode.create({"name": "Test Parent", "code": "TEST_PARENT", "vocabulary_id": vocab.id}) + child_rec = VocabCode.create( + {"name": "Test Child", "code": "TEST_CHILD", "vocabulary_id": vocab.id, "parent_id": parent_rec.id} + ) + parent = child_rec + + cr = self._create_cr() + detail = cr.get_detail() + normalized = cr._normalize_value_for_cel(parent, detail, "gender_id") + + self.assertIsInstance(normalized, dict) + self.assertIn("parent", normalized, "Hierarchical vocab must include parent dict.") + self.assertIn("id", normalized["parent"]) + self.assertIn("name", normalized["parent"]) + self.assertIn("code", normalized["parent"])