Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions spp_cr_type_assign_program/README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/OpenSPP/OpenSPP2/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 <https://github.com/OpenSPP/OpenSPP2/issues/new?body=module:%20spp_cr_type_assign_program%0Aversion:%2019.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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 <https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_cr_type_assign_program>`_ project on GitHub.

You are welcome to contribute.
3 changes: 3 additions & 0 deletions spp_cr_type_assign_program/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import details
from . import models
from . import strategies
24 changes: 24 additions & 0 deletions spp_cr_type_assign_program/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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"],
}
39 changes: 39 additions & 0 deletions spp_cr_type_assign_program/data/cr_types.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo noupdate="1">
<record id="cr_type_assign_program" model="spp.change.request.type">
<field name="name">Assign to Program</field>
<field name="code">assign_program</field>
<field
name="description"
>Assign a registrant (individual or household) to a program. The registrant becomes the program beneficiary.</field>
<field name="target_type">both</field>
<field name="is_requires_registrant" eval="True" />
<field name="detail_model">spp.cr.detail.assign_program</field>
<field name="detail_form_view_id" ref="spp_cr_detail_assign_program_form" />
<field name="apply_strategy">custom</field>
<field name="apply_model">spp.cr.apply.assign_program</field>
<field name="icon">fa-hand-holding-heart</field>
<field name="sequence">120</field>
<field name="is_studio_editable" eval="False" />
<field name="is_studio_cloneable" eval="False" />
<field name="is_system_type" eval="True" />
<field name="source_module">spp_cr_type_assign_program</field>
<field name="enable_conflict_detection" eval="True" />
<field
name="locked_reason"
>This type requires custom Python logic to create program memberships. Cannot be edited via Studio.</field>
</record>

<record id="cr_conflict_rule_assign_program_duplicate" model="spp.cr.conflict.rule">
<field name="name">Duplicate program assignment</field>
<field name="cr_type_id" ref="cr_type_assign_program" />
<field name="scope">custom</field>
<field name="check_same_type_only" eval="True" />
<field name="action">block</field>
<field name="conflict_states">all_active</field>
<field name="sequence">10</field>
<field
name="conflict_message"
>Another change request is already assigning this registrant to the same program. Cancel or wait for it to be applied first.</field>
</record>
</odoo>
1 change: 1 addition & 0 deletions spp_cr_type_assign_program/details/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import assign_program
75 changes: 75 additions & 0 deletions spp_cr_type_assign_program/details/assign_program.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spp_cr_type_assign_program/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import change_request
47 changes: 47 additions & 0 deletions spp_cr_type_assign_program/models/change_request.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions spp_cr_type_assign_program/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
46 changes: 46 additions & 0 deletions spp_cr_type_assign_program/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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`
Loading
Loading