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