diff --git a/spp_cr_type_assign_program/README.rst b/spp_cr_type_assign_program/README.rst new file mode 100644 index 00000000..6a49d539 --- /dev/null +++ b/spp_cr_type_assign_program/README.rst @@ -0,0 +1,143 @@ +=================================== +OpenSPP CR Type - Assign to Program +=================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2ba79797ea8d497c9a808e1ac44f05ef27897f6d01974e976247fb2ba108d5c6 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_cr_type_assign_program + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Adds a single change request type — ``assign_program`` — that records a +registrant being assigned to a program. The change request runs through +the standard approval, conflict-detection, and document workflow +provided by ``spp_change_request_v2``. On apply, an +``spp.program.membership`` record is created in the ``draft`` state for +the ``(registrant, program)`` pair. + +Beneficiary semantics +~~~~~~~~~~~~~~~~~~~~~ + +The CR's registrant **is** the program beneficiary. There is no "select +a member of the household" step. + +- Registrant is a group (household) → eligible programs are those with + ``target_type='group'`` and ``state='active'``. The household itself + is enrolled. +- Registrant is an individual → eligible programs are those with + ``target_type='individual'`` and ``state='active'``. The individual is + enrolled. + +Standalone individuals (registrants not in any household) are supported. + +Models defined by this module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++----------------------------------+---------------+--------------------------+ +| Model | Kind | Purpose | ++==================================+===============+==========================+ +| ``spp.cr.detail.assign_program`` | Model | Captures the program | +| | | selection for the CR | ++----------------------------------+---------------+--------------------------+ +| ``spp.cr.apply.assign_program`` | AbstractModel | Apply strategy that | +| | | creates the membership | ++----------------------------------+---------------+--------------------------+ + +Validation rules (apply-time) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The apply strategy refuses the operation when any of the following hold: + +- the registrant is ``disabled`` +- the program is not in ``state='active'`` +- the program's ``target_type`` does not match the registrant +- a membership for the same ``(registrant, program)`` pair already + exists +- the detail record has no ``program_id`` set + +Conflict detection +~~~~~~~~~~~~~~~~~~ + +Two in-flight ``assign_program`` change requests targeting the same +``(registrant, program)`` pair are treated as conflicting and the second +submission is blocked. Two CRs for the same registrant but different +programs are independent and both proceed. + +Dependencies +~~~~~~~~~~~~ + +- ``spp_change_request_v2`` +- ``spp_programs`` + +**Table of contents** + +.. contents:: + :local: + +Changelog +========= + +19.0.1.0.0 (2026-05-04) +----------------------- + +Added +~~~~~ + +- New module ``spp_cr_type_assign_program`` with the ``assign_program`` + change request type. +- Detail model ``spp.cr.detail.assign_program`` with live program-domain + filtering based on the registrant's target type. +- Apply strategy ``spp.cr.apply.assign_program`` that creates a draft + ``spp.program.membership`` record on apply. +- Conflict rule that blocks duplicate in-flight assignments to the same + ``(registrant, program)`` pair. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_cr_type_assign_program/__init__.py b/spp_cr_type_assign_program/__init__.py new file mode 100644 index 00000000..1d5fc995 --- /dev/null +++ b/spp_cr_type_assign_program/__init__.py @@ -0,0 +1,3 @@ +from . import details +from . import models +from . import strategies diff --git a/spp_cr_type_assign_program/__manifest__.py b/spp_cr_type_assign_program/__manifest__.py new file mode 100644 index 00000000..30bc2493 --- /dev/null +++ b/spp_cr_type_assign_program/__manifest__.py @@ -0,0 +1,24 @@ +{ + "name": "OpenSPP CR Type - Assign to Program", + "version": "19.0.1.0.0", + "sequence": 53, + "category": "OpenSPP", + "summary": "Change request type for assigning a registrant to a program", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Beta", + "depends": [ + "spp_change_request_v2", + "spp_programs", + ], + "data": [ + "security/ir.model.access.csv", + "views/detail_assign_program_views.xml", + "data/cr_types.xml", + ], + "installable": True, + "application": False, + "auto_install": False, + "maintainers": ["jeremi", "gonzalesedwin1123"], +} diff --git a/spp_cr_type_assign_program/data/cr_types.xml b/spp_cr_type_assign_program/data/cr_types.xml new file mode 100644 index 00000000..8a12dbae --- /dev/null +++ b/spp_cr_type_assign_program/data/cr_types.xml @@ -0,0 +1,39 @@ + + + + Assign to Program + assign_program + Assign a registrant (individual or household) to a program. The registrant becomes the program beneficiary. + both + + spp.cr.detail.assign_program + + custom + spp.cr.apply.assign_program + fa-hand-holding-heart + 120 + + + + spp_cr_type_assign_program + + This type requires custom Python logic to create program memberships. Cannot be edited via Studio. + + + + Duplicate program assignment + + custom + + block + all_active + 10 + Another change request is already assigning this registrant to the same program. Cancel or wait for it to be applied first. + + diff --git a/spp_cr_type_assign_program/details/__init__.py b/spp_cr_type_assign_program/details/__init__.py new file mode 100644 index 00000000..d678cbfd --- /dev/null +++ b/spp_cr_type_assign_program/details/__init__.py @@ -0,0 +1 @@ +from . import assign_program diff --git a/spp_cr_type_assign_program/details/assign_program.py b/spp_cr_type_assign_program/details/assign_program.py new file mode 100644 index 00000000..da30dade --- /dev/null +++ b/spp_cr_type_assign_program/details/assign_program.py @@ -0,0 +1,75 @@ +from odoo import api, fields, models + + +class SPPCRDetailAssignProgram(models.Model): + """Detail model for the assign-to-program CR type.""" + + _name = "spp.cr.detail.assign_program" + _description = "CR Detail: Assign to Program" + _inherit = ["spp.cr.detail.base", "mail.thread"] + + program_id = fields.Many2one( + "spp.program", + string="Program", + tracking=True, + domain="[('id', 'in', allowed_program_ids)]", + help=( + "Active programs whose target type matches this beneficiary. " + "On apply, a Draft membership is created — a Program Manager " + "activates it from there." + ), + ) + allowed_program_ids = fields.Many2many( + "spp.program", + string="Allowed Programs", + compute="_compute_allowed_program_ids", + ) + registrant_target_type = fields.Selection( + [("group", "Group"), ("individual", "Individual")], + compute="_compute_registrant_target_type", + store=True, + ) + created_membership_id = fields.Many2one( + "spp.program.membership", + string="Created Membership", + readonly=True, + ) + + @api.depends("registrant_id", "registrant_id.is_group") + def _compute_registrant_target_type(self): + for rec in self: + if not rec.registrant_id: + rec.registrant_target_type = False + continue + rec.registrant_target_type = "group" if rec.registrant_id.is_group else "individual" + + @api.depends( + "registrant_target_type", + "registrant_id.program_membership_ids.program_id", + ) + def _compute_allowed_program_ids(self): + Program = self.env["spp.program"] + # target_type only has two distinct values; cache the per-type + # active-program search so a recordset of N details runs at most + # 2 queries against spp.program. The per-registrant exclusion of + # already-enrolled programs is then applied in Python via set + # subtraction. + # Note: the result can become stale if a program transitions + # active <-> ended while a CR form is open. Acceptable: the apply + # strategy revalidates `state == 'active'` at apply time, so + # staleness is a UI-only concern. + cache = {} + for rec in self: + tt = rec.registrant_target_type + if not tt: + rec.allowed_program_ids = False + continue + if tt not in cache: + cache[tt] = Program.search( + [ + ("state", "=", "active"), + ("target_type", "=", tt), + ] + ) + already_in = rec.registrant_id.program_membership_ids.program_id + rec.allowed_program_ids = cache[tt] - already_in diff --git a/spp_cr_type_assign_program/models/__init__.py b/spp_cr_type_assign_program/models/__init__.py new file mode 100644 index 00000000..126f51df --- /dev/null +++ b/spp_cr_type_assign_program/models/__init__.py @@ -0,0 +1 @@ +from . import change_request diff --git a/spp_cr_type_assign_program/models/change_request.py b/spp_cr_type_assign_program/models/change_request.py new file mode 100644 index 00000000..21d52ddd --- /dev/null +++ b/spp_cr_type_assign_program/models/change_request.py @@ -0,0 +1,47 @@ +from odoo import models + + +class SPPChangeRequest(models.Model): + """Custom conflict-detection hook for the assign-program CR type. + + The base conflict rule scoped to the same registrant flags every in-flight + `assign_program` CR for that registrant. We narrow the match to those + targeting the same `(registrant, program)` pair so two CRs assigning the + same registrant to *different* programs are allowed to proceed. + """ + + _inherit = "spp.change.request" + + def _check_custom_conflicts(self, candidates, rule): + candidates = super()._check_custom_conflicts(candidates, rule) + + rule_xmlid = "spp_cr_type_assign_program.cr_conflict_rule_assign_program_duplicate" + our_rule = self.env.ref(rule_xmlid, raise_if_not_found=False) + if not our_rule or rule != our_rule: + return candidates + + my_detail = self.get_detail() + if not my_detail or not my_detail.program_id: + return self.env["spp.change.request"] + + # `check_same_type_only=True` on our rule guarantees all candidates + # share our detail model, but defend against edge cases where a + # candidate has no detail yet. + detail_model = my_detail._name + candidate_detail_ids = [ + c.detail_res_id for c in candidates if c.detail_res_model == detail_model and c.detail_res_id + ] + if not candidate_detail_ids: + return self.env["spp.change.request"] + + matching_detail_ids = set( + self.env[detail_model] + .search( + [ + ("id", "in", candidate_detail_ids), + ("program_id", "=", my_detail.program_id.id), + ] + ) + .ids + ) + return candidates.filtered(lambda c: c.detail_res_id in matching_detail_ids) diff --git a/spp_cr_type_assign_program/pyproject.toml b/spp_cr_type_assign_program/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_cr_type_assign_program/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_cr_type_assign_program/readme/DESCRIPTION.md b/spp_cr_type_assign_program/readme/DESCRIPTION.md new file mode 100644 index 00000000..ee64a8ec --- /dev/null +++ b/spp_cr_type_assign_program/readme/DESCRIPTION.md @@ -0,0 +1,46 @@ +Adds a single change request type — `assign_program` — that records a registrant +being assigned to a program. The change request runs through the standard +approval, conflict-detection, and document workflow provided by +`spp_change_request_v2`. On apply, an `spp.program.membership` record is +created in the `draft` state for the `(registrant, program)` pair. + +### Beneficiary semantics + +The CR's registrant **is** the program beneficiary. There is no "select a member +of the household" step. + +- Registrant is a group (household) → eligible programs are those with + `target_type='group'` and `state='active'`. The household itself is enrolled. +- Registrant is an individual → eligible programs are those with + `target_type='individual'` and `state='active'`. The individual is enrolled. + +Standalone individuals (registrants not in any household) are supported. + +### Models defined by this module + +| Model | Kind | Purpose | +| ----- | ---- | ------- | +| `spp.cr.detail.assign_program` | Model | Captures the program selection for the CR | +| `spp.cr.apply.assign_program` | AbstractModel | Apply strategy that creates the membership | + +### Validation rules (apply-time) + +The apply strategy refuses the operation when any of the following hold: + +- the registrant is `disabled` +- the program is not in `state='active'` +- the program's `target_type` does not match the registrant +- a membership for the same `(registrant, program)` pair already exists +- the detail record has no `program_id` set + +### Conflict detection + +Two in-flight `assign_program` change requests targeting the same +`(registrant, program)` pair are treated as conflicting and the second +submission is blocked. Two CRs for the same registrant but different programs +are independent and both proceed. + +### Dependencies + +- `spp_change_request_v2` +- `spp_programs` diff --git a/spp_cr_type_assign_program/readme/HISTORY.md b/spp_cr_type_assign_program/readme/HISTORY.md new file mode 100644 index 00000000..74862b00 --- /dev/null +++ b/spp_cr_type_assign_program/readme/HISTORY.md @@ -0,0 +1,12 @@ +## 19.0.1.0.0 (2026-05-04) + +### Added + +- New module `spp_cr_type_assign_program` with the `assign_program` change + request type. +- Detail model `spp.cr.detail.assign_program` with live program-domain + filtering based on the registrant's target type. +- Apply strategy `spp.cr.apply.assign_program` that creates a draft + `spp.program.membership` record on apply. +- Conflict rule that blocks duplicate in-flight assignments to the same + `(registrant, program)` pair. diff --git a/spp_cr_type_assign_program/security/ir.model.access.csv b/spp_cr_type_assign_program/security/ir.model.access.csv new file mode 100644 index 00000000..27d72795 --- /dev/null +++ b/spp_cr_type_assign_program/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_cr_detail_assign_program_user,spp.cr.detail.assign_program user,model_spp_cr_detail_assign_program,spp_change_request_v2.group_cr_user,1,1,1,0 +access_spp_cr_detail_assign_program_validator,spp.cr.detail.assign_program validator,model_spp_cr_detail_assign_program,spp_change_request_v2.group_cr_validator,1,1,1,0 +access_spp_cr_detail_assign_program_validator_hq,spp.cr.detail.assign_program validator hq,model_spp_cr_detail_assign_program,spp_change_request_v2.group_cr_validator_hq,1,1,1,0 +access_spp_cr_detail_assign_program_manager,spp.cr.detail.assign_program manager,model_spp_cr_detail_assign_program,spp_change_request_v2.group_cr_manager,1,1,1,1 diff --git a/spp_cr_type_assign_program/static/description/index.html b/spp_cr_type_assign_program/static/description/index.html new file mode 100644 index 00000000..aa6fd572 --- /dev/null +++ b/spp_cr_type_assign_program/static/description/index.html @@ -0,0 +1,501 @@ + + + + + +OpenSPP CR Type - Assign to Program + + + +
+

OpenSPP CR Type - Assign to Program

+ + +

Beta License: LGPL-3 OpenSPP/OpenSPP2

+

Adds a single change request type — assign_program — that records a +registrant being assigned to a program. The change request runs through +the standard approval, conflict-detection, and document workflow +provided by spp_change_request_v2. On apply, an +spp.program.membership record is created in the draft state for +the (registrant, program) pair.

+
+

Beneficiary semantics

+

The CR’s registrant is the program beneficiary. There is no “select +a member of the household” step.

+
    +
  • Registrant is a group (household) → eligible programs are those with +target_type='group' and state='active'. The household itself +is enrolled.
  • +
  • Registrant is an individual → eligible programs are those with +target_type='individual' and state='active'. The individual is +enrolled.
  • +
+

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

+
+
+

Models defined by this module

+ +++++ + + + + + + + + + + + + + + + + +
ModelKindPurpose
spp.cr.detail.assign_programModelCaptures the program +selection for the CR
spp.cr.apply.assign_programAbstractModelApply strategy that +creates the membership
+
+
+

Validation rules (apply-time)

+

The apply strategy refuses the operation when any of the following hold:

+
    +
  • the registrant is disabled
  • +
  • the program is not in state='active'
  • +
  • the program’s target_type does not match the registrant
  • +
  • a membership for the same (registrant, program) pair already +exists
  • +
  • the detail record has no program_id set
  • +
+
+
+

Conflict detection

+

Two in-flight assign_program change requests targeting the same +(registrant, program) pair are treated as conflicting and the second +submission is blocked. Two CRs for the same registrant but different +programs are independent and both proceed.

+
+
+

Dependencies

+
    +
  • spp_change_request_v2
  • +
  • spp_programs
  • +
+

Table of contents

+ + +
+
+

Added

+
    +
  • New module spp_cr_type_assign_program with the assign_program +change request type.
  • +
  • Detail model spp.cr.detail.assign_program with live program-domain +filtering based on the registrant’s target type.
  • +
  • Apply strategy spp.cr.apply.assign_program that creates a draft +spp.program.membership record on apply.
  • +
  • Conflict rule that blocks duplicate in-flight assignments to the same +(registrant, program) pair.
  • +
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_cr_type_assign_program/strategies/__init__.py b/spp_cr_type_assign_program/strategies/__init__.py new file mode 100644 index 00000000..d678cbfd --- /dev/null +++ b/spp_cr_type_assign_program/strategies/__init__.py @@ -0,0 +1 @@ +from . import assign_program diff --git a/spp_cr_type_assign_program/strategies/assign_program.py b/spp_cr_type_assign_program/strategies/assign_program.py new file mode 100644 index 00000000..4f2fb11c --- /dev/null +++ b/spp_cr_type_assign_program/strategies/assign_program.py @@ -0,0 +1,122 @@ +import logging + +import psycopg2 + +from odoo import _, models +from odoo.exceptions import UserError +from odoo.tools import mute_logger + +_logger = logging.getLogger(__name__) + + +class SPPCRApplyAssignProgram(models.AbstractModel): + """Custom apply strategy for the assign-to-program CR type. + + Creates an `spp.program.membership` record linking the CR's registrant to + the program selected on the detail. The membership starts in `draft` so + the program's own enrollment workflow can take over from there. + """ + + _name = "spp.cr.apply.assign_program" + _inherit = "spp.cr.strategy.base" + _description = "CR Apply: Assign to Program" + + def validate(self, change_request): + """Validate the CR can be applied. Raises UserError on any failure.""" + detail = change_request.get_detail() + if not detail: + raise UserError(_("No detail record found for this change request.")) + + program = detail.program_id + if not program: + raise UserError(_("Program is required to assign a registrant.")) + + registrant = change_request.registrant_id + if not registrant: + raise UserError(_("Registrant is required.")) + + if registrant.disabled: + raise UserError(_("Disabled registrants cannot be assigned to a program.")) + + if program.state != "active": + raise UserError(_("Only active programs can accept new registrants.")) + + expected_target_type = "group" if registrant.is_group else "individual" + if program.target_type != expected_target_type: + raise UserError( + _( + "Program '%(program)s' targets '%(program_target)s' " + "registrants but '%(registrant)s' is " + "'%(registrant_target)s'." + ) + % { + "program": program.display_name, + "program_target": program.target_type, + "registrant": registrant.display_name, + "registrant_target": expected_target_type, + } + ) + + existing = self.env["spp.program.membership"].search_count( + [("partner_id", "=", registrant.id), ("program_id", "=", program.id)] + ) + if existing: + raise UserError( + _("%(registrant)s is already in program %(program)s.") + % { + "registrant": registrant.display_name, + "program": program.display_name, + } + ) + + def apply(self, change_request): + """Validate, then create the program membership.""" + self.validate(change_request) + + detail = change_request.get_detail() + registrant = change_request.registrant_id + program = detail.program_id + + # `validate()` checks the (registrant, program) pair is unique, but a + # concurrent transaction can insert the same pair between that read + # and the create below. The DB unique constraint on + # spp.program.membership(partner_id, program_id) catches the race; + # wrap the create in a savepoint so the parent transaction stays + # usable, and translate the psycopg2 error into the same friendly + # UserError the validate() path produces. + try: + with self.env.cr.savepoint(), mute_logger("odoo.sql_db"): + membership = self.env["spp.program.membership"].create( + {"partner_id": registrant.id, "program_id": program.id} + ) + except psycopg2.errors.UniqueViolation as exc: + raise UserError( + _("%(registrant)s is already in program %(program)s.") + % { + "registrant": registrant.display_name, + "program": program.display_name, + } + ) from exc + detail.write({"created_membership_id": membership.id}) + + _logger.info( + "Created program membership id=%s for registrant_id=%s, program_id=%s via CR %s", + membership.id, + registrant.id, + program.id, + change_request.name, + ) + return True + + def preview(self, change_request): + """Preview what will happen on apply.""" + detail = change_request.get_detail() + if not detail or not detail.program_id: + return {} + + return { + "_action": "create_program_membership", + "registrant": change_request.registrant_id.display_name, + "program": detail.program_id.display_name, + "initial_state": "draft", + } diff --git a/spp_cr_type_assign_program/tests/__init__.py b/spp_cr_type_assign_program/tests/__init__.py new file mode 100644 index 00000000..27098114 --- /dev/null +++ b/spp_cr_type_assign_program/tests/__init__.py @@ -0,0 +1 @@ +from . import test_assign_program diff --git a/spp_cr_type_assign_program/tests/test_assign_program.py b/spp_cr_type_assign_program/tests/test_assign_program.py new file mode 100644 index 00000000..7baf7f89 --- /dev/null +++ b/spp_cr_type_assign_program/tests/test_assign_program.py @@ -0,0 +1,284 @@ +"""Tests for spp_cr_type_assign_program.""" + +from unittest.mock import patch + +import psycopg2 + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.spp_change_request_v2.tests.common import CRTestCase + +ASSIGN_PROGRAM_CR_TYPE_DEFS = { + "name": "Assign to Program", + "target_type": "both", + "detail_model": "spp.cr.detail.assign_program", + "apply_strategy": "custom", + "apply_model": "spp.cr.apply.assign_program", +} + + +@tagged("post_install", "-at_install") +class TestAssignProgram(CRTestCase): + """Detail model and apply strategy for the assign-program CR type.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.Program = cls.env["spp.program"] + cls.ProgramMembership = cls.env["spp.program.membership"] + + cls.cr_type = cls.CRType.search([("code", "=", "assign_program")], limit=1) + if not cls.cr_type: + cls.cr_type = cls.CRType.create({"code": "assign_program", **ASSIGN_PROGRAM_CR_TYPE_DEFS}) + + cls.group_program_active = cls.Program.create({"name": "Group Program (Active)", "target_type": "group"}) + cls.indiv_program_active = cls.Program.create( + {"name": "Individual Program (Active)", "target_type": "individual"} + ) + cls.indiv_program_inactive = cls.Program.create( + {"name": "Individual Program (Inactive)", "target_type": "individual"} + ) + cls.indiv_program_inactive.state = "ended" + + cls.disabled_individual = cls.Partner.create( + { + "name": "Disabled Individual", + "given_name": "Disabled", + "family_name": "Individual", + "is_registrant": True, + "is_group": False, + "disabled": fields.Datetime.now(), + } + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _make_cr(self, registrant, program=None): + """Create a CR of this type and (optionally) preset its detail's program. + + Returns (change_request, detail). + """ + cr = self.CR.create({"request_type_id": self.cr_type.id, "registrant_id": registrant.id}) + detail = cr.get_detail() + if program is not None: + detail.program_id = program.id + return cr, detail + + def _strategy(self): + return self.env["spp.cr.apply.assign_program"] + + # ------------------------------------------------------------------ + # Detail model — computed fields (D1-D4) + # ------------------------------------------------------------------ + + def test_d1_registrant_target_type_for_group(self): + _cr, detail = self._make_cr(self.test_group) + self.assertEqual(detail.registrant_target_type, "group") + + def test_d2_registrant_target_type_for_individual(self): + _cr, detail = self._make_cr(self.test_individual) + self.assertEqual(detail.registrant_target_type, "individual") + + def test_d3_allowed_programs_for_group_registrant(self): + _cr, detail = self._make_cr(self.test_group) + allowed = detail.allowed_program_ids + self.assertIn(self.group_program_active, allowed) + self.assertNotIn(self.indiv_program_active, allowed) + self.assertNotIn(self.indiv_program_inactive, allowed) + + def test_d4_allowed_programs_for_individual_registrant(self): + _cr, detail = self._make_cr(self.test_individual) + allowed = detail.allowed_program_ids + self.assertIn(self.indiv_program_active, allowed) + self.assertNotIn(self.group_program_active, allowed) + self.assertNotIn(self.indiv_program_inactive, allowed) + + def test_d5_allowed_programs_excludes_already_enrolled(self): + """Programs the registrant is already in must be filtered out so + duplicates surface at form-fill time, not after a wasted approval + cycle.""" + self.ProgramMembership.create( + { + "partner_id": self.test_individual.id, + "program_id": self.indiv_program_active.id, + } + ) + other_program = self.Program.create({"name": "Other Active Individual Program", "target_type": "individual"}) + + _cr, detail = self._make_cr(self.test_individual) + + self.assertNotIn(self.indiv_program_active, detail.allowed_program_ids) + self.assertIn(other_program, detail.allowed_program_ids) + + # ------------------------------------------------------------------ + # Apply strategy + # ------------------------------------------------------------------ + + def test_a1_apply_creates_membership_for_individual(self): + cr, detail = self._make_cr(self.test_individual, self.indiv_program_active) + + result = self._strategy().apply(cr) + + self.assertTrue(result) + self.assertTrue(detail.created_membership_id) + membership = detail.created_membership_id + self.assertEqual(membership.partner_id, self.test_individual) + self.assertEqual(membership.program_id, self.indiv_program_active) + self.assertEqual(membership.state, "draft") + + def test_a4_apply_without_program_raises(self): + cr, _detail = self._make_cr(self.test_individual) + + with self.assertRaises(UserError): + self._strategy().apply(cr) + + def test_a2_apply_creates_membership_for_group(self): + cr, detail = self._make_cr(self.test_group, self.group_program_active) + + self._strategy().apply(cr) + + self.assertTrue(detail.created_membership_id) + self.assertEqual(detail.created_membership_id.partner_id, self.test_group) + self.assertEqual(detail.created_membership_id.program_id, self.group_program_active) + self.assertEqual(detail.created_membership_id.state, "draft") + + def test_a5_apply_with_disabled_registrant_raises(self): + cr, _detail = self._make_cr(self.disabled_individual, self.indiv_program_active) + + with self.assertRaises(UserError) as cm: + self._strategy().apply(cr) + + self.assertIn("disabled", str(cm.exception).lower()) + + def test_a6_apply_with_inactive_program_raises(self): + cr, _detail = self._make_cr(self.test_individual, self.indiv_program_inactive) + + with self.assertRaises(UserError) as cm: + self._strategy().apply(cr) + + self.assertIn("active", str(cm.exception).lower()) + + def test_a7_apply_with_target_type_mismatch_raises(self): + # Group registrant + individual program — domain would normally prevent + # this in the UI, but the strategy must still defend against direct ID + # writes. + cr, _detail = self._make_cr(self.test_group, self.indiv_program_active) + + with self.assertRaises(UserError) as cm: + self._strategy().apply(cr) + + self.assertIn("target", str(cm.exception).lower()) + + def test_a8_apply_with_existing_membership_raises_friendly_error(self): + # First apply succeeds. + cr_first, _detail = self._make_cr(self.test_individual, self.indiv_program_active) + self._strategy().apply(cr_first) + + # Second CR for the same (registrant, program) pair must be rejected + # with our own friendly message (not the raw DB unique-constraint + # error). + cr_second, _detail2 = self._make_cr(self.test_individual, self.indiv_program_active) + + with self.assertRaises(UserError) as cm: + self._strategy().apply(cr_second) + + self.assertIn("already", str(cm.exception).lower()) + + # ------------------------------------------------------------------ + # Full CR lifecycle (F1) + # ------------------------------------------------------------------ + + def test_f1_full_cr_lifecycle_creates_membership(self): + cr, detail = self._make_cr(self.test_individual, self.indiv_program_active) + + cr.approval_state = "approved" + cr.action_apply() + + self.assertTrue(cr.is_applied) + self.assertTrue(detail.created_membership_id) + membership = detail.created_membership_id + self.assertEqual(membership.partner_id, self.test_individual) + self.assertEqual(membership.program_id, self.indiv_program_active) + self.assertEqual(membership.state, "draft") + + # ------------------------------------------------------------------ + # Conflict detection (F2, F3) + # ------------------------------------------------------------------ + + def test_f2_two_crs_for_same_registrant_program_block_second(self): + cr1, _d1 = self._make_cr(self.test_individual, self.indiv_program_active) + cr2, _d2 = self._make_cr(self.test_individual, self.indiv_program_active) + + cr2._run_conflict_checks() + + self.assertEqual(cr2.conflict_status, "blocked") + self.assertIn(cr1, cr2.conflicting_cr_ids) + + def test_f4_conflict_hook_passes_through_non_our_rules(self): + """If _check_custom_conflicts is invoked with a rule that isn't + ours, the hook must return the input candidates unchanged so other + modules' custom conflict logic isn't accidentally suppressed. + """ + cr1, _d1 = self._make_cr(self.test_individual, self.indiv_program_active) + cr2, _d2 = self._make_cr(self.test_individual, self.indiv_program_active) + + other_rule = self.env["spp.cr.conflict.rule"].create( + { + "name": "Unrelated rule", + "cr_type_id": self.cr_type.id, + "scope": "custom", + "action": "warn", + } + ) + + result = cr2._check_custom_conflicts(cr1, other_rule) + + self.assertEqual(result, cr1) + + def test_f3_two_crs_for_same_registrant_different_programs_allowed(self): + # Two distinct active individual programs targeting the same registrant + # must both be able to proceed. + other_indiv_program = self.Program.create({"name": "Other Individual Program", "target_type": "individual"}) + + _cr1, _d1 = self._make_cr(self.test_individual, self.indiv_program_active) + cr2, _d2 = self._make_cr(self.test_individual, other_indiv_program) + + cr2._run_conflict_checks() + + self.assertEqual(cr2.conflict_status, "none") + self.assertFalse(cr2.conflicting_cr_ids) + + def test_a10_apply_translates_unique_violation_to_user_error(self): + """Race-path: a concurrent transaction inserts the same + (registrant, program) pair between our validate() and create(). + The DB UNIQUE constraint fires; the strategy must translate it + into the same friendly UserError the validate() path produces, + not let the raw psycopg2 error surface. + """ + cr, _detail = self._make_cr(self.test_individual, self.indiv_program_active) + + membership_cls = type(self.ProgramMembership) + + def boom(self_, *args, **kwargs): + raise psycopg2.errors.UniqueViolation("simulated race") + + with patch.object(membership_cls, "create", boom): + with self.assertRaises(UserError) as cm: + self._strategy().apply(cr) + + self.assertIn("already", str(cm.exception).lower()) + + def test_a9_preview_returns_expected_shape(self): + cr, _detail = self._make_cr(self.test_individual, self.indiv_program_active) + + preview = self._strategy().preview(cr) + + self.assertEqual(preview.get("_action"), "create_program_membership") + self.assertEqual(preview.get("registrant"), self.test_individual.display_name) + self.assertEqual(preview.get("program"), self.indiv_program_active.display_name) + self.assertEqual(preview.get("initial_state"), "draft") diff --git a/spp_cr_type_assign_program/views/detail_assign_program_views.xml b/spp_cr_type_assign_program/views/detail_assign_program_views.xml new file mode 100644 index 00000000..e3ad7f96 --- /dev/null +++ b/spp_cr_type_assign_program/views/detail_assign_program_views.xml @@ -0,0 +1,102 @@ + + + + spp.cr.detail.assign_program.form + spp.cr.detail.assign_program + +
+
+ +
+ +
+

Assign to Program

+
+ + + + + + + + + + + + + + + + + +
+ + +
+
+