From 7a9a93fef2c507d9bd97636c94647abb3e23df07 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Fri, 14 Aug 2026 13:03:06 +0800 Subject: [PATCH 1/8] feat(spp_programs): let validators return duplicated memberships to draft Deduplication and eligibility checks put a membership into "duplicated" or "not eligible" and nothing took it out again. back_to_draft() existed, but only as a button on the membership form, so resolving a duplicate meant opening each record one at a time (OP#1170). Surfaces it where validators actually work: - a row button on the membership list, which is what a program's Duplicates smart button opens; - a "Back to Draft" server action bound to that list, so a whole selection can be cleared at once - the shape spp_programs already uses for "Reset to Draft" on entitlements, including filtering the selection and complaining only when nothing in it qualifies; - a row button on the Participation list of both individual and group registrants, where the inline list gets no action menu of its own. Validator-level in every place, matching the existing form button rather than the officer-level Pause and Exit buttons beside it. Clearing a duplicate flag from a list should not be easier than doing it on the record. back_to_draft() is also guarded now. It wrote "draft" over any state at all, which was fine while one button behind a state modifier was the only caller and is not fine with four. A mixed selection is refused whole rather than half-applied. --- spp_programs/models/program_membership.py | 32 +++++++-- spp_programs/tests/test_program_membership.py | 65 ++++++++++++++++++- .../views/program_membership_view.xml | 47 ++++++++++++++ spp_programs/views/registrant_view.xml | 30 +++++++++ 4 files changed, 164 insertions(+), 10 deletions(-) diff --git a/spp_programs/models/program_membership.py b/spp_programs/models/program_membership.py index d4c5d76f2..a9c2ebb6d 100644 --- a/spp_programs/models/program_membership.py +++ b/spp_programs/models/program_membership.py @@ -10,6 +10,11 @@ _logger = logging.getLogger(__name__) +# States a membership may be returned to draft from. Both are dead ends +# otherwise: deduplication and eligibility checks put a membership here and +# nothing ever took it out again (OP#1170). +RESETTABLE_TO_DRAFT = ("duplicated", "not_eligible") + class SPPProgramMembership(models.Model): _inherit = [ @@ -379,13 +384,26 @@ def deduplicate_beneficiaries(self): } def back_to_draft(self): - """Reset membership to draft state.""" - self.write( - { - "state": "draft", - } - ) - return + """Return duplicated or not-eligible memberships to draft. + + Deliberately recordset-safe: the "Back to Draft" server action hands + this a whole selection, and it is offered from lists as well as from + the membership form. + + Guarded because this is now reachable from four places rather than one. + It used to write ``draft`` over any state at all, so a stray call could + quietly undo an enrolment or reopen an exit (OP#1170). + """ + blocked = self.filtered(lambda membership: membership.state not in RESETTABLE_TO_DRAFT) + if blocked: + raise UserError( + _( + "Only duplicated or not-eligible memberships can be returned to draft. " + "%s of the selected memberships are in another state.", + len(blocked), + ) + ) + self.write({"state": "draft"}) def action_pause(self): """Pause the membership.""" diff --git a/spp_programs/tests/test_program_membership.py b/spp_programs/tests/test_program_membership.py index 421bf6f46..58198bf91 100644 --- a/spp_programs/tests/test_program_membership.py +++ b/spp_programs/tests/test_program_membership.py @@ -60,6 +60,21 @@ def _create_membership(self, partner=None, program=None, state="draft"): } ) + def _membership_for_a_new_registrant(self, state): + """A membership on its own registrant. + + spp.program.membership is unique per (partner, program), so tests that + need several memberships at once cannot share the class fixture. + """ + partner = self.env["res.partner"].create( + { + "name": f"Back To Draft {state} [TEST]", + "is_registrant": True, + "is_group": False, + } + ) + return self._create_membership(partner=partner, state=state) + # ------------------------------------------------------------------ # Creation # ------------------------------------------------------------------ @@ -200,13 +215,57 @@ def test_13_action_exit_from_invalid_state_raises(self): with self.assertRaisesRegex(UserError, "Only enrolled or paused memberships can be exited"): membership.action_exit() - def test_14_back_to_draft_resets_state(self): - """back_to_draft() resets any membership to 'draft'.""" - membership = self._create_membership(state="enrolled") + def test_14_back_to_draft_resets_a_duplicated_membership(self): + """The dead end this exists to undo (OP#1170).""" + membership = self._membership_for_a_new_registrant("duplicated") + membership.back_to_draft() + + self.assertEqual(membership.state, "draft") + + def test_14a_back_to_draft_resets_a_not_eligible_membership(self): + membership = self._membership_for_a_new_registrant("not_eligible") membership.back_to_draft() self.assertEqual(membership.state, "draft") + def test_14b_back_to_draft_refuses_other_states(self): + """It used to overwrite any state, which could undo an enrolment. + + Now reachable from a list row, a bulk action and the form, so a stray + call is much easier to make than when it lived on the form alone. + """ + for state in ("draft", "enrolled", "paused", "exited"): + with self.subTest(state=state): + membership = self._membership_for_a_new_registrant(state) + with self.assertRaises(UserError): + membership.back_to_draft() + self.assertEqual(membership.state, state, "the state should be untouched") + + def test_14c_back_to_draft_handles_a_recordset(self): + """The bulk action hands it a selection, not one record.""" + duplicated = self._membership_for_a_new_registrant("duplicated") + not_eligible = self._membership_for_a_new_registrant("not_eligible") + + (duplicated | not_eligible).back_to_draft() + + self.assertEqual(duplicated.state, "draft") + self.assertEqual(not_eligible.state, "draft") + + def test_14d_back_to_draft_rejects_a_mixed_selection_whole(self): + """A selection containing an ineligible record is refused outright. + + The server action filters before calling, so the UI never sends a mixed + selection; this is the guard behind it, and it must not half-apply. + """ + duplicated = self._membership_for_a_new_registrant("duplicated") + enrolled = self._membership_for_a_new_registrant("enrolled") + + with self.assertRaises(UserError): + (duplicated | enrolled).back_to_draft() + + self.assertEqual(duplicated.state, "duplicated", "nothing should have been written") + self.assertEqual(enrolled.state, "enrolled") + # ------------------------------------------------------------------ # Full lifecycle # ------------------------------------------------------------------ diff --git a/spp_programs/views/program_membership_view.xml b/spp_programs/views/program_membership_view.xml index f65a67afd..32c4f4ff9 100644 --- a/spp_programs/views/program_membership_view.xml +++ b/spp_programs/views/program_membership_view.xml @@ -16,6 +16,22 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details. icon="fa-external-link" class="btn-success" /> + + + + + + + +
+ +
+ + No duplicate detection configured. It is optional — click Add above to check for duplicate beneficiaries. +
+ - - + + +
+ + + + +
diff --git a/spp_programs/wizard/__init__.py b/spp_programs/wizard/__init__.py index 333f0b72a..252be2a3f 100644 --- a/spp_programs/wizard/__init__.py +++ b/spp_programs/wizard/__init__.py @@ -10,6 +10,7 @@ from . import reject_inkind_entitlement_wizard from . import reset_to_pending_wizard from . import create_program_wizard_compliance +from . import deduplication_setup_wizard from . import create_program_wizard_cel from . import cel_builder_wizard from . import enrollment_wizard diff --git a/spp_programs/wizard/deduplication_setup_wizard.py b/spp_programs/wizard/deduplication_setup_wizard.py new file mode 100644 index 000000000..17974f666 --- /dev/null +++ b/spp_programs/wizard/deduplication_setup_wizard.py @@ -0,0 +1,114 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Two-step creation for a deduplication manager (OP#1171). + +Adding one used to mean editing the wrapper's ``manager_ref_id`` inline: a +Reference field, which asks the user to pick a *model* and then find or create +a record of it. That is a developer's control — it exposes the wrapper/concrete +split, and choosing an existing record belonging to another program silently +mis-wires the manager. + +Compliance and Payment were converted to an "Add" button opening a dialog +(#952, #953). Deduplication is the same idea with one extra step, because it +has three methods where those have one: pick the method, then name it. +""" + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +# The concrete model behind each method, with the wording shown to the user. +# Kept here rather than read from MANAGER_TYPE_INFO so the selection stays a +# static list — Odoo needs the values at field-definition time. +DEDUPLICATION_METHODS = [ + ( + "spp.deduplication.manager.default", + "Shared members", + "Flags groups that have a member in common.", + ), + ( + "spp.deduplication.manager.id_dedup", + "ID document", + "Flags registrants sharing an ID document number.", + ), + ( + "spp.deduplication.manager.phone_number", + "Phone number", + "Flags registrants sharing a phone number.", + ), +] + + +class DeduplicationSetupWizard(models.TransientModel): + _name = "spp.deduplication.setup.wizard" + _description = "Add a Deduplication Method" + + program_id = fields.Many2one( + "spp.program", + required=True, + readonly=True, + ) + method = fields.Selection( + selection=[(model, label) for model, label, _description in DEDUPLICATION_METHODS], + string="Method", + required=True, + default=DEDUPLICATION_METHODS[0][0], + help="How duplicates are detected. Each method can be added once per program.", + ) + method_description = fields.Char(compute="_compute_method_description") + name = fields.Char( + string="Name", + required=True, + help="Shown on the program's configuration page.", + ) + + @api.depends("method") + def _compute_method_description(self): + descriptions = {model: description for model, _label, description in DEDUPLICATION_METHODS} + for wizard in self: + wizard.method_description = descriptions.get(wizard.method, "") + + @api.onchange("method") + def _onchange_method_suggests_a_name(self): + """Pre-fill the name from the method so the second step is one keystroke. + + Only while the user has not typed their own, and only replacing a + suggestion we made ourselves. + """ + labels = {model: label for model, label, _description in DEDUPLICATION_METHODS} + suggestions = set(labels.values()) + if not self.name or self.name in suggestions: + self.name = labels.get(self.method, "") + + def action_create_manager(self): + """Create the concrete manager; the wrapper follows automatically. + + ``spp.manager.source.mixin.create`` builds the + ``spp.deduplication.manager`` wrapper when it sees + ``_spp_wrapper_model`` in the context, so this creates one record and + gets both — and dismissing this dialog leaves nothing behind (#953). + + ``_spp_program_m2m_field`` matters as much as the wrapper model here: + ``spp.program.deduplication_manager_ids`` is a Many2many, so unlike a + One2many it does not resolve from the wrapper's ``program_id``. Without + it the manager is created and the program never picks it up — the card + keeps saying nothing is configured and deduplication never runs. + """ + self.ensure_one() + existing = self.env["spp.deduplication.manager"].search( + [("program_id", "=", self.program_id.id)], + ) + if any(wrapper.manager_ref_id and wrapper.manager_ref_id._name == self.method for wrapper in existing): + raise UserError( + _("This program already has a %s deduplication method.") % dict(self._fields["method"].selection)[self.method] + ) + + self.env[self.method].with_context( + default_program_id=self.program_id.id, + _spp_wrapper_model="spp.deduplication.manager", + _spp_program_m2m_field="deduplication_manager_ids", + ).create( + { + "name": self.name, + "program_id": self.program_id.id, + } + ) + return {"type": "ir.actions.act_window_close"} diff --git a/spp_programs/wizard/deduplication_setup_wizard.xml b/spp_programs/wizard/deduplication_setup_wizard.xml new file mode 100644 index 000000000..9015e42e3 --- /dev/null +++ b/spp_programs/wizard/deduplication_setup_wizard.xml @@ -0,0 +1,45 @@ + + + + + spp.deduplication.setup.wizard.form + spp.deduplication.setup.wizard + +
+ + + + +
+ + +
+ +
+
+
+ +
+
+
From a703fbe44cb2773e246295f4ebccda08d166162a Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 18 Aug 2026 12:14:31 +0800 Subject: [PATCH 5/8] fix(spp_programs): deny the link row on Duplicate Detection and sweep removed methods "Add a line" survived on the card. deduplication_manager_ids is a Many2many, and for those the list renderer reads `"link" in activeActions ? link : create`, so the list's create="0" and the create domain were both dead letters. The row that survived opened the link picker: every deduplication manager in the database, other programs' included, ready to be mis-wired into this one. The domain now covers 'link'. 'unlink' is left alone, so the row's x still removes a method. Removing a method that way drops the relation but keeps the wrapper, whose program_id still names the program. The duplicate check searched on that, so a method the card no longer showed still refused to be added back. It now asks the program's own field, and adding first sweeps wrappers that no program links. --- .../tests/test_deduplication_setup_wizard.py | 77 +++++++++++++++++-- .../views/program_config_cards_view.xml | 29 ++++--- .../wizard/deduplication_setup_wizard.py | 42 +++++++++- 3 files changed, 128 insertions(+), 20 deletions(-) diff --git a/spp_programs/tests/test_deduplication_setup_wizard.py b/spp_programs/tests/test_deduplication_setup_wizard.py index 786ac4d02..22f5e83fb 100644 --- a/spp_programs/tests/test_deduplication_setup_wizard.py +++ b/spp_programs/tests/test_deduplication_setup_wizard.py @@ -210,14 +210,81 @@ def test_the_card_lists_every_method_not_a_summary_line(self): def test_the_card_does_not_offer_add_a_line(self): """Adding goes through the Add button, which asks for the method. - An inline "Add a line" would create a bare wrapper with no method set — - the Reference-field trap this ticket removes. The list attribute alone - did not suppress it, so the field carries a create domain that never - matches (the same mechanism OP#1057 needed). + The row has to be denied through ``link``. The field is a Many2many, + and for those the list renderer reads + ``"link" in activeActions ? link : create`` — so the list's create="0" + and a create domain were both ignored, the row stayed, and it opened + the link picker: every deduplication manager in the database, other + programs' included (OP#1171 round 1). + + ``unlink`` is deliberately left out: removing a method stays available. """ arch = etree.fromstring(self.env.ref("spp_programs.view_program_form_config_cards").arch) field = arch.xpath("//field[@name='deduplication_manager_ids']")[0] + options = field.get("options") or "" - self.assertIn("'create'", field.get("options") or "", "create must be denied through options") + self.assertIn("'link'", options, "the link row is what the renderer shows for a Many2many") + self.assertIn("'create'", options, "create must be denied too") + self.assertNotIn("'unlink'", options, "removing a method must stay possible") self.assertEqual(field.xpath("./list")[0].get("create"), "0") + # ------------------------------------------------------------------ + # removing a method + # ------------------------------------------------------------------ + + def test_a_method_can_be_added_again_after_it_is_removed(self): + """The ✕ removes the relation, not the record. + + QA removed a method and could not add it back: the duplicate check + searched wrappers by ``program_id`` and found the one the card no + longer showed (OP#1171 round 1). + """ + self._wizard("spp.deduplication.manager.default", "Shared members").action_create_manager() + self.program.invalidate_recordset() + removed = self.program.deduplication_manager_ids + concrete = removed.manager_ref_id + self.program.write({"deduplication_manager_ids": [(3, removed.id)]}) + + self._wizard("spp.deduplication.manager.default", "Shared members").action_create_manager() + self.program.invalidate_recordset() + + self.assertEqual(len(self.program.deduplication_manager_ids), 1, "the method should be back") + self.assertFalse(removed.exists(), "the removed wrapper should not linger") + self.assertFalse(concrete.exists(), "nor the method behind it") + + def test_the_sweep_survives_a_reference_pointing_nowhere(self): + """manager_ref_id is a Reference: no foreign key, so it can dangle. + + Unlinking it blind would raise MissingError, and it would raise it on + the Add button — the one place this sweep runs. + """ + self._wizard("spp.deduplication.manager.default", "Shared members").action_create_manager() + self.program.invalidate_recordset() + dangling = self.program.deduplication_manager_ids + dangling.manager_ref_id.unlink() # takes the wrapper with it + dangling = self.env["spp.deduplication.manager"].create( + { + "program_id": self.program.id, + "manager_ref_id": "spp.deduplication.manager.default,999999999", + } + ) + + self._wizard("spp.deduplication.manager.default", "Shared members").action_create_manager() + self.program.invalidate_recordset() + + self.assertFalse(dangling.exists(), "the stale wrapper should be swept") + self.assertEqual(len(self.program.deduplication_manager_ids), 1) + + def test_the_sweep_spares_a_method_another_program_uses(self): + """The relation is a Many2many; a linked wrapper is not garbage.""" + self._wizard("spp.deduplication.manager.id_dedup", "By ID").action_create_manager() + self.program.invalidate_recordset() + shared = self.program.deduplication_manager_ids + other = self.env["spp.program"].create({"name": "Dedup Sweep Bystander [TEST]"}) + self.program.write({"deduplication_manager_ids": [(3, shared.id)]}) + other.write({"deduplication_manager_ids": [(4, shared.id)]}) + + self._wizard("spp.deduplication.manager.phone_number", "By phone").action_create_manager() + + self.assertTrue(shared.exists(), "another program still uses this method") + self.assertIn(shared, other.deduplication_manager_ids) diff --git a/spp_programs/views/program_config_cards_view.xml b/spp_programs/views/program_config_cards_view.xml index 1368bf845..da0b581e3 100644 --- a/spp_programs/views/program_config_cards_view.xml +++ b/spp_programs/views/program_config_cards_view.xml @@ -641,19 +641,26 @@ Replaces the technical manager configuration with intuitive sections. colspan="2" invisible="not deduplication_configured" readonly="state == 'ended' or not can_edit_configuration" - options="{'create': [('id', '<', 0)]}" + options="{'create': [('id', '<', 0)], 'link': [('id', '<', 0)]}" > diff --git a/spp_programs/wizard/deduplication_setup_wizard.py b/spp_programs/wizard/deduplication_setup_wizard.py index 17974f666..87805d6f1 100644 --- a/spp_programs/wizard/deduplication_setup_wizard.py +++ b/spp_programs/wizard/deduplication_setup_wizard.py @@ -78,6 +78,37 @@ def _onchange_method_suggests_a_name(self): if not self.name or self.name in suggestions: self.name = labels.get(self.method, "") + def _sweep_removed_methods(self): + """Delete the methods the card no longer shows. + + ``spp.program.deduplication_manager_ids`` is a Many2many, so the ✕ on a + row removes the *relation* and leaves the wrapper behind with its + ``program_id`` still pointing here. Those leftovers never run — the + program deduplicates through its own field, not through the wrappers' + ``program_id`` — but ``action_create_manager`` used to look for + duplicates with a search on ``program_id``, so a method the user had + removed still refused to be added back (OP#1171 round 1). + + Only wrappers that no program links are swept. The relation is a + Many2many: one this program created but another program links is that + program's method now, not something to delete. + """ + self.ensure_one() + removed = ( + self.env["spp.deduplication.manager"].search([("program_id", "=", self.program_id.id)]) + - self.program_id.deduplication_manager_ids + ) + if not removed: + return + linked = self.env["spp.program"].search([("deduplication_manager_ids", "in", removed.ids)]) + for leftover in removed - linked.deduplication_manager_ids: + # The concrete record owns the wrapper: spp.manager.source.mixin's + # unlink() takes the wrapper with it. manager_ref_id is a Reference, + # so it carries no foreign key and can outlive what it points at — + # unlinking that blind would raise MissingError on the Add button. + concrete = leftover.manager_ref_id + ((concrete and concrete.exists()) or leftover).unlink() + def action_create_manager(self): """Create the concrete manager; the wrapper follows automatically. @@ -93,12 +124,15 @@ def action_create_manager(self): keeps saying nothing is configured and deduplication never runs. """ self.ensure_one() - existing = self.env["spp.deduplication.manager"].search( - [("program_id", "=", self.program_id.id)], + self._sweep_removed_methods() + + configured = self.program_id.deduplication_manager_ids.filtered( + lambda wrapper: wrapper.manager_ref_id and wrapper.manager_ref_id._name == self.method ) - if any(wrapper.manager_ref_id and wrapper.manager_ref_id._name == self.method for wrapper in existing): + if configured: raise UserError( - _("This program already has a %s deduplication method.") % dict(self._fields["method"].selection)[self.method] + _("This program already has a %s deduplication method.") + % dict(self._fields["method"].selection)[self.method] ) self.env[self.method].with_context( From 71aa8208ce1870cce783df1fcefa4b99d2f0b3ac Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 18 Aug 2026 15:41:43 +0800 Subject: [PATCH 6/8] fix(spp_programs): ask which ID types a deduplication method compares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Add dialog asked for a method and a name, which is not enough for the ID document method. Its check is `id_type_id in supported_id_document_type_ids`, so a manager created with that list empty matches nothing and reports no duplicates at all — the card says Configured, Deduplicate runs, and nothing is ever flagged. QA asked for the list to appear on selecting ID document (OP#1171 finding 5). The dialog now shows the ID document types when that method is selected, with the same widget and options as the manager's own form, and requires at least one. action_create_manager refuses an empty list as well, because the view's required only binds the client and a programmatic caller would otherwise create the silent no-op. The manager's own form — reached from the card's cog — now requires the field too. Without that, the same empty manager could still be saved from there. Existing records keep what they have until someone edits them. Six tests in the wizard's suite created the ID method without types, which is the state now refused; they set them the way a user would, and one test deliberately omits them to assert the refusal. --- .../tests/test_deduplication_setup_wizard.py | 63 ++++++++++++++++++- .../managers/deduplication_manager_view.xml | 9 +++ .../wizard/deduplication_setup_wizard.py | 30 ++++++--- .../wizard/deduplication_setup_wizard.xml | 21 +++++++ 4 files changed, 114 insertions(+), 9 deletions(-) diff --git a/spp_programs/tests/test_deduplication_setup_wizard.py b/spp_programs/tests/test_deduplication_setup_wizard.py index 22f5e83fb..2539934ea 100644 --- a/spp_programs/tests/test_deduplication_setup_wizard.py +++ b/spp_programs/tests/test_deduplication_setup_wizard.py @@ -20,10 +20,22 @@ def setUpClass(cls): super().setUpClass() cls.program = cls.env["spp.program"].create({"name": "Dedup Setup Wizard [TEST]"}) - def _wizard(self, method, name): - return self.env["spp.deduplication.setup.wizard"].create( + def _wizard(self, method, name, with_id_types=True): + wizard = self.env["spp.deduplication.setup.wizard"].create( {"program_id": self.program.id, "method": method, "name": name} ) + if with_id_types and method == "spp.deduplication.manager.id_dedup": + # The dialog requires these for the ID method, so the helper fills + # them in the way a user would. An ID manager with none set compares + # nothing (OP#1171 round 2). + wizard.supported_id_document_type_ids = self._id_types() + return wizard + + def _id_types(self, limit=2): + return self.env["spp.vocabulary.code"].search( + [("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:id-type")], + limit=limit, + ) def _wrappers(self): return self.env["spp.deduplication.manager"].search([("program_id", "=", self.program.id)]) @@ -228,6 +240,53 @@ def test_the_card_does_not_offer_add_a_line(self): self.assertNotIn("'unlink'", options, "removing a method must stay possible") self.assertEqual(field.xpath("./list")[0].get("create"), "0") + # ------------------------------------------------------------------ + # the ID document method + # ------------------------------------------------------------------ + + def test_the_dialog_asks_which_id_types_to_compare(self): + """The field belongs to this method, so the dialog has to carry it. + + `deduplicate_beneficiaries` keeps only documents whose type is in + supported_id_document_type_ids, so a manager created without any finds + no duplicates at all and says nothing about why (OP#1171 round 2). + """ + arch = etree.fromstring(self.env.ref("spp_programs.view_deduplication_setup_wizard_form").arch) + field = arch.xpath("//field[@name='supported_id_document_type_ids']")[0] + + self.assertIn("id_dedup", field.get("invisible") or "", "only the ID method compares documents") + self.assertIn("id_dedup", field.get("required") or "", "an empty list would match nothing") + + def test_the_chosen_id_types_reach_the_manager(self): + id_types = self._id_types() + self.assertTrue(id_types, "spp_vocabulary seeds ID types; the dialog needs them") + wizard = self._wizard("spp.deduplication.manager.id_dedup", "By ID") + wizard.supported_id_document_type_ids = id_types + + wizard.action_create_manager() + + concrete = self._wrappers().manager_ref_id + self.assertEqual(concrete.supported_id_document_type_ids, id_types) + + def test_the_id_method_is_refused_without_a_type(self): + """Required in the view covers the dialog, not a programmatic caller.""" + with self.assertRaises(UserError): + self._wizard("spp.deduplication.manager.id_dedup", "By ID", with_id_types=False).action_create_manager() + + self.assertFalse(self._wrappers(), "nothing should be created") + + def test_the_other_methods_do_not_ask_for_id_types(self): + for method in ("spp.deduplication.manager.default", "spp.deduplication.manager.phone_number"): + with self.subTest(method=method): + program = self.env["spp.program"].create({"name": f"No ID types {method} [TEST]"}) + wizard = self.env["spp.deduplication.setup.wizard"].create( + {"program_id": program.id, "method": method, "name": "No types"} + ) + + wizard.action_create_manager() + + self.assertEqual(len(program.deduplication_manager_ids), 1) + # ------------------------------------------------------------------ # removing a method # ------------------------------------------------------------------ diff --git a/spp_programs/views/managers/deduplication_manager_view.xml b/spp_programs/views/managers/deduplication_manager_view.xml index d1220a776..3847590be 100644 --- a/spp_programs/views/managers/deduplication_manager_view.xml +++ b/spp_programs/views/managers/deduplication_manager_view.xml @@ -169,9 +169,18 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+ + +