diff --git a/spp_programs/README.rst b/spp_programs/README.rst index c05dac423..b79966144 100644 --- a/spp_programs/README.rst +++ b/spp_programs/README.rst @@ -254,6 +254,15 @@ Dependencies Changelog ========= +19.0.2.2.1 +~~~~~~~~~~ + +- fix(spp_programs): stop Enroll Eligible undoing a deliberate pause. A + paused membership is now left alone wherever eligibility is re-run — + the enrol pass, the disenrol sweep that would otherwise have moved it + to Not Eligible, and the per-membership methods reachable over RPC. + Pausing is a decision that only Resume reverses (#1117) + 19.0.2.1.3 ~~~~~~~~~~ diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py index 8dac1cba2..48590c716 100644 --- a/spp_programs/__manifest__.py +++ b/spp_programs/__manifest__.py @@ -4,7 +4,7 @@ "name": "OpenSPP Programs", "summary": "Manage programs, cycles, beneficiary enrollment, entitlements (cash and in-kind), payments, and fund tracking for social protection.", "category": "OpenSPP/Core", - "version": "19.0.2.2.0", + "version": "19.0.2.2.1", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_programs/models/constants.py b/spp_programs/models/constants.py index efb0e14d3..10c442b6f 100644 --- a/spp_programs/models/constants.py +++ b/spp_programs/models/constants.py @@ -7,6 +7,18 @@ STATE_ENDED = "ended" STATE_CANCELLED = "cancelled" +#: Membership states that only their own workflow may move a member out of. +#: Re-running eligibility — "Enroll Eligible" / "Verify Eligibility" — must step +#: over these rather than re-deciding them: +#: +#: - ``duplicated`` is resolved by deduplication +#: - ``exited`` is a closed record, reopened only by re-enrolling deliberately +#: - ``paused`` is a program officer's explicit decision, undone only by Resume +#: +#: ``paused`` was missing here, so Enroll Eligible silently resumed paused +#: members and, on the other branch, demoted them to not_eligible (OP#1117). +PROTECTED_MEMBERSHIP_STATES = ("duplicated", "exited", "paused") + MANAGER_ELIGIBILITY = 1 MANAGER_CYCLE = 2 MANAGER_PROGRAM = 3 diff --git a/spp_programs/models/managers/program_manager.py b/spp_programs/models/managers/program_manager.py index 7622e5f0b..21f71337c 100644 --- a/spp_programs/models/managers/program_manager.py +++ b/spp_programs/models/managers/program_manager.py @@ -7,6 +7,7 @@ from odoo.addons.job_worker.delay import group +from .. import constants from ..programs import SPPProgram from .pagination_utils import compute_id_ranges @@ -262,10 +263,13 @@ def _enroll_eligible_registrants(self, states, offset=0, limit=None, min_id=None for el in eligibility_managers: members = el.enroll_eligible_registrants(members) # enroll the one not already enrolled: - # Exclude members that are duplicated or exited — those states - # should only be changed through their own workflows. + # Exclude members in a state only its own workflow may leave — see + # PROTECTED_MEMBERSHIP_STATES. Notably `paused`: a program officer + # paused that member deliberately, and only Resume may undo it (OP#1117). _logger.debug("members filtered: %s", members) - not_enrolled = members.filtered(lambda m: m.state not in ("enrolled", "duplicated", "exited")) + not_enrolled = members.filtered( + lambda m: m.state != "enrolled" and m.state not in constants.PROTECTED_MEMBERSHIP_STATES + ) _logger.debug("not_enrolled: %s", not_enrolled) # Run pre-enrollment hooks (e.g., scoring eligibility checks). @@ -321,9 +325,15 @@ def _enroll_eligible_registrants(self, states, offset=0, limit=None, min_id=None for member in enrollable: program._post_enrollment_hook(member.partner_id) # dis-enroll the one not eligible anymore: + # Same protected states apply on the way down. A paused member the + # eligibility manager did not return was being swept into not_eligible, + # which destroys the pause just as thoroughly as re-enrolling it would + # (OP#1117) — that is a second, separate path to the same bug. enrolled_members_ids = members.ids members_to_remove = member_before.filtered( - lambda m: m.state not in ("not_eligible", "duplicated", "exited") and m.id not in enrolled_members_ids + lambda m: m.state != "not_eligible" + and m.state not in constants.PROTECTED_MEMBERSHIP_STATES + and m.id not in enrolled_members_ids ) # _logger.debug("members_to_remove: %s", members_to_remove) members_to_remove.write( diff --git a/spp_programs/models/program_membership.py b/spp_programs/models/program_membership.py index d4c5d76f2..0b3c5a532 100644 --- a/spp_programs/models/program_membership.py +++ b/spp_programs/models/program_membership.py @@ -280,7 +280,10 @@ def verify_eligibility(self): member = self for em in eligibility_managers: member = em.enroll_eligible_registrants(member) - if len(member) == 0: + if len(member) == 0 and self.state not in constants.PROTECTED_MEMBERSHIP_STATES: + # Leave duplicated / exited / paused alone: each is owned by its own + # workflow, and demoting a paused member to not_eligible would undo a + # deliberate pause just as surely as re-enrolling it (OP#1117). self.state = "not_eligible" return @@ -293,7 +296,9 @@ def enroll_eligible_registrants(self): member = em.enroll_eligible_registrants(member) if len(member) > 0: - if self.state in ("duplicated", "exited"): + if self.state in constants.PROTECTED_MEMBERSHIP_STATES: + # Includes paused: resuming is the Resume button's job, not + # something re-running eligibility may decide (OP#1117). message = _( "Cannot enroll: beneficiary is currently %s.", dict(self._fields["state"].selection).get(self.state, self.state), diff --git a/spp_programs/readme/HISTORY.md b/spp_programs/readme/HISTORY.md index 826b3233a..37046e6ba 100644 --- a/spp_programs/readme/HISTORY.md +++ b/spp_programs/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.2.1 + +- fix(spp_programs): stop Enroll Eligible undoing a deliberate pause. A paused membership is now left alone wherever eligibility is re-run — the enrol pass, the disenrol sweep that would otherwise have moved it to Not Eligible, and the per-membership methods reachable over RPC. Pausing is a decision that only Resume reverses (#1117) + ### 19.0.2.1.3 - fix(security): align Program Viewer / Validator / Cycle Approver roles with the OP#951 menu audit — Program Viewer additionally gets `group_registry_viewer` + `group_approval_viewer` (read-only Registry + Approvals access); all three program roles get `group_hazard_viewer` + `group_gis_report_user` so they retain Hazard / GIS Reports visibility once those menu roots are gated. Adds `spp_hazard` and `spp_gis_report` to module dependencies. diff --git a/spp_programs/static/description/index.html b/spp_programs/static/description/index.html index 50e35cbb3..71163ce80 100644 --- a/spp_programs/static/description/index.html +++ b/spp_programs/static/description/index.html @@ -658,6 +658,16 @@