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..6edd2b3a5 100644
--- a/spp_change_request_v2/details/add_member.py
+++ b/spp_change_request_v2/details/add_member.py
@@ -1,4 +1,13 @@
-from odoo import api, fields, models
+# 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
+from odoo.exceptions import ValidationError
class SPPCRDetailAddMember(models.Model):
@@ -8,57 +17,151 @@ 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
- # ══════════════════════════════════════════════════════════════════════════
+ # ──────────────────────────────────────────────────────────────────────
+ # Target group identity (helps distinguish same-named groups — OP#871)
+ # ──────────────────────────────────────────────────────────────────────
+ registrant_registration_date = fields.Date(
+ related="change_request_id.registrant_id.registration_date",
+ string="Group Registration Date",
+ readonly=True,
+ )
+ registrant_id_display = fields.Char(
+ string="Group ID(s)",
+ compute="_compute_registrant_id_display",
+ help="The target group's registry ID type(s) and value(s).",
+ )
- 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.",
)
- relationship_id = fields.Many2one(
- "spp.vocabulary.code",
- string="Relationship to Head",
- domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type'),"
- " ('code', '!=', 'head')]",
- tracking=True,
+
+ # ──────────────────────────────────────────────────────────────────────
+ # 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.",
)
- 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
- # ══════════════════════════════════════════════════════════════════════════
-
- @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,
- ]
-
- name = " ".join(filter(None, name_vals))
- self.member_name = name.upper()
+ # ──────────────────────────────────────────────────────────────────────
+ # 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.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([])
+
+ @api.depends("change_request_id.registrant_id.reg_ids")
+ def _compute_registrant_id_display(self):
+ """Render the target group's registry IDs as 'Type: value; ...' so a
+ group can be told apart from others with the same name (OP#871)."""
+ for rec in self:
+ group = rec.change_request_id.registrant_id
+ ids = group.reg_ids if group and "reg_ids" in group._fields else False
+ rec.registrant_id_display = (
+ "; ".join(f"{i.id_type_as_str or ''}: {i.value or ''}".strip(": ") for i in ids) if ids else False
+ )
+
+ @api.constrains("membership_type_id")
+ def _check_single_head(self):
+ """A group can have at most one Head of Household. Reject choosing the
+ Head role when the target group already has an active head — at save /
+ submit time, not just on apply (OP#871, uniform with the other CRs)."""
+ for rec in self:
+ role = rec.membership_type_id
+ group = rec.change_request_id.registrant_id
+ if not role or role.code != "head" or not group or not group.is_group:
+ continue
+ group_has_head = self.env["spp.group.membership"].search_count(
+ [
+ ("group", "=", group.id),
+ ("status", "=", "active"),
+ ("membership_type_ids.code", "=", "head"),
+ ]
+ )
+ if group_has_head:
+ raise ValidationError(_("This group already has a Head of Household. Only one member can be Head."))
+
+
+class SPPGroupMembership(models.Model):
+ """Expose the member's registration date for the Add Member CR's
+ Current Members table (OP#871) — membership start_date is the join date,
+ not the individual's registration date, so they are shown separately."""
+
+ _inherit = "spp.group.membership"
+
+ individual_registration_date = fields.Date(
+ related="individual.registration_date",
+ string="Registration Date",
+ readonly=True,
+ )
diff --git a/spp_change_request_v2/details/change_hoh.py b/spp_change_request_v2/details/change_hoh.py
index d87a0f566..94ff1c6ab 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,82 @@ 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"),
- ]
+ @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
+
+ def _seed_member_lines(self):
+ """Populate one editable role line per active group member.
+
+ The New Role is left blank (NOT prefilled with the current role): the
+ user only fills the row(s) they want to change — typically setting one
+ member's New Role to Head. A blank New Role means "no change"; the
+ previous head is demoted automatically on apply (OP#873 QA)."""
+ 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": False,
+ },
)
- # 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.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,
)
- 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_head_assignment(self):
+ """Validate the Head assignment at save/submit (OP#873 QA):
+ - at most one member may be set to Head;
+ - the current Head of Household cannot be reassigned Head (a Change HoH
+ must hand the role to a different member)."""
+ head = self.env["spp.vocabulary.code"].get_code(ROLE_NAMESPACE, HEAD_ROLE_CODE)
+ if not head:
+ return
+ for detail in self.mapped("detail_id"):
+ head_lines = detail.member_line_ids.filtered(lambda r: r.new_role_id == head)
+ if len(head_lines) > 1:
+ raise ValidationError(_("A group can have at most one Head of Household."))
+ if head_lines and detail.current_head_id and head_lines.individual_id == detail.current_head_id:
+ raise ValidationError(
+ _("The current Head of Household cannot be set as Head again. Designate a different member.")
+ )
diff --git a/spp_change_request_v2/details/create_group.py b/spp_change_request_v2/details/create_group.py
index e1c605e63..9065b2094 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,459 @@ 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",
+ )
+ split_household_detail_id = fields.Many2one(
+ "spp.cr.detail.split_household",
+ 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", "split_household_detail_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:
+ parents = (rec.detail_id, rec.add_member_detail_id, rec.member_new_id, rec.split_household_detail_id)
+ if sum(1 for p in parents 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",
+ )
+ split_household_detail_id = fields.Many2one(
+ "spp.cr.detail.split_household",
+ ondelete="cascade",
+ )
+ member_new_id = fields.Many2one(
+ "spp.cr.detail.create_group.member_new",
+ 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", "split_household_detail_id", "member_new_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:
+ parents = (rec.detail_id, rec.add_member_detail_id, rec.split_household_detail_id, rec.member_new_id)
+ if sum(1 for p in parents if p) > 1:
+ 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",
+ )
+ split_household_detail_id = fields.Many2one(
+ "spp.cr.detail.split_household",
+ 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", "split_household_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:
+ parents = (rec.detail_id, rec.add_member_detail_id, rec.split_household_detail_id)
+ if sum(1 for p in parents if p) > 1:
+ 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",
+ )
+ bank_line_ids = fields.One2many(
+ "spp.cr.detail.create_group.bank",
+ "member_new_id",
+ string="Bank Accounts",
+ )
+ 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_bank_line_ids": [
+ (0, 0, {"acc_number": b.acc_number, "acc_holder_name": b.acc_holder_name, "bank_id": b.bank_id.id})
+ for b in self.bank_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/details/remove_member.py b/spp_change_request_v2/details/remove_member.py
index 603034d0a..eb35a7a6a 100644
--- a/spp_change_request_v2/details/remove_member.py
+++ b/spp_change_request_v2/details/remove_member.py
@@ -31,16 +31,12 @@ class SPPCRDetailRemoveMember(models.Model):
readonly=True,
help="Membership record for the individual (automatically set)",
)
- end_date = fields.Date(
- string="End Date",
- default=fields.Date.today,
- tracking=True,
- )
+ # OP#872: "Married Out" removed from the reasons; End Date removed (it had no
+ # effect — removal applies immediately on approval).
end_reason = fields.Selection(
[
("left_household", "Left Household"),
("deceased", "Deceased"),
- ("married_out", "Married Out"),
("migrated", "Migrated"),
("correction", "Data Correction"),
("other", "Other"),
@@ -54,12 +50,6 @@ class SPPCRDetailRemoveMember(models.Model):
# COMPUTED FIELDS
# ══════════════════════════════════════════════════════════════════════════
- member_name = fields.Char(
- string="Member Name",
- related="individual_id.name",
- readonly=True,
- )
-
@api.depends("change_request_id.registrant_id")
def _compute_available_individuals(self):
"""Compute available individuals excluding head of household."""
diff --git a/spp_change_request_v2/details/split_household.py b/spp_change_request_v2/details/split_household.py
index 4e44ab872..2e3c5ea78 100644
--- a/spp_change_request_v2/details/split_household.py
+++ b/spp_change_request_v2/details/split_household.py
@@ -1,18 +1,26 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
+ROLE_NAMESPACE = "urn:openspp:vocab:group-membership-type"
+HEAD_ROLE_CODE = "head"
+
class SPPCRDetailSplitHousehold(models.Model):
- """Detail model for Split Household CR type."""
+ """Detail model for Split Household CR type (OP#877).
+
+ Splits members out of a source household into a brand-new household. The new
+ household captures the same group fields as Create Group (OP#876); the
+ members to move are an editable table (member + role + per-member edits via
+ a modal). A head for the new group is NOT mandatory.
+ """
_name = "spp.cr.detail.split_household"
_description = "CR Detail: Split Household"
_inherit = ["spp.cr.detail.base", "mail.thread"]
- # ══════════════════════════════════════════════════════════════════════════
- # SOURCE GROUP INFORMATION
- # ══════════════════════════════════════════════════════════════════════════
-
+ # ──────────────────────────────────────────────────────────────────────
+ # Source household (prefilled from the registrant) + reason
+ # ──────────────────────────────────────────────────────────────────────
source_group_id = fields.Many2one(
"res.partner",
string="Source Household",
@@ -20,250 +28,160 @@ class SPPCRDetailSplitHousehold(models.Model):
store=True,
readonly=True,
)
- source_group_name = fields.Char(
- string="Source Group Name",
- related="source_group_id.name",
- readonly=True,
+ source_group_name = fields.Char(related="source_group_id.name", readonly=True)
+ split_reason = fields.Selection(
+ [
+ ("marriage", "Marriage"),
+ ("separation", "Separation/Divorce"),
+ ("independence", "Member Independence"),
+ ("relocation", "Relocation"),
+ ("correction", "Data Correction"),
+ ("other", "Other"),
+ ],
+ string="Reason for Split",
+ tracking=True,
)
+ remarks = fields.Text(string="Remarks", tracking=True)
- # ══════════════════════════════════════════════════════════════════════════
- # MEMBERS TO SPLIT
- # ══════════════════════════════════════════════════════════════════════════
-
+ # Source members that may be moved (active, non-head). Used by the move-line
+ # member picker's domain via parent reference in the view.
available_member_ids = fields.Many2many(
"res.partner",
string="Available Members",
compute="_compute_available_member_ids",
- help="Active members of the source household (excluding head)",
+ help="Active members of the source household, excluding the head (who cannot be moved).",
)
- registrant_member_to_split_ids = fields.Many2many(
- "res.partner",
- "spp_cr_split_registrants_rel",
+ # ──────────────────────────────────────────────────────────────────────
+ # Members to move (table with per-member role + edit modal)
+ # ──────────────────────────────────────────────────────────────────────
+ member_line_ids = fields.One2many(
+ "spp.cr.detail.split_household.member",
"detail_id",
- "partner_id",
string="Members to Move",
- tracking=True,
- help="Select individuals to move to the new household",
- )
-
- members_to_split_ids = fields.Many2many(
- "spp.group.membership",
- "spp_cr_split_members_rel",
- "detail_id",
- "membership_id",
- string="Membership Records",
- compute="_compute_members_to_split_ids",
- store=True,
- readonly=True,
- help="Membership records for selected individuals (auto-populated)",
- )
-
- new_head_individual_id = fields.Many2one(
- "res.partner",
- string="New Household Head",
- tracking=True,
- help="Select who will be the head of the new household",
- )
-
- new_head_membership_id = fields.Many2one(
- "spp.group.membership",
- string="Head Membership Record",
- compute="_compute_new_head_membership_id",
- store=True,
- readonly=True,
- help="Membership record for the selected new head (auto-populated)",
)
- # ══════════════════════════════════════════════════════════════════════════
- # NEW GROUP INFORMATION
- # ══════════════════════════════════════════════════════════════════════════
-
- new_group_name = fields.Char(
- string="New Household Name",
- tracking=True,
- )
+ # ──────────────────────────────────────────────────────────────────────
+ # New household — same group fields as Create Group (OP#876)
+ # ──────────────────────────────────────────────────────────────────────
+ new_group_name = fields.Char(string="New Household Name", tracking=True)
new_group_type_id = fields.Many2one(
"spp.vocabulary.code",
- string="New Group Type",
+ string="Group Type",
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-type')]",
tracking=True,
- help="Leave empty to use same type as source",
- )
-
- # New group address
- copy_address = fields.Boolean(
- string="Copy Address from Source",
- default=False,
- tracking=True,
+ help="Leave empty to use the same type as the source household.",
)
- 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)
- phone = fields.Char(string="Phone", tracking=True)
- email = fields.Char(string="Email", tracking=True)
-
- # Split metadata
- split_reason = fields.Selection(
- [
- ("marriage", "Marriage"),
- ("separation", "Separation/Divorce"),
- ("independence", "Member Independence"),
- ("relocation", "Relocation"),
- ("correction", "Data Correction"),
- ("other", "Other"),
- ],
- string="Split Reason",
- tracking=True,
+ new_area_id = fields.Many2one("spp.area", string="Area", tracking=True)
+ new_address = fields.Text(string="Address", tracking=True)
+ new_email = fields.Char(string="Email", tracking=True)
+ new_latitude = fields.Float(string="Latitude", digits=(13, 10), tracking=True)
+ new_longitude = fields.Float(string="Longitude", digits=(13, 10), tracking=True)
+ new_phone_line_ids = fields.One2many(
+ "spp.cr.detail.create_group.phone", "split_household_detail_id", string="Phone Numbers"
)
- effective_date = fields.Date(
- string="Effective Date",
- default=fields.Date.today,
- tracking=True,
+ new_bank_line_ids = fields.One2many(
+ "spp.cr.detail.create_group.bank", "split_household_detail_id", string="Bank Accounts"
)
- remarks = fields.Text(string="Remarks", tracking=True)
-
- # Reference to created group (set after apply)
- created_group_id = fields.Many2one(
- "res.partner",
- string="Created Household",
- readonly=True,
+ new_id_doc_line_ids = fields.One2many(
+ "spp.cr.detail.create_group.id_doc", "split_household_detail_id", string="Identity Documents"
)
- # ══════════════════════════════════════════════════════════════════════════
- # COMPUTED FIELDS
- # ══════════════════════════════════════════════════════════════════════════
+ created_group_id = fields.Many2one("res.partner", string="Created Household", readonly=True)
- members_to_split_count = fields.Integer(
- string="Members to Move",
- compute="_compute_members_count",
- )
- remaining_members_count = fields.Integer(
- string="Remaining Members",
- compute="_compute_members_count",
- )
+ # ──────────────────────────────────────────────────────────────────────
+ # Stat counters
+ # ──────────────────────────────────────────────────────────────────────
+ members_to_split_count = fields.Integer(compute="_compute_members_count", string="Moving")
+ remaining_members_count = fields.Integer(compute="_compute_members_count", string="Remaining")
+ # ──────────────────────────────────────────────────────────────────────
+ # Computes / constraints
+ # ──────────────────────────────────────────────────────────────────────
@api.depends("source_group_id")
def _compute_available_member_ids(self):
- """Compute available members from source household (excluding head)."""
- for rec in self:
- if not rec.source_group_id:
- rec.available_member_ids = False
- continue
-
- # Get head membership type
- head_type = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
-
- # Get all active memberships excluding head
- memberships = self.env["spp.group.membership"].search(
- [
- ("group", "=", rec.source_group_id.id),
- ("status", "=", "active"),
- ]
- )
-
- # Filter out head member
- _head_type = head_type
- non_head_memberships = memberships.filtered(lambda m, ht=_head_type: ht not in m.membership_type_ids)
-
- rec.available_member_ids = non_head_memberships.mapped("individual")
-
- @api.depends("registrant_member_to_split_ids", "source_group_id")
- def _compute_members_to_split_ids(self):
- """Convert selected individuals to their membership records."""
- for rec in self:
- if not rec.source_group_id or not rec.registrant_member_to_split_ids:
- rec.members_to_split_ids = False
- continue
-
- # Find membership records for selected individuals
- memberships = self.env["spp.group.membership"].search(
- [
- ("group", "=", rec.source_group_id.id),
- ("individual", "in", rec.registrant_member_to_split_ids.ids),
- ("status", "=", "active"),
- ]
- )
- rec.members_to_split_ids = memberships
-
- @api.depends("new_head_individual_id", "source_group_id")
- def _compute_new_head_membership_id(self):
- """Convert selected new head individual to their membership record."""
+ head = self.env["spp.vocabulary.code"].get_code(ROLE_NAMESPACE, HEAD_ROLE_CODE)
for rec in self:
- if not rec.source_group_id or not rec.new_head_individual_id:
- rec.new_head_membership_id = False
- continue
-
- # Find membership record for the new head
- membership = self.env["spp.group.membership"].search(
- [
- ("group", "=", rec.source_group_id.id),
- ("individual", "=", rec.new_head_individual_id.id),
- ("status", "=", "active"),
- ],
- limit=1,
- )
- rec.new_head_membership_id = membership
+ individuals = self.env["res.partner"]
+ if rec.source_group_id and rec.source_group_id.is_group:
+ memberships = self.env["spp.group.membership"].search(
+ [("group", "=", rec.source_group_id.id), ("status", "=", "active")]
+ )
+ non_head = memberships.filtered(lambda m, h=head: not h or h not in m.membership_type_ids)
+ individuals = non_head.mapped("individual")
+ rec.available_member_ids = individuals
- @api.depends("members_to_split_ids", "source_group_id")
+ @api.depends("member_line_ids", "source_group_id")
def _compute_members_count(self):
for rec in self:
- rec.members_to_split_count = len(rec.members_to_split_ids)
+ rec.members_to_split_count = len(rec.member_line_ids)
total = 0
if rec.source_group_id:
total = self.env["spp.group.membership"].search_count(
- [
- ("group", "=", rec.source_group_id.id),
- ("status", "=", "active"),
- ]
+ [("group", "=", rec.source_group_id.id), ("status", "=", "active")]
)
rec.remaining_members_count = total - rec.members_to_split_count
- @api.constrains("registrant_member_to_split_ids", "new_head_individual_id")
- def _check_new_head_in_split(self):
- """Ensure new head is in the members being split."""
- for rec in self:
- if rec.new_head_individual_id and rec.registrant_member_to_split_ids:
- if rec.new_head_individual_id not in rec.registrant_member_to_split_ids:
- raise ValidationError("The new household head must be one of the members being moved.")
-
- @api.constrains("members_to_split_ids", "source_group_id")
+ @api.constrains("member_line_ids", "source_group_id")
def _check_minimum_remaining(self):
- """Ensure at least one member remains in source group."""
+ """At least one member must remain in the source household."""
for rec in self:
- if rec.source_group_id and rec.members_to_split_ids:
+ if rec.source_group_id and rec.member_line_ids:
total = self.env["spp.group.membership"].search_count(
- [
- ("group", "=", rec.source_group_id.id),
- ("status", "=", "active"),
- ]
+ [("group", "=", rec.source_group_id.id), ("status", "=", "active")]
)
- if len(rec.members_to_split_ids) >= total:
+ if len(rec.member_line_ids) >= total:
raise ValidationError(
"Cannot move all members. At least one member must remain in the source household."
)
- @api.onchange("copy_address")
- def _onchange_copy_address(self):
- """Copy address from source group when toggled on, clear when toggled off."""
- if self.copy_address and self.source_group_id:
- self.address_line1 = self.source_group_id.street
- self.address_line2 = self.source_group_id.street2
- self.city = self.source_group_id.city
- self.state_id = self.source_group_id.state_id
- self.postal_code = self.source_group_id.zip
- self.country_id = self.source_group_id.country_id
- self.phone = self.source_group_id.phone
- self.email = self.source_group_id.email
- elif not self.copy_address:
- self.address_line1 = False
- self.address_line2 = False
- self.city = False
- self.state_id = False
- self.postal_code = False
- self.country_id = False
- self.phone = False
- self.email = False
+
+class SPPCRDetailSplitHouseholdMember(models.Model):
+ """A member moved to the new household, with role and optional edits (OP#877).
+
+ The editable fields capture proposed changes to the individual (applied on
+ CR apply, like the Edit Member CR); they are surfaced through the
+ "Edit Member Information" modal and default to the member's current values.
+ """
+
+ _name = "spp.cr.detail.split_household.member"
+ _description = "CR Detail: Split Household - Member to Move"
+
+ detail_id = fields.Many2one("spp.cr.detail.split_household", required=True, ondelete="cascade")
+ individual_id = fields.Many2one("res.partner", string="Member", required=True)
+ membership_type_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Role",
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type')]",
+ )
+
+ # Editable member info (proposed edits, prefilled from the member).
+ given_name = fields.Char(string="Given Name")
+ family_name = fields.Char(string="Family Name")
+ middle_name = fields.Char(string="Middle Name")
+ birthdate = fields.Date(string="Date of Birth")
+ birth_place = fields.Char(string="Birth Place")
+ 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')]",
+ )
+ occupation_id = fields.Many2one(
+ "spp.vocabulary.code", string="Occupation", domain="[('vocabulary_id.namespace_uri', '=', 'urn:ilo:isco-08')]"
+ )
+ income = fields.Float(string="Income")
+
+ @api.onchange("individual_id")
+ def _onchange_individual_id(self):
+ """Prefill the editable fields from the selected member's current values."""
+ p = self.individual_id
+ if not p:
+ return
+ self.given_name = p.given_name if "given_name" in p._fields else False
+ self.family_name = p.family_name if "family_name" in p._fields else False
+ for fname in ("birthdate", "birth_place", "gender_id", "civil_status_id", "occupation_id", "income"):
+ if fname in p._fields:
+ self[fname] = p[fname]
diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py
index d1b1012ee..565185bad 100644
--- a/spp_change_request_v2/models/change_request.py
+++ b/spp_change_request_v2/models/change_request.py
@@ -392,10 +392,43 @@ 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()
+ # The reason lives on `reason` (Change HoH), `split_reason` (Split)
+ # or `end_reason` (Remove Member).
+ reason = False
+ if detail:
+ for rfield in ("reason", "split_reason", "end_reason"):
+ if rfield in detail._fields and detail[rfield]:
+ reason = detail[rfield]
+ break
+ 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 +438,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 +1192,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..a08d878c8 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,18 @@ 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
+ exposes a reason field (Change HoH's ``reason``, Split Household's
+ ``split_reason`` or Remove Member's ``end_reason``)."""
+ for rec in self:
+ model = self.env[rec.detail_model] if (rec.detail_model and rec.detail_model in self.env) else None
+ rec.supports_reason_documents = bool(
+ model
+ and ("reason" in model._fields or "split_reason" in model._fields or "end_reason" in model._fields)
+ )
+
@api.depends("detail_model")
def _compute_available_field_ids(self):
"""Compute available fields from the detail model."""
@@ -502,3 +550,61 @@ 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/#877).
+
+ Configured on a change request type; consumed by
+ spp.change.request._get_effective_required_document_ids(). The reason values
+ union the reasons of the CR types that expose a reason (Change HoH, Split
+ Household); only the values relevant to a given type's reason will match."""
+
+ _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(
+ [
+ # Change Head of Household reasons
+ ("deceased", "Head Deceased"),
+ ("incapacitated", "Head Incapacitated"),
+ ("left_household", "Head Left Household"),
+ ("age_change", "Age-based Change"),
+ # Split Household reasons
+ ("marriage", "Marriage"),
+ ("separation", "Separation/Divorce"),
+ ("independence", "Member Independence"),
+ ("relocation", "Relocation"),
+ # Remove Member reasons (deceased / left_household shared above)
+ ("migrated", "Migrated"),
+ # Shared
+ ("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..e333df0e6 100644
--- a/spp_change_request_v2/security/ir.model.access.csv
+++ b/spp_change_request_v2/security/ir.model.access.csv
@@ -47,6 +47,38 @@ 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_create_group_member_wizard_bank_user,spp.cr.detail.create_group.member.wizard.bank user,model_spp_cr_detail_create_group_member_wizard_bank,group_cr_user,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_bank_validator,spp.cr.detail.create_group.member.wizard.bank validator,model_spp_cr_detail_create_group_member_wizard_bank,group_cr_validator,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_bank_validator_hq,spp.cr.detail.create_group.member.wizard.bank validator hq,model_spp_cr_detail_create_group_member_wizard_bank,group_cr_validator_hq,1,1,1,1
+access_spp_cr_detail_create_group_member_wizard_bank_manager,spp.cr.detail.create_group.member.wizard.bank manager,model_spp_cr_detail_create_group_member_wizard_bank,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
@@ -55,6 +87,10 @@ access_spp_cr_detail_split_household_user,spp.cr.detail.split_household user,mod
access_spp_cr_detail_split_household_validator,spp.cr.detail.split_household validator,model_spp_cr_detail_split_household,group_cr_validator,1,1,1,0
access_spp_cr_detail_split_household_validator_hq,spp.cr.detail.split_household validator hq,model_spp_cr_detail_split_household,group_cr_validator_hq,1,1,1,0
access_spp_cr_detail_split_household_manager,spp.cr.detail.split_household manager,model_spp_cr_detail_split_household,group_cr_manager,1,1,1,1
+access_spp_cr_detail_split_household_member_user,spp.cr.detail.split_household.member user,model_spp_cr_detail_split_household_member,group_cr_user,1,1,1,1
+access_spp_cr_detail_split_household_member_validator,spp.cr.detail.split_household.member validator,model_spp_cr_detail_split_household_member,group_cr_validator,1,1,1,1
+access_spp_cr_detail_split_household_member_validator_hq,spp.cr.detail.split_household.member validator hq,model_spp_cr_detail_split_household_member,group_cr_validator_hq,1,1,1,1
+access_spp_cr_detail_split_household_member_manager,spp.cr.detail.split_household.member manager,model_spp_cr_detail_split_household_member,group_cr_manager,1,1,1,1
access_spp_cr_preview_wizard_user,spp.cr.preview.wizard user,model_spp_cr_preview_wizard,group_cr_user,1,1,1,1
access_spp_cr_preview_wizard_validator,spp.cr.preview.wizard validator,model_spp_cr_preview_wizard,group_cr_validator,1,1,1,1
access_spp_cr_preview_wizard_validator_hq,spp.cr.preview.wizard validator hq,model_spp_cr_preview_wizard,group_cr_validator_hq,1,1,1,1
@@ -123,3 +159,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..aa0c0a54c 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,105 @@ 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."
+ )
+ )
+
+ # OP#871: the Head role is offered for all groups; if the target group
+ # already has an active Head of Household, adding another as Head is
+ # rejected here (a validation error, for uniformity with the other CRs)
+ # rather than silently hidden from the picker.
+ role = detail.membership_type_id
+ if role and role.code == "head":
+ group_has_head = self.env["spp.group.membership"].search_count(
+ [
+ ("group", "=", group.id),
+ ("status", "=", "active"),
+ ("membership_type_ids.code", "=", "head"),
+ ]
+ )
+ if group_has_head:
+ raise UserError(_("This group already has a Head of Household. Only one member can be Head."))
diff --git a/spp_change_request_v2/strategies/change_hoh.py b/spp_change_request_v2/strategies/change_hoh.py
index 8020843e7..600d40bc2 100644
--- a/spp_change_request_v2/strategies/change_hoh.py
+++ b/spp_change_request_v2/strategies/change_hoh.py
@@ -5,16 +5,22 @@
_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. Each member's role is set to
+ exactly their New Role; a blank New Role means the member ends up with
+ NO role (OP#873 QA). The member assigned Head becomes the new head; the
+ current head may not be reassigned Head and only one Head is allowed."""
group = change_request.registrant_id
if not group.is_group:
raise UserError(_("Registrant must be a group."))
@@ -23,77 +29,98 @@ 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
+ lines = detail.member_line_ids
+ if not lines:
+ raise UserError(_("No members are available to assign roles to."))
- # 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,
+ 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 new Head of Household."))
+ if len(head_lines) > 1:
+ raise UserError(_("A group can have at most one Head of Household."))
+ if detail.current_head_id and head_lines.individual_id == detail.current_head_id:
+ raise UserError(
+ _("The current Head of Household cannot be set as Head again. Designate a different member.")
)
- # 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)],
- }
- )
+ Membership = self.env["spp.group.membership"]
+
+ def active_membership(line):
+ membership = line.membership_id
+ if not membership or membership.status != "active":
+ # Membership may have changed since the lines were seeded.
+ membership = Membership.search(
+ [
+ ("group", "=", group.id),
+ ("individual", "=", line.individual_id.id),
+ ("status", "=", "active"),
+ ],
+ limit=1,
+ )
+ return membership
+
+ # The CR follows the New Role column exactly: every member's roles become
+ # [New Role] or [] when blank. Process the new head LAST so the group
+ # never transiently holds two heads (rejected by the registry constraint
+ # at flush) while the outgoing head's row is being cleared.
+ ordered = lines.sorted(key=lambda r: 1 if r.new_role_id == head_kind else 0)
+ for line in ordered:
+ membership = active_membership(line)
+ if not membership:
+ continue
+ role_ids = line.new_role_id.ids if line.new_role_id else []
+ membership.write({"membership_type_ids": [Command.set(role_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..4ae0f341f 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.bank_line_ids, 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,301 @@ 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_bank_rows = [
+ [b.bank_id.display_name or "", b.acc_number or "", b.acc_holder_name or ""] for b in m.bank_line_ids
+ ]
+ member_tables = []
+ if member_phone_rows:
+ member_tables.append(
+ {
+ "title": _("Phone Numbers"),
+ "columns": [_("Number"), _("Country"), _("Primary")],
+ "rows": member_phone_rows,
+ }
+ )
+ if member_bank_rows:
+ member_tables.append(
+ {
+ "title": _("Bank Accounts"),
+ "columns": [_("Bank"), _("Account Number"), _("Account Holder")],
+ "rows": member_bank_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, bank_lines, partner):
+ Bank = self.env["res.partner.bank"]
+ for line in bank_lines:
+ vals = {
+ "partner_id": partner.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 + bank records (the registry's
+ # Phone Numbers / Financial lists), the same way the group's are.
+ self._attach_phones(line.phone_line_ids, individual)
+ self._attach_banks(line.bank_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/strategies/remove_member.py b/spp_change_request_v2/strategies/remove_member.py
index 41f86a15b..bbfdf7c03 100644
--- a/spp_change_request_v2/strategies/remove_member.py
+++ b/spp_change_request_v2/strategies/remove_member.py
@@ -32,16 +32,11 @@ def apply(self, change_request):
if membership.status != "active":
raise UserError(_("Membership is already inactive."))
- # Convert date to datetime for ended_date field
- # Ensure ended_date is not before start_date (can happen with same-day operations)
- end_datetime = fields.Datetime.to_datetime(detail.end_date)
- if membership.start_date and end_datetime < membership.start_date:
- end_datetime = membership.start_date
-
- # End the membership
+ # OP#872: removal applies immediately on approval (the End Date field was
+ # dropped as it had no effect on effectivity).
membership.write(
{
- "ended_date": end_datetime,
+ "ended_date": fields.Datetime.now(),
"active": False,
}
)
@@ -62,10 +57,15 @@ def preview(self, change_request):
if not detail:
return {}
+ reason_label = None
+ if detail.end_reason:
+ reason_label = dict(detail.fields_get(["end_reason"])["end_reason"]["selection"]).get(detail.end_reason)
+
return {
"_action": "remove_member",
- "member_name": detail.member_name,
- "group": change_request.registrant_id.name,
- "end_date": str(detail.end_date),
- "reason": detail.end_reason,
+ "_header": _("The following individual is to be removed."),
+ _("Member"): detail.individual_id.display_name if detail.individual_id else None,
+ _("Group"): change_request.registrant_id.display_name,
+ _("Reason for Removal"): reason_label,
+ _("Additional Information"): detail.remarks or None,
}
diff --git a/spp_change_request_v2/strategies/split_household.py b/spp_change_request_v2/strategies/split_household.py
index 19df068a2..a0d988273 100644
--- a/spp_change_request_v2/strategies/split_household.py
+++ b/spp_change_request_v2/strategies/split_household.py
@@ -5,16 +5,34 @@
_logger = logging.getLogger(__name__)
+HEAD_ROLE_CODE = "head"
+
+# Editable member fields captured on a move-line (proposed edits, OP#877).
+# Labels are translated at use-time (preview), not at module import.
+MEMBER_EDIT_FIELDS = [
+ ("given_name", "Given Name"),
+ ("family_name", "Family Name"),
+ ("middle_name", "Middle Name"),
+ ("birthdate", "Date of Birth"),
+ ("birth_place", "Birth Place"),
+ ("gender_id", "Gender"),
+ ("civil_status_id", "Civil Status"),
+ ("occupation_id", "Occupation"),
+ ("income", "Income"),
+]
+
class SPPCRApplySplitHousehold(models.AbstractModel):
- """Custom apply strategy for Split Household CR type."""
+ """Custom apply strategy for Split Household CR type (OP#877)."""
_name = "spp.cr.apply.split_household"
_inherit = "spp.cr.strategy.base"
_description = "CR Apply: Split Household"
+ # ──────────────────────────────────────────────────────────────────────
+ # apply
+ # ──────────────────────────────────────────────────────────────────────
def apply(self, change_request):
- """Split household by creating new group and transferring members."""
source_group = change_request.registrant_id
if not source_group.is_group:
raise UserError(_("Registrant must be a group."))
@@ -22,139 +40,164 @@ def apply(self, change_request):
detail = change_request.get_detail()
if not detail:
raise UserError(_("No detail record found."))
-
- if not detail.members_to_split_ids:
- raise UserError(_("No members selected for split."))
-
- if not detail.new_head_membership_id:
- raise UserError(_("No head selected for new household."))
-
+ if not detail.member_line_ids:
+ raise UserError(_("Select at least one member to move to the new household."))
if not detail.new_group_name:
raise UserError(_("New household name is required."))
- # Create new group
- group_vals = {
+ new_group = self._create_new_group(detail, source_group)
+ self._attach_group_lines(detail, new_group)
+
+ Membership = self.env["spp.group.membership"]
+ head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", HEAD_ROLE_CODE)
+ for line in detail.member_line_ids:
+ individual = line.individual_id
+ # End the source membership.
+ source_membership = Membership.search(
+ [("group", "=", source_group.id), ("individual", "=", individual.id), ("status", "=", "active")],
+ limit=1,
+ )
+ if source_membership:
+ source_membership.write({"ended_date": fields.Datetime.now()})
+ # Apply any proposed edits to the individual.
+ self._apply_member_edits(line, individual)
+ # Create the membership in the new group with the chosen role.
+ vals = {"group": new_group.id, "individual": individual.id, "start_date": fields.Datetime.now()}
+ if line.membership_type_id:
+ vals["membership_type_ids"] = [Command.link(line.membership_type_id.id)]
+ elif head_kind:
+ # carry over non-head roles from the source membership
+ roles = (source_membership.membership_type_ids - head_kind) if source_membership else False
+ if roles:
+ vals["membership_type_ids"] = [Command.set(roles.ids)]
+ Membership.create(vals)
+
+ detail.write({"created_group_id": new_group.id})
+ _logger.info(
+ "Split household: moved %d members from group partner_id=%s to new group partner_id=%s via CR %s",
+ len(detail.member_line_ids),
+ source_group.id,
+ new_group.id,
+ change_request.name,
+ )
+ return True
+
+ def _create_new_group(self, detail, source_group):
+ Partner = self.env["res.partner"]
+ vals = {
"name": detail.new_group_name,
"is_registrant": True,
"is_group": True,
}
-
- # Set group type (use source type if not specified)
- if detail.new_group_type_id:
- group_vals["group_type_id"] = detail.new_group_type_id.id
- elif source_group.group_type_id:
- group_vals["group_type_id"] = source_group.group_type_id.id
-
- # Set address
- if detail.copy_address:
- group_vals.update(
+ group_type = detail.new_group_type_id or source_group.group_type_id
+ if group_type and "group_type_id" in Partner._fields:
+ vals["group_type_id"] = group_type.id
+ for fname, value in [
+ ("email", detail.new_email),
+ ("address", detail.new_address),
+ ("area_id", detail.new_area_id.id if detail.new_area_id else False),
+ ("latitude", detail.new_latitude),
+ ("longitude", detail.new_longitude),
+ ]:
+ if fname in Partner._fields:
+ vals[fname] = value
+ return Partner.create(vals)
+
+ def _attach_group_lines(self, detail, group):
+ """Attach the new household's phone/bank/ID lines to the group partner."""
+ for line in detail.new_phone_line_ids:
+ self.env["spp.phone.number"].create(
{
- "street": source_group.street,
- "street2": source_group.street2,
- "city": source_group.city,
- "state_id": source_group.state_id.id if source_group.state_id else False,
- "zip": source_group.zip,
- "country_id": source_group.country_id.id if source_group.country_id else False,
- "phone": source_group.phone,
- "email": source_group.email,
+ "partner_id": group.id,
+ "phone_no": line.phone_no,
+ "country_id": line.country_id.id if line.country_id else False,
+ "date_collected": fields.Date.today(),
}
)
- else:
- group_vals.update(
+ for line in detail.new_bank_line_ids:
+ bank_vals = {"partner_id": group.id, "acc_number": line.acc_number}
+ if line.acc_holder_name:
+ bank_vals["acc_holder_name"] = line.acc_holder_name
+ if line.bank_id:
+ bank_vals["bank_id"] = line.bank_id.id
+ self.env["res.partner.bank"].create(bank_vals)
+ for line in detail.new_id_doc_line_ids:
+ self.env["spp.registry.id"].create(
{
- "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,
+ "partner_id": group.id,
+ "id_type_id": line.id_type_id.id,
+ "value": line.value,
+ "expiry_date": line.expiry_date,
}
)
- new_group = self.env["res.partner"].create(group_vals)
- _logger.info(
- "Created new group partner_id=%s (%s) from split of partner_id=%s",
- new_group.id,
- new_group.name,
- source_group.id,
- )
-
- # Get head membership kind from vocabulary
- head_kind = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
-
- # Transfer members to new group
- effective_datetime = fields.Datetime.to_datetime(detail.effective_date)
- new_head_individual = detail.new_head_membership_id.individual
-
- for membership in detail.members_to_split_ids:
- individual = membership.individual
-
- # End membership in source group
- # Ensure ended_date is not before start_date (can happen with same-day operations)
- actual_end = effective_datetime
- if membership.start_date and effective_datetime < membership.start_date:
- actual_end = membership.start_date
- membership.write({"ended_date": actual_end, "active": False})
-
- # Create membership in new group
- new_membership_vals = {
- "group": new_group.id,
- "individual": individual.id,
- "start_date": effective_datetime,
- }
-
- # Set head role for new head
- if individual == new_head_individual and head_kind:
- new_membership_vals["membership_type_ids"] = [Command.link(head_kind.id)]
- else:
- # Keep existing roles except head
- existing_roles = membership.membership_type_ids
- if head_kind:
- existing_roles = existing_roles - head_kind
- if existing_roles:
- new_membership_vals["membership_type_ids"] = [Command.set(existing_roles.ids)]
-
- self.env["spp.group.membership"].create(new_membership_vals)
-
- _logger.info(
- "Transferred member partner_id=%s from group %s to new group %s",
- individual.id,
- source_group.id,
- new_group.id,
- )
-
- # Store reference to created group
- detail.write({"created_group_id": new_group.id})
-
- _logger.info(
- "Split household: moved %d members from partner_id=%s to new partner_id=%s via CR %s",
- len(detail.members_to_split_ids),
- source_group.id,
- new_group.id,
- change_request.name,
- )
-
- return True
-
+ def _changed_edits(self, line):
+ """Return [(field, label, new_value)] for member fields the line changes."""
+ individual = line.individual_id
+ changed = []
+ for fname, label in MEMBER_EDIT_FIELDS:
+ if fname not in line._fields:
+ continue
+ new_val = line[fname]
+ if not new_val:
+ continue
+ current = individual[fname] if individual and fname in individual._fields else False
+ if new_val != current:
+ changed.append((fname, label, new_val))
+ return changed
+
+ def _apply_member_edits(self, line, individual):
+ vals = {}
+ for fname, _label, new_val in self._changed_edits(line):
+ if fname in individual._fields:
+ vals[fname] = new_val.id if hasattr(new_val, "id") else new_val
+ if vals:
+ individual.write(vals)
+ if hasattr(individual, "name_change"):
+ individual.name_change()
+
+ # ──────────────────────────────────────────────────────────────────────
+ # preview
+ # ──────────────────────────────────────────────────────────────────────
def preview(self, change_request):
- """Preview what will be changed."""
detail = change_request.get_detail()
if not detail:
return {}
- members = []
- for m in detail.members_to_split_ids:
- members.append(m.individual.name)
+ reason_label = None
+ if detail.split_reason:
+ reason_label = dict(detail.fields_get(["split_reason"])["split_reason"]["selection"]).get(
+ detail.split_reason
+ )
+
+ members_rows = [
+ [line.individual_id.display_name or "", line.membership_type_id.display if line.membership_type_id else ""]
+ for line in detail.member_line_ids
+ ]
+ tables = [
+ {"title": _("Members to Move"), "columns": [_("Name"), _("Role")], "rows": members_rows},
+ ]
+
+ # Separate table for proposed member edits (like the Edit Member CR).
+ edit_rows = []
+ for line in detail.member_line_ids:
+ for _fname, label, new_val in self._changed_edits(line):
+ display = new_val.display_name if hasattr(new_val, "display_name") else str(new_val)
+ edit_rows.append([line.individual_id.display_name or "", _(label), display])
+ if edit_rows:
+ tables.append(
+ {"title": _("Member Edits"), "columns": [_("Member"), _("Field"), _("New Value")], "rows": edit_rows}
+ )
return {
"_action": "split_household",
- "source_group": detail.source_group_name,
- "new_group_name": detail.new_group_name,
- "new_head": detail.new_head_membership_id.individual.name if detail.new_head_membership_id else None,
- "members_to_move": members,
- "members_count": detail.members_to_split_count,
- "remaining_count": detail.remaining_members_count,
- "reason": detail.split_reason,
+ "_header": _("The following new household will be created:"),
+ _("New Household Name"): detail.new_group_name,
+ _("Group Type"): detail.new_group_type_id.display if detail.new_group_type_id else None,
+ _("Area"): detail.new_area_id.display_name if detail.new_area_id else None,
+ _("Address"): detail.new_address or None,
+ _("Email"): detail.new_email or None,
+ _("Reason for Split"): reason_label,
+ _("Remarks"): detail.remarks or None,
+ "_tables": tables,
}
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..71797b361 100644
--- a/spp_change_request_v2/tests/test_add_member_strategy.py
+++ b/spp_change_request_v2/tests/test_add_member_strategy.py
@@ -1,7 +1,12 @@
# 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).
-from odoo.exceptions import UserError
+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, ValidationError
from odoo.tests import TransactionCase
from .common import get_or_create_cr_type
@@ -14,137 +19,179 @@ 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_adding_head_when_group_has_head_raises(self):
+ """OP#871: the Head role is no longer hidden; choosing Head for a group
+ that already has an active head raises a validation error at save/submit
+ time (a model constraint, uniform with the other CRs)."""
+ if not self.head_kind:
+ self.skipTest("head membership-type code not present")
+ group_with_head = self.partner_model.create(
+ {"name": "Group With Head", "is_registrant": True, "is_group": True}
)
+ self._add_existing_head(group_with_head)
+ candidate = self.partner_model.create({"name": "Wannabe Head", "is_registrant": True, "is_group": False})
+
+ # The constraint fires when the detail is written (i.e. on submit), not
+ # only on apply.
+ with self.assertRaises(ValidationError) as cm:
+ self._make_cr(
+ registrant=group_with_head,
+ individual_id=candidate.id,
+ membership_type_id=self.head_kind.id,
+ )
+ self.assertIn("head", str(cm.exception).lower())
+
+ def test_adding_head_when_group_has_no_head_is_allowed(self):
+ """Choosing Head is fine when the group has no active head."""
+ if not self.head_kind:
+ self.skipTest("head membership-type code not present")
+ headless = self.partner_model.create({"name": "Headless Group", "is_registrant": True, "is_group": True})
+ candidate = self.partner_model.create({"name": "New Head", "is_registrant": True, "is_group": False})
+ cr = self._make_cr(
+ registrant=headless,
+ individual_id=candidate.id,
+ membership_type_id=self.head_kind.id,
+ )
+ cr.approval_state = "approved"
+ cr.action_apply()
+ self.assertTrue(cr.is_applied)
+ # ──────────────────────────────────────────────────────────────────
+ # 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..da3fbdb67 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,
- }
- )
-
- # 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,
- }
- )
+ 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 memberships
cls.head_membership = cls.membership_model.create(
{
"group": cls.group.id,
@@ -61,198 +42,205 @@ 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)
- 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",
+ # ──────────────────────────────────────────────────────────────────
+ # Seeding
+ # ──────────────────────────────────────────────────────────────────
+ def test_member_lines_seeded_from_active_members(self):
+ """One role line is seeded per active member; the Current Role is shown
+ but the New Role is left BLANK (not prefilled) — OP#873 QA."""
+ 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},
)
- 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."""
+ # New Role is blank for every seeded line.
+ self.assertFalse(any(detail.member_line_ids.mapped("new_role_id")))
+ if self.head_kind:
+ head_line = self._line_for(detail, self.current_head)
+ self.assertEqual(head_line.old_role_display, self.head_kind.display)
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.group.id,
- }
- )
+ 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"
+ with self.assertRaises(UserError) as cm:
+ cr.action_apply()
+ self.assertIn("group", str(cm.exception).lower())
+ # ──────────────────────────────────────────────────────────────────
+ # Apply
+ # ──────────────────────────────────────────────────────────────────
+ def test_apply_follows_new_role_blank_clears(self):
+ """OP#873 QA: the CR follows the New Role column exactly. Designating the
+ new head (and leaving the outgoing head blank) hands over the role; a
+ blank New Role clears that member's roles entirely."""
+ 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(),
- }
- )
+ # Only the new head is designated; the current head's row stays blank.
+ 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()
+ # New head holds Head; the previous head's blank row cleared all roles.
+ self.assertIn(self.head_kind, self.member_membership.membership_type_ids)
+ self.assertFalse(self.head_membership.membership_type_ids)
+ # Exactly one active head remains in the group.
+ heads = self.membership_model.search(
+ [
+ ("group", "=", self.group.id),
+ ("status", "=", "active"),
+ ("membership_type_ids", "in", self.head_kind.ids),
+ ]
+ )
+ self.assertEqual(len(heads), 1)
+
+ 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 (all set to a non-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.assertIn("head", str(cm.exception).lower())
+
+ def test_two_heads_blocked_by_constraint(self):
+ """Designating two (non-current-head) members as Head is rejected."""
+ if not self.head_kind:
+ self.skipTest("head membership-type code not present in the vocabulary")
+ # A second non-head member so we can set two heads without touching the
+ # current head (which has its own dedicated validation).
+ member2 = self.partner_model.create({"name": "Member Three", "is_registrant": True, "is_group": False})
+ self.membership_model.create(
+ {"group": self.group.id, "individual": member2.id, "start_date": fields.Datetime.now()}
+ )
+ detail = self._make_cr().get_detail()
+ self._line_for(detail, self.member).new_role_id = self.head_kind
+ with self.assertRaises(ValidationError):
+ self._line_for(detail, member2).new_role_id = self.head_kind
+
+ def test_current_head_cannot_be_set_as_head(self):
+ """The current Head of Household may not be reassigned Head in this CR."""
+ if not self.head_kind:
+ self.skipTest("head membership-type code not present in the vocabulary")
+ detail = self._make_cr().get_detail()
+ with self.assertRaises(ValidationError) as cm:
+ self._line_for(detail, self.current_head).new_role_id = self.head_kind
+ self.assertIn("current head", str(cm.exception).lower())
+
+ 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"):
+ # Fresh group + head + member each iteration (applying changes the
+ # head, so reusing one group would make the next iteration try to
+ # re-assign the now-current head).
+ grp = self.partner_model.create({"name": f"G {reason}", "is_registrant": True, "is_group": True})
+ head = self.partner_model.create({"name": f"Head {reason}", "is_registrant": True, "is_group": False})
+ new_head = self.partner_model.create({"name": f"New {reason}", "is_registrant": True, "is_group": False})
self.membership_model.create(
{
- "group": group.id,
- "individual": member1.id,
+ "group": grp.id,
+ "individual": head.id,
"start_date": fields.Datetime.now(),
- "membership_type_ids": [Command.link(self.head_kind.id)] if self.head_kind else [],
+ "membership_type_ids": [Command.link(self.head_kind.id)],
}
)
- 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.membership_model.create(
+ {"group": grp.id, "individual": new_head.id, "start_date": fields.Datetime.now()}
)
-
+ cr = self._make_cr(registrant=grp)
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, new_head).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..6509534e2 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,684 @@ 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,
- }
+ # ──────────────────────────────────────────────────────────────────
+ # 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,
+ },
+ ),
+ ],
)
- detail = cr.get_detail()
- detail.write(
- {
- # No group_name
- "city": "Manila",
- }
- )
+ 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())
+ # ──────────────────────────────────────────────────────────────────
+ # 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())
- self.assertIn("name", 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
- def test_create_group_new_head_requires_name(self):
- """Test creating new head requires name."""
+ cr = self._make_cr(group_name="Two-Head Group")
+ detail = cr.get_detail()
+ 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,
+ },
+ ),
+ ],
+ }
+ )
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.dummy_group.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()
- detail = cr.get_detail()
- detail.write(
- {
- "group_name": "Group Without Head Name",
- "create_new_head": True,
- # No head_name
- }
+ 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")
- cr.approval_state = "approved"
+ 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()
- with self.assertRaises(UserError) as cm:
- cr.action_apply()
+ # 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()
- self.assertIn("head", str(cm.exception).lower())
+ # 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_new_member_bank_accounts_flow_through(self):
+ """OP#876: a new member's Financial Information bank accounts are applied
+ to the created individual and shown in the review (not just the group's)."""
+ cr = self._make_cr(
+ group_name="Member Bank Group",
+ member_new_ids=[
+ (
+ 0,
+ 0,
+ {
+ "given_name": "Bea",
+ "family_name": "Reyes",
+ "membership_type_id": self.head_kind.id if self.head_kind else False,
+ "bank_line_ids": [
+ (0, 0, {"acc_number": "MEMBER-ACC-1", "acc_holder_name": "Bea Reyes"}),
+ ],
+ },
+ )
+ ],
+ )
- def test_create_group_preview(self):
- """Test preview returns expected structure."""
+ # Review: the new member's section carries a Bank Accounts table.
+ preview = cr.action_preview_changes()
+ sec = preview["_sections"][0]
+ bank_tbl = [t for t in sec["tables"] if t["title"] == "Bank Accounts"]
+ self.assertEqual(len(bank_tbl), 1)
+ self.assertIn("MEMBER-ACC-1", bank_tbl[0]["rows"][0])
- cr = self.cr_model.create(
+ # Apply: the bank account is created on the new individual.
+ cr.approval_state = "approved"
+ cr.action_apply()
+ new_group = cr.get_detail().created_group_id
+ membership = self.membership_model.search([("group", "=", new_group.id), ("status", "=", "active")])
+ individual = membership.individual
+ banks = self.env["res.partner.bank"].search([("partner_id", "=", individual.id)])
+ self.assertIn("MEMBER-ACC-1", banks.mapped("acc_number"))
+
+ 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..b4ab7cf6a 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)
@@ -247,7 +248,7 @@ def test_scenario_marriage_household_split(self):
"start_date": fields.Datetime.now(),
}
)
- mem_child = self.membership_model.create(
+ self.membership_model.create(
{
"group": original.id,
"individual": child.id,
@@ -265,14 +266,10 @@ def test_scenario_marriage_household_split(self):
detail1 = cr1.get_detail()
detail1.write(
{
- "members_to_split_ids": [(6, 0, [mem_child.id])],
- "new_head_membership_id": mem_child.id,
+ "member_line_ids": [(0, 0, {"individual_id": child.id})],
"new_group_name": "New Family",
"split_reason": "marriage",
- "effective_date": fields.Date.today(),
- "copy_address": False,
- "address_line1": "456 New St",
- "city": "Makati",
+ "new_address": "456 New St, Makati",
}
)
self._approve_and_apply(cr1)
@@ -280,7 +277,6 @@ def test_scenario_marriage_household_split(self):
new_household = detail1.created_group_id
self.assertTrue(new_household)
self.assertEqual(new_household.name, "New Family")
- self.assertEqual(new_household.street, "456 New St")
# Verify child is in new household
child_membership = self.membership_model.search(
@@ -292,20 +288,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 +374,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 +600,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 +629,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),
@@ -663,7 +663,6 @@ def test_scenario_full_lifecycle(self):
detail3.write(
{
"membership_id": member_mem.id,
- "end_date": fields.Date.today(),
"end_reason": "left_household",
}
)
diff --git a/spp_change_request_v2/tests/test_remove_member_strategy.py b/spp_change_request_v2/tests/test_remove_member_strategy.py
index 931132a68..6bf703d24 100644
--- a/spp_change_request_v2/tests/test_remove_member_strategy.py
+++ b/spp_change_request_v2/tests/test_remove_member_strategy.py
@@ -1,7 +1,12 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-"""Tests for Remove Member strategy."""
+"""Tests for the Remove Member strategy (OP#872).
-from odoo import fields
+End Date and Member Name fields removed; "Married Out" reason dropped; the
+review shows a header + Additional Information; the removal reason can drive
+required documents (reuses the #873 mechanism).
+"""
+
+from odoo import Command, fields
from odoo.exceptions import UserError
from odoo.tests import TransactionCase
@@ -17,205 +22,124 @@ 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"]
- # Create test group
- cls.group = cls.partner_model.create(
- {
- "name": "Test Household",
- "is_registrant": True,
- "is_group": True,
- }
- )
-
- # Create test individuals
- cls.member1 = cls.partner_model.create(
- {
- "name": "Member One",
- "is_registrant": True,
- "is_group": False,
- }
- )
- cls.member2 = cls.partner_model.create(
- {
- "name": "Member Two",
- "is_registrant": True,
- "is_group": False,
- }
- )
+ cls.group = cls.partner_model.create({"name": "Test Household", "is_registrant": True, "is_group": True})
+ cls.member1 = cls.partner_model.create({"name": "Member One", "is_registrant": True, "is_group": False})
+ cls.member2 = cls.partner_model.create({"name": "Member Two", "is_registrant": True, "is_group": False})
- # Create memberships
cls.membership1 = cls.membership_model.create(
- {
- "group": cls.group.id,
- "individual": cls.member1.id,
- "start_date": fields.Datetime.now(),
- }
+ {"group": cls.group.id, "individual": cls.member1.id, "start_date": fields.Datetime.now()}
)
cls.membership2 = cls.membership_model.create(
- {
- "group": cls.group.id,
- "individual": cls.member2.id,
- "start_date": fields.Datetime.now(),
- }
+ {"group": cls.group.id, "individual": cls.member2.id, "start_date": fields.Datetime.now()}
)
- # Get or create CR type
cls.cr_type = get_or_create_cr_type(cls.env, "remove_member")
- def test_remove_member_ends_membership(self):
- """Test removing member sets ended_date on membership."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.group.id,
- }
- )
+ def _make_cr(self, registrant=None, **detail_vals):
+ cr = self.cr_model.create({"request_type_id": self.cr_type.id, "registrant_id": (registrant or self.group).id})
+ if detail_vals:
+ cr.get_detail().write(detail_vals)
+ return cr
- detail = cr.get_detail()
- detail.write(
- {
- "membership_id": self.membership1.id,
- "end_date": fields.Date.today(),
- "end_reason": "left_household",
- }
+ # ──────────────────────────────────────────────────────────────────
+ # Apply
+ # ──────────────────────────────────────────────────────────────────
+ def test_remove_member_ends_membership(self):
+ cr = self._make_cr(
+ individual_id=self.member1.id, membership_id=self.membership1.id, end_reason="left_household"
)
-
cr.approval_state = "approved"
cr.action_apply()
-
- # Verify membership ended
self.assertTrue(cr.is_applied)
self.assertTrue(self.membership1.ended_date)
self.assertEqual(self.membership1.status, "inactive")
def test_remove_member_inactive_fails(self):
- """Test removing already inactive member fails."""
-
- # End membership first
self.membership2.write({"ended_date": fields.Datetime.now()})
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.group.id,
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "membership_id": self.membership2.id,
- "end_date": fields.Date.today(),
- "end_reason": "other",
- }
- )
-
+ cr = self._make_cr(membership_id=self.membership2.id, end_reason="other")
cr.approval_state = "approved"
-
with self.assertRaises(UserError) as cm:
cr.action_apply()
-
self.assertIn("inactive", str(cm.exception).lower())
def test_remove_member_from_individual_fails(self):
- """Test cannot remove member from individual registrant."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.member1.id, # Individual, not group
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "membership_id": self.membership1.id,
- "end_date": fields.Date.today(),
- "end_reason": "other",
- }
- )
-
+ cr = self._make_cr(registrant=self.member1, membership_id=self.membership1.id, end_reason="other")
cr.approval_state = "approved"
-
with self.assertRaises(UserError) as cm:
cr.action_apply()
-
self.assertIn("group", str(cm.exception).lower())
- def test_remove_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,
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "membership_id": self.membership1.id,
- "end_date": fields.Date.today(),
- "end_reason": "deceased",
- }
- )
-
- preview = cr.action_preview_changes()
-
- self.assertIn("_action", preview)
- self.assertEqual(preview["_action"], "remove_member")
- self.assertEqual(preview["reason"], "deceased")
-
- def test_remove_member_all_reasons(self):
- """Test all removal reasons work correctly."""
-
- reasons = [
- "left_household",
- "deceased",
- "married_out",
- "migrated",
- "correction",
- "other",
- ]
-
- for reason in reasons:
- # Create new member for each test
- member = self.partner_model.create(
- {
- "name": f"Member {reason}",
- "is_registrant": True,
- "is_group": False,
- }
- )
+ # ──────────────────────────────────────────────────────────────────
+ # Field/reason changes (OP#872)
+ # ──────────────────────────────────────────────────────────────────
+ def test_married_out_reason_removed(self):
+ detail = self._make_cr().get_detail()
+ codes = dict(detail.fields_get(["end_reason"])["end_reason"]["selection"])
+ self.assertNotIn("married_out", codes)
+ self.assertIn("left_household", codes)
+
+ def test_end_date_field_removed(self):
+ self.assertNotIn("end_date", self.env["spp.cr.detail.remove_member"]._fields)
+
+ def test_remove_member_reasons_apply(self):
+ for reason in ("left_household", "deceased", "migrated", "correction", "other"):
+ member = self.partner_model.create({"name": f"M {reason}", "is_registrant": True, "is_group": False})
membership = self.membership_model.create(
- {
- "group": self.group.id,
- "individual": member.id,
- "start_date": fields.Datetime.now(),
- }
- )
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.group.id,
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "membership_id": membership.id,
- "end_date": fields.Date.today(),
- "end_reason": reason,
- }
+ {"group": self.group.id, "individual": member.id, "start_date": fields.Datetime.now()}
)
-
+ cr = self._make_cr(individual_id=member.id, membership_id=membership.id, end_reason=reason)
cr.approval_state = "approved"
cr.action_apply()
-
self.assertTrue(cr.is_applied, f"Failed for reason: {reason}")
self.assertEqual(membership.status, "inactive")
+
+ # ──────────────────────────────────────────────────────────────────
+ # Review / preview
+ # ──────────────────────────────────────────────────────────────────
+ def test_remove_member_preview(self):
+ cr = self._make_cr(
+ individual_id=self.member1.id,
+ membership_id=self.membership1.id,
+ end_reason="deceased",
+ remarks="Passed away last month",
+ )
+ preview = cr.action_preview_changes()
+ self.assertEqual(preview["_action"], "remove_member")
+ self.assertIn("to be removed", (preview.get("_header") or "").lower())
+ self.assertEqual(preview["Member"], self.member1.display_name)
+ self.assertEqual(preview["Reason for Removal"], "Deceased")
+ self.assertEqual(preview["Additional Information"], "Passed away last month")
+ # No removed keys leak into the review.
+ for removed in ("member_name", "end_date"):
+ self.assertNotIn(removed, preview)
+ html = cr._generate_review_comparison_html()
+ self.assertIn("to be removed", html.lower())
+
+ # ──────────────────────────────────────────────────────────────────
+ # Reason -> required documents (reuses #873)
+ # ──────────────────────────────────────────────────────────────────
+ def test_removal_reason_drives_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")
+ self.cr_type.write(
+ {
+ "reason_document_ids": [
+ (5, 0, 0),
+ (0, 0, {"reason": "deceased", "required_document_ids": [Command.set(doc_type.ids)]}),
+ ],
+ }
+ )
+ cr = self._make_cr(individual_id=self.member1.id, membership_id=self.membership1.id)
+ self.assertTrue(cr.documents_complete)
+ cr.get_detail().end_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)
+ cr.get_detail().end_reason = "migrated"
+ cr.invalidate_recordset(["documents_complete", "missing_required_document_ids"])
+ self.assertTrue(cr.documents_complete)
diff --git a/spp_change_request_v2/tests/test_split_household_strategy.py b/spp_change_request_v2/tests/test_split_household_strategy.py
index 26dafacaa..3c89dd58b 100644
--- a/spp_change_request_v2/tests/test_split_household_strategy.py
+++ b/spp_change_request_v2/tests/test_split_household_strategy.py
@@ -1,5 +1,10 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-"""Tests for Split Household strategy."""
+"""Tests for the redesigned Split Household strategy (OP#877).
+
+Members to move are an editable table (member + role + per-member edits); the
+new household uses the Create-Group field set; a head is not mandatory; and the
+split reason can drive required documents (reusing the #873 mechanism).
+"""
from odoo import Command, fields
from odoo.exceptions import ValidationError
@@ -17,403 +22,176 @@ 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 group type from vocabulary
- cls.group_kind = cls.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-type", "household")
+ cls.head_kind = cls.code_model.get_code("urn:openspp:vocab:group-membership-type", "head")
+ cls.member_kind = cls.code_model.get_code("urn:openspp:vocab:group-membership-type", "member")
- # Create source group with address
cls.source_group = cls.partner_model.create(
- {
- "name": "Original Household",
- "is_registrant": True,
- "is_group": True,
- "group_type_id": cls.group_kind.id,
- "street": "100 Original St",
- "city": "Original City",
- "phone": "+63111111111",
- }
- )
-
- # Create members
- cls.member1 = cls.partner_model.create(
- {
- "name": "Member One",
- "is_registrant": True,
- "is_group": False,
- }
- )
- cls.member2 = cls.partner_model.create(
- {
- "name": "Member Two",
- "is_registrant": True,
- "is_group": False,
- }
- )
- cls.member3 = cls.partner_model.create(
- {
- "name": "Member Three",
- "is_registrant": True,
- "is_group": False,
- }
- )
- cls.member4 = cls.partner_model.create(
- {
- "name": "Member Four",
- "is_registrant": True,
- "is_group": False,
- }
+ {"name": "Original Household", "is_registrant": True, "is_group": True}
)
+ cls.head = cls.partner_model.create({"name": "The Head", "is_registrant": True, "is_group": False})
+ cls.m2 = cls.partner_model.create({"name": "Member Two", "is_registrant": True, "is_group": False})
+ cls.m3 = cls.partner_model.create({"name": "Member Three", "is_registrant": True, "is_group": False})
- # Create memberships
- cls.membership1 = cls.membership_model.create(
+ cls.membership_model.create(
{
"group": cls.source_group.id,
- "individual": cls.member1.id,
+ "individual": cls.head.id,
"start_date": fields.Datetime.now(),
"membership_type_ids": [Command.link(cls.head_kind.id)] if cls.head_kind else [],
}
)
- cls.membership2 = cls.membership_model.create(
- {
- "group": cls.source_group.id,
- "individual": cls.member2.id,
- "start_date": fields.Datetime.now(),
- }
- )
- cls.membership3 = cls.membership_model.create(
- {
- "group": cls.source_group.id,
- "individual": cls.member3.id,
- "start_date": fields.Datetime.now(),
- }
- )
- cls.membership4 = cls.membership_model.create(
- {
- "group": cls.source_group.id,
- "individual": cls.member4.id,
- "start_date": fields.Datetime.now(),
- }
- )
+ for member in (cls.m2, cls.m3):
+ cls.membership_model.create(
+ {"group": cls.source_group.id, "individual": member.id, "start_date": fields.Datetime.now()}
+ )
- # Get or create CR type
cls.cr_type = get_or_create_cr_type(cls.env, "split_household")
- def test_split_creates_new_group(self):
- """Test splitting creates new group."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.source_group.id,
- }
+ # ──────────────────────────────────────────────────────────────────
+ # Helpers
+ # ──────────────────────────────────────────────────────────────────
+ def _make_cr(self, **detail_vals):
+ cr = self.cr_model.create({"request_type_id": self.cr_type.id, "registrant_id": self.source_group.id})
+ if detail_vals:
+ cr.get_detail().write(detail_vals)
+ return cr
+
+ def _line(self, individual, **edits):
+ vals = {"individual_id": individual.id}
+ vals.update(edits)
+ return (0, 0, vals)
+
+ def _active_membership(self, group, individual):
+ return self.membership_model.search(
+ [("group", "=", group.id), ("individual", "=", individual.id), ("status", "=", "active")]
+ )
+
+ # ──────────────────────────────────────────────────────────────────
+ # Core flow
+ # ──────────────────────────────────────────────────────────────────
+ def test_split_creates_new_group_and_moves_members(self):
+ cr = self._make_cr(
+ new_group_name="Split Household",
+ split_reason="independence",
+ member_line_ids=[self._line(self.m3)],
)
-
- detail = cr.get_detail()
- detail.write(
- {
- "members_to_split_ids": [(6, 0, [self.membership3.id, self.membership4.id])],
- "new_head_membership_id": self.membership3.id,
- "new_group_name": "Split Household",
- "split_reason": "independence",
- "effective_date": fields.Date.today(),
- "copy_address": True,
- }
- )
-
cr.approval_state = "approved"
cr.action_apply()
- # Verify new group created
self.assertTrue(cr.is_applied)
- self.assertTrue(detail.created_group_id)
-
- new_group = detail.created_group_id
+ new_group = cr.get_detail().created_group_id
+ self.assertTrue(new_group)
self.assertEqual(new_group.name, "Split Household")
- self.assertTrue(new_group.is_registrant)
self.assertTrue(new_group.is_group)
-
- def test_split_copies_address(self):
- """Test splitting copies address from source."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.source_group.id,
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "members_to_split_ids": [(6, 0, [self.membership3.id])],
- "new_head_membership_id": self.membership3.id,
- "new_group_name": "Address Copy Test",
- "split_reason": "marriage",
- "effective_date": fields.Date.today(),
- "copy_address": True,
- }
+ # m3 now active in the new group, no longer active in the source.
+ self.assertTrue(self._active_membership(new_group, self.m3))
+ self.assertFalse(self._active_membership(self.source_group, self.m3))
+ # head + m2 remain in the source.
+ self.assertTrue(self._active_membership(self.source_group, self.head))
+
+ def test_role_assigned_in_new_group(self):
+ if not self.member_kind:
+ self.skipTest("member role code not present")
+ cr = self._make_cr(
+ new_group_name="Roled Split",
+ member_line_ids=[self._line(self.m3, membership_type_id=self.member_kind.id)],
)
-
cr.approval_state = "approved"
cr.action_apply()
+ membership = self._active_membership(cr.get_detail().created_group_id, self.m3)
+ self.assertIn(self.member_kind, membership.membership_type_ids)
- # Verify address copied
- new_group = detail.created_group_id
- self.assertEqual(new_group.street, self.source_group.street)
- self.assertEqual(new_group.city, self.source_group.city)
- self.assertEqual(new_group.phone, self.source_group.phone)
-
- def test_split_transfers_members(self):
- """Test splitting transfers selected members."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.source_group.id,
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "members_to_split_ids": [(6, 0, [self.membership3.id, self.membership4.id])],
- "new_head_membership_id": self.membership3.id,
- "new_group_name": "Transfer Test",
- "split_reason": "separation",
- "effective_date": fields.Date.today(),
- "copy_address": True,
- }
- )
-
+ def test_head_not_mandatory(self):
+ """A new household can be created without designating a head (per spec)."""
+ cr = self._make_cr(new_group_name="Headless Split", member_line_ids=[self._line(self.m3)])
cr.approval_state = "approved"
cr.action_apply()
+ self.assertTrue(cr.is_applied)
- # Verify old memberships ended
- self.assertTrue(self.membership3.ended_date)
- self.assertTrue(self.membership4.ended_date)
-
- # Verify new memberships created
- new_group = detail.created_group_id
- new_memberships = self.membership_model.search(
- [
- ("group", "=", new_group.id),
- ("status", "=", "active"),
- ]
- )
- self.assertEqual(len(new_memberships), 2)
-
- members = new_memberships.mapped("individual")
- self.assertIn(self.member3, members)
- self.assertIn(self.member4, members)
-
- def test_split_assigns_head_role(self):
- """Test splitting assigns head role to new head."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.source_group.id,
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "members_to_split_ids": [(6, 0, [self.membership3.id])],
- "new_head_membership_id": self.membership3.id,
- "new_group_name": "Head Role Test",
- "split_reason": "relocation",
- "effective_date": fields.Date.today(),
- "copy_address": True,
- }
- )
-
- cr.approval_state = "approved"
- cr.action_apply()
-
- # Verify new head has head role
- new_group = detail.created_group_id
- new_head_membership = self.membership_model.search(
- [
- ("group", "=", new_group.id),
- ("individual", "=", self.member3.id),
- ("status", "=", "active"),
- ]
- )
- self.assertIn(self.head_kind, new_head_membership.membership_type_ids)
-
- def test_split_cannot_move_all_members(self):
- """Test cannot move all members from source group."""
-
- # Create group with only 2 members
- small_group = self.partner_model.create(
- {
- "name": "Small Group",
- "is_registrant": True,
- "is_group": True,
- }
- )
- m1 = self.partner_model.create({"name": "M1", "is_registrant": True, "is_group": False})
- m2 = self.partner_model.create({"name": "M2", "is_registrant": True, "is_group": False})
- mem1 = self.membership_model.create(
- {
- "group": small_group.id,
- "individual": m1.id,
- "start_date": fields.Datetime.now(),
- }
- )
- mem2 = self.membership_model.create(
- {
- "group": small_group.id,
- "individual": m2.id,
- "start_date": fields.Datetime.now(),
- }
- )
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": small_group.id,
- }
- )
-
- detail = cr.get_detail()
-
- with self.assertRaises(ValidationError):
- detail.write(
- {
- "members_to_split_ids": [(6, 0, [mem1.id, mem2.id])], # All members
- "new_head_membership_id": mem1.id,
- "new_group_name": "Invalid Split",
- "split_reason": "other",
- "effective_date": fields.Date.today(),
- }
- )
-
- def test_split_head_must_be_in_split(self):
- """Test new head must be in members to split."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.source_group.id,
- }
- )
-
- detail = cr.get_detail()
-
+ # ──────────────────────────────────────────────────────────────────
+ # Validation
+ # ──────────────────────────────────────────────────────────────────
+ def test_minimum_one_member_remains(self):
+ """Moving every member is rejected (at least one must remain)."""
with self.assertRaises(ValidationError):
- detail.write(
- {
- "registrant_member_to_split_ids": [(6, 0, [self.member3.id])],
- "new_head_individual_id": self.member4.id, # Not in split list
- "new_group_name": "Invalid Head",
- "split_reason": "other",
- "effective_date": fields.Date.today(),
- }
- )
-
- def test_split_all_reasons(self):
- """Test all split reasons work."""
-
- reasons = [
- "marriage",
- "separation",
- "independence",
- "relocation",
- "correction",
- "other",
- ]
-
- for reason in reasons:
- # Create fresh group for each test
- group = self.partner_model.create(
- {
- "name": f"Group {reason}",
- "is_registrant": True,
- "is_group": True,
- }
- )
- m1 = self.partner_model.create(
- {
- "name": f"M1 {reason}",
- "is_registrant": True,
- "is_group": False,
- }
- )
- m2 = self.partner_model.create(
- {
- "name": f"M2 {reason}",
- "is_registrant": True,
- "is_group": False,
- }
- )
- self.membership_model.create(
- {
- "group": group.id,
- "individual": m1.id,
- "start_date": fields.Datetime.now(),
- }
- )
- mem2 = self.membership_model.create(
- {
- "group": group.id,
- "individual": m2.id,
- "start_date": fields.Datetime.now(),
- }
+ self._make_cr(
+ new_group_name="Empties Source",
+ member_line_ids=[self._line(self.head), self._line(self.m2), self._line(self.m3)],
)
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": group.id,
- }
- )
-
- detail = cr.get_detail()
- detail.write(
- {
- "members_to_split_ids": [(6, 0, [mem2.id])],
- "new_head_membership_id": mem2.id,
- "new_group_name": f"New {reason}",
- "split_reason": reason,
- "effective_date": fields.Date.today(),
- "copy_address": True,
- }
- )
-
- cr.approval_state = "approved"
- cr.action_apply()
-
- self.assertTrue(cr.is_applied, f"Failed for reason: {reason}")
-
- def test_split_preview(self):
- """Test preview returns expected structure."""
-
- cr = self.cr_model.create(
- {
- "request_type_id": self.cr_type.id,
- "registrant_id": self.source_group.id,
- }
+ def test_available_members_exclude_head(self):
+ """The source head is not offered as a movable member."""
+ available = self._make_cr().get_detail().available_member_ids
+ self.assertIn(self.m2, available)
+ self.assertIn(self.m3, available)
+ if self.head_kind:
+ self.assertNotIn(self.head, available)
+
+ # ──────────────────────────────────────────────────────────────────
+ # Per-member edits (Edit Member modal)
+ # ──────────────────────────────────────────────────────────────────
+ def test_member_edits_applied_on_move(self):
+ if "family_name" not in self.partner_model._fields:
+ self.skipTest("registry name fields not present")
+ cr = self._make_cr(
+ new_group_name="Edited Split",
+ member_line_ids=[self._line(self.m3, family_name="Renamed")],
)
-
- detail = cr.get_detail()
- detail.write(
- {
- "members_to_split_ids": [(6, 0, [self.membership3.id, self.membership4.id])],
- "new_head_membership_id": self.membership3.id,
- "new_group_name": "Preview Split",
- "split_reason": "marriage",
- "effective_date": fields.Date.today(),
- "copy_address": True,
- }
+ cr.approval_state = "approved"
+ cr.action_apply()
+ self.m3.invalidate_recordset()
+ self.assertEqual(self.m3.family_name, "Renamed")
+
+ # ──────────────────────────────────────────────────────────────────
+ # Preview / review page
+ # ──────────────────────────────────────────────────────────────────
+ def test_preview_header_and_tables(self):
+ edits = {"family_name": "Renamed"} if "family_name" in self.partner_model._fields else {}
+ cr = self._make_cr(
+ new_group_name="Preview Split",
+ split_reason="marriage",
+ member_line_ids=[self._line(self.m2), self._line(self.m3, **edits)],
)
-
preview = cr.action_preview_changes()
-
- self.assertIn("_action", preview)
self.assertEqual(preview["_action"], "split_household")
- self.assertEqual(preview["members_count"], 2)
+ self.assertIn("new household will be created", (preview.get("_header") or "").lower())
+ self.assertEqual(preview["New Household Name"], "Preview Split")
+ titles = [t["title"] for t in preview["_tables"]]
+ self.assertIn("Members to Move", titles)
+ if edits:
+ self.assertIn("Member Edits", titles)
+ html = cr._generate_review_comparison_html()
+ self.assertIn("new household will be created", html.lower())
+
+ # ──────────────────────────────────────────────────────────────────
+ # Phase C: reason-for-split -> required documents (reuses #873)
+ # ──────────────────────────────────────────────────────────────────
+ def test_split_reason_drives_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")
+ self.cr_type.write(
+ {
+ "reason_document_ids": [
+ (5, 0, 0),
+ (0, 0, {"reason": "marriage", "required_document_ids": [Command.set(doc_type.ids)]}),
+ ],
+ }
+ )
+ cr = self._make_cr(new_group_name="Docs Split", member_line_ids=[self._line(self.m3)])
+
+ # No reason yet -> falls back to the (empty) flat list -> complete.
+ self.assertTrue(cr.documents_complete)
+ # Split reason with a rule -> the rule's docs are required.
+ cr.get_detail().split_reason = "marriage"
+ 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 without a rule -> nothing required.
+ cr.get_detail().split_reason = "relocation"
+ cr.invalidate_recordset(["documents_complete", "missing_required_document_ids"])
+ self.assertTrue(cr.documents_complete)
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.
+