From 63c6e34a4e1b56555ce41612da2a980bd9887bf2 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Wed, 22 Apr 2026 12:01:11 +0800 Subject: [PATCH] feat(spp_mis_demo_v2): add child/spouse/other membership types Enable the child, spouse, and other group-membership-type vocabulary codes inside the demo module and apply them when generating households. The existing blueprint roles already honor gender and age ranges, so the generator assigns membership types from those roles: head -> head spouse -> spouse child -> child adult, elderly -> other Fix a stale reference to spp_registry.group_membership_kind_child in the add_member change-request demo data; point it to the new xmlid. The types are scoped to spp_mis_demo_v2 (not spp_vocabulary) so that core stays non-prescriptive about household composition. --- spp_mis_demo_v2/__manifest__.py | 1 + .../data/vocabulary_group_membership_type.xml | 28 +++++++++++ spp_mis_demo_v2/models/mis_demo_generator.py | 21 ++++++--- .../models/seeded_volume_generator.py | 35 ++++++++++---- .../tests/test_mis_demo_generator.py | 46 +++++++++++++++++++ 5 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 spp_mis_demo_v2/data/vocabulary_group_membership_type.xml diff --git a/spp_mis_demo_v2/__manifest__.py b/spp_mis_demo_v2/__manifest__.py index b166e37e..8f57e70c 100644 --- a/spp_mis_demo_v2/__manifest__.py +++ b/spp_mis_demo_v2/__manifest__.py @@ -38,6 +38,7 @@ "post_init_hook": "post_init_hook", "data": [ "security/ir.model.access.csv", + "data/vocabulary_group_membership_type.xml", "data/demo_currencies.xml", "data/demo_constants.xml", "data/demo_personas.xml", diff --git a/spp_mis_demo_v2/data/vocabulary_group_membership_type.xml b/spp_mis_demo_v2/data/vocabulary_group_membership_type.xml new file mode 100644 index 00000000..dc76df25 --- /dev/null +++ b/spp_mis_demo_v2/data/vocabulary_group_membership_type.xml @@ -0,0 +1,28 @@ + + + + + child + Child + Child member of the group/household. + 2 + + + + + spouse + Spouse + Spouse or partner of the head of household. + 3 + + + + + other + Other + Other member of the group/household (e.g., relative, dependent). + 10 + + diff --git a/spp_mis_demo_v2/models/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py index 5ae10807..77d75afb 100644 --- a/spp_mis_demo_v2/models/mis_demo_generator.py +++ b/spp_mis_demo_v2/models/mis_demo_generator.py @@ -901,21 +901,27 @@ def _create_household_members(self, group, profile, days_back): registration_date = fields.Date.today() - datetime.timedelta(days=days_back) members_created = [] + VocabCode = self.env["spp.vocabulary.code"] + type_ids_by_code = {} + for code in ("head", "spouse", "child", "other"): + rec = VocabCode.get_code("urn:openspp:vocab:group-membership-type", code) + type_ids_by_code[code] = rec.id if rec else False + + def _membership_type_commands(code): + tid = type_ids_by_code.get(code) + return [Command.link(tid)] if tid else [] + # Create head of household head_data = profile.get("head", {}) if head_data: head = self._create_individual_member(head_data, registration_date) if head: members_created.append(head) - # Add as head member - head_membership_type = self.env["spp.vocabulary.code"].get_code( - "urn:openspp:vocab:group-membership-type", "head" - ) self.env["spp.group.membership"].create( { "group": group.id, "individual": head.id, - "membership_type_ids": [Command.link(head_membership_type.id)] if head_membership_type else [], + "membership_type_ids": _membership_type_commands("head"), } ) @@ -929,6 +935,7 @@ def _create_household_members(self, group, profile, days_back): { "group": group.id, "individual": spouse.id, + "membership_type_ids": _membership_type_commands("spouse"), } ) @@ -941,6 +948,7 @@ def _create_household_members(self, group, profile, days_back): { "group": group.id, "individual": adult.id, + "membership_type_ids": _membership_type_commands("other"), } ) @@ -953,6 +961,7 @@ def _create_household_members(self, group, profile, days_back): { "group": group.id, "individual": child.id, + "membership_type_ids": _membership_type_commands("child"), } ) @@ -2681,7 +2690,7 @@ def _get_demo_user(self, role): "given_name": "Baby Morales", "family_name": "Morales", "birthdate": fields.Date.today(), - "relationship_xmlid": "spp_registry.group_membership_kind_child", + "relationship_xmlid": "spp_mis_demo_v2.code_membership_type_child", }, }, # Phase 5.1: Add remove_member CR diff --git a/spp_mis_demo_v2/models/seeded_volume_generator.py b/spp_mis_demo_v2/models/seeded_volume_generator.py index 5c0c8946..8bc71f2d 100644 --- a/spp_mis_demo_v2/models/seeded_volume_generator.py +++ b/spp_mis_demo_v2/models/seeded_volume_generator.py @@ -72,7 +72,7 @@ def __init__(self, env, locale, seed=42): # Caches self._gender_cache = {} - self._head_type_id = None + self._membership_type_cache = {} self._group_type_id = None # ========================================================================= @@ -177,7 +177,13 @@ def generate_all_households(self, blueprints): # Phase 4: Create memberships and link to groups _logger.info("Phase 4/%d: Creating %d memberships...", 4, len(individuals)) membership_vals_list = [] - head_type_id = self._get_head_type_id() + role_to_type_code = { + "head": "head", + "spouse": "spouse", + "child": "child", + "adult": "other", + "elderly": "other", + } current_group = None has_head_for_current_group = False @@ -196,8 +202,17 @@ def generate_all_households(self, blueprints): "start_date": group_record.registration_date, } - if member_spec["role"] == "head" and not has_head_for_current_group and head_type_id: - mval["membership_type_ids"] = [(4, head_type_id)] + role = member_spec["role"] + if role == "head" and has_head_for_current_group: + type_code = "other" + else: + type_code = role_to_type_code.get(role, "other") + + type_id = self._get_membership_type_id(type_code) + if type_id: + mval["membership_type_ids"] = [(4, type_id)] + + if role == "head" and not has_head_for_current_group: has_head_for_current_group = True # Update group name to head's family name group_record.name = individual.family_name or individual.name @@ -483,12 +498,12 @@ def _get_gender_id(self, gender): self._gender_cache[gender] = code.id if code else False return self._gender_cache[gender] - def _get_head_type_id(self): - """Get the 'head' membership type ID, with caching.""" - if self._head_type_id is None: - head_type = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head") - self._head_type_id = head_type.id if head_type else False - return self._head_type_id + def _get_membership_type_id(self, code): + """Get a group-membership-type vocabulary code ID, with caching.""" + if code not in self._membership_type_cache: + rec = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", code) + self._membership_type_cache[code] = rec.id if rec else False + return self._membership_type_cache[code] def _get_group_type_id(self): """Get a default group type ID, with caching.""" diff --git a/spp_mis_demo_v2/tests/test_mis_demo_generator.py b/spp_mis_demo_v2/tests/test_mis_demo_generator.py index 38d7dfe2..6dc5d09c 100644 --- a/spp_mis_demo_v2/tests/test_mis_demo_generator.py +++ b/spp_mis_demo_v2/tests/test_mis_demo_generator.py @@ -645,6 +645,52 @@ def test_head_of_household_has_correct_membership_type(self): len(head_membership), 1, f"Household '{story_name}' should have exactly one head of household" ) + def test_non_head_members_have_membership_types(self): + """Spouse, child, and other adult members each get the matching membership type.""" + self._run_generator_for_stories() + + VocabCode = self.env["spp.vocabulary.code"] + ns = "urn:openspp:vocab:group-membership-type" + types = {code: VocabCode.get_code(ns, code) for code in ("head", "spouse", "child", "other")} + + if not all(types.values()): + self.skipTest("Group-membership-type vocabulary codes not configured") + + for story_name in ["Bautista", "Navarro", "Morales"]: + group = self.env["res.partner"].search([("name", "=", story_name), ("is_group", "=", True)], limit=1) + if not group: + continue + + memberships = self.env["spp.group.membership"].search([("group", "=", group.id)]) + self.assertTrue(memberships, f"{story_name} should have memberships") + + # Every membership should carry exactly one type from our set + for m in memberships: + assigned = m.membership_type_ids & (types["head"] | types["spouse"] | types["child"] | types["other"]) + self.assertEqual( + len(assigned), + 1, + f"Membership for {m.individual.name} in '{story_name}' should have exactly one " + f"group-membership-type code, got {m.membership_type_ids.mapped('code')}", + ) + + # At most one spouse per household + spouse_memberships = memberships.filtered(lambda x: types["spouse"] in x.membership_type_ids) + self.assertLessEqual(len(spouse_memberships), 1, f"{story_name} should have at most one spouse") + + # 'child' members are younger than the household head + head_membership = memberships.filtered(lambda x: types["head"] in x.membership_type_ids) + if head_membership and head_membership.individual.birthdate: + head_birthdate = head_membership.individual.birthdate + for m in memberships.filtered(lambda x: types["child"] in x.membership_type_ids): + if m.individual.birthdate: + self.assertGreater( + m.individual.birthdate, + head_birthdate, + f"Member {m.individual.name} tagged as 'child' in '{story_name}' " + f"should be younger than the head", + ) + def test_idempotent_member_creation(self): """Test that running generator twice doesn't duplicate members.""" # Run generator first time