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'' for c in columns) + + "" + ) + out.append("") + for row in rows: + out.append( + "" + "".join(f"" for c in row) + "" + ) + out.append("
{html_escape(c)}
{html_escape('' if c is None else str(c))}
") + 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'' + f"" + ) + out.append("
{html_escape(label)}{display}
") + 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). +
+ + + + + + +
+ + + + spp.cr.detail.create_group.member.wizard.form + spp.cr.detail.create_group.member.wizard + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+
diff --git a/spp_change_request_v2/views/detail_add_member_views.xml b/spp_change_request_v2/views/detail_add_member_views.xml index 76d5cf203..21dffaad0 100644 --- a/spp_change_request_v2/views/detail_add_member_views.xml +++ b/spp_change_request_v2/views/detail_add_member_views.xml @@ -1,6 +1,6 @@ - + spp.cr.detail.add_member.form spp.cr.detail.add_member @@ -10,6 +10,9 @@ >
+ + +
- + + + + + - - - - - - - - - - - - + + + - - - + + + + + - - - - - - - + + + diff --git a/spp_change_request_v2/views/detail_change_hoh_views.xml b/spp_change_request_v2/views/detail_change_hoh_views.xml index f8f39ce28..573dea61a 100644 --- a/spp_change_request_v2/views/detail_change_hoh_views.xml +++ b/spp_change_request_v2/views/detail_change_hoh_views.xml @@ -1,6 +1,6 @@ - + spp.cr.detail.change_hoh.form spp.cr.detail.change_hoh @@ -45,44 +45,50 @@ - - - - - - + + - - + + + + + + + + - - - - + + + + diff --git a/spp_change_request_v2/views/detail_create_group_views.xml b/spp_change_request_v2/views/detail_create_group_views.xml index 7f1efb407..c9a7d9c37 100644 --- a/spp_change_request_v2/views/detail_create_group_views.xml +++ b/spp_change_request_v2/views/detail_create_group_views.xml @@ -1,6 +1,6 @@ - + spp.cr.detail.create_group.form spp.cr.detail.create_group @@ -11,6 +11,10 @@ >
+ + + +
+ + + - - + + - - - - - + - - - - - - + + - - - - - - - - - + + + + + + + + + - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + +
+
+ + + + + + + + + - - - - - - - - - - - - - -

- Select individuals to move from the source household to create a new household. - The household head cannot be moved. -

+ + +
+ - - - + + - + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Only members that are not head of the source household can be moved. + Click a row to open the member, set their role, and edit their information. +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + +
+
diff --git a/spp_change_request_v2/wizards/__init__.py b/spp_change_request_v2/wizards/__init__.py index ddd1663c6..e184712b5 100644 --- a/spp_change_request_v2/wizards/__init__.py +++ b/spp_change_request_v2/wizards/__init__.py @@ -5,3 +5,4 @@ from . import batch_approval_wizard from . import conflict_comparison_wizard from . import revision_wizard +from . import create_group_member_wizard diff --git a/spp_change_request_v2/wizards/create_group_member_wizard.py b/spp_change_request_v2/wizards/create_group_member_wizard.py new file mode 100644 index 000000000..d17055554 --- /dev/null +++ b/spp_change_request_v2/wizards/create_group_member_wizard.py @@ -0,0 +1,273 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Add Member wizard for the Create Group CR (OP#876). + +A single transient model handles two cases: +- ``mode = 'existing'`` — pick an individual already in the registry. +- ``mode = 'new'`` — collect the minimum field set for a new individual, + which the apply strategy later creates. + +The wizard is opened from two buttons on the detail form (one per mode) and +can also be re-opened pre-populated to edit an existing **new** row. Existing +rows are immutable once added: to change them the user deletes and re-adds. +""" + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class SPPCRCreateGroupMemberWizard(models.TransientModel): + _name = "spp.cr.detail.create_group.member.wizard" + _description = "Create Group — Add Member Wizard" + + detail_id = fields.Many2one( + "spp.cr.detail.create_group", + required=True, + ondelete="cascade", + ) + mode = fields.Selection( + [ + ("existing", "Existing Individual"), + ("new", "New Individual"), + ], + required=True, + ) + + # ────────────────────────────────────────────────────────────────── + # Existing-mode fields + # ────────────────────────────────────────────────────────────────── + individual_id = fields.Many2one( + "res.partner", + string="Individual", + domain="[('is_group', '=', False), ('is_registrant', '=', True)]", + ) + + # ────────────────────────────────────────────────────────────────── + # New-mode fields (mirror the registry individual overview — OP#876) + # ────────────────────────────────────────────────────────────────── + given_name = fields.Char() + family_name = fields.Char() + middle_name = fields.Char() + birthdate = fields.Date(string="Date of Birth") + is_approximate_birthdate = fields.Boolean(string="Approximate Birthdate") + birth_place = fields.Char(string="Birth Place") + occupation_id = fields.Many2one( + "spp.vocabulary.code", + string="Occupation", + domain="[('vocabulary_id.namespace_uri', '=', 'urn:ilo:isco-08')]", + ) + gender_id = fields.Many2one( + "spp.vocabulary.code", + string="Gender", + domain="[('namespace_uri', '=', 'urn:iso:std:iso:5218')]", + ) + civil_status_id = fields.Many2one( + "spp.vocabulary.code", + string="Civil Status", + domain="[('vocabulary_id.namespace_uri', '=', 'urn:un:unsd:pop-census:marital-status')]", + ) + income = fields.Float(string="Income") + area_id = fields.Many2one("spp.area", string="Area") + address = fields.Text(string="Address") + email = fields.Char(string="Email") + phone_line_ids = fields.One2many( + "spp.cr.detail.create_group.member.wizard.phone", + "wizard_id", + string="Phone Numbers", + ) + bank_line_ids = fields.One2many( + "spp.cr.detail.create_group.member.wizard.bank", + "wizard_id", + string="Bank Accounts", + ) + + # ────────────────────────────────────────────────────────────────── + # Both modes + # ────────────────────────────────────────────────────────────────── + membership_type_id = fields.Many2one( + "spp.vocabulary.code", + string="Role", + domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:group-membership-type')]", + ) + + # Edit-mode handle: only meaningful when ``mode == 'new'``. When set, + # ``_persist`` updates this row instead of creating a new one. + editing_member_new_id = fields.Many2one( + "spp.cr.detail.create_group.member_new", + string="Editing Row", + ) + + # Convenience for the view: show "Edit" vs "Add" labels on buttons. + is_editing = fields.Boolean(compute="_compute_is_editing") + + @api.depends("editing_member_new_id") + def _compute_is_editing(self): + for rec in self: + rec.is_editing = bool(rec.editing_member_new_id) + + # ────────────────────────────────────────────────────────────────── + # Validation + # ────────────────────────────────────────────────────────────────── + def _validate(self): + self.ensure_one() + if self.mode == "existing": + if not self.individual_id: + raise UserError(_("Pick an individual before adding.")) + already_added = self.detail_id.member_existing_ids.filtered( + lambda m: m.individual_id.id == self.individual_id.id + ) + if already_added: + raise UserError(_("'%s' is already in the existing-members list.") % self.individual_id.name) + elif self.mode == "new": + if not self.given_name or not self.family_name: + raise UserError(_("Given name and family name are both required for a new individual.")) + + # Block a second Head of Household with a clear message (the model-level + # constraint on the member rows is the safety net). + if self.membership_type_id and self.membership_type_id.code == "head": + existing_heads, new_heads = self.detail_id._heads() + if self.editing_member_new_id: + new_heads = new_heads.filtered(lambda m: m.id != self.editing_member_new_id.id) + if existing_heads or new_heads: + raise UserError(_("This group already has a Head of Household. Only one member can be Head.")) + + # ────────────────────────────────────────────────────────────────── + # Persist the wizard's payload to the detail's O2M tables + # ────────────────────────────────────────────────────────────────── + def _persist(self): + self._validate() + if self.mode == "existing": + self.env["spp.cr.detail.create_group.member_existing"].create( + { + "detail_id": self.detail_id.id, + "individual_id": self.individual_id.id, + "membership_type_id": self.membership_type_id.id if self.membership_type_id else False, + } + ) + return + + # mode == 'new' + # Copy the wizard's transient phone lines onto the member_new row. + # detail_id is forced False: the wizard is opened with a + # default_detail_id context (for the member row), and the phone model + # also has a detail_id field, so that default would otherwise leak onto + # the phone rows and give them two parents (detail_id + member_new_id). + phone_cmds = [ + (0, 0, {"phone_no": pl.phone_no, "country_id": pl.country_id.id, "detail_id": False}) + for pl in self.phone_line_ids + if pl.phone_no + ] + # Same two-parent guard as phones: force detail_id False so the default + # context detail_id does not leak onto the bank rows. + bank_cmds = [ + ( + 0, + 0, + { + "acc_number": bl.acc_number, + "acc_holder_name": bl.acc_holder_name, + "bank_id": bl.bank_id.id, + "detail_id": False, + }, + ) + for bl in self.bank_line_ids + if bl.acc_number + ] + vals = { + "given_name": self.given_name, + "family_name": self.family_name, + "middle_name": self.middle_name, + "birthdate": self.birthdate, + "is_approximate_birthdate": self.is_approximate_birthdate, + "birth_place": self.birth_place, + "occupation_id": self.occupation_id.id if self.occupation_id else False, + "gender_id": self.gender_id.id if self.gender_id else False, + "civil_status_id": self.civil_status_id.id if self.civil_status_id else False, + "income": self.income, + "area_id": self.area_id.id if self.area_id else False, + "address": self.address, + "email": self.email, + "membership_type_id": self.membership_type_id.id if self.membership_type_id else False, + } + if self.editing_member_new_id: + # Replace the existing phone/bank rows with the wizard's current set. + # Delete (2) the old rows rather than clear (5): clearing a + # one2many only nulls the inverse FK, which would orphan the rows + # and trip the row's exactly-one-parent constraint. + delete_phone = [(2, p.id, 0) for p in self.editing_member_new_id.phone_line_ids] + delete_bank = [(2, b.id, 0) for b in self.editing_member_new_id.bank_line_ids] + vals["phone_line_ids"] = delete_phone + phone_cmds + vals["bank_line_ids"] = delete_bank + bank_cmds + self.editing_member_new_id.write(vals) + else: + vals["detail_id"] = self.detail_id.id + vals["phone_line_ids"] = phone_cmds + vals["bank_line_ids"] = bank_cmds + self.env["spp.cr.detail.create_group.member_new"].create(vals) + + # ────────────────────────────────────────────────────────────────── + # Buttons + # ────────────────────────────────────────────────────────────────── + def action_add(self): + """Persist + reopen the wizard fresh so the user can add another row.""" + self.ensure_one() + self._persist() + if self.is_editing: + # Editing is a one-shot operation; close after saving even on the + # plain "Save" button. + return {"type": "ir.actions.act_window_close"} + return { + "type": "ir.actions.act_window", + "res_model": self._name, + "view_mode": "form", + "target": "new", + "context": { + "default_detail_id": self.detail_id.id, + "default_mode": self.mode, + }, + } + + def action_add_close(self): + self.ensure_one() + self._persist() + return {"type": "ir.actions.act_window_close"} + + +class SPPCRCreateGroupMemberWizardPhone(models.TransientModel): + """Transient phone row for the Add Member wizard's editable list. + + Persisted onto ``member_new.phone_line_ids`` when the wizard saves; on apply + the new individual's phone numbers are concatenated into the partner's + single header phone field. + """ + + _name = "spp.cr.detail.create_group.member.wizard.phone" + _description = "Create Group — Add Member Wizard Phone" + _order = "is_primary desc, id" + + wizard_id = fields.Many2one( + "spp.cr.detail.create_group.member.wizard", + required=True, + ondelete="cascade", + ) + phone_no = fields.Char(string="Phone Number", required=True) + country_id = fields.Many2one("res.country", string="Country") + is_primary = fields.Boolean(string="Primary") + + +class SPPCRCreateGroupMemberWizardBank(models.TransientModel): + """Transient bank-account row for the Add Member wizard's editable list. + + Persisted onto ``member_new.bank_line_ids`` when the wizard saves; on apply + each becomes a res.partner.bank on the new individual (OP#876).""" + + _name = "spp.cr.detail.create_group.member.wizard.bank" + _description = "Create Group — Add Member Wizard Bank" + + wizard_id = fields.Many2one( + "spp.cr.detail.create_group.member.wizard", + required=True, + 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") diff --git a/spp_change_request_v2/wizards/create_wizard.py b/spp_change_request_v2/wizards/create_wizard.py index aa6524941..2f1c25438 100644 --- a/spp_change_request_v2/wizards/create_wizard.py +++ b/spp_change_request_v2/wizards/create_wizard.py @@ -130,13 +130,37 @@ def _compute_show_registrant(self): for rec in self: rec.show_registrant = bool(rec.request_type_id and rec.request_type_id.is_requires_registrant) - @api.depends("request_type_id", "request_type_id.target_type") + @api.depends("request_type_id", "request_type_id.target_type", "request_type_id.is_requires_registrant") def _compute_target_type_message(self): for rec in self: rec.target_type_message = "" - if not rec.request_type_id or not rec.request_type_id.target_type: + cr_type = rec.request_type_id + if not cr_type: continue - target_type = rec.request_type_id.target_type + + # For types that *don't* require a registrant, the registrant is + # what the CR is about to create — say so explicitly instead of + # the generic "applies to X" hint so the user understands why no + # registrant picker shows up below. + if not cr_type.is_requires_registrant: + target_type = cr_type.target_type + if target_type == "individual": + rec.target_type_message = _( + "This request type creates a new individual — no existing registrant is needed." + ) + elif target_type == "group": + rec.target_type_message = _( + "This request type creates a new group/household — no existing registrant is needed." + ) + else: + rec.target_type_message = _( + "This request type creates a new registrant — no existing one is needed." + ) + continue + + if not cr_type.target_type: + continue + target_type = cr_type.target_type if target_type == "individual": rec.target_type_message = _("This request type applies to individuals only.") elif target_type == "group": diff --git a/spp_cr_types_advanced/data/cr_types.xml b/spp_cr_types_advanced/data/cr_types.xml index 180d36f02..e6b0c89a5 100644 --- a/spp_cr_types_advanced/data/cr_types.xml +++ b/spp_cr_types_advanced/data/cr_types.xml @@ -163,6 +163,10 @@ spp.cr.apply.create_group fa-home 90 + + False False diff --git a/spp_mis_demo_v2/models/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py index ebad1a3bc..ea29b1bb8 100644 --- a/spp_mis_demo_v2/models/mis_demo_generator.py +++ b/spp_mis_demo_v2/models/mis_demo_generator.py @@ -2745,8 +2745,7 @@ def _get_demo_user(self, role): "proposed_changes": { "group_name": "Ramos", "head_name": "Maricel Ramos", - "address_line1": "123 Marriage Lane", - "city": "New Family City", + "address": "123 Marriage Lane, New Family City", }, }, # Phase 5.1 & 5.2: Add split_household CR (REJECTED) @@ -3106,6 +3105,90 @@ def _set_cr_state(self, cr, target_state, apply=False, rejection_reason=None, re } ) + def _add_member_detail_vals(self, proposed_changes): + """Register the individual for an Add Member CR and return detail vals (OP#871). + + Add Member now adds an EXISTING individual, so the MIS "new baby" scenario + registers the person first and the CR references them via individual_id. + """ + rel_xmlid = proposed_changes.get("relationship_xmlid") + membership_type_id = False + if rel_xmlid: + code = self.env.ref(rel_xmlid, raise_if_not_found=False) + membership_type_id = code.id if code else False + given = (proposed_changes.get("given_name") or "").strip() + family = (proposed_changes.get("family_name") or "").strip() + if family and given: + full_name = f"{family.upper()}, {given}" + elif family: + full_name = family.upper() + else: + full_name = given or "New Member" + Partner = self.env["res.partner"] + partner_vals = {"name": full_name, "is_registrant": True, "is_group": False} + for fname, value in [ + ("given_name", given), + ("family_name", family), + ("birthdate", proposed_changes.get("birthdate")), + ]: + if value and fname in Partner._fields: + partner_vals[fname] = value + individual = Partner.create(partner_vals) + return {"individual_id": individual.id, "membership_type_id": membership_type_id} + + def _change_hoh_member_lines(self, registrant, new_head_name): + """Rebuild the Change HoH member role lines (OP#873). + + Returns member_line_ids commands promoting the named new head to "head" + and demoting whoever currently holds it (to an existing non-head role, or + any non-head role, so the apply step does not skip the line and leave two + heads). Returns None when no matching new head is found. + """ + if not new_head_name: + return None + name_parts = new_head_name.split() + given_name = name_parts[0] if name_parts else new_head_name + new_head = self.env["res.partner"].search( + [("given_name", "ilike", given_name), ("is_group", "=", False), ("is_registrant", "=", True)], + limit=1, + ) + if not new_head: + return None + Code = self.env["spp.vocabulary.code"] + head_code = Code.get_code("urn:openspp:vocab:group-membership-type", "head") + non_head_codes = Code.search( + [ + ("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:group-membership-type"), + ("code", "!=", "head"), + ] + ) + memberships = self.env["spp.group.membership"].search( + [("group", "=", registrant.id), ("status", "=", "active")] + ) + lines = [(5, 0, 0)] + for m in memberships: + current_roles = m.membership_type_ids + if m.individual == new_head: + new_role = head_code + elif head_code and head_code in current_roles: + remaining = current_roles.filtered(lambda r, h=head_code: r != h) + new_role = remaining[:1] or non_head_codes[:1] + else: + new_role = current_roles[:1] + lines.append( + ( + 0, + 0, + { + "individual_id": m.individual.id, + "membership_id": m.id, + "old_role_display": ", ".join(current_roles.mapped("display")), + "new_role_id": new_role.id if new_role else False, + }, + ) + ) + return lines + def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_def): """Map proposed_changes to CR detail fields for V2 CR types.""" vals = {} @@ -3139,22 +3222,9 @@ def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_d } ) elif detail_model == "spp.cr.detail.add_member": - rel_xmlid = proposed_changes.get("relationship_xmlid") - relationship_id = False - if rel_xmlid: - relationship_id = self.env.ref(rel_xmlid, raise_if_not_found=False) - relationship_id = relationship_id.id if relationship_id else False - vals.update( - { - "given_name": proposed_changes.get("given_name"), - "family_name": proposed_changes.get("family_name"), - "member_name": " ".join( - filter(None, [proposed_changes.get("given_name"), proposed_changes.get("family_name")]) - ), - "birthdate": proposed_changes.get("birthdate"), - "relationship_id": relationship_id, - } - ) + # OP#871: add_member selects an EXISTING individual; register the + # MIS "new baby" first, then the CR adds them to the group. + vals.update(self._add_member_detail_vals(proposed_changes)) elif detail_model == "spp.cr.detail.transfer_member": member_name = proposed_changes.get("member_name") target_story = proposed_changes.get("target_group_story") @@ -3188,20 +3258,11 @@ def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_d } ) elif detail_model == "spp.cr.detail.change_hoh": - new_head_name = proposed_changes.get("new_head_name") - if new_head_name: - # Search by given_name for case-insensitive match - name_parts = new_head_name.split() - given_name = name_parts[0] if name_parts else new_head_name - individual = self.env["res.partner"].search( - [ - ("given_name", "ilike", given_name), - ("is_group", "=", False), - ("is_registrant", "=", True), - ], - limit=1, - ) - vals["new_head_id"] = individual.id if individual else False + # OP#873: change_hoh uses per-member role lines instead of a single + # new_head_id — rebuild the seeded lines (see helper). + lines = self._change_hoh_member_lines(registrant, proposed_changes.get("new_head_name")) + if lines is not None: + vals["member_line_ids"] = lines # Phase 5.1: Add remove_member support elif detail_model == "spp.cr.detail.remove_member": member_name = proposed_changes.get("member_name") @@ -3226,16 +3287,37 @@ def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_d } ) # Phase 5.1: Add create_group support + # + # OP#876 redesigned the detail model — head info now lives on a + # member_new_ids sub-record with the "head" membership-type code. + # Split `head_name` ("Maricel Ramos") into given + family so the + # downstream CR has a real new-member row. elif detail_model == "spp.cr.detail.create_group": vals.update( { "group_name": proposed_changes.get("group_name", "New Household"), - "head_name": proposed_changes.get("head_name", ""), - "address_line1": proposed_changes.get("address_line1", ""), - "city": proposed_changes.get("city", ""), - "postal_code": proposed_changes.get("postal_code", ""), + "address": proposed_changes.get("address", ""), } ) + head_name = proposed_changes.get("head_name", "").strip() + if head_name: + parts = head_name.split(None, 1) + given = parts[0] + family = parts[1] if len(parts) > 1 else parts[0] + head_kind = self.env["spp.vocabulary.code"].get_code( + "urn:openspp:vocab:group-membership-type", "head" + ) + vals["member_new_ids"] = [ + ( + 0, + 0, + { + "given_name": given, + "family_name": family, + "membership_type_id": head_kind.id if head_kind else False, + }, + ) + ] # Phase 5.1: Add split_household support elif detail_model == "spp.cr.detail.split_household": vals.update(