diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py
index 3a26a2c99..7e921c636 100644
--- a/spp_change_request_v2/__manifest__.py
+++ b/spp_change_request_v2/__manifest__.py
@@ -43,6 +43,7 @@
"views/detail_transfer_member_views.xml",
"views/detail_update_id_views.xml",
"views/detail_create_group_views.xml",
+ "views/create_group_member_wizard_views.xml",
"views/detail_merge_registrants_views.xml",
"views/detail_split_household_views.xml",
"views/preview_changes_wizard_views.xml",
diff --git a/spp_change_request_v2/details/add_member.py b/spp_change_request_v2/details/add_member.py
index 5c41c39f8..f69ec1cbc 100644
--- a/spp_change_request_v2/details/add_member.py
+++ b/spp_change_request_v2/details/add_member.py
@@ -1,3 +1,11 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Detail model for the Add Member CR (OP#871).
+
+The first page searches for an existing individual registrant and adds them to
+the group with a role. (The earlier create-a-new-individual flow was replaced
+per the updated #871 spec — Add Member now selects an existing member.)
+"""
+
from odoo import api, fields, models
@@ -8,57 +16,124 @@ class SPPCRDetailAddMember(models.Model):
_description = "CR Detail: Add Group Member"
_inherit = ["spp.cr.detail.base", "mail.thread"]
- # ══════════════════════════════════════════════════════════════════════════
- # MEMBER INFORMATION - Real Odoo fields with full features
- # ══════════════════════════════════════════════════════════════════════════
-
- member_name = fields.Char(
- string="Full Name",
+ # ──────────────────────────────────────────────────────────────────────
+ # Member to add (existing individual)
+ # ──────────────────────────────────────────────────────────────────────
+ individual_id = fields.Many2one(
+ "res.partner",
+ string="Member",
tracking=True,
- help="Required before apply. Auto-computed from given/family names.",
+ help="Search for an existing individual registrant to add to the group.",
)
- given_name = fields.Char(string="Given Name", tracking=True)
- family_name = fields.Char(string="Family Name", tracking=True)
- birthdate = fields.Date(string="Date of Birth", tracking=True)
- gender_id = fields.Many2one(
+ # Domain string (computed) restricting the picker to existing individual
+ # registrants who are not already active members of the group. A computed
+ # domain is used instead of a materialised Many2many so the picker scales
+ # with a large registry (mirrors spp.change.request.registrant_domain).
+ individual_domain = fields.Char(compute="_compute_individual_domain")
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Role (per-member, single row)
+ # ──────────────────────────────────────────────────────────────────────
+ membership_type_id = fields.Many2one(
"spp.vocabulary.code",
- string="Gender",
- domain="[('namespace_uri', '=', 'urn:iso:std:iso:5218')]",
+ string="Role",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type')]",
tracking=True,
+ help="Role of the new member in the group. Optionality controlled by the CR type.",
+ )
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Type-config mirrors (for view conditionals)
+ # ──────────────────────────────────────────────────────────────────────
+ type_requires_head = fields.Boolean(
+ related="change_request_id.request_type_id.requires_head",
+ string="Requires Head",
+ )
+ roles_available = fields.Boolean(
+ compute="_compute_roles_available",
+ string="Has Membership Roles",
+ help="True when the urn:openspp:vocab:group-membership-type vocabulary has any active code.",
)
- relationship_id = fields.Many2one(
+ allowed_role_ids = fields.Many2many(
"spp.vocabulary.code",
- string="Relationship to Head",
- domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'),"
- " ('code', '!=', 'head')]",
- tracking=True,
+ string="Allowed Roles",
+ compute="_compute_allowed_role_ids",
+ help="Roles selectable for the new member; the Head role is excluded when "
+ "the group already has a Head of Household (OP#871).",
)
- id_number = fields.Char(string="ID Number", tracking=True)
- phone = fields.Char(string="Phone Number", tracking=True)
- # Reference to created individual (set after apply)
- created_individual_id = fields.Many2one(
- "res.partner",
- string="Created Individual",
- readonly=True,
+ # ──────────────────────────────────────────────────────────────────────
+ # Read-only context: existing members of the group
+ # ──────────────────────────────────────────────────────────────────────
+ existing_membership_ids = fields.Many2many(
+ "spp.group.membership",
+ string="Existing Members",
+ compute="_compute_existing_memberships",
)
- # ══════════════════════════════════════════════════════════════════════════
- # ONCHANGE - Full Odoo functionality
- # ══════════════════════════════════════════════════════════════════════════
+ # ──────────────────────────────────────────────────────────────────────
+ # Computes
+ # ──────────────────────────────────────────────────────────────────────
+ def _active_member_individual_ids(self, group):
+ """ids of individuals who are active members of the group."""
+ if not group or not group.is_group:
+ return []
+ memberships = self.env["spp.group.membership"].search([("group", "=", group.id), ("status", "=", "active")])
+ return memberships.mapped("individual").ids
+
+ @api.depends("change_request_id", "change_request_id.registrant_id")
+ def _compute_individual_domain(self):
+ for rec in self:
+ member_ids = rec._active_member_individual_ids(rec.change_request_id.registrant_id)
+ rec.individual_domain = str(
+ [
+ ("is_registrant", "=", True),
+ ("is_group", "=", False),
+ ("id", "not in", member_ids),
+ ]
+ )
+
+ @api.depends_context("uid")
+ def _compute_roles_available(self):
+ has_any = bool(
+ self.env["spp.vocabulary.code"].search_count(
+ [("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:group-membership-type")]
+ )
+ )
+ for rec in self:
+ rec.roles_available = has_any
- @api.onchange("given_name", "family_name")
- def _onchange_names(self):
- """Auto-compute full name from given + family."""
- if self.given_name or self.family_name:
- name_vals = [
- f"{self.family_name},"
- if self.family_name and self.given_name
- else f"{self.family_name}"
- if self.family_name
- else "",
- self.given_name,
- ]
+ @api.depends("change_request_id", "change_request_id.registrant_id")
+ def _compute_existing_memberships(self):
+ Membership = self.env["spp.group.membership"]
+ for rec in self:
+ group = rec.change_request_id.registrant_id
+ if group and group.is_group:
+ rec.existing_membership_ids = Membership.search([("group", "=", group.id), ("status", "=", "active")])
+ else:
+ rec.existing_membership_ids = Membership.browse([])
- name = " ".join(filter(None, name_vals))
- self.member_name = name.upper()
+ @api.depends("change_request_id", "change_request_id.registrant_id")
+ def _compute_allowed_role_ids(self):
+ """Restrict the Role selection: a group can have only one Head, so the
+ Head role is removed from the options when the target group already has
+ an active Head of Household (OP#871)."""
+ Code = self.env["spp.vocabulary.code"]
+ Membership = self.env["spp.group.membership"]
+ all_roles = Code.search([("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:group-membership-type")])
+ for rec in self:
+ roles = all_roles
+ group = rec.change_request_id.registrant_id
+ if group and group.is_group:
+ group_has_head = bool(
+ Membership.search_count(
+ [
+ ("group", "=", group.id),
+ ("status", "=", "active"),
+ ("membership_type_ids.code", "=", "head"),
+ ]
+ )
+ )
+ if group_has_head:
+ roles = all_roles.filtered(lambda r: r.code != "head")
+ rec.allowed_role_ids = roles
diff --git a/spp_change_request_v2/details/change_hoh.py b/spp_change_request_v2/details/change_hoh.py
index d87a0f566..f4ead2311 100644
--- a/spp_change_request_v2/details/change_hoh.py
+++ b/spp_change_request_v2/details/change_hoh.py
@@ -1,8 +1,12 @@
-from odoo import api, fields, models
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+ROLE_NAMESPACE = "urn:openspp:vocab:group-membership-type"
+HEAD_ROLE_CODE = "head"
class SPPCRDetailChangeHOH(models.Model):
- """Detail model for Change Head of Household CR type."""
+ """Detail model for Change Head of Household CR type (OP#873)."""
_name = "spp.cr.detail.change_hoh"
_description = "CR Detail: Change Head of Household"
@@ -19,32 +23,13 @@ class SPPCRDetailChangeHOH(models.Model):
store=True,
readonly=True,
)
- available_individual_ids = fields.Many2many(
- "res.partner",
- string="Available Individuals",
- compute="_compute_available_individuals",
- help="Active members excluding current head of household",
- )
- new_head_id = fields.Many2one(
- "res.partner",
- string="New Head of Household",
- tracking=True,
- domain="[('is_group', '=', False), ('is_registrant', '=', True)]",
- help="Select the individual who will become the new head of household",
- )
- new_head_membership_id = fields.Many2one(
- "spp.group.membership",
- string="New Head Membership",
- readonly=True,
- help="Membership record for the new head (automatically set)",
- )
- previous_head_new_role_id = fields.Many2one(
- "spp.vocabulary.code",
- string="Previous Head's New Role",
- domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'),"
- " ('code', '!=', 'head')]",
- tracking=True,
- help="The new role for the previous head (e.g., Spouse, Other Adult)",
+ # One editable role line per active group member. The new head is whichever
+ # member is assigned the Head role (OP#873 — replaces the single new-head
+ # dropdown with a members-with-roles table).
+ member_line_ids = fields.One2many(
+ "spp.cr.detail.change_hoh.member",
+ "detail_id",
+ string="Members",
)
reason = fields.Selection(
[
@@ -52,18 +37,12 @@ class SPPCRDetailChangeHOH(models.Model):
("incapacitated", "Head Incapacitated"),
("left_household", "Head Left Household"),
("age_change", "Age-based Change"),
- ("voluntary", "Voluntary Transfer"),
("correction", "Data Correction"),
("other", "Other"),
],
string="Reason for Change",
tracking=True,
)
- effective_date = fields.Date(
- string="Effective Date",
- default=fields.Date.today,
- tracking=True,
- )
remarks = fields.Text(string="Remarks", tracking=True)
# ══════════════════════════════════════════════════════════════════════════
@@ -73,11 +52,11 @@ class SPPCRDetailChangeHOH(models.Model):
@api.depends("change_request_id.registrant_id")
def _compute_current_head(self):
"""Find the current head of household."""
- head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
+ head_kind = self.env["spp.vocabulary.code"].get_code(ROLE_NAMESPACE, HEAD_ROLE_CODE)
for rec in self:
current_head = False
if rec.change_request_id.registrant_id and head_kind:
- memberships = self.env["spp.group.membership"].search(
+ membership = self.env["spp.group.membership"].search(
[
("group", "=", rec.change_request_id.registrant_id.id),
("membership_type_ids", "in", [head_kind.id]),
@@ -85,46 +64,68 @@ def _compute_current_head(self):
],
limit=1,
)
- if memberships:
- current_head = memberships.individual
+ if membership:
+ current_head = membership.individual
rec.current_head_id = current_head
- @api.depends("change_request_id.registrant_id", "current_head_id")
- def _compute_available_individuals(self):
- """Compute available individuals excluding current head of household."""
- head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
- for rec in self:
- available_individuals = self.env["res.partner"]
- if rec.change_request_id.registrant_id:
- # Get all active memberships for this group
- all_memberships = self.env["spp.group.membership"].search(
- [
- ("group", "=", rec.change_request_id.registrant_id.id),
- ("status", "=", "active"),
- ]
- )
- # Exclude current head
- if head_kind:
- available_memberships = all_memberships.filtered(lambda m: head_kind not in m.membership_type_ids)
- else:
- available_memberships = all_memberships
- # Get the individuals from these memberships
- available_individuals = available_memberships.mapped("individual")
- rec.available_individual_ids = available_individuals
+ @api.model_create_multi
+ def create(self, vals_list):
+ details = super().create(vals_list)
+ for detail in details:
+ if not detail.member_line_ids:
+ detail._seed_member_lines()
+ return details
- @api.onchange("new_head_id")
- def _onchange_new_head_id(self):
- """Set the membership_id when individual is selected."""
- self.new_head_membership_id = False
- if self.new_head_id and self.change_request_id.registrant_id:
- # Find the membership for this individual in this group
- membership = self.env["spp.group.membership"].search(
- [
- ("group", "=", self.change_request_id.registrant_id.id),
- ("individual", "=", self.new_head_id.id),
- ("status", "=", "active"),
- ],
- limit=1,
+ def _seed_member_lines(self):
+ """Populate one editable role line per active group member, defaulting
+ each member's new role to their current role."""
+ self.ensure_one()
+ group = self.change_request_id.registrant_id
+ if not group or not group.is_group:
+ return
+ memberships = self.env["spp.group.membership"].search([("group", "=", group.id), ("status", "=", "active")])
+ lines = []
+ for membership in memberships:
+ current_roles = membership.membership_type_ids
+ lines.append(
+ (
+ 0,
+ 0,
+ {
+ "individual_id": membership.individual.id,
+ "membership_id": membership.id,
+ "old_role_display": ", ".join(current_roles.mapped("display")),
+ "new_role_id": current_roles[:1].id if current_roles else False,
+ },
+ )
)
- if membership:
- self.new_head_membership_id = membership
+ self.member_line_ids = lines
+
+
+class SPPCRDetailChangeHOHMember(models.Model):
+ """One editable role line per current group member (Change HoH, OP#873)."""
+
+ _name = "spp.cr.detail.change_hoh.member"
+ _description = "CR Detail: Change HoH - Member Role"
+
+ detail_id = fields.Many2one(
+ "spp.cr.detail.change_hoh",
+ required=True,
+ ondelete="cascade",
+ )
+ individual_id = fields.Many2one("res.partner", string="Member", readonly=True)
+ membership_id = fields.Many2one("spp.group.membership", readonly=True)
+ old_role_display = fields.Char(string="Current Role", readonly=True)
+ new_role_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="New Role",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type')]",
+ )
+
+ @api.constrains("new_role_id")
+ def _check_single_head(self):
+ """A group can have at most one Head of Household."""
+ head = self.env["spp.vocabulary.code"].get_code(ROLE_NAMESPACE, HEAD_ROLE_CODE)
+ for detail in self.mapped("detail_id"):
+ if head and len(detail.member_line_ids.filtered(lambda r: r.new_role_id == head)) > 1:
+ raise ValidationError(_("A group can have at most one Head of Household."))
diff --git a/spp_change_request_v2/details/create_group.py b/spp_change_request_v2/details/create_group.py
index e1c605e63..6692cc71b 100644
--- a/spp_change_request_v2/details/create_group.py
+++ b/spp_change_request_v2/details/create_group.py
@@ -1,4 +1,10 @@
-from odoo import api, fields, models
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Detail model + sub-models for the Create New Group CR (OP#876)."""
+
+from datetime import date
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
class SPPCRDetailCreateGroup(models.Model):
@@ -8,10 +14,9 @@ class SPPCRDetailCreateGroup(models.Model):
_description = "CR Detail: Create New Group"
_inherit = ["spp.cr.detail.base", "mail.thread"]
- # ══════════════════════════════════════════════════════════════════════════
- # GROUP INFORMATION
- # ══════════════════════════════════════════════════════════════════════════
-
+ # ──────────────────────────────────────────────────────────────────────
+ # Group Identification
+ # ──────────────────────────────────────────────────────────────────────
group_name = fields.Char(
string="Group Name",
tracking=True,
@@ -22,84 +27,431 @@ class SPPCRDetailCreateGroup(models.Model):
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-type')]",
tracking=True,
)
-
- # Head of Household
- head_individual_id = fields.Many2one(
- "res.partner",
- string="Head of Household",
- tracking=True,
- domain="[('is_group', '=', False), ('is_registrant', '=', True)]",
- help="Select existing individual as head, or leave empty to create new",
+ # When the vocabulary has more than one active code we surface the picker;
+ # with exactly one option we auto-default and hide it.
+ group_type_option_count = fields.Integer(
+ compute="_compute_group_type_option_count",
)
- create_new_head = fields.Boolean(
- string="Create New Head",
- default=False,
- tracking=True,
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Group Contact Information
+ # ──────────────────────────────────────────────────────────────────────
+ area_id = fields.Many2one("spp.area", string="Area", tracking=True)
+ # The registry stores a single free-text address (res.partner.address), so the
+ # CR collects it the same way to map cleanly on apply (OP#876 QA round 1).
+ address = fields.Text(string="Address", tracking=True)
+ email = fields.Char(string="Email", tracking=True)
+ phone_line_ids = fields.One2many(
+ "spp.cr.detail.create_group.phone",
+ "detail_id",
+ string="Phone Numbers",
)
- head_name = fields.Char(
- string="Head Name",
- tracking=True,
- help="Name for new head of household",
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Group Location (coordinates only — see OP#876 plan note)
+ # ──────────────────────────────────────────────────────────────────────
+ latitude = fields.Float(string="Latitude", digits=(13, 10), tracking=True)
+ longitude = fields.Float(string="Longitude", digits=(13, 10), tracking=True)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Group Financial Information
+ # ──────────────────────────────────────────────────────────────────────
+ bank_line_ids = fields.One2many(
+ "spp.cr.detail.create_group.bank",
+ "detail_id",
+ string="Bank Accounts",
)
- head_given_name = fields.Char(string="Head Given Name", tracking=True)
- head_family_name = fields.Char(string="Head Family Name", tracking=True)
- head_birthdate = fields.Date(string="Head Date of Birth", tracking=True)
- head_gender_id = fields.Many2one(
- "spp.vocabulary.code",
- string="Head Gender",
- domain="[('namespace_uri', '=', 'urn:iso:std:iso:5218')]",
- tracking=True,
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Group Identity Documents
+ # ──────────────────────────────────────────────────────────────────────
+ id_doc_line_ids = fields.One2many(
+ "spp.cr.detail.create_group.id_doc",
+ "detail_id",
+ string="Identity Documents",
)
- head_phone = fields.Char(string="Head Phone", tracking=True)
- # Address
- address_line1 = fields.Char(string="Address Line 1", tracking=True)
- address_line2 = fields.Char(string="Address Line 2", tracking=True)
- city = fields.Char(string="City", tracking=True)
- state_id = fields.Many2one("res.country.state", string="State/Province", tracking=True)
- postal_code = fields.Char(string="Postal Code", tracking=True)
- country_id = fields.Many2one("res.country", string="Country", tracking=True)
+ # ──────────────────────────────────────────────────────────────────────
+ # Membership flow
+ # Two parallel sub-tables: existing individuals to attach, and new
+ # individuals to create. Both carry the role (membership_type_id) so
+ # the Roles requirement can be enforced uniformly at apply time.
+ # ──────────────────────────────────────────────────────────────────────
+ member_existing_ids = fields.One2many(
+ "spp.cr.detail.create_group.member_existing",
+ "detail_id",
+ string="Existing Members",
+ )
+ member_new_ids = fields.One2many(
+ "spp.cr.detail.create_group.member_new",
+ "detail_id",
+ string="New Members",
+ )
- # Contact
- phone = fields.Char(string="Group Phone", tracking=True)
- email = fields.Char(string="Group Email", tracking=True)
+ # Mirrors of the CR type config so the view can reference them via
+ # related fields rather than reading parent.request_type_id.* each time.
+ type_allow_empty_members = fields.Boolean(
+ related="change_request_id.request_type_id.allow_empty_members",
+ string="Allows Empty Groups",
+ )
+ type_requires_head = fields.Boolean(
+ related="change_request_id.request_type_id.requires_head",
+ string="Requires Head",
+ )
+ roles_available = fields.Boolean(
+ compute="_compute_roles_available",
+ string="Has Membership Roles",
+ help="True when the urn:openspp:vocab:group-membership-type vocabulary has any active code.",
+ )
- # Reference to created group (set after apply)
+ # Reference to created group (set after apply).
created_group_id = fields.Many2one(
"res.partner",
string="Created Group",
readonly=True,
)
- # ══════════════════════════════════════════════════════════════════════════
- # ONCHANGE
- # ══════════════════════════════════════════════════════════════════════════
-
- @api.onchange("head_given_name", "head_family_name")
- def _onchange_head_names(self):
- """Auto-compute head name from given + family."""
- if self.create_new_head and (self.head_given_name or self.head_family_name):
- name_vals = [
- f"{self.head_family_name},"
- if self.head_family_name and self.head_given_name
- else f"{self.head_family_name}"
- if self.head_family_name
- else "",
- self.head_given_name,
- ]
-
- name = " ".join(filter(None, name_vals))
- self.head_name = name.upper()
-
- @api.onchange("create_new_head")
- def _onchange_create_new_head(self):
- """Clear head selection when toggling create mode."""
- if self.create_new_head:
- self.head_individual_id = False
- else:
- self.head_name = False
- self.head_given_name = False
- self.head_family_name = False
- self.head_birthdate = False
- self.head_gender_id = False
- self.head_phone = False
+ # ──────────────────────────────────────────────────────────────────────
+ # Computes
+ # ──────────────────────────────────────────────────────────────────────
+ @api.depends_context("uid")
+ def _compute_group_type_option_count(self):
+ count = self.env["spp.vocabulary.code"].search_count(
+ [("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:group-type")]
+ )
+ for rec in self:
+ rec.group_type_option_count = count
+
+ @api.depends_context("uid")
+ def _compute_roles_available(self):
+ has_any = bool(
+ self.env["spp.vocabulary.code"].search_count(
+ [("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:group-membership-type")]
+ )
+ )
+ for rec in self:
+ rec.roles_available = has_any
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Member-wizard openers (one button per mode on the detail form)
+ # ──────────────────────────────────────────────────────────────────────
+ def _open_member_wizard(self, mode):
+ self.ensure_one()
+ return {
+ "type": "ir.actions.act_window",
+ "name": _("Add Existing Individual") if mode == "existing" else _("Add New Individual"),
+ "res_model": "spp.cr.detail.create_group.member.wizard",
+ "view_mode": "form",
+ "target": "new",
+ "context": {
+ "default_detail_id": self.id,
+ "default_mode": mode,
+ },
+ }
+
+ def action_open_add_existing_wizard(self):
+ return self._open_member_wizard("existing")
+
+ def action_open_add_new_wizard(self):
+ return self._open_member_wizard("new")
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Helpers used by the apply strategy
+ # ──────────────────────────────────────────────────────────────────────
+ def _heads(self):
+ """Return a tuple ``(existing_heads, new_heads)`` of recordsets.
+
+ ``member_existing_ids`` and ``member_new_ids`` are different models
+ so we can't merge them into one recordset — but the caller usually
+ wants to know "how many heads total" and "which row is the head",
+ which both work cleanly off the tuple.
+ """
+ self.ensure_one()
+ return (
+ self.member_existing_ids.filtered(lambda m: m.is_head),
+ self.member_new_ids.filtered(lambda m: m.is_head),
+ )
+
+ def _head_count(self):
+ """Total number of rows flagged as head across both sub-tables."""
+ self.ensure_one()
+ existing_heads, new_heads = self._heads()
+ return len(existing_heads) + len(new_heads)
+
+ @api.constrains("member_existing_ids", "member_new_ids")
+ def _check_at_most_one_head(self):
+ for rec in self:
+ if rec._head_count() > 1:
+ raise ValidationError(_("A group can have at most one Head of Household."))
+
+
+class SPPCRDetailCreateGroupPhone(models.Model):
+ _name = "spp.cr.detail.create_group.phone"
+ _description = "CR Detail: Phone Number (Create Group / Add Member)"
+ _order = "is_primary desc, id"
+
+ # The same row shape is reused by the group detail (Create Group), the Add
+ # Member detail, and a Create-Group new-member row. Exactly one parent FK
+ # must be set — enforced by ``_check_one_parent`` (OP#871/#876).
+ detail_id = fields.Many2one(
+ "spp.cr.detail.create_group",
+ ondelete="cascade",
+ )
+ add_member_detail_id = fields.Many2one(
+ "spp.cr.detail.add_member",
+ ondelete="cascade",
+ )
+ member_new_id = fields.Many2one(
+ "spp.cr.detail.create_group.member_new",
+ ondelete="cascade",
+ )
+ phone_no = fields.Char(string="Phone Number", required=True)
+ country_id = fields.Many2one("res.country", string="Country")
+ is_primary = fields.Boolean(
+ string="Primary",
+ help="The first primary phone is also written to the partner's header phone field.",
+ )
+
+ @api.constrains("detail_id", "add_member_detail_id", "member_new_id")
+ def _check_one_parent(self):
+ # Only reject a row linked to more than one context. A row with no
+ # parent is harmless (an unreferenced orphan) and can occur transiently
+ # while Odoo rewrites a one2many, so it must not raise — requiring
+ # exactly one surfaced a confusing "parent" error to users editing a
+ # member's phone list.
+ for rec in self:
+ if sum(1 for p in (rec.detail_id, rec.add_member_detail_id, rec.member_new_id) if p) > 1:
+ raise ValidationError(_("A phone-number row cannot belong to more than one record."))
+
+
+class SPPCRDetailCreateGroupBank(models.Model):
+ _name = "spp.cr.detail.create_group.bank"
+ _description = "CR Detail: Bank Account (Create Group / Add Member)"
+
+ detail_id = fields.Many2one(
+ "spp.cr.detail.create_group",
+ ondelete="cascade",
+ )
+ add_member_detail_id = fields.Many2one(
+ "spp.cr.detail.add_member",
+ ondelete="cascade",
+ )
+ acc_number = fields.Char(string="Account Number", required=True)
+ acc_holder_name = fields.Char(string="Account Holder")
+ bank_id = fields.Many2one("res.bank", string="Bank")
+
+ @api.constrains("detail_id", "add_member_detail_id")
+ def _check_one_parent(self):
+ # Only reject multi-parenting; a transient zero-parent state during a
+ # one2many rewrite is harmless (see the phone row note).
+ for rec in self:
+ if rec.detail_id and rec.add_member_detail_id:
+ raise ValidationError(_("A bank-account row cannot belong to more than one record."))
+
+
+class SPPCRDetailCreateGroupIdDoc(models.Model):
+ _name = "spp.cr.detail.create_group.id_doc"
+ _description = "CR Detail: Identity Document (Create Group / Add Member)"
+
+ detail_id = fields.Many2one(
+ "spp.cr.detail.create_group",
+ ondelete="cascade",
+ )
+ add_member_detail_id = fields.Many2one(
+ "spp.cr.detail.add_member",
+ ondelete="cascade",
+ )
+ id_type_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="ID Type",
+ required=True,
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:id-type')]",
+ )
+ value = fields.Char(string="Value", required=True)
+ expiry_date = fields.Date(string="Expiry Date")
+
+ @api.constrains("detail_id", "add_member_detail_id")
+ def _check_one_parent(self):
+ # Only reject multi-parenting; a transient zero-parent state during a
+ # one2many rewrite is harmless (see the phone row note).
+ for rec in self:
+ if rec.detail_id and rec.add_member_detail_id:
+ raise ValidationError(_("An ID document row cannot belong to more than one record."))
+
+
+class SPPCRDetailCreateGroupMemberExisting(models.Model):
+ """Existing individual being added to the new group."""
+
+ _name = "spp.cr.detail.create_group.member_existing"
+ _description = "CR Detail: Create Group — Existing Member"
+
+ detail_id = fields.Many2one(
+ "spp.cr.detail.create_group",
+ required=True,
+ ondelete="cascade",
+ )
+ individual_id = fields.Many2one(
+ "res.partner",
+ string="Individual",
+ required=True,
+ domain="[('is_group', '=', False), ('is_registrant', '=', True)]",
+ )
+ membership_type_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Role",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type')]",
+ )
+ is_head = fields.Boolean(
+ string="Is Head",
+ compute="_compute_is_head",
+ store=True,
+ )
+
+ @api.depends("membership_type_id")
+ def _compute_is_head(self):
+ for rec in self:
+ rec.is_head = bool(rec.membership_type_id and rec.membership_type_id.code == "head")
+
+ @api.constrains("membership_type_id")
+ def _check_single_head(self):
+ # The parent's @api.constrains on the o2m only fires when the o2m is
+ # written through the parent. Rows added via the member wizard are
+ # created directly with detail_id set, bypassing it — so guard here too.
+ for rec in self:
+ if rec.is_head and rec.detail_id._head_count() > 1:
+ raise ValidationError(_("A group can have at most one Head of Household."))
+
+
+class SPPCRDetailCreateGroupMemberNew(models.Model):
+ """New individual to create and attach to the new group."""
+
+ _name = "spp.cr.detail.create_group.member_new"
+ _description = "CR Detail: Create Group — New Member"
+
+ detail_id = fields.Many2one(
+ "spp.cr.detail.create_group",
+ required=True,
+ ondelete="cascade",
+ )
+ # Names
+ given_name = fields.Char(string="Given Name", required=True)
+ family_name = fields.Char(string="Family Name", required=True)
+ middle_name = fields.Char(
+ string="Middle Name",
+ help="res.partner has no native middle name; on apply it is prepended to "
+ "the given name when composing the individual's display name.",
+ )
+ full_name = fields.Char(
+ string="Full Name",
+ compute="_compute_full_name",
+ store=True,
+ )
+ # Demographics (mirrors the registry's individual overview — OP#876 QA round 1)
+ birthdate = fields.Date(string="Date of Birth")
+ is_approximate_birthdate = fields.Boolean(string="Approximate Birthdate")
+ age = fields.Integer(string="Age", compute="_compute_age")
+ birth_place = fields.Char(string="Birth Place")
+ occupation_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Occupation",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:ilo:isco-08')]",
+ )
+ gender_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Gender",
+ domain="[('namespace_uri', '=', 'urn:iso:std:iso:5218')]",
+ )
+ civil_status_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Civil Status",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:un:unsd:pop-census:marital-status')]",
+ )
+ income = fields.Float(string="Income")
+ # Contact
+ area_id = fields.Many2one("spp.area", string="Area")
+ address = fields.Text(string="Address")
+ email = fields.Char(string="Email")
+ phone_line_ids = fields.One2many(
+ "spp.cr.detail.create_group.phone",
+ "member_new_id",
+ string="Phone Numbers",
+ )
+ membership_type_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Role",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type')]",
+ )
+ is_head = fields.Boolean(
+ string="Is Head",
+ compute="_compute_is_head",
+ store=True,
+ )
+
+ @api.depends("given_name", "family_name", "middle_name")
+ def _compute_full_name(self):
+ for rec in self:
+ given = (rec.given_name or "").strip()
+ family = (rec.family_name or "").strip()
+ middle = (rec.middle_name or "").strip()
+ first_part = " ".join(filter(None, [given, middle]))
+ if family and first_part:
+ rec.full_name = f"{family.upper()}, {first_part}"
+ else:
+ rec.full_name = (first_part or family).upper() or False
+
+ @api.depends("birthdate")
+ def _compute_age(self):
+ today = date.today()
+ for rec in self:
+ if not rec.birthdate:
+ rec.age = 0
+ continue
+ bd = rec.birthdate
+ rec.age = max(today.year - bd.year - ((today.month, today.day) < (bd.month, bd.day)), 0)
+
+ @api.depends("membership_type_id")
+ def _compute_is_head(self):
+ for rec in self:
+ rec.is_head = bool(rec.membership_type_id and rec.membership_type_id.code == "head")
+
+ @api.constrains("membership_type_id")
+ def _check_single_head(self):
+ # See note on the existing-member model: wizard rows bypass the
+ # parent-level constraint, so enforce one-head at the row level too.
+ for rec in self:
+ if rec.is_head and rec.detail_id._head_count() > 1:
+ raise ValidationError(_("A group can have at most one Head of Household."))
+
+ def action_open_edit_wizard(self):
+ """Re-open the Add Member wizard pre-populated to edit this row."""
+ self.ensure_one()
+ return {
+ "type": "ir.actions.act_window",
+ "name": _("Edit New Individual"),
+ "res_model": "spp.cr.detail.create_group.member.wizard",
+ "view_mode": "form",
+ "target": "new",
+ "context": {
+ "default_detail_id": self.detail_id.id,
+ "default_mode": "new",
+ "default_editing_member_new_id": self.id,
+ "default_given_name": self.given_name,
+ "default_family_name": self.family_name,
+ "default_middle_name": self.middle_name,
+ "default_birthdate": self.birthdate,
+ "default_is_approximate_birthdate": self.is_approximate_birthdate,
+ "default_birth_place": self.birth_place,
+ "default_occupation_id": self.occupation_id.id if self.occupation_id else False,
+ "default_gender_id": self.gender_id.id if self.gender_id else False,
+ "default_civil_status_id": self.civil_status_id.id if self.civil_status_id else False,
+ "default_income": self.income,
+ "default_area_id": self.area_id.id if self.area_id else False,
+ "default_address": self.address,
+ "default_email": self.email,
+ "default_phone_line_ids": [
+ (0, 0, {"phone_no": p.phone_no, "country_id": p.country_id.id, "is_primary": p.is_primary})
+ for p in self.phone_line_ids
+ ],
+ "default_membership_type_id": self.membership_type_id.id if self.membership_type_id else False,
+ },
+ }
diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py
index d1b1012ee..9d5addda9 100644
--- a/spp_change_request_v2/models/change_request.py
+++ b/spp_change_request_v2/models/change_request.py
@@ -392,10 +392,36 @@ def _compute_stage_banner_html(self):
)
rec.stage_banner_html = html
- @api.depends("document_ids", "document_ids.document_type_id", "request_type_id.required_document_ids")
+ def _get_effective_required_document_ids(self):
+ """Return the document types required for this request.
+
+ When the request type defines per-reason document rules (OP#873) and the
+ request's detail exposes a matching reason, that rule's documents take
+ precedence over the flat ``required_document_ids`` list. A configured
+ rule with no documents means nothing is required for that reason."""
+ self.ensure_one()
+ empty = self.env["spp.vocabulary.code"]
+ rt = self.request_type_id
+ if not rt:
+ return empty
+ reason_rules = rt.reason_document_ids
+ if reason_rules:
+ detail = self.get_detail()
+ reason = detail.reason if detail and "reason" in detail._fields else False
+ if reason:
+ rule = reason_rules.filtered(lambda r: r.reason == reason)
+ return rule[:1].required_document_ids if rule else empty
+ return rt.required_document_ids
+
+ @api.depends(
+ "document_ids",
+ "document_ids.document_type_id",
+ "request_type_id.required_document_ids",
+ "request_type_id.reason_document_ids",
+ )
def _compute_missing_required_documents(self):
for rec in self:
- required = rec.request_type_id.required_document_ids if rec.request_type_id else None
+ required = rec._get_effective_required_document_ids() if rec.request_type_id else None
if not required:
rec.missing_required_document_ids = self.env["spp.vocabulary.code"]
rec.documents_complete = True
@@ -405,10 +431,15 @@ def _compute_missing_required_documents(self):
rec.missing_required_document_ids = missing
rec.documents_complete = not bool(missing)
- @api.depends("document_ids", "document_ids.document_type_id", "request_type_id.required_document_ids")
+ @api.depends(
+ "document_ids",
+ "document_ids.document_type_id",
+ "request_type_id.required_document_ids",
+ "request_type_id.reason_document_ids",
+ )
def _compute_required_documents_html(self):
for rec in self:
- required = rec.request_type_id.required_document_ids if rec.request_type_id else None
+ required = rec._get_effective_required_document_ids() if rec.request_type_id else None
if not required:
rec.required_documents_html = (
'
'
@@ -1154,13 +1185,74 @@ def _generate_review_comparison_html(self):
action = changes.pop("_action", None)
header = changes.pop("_header", None)
+ tables = changes.pop("_tables", None)
+ sections = changes.pop("_sections", None)
# Determine if this is a field-mapping type (has old/new dicts)
has_comparison = any(isinstance(v, dict) and "old" in v and "new" in v for v in changes.values())
if has_comparison:
- return self._render_comparison_table(changes, header=header)
- return self._render_action_summary(action, changes, header=header)
+ html = self._render_comparison_table(changes, header=header)
+ else:
+ html = self._render_action_summary(action, changes, header=header)
+ if tables:
+ html += self._render_data_tables(tables)
+ if sections:
+ html += self._render_data_sections(sections)
+ return html
+
+ def _render_data_tables(self, tables):
+ """Render preview() ``_tables`` entries as separate HTML tables.
+
+ Each entry is ``{"title", "columns", "rows"}`` where ``rows`` is a list
+ of cell-string lists. Used to show one2many data (phones, bank accounts,
+ ID documents, ...) on the review page instead of a bare count (OP#876).
+ """
+ out = []
+ for table in tables:
+ columns = table.get("columns") or []
+ rows = table.get("rows") or []
+ out.append(f'
{html_escape(table.get("title") or "")}
')
+ if not rows:
+ out.append('
None.
')
+ continue
+ out.append('
')
+ out.append(
+ "
"
+ + "".join(f'
{html_escape(c)}
' for c in columns)
+ + "
"
+ )
+ out.append("")
+ for row in rows:
+ out.append(
+ "
" + "".join(f"
{html_escape('' if c is None else str(c))}
" for c in row) + "
"
+ )
+ out.append("
")
+ return "".join(out)
+
+ def _render_data_sections(self, sections):
+ """Render preview() ``_sections`` entries — one labelled detail block per
+ entity (e.g. each new group member): its fields as a key/value table plus
+ any nested ``tables`` (e.g. that member's phone numbers) (OP#876).
+ """
+ out = []
+ for section in sections:
+ out.append(f'
{html_escape(section.get("title") or "")}
')
+ field_rows = section.get("fields") or []
+ if field_rows:
+ out.append('
')
+ out.append("")
+ for label, value in field_rows:
+ display = html_escape(value) if value else '—'
+ out.append(
+ f'
{html_escape(label)}
'
+ f"
{display}
"
+ )
+ out.append("
")
+ nested = section.get("tables")
+ if nested:
+ out.append(self._render_data_tables(nested))
+ return "".join(out)
def _render_comparison_table(self, changes, header=None):
"""Render a three-column comparison table for field-mapping CR types."""
diff --git a/spp_change_request_v2/models/change_request_type.py b/spp_change_request_v2/models/change_request_type.py
index 3d4da4ae3..dd23a4f68 100644
--- a/spp_change_request_v2/models/change_request_type.py
+++ b/spp_change_request_v2/models/change_request_type.py
@@ -78,16 +78,35 @@ class SPPChangeRequestType(models.Model):
)
is_requires_registrant = fields.Boolean(
+ string="Requires Registrant",
default=True,
help="Require selecting a registrant when creating this type of change request. "
"Disable for types like 'Create New Group' that don't apply to an existing registrant.",
)
is_requires_applicant = fields.Boolean(
+ string="Requires Applicant",
default=False,
help="Require an applicant (person submitting on behalf of registrant)",
)
+ # OP#876: group-creation-specific config. Only read by the Create Group
+ # detail/strategy today, but lives on the type so each group-creating CR
+ # type can ship its own defaults (e.g. cooperatives may not require a head
+ # while households do).
+ allow_empty_members = fields.Boolean(
+ string="Allow Empty Groups",
+ default=False,
+ help="When set, the Create Group flow asks whether the user wants to add members "
+ "instead of forcing it. When unset, the user must add at least one member.",
+ )
+ requires_head = fields.Boolean(
+ string="Requires Head of Household",
+ default=False,
+ help="When set, the Create Group flow requires exactly one member to be assigned "
+ "the 'head' role from the group-membership-type vocabulary before the CR can apply.",
+ )
+
# ══════════════════════════════════════════════════════════════════════════
# DETAIL MODEL CONFIGURATION
# ══════════════════════════════════════════════════════════════════════════
@@ -152,6 +171,23 @@ class SPPChangeRequestType(models.Model):
string="Required Documents (Deprecated)",
help="Deprecated: Use required_document_ids instead",
)
+ # Per-reason document rules (OP#873). When a CR type exposes a "reason"
+ # (e.g. Change Head of Household), the required documents can be driven by
+ # the chosen reason instead of the flat required_document_ids list.
+ supports_reason_documents = fields.Boolean(
+ string="Supports Reason-based Documents",
+ compute="_compute_supports_reason_documents",
+ help="True when the detail model exposes a 'reason' field, so required "
+ "documents can depend on the chosen reason.",
+ )
+ reason_document_ids = fields.One2many(
+ "spp.cr.type.reason.document",
+ "cr_type_id",
+ string="Required Documents by Reason",
+ help="Optional: make required documents depend on the request's Reason. "
+ "When set and the request has a matching reason, these documents are "
+ "required in place of the flat 'Required Documents' list.",
+ )
allow_document_download = fields.Boolean(
string="Allow Document Download",
default=False,
@@ -324,6 +360,15 @@ def _compute_detail_model_exists(self):
for rec in self:
rec.is_detail_model_exists = bool(rec.detail_model and rec.detail_model in self.env)
+ @api.depends("detail_model")
+ def _compute_supports_reason_documents(self):
+ """A CR type supports reason-driven documents only when its detail model
+ has a 'reason' field (e.g. Change Head of Household)."""
+ for rec in self:
+ rec.supports_reason_documents = bool(
+ rec.detail_model and rec.detail_model in self.env and "reason" in self.env[rec.detail_model]._fields
+ )
+
@api.depends("detail_model")
def _compute_available_field_ids(self):
"""Compute available fields from the detail model."""
@@ -502,3 +547,51 @@ def validate_required_fields(self, detail_record):
missing_fields.append(field.field_description)
return len(missing_fields) == 0, missing_fields
+
+
+class SPPCRTypeReasonDocument(models.Model):
+ """Maps a request reason to the set of documents required for it (OP#873).
+
+ Configured on a change request type; consumed by
+ spp.change.request._get_effective_required_document_ids(). The reason values
+ mirror spp.cr.detail.change_hoh.reason."""
+
+ _name = "spp.cr.type.reason.document"
+ _description = "CR Type: Required Documents by Reason"
+ _order = "cr_type_id, reason"
+
+ cr_type_id = fields.Many2one(
+ "spp.change.request.type",
+ string="Change Request Type",
+ required=True,
+ ondelete="cascade",
+ )
+ reason = fields.Selection(
+ [
+ ("deceased", "Head Deceased"),
+ ("incapacitated", "Head Incapacitated"),
+ ("left_household", "Head Left Household"),
+ ("age_change", "Age-based Change"),
+ ("correction", "Data Correction"),
+ ("other", "Other"),
+ ],
+ string="Reason",
+ required=True,
+ )
+ required_document_ids = fields.Many2many(
+ "spp.vocabulary.code",
+ "cr_type_reason_doc_rel",
+ "rule_id",
+ "doc_id",
+ string="Required Documents",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:cr_document_type')]",
+ help="Documents required when the request's reason matches this rule.",
+ )
+
+ _sql_constraints = [
+ (
+ "reason_uniq",
+ "unique(cr_type_id, reason)",
+ "A reason can only have one document rule per change request type.",
+ ),
+ ]
diff --git a/spp_change_request_v2/security/ir.model.access.csv b/spp_change_request_v2/security/ir.model.access.csv
index 6cb0aa5cc..1f20ed3d0 100644
--- a/spp_change_request_v2/security/ir.model.access.csv
+++ b/spp_change_request_v2/security/ir.model.access.csv
@@ -47,6 +47,34 @@ access_spp_cr_detail_create_group_user,spp.cr.detail.create_group user,model_spp
access_spp_cr_detail_create_group_validator,spp.cr.detail.create_group validator,model_spp_cr_detail_create_group,group_cr_validator,1,1,1,0
access_spp_cr_detail_create_group_validator_hq,spp.cr.detail.create_group validator hq,model_spp_cr_detail_create_group,group_cr_validator_hq,1,1,1,0
access_spp_cr_detail_create_group_manager,spp.cr.detail.create_group manager,model_spp_cr_detail_create_group,group_cr_manager,1,1,1,1
+access_spp_cr_detail_create_group_phone_user,spp.cr.detail.create_group.phone user,model_spp_cr_detail_create_group_phone,group_cr_user,1,1,1,0
+access_spp_cr_detail_create_group_phone_validator,spp.cr.detail.create_group.phone validator,model_spp_cr_detail_create_group_phone,group_cr_validator,1,1,1,0
+access_spp_cr_detail_create_group_phone_validator_hq,spp.cr.detail.create_group.phone validator hq,model_spp_cr_detail_create_group_phone,group_cr_validator_hq,1,1,1,0
+access_spp_cr_detail_create_group_phone_manager,spp.cr.detail.create_group.phone manager,model_spp_cr_detail_create_group_phone,group_cr_manager,1,1,1,1
+access_spp_cr_detail_create_group_bank_user,spp.cr.detail.create_group.bank user,model_spp_cr_detail_create_group_bank,group_cr_user,1,1,1,0
+access_spp_cr_detail_create_group_bank_validator,spp.cr.detail.create_group.bank validator,model_spp_cr_detail_create_group_bank,group_cr_validator,1,1,1,0
+access_spp_cr_detail_create_group_bank_validator_hq,spp.cr.detail.create_group.bank validator hq,model_spp_cr_detail_create_group_bank,group_cr_validator_hq,1,1,1,0
+access_spp_cr_detail_create_group_bank_manager,spp.cr.detail.create_group.bank manager,model_spp_cr_detail_create_group_bank,group_cr_manager,1,1,1,1
+access_spp_cr_detail_create_group_id_doc_user,spp.cr.detail.create_group.id_doc user,model_spp_cr_detail_create_group_id_doc,group_cr_user,1,1,1,0
+access_spp_cr_detail_create_group_id_doc_validator,spp.cr.detail.create_group.id_doc validator,model_spp_cr_detail_create_group_id_doc,group_cr_validator,1,1,1,0
+access_spp_cr_detail_create_group_id_doc_validator_hq,spp.cr.detail.create_group.id_doc validator hq,model_spp_cr_detail_create_group_id_doc,group_cr_validator_hq,1,1,1,0
+access_spp_cr_detail_create_group_id_doc_manager,spp.cr.detail.create_group.id_doc manager,model_spp_cr_detail_create_group_id_doc,group_cr_manager,1,1,1,1
+access_spp_cr_detail_create_group_member_existing_user,spp.cr.detail.create_group.member_existing user,model_spp_cr_detail_create_group_member_existing,group_cr_user,1,1,1,0
+access_spp_cr_detail_create_group_member_existing_validator,spp.cr.detail.create_group.member_existing validator,model_spp_cr_detail_create_group_member_existing,group_cr_validator,1,1,1,0
+access_spp_cr_detail_create_group_member_existing_validator_hq,spp.cr.detail.create_group.member_existing validator hq,model_spp_cr_detail_create_group_member_existing,group_cr_validator_hq,1,1,1,0
+access_spp_cr_detail_create_group_member_existing_manager,spp.cr.detail.create_group.member_existing manager,model_spp_cr_detail_create_group_member_existing,group_cr_manager,1,1,1,1
+access_spp_cr_detail_create_group_member_new_user,spp.cr.detail.create_group.member_new user,model_spp_cr_detail_create_group_member_new,group_cr_user,1,1,1,0
+access_spp_cr_detail_create_group_member_new_validator,spp.cr.detail.create_group.member_new validator,model_spp_cr_detail_create_group_member_new,group_cr_validator,1,1,1,0
+access_spp_cr_detail_create_group_member_new_validator_hq,spp.cr.detail.create_group.member_new validator hq,model_spp_cr_detail_create_group_member_new,group_cr_validator_hq,1,1,1,0
+access_spp_cr_detail_create_group_member_new_manager,spp.cr.detail.create_group.member_new manager,model_spp_cr_detail_create_group_member_new,group_cr_manager,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_user,spp.cr.detail.create_group.member.wizard user,model_spp_cr_detail_create_group_member_wizard,group_cr_user,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_validator,spp.cr.detail.create_group.member.wizard validator,model_spp_cr_detail_create_group_member_wizard,group_cr_validator,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_validator_hq,spp.cr.detail.create_group.member.wizard validator hq,model_spp_cr_detail_create_group_member_wizard,group_cr_validator_hq,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_manager,spp.cr.detail.create_group.member.wizard manager,model_spp_cr_detail_create_group_member_wizard,group_cr_manager,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_phone_user,spp.cr.detail.create_group.member.wizard.phone user,model_spp_cr_detail_create_group_member_wizard_phone,group_cr_user,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_phone_validator,spp.cr.detail.create_group.member.wizard.phone validator,model_spp_cr_detail_create_group_member_wizard_phone,group_cr_validator,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_phone_validator_hq,spp.cr.detail.create_group.member.wizard.phone validator hq,model_spp_cr_detail_create_group_member_wizard_phone,group_cr_validator_hq,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_phone_manager,spp.cr.detail.create_group.member.wizard.phone manager,model_spp_cr_detail_create_group_member_wizard_phone,group_cr_manager,1,1,1,1
access_spp_cr_detail_merge_registrants_user,spp.cr.detail.merge_registrants user,model_spp_cr_detail_merge_registrants,group_cr_user,1,1,1,0
access_spp_cr_detail_merge_registrants_validator,spp.cr.detail.merge_registrants validator,model_spp_cr_detail_merge_registrants,group_cr_validator,1,1,1,0
access_spp_cr_detail_merge_registrants_validator_hq,spp.cr.detail.merge_registrants validator hq,model_spp_cr_detail_merge_registrants,group_cr_validator_hq,1,1,1,0
@@ -123,3 +151,11 @@ access_spp_change_request_log_user,spp.change.request.log user,model_spp_change_
access_spp_change_request_log_validator,spp.change.request.log validator,model_spp_change_request_log,group_cr_validator,1,0,0,0
access_spp_change_request_log_validator_hq,spp.change.request.log validator hq,model_spp_change_request_log,group_cr_validator_hq,1,0,0,0
access_spp_change_request_log_manager,spp.change.request.log manager,model_spp_change_request_log,group_cr_manager,1,1,1,1
+access_spp_cr_detail_change_hoh_member_user,spp.cr.detail.change_hoh.member user,model_spp_cr_detail_change_hoh_member,group_cr_user,1,1,1,1
+access_spp_cr_detail_change_hoh_member_validator,spp.cr.detail.change_hoh.member validator,model_spp_cr_detail_change_hoh_member,group_cr_validator,1,1,1,1
+access_spp_cr_detail_change_hoh_member_validator_hq,spp.cr.detail.change_hoh.member validator hq,model_spp_cr_detail_change_hoh_member,group_cr_validator_hq,1,1,1,1
+access_spp_cr_detail_change_hoh_member_manager,spp.cr.detail.change_hoh.member manager,model_spp_cr_detail_change_hoh_member,group_cr_manager,1,1,1,1
+access_spp_cr_type_reason_document_user,spp.cr.type.reason.document user,model_spp_cr_type_reason_document,group_cr_user,1,0,0,0
+access_spp_cr_type_reason_document_validator,spp.cr.type.reason.document validator,model_spp_cr_type_reason_document,group_cr_validator,1,0,0,0
+access_spp_cr_type_reason_document_validator_hq,spp.cr.type.reason.document validator hq,model_spp_cr_type_reason_document,group_cr_validator_hq,1,0,0,0
+access_spp_cr_type_reason_document_manager,spp.cr.type.reason.document manager,model_spp_cr_type_reason_document,group_cr_manager,1,1,1,1
diff --git a/spp_change_request_v2/strategies/add_member.py b/spp_change_request_v2/strategies/add_member.py
index 79c1bcbb8..560c0f66b 100644
--- a/spp_change_request_v2/strategies/add_member.py
+++ b/spp_change_request_v2/strategies/add_member.py
@@ -1,3 +1,11 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Apply strategy for the Add Member CR (OP#871).
+
+Adds an existing individual registrant to the group with a role. (The earlier
+create-a-new-individual flow was replaced per the updated #871 spec — the first
+page now searches for an existing member.)
+"""
+
import logging
from odoo import Command, _, fields, models
@@ -13,8 +21,10 @@ class SPPCRApplyAddMember(models.AbstractModel):
_inherit = "spp.cr.strategy.base"
_description = "CR Apply: Add Group Member"
+ # ──────────────────────────────────────────────────────────────────────
+ # apply
+ # ──────────────────────────────────────────────────────────────────────
def apply(self, change_request):
- """Create individual and add as group member."""
group = change_request.registrant_id
if not group.is_group:
raise UserError(_("Registrant must be a group."))
@@ -23,67 +33,89 @@ def apply(self, change_request):
if not detail:
raise UserError(_("No detail record found."))
- # Validate required fields
- if not detail.member_name:
- raise UserError(_("Member name is required to add a new member."))
-
- # Create the individual
- individual_vals = {
- "name": detail.member_name,
- "given_name": detail.given_name,
- "family_name": detail.family_name,
- "birthdate": detail.birthdate,
- "phone": detail.phone,
- "is_registrant": True,
- "is_group": False,
- }
-
- # Handle gender field - Many2one to spp.vocabulary.code
- if detail.gender_id:
- individual_vals["gender_id"] = detail.gender_id.id
-
- _logger.info(
- "Creating individual from CR %s for registrant_id=%s",
- change_request.name,
- change_request.registrant_id.id,
- )
- individual = self.env["res.partner"].create(individual_vals)
- individual.name_change()
+ self._validate(detail, group)
- # Create group membership
- membership_vals = {
+ vals = {
"group": group.id,
- "individual": individual.id,
+ "individual": detail.individual_id.id,
"start_date": fields.Datetime.now(),
}
-
- # Handle relationship/membership type
- if detail.relationship_id:
- membership_vals["membership_type_ids"] = [Command.link(detail.relationship_id.id)]
-
- self.env["spp.group.membership"].create(membership_vals)
-
- # Store reference to created individual
- detail.write({"created_individual_id": individual.id})
+ if detail.membership_type_id:
+ vals["membership_type_ids"] = [Command.link(detail.membership_type_id.id)]
+ self.env["spp.group.membership"].create(vals)
_logger.info(
- "Added member partner_id=%s to group partner_id=%s via CR %s",
- individual.id,
+ "Added existing member partner_id=%s to group partner_id=%s via CR %s",
+ detail.individual_id.id,
group.id,
change_request.name,
)
-
return True
+ # ──────────────────────────────────────────────────────────────────────
+ # preview
+ # ──────────────────────────────────────────────────────────────────────
def preview(self, change_request):
- """Preview what will be created."""
detail = change_request.get_detail()
if not detail:
return {}
-
+ individual = detail.individual_id
+
+ def field_val(name):
+ """Read a field off the selected individual, guarding for registry
+ fields that may be absent without spp_registry on the path."""
+ if not individual or name not in individual._fields:
+ return None
+ return individual[name] or None
+
+ gender = field_val("gender_id")
+ civil_status = field_val("civil_status_id")
+ occupation = field_val("occupation_id")
+ area = field_val("area_id")
+ birthdate = field_val("birthdate")
+
+ # The review page shows who is being added; empty fields render as a
+ # "-" placeholder through the action-summary formatter.
return {
- "_action": "create_member",
- "member_name": detail.member_name,
- "group": change_request.registrant_id.name,
- "relationship": detail.relationship_id.display if detail.relationship_id else None,
+ "_action": "add_member",
+ "_header": _("The following individual is to be added to the group:"),
+ _("Group"): change_request.registrant_id.display_name,
+ _("Name"): individual.display_name if individual else None,
+ _("Role"): detail.membership_type_id.display if detail.membership_type_id else None,
+ _("Date of Birth"): str(birthdate) if birthdate else None,
+ _("Gender"): gender.display_name if gender else None,
+ _("Civil Status"): civil_status.display_name if civil_status else None,
+ _("Occupation"): occupation.display_name if occupation else None,
+ _("Area"): area.display_name if area else None,
+ _("Address"): field_val("address"),
+ _("Email"): individual.email if individual else None,
}
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Validation
+ # ──────────────────────────────────────────────────────────────────────
+ def _validate(self, detail, group):
+ individual = detail.individual_id
+ if not individual:
+ raise UserError(_("Select an individual to add to the group."))
+ if individual.is_group:
+ raise UserError(_("Only individuals can be added as group members."))
+
+ already_member = self.env["spp.group.membership"].search_count(
+ [
+ ("group", "=", group.id),
+ ("individual", "=", individual.id),
+ ("status", "=", "active"),
+ ]
+ )
+ if already_member:
+ raise UserError(_("%s is already an active member of this group.") % individual.display_name)
+
+ cr_type = detail.change_request_id.request_type_id
+ if cr_type.requires_head and not detail.membership_type_id:
+ raise UserError(
+ _(
+ "This Change Request type requires a Head of Household role assignment. "
+ "Pick a role for the new member before applying."
+ )
+ )
diff --git a/spp_change_request_v2/strategies/change_hoh.py b/spp_change_request_v2/strategies/change_hoh.py
index 8020843e7..a96cd22e7 100644
--- a/spp_change_request_v2/strategies/change_hoh.py
+++ b/spp_change_request_v2/strategies/change_hoh.py
@@ -5,16 +5,21 @@
_logger = logging.getLogger(__name__)
+ROLE_NAMESPACE = "urn:openspp:vocab:group-membership-type"
+HEAD_ROLE_CODE = "head"
+
class SPPCRApplyChangeHOH(models.AbstractModel):
- """Custom apply strategy for Change Head of Household CR type."""
+ """Custom apply strategy for Change Head of Household CR type (OP#873)."""
_name = "spp.cr.apply.change_hoh"
_inherit = "spp.cr.strategy.base"
_description = "CR Apply: Change Head of Household"
def apply(self, change_request):
- """Change the head of household."""
+ """Apply the per-member role assignments. The member assigned the Head
+ role becomes the new head of household; every other member's role is set
+ to their chosen new role."""
group = change_request.registrant_id
if not group.is_group:
raise UserError(_("Registrant must be a group."))
@@ -23,77 +28,85 @@ def apply(self, change_request):
if not detail:
raise UserError(_("No detail record found."))
- if not detail.new_head_membership_id:
- raise UserError(_("No new head of household selected."))
-
- head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
+ head_kind = self.env["spp.vocabulary.code"].get_code(ROLE_NAMESPACE, HEAD_ROLE_CODE)
if not head_kind:
raise UserError(
_(
"Head of Household membership type not found. "
- "Please configure 'head' membership type in the vocabulary."
+ "Please configure the 'head' membership type in the vocabulary."
)
)
- # Find current head membership
- current_head_membership = self.env["spp.group.membership"].search(
- [
- ("group", "=", group.id),
- ("membership_type_ids", "in", [head_kind.id]),
- ("status", "=", "active"),
- ],
- limit=1,
- )
-
- new_head_membership = detail.new_head_membership_id
-
- # Remove head role from current head
- if current_head_membership:
- current_roles = current_head_membership.membership_type_ids - head_kind
- new_roles = current_roles
- # Add the new role for previous head if specified
- if detail.previous_head_new_role_id:
- new_roles = current_roles | detail.previous_head_new_role_id
-
- current_head_membership.write(
- {
- "membership_type_ids": [Command.set(new_roles.ids)],
- }
- )
- _logger.info(
- "Removed head role from partner_id=%s in group partner_id=%s",
- current_head_membership.individual.id,
- group.id,
- )
-
- # Add head role to new head
- new_roles = new_head_membership.membership_type_ids | head_kind
- new_head_membership.write(
- {
- "membership_type_ids": [Command.set(new_roles.ids)],
- }
- )
+ lines = detail.member_line_ids
+ if not lines:
+ raise UserError(_("No members are available to assign roles to."))
+
+ head_lines = lines.filtered(lambda r: r.new_role_id == head_kind)
+ if not head_lines:
+ raise UserError(_("You must designate one member as the Head of Household."))
+ if len(head_lines) > 1:
+ raise UserError(_("Only one member can be the Head of Household."))
+
+ Membership = self.env["spp.group.membership"]
+ for line in lines:
+ if not line.new_role_id:
+ continue
+ membership = line.membership_id
+ if not membership or membership.status != "active":
+ # Membership may have changed since the lines were seeded; re-find it.
+ membership = Membership.search(
+ [
+ ("group", "=", group.id),
+ ("individual", "=", line.individual_id.id),
+ ("status", "=", "active"),
+ ],
+ limit=1,
+ )
+ if not membership:
+ continue
+ membership.write({"membership_type_ids": [Command.set(line.new_role_id.ids)]})
_logger.info(
- "Changed head of household for group partner_id=%s from partner_id=%s to partner_id=%s via CR %s",
+ "Applied head-of-household role changes for group partner_id=%s via CR %s (new head partner_id=%s)",
group.id,
- current_head_membership.individual.id if current_head_membership else None,
- new_head_membership.individual.id,
change_request.name,
+ head_lines.individual_id.id,
)
-
return True
def preview(self, change_request):
- """Preview what will be changed."""
+ """Preview the role changes: household, reason, remarks and a members
+ table (Name / Current Role / New Role)."""
detail = change_request.get_detail()
if not detail:
return {}
+ reason_label = None
+ if detail.reason:
+ selection = dict(detail.fields_get(["reason"])["reason"]["selection"])
+ reason_label = selection.get(detail.reason)
+
+ rows = []
+ for line in detail.member_line_ids:
+ rows.append(
+ [
+ line.individual_id.display_name or "",
+ line.old_role_display or "",
+ line.new_role_id.display if line.new_role_id else "",
+ ]
+ )
+
return {
"_action": "change_head_of_household",
- "group": change_request.registrant_id.name,
- "current_head": detail.current_head_id.name if detail.current_head_id else None,
- "new_head": detail.new_head_id.name if detail.new_head_id else None,
- "reason": detail.reason,
+ "_header": _("Head of Household role changes to apply:"),
+ _("Household"): change_request.registrant_id.display_name,
+ _("Reason for Change"): reason_label,
+ _("Remarks"): detail.remarks,
+ "_tables": [
+ {
+ "title": _("Members"),
+ "columns": [_("Name"), _("Current Role"), _("New Role")],
+ "rows": rows,
+ }
+ ],
}
diff --git a/spp_change_request_v2/strategies/create_group.py b/spp_change_request_v2/strategies/create_group.py
index b5c49bffc..94b428328 100644
--- a/spp_change_request_v2/strategies/create_group.py
+++ b/spp_change_request_v2/strategies/create_group.py
@@ -1,3 +1,6 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Apply strategy for the Create New Group CR (OP#876)."""
+
import logging
from odoo import Command, _, fields, models
@@ -5,6 +8,8 @@
_logger = logging.getLogger(__name__)
+HEAD_ROLE_CODE = "head"
+
class SPPCRApplyCreateGroup(models.AbstractModel):
"""Custom apply strategy for Create Group CR type."""
@@ -13,35 +18,30 @@ class SPPCRApplyCreateGroup(models.AbstractModel):
_inherit = "spp.cr.strategy.base"
_description = "CR Apply: Create New Group"
+ # ──────────────────────────────────────────────────────────────────────
+ # apply
+ # ──────────────────────────────────────────────────────────────────────
def apply(self, change_request):
- """Create a new group/household."""
detail = change_request.get_detail()
if not detail:
raise UserError(_("No detail record found."))
- if not detail.group_name:
- raise UserError(_("Group name is required."))
+ self._validate(detail)
- # Prepare group values
- group_vals = {
- "name": detail.group_name,
- "is_registrant": True,
- "is_group": True,
- "street": detail.address_line1,
- "street2": detail.address_line2,
- "city": detail.city,
- "state_id": detail.state_id.id if detail.state_id else False,
- "zip": detail.postal_code,
- "country_id": detail.country_id.id if detail.country_id else False,
- "phone": detail.phone,
- "email": detail.email,
- }
+ # 1. Group itself.
+ group = self._create_group(detail)
- if detail.group_type_id:
- group_vals["group_type_id"] = detail.group_type_id.id
+ # 2. Multi-value attachments tied to the group partner.
+ self._attach_phones(detail.phone_line_ids, group)
+ self._attach_banks(detail, group)
+ self._attach_id_docs(detail, group)
+
+ # 3. Members (existing + new). Each line carries its own role, the
+ # Head requirement is already validated in `_validate`.
+ self._attach_members(detail, group)
- # Create the group
- group = self.env["res.partner"].create(group_vals)
+ detail.write({"created_group_id": group.id})
+ change_request.write({"registrant_id": group.id})
_logger.info(
"Created group partner_id=%s (%s) via CR %s",
@@ -49,79 +49,289 @@ def apply(self, change_request):
group.name,
change_request.name,
)
-
- # Handle head of household
- head_individual = None
- if detail.create_new_head:
- # Create new individual as head
- if not detail.head_name:
- raise UserError(_("Head name is required when creating new head."))
-
- head_vals = {
- "name": detail.head_name,
- "given_name": detail.head_given_name,
- "family_name": detail.head_family_name,
- "birthdate": detail.head_birthdate,
- "phone": detail.head_phone,
- "is_registrant": True,
- "is_group": False,
- }
- if detail.head_gender_id:
- head_vals["gender_id"] = detail.head_gender_id.id
-
- head_individual = self.env["res.partner"].create(head_vals)
- head_individual.name_change()
- _logger.info(
- "Created head individual partner_id=%s for group partner_id=%s",
- head_individual.id,
- group.id,
- )
- elif detail.head_individual_id:
- head_individual = detail.head_individual_id
-
- # Create head membership if we have a head
- if head_individual:
- head_type = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
- membership_vals = {
- "group": group.id,
- "individual": head_individual.id,
- "start_date": fields.Datetime.now(),
- }
- if head_type:
- membership_vals["membership_type_ids"] = [Command.link(head_type.id)]
-
- self.env["spp.group.membership"].create(membership_vals)
- _logger.info(
- "Added head partner_id=%s to group partner_id=%s",
- head_individual.id,
- group.id,
- )
-
- # Store reference to created group
- detail.write({"created_group_id": group.id})
-
- # Update the change request's registrant_id to point to the new group
- change_request.write({"registrant_id": group.id})
-
return True
+ # ──────────────────────────────────────────────────────────────────────
+ # preview
+ # ──────────────────────────────────────────────────────────────────────
def preview(self, change_request):
- """Preview what will be created."""
detail = change_request.get_detail()
if not detail:
return {}
- head_name = None
- if detail.create_new_head:
- head_name = detail.head_name
- elif detail.head_individual_id:
- head_name = detail.head_individual_id.name
+ existing_heads, new_heads = detail._heads()
+ head_label = None
+ if existing_heads:
+ head_label = existing_heads[0].individual_id.name
+ elif new_heads:
+ head_label = new_heads[0].full_name
+
+ location = None
+ if detail.latitude or detail.longitude:
+ location = f"{detail.latitude}, {detail.longitude}"
+
+ # One2many lines are shown as separate tables on the review page (OP#876).
+ # preview() supplies them via the generic "_tables" contract:
+ # each entry is {title, columns, rows} with rows as lists of cell strings.
+ tables = []
+ phone_rows = [
+ [p.phone_no or "", p.country_id.display_name or "", _("Yes") if p.is_primary else ""]
+ for p in detail.phone_line_ids
+ ]
+ if phone_rows:
+ tables.append(
+ {"title": _("Phone Numbers"), "columns": [_("Number"), _("Country"), _("Primary")], "rows": phone_rows}
+ )
+ bank_rows = [
+ [b.acc_number or "", b.acc_holder_name or "", b.bank_id.display_name or ""] for b in detail.bank_line_ids
+ ]
+ if bank_rows:
+ tables.append(
+ {
+ "title": _("Bank Accounts"),
+ "columns": [_("Account Number"), _("Account Holder"), _("Bank")],
+ "rows": bank_rows,
+ }
+ )
+ id_doc_rows = [
+ [d.id_type_id.display_name or "", d.value or "", str(d.expiry_date) if d.expiry_date else ""]
+ for d in detail.id_doc_line_ids
+ ]
+ if id_doc_rows:
+ tables.append(
+ {
+ "title": _("ID Documents"),
+ "columns": [_("Type"), _("Number"), _("Expiry Date")],
+ "rows": id_doc_rows,
+ }
+ )
+
+ # Existing members: a simple Name + Role table.
+ existing_rows = [
+ [m.individual_id.name or "", m.membership_type_id.display or ""] for m in detail.member_existing_ids
+ ]
+ if existing_rows:
+ tables.append({"title": _("Existing Members"), "columns": [_("Name"), _("Role")], "rows": existing_rows})
+
+ # New members: one labelled detail block each (full individual record plus
+ # that member's own phone numbers), via the generic "_sections" contract.
+ sections = []
+ for m in detail.member_new_ids:
+ title = _("New member: %s") % (m.full_name or "")
+ if m.membership_type_id:
+ title = f"{title} ({m.membership_type_id.display})"
+ member_phone_rows = [
+ [p.phone_no or "", p.country_id.display_name or "", _("Yes") if p.is_primary else ""]
+ for p in m.phone_line_ids
+ ]
+ member_tables = []
+ if member_phone_rows:
+ member_tables.append(
+ {
+ "title": _("Phone Numbers"),
+ "columns": [_("Number"), _("Country"), _("Primary")],
+ "rows": member_phone_rows,
+ }
+ )
+ sections.append(
+ {
+ "title": title,
+ "fields": [
+ [_("Role"), m.membership_type_id.display or ""],
+ [_("Date of Birth"), str(m.birthdate) if m.birthdate else ""],
+ [_("Gender"), m.gender_id.display_name or ""],
+ [_("Civil Status"), m.civil_status_id.display_name or ""],
+ [_("Occupation"), m.occupation_id.display_name or ""],
+ [_("Birth Place"), m.birth_place or ""],
+ [_("Income"), str(m.income) if m.income else ""],
+ [_("Area"), m.area_id.display_name or ""],
+ [_("Address"), m.address or ""],
+ [_("Email"), m.email or ""],
+ ],
+ "tables": member_tables,
+ }
+ )
return {
"_action": "create_group",
+ "_header": _("The following group is to be added:"),
"group_name": detail.group_name,
"group_type": detail.group_type_id.display if detail.group_type_id else None,
- "head_of_household": head_name,
- "create_new_head": detail.create_new_head,
- "address": f"{detail.address_line1 or ''}, {detail.city or ''}".strip(", "),
+ "area": detail.area_id.display_name if detail.area_id else None,
+ "address": detail.address,
+ "email": detail.email,
+ "location": location,
+ "head_of_household": head_label,
+ "_tables": tables,
+ "_sections": sections,
+ }
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Validation
+ # ──────────────────────────────────────────────────────────────────────
+ def _validate(self, detail):
+ if not detail.group_name:
+ raise UserError(_("Group name is required."))
+
+ cr_type = detail.change_request_id.request_type_id
+
+ # Member-presence requirement.
+ has_members = bool(detail.member_existing_ids or detail.member_new_ids)
+ if not cr_type.allow_empty_members and not has_members:
+ raise UserError(
+ _(
+ "This Change Request type requires at least one member. "
+ "Add an existing individual or create a new one before applying."
+ )
+ )
+
+ # Head requirement.
+ if cr_type.requires_head:
+ head_count = detail._head_count()
+ if head_count == 0:
+ raise UserError(
+ _(
+ "This Change Request type requires a Head of Household. "
+ "Assign the 'Head' role to exactly one member before applying."
+ )
+ )
+ if head_count > 1:
+ # _check_at_most_one_head already catches this at write-time,
+ # but apply-time is the last line of defense.
+ raise UserError(_("A group can have at most one Head of Household."))
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Group creation
+ # ──────────────────────────────────────────────────────────────────────
+ def _create_group(self, detail):
+ # Pick the first explicitly-flagged primary phone, falling back to
+ # the first phone in the list, so the partner header carries
+ # something searchable.
+ primary_phone = False
+ if detail.phone_line_ids:
+ primary = detail.phone_line_ids.filtered(lambda p: p.is_primary)[:1]
+ chosen = primary or detail.phone_line_ids[:1]
+ primary_phone = chosen.phone_no
+
+ group_vals = {
+ "name": detail.group_name,
+ "is_registrant": True,
+ "is_group": True,
+ "address": detail.address,
+ "phone": primary_phone,
+ "email": detail.email,
+ }
+ if detail.group_type_id:
+ group_vals["group_type_id"] = detail.group_type_id.id
+ if detail.area_id and "area_id" in self.env["res.partner"]._fields:
+ group_vals["area_id"] = detail.area_id.id
+ return self.env["res.partner"].create(group_vals)
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Sub-record attachers
+ # ──────────────────────────────────────────────────────────────────────
+ def _attach_phones(self, phone_lines, partner):
+ """Create spp.phone.number records (the registry's Phone Numbers list)
+ on ``partner`` from the given phone rows."""
+ SppPhone = self.env["spp.phone.number"]
+ for line in phone_lines:
+ SppPhone.create(
+ {
+ "partner_id": partner.id,
+ "phone_no": line.phone_no,
+ "country_id": line.country_id.id if line.country_id else False,
+ "date_collected": fields.Date.today(),
+ }
+ )
+
+ def _attach_banks(self, detail, group):
+ Bank = self.env["res.partner.bank"]
+ for line in detail.bank_line_ids:
+ vals = {
+ "partner_id": group.id,
+ "acc_number": line.acc_number,
+ }
+ if line.acc_holder_name:
+ vals["acc_holder_name"] = line.acc_holder_name
+ if line.bank_id:
+ vals["bank_id"] = line.bank_id.id
+ Bank.create(vals)
+
+ def _attach_id_docs(self, detail, group):
+ RegId = self.env["spp.registry.id"]
+ for line in detail.id_doc_line_ids:
+ RegId.create(
+ {
+ "partner_id": group.id,
+ "id_type_id": line.id_type_id.id,
+ "value": line.value,
+ "expiry_date": line.expiry_date,
+ }
+ )
+
+ # ──────────────────────────────────────────────────────────────────────
+ # Members
+ # ──────────────────────────────────────────────────────────────────────
+ def _attach_members(self, detail, group):
+ Membership = self.env["spp.group.membership"]
+ Partner = self.env["res.partner"]
+ now = fields.Datetime.now()
+
+ for line in detail.member_existing_ids:
+ self._create_membership(Membership, group, line.individual_id, line.membership_type_id, now)
+
+ for line in detail.member_new_ids:
+ individual = Partner.create(self._new_member_vals(line))
+ # Some downstream modules format the partner's name on the fly.
+ if hasattr(individual, "name_change"):
+ individual.name_change()
+ # Create the individual's phone records (the registry's Phone
+ # Numbers list), the same way the group's phones are attached.
+ self._attach_phones(line.phone_line_ids, individual)
+ self._create_membership(Membership, group, individual, line.membership_type_id, now)
+
+ def _new_member_vals(self, line):
+ """Build res.partner vals for a new in-group individual from a member_new row.
+
+ Mirrors the registry's individual field set (OP#876 QA round 1). res.partner
+ has no native middle name, so the middle name is folded into the display name
+ only (full_name is "FAMILY, GIVEN MIDDLE").
+ """
+ full_name = line.full_name or " ".join(filter(None, [line.given_name, line.family_name]))
+ # res.partner has a single header phone; fold the captured numbers into
+ # it in entry order — there's no "primary" concept for a new member
+ # since they all land in the one field (OP#876 QA round 1).
+ phone = ", ".join(p.phone_no for p in line.phone_line_ids if p.phone_no)
+ vals = {
+ "name": full_name,
+ "given_name": line.given_name,
+ "family_name": line.family_name,
+ "birthdate": line.birthdate,
+ "birthdate_not_exact": line.is_approximate_birthdate,
+ "birth_place": line.birth_place,
+ "income": line.income,
+ "address": line.address,
+ "email": line.email,
+ "phone": phone,
+ "is_registrant": True,
+ "is_group": False,
+ }
+ if line.gender_id:
+ vals["gender_id"] = line.gender_id.id
+ if line.occupation_id:
+ vals["occupation_id"] = line.occupation_id.id
+ if line.civil_status_id:
+ vals["civil_status_id"] = line.civil_status_id.id
+ if line.area_id and "area_id" in self.env["res.partner"]._fields:
+ vals["area_id"] = line.area_id.id
+ return vals
+
+ def _create_membership(self, Membership, group, individual, membership_type, when):
+ vals = {
+ "group": group.id,
+ "individual": individual.id,
+ "start_date": when,
}
+ if membership_type:
+ vals["membership_type_ids"] = [Command.link(membership_type.id)]
+ Membership.create(vals)
diff --git a/spp_change_request_v2/tests/test_add_member_strategy.py b/spp_change_request_v2/tests/test_add_member_strategy.py
index d54db2acd..28990ef3e 100644
--- a/spp_change_request_v2/tests/test_add_member_strategy.py
+++ b/spp_change_request_v2/tests/test_add_member_strategy.py
@@ -1,6 +1,11 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-"""Tests for Add Member strategy."""
+"""Tests for the redesigned Add Member strategy (OP#871).
+Add Member now searches for an existing individual registrant and adds them to
+the group with a role (the create-a-new-individual flow was replaced).
+"""
+
+from odoo import Command
from odoo.exceptions import UserError
from odoo.tests import TransactionCase
@@ -14,137 +19,157 @@ class TestAddMemberStrategy(TransactionCase):
def setUpClass(cls):
super().setUpClass()
cls.partner_model = cls.env["res.partner"]
+ cls.membership_model = cls.env["spp.group.membership"]
cls.cr_model = cls.env["spp.change.request"]
- # Create test group
- cls.group = cls.partner_model.create(
- {
- "name": "Test Household",
- "is_registrant": True,
- "is_group": True,
- }
- )
+ cls.head_kind = cls.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
+ cls.member_kind = cls.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "member")
- # Create test individual (for non-group test)
- cls.individual = cls.partner_model.create(
- {
- "name": "Test Individual",
- "is_registrant": True,
- "is_group": False,
- }
+ cls.group = cls.partner_model.create({"name": "Test Household", "is_registrant": True, "is_group": True})
+ # An existing individual not yet in the group (the one we add).
+ cls.candidate = cls.partner_model.create({"name": "Maria Santos", "is_registrant": True, "is_group": False})
+ cls.lone_individual = cls.partner_model.create(
+ {"name": "Lone Individual", "is_registrant": True, "is_group": False}
)
- # Get or create CR type
cls.cr_type = get_or_create_cr_type(cls.env, "add_member")
+ cls.cr_type.write({"requires_head": False})
- def test_add_member_creates_individual(self):
- """Test adding member creates individual registrant."""
+ # ──────────────────────────────────────────────────────────────────
+ # Helpers
+ # ──────────────────────────────────────────────────────────────────
+ def _make_cr(self, registrant=None, **detail_vals):
cr = self.cr_model.create(
{
"request_type_id": self.cr_type.id,
- "registrant_id": self.group.id,
+ "registrant_id": (registrant or self.group).id,
}
)
+ if detail_vals:
+ cr.get_detail().write(detail_vals)
+ return cr
- # Fill detail
- detail = cr.get_detail()
- detail.write(
+ def _add_existing_head(self, group):
+ head = self.partner_model.create({"name": "Existing Head", "is_registrant": True, "is_group": False})
+ self.membership_model.create(
{
- "given_name": "Maria",
- "family_name": "Santos",
- "member_name": "Maria Santos",
+ "group": group.id,
+ "individual": head.id,
+ "membership_type_ids": [Command.link(self.head_kind.id)] if self.head_kind else [],
}
)
-
- # Approve and apply
+ return head
+
+ # ──────────────────────────────────────────────────────────────────
+ # Basic flow: add an existing individual
+ # ──────────────────────────────────────────────────────────────────
+ def test_add_existing_individual_creates_membership(self):
+ cr = self._make_cr(
+ individual_id=self.candidate.id,
+ membership_type_id=self.member_kind.id if self.member_kind else False,
+ )
cr.approval_state = "approved"
cr.action_apply()
- # Verify individual created
self.assertTrue(cr.is_applied)
- self.assertTrue(detail.created_individual_id)
-
- new_member = detail.created_individual_id
- self.assertEqual(new_member.given_name, "Maria")
- self.assertEqual(new_member.family_name, "Santos")
- self.assertTrue(new_member.is_registrant)
- self.assertFalse(new_member.is_group)
-
- def test_add_member_creates_membership(self):
- """Test membership link created."""
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.group.id,
- }
+ membership = self.membership_model.search(
+ [("group", "=", self.group.id), ("individual", "=", self.candidate.id)]
)
-
- detail = cr.get_detail()
- detail.write(
- {
- "given_name": "Juan",
- "family_name": "Cruz",
- "member_name": "Juan Cruz",
- }
- )
-
+ self.assertTrue(membership, "An active membership should be created for the selected individual")
+ self.assertEqual(membership.status, "active")
+ if self.member_kind:
+ self.assertIn(self.member_kind, membership.membership_type_ids)
+
+ def test_no_new_partner_is_created(self):
+ """The selected existing individual is reused — no new partner."""
+ before = self.partner_model.search_count([])
+ cr = self._make_cr(individual_id=self.candidate.id)
cr.approval_state = "approved"
cr.action_apply()
+ self.assertEqual(self.partner_model.search_count([]), before, "Add Member must not create a new partner")
- # Check membership exists
- new_member = detail.created_individual_id
- membership = self.env["spp.group.membership"].search(
- [
- ("group", "=", self.group.id),
- ("individual", "=", new_member.id),
- ]
- )
- self.assertTrue(membership, "Membership should be created")
-
- def test_add_member_to_individual_fails(self):
- """Test adding member to individual registrant fails."""
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.individual.id, # Individual, not group
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "given_name": "Test",
- "family_name": "Member",
- "member_name": "Test Member",
- }
- )
-
+ # ──────────────────────────────────────────────────────────────────
+ # Validation
+ # ──────────────────────────────────────────────────────────────────
+ def test_missing_individual_blocks_apply(self):
+ cr = self._make_cr() # no individual selected
cr.approval_state = "approved"
-
with self.assertRaises(UserError) as cm:
cr.action_apply()
+ self.assertIn("individual", str(cm.exception).lower())
+ def test_add_member_to_non_group_blocks(self):
+ cr = self._make_cr(registrant=self.lone_individual, individual_id=self.candidate.id)
+ cr.approval_state = "approved"
+ with self.assertRaises(UserError) as cm:
+ cr.action_apply()
self.assertIn("group", str(cm.exception).lower())
- def test_add_member_preview(self):
- """Test preview returns expected structure."""
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.group.id,
- }
- )
+ def test_already_member_blocks_apply(self):
+ # Seed the candidate as an existing active member.
+ self.membership_model.create({"group": self.group.id, "individual": self.candidate.id})
+ cr = self._make_cr(individual_id=self.candidate.id)
+ cr.approval_state = "approved"
+ with self.assertRaises(UserError) as cm:
+ cr.action_apply()
+ self.assertIn("already", str(cm.exception).lower())
- detail = cr.get_detail()
- detail.write(
- {
- "given_name": "Preview",
- "family_name": "Test",
- "member_name": "Preview Test",
- }
+ def test_requires_head_forces_role_choice(self):
+ self.cr_type.write({"requires_head": True})
+ cr = self._make_cr(individual_id=self.candidate.id) # no role
+ cr.approval_state = "approved"
+ with self.assertRaises(UserError) as cm:
+ cr.action_apply()
+ self.assertIn("role", str(cm.exception).lower())
+ self.cr_type.write({"requires_head": False})
+
+ # ──────────────────────────────────────────────────────────────────
+ # Picker domain + role restriction
+ # ──────────────────────────────────────────────────────────────────
+ def test_individual_domain_excludes_active_members(self):
+ """The picker domain excludes individuals already in the group."""
+ self.membership_model.create({"group": self.group.id, "individual": self.candidate.id})
+ detail = self._make_cr().get_detail()
+ domain = detail.individual_domain
+ self.assertIn("not in", domain)
+ # The already-member candidate id is in the excluded list.
+ self.assertIn(str(self.candidate.id), domain)
+
+ def test_allowed_roles_excludes_head_when_group_has_head(self):
+ """The Head role is removed from the options when the group already has
+ an active Head of Household; otherwise all roles are allowed (OP#871)."""
+ no_head_codes = set(self._make_cr().get_detail().allowed_role_ids.mapped("code"))
+ if self.head_kind:
+ self.assertIn("head", no_head_codes)
+
+ group_with_head = self.partner_model.create(
+ {"name": "Group With Head", "is_registrant": True, "is_group": True}
+ )
+ self._add_existing_head(group_with_head)
+ with_head_codes = set(self._make_cr(registrant=group_with_head).get_detail().allowed_role_ids.mapped("code"))
+ self.assertNotIn("head", with_head_codes)
+ self.assertEqual(with_head_codes, no_head_codes - {"head"})
+
+ # ──────────────────────────────────────────────────────────────────
+ # Preview / review page
+ # ──────────────────────────────────────────────────────────────────
+ def test_preview_returns_add_member_action_and_header(self):
+ cr = self._make_cr(
+ individual_id=self.candidate.id,
+ membership_type_id=self.member_kind.id if self.member_kind else False,
)
-
preview = cr.action_preview_changes()
-
- self.assertIn("_action", preview)
- self.assertEqual(preview["_action"], "create_member")
+ self.assertEqual(preview["_action"], "add_member")
+ self.assertIn("added to the group", (preview.get("_header") or "").lower())
+ self.assertEqual(preview["Name"], self.candidate.display_name)
+ if self.member_kind:
+ self.assertEqual(preview["Role"], self.member_kind.display)
+ # Fields are present even when the individual has no value (render as "-").
+ self.assertIn("Email", preview)
+ self.assertIn("Date of Birth", preview)
+
+ def test_review_html_names_the_individual(self):
+ cr = self._make_cr(individual_id=self.candidate.id)
+ html = cr._generate_review_comparison_html()
+ self.assertIn("Maria Santos", html)
+ self.assertIn("added to the group", html.lower())
diff --git a/spp_change_request_v2/tests/test_change_hoh_strategy.py b/spp_change_request_v2/tests/test_change_hoh_strategy.py
index d5ccf004d..e64ed2ed8 100644
--- a/spp_change_request_v2/tests/test_change_hoh_strategy.py
+++ b/spp_change_request_v2/tests/test_change_hoh_strategy.py
@@ -1,8 +1,13 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-"""Tests for Change Head of Household strategy."""
+"""Tests for the redesigned Change Head of Household strategy (OP#873).
+
+The CR now seeds one editable role line per active group member; the member
+assigned the Head role becomes the new head. Required documents can be driven
+by the chosen reason.
+"""
from odoo import Command, fields
-from odoo.exceptions import UserError
+from odoo.exceptions import UserError, ValidationError
from odoo.tests import TransactionCase
from .common import get_or_create_cr_type, get_or_create_membership_kind
@@ -17,39 +22,15 @@ def setUpClass(cls):
cls.partner_model = cls.env["res.partner"]
cls.membership_model = cls.env["spp.group.membership"]
cls.cr_model = cls.env["spp.change.request"]
+ cls.code_model = cls.env["spp.vocabulary.code"]
- # Get head membership kind from vocabulary
- cls.head_kind = cls.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
-
- # Get or create spouse kind from vocabulary
+ cls.head_kind = cls.code_model.get_code("urn:openspp:vocab:group-membership-type", "head")
cls.spouse_kind = get_or_create_membership_kind(cls.env, "spouse")
- # Create test group
- cls.group = cls.partner_model.create(
- {
- "name": "Test Household",
- "is_registrant": True,
- "is_group": True,
- }
- )
+ cls.group = cls.partner_model.create({"name": "Test Household", "is_registrant": True, "is_group": True})
+ cls.current_head = cls.partner_model.create({"name": "Current Head", "is_registrant": True, "is_group": False})
+ cls.member = cls.partner_model.create({"name": "Member Two", "is_registrant": True, "is_group": False})
- # Create test individuals
- cls.current_head = cls.partner_model.create(
- {
- "name": "Current Head",
- "is_registrant": True,
- "is_group": False,
- }
- )
- cls.new_head = cls.partner_model.create(
- {
- "name": "New Head",
- "is_registrant": True,
- "is_group": False,
- }
- )
-
- # Create memberships
cls.head_membership = cls.membership_model.create(
{
"group": cls.group.id,
@@ -61,198 +42,164 @@ def setUpClass(cls):
cls.member_membership = cls.membership_model.create(
{
"group": cls.group.id,
- "individual": cls.new_head.id,
+ "individual": cls.member.id,
"start_date": fields.Datetime.now(),
+ "membership_type_ids": [Command.link(cls.spouse_kind.id)],
}
)
- # Get or create CR type
cls.cr_type = get_or_create_cr_type(cls.env, "change_hoh")
- def test_change_hoh_transfers_role(self):
- """Test changing HOH transfers head role."""
-
- cr = self.cr_model.create(
+ # ──────────────────────────────────────────────────────────────────
+ # Helpers
+ # ──────────────────────────────────────────────────────────────────
+ def _make_cr(self, registrant=None):
+ return self.cr_model.create(
{
"request_type_id": self.cr_type.id,
- "registrant_id": self.group.id,
+ "registrant_id": (registrant or self.group).id,
}
)
- detail = cr.get_detail()
- detail.write(
- {
- "new_head_membership_id": self.member_membership.id,
- "reason": "voluntary",
- "effective_date": fields.Date.today(),
- }
- )
+ def _line_for(self, detail, individual):
+ return detail.member_line_ids.filtered(lambda r: r.individual_id == individual)
+
+ # ──────────────────────────────────────────────────────────────────
+ # Seeding
+ # ──────────────────────────────────────────────────────────────────
+ def test_member_lines_seeded_from_active_members(self):
+ """One role line is seeded per active member, defaulting each member's
+ new role to their current role."""
+ detail = self._make_cr().get_detail()
+ self.assertEqual(len(detail.member_line_ids), 2)
+ self.assertEqual(
+ set(detail.member_line_ids.mapped("individual_id")),
+ {self.current_head, self.member},
+ )
+ if self.head_kind:
+ head_line = self._line_for(detail, self.current_head)
+ self.assertEqual(head_line.new_role_id, self.head_kind)
+ self.assertEqual(head_line.old_role_display, self.head_kind.display)
+ member_line = self._line_for(detail, self.member)
+ self.assertEqual(member_line.new_role_id, self.spouse_kind)
+ def test_change_hoh_on_individual_fails(self):
+ cr = self._make_cr(registrant=self.current_head) # an individual, not a group
cr.approval_state = "approved"
- cr.action_apply()
-
- # Verify role transferred
- self.assertTrue(cr.is_applied)
- self.assertIn(
- self.head_kind,
- self.member_membership.membership_type_ids,
- "New head should have head role",
- )
- self.assertNotIn(
- self.head_kind,
- self.head_membership.membership_type_ids,
- "Old head should not have head role",
- )
-
- def test_change_hoh_assigns_new_role_to_previous(self):
- """Test previous head gets new role assigned."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.group.id,
- }
- )
+ with self.assertRaises(UserError) as cm:
+ cr.action_apply()
+ self.assertIn("group", str(cm.exception).lower())
+ # ──────────────────────────────────────────────────────────────────
+ # Apply
+ # ──────────────────────────────────────────────────────────────────
+ def test_apply_transfers_head(self):
+ if not self.head_kind:
+ self.skipTest("head membership-type code not present in the vocabulary")
+ cr = self._make_cr()
detail = cr.get_detail()
- detail.write(
- {
- "new_head_membership_id": self.member_membership.id,
- "previous_head_new_role_id": self.spouse_kind.id,
- "reason": "voluntary",
- "effective_date": fields.Date.today(),
- }
- )
+ # Step down the current head first to avoid a transient two-heads state,
+ # then promote the other member.
+ self._line_for(detail, self.current_head).new_role_id = self.spouse_kind
+ self._line_for(detail, self.member).new_role_id = self.head_kind
cr.approval_state = "approved"
cr.action_apply()
- # Verify previous head has new role
- self.assertIn(
- self.spouse_kind,
- self.head_membership.membership_type_ids,
- "Previous head should have spouse role",
- )
-
- def test_change_hoh_on_individual_fails(self):
- """Test cannot change HOH on individual registrant."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.current_head.id, # Individual, not group
- }
- )
-
+ self.assertTrue(cr.is_applied)
+ self.member_membership.invalidate_recordset()
+ self.head_membership.invalidate_recordset()
+ self.assertIn(self.head_kind, self.member_membership.membership_type_ids)
+ self.assertNotIn(self.head_kind, self.head_membership.membership_type_ids)
+ self.assertIn(self.spouse_kind, self.head_membership.membership_type_ids)
+
+ def test_apply_requires_a_head(self):
+ if not self.head_kind:
+ self.skipTest("head membership-type code not present in the vocabulary")
+ cr = self._make_cr()
detail = cr.get_detail()
- detail.write(
- {
- "new_head_membership_id": self.member_membership.id,
- "reason": "other",
- "effective_date": fields.Date.today(),
- }
- )
-
+ # Nobody assigned the head role.
+ detail.member_line_ids.write({"new_role_id": self.spouse_kind.id})
cr.approval_state = "approved"
-
with self.assertRaises(UserError) as cm:
cr.action_apply()
-
- self.assertIn("group", str(cm.exception).lower())
-
- def test_change_hoh_all_reasons(self):
- """Test all change reasons work."""
-
- reasons = [
- "deceased",
- "incapacitated",
- "left_household",
- "age_change",
- "voluntary",
- "correction",
- "other",
- ]
-
- for _i, reason in enumerate(reasons):
- # Create new group and members for each test
- group = self.partner_model.create(
- {
- "name": f"Group {reason}",
- "is_registrant": True,
- "is_group": True,
- }
- )
- member1 = self.partner_model.create(
- {
- "name": f"Member1 {reason}",
- "is_registrant": True,
- "is_group": False,
- }
- )
- member2 = self.partner_model.create(
- {
- "name": f"Member2 {reason}",
- "is_registrant": True,
- "is_group": False,
- }
- )
- self.membership_model.create(
- {
- "group": group.id,
- "individual": member1.id,
- "start_date": fields.Datetime.now(),
- "membership_type_ids": [Command.link(self.head_kind.id)] if self.head_kind else [],
- }
- )
- membership2 = self.membership_model.create(
- {
- "group": group.id,
- "individual": member2.id,
- "start_date": fields.Datetime.now(),
- }
- )
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": group.id,
- }
- )
-
+ self.assertIn("head", str(cm.exception).lower())
+
+ def test_two_heads_blocked_by_constraint(self):
+ if not self.head_kind:
+ self.skipTest("head membership-type code not present in the vocabulary")
+ detail = self._make_cr().get_detail()
+ # current_head's line is already Head; promoting the member too -> 2 heads.
+ with self.assertRaises(ValidationError):
+ self._line_for(detail, self.member).new_role_id = self.head_kind
+
+ def test_all_reasons_apply(self):
+ if not self.head_kind:
+ self.skipTest("head membership-type code not present in the vocabulary")
+ for reason in ("deceased", "incapacitated", "left_household", "age_change", "correction", "other"):
+ cr = self._make_cr()
detail = cr.get_detail()
- detail.write(
- {
- "new_head_membership_id": membership2.id,
- "reason": reason,
- "effective_date": fields.Date.today(),
- }
- )
-
+ detail.reason = reason
+ self._line_for(detail, self.current_head).new_role_id = self.spouse_kind
+ self._line_for(detail, self.member).new_role_id = self.head_kind
cr.approval_state = "approved"
cr.action_apply()
-
self.assertTrue(cr.is_applied, f"Failed for reason: {reason}")
- def test_change_hoh_preview(self):
- """Test preview returns expected structure."""
+ # ──────────────────────────────────────────────────────────────────
+ # Preview / review
+ # ──────────────────────────────────────────────────────────────────
+ def test_preview_structure_and_members_table(self):
+ cr = self._make_cr()
+ detail = cr.get_detail()
+ detail.reason = "left_household"
+ detail.remarks = "Head relocating abroad"
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.group.id,
- }
+ preview = cr.action_preview_changes()
+ self.assertEqual(preview["_action"], "change_head_of_household")
+ self.assertEqual(preview["Household"], self.group.display_name)
+ self.assertEqual(preview["Reason for Change"], "Head Left Household")
+ self.assertEqual(preview["Remarks"], "Head relocating abroad")
+
+ members_tbl = next(t for t in preview["_tables"] if t["title"] == "Members")
+ self.assertEqual(members_tbl["columns"], ["Name", "Current Role", "New Role"])
+ self.assertEqual(len(members_tbl["rows"]), 2)
+
+ html = cr._generate_review_comparison_html()
+ self.assertIn(self.current_head.display_name, html)
+ self.assertIn("New Role", html)
+
+ # ──────────────────────────────────────────────────────────────────
+ # Item 5: reason-driven required documents
+ # ──────────────────────────────────────────────────────────────────
+ def test_reason_driven_required_documents(self):
+ doc_type = self.code_model.search(
+ [("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:cr_document_type")], limit=1
)
+ if not doc_type:
+ self.skipTest("no cr_document_type vocabulary codes present")
- detail = cr.get_detail()
- detail.write(
+ self.cr_type.write(
{
- "new_head_membership_id": self.member_membership.id,
- "reason": "voluntary",
- "effective_date": fields.Date.today(),
+ "reason_document_ids": [
+ (5, 0, 0),
+ (0, 0, {"reason": "deceased", "required_document_ids": [Command.set(doc_type.ids)]}),
+ ],
}
)
+ cr = self._make_cr()
+ detail = cr.get_detail()
- preview = cr.action_preview_changes()
+ # No reason chosen yet -> falls back to the (empty) flat list -> complete.
+ self.assertTrue(cr.documents_complete)
- self.assertIn("_action", preview)
- self.assertEqual(preview["_action"], "change_head_of_household")
+ # Reason with a configured rule -> that rule's documents are required.
+ detail.reason = "deceased"
+ cr.invalidate_recordset(["documents_complete", "missing_required_document_ids"])
+ self.assertIn(doc_type, cr.missing_required_document_ids)
+ self.assertFalse(cr.documents_complete)
+
+ # A reason with no rule -> nothing required.
+ detail.reason = "other"
+ cr.invalidate_recordset(["documents_complete", "missing_required_document_ids"])
+ self.assertTrue(cr.documents_complete)
diff --git a/spp_change_request_v2/tests/test_create_group_strategy.py b/spp_change_request_v2/tests/test_create_group_strategy.py
index c07fc7a12..b410c450a 100644
--- a/spp_change_request_v2/tests/test_create_group_strategy.py
+++ b/spp_change_request_v2/tests/test_create_group_strategy.py
@@ -1,5 +1,5 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-"""Tests for Create Group strategy."""
+"""Tests for the redesigned Create Group strategy (OP#876)."""
from odoo.exceptions import UserError
from odoo.tests import TransactionCase
@@ -17,13 +17,10 @@ def setUpClass(cls):
cls.membership_model = cls.env["spp.group.membership"]
cls.cr_model = cls.env["spp.change.request"]
- # Get head membership kind from vocabulary
cls.head_kind = cls.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
-
- # Get group type from vocabulary
+ cls.member_kind = cls.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "member")
cls.group_kind = cls.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-type", "household")
- # Create existing individual for head
cls.existing_head = cls.partner_model.create(
{
"name": "Existing Head",
@@ -32,7 +29,9 @@ def setUpClass(cls):
}
)
- # Get CR type - use a dummy registrant since create_group creates the actual group
+ # Placeholder registrant — required by the base CR model even though
+ # the apply strategy replaces registrant_id with the newly-created
+ # group at the end of apply.
cls.dummy_group = cls.partner_model.create(
{
"name": "Placeholder",
@@ -42,202 +41,647 @@ def setUpClass(cls):
)
cls.cr_type = get_or_create_cr_type(cls.env, "create_group")
+ # Default: groups don't have to be empty, head not required, members allowed.
+ cls.cr_type.write({"allow_empty_members": True, "requires_head": False})
- def test_create_group_basic(self):
- """Test creating new group."""
-
+ # ──────────────────────────────────────────────────────────────────
+ # Helpers
+ # ──────────────────────────────────────────────────────────────────
+ def _make_cr(self, **detail_vals):
cr = self.cr_model.create(
{
"request_type_id": self.cr_type.id,
- "registrant_id": self.dummy_group.id, # Placeholder
+ "registrant_id": self.dummy_group.id,
}
)
-
- detail = cr.get_detail()
- detail.write(
- {
- "group_name": "New Household",
- "group_type_id": self.group_kind.id,
- "address_line1": "123 Main St",
- "city": "Manila",
- "phone": "+63912345678",
- }
+ cr.get_detail().write(detail_vals)
+ return cr
+
+ # ──────────────────────────────────────────────────────────────────
+ # Basic create — empty group allowed by config
+ # ──────────────────────────────────────────────────────────────────
+ def test_create_group_basic_no_members(self):
+ self.cr_type.write({"allow_empty_members": True, "requires_head": False})
+ cr = self._make_cr(
+ group_name="New Household",
+ group_type_id=self.group_kind.id if self.group_kind else False,
+ address="123 Main St, Manila",
)
cr.approval_state = "approved"
cr.action_apply()
- # Verify group created
self.assertTrue(cr.is_applied)
+ detail = cr.get_detail()
self.assertTrue(detail.created_group_id)
-
new_group = detail.created_group_id
self.assertEqual(new_group.name, "New Household")
self.assertTrue(new_group.is_registrant)
self.assertTrue(new_group.is_group)
- self.assertEqual(new_group.street, "123 Main St")
- self.assertEqual(new_group.city, "Manila")
+ self.assertEqual(new_group.address, "123 Main St, Manila")
+ # ──────────────────────────────────────────────────────────────────
+ # Existing member becomes the head
+ # ──────────────────────────────────────────────────────────────────
def test_create_group_with_existing_head(self):
- """Test creating group with existing individual as head."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.dummy_group.id,
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "group_name": "Group with Head",
- "head_individual_id": self.existing_head.id,
- "create_new_head": False,
- }
+ cr = self._make_cr(
+ group_name="Group with Head",
+ member_existing_ids=[
+ (
+ 0,
+ 0,
+ {
+ "individual_id": self.existing_head.id,
+ "membership_type_id": self.head_kind.id if self.head_kind else False,
+ },
+ ),
+ ],
)
cr.approval_state = "approved"
cr.action_apply()
- # Verify group and membership
self.assertTrue(cr.is_applied)
- new_group = detail.created_group_id
-
+ new_group = cr.get_detail().created_group_id
membership = self.membership_model.search(
- [
- ("group", "=", new_group.id),
- ("individual", "=", self.existing_head.id),
- ]
+ [("group", "=", new_group.id), ("individual", "=", self.existing_head.id)]
)
self.assertTrue(membership, "Head should be member of new group")
-
if self.head_kind:
- self.assertIn(
- self.head_kind,
- membership.membership_type_ids,
- "Head should have head role",
- )
+ self.assertIn(self.head_kind, membership.membership_type_ids)
+ # ──────────────────────────────────────────────────────────────────
+ # New individual is created and attached as head
+ # ──────────────────────────────────────────────────────────────────
def test_create_group_with_new_head(self):
- """Test creating group with new individual as head."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.dummy_group.id,
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "group_name": "Group with New Head",
- "create_new_head": True,
- "head_given_name": "Juan",
- "head_family_name": "Dela Cruz",
- "head_name": "Juan Dela Cruz",
- "head_phone": "+63987654321",
- }
+ cr = self._make_cr(
+ group_name="Group with New Head",
+ member_new_ids=[
+ (
+ 0,
+ 0,
+ {
+ "given_name": "Juan",
+ "family_name": "Dela Cruz",
+ "phone_line_ids": [(0, 0, {"phone_no": "+63987654321"})],
+ "membership_type_id": self.head_kind.id if self.head_kind else False,
+ },
+ ),
+ ],
)
cr.approval_state = "approved"
cr.action_apply()
- # Verify group and new individual
self.assertTrue(cr.is_applied)
- new_group = detail.created_group_id
-
- # Find the new head
- membership = self.membership_model.search(
- [
- ("group", "=", new_group.id),
- ("status", "=", "active"),
- ]
- )
+ new_group = cr.get_detail().created_group_id
+ membership = self.membership_model.search([("group", "=", new_group.id), ("status", "=", "active")])
self.assertTrue(membership)
-
new_head = membership.individual
- self.assertEqual(new_head.name, "DELA CRUZ, JUAN")
self.assertEqual(new_head.given_name, "Juan")
self.assertEqual(new_head.family_name, "Dela Cruz")
self.assertTrue(new_head.is_registrant)
self.assertFalse(new_head.is_group)
- def test_create_group_without_name_fails(self):
- """Test creating group without name fails."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.dummy_group.id,
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- # No group_name
- "city": "Manila",
- }
+ # ──────────────────────────────────────────────────────────────────
+ # Multiple members of mixed origin all attached
+ # ──────────────────────────────────────────────────────────────────
+ def test_create_group_mixed_members(self):
+ spouse = self.partner_model.create({"name": "Existing Spouse", "is_registrant": True, "is_group": False})
+
+ cr = self._make_cr(
+ group_name="Mixed-membership Group",
+ member_existing_ids=[
+ (
+ 0,
+ 0,
+ {
+ "individual_id": self.existing_head.id,
+ "membership_type_id": self.head_kind.id if self.head_kind else False,
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "individual_id": spouse.id,
+ "membership_type_id": self.member_kind.id if self.member_kind else False,
+ },
+ ),
+ ],
+ member_new_ids=[
+ (
+ 0,
+ 0,
+ {
+ "given_name": "Anak",
+ "family_name": "Dela Cruz",
+ "membership_type_id": self.member_kind.id if self.member_kind else False,
+ },
+ ),
+ ],
)
cr.approval_state = "approved"
+ cr.action_apply()
+
+ new_group = cr.get_detail().created_group_id
+ memberships = self.membership_model.search([("group", "=", new_group.id)])
+ self.assertEqual(len(memberships), 3, "all 3 members should be attached")
+ # ──────────────────────────────────────────────────────────────────
+ # Validation: group name still required
+ # ──────────────────────────────────────────────────────────────────
+ def test_create_group_without_name_fails(self):
+ cr = self._make_cr(address="Manila")
+ cr.approval_state = "approved"
with self.assertRaises(UserError) as cm:
cr.action_apply()
-
self.assertIn("name", str(cm.exception).lower())
- def test_create_group_new_head_requires_name(self):
- """Test creating new head requires name."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.dummy_group.id,
- }
+ # ──────────────────────────────────────────────────────────────────
+ # Validation: allow_empty_members=False forces at least one member
+ # ──────────────────────────────────────────────────────────────────
+ def test_apply_blocks_when_members_required_but_absent(self):
+ self.cr_type.write({"allow_empty_members": False, "requires_head": False})
+ cr = self._make_cr(group_name="Members Required Group")
+ cr.approval_state = "approved"
+ with self.assertRaises(UserError) as cm:
+ cr.action_apply()
+ self.assertIn("member", str(cm.exception).lower())
+
+ # ──────────────────────────────────────────────────────────────────
+ # Validation: requires_head=True forces exactly one Head
+ # ──────────────────────────────────────────────────────────────────
+ def test_apply_blocks_when_head_required_but_absent(self):
+ self.cr_type.write({"allow_empty_members": True, "requires_head": True})
+ cr = self._make_cr(
+ group_name="Headless Group",
+ member_new_ids=[
+ (
+ 0,
+ 0,
+ {
+ "given_name": "Member",
+ "family_name": "Only",
+ "membership_type_id": self.member_kind.id if self.member_kind else False,
+ },
+ ),
+ ],
)
+ cr.approval_state = "approved"
+ with self.assertRaises(UserError) as cm:
+ cr.action_apply()
+ self.assertIn("head", str(cm.exception).lower())
+
+ # ──────────────────────────────────────────────────────────────────
+ # Validation: at most one Head — caught at write-time on the detail
+ # ──────────────────────────────────────────────────────────────────
+ def test_two_heads_is_rejected(self):
+ if not self.head_kind:
+ self.skipTest("head membership-type code missing in vocabulary")
+ from odoo.exceptions import ValidationError
+ cr = self._make_cr(group_name="Two-Head Group")
detail = cr.get_detail()
- detail.write(
- {
- "group_name": "Group Without Head Name",
- "create_new_head": True,
- # No head_name
- }
- )
+ with self.assertRaises(ValidationError):
+ detail.write(
+ {
+ "member_existing_ids": [
+ (0, 0, {"individual_id": self.existing_head.id, "membership_type_id": self.head_kind.id}),
+ ],
+ "member_new_ids": [
+ (
+ 0,
+ 0,
+ {
+ "given_name": "Another",
+ "family_name": "Head",
+ "membership_type_id": self.head_kind.id,
+ },
+ ),
+ ],
+ }
+ )
+ # ──────────────────────────────────────────────────────────────────
+ # Multi-value sub-records: phones, banks, ID docs
+ # ──────────────────────────────────────────────────────────────────
+ def test_phones_banks_id_docs_attach_to_created_group(self):
+ id_type = self.env["spp.vocabulary.code"].search(
+ [("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:id-type")], limit=1
+ )
+ cr = self._make_cr(
+ group_name="Fully-loaded Group",
+ phone_line_ids=[
+ (0, 0, {"phone_no": "+63912345678", "is_primary": True}),
+ (0, 0, {"phone_no": "+63923456789", "is_primary": False}),
+ ],
+ bank_line_ids=[
+ (0, 0, {"acc_number": "PH00 ACME 1234 5678", "acc_holder_name": "Group Account"}),
+ ],
+ id_doc_line_ids=(
+ [(0, 0, {"id_type_id": id_type.id, "value": "X-12345", "expiry_date": "2030-01-01"})] if id_type else []
+ ),
+ )
cr.approval_state = "approved"
+ cr.action_apply()
- with self.assertRaises(UserError) as cm:
- cr.action_apply()
+ new_group = cr.get_detail().created_group_id
+
+ phones = self.env["spp.phone.number"].search([("partner_id", "=", new_group.id)])
+ self.assertEqual(len(phones), 2)
+
+ banks = self.env["res.partner.bank"].search([("partner_id", "=", new_group.id)])
+ self.assertEqual(len(banks), 1)
+ self.assertEqual(banks.acc_number, "PH00 ACME 1234 5678")
+
+ # Group header phone should match the primary entry.
+ self.assertEqual(new_group.phone, "+63912345678")
+
+ if id_type:
+ ids = self.env["spp.registry.id"].search([("partner_id", "=", new_group.id)])
+ self.assertEqual(len(ids), 1)
+ self.assertEqual(ids.value, "X-12345")
+
+ # ──────────────────────────────────────────────────────────────────
+ # preview returns counts + head label
+ # ──────────────────────────────────────────────────────────────────
+ def test_preview(self):
+ cr = self._make_cr(
+ group_name="Preview Group",
+ group_type_id=self.group_kind.id if self.group_kind else False,
+ member_new_ids=[
+ (
+ 0,
+ 0,
+ {
+ "given_name": "Head",
+ "family_name": "Person",
+ "membership_type_id": self.head_kind.id if self.head_kind else False,
+ },
+ ),
+ ],
+ bank_line_ids=[(0, 0, {"acc_number": "12-34-56"})],
+ )
+ preview = cr.action_preview_changes()
+ self.assertEqual(preview["_action"], "create_group")
+ self.assertEqual(preview["group_name"], "Preview Group")
+ # The new member is now a "_sections" detail block, not a count (OP#876).
+ self.assertNotIn("new_member_count", preview)
+ self.assertEqual(len(preview["_sections"]), 1)
+ # The bank line is now surfaced as a "_tables" entry, not a count (OP#876).
+ self.assertNotIn("bank_count", preview)
+ bank_tables = [t for t in preview["_tables"] if t["title"] == "Bank Accounts"]
+ self.assertEqual(len(bank_tables), 1)
+ self.assertEqual(len(bank_tables[0]["rows"]), 1)
+ self.assertIn("12-34-56", bank_tables[0]["rows"][0])
+ if self.head_kind:
+ self.assertEqual(preview["head_of_household"], "PERSON, Head")
- self.assertIn("head", str(cm.exception).lower())
+ def test_preview_one2many_as_tables_and_scalars(self):
+ """OP#876: phones / banks / ID docs are surfaced as `_tables` (actual
+ data, not counts), and the previously-missing scalar fields are added."""
+ id_type = self.env["spp.vocabulary.code"].search(
+ [("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:id-type")], limit=1
+ )
+ cr = self._make_cr(
+ group_name="Tables Group",
+ address="12 Rizal St",
+ email="group@example.com",
+ phone_line_ids=[
+ (0, 0, {"phone_no": "+63911111111", "is_primary": True}),
+ (0, 0, {"phone_no": "+63922222222"}),
+ ],
+ bank_line_ids=[(0, 0, {"acc_number": "ACC-1", "acc_holder_name": "Jane"})],
+ id_doc_line_ids=([(0, 0, {"id_type_id": id_type.id, "value": "ID-9"})] if id_type else []),
+ )
+ preview = cr.action_preview_changes()
- def test_create_group_preview(self):
- """Test preview returns expected structure."""
+ # Previously-missing scalar fields are now surfaced.
+ self.assertEqual(preview["email"], "group@example.com")
+ self.assertEqual(preview["address"], "12 Rizal St")
+ self.assertIn("area", preview)
+
+ # Counts are replaced by the generic `_tables` contract.
+ for removed in ("phone_count", "bank_count", "id_doc_count"):
+ self.assertNotIn(removed, preview)
+
+ titles = [t["title"] for t in preview["_tables"]]
+ self.assertIn("Phone Numbers", titles)
+ self.assertIn("Bank Accounts", titles)
+ phones = next(t for t in preview["_tables"] if t["title"] == "Phone Numbers")
+ self.assertEqual(phones["columns"], ["Number", "Country", "Primary"])
+ self.assertEqual(len(phones["rows"]), 2)
+ self.assertIn("+63911111111", phones["rows"][0])
+ if id_type:
+ self.assertIn("ID Documents", titles)
+
+ def test_preview_members_table_and_sections(self):
+ """OP#876: existing members render as a Name/Role table; new members
+ render as per-member detail sections (with their own phones)."""
+ existing = self.partner_model.create({"name": "Existing Member A", "is_registrant": True, "is_group": False})
+ cr = self._make_cr(
+ group_name="Members Group",
+ member_existing_ids=[
+ (
+ 0,
+ 0,
+ {
+ "individual_id": existing.id,
+ "membership_type_id": self.member_kind.id if self.member_kind else False,
+ },
+ )
+ ],
+ member_new_ids=[
+ (
+ 0,
+ 0,
+ {
+ "given_name": "Nina",
+ "family_name": "Cruz",
+ "birthdate": "2000-05-05",
+ "membership_type_id": self.head_kind.id if self.head_kind else False,
+ "phone_line_ids": [(0, 0, {"phone_no": "+63999999999", "is_primary": True})],
+ },
+ )
+ ],
+ )
+ preview = cr.action_preview_changes()
- cr = self.cr_model.create(
+ # Counts are gone.
+ self.assertNotIn("existing_member_count", preview)
+ self.assertNotIn("new_member_count", preview)
+
+ # Existing members -> a Name/Role table.
+ existing_tbl = [t for t in preview["_tables"] if t["title"] == "Existing Members"]
+ self.assertEqual(len(existing_tbl), 1)
+ self.assertIn("Existing Member A", existing_tbl[0]["rows"][0])
+
+ # New members -> a per-member detail section with fields + own phones.
+ self.assertEqual(len(preview["_sections"]), 1)
+ sec = preview["_sections"][0]
+ self.assertIn("Nina", sec["title"])
+ labels = [f[0] for f in sec["fields"]]
+ self.assertIn("Date of Birth", labels)
+ self.assertIn("Gender", labels)
+ self.assertTrue(any(t["title"] == "Phone Numbers" for t in sec["tables"]))
+ self.assertIn("+63999999999", sec["tables"][0]["rows"][0])
+
+ # The rendered review HTML surfaces the member data.
+ html = cr._generate_review_comparison_html()
+ self.assertIn("Existing Members", html)
+ self.assertIn("Existing Member A", html)
+ self.assertIn("Nina", html)
+ self.assertIn("+63999999999", html)
+
+ def test_review_comparison_html_renders_tables(self):
+ """The review page HTML shows the actual phone / bank rows as tables,
+ not a bare count (OP#876)."""
+ cr = self._make_cr(
+ group_name="HTML Group",
+ email="g@example.com",
+ phone_line_ids=[(0, 0, {"phone_no": "+63900000000", "is_primary": True})],
+ bank_line_ids=[(0, 0, {"acc_number": "BANK-777"})],
+ )
+ html = cr._generate_review_comparison_html()
+ self.assertIn("Phone Numbers", html)
+ self.assertIn("+63900000000", html)
+ self.assertIn("Bank Accounts", html)
+ self.assertIn("BANK-777", html)
+ self.assertIn("g@example.com", html)
+ # Raw count keys must not leak into the review output.
+ self.assertNotIn("phone_count", html)
+ self.assertNotIn("bank_count", html)
+
+ # ──────────────────────────────────────────────────────────────────
+ # Wizard flow (OP#876 round 2): Add Member wizard, both modes
+ # ──────────────────────────────────────────────────────────────────
+ def _make_wizard(self, detail, mode, **extra):
+ Wizard = self.env["spp.cr.detail.create_group.member.wizard"]
+ return Wizard.create({"detail_id": detail.id, "mode": mode, **extra})
+
+ def test_wizard_add_existing_close_creates_row(self):
+ cr = self._make_cr(group_name="Wizard Existing Group")
+ detail = cr.get_detail()
+ wiz = self._make_wizard(
+ detail,
+ "existing",
+ individual_id=self.existing_head.id,
+ membership_type_id=self.head_kind.id if self.head_kind else False,
+ )
+ action = wiz.action_add_close()
+ self.assertEqual(action["type"], "ir.actions.act_window_close")
+ self.assertEqual(len(detail.member_existing_ids), 1)
+ self.assertEqual(detail.member_existing_ids.individual_id, self.existing_head)
+
+ def test_wizard_add_existing_keeps_window_open(self):
+ cr = self._make_cr(group_name="Wizard Add-More Group")
+ detail = cr.get_detail()
+ wiz = self._make_wizard(detail, "existing", individual_id=self.existing_head.id)
+ action = wiz.action_add()
+ # The row is persisted...
+ self.assertEqual(len(detail.member_existing_ids), 1)
+ # ...and the wizard returns a follow-up act_window for itself.
+ self.assertEqual(action["type"], "ir.actions.act_window")
+ self.assertEqual(action["res_model"], "spp.cr.detail.create_group.member.wizard")
+ self.assertEqual(action["context"]["default_mode"], "existing")
+
+ def test_wizard_add_new_close_creates_row(self):
+ cr = self._make_cr(group_name="Wizard New-Member Group")
+ detail = cr.get_detail()
+ wiz = self._make_wizard(
+ detail,
+ "new",
+ given_name="Wizard",
+ family_name="Added",
+ phone_line_ids=[(0, 0, {"phone_no": "+639000", "is_primary": True})],
+ membership_type_id=self.head_kind.id if self.head_kind else False,
+ )
+ wiz.action_add_close()
+ self.assertEqual(len(detail.member_new_ids), 1)
+ row = detail.member_new_ids
+ self.assertEqual(row.given_name, "Wizard")
+ self.assertEqual(row.family_name, "Added")
+ self.assertEqual(row.full_name, "ADDED, Wizard")
+ # The wizard's phone line is persisted onto the member_new row.
+ self.assertEqual(row.phone_line_ids.phone_no, "+639000")
+
+ def test_wizard_edit_new_member_updates_row(self):
+ cr = self._make_cr(group_name="Edit-Wizard Group")
+ detail = cr.get_detail()
+ # Seed a new-member row first, with a phone (regression: editing a
+ # member that already has phone rows must not orphan them).
+ wiz = self._make_wizard(
+ detail,
+ "new",
+ given_name="Old",
+ family_name="Name",
+ phone_line_ids=[(0, 0, {"phone_no": "+63111", "is_primary": True})],
+ )
+ wiz.action_add_close()
+ row = detail.member_new_ids
+ self.assertEqual(row.full_name, "NAME, Old")
+ self.assertEqual(row.phone_line_ids.phone_no, "+63111")
+
+ # Open the wizard for that row in edit mode.
+ open_action = row.action_open_edit_wizard()
+ self.assertEqual(open_action["context"]["default_editing_member_new_id"], row.id)
+ # The edit context carries the existing phone rows.
+ self.assertTrue(open_action["context"]["default_phone_line_ids"])
+
+ # Recreate the wizard with the edit context as Odoo would (incl. a
+ # changed phone set).
+ edit_wiz = self.env["spp.cr.detail.create_group.member.wizard"].create(
{
- "request_type_id": self.cr_type.id,
- "registrant_id": self.dummy_group.id,
+ "detail_id": detail.id,
+ "mode": "new",
+ "editing_member_new_id": row.id,
+ "given_name": "New",
+ "family_name": "Name",
+ "phone_line_ids": [(0, 0, {"phone_no": "+63222", "is_primary": True})],
}
)
-
+ self.assertTrue(edit_wiz.is_editing)
+ action = edit_wiz.action_add()
+ # Edit branch should close the window in one shot.
+ self.assertEqual(action["type"], "ir.actions.act_window_close")
+ # ...and only one row remains, with the updated name and phone.
+ self.assertEqual(len(detail.member_new_ids), 1)
+ self.assertEqual(detail.member_new_ids.given_name, "New")
+ self.assertEqual(detail.member_new_ids.phone_line_ids.phone_no, "+63222")
+
+ def test_wizard_new_phone_ignores_detail_context(self):
+ """Regression: the wizard is opened with a default_detail_id context for
+ the member row; that default must not leak onto the new phone rows
+ (the phone model also has a detail_id field), which would give them two
+ parents and raise on save (OP#876 QA round 1)."""
+ cr = self._make_cr(group_name="Ctx-Wizard Group")
detail = cr.get_detail()
- detail.write(
+ Wizard = self.env["spp.cr.detail.create_group.member.wizard"].with_context(default_detail_id=detail.id)
+ wiz = Wizard.create(
{
- "group_name": "Preview Group",
- "group_type_id": self.group_kind.id,
- "create_new_head": True,
- "head_name": "Head Name",
+ "detail_id": detail.id,
+ "mode": "new",
+ "given_name": "Ctx",
+ "family_name": "Test",
+ "phone_line_ids": [(0, 0, {"phone_no": "+63111"})],
}
)
+ wiz.action_add_close() # must not raise
+ phone = detail.member_new_ids.phone_line_ids
+ self.assertEqual(phone.phone_no, "+63111")
+ # The phone row belongs only to the member, not the group detail.
+ self.assertFalse(phone.detail_id)
+ self.assertEqual(phone.member_new_id, detail.member_new_ids)
+
+ def test_wizard_existing_blocks_duplicate(self):
+ cr = self._make_cr(group_name="Dedup-Wizard Group")
+ detail = cr.get_detail()
+ self._make_wizard(detail, "existing", individual_id=self.existing_head.id).action_add_close()
+ # Trying to add the same individual again must fail.
+ dup_wiz = self._make_wizard(detail, "existing", individual_id=self.existing_head.id)
+ with self.assertRaises(UserError):
+ dup_wiz.action_add_close()
+
+ def test_wizard_new_requires_names(self):
+ cr = self._make_cr(group_name="Bad-Wizard Group")
+ detail = cr.get_detail()
+ wiz = self._make_wizard(detail, "new", given_name="Only")
+ with self.assertRaises(UserError):
+ wiz.action_add_close()
+
+ def test_wizard_blocks_second_head(self):
+ """A second Head added via the wizard is rejected (OP#876 QA round 1).
+
+ The parent-level @api.constrains doesn't fire on rows the wizard creates
+ directly, so the wizard guard + the per-row constraint must catch it.
+ """
+ if not self.head_kind:
+ self.skipTest("head membership-type code missing in vocabulary")
+ cr = self._make_cr(group_name="Wizard Two-Head Group")
+ detail = cr.get_detail()
+ # First head — existing individual.
+ self._make_wizard(
+ detail,
+ "existing",
+ individual_id=self.existing_head.id,
+ membership_type_id=self.head_kind.id,
+ ).action_add_close()
+ # Second head — new individual via wizard. Must be rejected.
+ second = self._make_wizard(
+ detail,
+ "new",
+ given_name="Second",
+ family_name="Head",
+ membership_type_id=self.head_kind.id,
+ )
+ with self.assertRaises(UserError):
+ second.action_add_close()
+
+ # ──────────────────────────────────────────────────────────────────
+ # New individual carries the full registry profile (OP#876 QA round 1)
+ # ──────────────────────────────────────────────────────────────────
+ def test_new_member_full_profile_written(self):
+ occupation = self.env["spp.vocabulary.code"].search(
+ [("vocabulary_id.namespace_uri", "=", "urn:ilo:isco-08")], limit=1
+ )
+ civil = self.env["spp.vocabulary.code"].search(
+ [("vocabulary_id.namespace_uri", "=", "urn:un:unsd:pop-census:marital-status")], limit=1
+ )
+ cr = self._make_cr(
+ group_name="Full-Profile Group",
+ member_new_ids=[
+ (
+ 0,
+ 0,
+ {
+ "given_name": "Maria",
+ "family_name": "Cruz",
+ "middle_name": "Santos",
+ "birthdate": "1990-05-20",
+ "is_approximate_birthdate": True,
+ "birth_place": "Cebu",
+ "income": 12345.0,
+ "address": "10 Rizal St, Cebu",
+ "email": "maria@example.com",
+ "phone_line_ids": [
+ (0, 0, {"phone_no": "+63911"}),
+ (0, 0, {"phone_no": "+63922"}),
+ ],
+ "occupation_id": occupation.id if occupation else False,
+ "civil_status_id": civil.id if civil else False,
+ "membership_type_id": self.head_kind.id if self.head_kind else False,
+ },
+ ),
+ ],
+ )
+ cr.approval_state = "approved"
+ cr.action_apply()
- preview = cr.action_preview_changes()
+ detail = cr.get_detail()
+ # Middle name is captured on the CR row (res.partner has no native field;
+ # name_change() recomposes the partner name from given+family only).
+ self.assertEqual(detail.member_new_ids.middle_name, "Santos")
- self.assertIn("_action", preview)
- self.assertEqual(preview["_action"], "create_group")
- self.assertEqual(preview["group_name"], "Preview Group")
- self.assertTrue(preview["create_new_head"])
+ new_group = detail.created_group_id
+ membership = self.membership_model.search([("group", "=", new_group.id), ("status", "=", "active")])
+ individual = membership.individual
+ self.assertEqual(individual.given_name, "Maria")
+ self.assertEqual(individual.family_name, "Cruz")
+ self.assertEqual(individual.birth_place, "Cebu")
+ self.assertTrue(individual.birthdate_not_exact)
+ self.assertEqual(individual.address, "10 Rizal St, Cebu")
+ self.assertEqual(individual.email, "maria@example.com")
+ self.assertEqual(individual.income, 12345.0)
+ # Multiple captured phone numbers are folded (in entry order) into the
+ # partner's single header phone field...
+ self.assertEqual(individual.phone, "+63911, +63922")
+ # ...and also created as proper phone records (the registry's Phone
+ # Numbers list), one per captured number.
+ phone_recs = self.env["spp.phone.number"].search([("partner_id", "=", individual.id)])
+ self.assertEqual(sorted(phone_recs.mapped("phone_no")), ["+63911", "+63922"])
+ if occupation:
+ self.assertEqual(individual.occupation_id, occupation)
+ if civil:
+ self.assertEqual(individual.civil_status_id, civil)
diff --git a/spp_change_request_v2/tests/test_e2e_workflows.py b/spp_change_request_v2/tests/test_e2e_workflows.py
index 723095797..9fc2115cc 100644
--- a/spp_change_request_v2/tests/test_e2e_workflows.py
+++ b/spp_change_request_v2/tests/test_e2e_workflows.py
@@ -84,17 +84,24 @@ def test_scenario_new_household_registration(self):
"registrant_id": placeholder.id,
}
)
+ head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
detail1 = cr1.get_detail()
detail1.write(
{
"group_name": "Dela Cruz Household",
- "create_new_head": True,
- "head_given_name": "Juan",
- "head_family_name": "Dela Cruz",
- "head_name": "Juan Dela Cruz",
- "address_line1": "123 Mabini St",
- "city": "Quezon City",
- "phone": "+639123456789",
+ "address": "123 Mabini St, Quezon City",
+ "phone_line_ids": [(0, 0, {"phone_no": "+639123456789", "is_primary": True})],
+ "member_new_ids": [
+ (
+ 0,
+ 0,
+ {
+ "given_name": "Juan",
+ "family_name": "Dela Cruz",
+ "membership_type_id": head_kind.id if head_kind else False,
+ },
+ )
+ ],
}
)
self._approve_and_apply(cr1)
@@ -113,42 +120,36 @@ def test_scenario_new_household_registration(self):
head = head_membership.individual
self.assertEqual(head.name, "DELA CRUZ, JUAN")
- # Step 2: Add spouse
+ # Step 2: Add spouse (an existing individual)
+ spouse = self.partner_model.create({"name": "DELA CRUZ, MARIA", "is_registrant": True, "is_group": False})
cr2 = self.cr_model.create(
{
"request_type_id": self.add_member_type.id,
"registrant_id": household.id,
}
)
- detail2 = cr2.get_detail()
- detail2.write(
+ cr2.get_detail().write(
{
- "given_name": "Maria",
- "family_name": "Dela Cruz",
- "member_name": "Maria Dela Cruz",
- "relationship_id": self.spouse_kind.id,
+ "individual_id": spouse.id,
+ "membership_type_id": self.spouse_kind.id,
}
)
self._approve_and_apply(cr2)
-
- spouse = detail2.created_individual_id
self.assertEqual(spouse.name, "DELA CRUZ, MARIA")
- # Step 3: Add children
- for _i, name in enumerate(["Pedro", "Ana"]):
+ # Step 3: Add children (existing individuals)
+ for name in ["PEDRO", "ANA"]:
+ child = self.partner_model.create({"name": f"DELA CRUZ, {name}", "is_registrant": True, "is_group": False})
cr = self.cr_model.create(
{
"request_type_id": self.add_member_type.id,
"registrant_id": household.id,
}
)
- detail = cr.get_detail()
- detail.write(
+ cr.get_detail().write(
{
- "given_name": name,
- "family_name": "Dela Cruz",
- "member_name": f"{name} Dela Cruz",
- "relationship_id": self.child_kind.id,
+ "individual_id": child.id,
+ "membership_type_id": self.child_kind.id,
}
)
self._approve_and_apply(cr)
@@ -292,20 +293,18 @@ def test_scenario_marriage_household_split(self):
)
self.assertTrue(child_membership)
- # Step 2: Add spouse to new household
+ # Step 2: Add spouse (an existing individual) to new household
+ spouse = self.partner_model.create({"name": "New Spouse", "is_registrant": True, "is_group": False})
cr2 = self.cr_model.create(
{
"request_type_id": self.add_member_type.id,
"registrant_id": new_household.id,
}
)
- detail2 = cr2.get_detail()
- detail2.write(
+ cr2.get_detail().write(
{
- "given_name": "New",
- "family_name": "Spouse",
- "member_name": "New Spouse",
- "relationship_id": self.spouse_kind.id,
+ "individual_id": spouse.id,
+ "membership_type_id": self.spouse_kind.id,
}
)
self._approve_and_apply(cr2)
@@ -380,13 +379,11 @@ def test_scenario_hoh_deceased(self):
}
)
detail1 = cr1.get_detail()
- detail1.write(
- {
- "new_head_membership_id": spouse_mem.id,
- "reason": "deceased",
- "effective_date": fields.Date.today(),
- }
- )
+ detail1.reason = "deceased"
+ # Original head steps down; spouse is promoted to head. Step the current
+ # head down first to avoid a transient two-heads state.
+ detail1.member_line_ids.filtered(lambda r: r.individual_id == head).new_role_id = self.spouse_kind
+ detail1.member_line_ids.filtered(lambda r: r.individual_id == spouse).new_role_id = self.head_kind
self._approve_and_apply(cr1)
# Verify spouse is now head
@@ -608,12 +605,22 @@ def test_scenario_full_lifecycle(self):
"registrant_id": placeholder.id,
}
)
+ head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
detail1 = cr1.get_detail()
detail1.write(
{
"group_name": "Lifecycle Household",
- "create_new_head": True,
- "head_name": "Lifecycle Head",
+ "member_new_ids": [
+ (
+ 0,
+ 0,
+ {
+ "given_name": "Lifecycle",
+ "family_name": "Head",
+ "membership_type_id": head_kind.id if head_kind else False,
+ },
+ )
+ ],
}
)
self._approve_and_apply(cr1)
@@ -627,23 +634,21 @@ def test_scenario_full_lifecycle(self):
)
head = head_mem.individual
- # Step 2: Add member
+ # Step 2: Add member (an existing individual)
+ member = self.partner_model.create({"name": "Lifecycle Member", "is_registrant": True, "is_group": False})
cr2 = self.cr_model.create(
{
"request_type_id": self.add_member_type.id,
"registrant_id": household.id,
}
)
- detail2 = cr2.get_detail()
- detail2.write(
+ cr2.get_detail().write(
{
- "member_name": "Lifecycle Member",
- "relationship_id": self.spouse_kind.id,
+ "individual_id": member.id,
+ "membership_type_id": self.spouse_kind.id,
}
)
self._approve_and_apply(cr2)
-
- member = detail2.created_individual_id
member_mem = self.membership_model.search(
[
("group", "=", household.id),
diff --git a/spp_change_request_v2/views/change_request_type_views.xml b/spp_change_request_v2/views/change_request_type_views.xml
index 1ecc9c2b0..95e2b5af4 100644
--- a/spp_change_request_v2/views/change_request_type_views.xml
+++ b/spp_change_request_v2/views/change_request_type_views.xml
@@ -64,6 +64,13 @@
+
+
+
+
@@ -213,6 +220,7 @@
+
@@ -231,6 +239,25 @@
options="{'no_create': True, 'no_quick_create': True}"
/>
+
+
+
+ Optional. When set, the request's
+ Reason for Change determines which
+ documents are required (overriding the flat
+ "Required Documents" list above for that reason).
+
+ Assign roles to the group members.
+ Set one member's New Role to Head to designate
+ them as the new Head of Household. Each member's current role is shown for
+ reference.
+
+ Existing individuals.
+ Add people already registered in the system. Pick the role for each.
+ Once added, a row can be removed but not edited — remove and re-add to change it.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ New individuals.
+ Create new individuals to be added to this group. These rows can be removed
+ or edited; the individual is created in the registry only when the CR is applied.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This Change Request type requires a Head of Household.
+ Set the Role on exactly one member to Head
+ before submitting.
+
+
+
+
diff --git a/spp_change_request_v2/wizards/__init__.py b/spp_change_request_v2/wizards/__init__.py
index ddd1663c6..e184712b5 100644
--- a/spp_change_request_v2/wizards/__init__.py
+++ b/spp_change_request_v2/wizards/__init__.py
@@ -5,3 +5,4 @@
from . import batch_approval_wizard
from . import conflict_comparison_wizard
from . import revision_wizard
+from . import create_group_member_wizard
diff --git a/spp_change_request_v2/wizards/create_group_member_wizard.py b/spp_change_request_v2/wizards/create_group_member_wizard.py
new file mode 100644
index 000000000..673480b81
--- /dev/null
+++ b/spp_change_request_v2/wizards/create_group_member_wizard.py
@@ -0,0 +1,230 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Add Member wizard for the Create Group CR (OP#876).
+
+A single transient model handles two cases:
+- ``mode = 'existing'`` — pick an individual already in the registry.
+- ``mode = 'new'`` — collect the minimum field set for a new individual,
+ which the apply strategy later creates.
+
+The wizard is opened from two buttons on the detail form (one per mode) and
+can also be re-opened pre-populated to edit an existing **new** row. Existing
+rows are immutable once added: to change them the user deletes and re-adds.
+"""
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+
+class SPPCRCreateGroupMemberWizard(models.TransientModel):
+ _name = "spp.cr.detail.create_group.member.wizard"
+ _description = "Create Group — Add Member Wizard"
+
+ detail_id = fields.Many2one(
+ "spp.cr.detail.create_group",
+ required=True,
+ ondelete="cascade",
+ )
+ mode = fields.Selection(
+ [
+ ("existing", "Existing Individual"),
+ ("new", "New Individual"),
+ ],
+ required=True,
+ )
+
+ # ──────────────────────────────────────────────────────────────────
+ # Existing-mode fields
+ # ──────────────────────────────────────────────────────────────────
+ individual_id = fields.Many2one(
+ "res.partner",
+ string="Individual",
+ domain="[('is_group', '=', False), ('is_registrant', '=', True)]",
+ )
+
+ # ──────────────────────────────────────────────────────────────────
+ # New-mode fields (mirror the registry individual overview — OP#876)
+ # ──────────────────────────────────────────────────────────────────
+ given_name = fields.Char()
+ family_name = fields.Char()
+ middle_name = fields.Char()
+ birthdate = fields.Date(string="Date of Birth")
+ is_approximate_birthdate = fields.Boolean(string="Approximate Birthdate")
+ birth_place = fields.Char(string="Birth Place")
+ occupation_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Occupation",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:ilo:isco-08')]",
+ )
+ gender_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Gender",
+ domain="[('namespace_uri', '=', 'urn:iso:std:iso:5218')]",
+ )
+ civil_status_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Civil Status",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:un:unsd:pop-census:marital-status')]",
+ )
+ income = fields.Float(string="Income")
+ area_id = fields.Many2one("spp.area", string="Area")
+ address = fields.Text(string="Address")
+ email = fields.Char(string="Email")
+ phone_line_ids = fields.One2many(
+ "spp.cr.detail.create_group.member.wizard.phone",
+ "wizard_id",
+ string="Phone Numbers",
+ )
+
+ # ──────────────────────────────────────────────────────────────────
+ # Both modes
+ # ──────────────────────────────────────────────────────────────────
+ membership_type_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Role",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type')]",
+ )
+
+ # Edit-mode handle: only meaningful when ``mode == 'new'``. When set,
+ # ``_persist`` updates this row instead of creating a new one.
+ editing_member_new_id = fields.Many2one(
+ "spp.cr.detail.create_group.member_new",
+ string="Editing Row",
+ )
+
+ # Convenience for the view: show "Edit" vs "Add" labels on buttons.
+ is_editing = fields.Boolean(compute="_compute_is_editing")
+
+ @api.depends("editing_member_new_id")
+ def _compute_is_editing(self):
+ for rec in self:
+ rec.is_editing = bool(rec.editing_member_new_id)
+
+ # ──────────────────────────────────────────────────────────────────
+ # Validation
+ # ──────────────────────────────────────────────────────────────────
+ def _validate(self):
+ self.ensure_one()
+ if self.mode == "existing":
+ if not self.individual_id:
+ raise UserError(_("Pick an individual before adding."))
+ already_added = self.detail_id.member_existing_ids.filtered(
+ lambda m: m.individual_id.id == self.individual_id.id
+ )
+ if already_added:
+ raise UserError(_("'%s' is already in the existing-members list.") % self.individual_id.name)
+ elif self.mode == "new":
+ if not self.given_name or not self.family_name:
+ raise UserError(_("Given name and family name are both required for a new individual."))
+
+ # Block a second Head of Household with a clear message (the model-level
+ # constraint on the member rows is the safety net).
+ if self.membership_type_id and self.membership_type_id.code == "head":
+ existing_heads, new_heads = self.detail_id._heads()
+ if self.editing_member_new_id:
+ new_heads = new_heads.filtered(lambda m: m.id != self.editing_member_new_id.id)
+ if existing_heads or new_heads:
+ raise UserError(_("This group already has a Head of Household. Only one member can be Head."))
+
+ # ──────────────────────────────────────────────────────────────────
+ # Persist the wizard's payload to the detail's O2M tables
+ # ──────────────────────────────────────────────────────────────────
+ def _persist(self):
+ self._validate()
+ if self.mode == "existing":
+ self.env["spp.cr.detail.create_group.member_existing"].create(
+ {
+ "detail_id": self.detail_id.id,
+ "individual_id": self.individual_id.id,
+ "membership_type_id": self.membership_type_id.id if self.membership_type_id else False,
+ }
+ )
+ return
+
+ # mode == 'new'
+ # Copy the wizard's transient phone lines onto the member_new row.
+ # detail_id is forced False: the wizard is opened with a
+ # default_detail_id context (for the member row), and the phone model
+ # also has a detail_id field, so that default would otherwise leak onto
+ # the phone rows and give them two parents (detail_id + member_new_id).
+ phone_cmds = [
+ (0, 0, {"phone_no": pl.phone_no, "country_id": pl.country_id.id, "detail_id": False})
+ for pl in self.phone_line_ids
+ if pl.phone_no
+ ]
+ vals = {
+ "given_name": self.given_name,
+ "family_name": self.family_name,
+ "middle_name": self.middle_name,
+ "birthdate": self.birthdate,
+ "is_approximate_birthdate": self.is_approximate_birthdate,
+ "birth_place": self.birth_place,
+ "occupation_id": self.occupation_id.id if self.occupation_id else False,
+ "gender_id": self.gender_id.id if self.gender_id else False,
+ "civil_status_id": self.civil_status_id.id if self.civil_status_id else False,
+ "income": self.income,
+ "area_id": self.area_id.id if self.area_id else False,
+ "address": self.address,
+ "email": self.email,
+ "membership_type_id": self.membership_type_id.id if self.membership_type_id else False,
+ }
+ if self.editing_member_new_id:
+ # Replace the existing phone rows with the wizard's current set.
+ # Delete (2) the old rows rather than clear (5): clearing a
+ # one2many only nulls the inverse FK, which would orphan the rows
+ # and trip the phone row's exactly-one-parent constraint.
+ delete_cmds = [(2, p.id, 0) for p in self.editing_member_new_id.phone_line_ids]
+ vals["phone_line_ids"] = delete_cmds + phone_cmds
+ self.editing_member_new_id.write(vals)
+ else:
+ vals["detail_id"] = self.detail_id.id
+ vals["phone_line_ids"] = phone_cmds
+ self.env["spp.cr.detail.create_group.member_new"].create(vals)
+
+ # ──────────────────────────────────────────────────────────────────
+ # Buttons
+ # ──────────────────────────────────────────────────────────────────
+ def action_add(self):
+ """Persist + reopen the wizard fresh so the user can add another row."""
+ self.ensure_one()
+ self._persist()
+ if self.is_editing:
+ # Editing is a one-shot operation; close after saving even on the
+ # plain "Save" button.
+ return {"type": "ir.actions.act_window_close"}
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": self._name,
+ "view_mode": "form",
+ "target": "new",
+ "context": {
+ "default_detail_id": self.detail_id.id,
+ "default_mode": self.mode,
+ },
+ }
+
+ def action_add_close(self):
+ self.ensure_one()
+ self._persist()
+ return {"type": "ir.actions.act_window_close"}
+
+
+class SPPCRCreateGroupMemberWizardPhone(models.TransientModel):
+ """Transient phone row for the Add Member wizard's editable list.
+
+ Persisted onto ``member_new.phone_line_ids`` when the wizard saves; on apply
+ the new individual's phone numbers are concatenated into the partner's
+ single header phone field.
+ """
+
+ _name = "spp.cr.detail.create_group.member.wizard.phone"
+ _description = "Create Group — Add Member Wizard Phone"
+ _order = "is_primary desc, id"
+
+ wizard_id = fields.Many2one(
+ "spp.cr.detail.create_group.member.wizard",
+ required=True,
+ ondelete="cascade",
+ )
+ phone_no = fields.Char(string="Phone Number", required=True)
+ country_id = fields.Many2one("res.country", string="Country")
+ is_primary = fields.Boolean(string="Primary")
diff --git a/spp_change_request_v2/wizards/create_wizard.py b/spp_change_request_v2/wizards/create_wizard.py
index aa6524941..2f1c25438 100644
--- a/spp_change_request_v2/wizards/create_wizard.py
+++ b/spp_change_request_v2/wizards/create_wizard.py
@@ -130,13 +130,37 @@ def _compute_show_registrant(self):
for rec in self:
rec.show_registrant = bool(rec.request_type_id and rec.request_type_id.is_requires_registrant)
- @api.depends("request_type_id", "request_type_id.target_type")
+ @api.depends("request_type_id", "request_type_id.target_type", "request_type_id.is_requires_registrant")
def _compute_target_type_message(self):
for rec in self:
rec.target_type_message = ""
- if not rec.request_type_id or not rec.request_type_id.target_type:
+ cr_type = rec.request_type_id
+ if not cr_type:
continue
- target_type = rec.request_type_id.target_type
+
+ # For types that *don't* require a registrant, the registrant is
+ # what the CR is about to create — say so explicitly instead of
+ # the generic "applies to X" hint so the user understands why no
+ # registrant picker shows up below.
+ if not cr_type.is_requires_registrant:
+ target_type = cr_type.target_type
+ if target_type == "individual":
+ rec.target_type_message = _(
+ "This request type creates a new individual — no existing registrant is needed."
+ )
+ elif target_type == "group":
+ rec.target_type_message = _(
+ "This request type creates a new group/household — no existing registrant is needed."
+ )
+ else:
+ rec.target_type_message = _(
+ "This request type creates a new registrant — no existing one is needed."
+ )
+ continue
+
+ if not cr_type.target_type:
+ continue
+ target_type = cr_type.target_type
if target_type == "individual":
rec.target_type_message = _("This request type applies to individuals only.")
elif target_type == "group":
diff --git a/spp_cr_types_advanced/data/cr_types.xml b/spp_cr_types_advanced/data/cr_types.xml
index 180d36f02..e6b0c89a5 100644
--- a/spp_cr_types_advanced/data/cr_types.xml
+++ b/spp_cr_types_advanced/data/cr_types.xml
@@ -163,6 +163,10 @@
spp.cr.apply.create_groupfa-home90
+
+ FalseFalse
diff --git a/spp_mis_demo_v2/models/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py
index ebad1a3bc..ea29b1bb8 100644
--- a/spp_mis_demo_v2/models/mis_demo_generator.py
+++ b/spp_mis_demo_v2/models/mis_demo_generator.py
@@ -2745,8 +2745,7 @@ def _get_demo_user(self, role):
"proposed_changes": {
"group_name": "Ramos",
"head_name": "Maricel Ramos",
- "address_line1": "123 Marriage Lane",
- "city": "New Family City",
+ "address": "123 Marriage Lane, New Family City",
},
},
# Phase 5.1 & 5.2: Add split_household CR (REJECTED)
@@ -3106,6 +3105,90 @@ def _set_cr_state(self, cr, target_state, apply=False, rejection_reason=None, re
}
)
+ def _add_member_detail_vals(self, proposed_changes):
+ """Register the individual for an Add Member CR and return detail vals (OP#871).
+
+ Add Member now adds an EXISTING individual, so the MIS "new baby" scenario
+ registers the person first and the CR references them via individual_id.
+ """
+ rel_xmlid = proposed_changes.get("relationship_xmlid")
+ membership_type_id = False
+ if rel_xmlid:
+ code = self.env.ref(rel_xmlid, raise_if_not_found=False)
+ membership_type_id = code.id if code else False
+ given = (proposed_changes.get("given_name") or "").strip()
+ family = (proposed_changes.get("family_name") or "").strip()
+ if family and given:
+ full_name = f"{family.upper()}, {given}"
+ elif family:
+ full_name = family.upper()
+ else:
+ full_name = given or "New Member"
+ Partner = self.env["res.partner"]
+ partner_vals = {"name": full_name, "is_registrant": True, "is_group": False}
+ for fname, value in [
+ ("given_name", given),
+ ("family_name", family),
+ ("birthdate", proposed_changes.get("birthdate")),
+ ]:
+ if value and fname in Partner._fields:
+ partner_vals[fname] = value
+ individual = Partner.create(partner_vals)
+ return {"individual_id": individual.id, "membership_type_id": membership_type_id}
+
+ def _change_hoh_member_lines(self, registrant, new_head_name):
+ """Rebuild the Change HoH member role lines (OP#873).
+
+ Returns member_line_ids commands promoting the named new head to "head"
+ and demoting whoever currently holds it (to an existing non-head role, or
+ any non-head role, so the apply step does not skip the line and leave two
+ heads). Returns None when no matching new head is found.
+ """
+ if not new_head_name:
+ return None
+ name_parts = new_head_name.split()
+ given_name = name_parts[0] if name_parts else new_head_name
+ new_head = self.env["res.partner"].search(
+ [("given_name", "ilike", given_name), ("is_group", "=", False), ("is_registrant", "=", True)],
+ limit=1,
+ )
+ if not new_head:
+ return None
+ Code = self.env["spp.vocabulary.code"]
+ head_code = Code.get_code("urn:openspp:vocab:group-membership-type", "head")
+ non_head_codes = Code.search(
+ [
+ ("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:group-membership-type"),
+ ("code", "!=", "head"),
+ ]
+ )
+ memberships = self.env["spp.group.membership"].search(
+ [("group", "=", registrant.id), ("status", "=", "active")]
+ )
+ lines = [(5, 0, 0)]
+ for m in memberships:
+ current_roles = m.membership_type_ids
+ if m.individual == new_head:
+ new_role = head_code
+ elif head_code and head_code in current_roles:
+ remaining = current_roles.filtered(lambda r, h=head_code: r != h)
+ new_role = remaining[:1] or non_head_codes[:1]
+ else:
+ new_role = current_roles[:1]
+ lines.append(
+ (
+ 0,
+ 0,
+ {
+ "individual_id": m.individual.id,
+ "membership_id": m.id,
+ "old_role_display": ", ".join(current_roles.mapped("display")),
+ "new_role_id": new_role.id if new_role else False,
+ },
+ )
+ )
+ return lines
+
def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_def):
"""Map proposed_changes to CR detail fields for V2 CR types."""
vals = {}
@@ -3139,22 +3222,9 @@ def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_d
}
)
elif detail_model == "spp.cr.detail.add_member":
- rel_xmlid = proposed_changes.get("relationship_xmlid")
- relationship_id = False
- if rel_xmlid:
- relationship_id = self.env.ref(rel_xmlid, raise_if_not_found=False)
- relationship_id = relationship_id.id if relationship_id else False
- vals.update(
- {
- "given_name": proposed_changes.get("given_name"),
- "family_name": proposed_changes.get("family_name"),
- "member_name": " ".join(
- filter(None, [proposed_changes.get("given_name"), proposed_changes.get("family_name")])
- ),
- "birthdate": proposed_changes.get("birthdate"),
- "relationship_id": relationship_id,
- }
- )
+ # OP#871: add_member selects an EXISTING individual; register the
+ # MIS "new baby" first, then the CR adds them to the group.
+ vals.update(self._add_member_detail_vals(proposed_changes))
elif detail_model == "spp.cr.detail.transfer_member":
member_name = proposed_changes.get("member_name")
target_story = proposed_changes.get("target_group_story")
@@ -3188,20 +3258,11 @@ def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_d
}
)
elif detail_model == "spp.cr.detail.change_hoh":
- new_head_name = proposed_changes.get("new_head_name")
- if new_head_name:
- # Search by given_name for case-insensitive match
- name_parts = new_head_name.split()
- given_name = name_parts[0] if name_parts else new_head_name
- individual = self.env["res.partner"].search(
- [
- ("given_name", "ilike", given_name),
- ("is_group", "=", False),
- ("is_registrant", "=", True),
- ],
- limit=1,
- )
- vals["new_head_id"] = individual.id if individual else False
+ # OP#873: change_hoh uses per-member role lines instead of a single
+ # new_head_id — rebuild the seeded lines (see helper).
+ lines = self._change_hoh_member_lines(registrant, proposed_changes.get("new_head_name"))
+ if lines is not None:
+ vals["member_line_ids"] = lines
# Phase 5.1: Add remove_member support
elif detail_model == "spp.cr.detail.remove_member":
member_name = proposed_changes.get("member_name")
@@ -3226,16 +3287,37 @@ def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_d
}
)
# Phase 5.1: Add create_group support
+ #
+ # OP#876 redesigned the detail model — head info now lives on a
+ # member_new_ids sub-record with the "head" membership-type code.
+ # Split `head_name` ("Maricel Ramos") into given + family so the
+ # downstream CR has a real new-member row.
elif detail_model == "spp.cr.detail.create_group":
vals.update(
{
"group_name": proposed_changes.get("group_name", "New Household"),
- "head_name": proposed_changes.get("head_name", ""),
- "address_line1": proposed_changes.get("address_line1", ""),
- "city": proposed_changes.get("city", ""),
- "postal_code": proposed_changes.get("postal_code", ""),
+ "address": proposed_changes.get("address", ""),
}
)
+ head_name = proposed_changes.get("head_name", "").strip()
+ if head_name:
+ parts = head_name.split(None, 1)
+ given = parts[0]
+ family = parts[1] if len(parts) > 1 else parts[0]
+ head_kind = self.env["spp.vocabulary.code"].get_code(
+ "urn:openspp:vocab:group-membership-type", "head"
+ )
+ vals["member_new_ids"] = [
+ (
+ 0,
+ 0,
+ {
+ "given_name": given,
+ "family_name": family,
+ "membership_type_id": head_kind.id if head_kind else False,
+ },
+ )
+ ]
# Phase 5.1: Add split_household support
elif detail_model == "spp.cr.detail.split_household":
vals.update(