From 12255bbe0ab70767e0cc70ce4db6c9caca16fdfc Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 2 Jun 2026 15:51:21 +0800 Subject: [PATCH 01/20] feat(spp_change_request_v2): redesign Create Group CR (OP#876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat head-of-household fields on the Create Group detail with member-list sub-tables and per-type configuration: - Detail: drop head_individual_id / create_new_head / head_* fields; add phone_line_ids, bank_line_ids, id_doc_line_ids, member_existing_ids, member_new_ids sub-tables, plus area_id and latitude/longitude. - CR type: add allow_empty_members and requires_head booleans so each group-creating type ships its own rules; apply strategy validates both. - Wizard: new spp.cr.detail.create_group.member.wizard handles add/edit for existing + new members in one transient model. - Strategy: rewrite apply() into _validate / _create_group / _attach_* helpers; preview() reports per-sub-table counts and head label. - View: notebook 'Members' page with per-mode alert blocks and primary buttons; member lists are wizard-only (create=0 edit=0). - Type registration: create_group sets is_requires_registrant=False — the CR creates the registrant, so the picker is hidden in the create wizard. create_wizard.py renders a tailored "creates a new ..." hint. - MIS demo generator: split head_name into given/family and emit a member_new_ids row with the 'head' role. - Security: ACLs for the 5 new sub-models + wizard. - Tests: rewrite test_create_group_strategy.py around the new schema (empty / existing-head / new-head / mixed / name-required); update two E2E call sites in test_e2e_workflows.py. --- spp_change_request_v2/__manifest__.py | 1 + spp_change_request_v2/details/create_group.py | 380 +++++++++++--- .../models/change_request_type.py | 19 + .../security/ir.model.access.csv | 24 + .../strategies/create_group.py | 267 ++++++---- .../tests/test_create_group_strategy.py | 464 ++++++++++++------ .../tests/test_e2e_workflows.py | 32 +- .../create_group_member_wizard_views.xml | 78 +++ .../views/detail_create_group_views.xml | 218 ++++---- spp_change_request_v2/wizards/__init__.py | 1 + .../wizards/create_group_member_wizard.py | 153 ++++++ .../wizards/create_wizard.py | 30 +- spp_cr_types_advanced/data/cr_types.xml | 4 + spp_mis_demo_v2/models/mis_demo_generator.py | 25 +- 14 files changed, 1311 insertions(+), 385 deletions(-) create mode 100644 spp_change_request_v2/views/create_group_member_wizard_views.xml create mode 100644 spp_change_request_v2/wizards/create_group_member_wizard.py 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/create_group.py b/spp_change_request_v2/details/create_group.py index e1c605e63..91f770f2a 100644 --- a/spp_change_request_v2/details/create_group.py +++ b/spp_change_request_v2/details/create_group.py @@ -1,4 +1,8 @@ -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 odoo import _, api, fields, models +from odoo.exceptions import ValidationError class SPPCRDetailCreateGroup(models.Model): @@ -8,10 +12,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 +25,321 @@ 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", - ) - create_new_head = fields.Boolean( - string="Create New Head", - default=False, - tracking=True, - ) - head_name = fields.Char( - string="Head Name", - tracking=True, - help="Name for new head of household", + # 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", ) - 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, - ) - head_phone = fields.Char(string="Head Phone", tracking=True) - # Address + # ────────────────────────────────────────────────────────────────────── + # Group Contact Information + # ────────────────────────────────────────────────────────────────────── + area_id = fields.Many2one("spp.area", string="Area", tracking=True) 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) + email = fields.Char(string="Email", tracking=True) + phone_line_ids = fields.One2many( + "spp.cr.detail.create_group.phone", + "detail_id", + string="Phone Numbers", + ) + + # ────────────────────────────────────────────────────────────────────── + # 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", + ) + + # ────────────────────────────────────────────────────────────────────── + # Group Identity Documents + # ────────────────────────────────────────────────────────────────────── + id_doc_line_ids = fields.One2many( + "spp.cr.detail.create_group.id_doc", + "detail_id", + string="Identity Documents", + ) - # Contact - phone = fields.Char(string="Group Phone", tracking=True) - email = fields.Char(string="Group Email", 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", + ) + + # 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: Create Group — Phone Number" + _order = "is_primary desc, id" + + detail_id = fields.Many2one( + "spp.cr.detail.create_group", + required=True, + ondelete="cascade", + ) + phone_no = fields.Char(string="Phone Number", required=True) + country_id = fields.Many2one("res.country", string="Country") + is_primary = fields.Boolean( + string="Primary", + help="The first primary phone is also written to the group's header phone field.", + ) + + +class SPPCRDetailCreateGroupBank(models.Model): + _name = "spp.cr.detail.create_group.bank" + _description = "CR Detail: Create Group — Bank Account" + + detail_id = fields.Many2one( + "spp.cr.detail.create_group", + required=True, + ondelete="cascade", + ) + acc_number = fields.Char(string="Account Number", required=True) + acc_holder_name = fields.Char(string="Account Holder") + bank_id = fields.Many2one("res.bank", string="Bank") + + +class SPPCRDetailCreateGroupIdDoc(models.Model): + _name = "spp.cr.detail.create_group.id_doc" + _description = "CR Detail: Create Group — Identity Document" + + detail_id = fields.Many2one( + "spp.cr.detail.create_group", + required=True, + 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") + + +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") + + +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", + ) + given_name = fields.Char(string="Given Name", required=True) + family_name = fields.Char(string="Family Name", required=True) + full_name = fields.Char( + string="Full Name", + compute="_compute_full_name", + store=True, + ) + birthdate = fields.Date(string="Date of Birth") + gender_id = fields.Many2one( + "spp.vocabulary.code", + string="Gender", + domain="[('namespace_uri', '=', 'urn:iso:std:iso:5218')]", + ) + phone = fields.Char(string="Phone") + 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") + def _compute_full_name(self): + for rec in self: + given = (rec.given_name or "").strip() + family = (rec.family_name or "").strip() + if given and family: + rec.full_name = f"{family.upper()}, {given}" + else: + rec.full_name = (given or family).upper() or False + + @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") + + 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_birthdate": self.birthdate, + "default_gender_id": self.gender_id.id if self.gender_id else False, + "default_phone": self.phone, + "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_type.py b/spp_change_request_v2/models/change_request_type.py index 3d4da4ae3..4b84d2cfe 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 # ══════════════════════════════════════════════════════════════════════════ diff --git a/spp_change_request_v2/security/ir.model.access.csv b/spp_change_request_v2/security/ir.model.access.csv index 6cb0aa5cc..95ba6a7eb 100644 --- a/spp_change_request_v2/security/ir.model.access.csv +++ b/spp_change_request_v2/security/ir.model.access.csv @@ -47,6 +47,30 @@ 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_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 diff --git a/spp_change_request_v2/strategies/create_group.py b/spp_change_request_v2/strategies/create_group.py index b5c49bffc..89ee6e97a 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, group) + self._attach_banks(detail, group) + self._attach_id_docs(detail, group) - # Create the group - group = self.env["res.partner"].create(group_vals) + # 3. Members (existing + new). Each line carries its own role, the + # Head requirement is already validated in `_validate`. + self._attach_members(detail, group) + + 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,178 @@ 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 return { "_action": "create_group", "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(", "), + "address": ", ".join(filter(None, [detail.address_line1, detail.city])), + "phone_count": len(detail.phone_line_ids), + "bank_count": len(detail.bank_line_ids), + "id_doc_count": len(detail.id_doc_line_ids), + "existing_member_count": len(detail.member_existing_ids), + "new_member_count": len(detail.member_new_ids), + "head_of_household": head_label, + } + + # ────────────────────────────────────────────────────────────────────── + # 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, + "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": 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, detail, group): + SppPhone = self.env["spp.phone.number"] + for line in detail.phone_line_ids: + SppPhone.create( + { + "partner_id": group.id, + "phone_no": line.phone_no, + "country_id": line.country_id.id if line.country_id else False, + "date_collected": fields.Date.today(), + } + ) + + 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: + full_name = line.full_name or " ".join(filter(None, [line.given_name, line.family_name])) + individual_vals = { + "name": full_name, + "given_name": line.given_name, + "family_name": line.family_name, + "birthdate": line.birthdate, + "phone": line.phone, + "is_registrant": True, + "is_group": False, + } + if line.gender_id: + individual_vals["gender_id"] = line.gender_id.id + individual = Partner.create(individual_vals) + # Some downstream modules format the partner's name on the fly. + if hasattr(individual, "name_change"): + individual.name_change() + self._create_membership(Membership, group, individual, line.membership_type_id, now) + + 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_create_group_strategy.py b/spp_change_request_v2/tests/test_create_group_strategy.py index c07fc7a12..938bc4d02 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,35 +41,40 @@ 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_line1="123 Main St", + city="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) @@ -78,166 +82,352 @@ def test_create_group_basic(self): self.assertEqual(new_group.street, "123 Main St") self.assertEqual(new_group.city, "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": "+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(city="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() - - self.assertIn("head", str(cm.exception).lower()) + 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") + self.assertEqual(preview["new_member_count"], 1) + self.assertEqual(preview["bank_count"], 1) + if self.head_kind: + self.assertEqual(preview["head_of_household"], "PERSON, Head") - def test_create_group_preview(self): - """Test preview returns expected structure.""" + # ────────────────────────────────────────────────────────────────── + # 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}) - cr = self.cr_model.create( - { - "request_type_id": self.cr_type.id, - "registrant_id": self.dummy_group.id, - } + 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() - detail.write( + wiz = self._make_wizard( + detail, + "new", + given_name="Wizard", + family_name="Added", + phone="+639000", + 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") + + 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. + wiz = self._make_wizard(detail, "new", given_name="Old", family_name="Name") + wiz.action_add_close() + row = detail.member_new_ids + self.assertEqual(row.full_name, "NAME, Old") + + # 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) + + # Recreate the wizard with the edit context as Odoo would. + edit_wiz = self.env["spp.cr.detail.create_group.member.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", + "editing_member_new_id": row.id, + "given_name": "New", + "family_name": "Name", } ) - - preview = cr.action_preview_changes() - - self.assertIn("_action", preview) - self.assertEqual(preview["_action"], "create_group") - self.assertEqual(preview["group_name"], "Preview Group") - self.assertTrue(preview["create_new_head"]) + 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. + self.assertEqual(len(detail.member_new_ids), 1) + self.assertEqual(detail.member_new_ids.given_name, "New") + + 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() diff --git a/spp_change_request_v2/tests/test_e2e_workflows.py b/spp_change_request_v2/tests/test_e2e_workflows.py index 723095797..b4693c216 100644 --- a/spp_change_request_v2/tests/test_e2e_workflows.py +++ b/spp_change_request_v2/tests/test_e2e_workflows.py @@ -84,17 +84,25 @@ 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", + "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) @@ -608,12 +616,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) diff --git a/spp_change_request_v2/views/create_group_member_wizard_views.xml b/spp_change_request_v2/views/create_group_member_wizard_views.xml new file mode 100644 index 000000000..155c69df1 --- /dev/null +++ b/spp_change_request_v2/views/create_group_member_wizard_views.xml @@ -0,0 +1,78 @@ + + + + + spp.cr.detail.create_group.member.wizard.form + spp.cr.detail.create_group.member.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
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..03d69dfe2 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 @@ >
+ + + +
+ + + - - + + - - - - - + - - - - - - + + + + - - - - - - - + + + + + + + + + + - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + +
+
+ + + + + + + + +