Skip to content

fix(spp_programs): stop Enroll Eligible undoing a deliberate pause - #436

Merged
emjay0921 merged 6 commits into
19.0from
fix/1117-enroll-eligible-skips-paused
Aug 20, 2026
Merged

fix(spp_programs): stop Enroll Eligible undoing a deliberate pause#436
emjay0921 merged 6 commits into
19.0from
fix/1117-enroll-eligible-skips-paused

Conversation

@emjay0921

Copy link
Copy Markdown
Contributor

Why is this change needed?

Pausing a membership is a program officer's explicit decision, meant to be undone only by Resume. Enroll Eligible re-evaluated paused members along with everyone else and wrote them back to enrolled, reversing that decision with no warning (OP#1117). Low frequency, high trust impact: an officer who pauses a beneficiary expects it to stick.

The concept was already half-present in the code — a comment noting that duplicated and exited "should only be changed through their own workflows" — but paused was not in the set.

How was the change implemented?

  • The set is now explicit as constants.PROTECTED_MEMBERSHIP_STATES and applied everywhere re-running eligibility decides a membership's state.
  • Three paths, not one. The reported symptom is the enrol branch of _enroll_eligible_registrants, but its disenrol sweep had the same gap and would write a paused member the eligibility manager did not return to not_eligible — destroying the pause just as thoroughly. The per-membership enroll_eligible_registrants and verify_eligibility on spp.program.membership were the third: their buttons are hidden unless the record is draft, but the methods are public and reachable over RPC, and the point of the ticket is that a pause should be trustworthy.
  • The async branch, taken for programs above MIN_ROW_JOB_QUEUE, dispatches into the same _enroll_eligible_registrants, so both branches are covered. Program-level verify_eligibility passes ["enrolled", "not_eligible"] and never sees paused.
  • Nothing outside spp_programs overrides this logic, so SP-MIS and Farmer Registry pick the fix up from the shared code, as the ticket expects.

Deliberately untouched: deduplication still flags a paused member as duplicated. A duplicate is a duplicate regardless of pause, and that is a different action from the one reported here — though it does mean dedup protects exited and not_eligible while leaving paused open.

New unit tests

spp_programs/tests/test_program_enrollment.py — four assertions that a paused membership is still paused after Enroll Eligible (enrol path, disenrol sweep, and the two per-membership methods), plus one that Resume still has something to resume. Written first and confirmed failing against the unfixed code, then passing.

Unit tests executed by the author

Full spp_programs suite on this branch after merging 19.0 in: 684 tests, 0 failed, 0 errors.

How to test manually

  1. Open a registrant enrolled in a program, and on the Participation tab click Pause on the membership.
  2. Go to the program and click Enroll Eligible.
  3. The membership is still Paused — before, it went back to Enrolled.
  4. Click Resume on the membership: it returns to Enrolled, which is the only route that should do so.
  5. Make a paused member fail the eligibility rules, then run Enroll Eligible again — it stays Paused rather than being swept to not_eligible.

Related links

Pausing a membership is a program officer's explicit decision, undone only
by Resume. Enroll Eligible re-evaluated paused members along with everyone
else and wrote them back to enrolled, silently reversing that decision.

The concept was already in the code — a comment noting that duplicated and
exited "should only be changed through their own workflows" — but paused
was not in the set. Make it explicit as
constants.PROTECTED_MEMBERSHIP_STATES and apply it everywhere re-running
eligibility decides a membership's state.

Three paths, not one. The reported symptom is the enrol branch of
_enroll_eligible_registrants, but its disenrol sweep had the same gap and
wrote a paused member the eligibility manager did not return to
not_eligible — destroying the pause just as thoroughly. The per-membership
enroll_eligible_registrants and verify_eligibility on spp.program.membership
were a third: their buttons are hidden unless the record is draft, but the
methods are public and reachable over RPC, and the ticket's point is that a
pause should be trustworthy.

The async branch, taken for programs above MIN_ROW_JOB_QUEUE, dispatches
into the same _enroll_eligible_registrants, so both branches are covered.
Program-level verify_eligibility passes ["enrolled", "not_eligible"] and
never sees paused. Nothing outside spp_programs overrides this logic, so
SP-MIS and Farmer Registry pick the fix up from the shared code as the
ticket expects.

Tests were written first and confirmed failing against the unfixed code —
four assertions of 'enrolled' != 'paused' plus an error where Resume had
nothing paused left to resume — then passing after.

Deliberately untouched: deduplication still flags a paused member as
duplicated. A duplicate is a duplicate regardless of pause, and that is a
different action from the one reported here, though it does mean dedup
protects exited and not_eligible while leaving paused open.

OP#1117
@emjay0921
emjay0921 marked this pull request as ready for review August 18, 2026 07:56
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.15%. Comparing base (a16c175) to head (792d169).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #436      +/-   ##
==========================================
- Coverage   74.77%   73.15%   -1.63%     
==========================================
  Files         509      480      -29     
  Lines       35022    32181    -2841     
==========================================
- Hits        26189    23541    -2648     
+ Misses       8833     8640     -193     
Flag Coverage Δ
spp_api_v2_cycles 71.03% <ø> (ø)
spp_api_v2_entitlements 70.23% <ø> (ø)
spp_api_v2_programs 92.22% <ø> (ø)
spp_api_v2_simulation 71.19% <ø> (ø)
spp_base_common 91.07% <ø> (ø)
spp_case_entitlements 100.00% <ø> (?)
spp_case_programs 100.00% <ø> (?)
spp_cr_type_assign_program 92.07% <ø> (ø)
spp_dci_client_ibr ?
spp_dci_compliance 93.01% <ø> (ø)
spp_dci_demo 94.28% <ø> (ø)
spp_dci_indicators ?
spp_dci_server ?
spp_dci_server_social 89.57% <ø> (ø)
spp_farmer_registry_demo 63.43% <ø> (ø)
spp_gis_report_programs 100.00% <ø> (?)
spp_grm_demo 80.13% <ø> (?)
spp_grm_programs 92.13% <ø> (?)
spp_hazard ?
spp_hazard_programs 98.55% <ø> (?)
spp_programs 65.53% <100.00%> (+0.25%) ⬆️
spp_registry 87.22% <ø> (+0.07%) ⬆️
spp_security 69.56% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_programs/models/constants.py 100.00% <100.00%> (ø)
spp_programs/models/managers/program_manager.py 86.47% <100.00%> (+0.08%) ⬆️
spp_programs/models/program_membership.py 67.33% <100.00%> (+8.54%) ⬆️

... and 97 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gonzalesedwin1123 gonzalesedwin1123 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "three paths, not one" analysis is what makes this PR good, and it survived an independent sweep. What I verified:

  • The three-paths claim is complete. I went looking for a fourth writer and the candidates all clear: cycle.py's compliance filter writes state = "enrolled" on spp.cycle.membership — a different model, no effect on the program-membership pause; every external writer of "enrolled" is a demo-data seeder; and the API v2 programs endpoint doesn't write membership state at all. Nothing outside spp_programs overrides the fixed logic, as claimed.
  • Finding the disenrol-sweep second bug (paused → not_eligible) beyond the reported symptom is exactly the kind of review-resistant fix this deserves — as is treating the per-membership methods as live paths because they're RPC-reachable regardless of hidden buttons.
  • The constant carries its reasoning in a docstring, the async branch funnels into the same fixed method, and the tests cover all three paths plus the no-collateral case (a draft neighbour still enrolls while the paused member is skipped) and resume-still-works — written failing-first.

1. Blocker — version bump

Per the ruling (see #391): bump + HISTORY in the PR, number assigned at merge-time rebase. spp_programs sits at 19.0.2.2.0 and shipped in 2026.08. Pure-Python fix, so this is the convention ask, not a breakage one — with it added, this is an approve.

2. Non-blocking — the dedup asymmetry you flagged deserves a ticket

You noted it yourself: deduplication still flags a paused member as duplicated, while it protects exited and not_eligible. "A duplicate is a duplicate" is a defensible position — but now that PROTECTED_MEMBERSHIP_STATES codifies which states are workflow-owned, dedup honouring a different, overlapping set is a design inconsistency that should be a recorded decision rather than an accident. Filed as a follow-up on OpenProject so it gets decided once, deliberately.

The review's one blocker: 19.0.2.2.1 with its changelog entry. Pure Python, so
nothing breaks without an upgrade — but a same-version change gives upgraders no
signal.
Note the file had no 19.0.2.2.0 entry: the manifest was bumped to that version
without one, so the changelog's head was 19.0.2.1.3. This adds 19.0.2.2.1 only —
reconstructing what 2.2.0 contained is not something to guess at here.
@emjay0921
emjay0921 merged commit 99f07a2 into 19.0 Aug 20, 2026
35 checks passed
@emjay0921
emjay0921 deleted the fix/1117-enroll-eligible-skips-paused branch August 20, 2026 09:01
@emjay0921

Copy link
Copy Markdown
Contributor Author

Thanks — bump added and merged as 99f07a20.

1. Version bump — spp_programs 19.0.2.2.1 with its changelog entry.

One thing worth flagging while I was there: readme/HISTORY.md had no entry for 19.0.2.2.0 — its head was 19.0.2.1.3 while the manifest already read 19.0.2.2.0, so a bump landed at some point without a changelog line. I added only 19.0.2.2.1; reconstructing what 2.2.0 contained is not something to guess at from here. Might be worth a look if the release notes are generated from these files.

spp_programs suite after the merge: 684 tests, 0 failed.

2. The dedup asymmetry — thanks for filing it. Agreed on the framing: it was defensible as "a duplicate is a duplicate", but once PROTECTED_MEMBERSHIP_STATES exists as the recorded set of workflow-owned states, dedup honouring a different overlapping set is an inconsistency rather than a decision. Better decided once than rediscovered.

gonzalesedwin1123 added a commit that referenced this pull request Aug 20, 2026
Second drift back-merge (#392, #436, #438 landed on 19.0). Conflicts
confined to spp_programs metadata: 19.0's #436 took 19.0.2.2.1, which the
batch chain had assigned to #336 (with #353 at .2 carrying a migration).
Resolution renumbers the batch chain one notch up: manifest -> 19.0.2.2.3,
HISTORY ladder 2.2.3 = #353, 2.2.2 = #336, 2.2.1 = #436, and
migrations/19.0.2.2.2/ renamed to 19.0.2.2.3/ so the dir matches the
version claiming #353's change in the merged changelog (either name runs
for real upgrade paths; only test DBs ever saw it as 2.2.2).
README.rst/index.html regenerated via the pinned oca-gen hook.
program_manager.py auto-merged; batch 2 (lock helpers) and #436
(protected-state filtering) touch disjoint functions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants