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

+ +
+

19.0.3.1.12

-
+

19.0.3.1.11

-
+

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 @@ +