diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst
index 99b63c96..754b51a3 100644
--- a/spp_change_request_v2/README.rst
+++ b/spp_change_request_v2/README.rst
@@ -853,6 +853,22 @@ Before declaring a new CR type complete:
Changelog
=========
+19.0.3.1.13
+~~~~~~~~~~~
+
+- fix(change_request): a selectable field on a dynamic-approval type may
+ now be applied through more than one mapping. Apply is narrowed to the
+ field a request was routed and approved on, matched against the
+ mapping's ``source_field`` — which assumed every selectable value is a
+ physical source field. They need not be: a name may be offered as one
+ choice but stored as separate components, so one selectable value
+ legitimately drives several mappings, and matching on ``source_field``
+ alone matched none of them, applying nothing at all. A mapping can now
+ declare the selectable value it serves via ``routing_field``,
+ defaulting to ``source_field``, so existing configurations are
+ unchanged. Narrowing still holds — a mapping belonging to another
+ routing key is still not applied.
+
19.0.3.1.12
~~~~~~~~~~~
diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py
index d3d91b88..50a68562 100644
--- a/spp_change_request_v2/__manifest__.py
+++ b/spp_change_request_v2/__manifest__.py
@@ -1,6 +1,6 @@
{
"name": "OpenSPP Change Request V2",
- "version": "19.0.3.1.12",
+ "version": "19.0.3.1.13",
"sequence": 50,
"category": "OpenSPP",
"summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention",
diff --git a/spp_change_request_v2/models/change_request_type_mapping.py b/spp_change_request_v2/models/change_request_type_mapping.py
index 53879ffb..307442f5 100644
--- a/spp_change_request_v2/models/change_request_type_mapping.py
+++ b/spp_change_request_v2/models/change_request_type_mapping.py
@@ -20,6 +20,15 @@ class SPPChangeRequestTypeMapping(models.Model):
required=True,
help="Field name on detail model",
)
+ routing_field = fields.Char(
+ help=(
+ "For dynamic-approval types, the selectable value this mapping serves. "
+ "Defaults to source_field. Set it when one selectable field is applied "
+ "through several mappings -- e.g. a name captured as one choice but "
+ "stored as separate components -- so apply still writes exactly what "
+ "was routed and approved."
+ ),
+ )
target_field = fields.Char(
required=True,
help="Field name on registrant (res.partner)",
diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md
index 5e160703..f90bf854 100644
--- a/spp_change_request_v2/readme/HISTORY.md
+++ b/spp_change_request_v2/readme/HISTORY.md
@@ -1,3 +1,7 @@
+### 19.0.3.1.13
+
+- fix(change_request): a selectable field on a dynamic-approval type may now be applied through more than one mapping. Apply is narrowed to the field a request was routed and approved on, matched against the mapping's `source_field` — which assumed every selectable value is a physical source field. They need not be: a name may be offered as one choice but stored as separate components, so one selectable value legitimately drives several mappings, and matching on `source_field` alone matched none of them, applying nothing at all. A mapping can now declare the selectable value it serves via `routing_field`, defaulting to `source_field`, so existing configurations are unchanged. Narrowing still holds — a mapping belonging to another routing key is still not applied.
+
### 19.0.3.1.12
- fix(change_request): auto-apply-on-approve runs through the public `action_apply` again. Requiring change-request manager rights to apply meant auto-apply was routed to the internal mechanism instead, so the approver could be a validator — but `action_apply` is the extension point modules override to hang post-apply work off an apply, and bypassing it left those overrides silently not running on approval: no error, just missing side effects. Auto-apply now calls `action_apply` under `sudo()`, which the manager gate already exempts. `sudo()` sets superuser mode without changing the user, so the applying user is still recorded as the approver.
diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html
index c8d57bc4..281deb2d 100644
--- a/spp_change_request_v2/static/description/index.html
+++ b/spp_change_request_v2/static/description/index.html
@@ -1339,6 +1339,23 @@
Changelog
+
19.0.3.1.13
+
+- fix(change_request): a selectable field on a dynamic-approval type may
+now be applied through more than one mapping. Apply is narrowed to the
+field a request was routed and approved on, matched against the
+mapping’s source_field — which assumed every selectable value is a
+physical source field. They need not be: a name may be offered as one
+choice but stored as separate components, so one selectable value
+legitimately drives several mappings, and matching on source_field
+alone matched none of them, applying nothing at all. A mapping can now
+declare the selectable value it serves via routing_field,
+defaulting to source_field, so existing configurations are
+unchanged. Narrowing still holds — a mapping belonging to another
+routing key is still not applied.
+
+
+
19.0.3.1.12
- fix(change_request): auto-apply-on-approve runs through the public
@@ -1353,7 +1370,7 @@
19.0.3.1.12
the applying user is still recorded as the approver.
-
+
19.0.3.1.11
- fix(change_request): field-mapping transform expressions are evaluated
@@ -1392,7 +1409,7 @@
19.0.3.1.11
the full traceback is logged only at DEBUG.
-
+
19.0.3.1.10
- fix(security): conflict and duplicate detection now decide whether a
@@ -1432,7 +1449,7 @@
19.0.3.1.10
configured mapping.
-
+
19.0.3.1.9
- fix(security): duplicate detection now scores the fields both change
@@ -1449,7 +1466,7 @@
19.0.3.1.9
requester-writable selected_field_name / field_to_modify.
-
+
19.0.3.1.8
- fix(security): scope the Create-Group member wizards to the parent
@@ -1467,7 +1484,7 @@
19.0.3.1.8
access-control entry grants.
-
+
19.0.3.1.7
- fix(security): require change-request manager rights to apply a change
@@ -1482,7 +1499,7 @@
19.0.3.1.7
endpoint.
-
+
19.0.3.1.6
- fix(security): derive conflict and duplicate detection from the change
@@ -1496,7 +1513,7 @@
19.0.3.1.6
an empty one, so detection cannot silently disable itself.
-
+
19.0.3.1.5
- fix(security): scope the CR Requestor, Local Validator and HQ
@@ -1508,7 +1525,7 @@
19.0.3.1.5
are noupdate.
-
+
19.0.3.1.4
- fix(security): add ownership and area record rules to every concrete
@@ -1525,7 +1542,7 @@
19.0.3.1.4
unrestricted delete their access-control entries grant.
-
+
19.0.3.1.3
- fix(security): route and apply the same single field for
@@ -1538,7 +1555,7 @@
19.0.3.1.3
the routing selector.
-
+
19.0.3.1.2
- fix(change_request_v2): adding an ID now looks for a live one of that
@@ -1547,7 +1564,7 @@
19.0.3.1.2
(#1136)
-
+
19.0.3.1.1
- fix(change_request): enforce the (cr_type_id, reason) uniqueness
@@ -1561,7 +1578,7 @@
19.0.3.1.1
applied) so the constraint applies cleanly on upgrade.
-
+
19.0.3.1.0
- revert(change_request): restore the create-a-new-individual Add
@@ -1579,7 +1596,7 @@
19.0.3.1.0
not restored here; reinstate separately if needed.
-
+
19.0.3.0.0
- feat(change_request): redesign the group/membership CR flows (#242) —
@@ -1601,7 +1618,7 @@
19.0.3.0.0
must adapt (see #1133).
-
+
19.0.2.0.8
- fix(views): disable inline creation of CR document types on the Change
@@ -1612,7 +1629,7 @@
19.0.2.0.8
Documents” modal (missing Name field) that blocked saving (#1125)
-
+
19.0.2.0.7
- fix(security): align CR Requestor / CR Local Validator / CR HQ
@@ -1624,7 +1641,7 @@
19.0.2.0.7
dependencies.
-
+
19.0.2.0.6
- fix(views): route post-submit CRs (pending / approved / applied /
@@ -1639,7 +1656,7 @@
19.0.2.0.6
list so row-click goes through the stage router.
-
+
19.0.2.0.5
- fix(security): add a global ir.rule on spp.change.request that
@@ -1652,27 +1669,27 @@
19.0.2.0.5
roles).
-
+
19.0.2.0.3
- fix: add HTML escaping to all computed Html fields with
sanitize=False to prevent stored XSS (#50)
-
+
19.0.2.0.2
- fix: fix batch approval wizard line deletion (#130)
-
+
19.0.2.0.1
- fix: skip field types before getattr and isolate detail prefetch
(#129)
-
+
19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_change_request_v2/strategies/field_mapping.py b/spp_change_request_v2/strategies/field_mapping.py
index 1e692f5d..81e3b8d5 100644
--- a/spp_change_request_v2/strategies/field_mapping.py
+++ b/spp_change_request_v2/strategies/field_mapping.py
@@ -25,6 +25,14 @@ def _effective_mappings(self, change_request):
fields that were also changed. This keeps the applied change in lockstep
with what was actually approved. Fail closed: if no field is selected, or
the selection maps to no configured field, nothing is applied.
+
+ A mapping matches on ``routing_field`` where set, else on
+ ``source_field``. The selectable values come from the detail model's
+ ``_get_field_to_modify_selection()`` and need not be physical source
+ fields: one selectable value may be applied through several mappings --
+ a name offered as a single choice but stored as separate components,
+ say. Matching on ``source_field`` alone could not express that, and
+ matched nothing, so such a request applied nothing at all.
"""
cr_type = change_request.request_type_id
mappings = cr_type.apply_mapping_ids
@@ -33,7 +41,7 @@ def _effective_mappings(self, change_request):
selected = change_request.selected_field_name
if not selected:
return mappings.browse()
- return mappings.filtered(lambda m: m.source_field == selected)
+ return mappings.filtered(lambda m: (m.routing_field or m.source_field) == selected)
def current_target_value(self, mapping, registrant):
"""The registrant's current value for ``mapping``, as apply compares it."""
diff --git a/spp_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py
index 38ec50cf..9d1cb083 100644
--- a/spp_change_request_v2/tests/__init__.py
+++ b/spp_change_request_v2/tests/__init__.py
@@ -36,3 +36,4 @@
from . import test_frozen_detail_binding
from . import test_detection_matches_apply
from . import test_field_mapping_transform
+from . import test_routing_field
diff --git a/spp_change_request_v2/tests/test_routing_field.py b/spp_change_request_v2/tests/test_routing_field.py
new file mode 100644
index 00000000..42b73381
--- /dev/null
+++ b/spp_change_request_v2/tests/test_routing_field.py
@@ -0,0 +1,152 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""A selectable routing key may be served by more than one mapping.
+
+For a dynamic-approval type, apply is narrowed to the field the request was
+routed and approved on. That narrowing matched ``source_field`` against
+``selected_field_name``, which assumed the selectable values returned by
+``_get_field_to_modify_selection()`` are always physical source fields.
+
+They need not be. A name may be offered as a single choice but stored as
+separate components, so one selectable value legitimately maps to several
+mappings. Matching on ``source_field`` alone matched nothing, and the request
+applied nothing at all -- silently, until applying with nothing to write began
+raising.
+
+``routing_field`` lets a mapping declare the selectable value it serves. It
+defaults to ``source_field``, so existing configurations are unchanged.
+"""
+
+from odoo.exceptions import UserError
+from odoo.tests import tagged
+
+from .common import CRTestCase
+
+
+@tagged("post_install", "-at_install")
+class TestRoutingField(CRTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.registrant = cls.Partner.create(
+ {
+ "name": "Routing Registrant",
+ "given_name": "Ann",
+ "family_name": "Original",
+ "is_registrant": True,
+ "is_group": False,
+ }
+ )
+
+ def _type(self, code, mappings):
+ return self.CRType.create(
+ {
+ "code": code,
+ "name": code,
+ "target_type": "individual",
+ "detail_model": "spp.cr.detail.edit_individual",
+ "apply_strategy": "field_mapping",
+ "use_dynamic_approval": True,
+ "apply_mapping_ids": [(0, 0, m) for m in mappings],
+ }
+ )
+
+ def _cr(self, cr_type, detail_vals, selected):
+ cr = self.CR.create({"request_type_id": cr_type.id, "registrant_id": self.registrant.id})
+ cr.get_detail().write(dict(detail_vals, field_to_modify=selected))
+ # The routing selector drives apply; set it directly for fields that are
+ # not in this detail model's own selection list.
+ cr.selected_field_name = selected
+ return cr
+
+ # ------------------------------------------------------------------
+ # One routing key, several mappings
+ # ------------------------------------------------------------------
+
+ def test_one_routing_key_can_drive_several_mappings(self):
+ cr_type = self._type(
+ "routing_multi",
+ [
+ {"source_field": "given_name", "target_field": "given_name", "routing_field": "full_name"},
+ {"source_field": "family_name", "target_field": "family_name", "routing_field": "full_name"},
+ ],
+ )
+ cr = self._cr(cr_type, {"given_name": "Beth", "family_name": "Changed"}, "full_name")
+
+ applied = sorted(m.source_field for m in self.env["spp.cr.strategy.field_mapping"]._effective_mappings(cr))
+ self.assertEqual(applied, ["family_name", "given_name"])
+
+ self.env["spp.cr.strategy.field_mapping"].apply(cr)
+ self.assertEqual(self.registrant.given_name, "Beth")
+ self.assertEqual(self.registrant.family_name, "Changed")
+
+ def test_a_different_routing_key_still_applies_nothing(self):
+ """Narrowing must still hold: only what was routed may be applied."""
+ cr_type = self._type(
+ "routing_scope",
+ [
+ {"source_field": "given_name", "target_field": "given_name", "routing_field": "full_name"},
+ {"source_field": "phone", "target_field": "phone"},
+ ],
+ )
+ cr = self._cr(cr_type, {"given_name": "Beth", "phone": "12345"}, "full_name")
+
+ applied = sorted(m.source_field for m in self.env["spp.cr.strategy.field_mapping"]._effective_mappings(cr))
+ self.assertEqual(applied, ["given_name"], "a mapping for another routing key must not be applied")
+
+ self.env["spp.cr.strategy.field_mapping"].apply(cr)
+ self.assertEqual(self.registrant.given_name, "Beth")
+ self.assertNotEqual(self.registrant.phone, "12345", "an unrouted mapping was applied")
+
+ # ------------------------------------------------------------------
+ # Existing configurations are unchanged
+ # ------------------------------------------------------------------
+
+ def test_source_field_is_the_default_routing_key(self):
+ cr_type = self._type(
+ "routing_default",
+ [{"source_field": "given_name", "target_field": "given_name"}],
+ )
+ cr = self._cr(cr_type, {"given_name": "Beth"}, "given_name")
+ applied = [m.source_field for m in self.env["spp.cr.strategy.field_mapping"]._effective_mappings(cr)]
+ self.assertEqual(applied, ["given_name"])
+
+ def test_routing_field_shadows_source_field_for_matching(self):
+ """With routing_field set, the source field no longer matches."""
+ cr_type = self._type(
+ "routing_shadow",
+ [{"source_field": "given_name", "target_field": "given_name", "routing_field": "full_name"}],
+ )
+ cr = self._cr(cr_type, {"given_name": "Beth"}, "given_name")
+ with self.assertRaisesRegex(UserError, "no longer has a mapping"):
+ cr.sudo().write({"approval_state": "approved"})
+ cr.sudo()._apply_change_request()
+
+ def test_routing_field_is_exposed_in_the_type_form_view(self):
+ """The mapping list must let an admin see and set the routing key.
+
+ routing_field shadows source_field for matching, so a value set only
+ through the API would make apply refuse with nothing in the form
+ explaining why. The inline mapping list therefore exposes it, hidden
+ by default to keep the common case uncluttered.
+ """
+ arch = self.env.ref("spp_change_request_v2.spp_change_request_type_form").arch
+ self.assertIn('name="routing_field"', arch)
+
+ def test_non_dynamic_types_are_unaffected(self):
+ cr_type = self.CRType.create(
+ {
+ "code": "routing_static",
+ "name": "routing_static",
+ "target_type": "individual",
+ "detail_model": "spp.cr.detail.edit_individual",
+ "apply_strategy": "field_mapping",
+ "use_dynamic_approval": False,
+ "apply_mapping_ids": [
+ (0, 0, {"source_field": "given_name", "target_field": "given_name", "routing_field": "x"}),
+ (0, 0, {"source_field": "family_name", "target_field": "family_name"}),
+ ],
+ }
+ )
+ cr = self.CR.create({"request_type_id": cr_type.id, "registrant_id": self.registrant.id})
+ applied = sorted(m.source_field for m in self.env["spp.cr.strategy.field_mapping"]._effective_mappings(cr))
+ self.assertEqual(applied, ["family_name", "given_name"], "routing keys must not narrow a non-dynamic type")
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 fae70499..c87d868d 100644
--- a/spp_change_request_v2/views/change_request_type_views.xml
+++ b/spp_change_request_v2/views/change_request_type_views.xml
@@ -161,6 +161,7 @@
+