Skip to content

feat: add spp_cr_type_assign_program module#187

Merged
gonzalesedwin1123 merged 12 commits into19.0from
feat/spp-cr-type-assign-program
May 5, 2026
Merged

feat: add spp_cr_type_assign_program module#187
gonzalesedwin1123 merged 12 commits into19.0from
feat/spp-cr-type-assign-program

Conversation

@gonzalesedwin1123
Copy link
Copy Markdown
Member

Why is this change needed?

OpenSPP needs a way to record program enrollment as an auditable change request — covered by the standard CR approval, document, and conflict workflow rather than the direct spp.assign.program.wizard shortcut. This module adds a single CR type, assign_program, that does exactly that.

The CR's registrant is the program beneficiary. There is no "select a member of the household" step:

  • Registrant is a group → eligible programs are target_type='group' (the household itself is enrolled)
  • Registrant is an individual → eligible programs are target_type='individual' (the individual is enrolled, whether or not they belong to a household)

Standalone individuals (registrants not in any household) are supported.

How was the change implemented?

New module spp_cr_type_assign_program (Layer 2 capability, depends on spp_change_request_v2 and spp_programs) bundling:

  • Detail model spp.cr.detail.assign_programprogram_id with a live, computed allowed_program_ids filtered by state='active' AND target_type matching the registrant; created_membership_id records the resulting membership for the audit trail.
  • Apply strategy spp.cr.apply.assign_program — explicit validate() / apply() / preview(). Validation rejects: missing program, disabled registrant, inactive program, target-type mismatch (defensive guard against direct ID writes that bypass the form domain), and existing memberships for the same (registrant, program) pair (intercepting the DB unique constraint with a friendlier message).
  • Form view with a prominent "Beneficiary" section and a callout making the semantics explicit so users from the household form do not mistake this for a member-picker flow.
  • CR type record loaded from data/cr_types.xml with target_type='both', apply_strategy='custom', non-Studio-editable, system-defined.
  • Conflict rule scoped custom + a _check_custom_conflicts override on spp.change.request that narrows the registrant-scoped match to candidates whose detail's program_id equals ours. Two CRs assigning the same registrant to the same program block; two CRs for the same registrant on different programs proceed independently.
  • ACLs for cr_user / cr_validator(_hq) / cr_manager matching the convention used by other detail models in spp_change_request_v2.

The new beneficiary semantics, decision history (group-targeted vs individual-targeted vs flexible), and design tradeoffs are written up in docs/plans/SPP_CR_TYPE_ASSIGN_PROGRAM_PLAN.md (lives in the local docs symlink, not in this repo).

History is split into 7 logical commits (skeleton → detail+ACL → strategy → view+type → conflicts → integration test → generated README) so each commit is independently reviewable and installable.

New unit tests

spp_cr_type_assign_program/tests/test_assign_program.py — 15 tests:

  • D1–D4 detail-model computes: registrant_target_type for group + individual, and allowed_program_ids filtering for each (active vs. inactive, group vs. individual programs).
  • A1, A2 apply happy paths for individual + group registrants creating draft spp.program.membership records.
  • A4 missing program raises UserError.
  • A5 disabled registrant rejected.
  • A6 inactive program rejected.
  • A7 target-type mismatch rejected (defensive validation).
  • A8 duplicate (registrant, program) rejected with friendly message rather than raw DB unique-constraint error.
  • A9 preview shape assertion.
  • F1 full CR lifecycle integration: create → fill → approve → action_apply() → membership exists with correct partner/program/state and is back-referenced on the detail.
  • F2 two CRs for the same (registrant, program) → second is conflict_status='blocked' with first listed in conflicting_cr_ids.
  • F3 two CRs for the same registrant on different programs → both conflict_status='none' (proves the custom hook narrows correctly).

Unit tests executed by the author

./scripts/test_single_module.sh spp_cr_type_assign_program
========== TEST RESULTS ==========
Passed: 15 | Failed: 0 | Errors: 0

Pre-commit (ruff, ruff-format, prettier, pylint_odoo, OpenSPP compliance hooks, bandit, semgrep) all pass on every changed file.

How to test manually

  1. ODOO_INIT_MODULES=spp_cr_type_assign_program docker compose --profile ui up -d
  2. Sign in (admin/admin), open Change RequestsAll RequestsNew.
  3. Pick "Assign to Program" from the type selector.
  4. Pick a household registrant. Confirm the Program dropdown only shows active group-targeted programs.
  5. Save, then change the registrant to an individual. Confirm the dropdown switches to active individual-targeted programs.
  6. Pick a program, walk through Documents → Review, submit, approve, apply.
  7. Verify the resulting spp.program.membership exists in Programs → the relevant program → Beneficiaries, with state Draft.
  8. Repeat steps 3–6 for the same (registrant, program) pair: the second CR should be blocked with a "Duplicate program assignment" conflict notice.
  9. Repeat steps 3–6 for the same registrant but a different program: both CRs should proceed without conflict.

Related links

  • Reference modules: spp_cr_types_base, spp_cr_types_advanced
  • Direct-enrollment counterpart: spp.assign.program.wizard in spp_programs/wizard/assign_to_program_wizard.py

Add a new module declaring its manifest, dependencies, and readme
fragments. Module installs cleanly but defines no behaviour yet —
detail model, apply strategy, view, CR-type record, and conflict rule
land in subsequent commits.
…ram domain

Define `spp.cr.detail.assign_program` with `program_id`, a computed
`allowed_program_ids` filtered to active programs whose `target_type`
matches the registrant, and `created_membership_id` for the apply-time
audit back-reference. ACL rows match the cr_user / cr_validator(_hq)
/ cr_manager pattern used by other detail models. Tests cover both
target-type branches and the active/inactive program filter.
…tion

Implement `spp.cr.apply.assign_program` with explicit `validate()`,
`apply()`, and `preview()`. Validation rejects: missing program,
disabled registrant, inactive program, target_type mismatch (defensive
guard against direct ID writes that bypass the form domain), and
existing memberships for the same `(registrant, program)` pair —
intercepting the DB unique constraint with a friendlier message.
On apply, creates a draft `spp.program.membership` and stores its ID
on the detail for the audit trail.
Add the detail form view (with a prominent Beneficiary section and a
callout explaining that the registrant itself is the program
beneficiary) and the `spp.change.request.type` data record that wires
the type into the standard Create-Change-Request wizard. The type is
non-editable via Studio, system-defined, and has conflict detection
enabled — the rule itself lands in the next commit.
…ssignments

Add a `scope='custom'` conflict rule on the new CR type and the
`_check_custom_conflicts` override on `spp.change.request` that
narrows the registrant-scoped match to candidates whose detail's
`program_id` equals ours. Two CRs assigning the same registrant to
the *same* program block; two CRs for the same registrant on
*different* programs proceed independently.
Approve and apply a draft CR end-to-end, asserting that
`cr.action_apply()` creates the program membership and back-links it
on the detail. Complements the unit-level apply tests by exercising
the strategy through the CR's own apply pipeline (preview snapshot,
sudo, audit logging) instead of calling `apply()` directly.
Run the oca-gen-addon-readme pre-commit hook to produce README.rst
and static/description/index.html from the readme/ fragments.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 4, 2026

Codecov Report

❌ Patch coverage is 91.17647% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.67%. Comparing base (7e8c009) to head (b7d9028).
⚠️ Report is 11 commits behind head on 19.0.

Files with missing lines Patch % Lines
...p_cr_type_assign_program/details/assign_program.py 86.20% 4 Missing ⚠️
...r_type_assign_program/strategies/assign_program.py 93.75% 3 Missing ⚠️
spp_cr_type_assign_program/__manifest__.py 0.00% 1 Missing ⚠️
...pp_cr_type_assign_program/models/change_request.py 94.44% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #187      +/-   ##
==========================================
+ Coverage   71.63%   71.67%   +0.04%     
==========================================
  Files         933      942       +9     
  Lines       55369    55478     +109     
==========================================
+ Hits        39664    39765     +101     
- Misses      15705    15713       +8     
Flag Coverage Δ
spp_base_common 90.26% <ø> (ø)
spp_cr_type_assign_program 91.17% <91.17%> (?)
spp_programs 64.54% <ø> (+0.05%) ⬆️
spp_security 66.66% <ø> (ø)

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

Files with missing lines Coverage Δ
spp_cr_type_assign_program/__init__.py 100.00% <100.00%> (ø)
spp_cr_type_assign_program/details/__init__.py 100.00% <100.00%> (ø)
spp_cr_type_assign_program/models/__init__.py 100.00% <100.00%> (ø)
spp_cr_type_assign_program/strategies/__init__.py 100.00% <100.00%> (ø)
spp_cr_type_assign_program/__manifest__.py 0.00% <0.00%> (ø)
...pp_cr_type_assign_program/models/change_request.py 94.44% <94.44%> (ø)
...r_type_assign_program/strategies/assign_program.py 93.75% <93.75%> (ø)
...p_cr_type_assign_program/details/assign_program.py 86.20% <86.20%> (ø)

... and 10 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.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the spp_cr_type_assign_program module, which adds a new change request type for enrolling registrants into programs. The implementation includes a detail model for program selection, an apply strategy to generate draft membership records, and custom conflict detection logic. Feedback focuses on optimizing performance by batching database queries in computed fields and conflict checks to avoid N+1 patterns. Additionally, a non-standard readonly attribute on a form view was identified for removal.

Comment thread spp_cr_type_assign_program/details/assign_program.py Outdated
Comment thread spp_cr_type_assign_program/models/change_request.py Outdated
Comment thread spp_cr_type_assign_program/views/detail_assign_program_views.xml
Two N+1 query patterns identified by review:

- `_compute_allowed_program_ids` ran one search per detail record. Cache
  results by `registrant_target_type` so a recordset of N details runs
  at most 2 queries (one per target type).
- `_check_custom_conflicts` called `get_detail()` per candidate, each
  triggering its own load. Replace with one `search` over the candidate
  detail IDs filtered by `program_id`. `check_same_type_only=True` on
  our rule guarantees candidates share our detail model; defensive
  guards still skip candidates whose detail model or detail_res_id is
  missing.
…UserError

`validate()` checks the (registrant, program) pair is unique before
`apply()` creates the membership, but a concurrent transaction can
insert the same pair between the two operations. The DB unique
constraint on `spp.program.membership(partner_id, program_id)` fires
and surfaces to the user as a raw `psycopg2.errors.UniqueViolation`
plus a poisoned transaction (subsequent ORM calls fail with
`InFailedSqlTransaction`).

Wrap the create in `cr.savepoint()` so the parent transaction stays
usable, mute the noisy SQL error log, and translate `UniqueViolation`
into the same friendly UserError the validate() path produces.

Add a test that mocks `spp.program.membership.create` to raise
`UniqueViolation`, asserting the strategy raises UserError with the
expected message.

Also add a test for the conflict-hook short-circuit: when
`_check_custom_conflicts` is called with a rule that isn't ours,
candidates must be returned unchanged so other modules' custom
conflict logic isn't suppressed.

Document the deliberate UI-staleness of `_compute_allowed_program_ids`
when a program transitions active <-> ended mid-form: the apply
strategy revalidates `state == 'active'`, so staleness is UI-only.
…in the form

Address UX-review findings on PR #187 around beneficiary disambiguation
and missing affordances:

1. Surface `registrant_target_type` as a visible labelled field
   ("Beneficiary type") next to `registrant_id` in the Beneficiary
   group. Single highest-leverage change for users coming from the
   household form: makes "Group" vs "Individual" enrolment explicit
   instead of inferred.

2. Rewrite the explanatory alert to:
   - name the registrant as the beneficiary (not just "the registrant
     above"),
   - tie the enrollment shape to the new visible Beneficiary type
     field,
   - direct users who actually want to enroll a household *member* to
     start the CR from the member's own record.

3. Add an `alert-warning` shown only when no active programs match
   the beneficiary's target type — replaces the silent empty-dropdown
   dead end with concrete guidance to ask a Program Manager.
   `aria-live="polite"` so screen readers announce the state when it
   appears.

4. Rename the post-apply group "Result" -> "Created Membership". The
   `created_membership_id` field is already openable (no
   `no_open: True` set), so the user can navigate from here to the
   membership record without an extra smart button.

5. Enrich the `program_id` help text to surface the filter rule
   (active + matching target type) and the post-apply state (Draft,
   Program Manager activates).

6. Mirror the in-form guidance in `readme/DESCRIPTION.md` so the
   module description and the in-form alert tell the same story.
… the dropdown

Move duplicate prevention from "discovered at apply, after a wasted
approval cycle" to "discovered at form-fill." Extend
`_compute_allowed_program_ids` with `registrant_id.program_membership_ids`
in its depends, then subtract the registrant's existing memberships from
the cached active-matching set. The per-target-type cache stays — it
saves the expensive program search; the per-registrant exclusion is a
cheap Python set subtraction.

Refine the empty-state warning copy to cover both reasons the dropdown
can be empty (no active programs match the beneficiary's type, or the
registrant is enrolled in all of them).

The existing validate() check and the savepoint-protected create remain
the safety net for races between form-fill and apply.

Add `test_d5_allowed_programs_excludes_already_enrolled` asserting the
exclusion.
…irect line

Remove the sentence advising users to "open that member's record and
start a change request from there." Verified there is no in-form path
to start a CR from a partner record:

- `spp_change_request_v2/models/res_partner.py` adds no smart button
- `spp_change_request_v2/views/` has no `res.partner` view inheritance
- `action_cr_create_wizard` has no `binding_model`, so it does not
  appear in the partner form's Action menu

The wizard's `default_get` would pre-fill `registrant_id` from
`active_model='res.partner'` context, but nothing actually opens it
that way. Telling users to "start a CR from there" is misleading.

Drop the line from the in-form info alert and from
`readme/DESCRIPTION.md`. The disambiguating copy on the
"registrant IS the beneficiary" semantic stays. Regenerate
README.rst and static/description/index.html to match.
@gonzalesedwin1123 gonzalesedwin1123 merged commit 30f6364 into 19.0 May 5, 2026
19 checks passed
@gonzalesedwin1123 gonzalesedwin1123 deleted the feat/spp-cr-type-assign-program branch May 5, 2026 08:20
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