Skip to content

Commit 2ba8657

Browse files
committed
Merge branch '19.0' into fix/dms-deprecated-role
2 parents ab57260 + 6e859bb commit 2ba8657

5 files changed

Lines changed: 131 additions & 28 deletions

File tree

spp_mis_demo_v2/__manifest__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"name": "OpenSPP MIS Demo V2",
55
"summary": "Demo Generator V2 for SP-MIS programs with fixed stories and volume generation",
66
"category": "OpenSPP",
7-
"version": "19.0.2.0.0",
7+
"version": "19.0.2.0.1",
88
"sequence": 1,
99
"author": "OpenSPP.org",
1010
"website": "https://github.com/OpenSPP/OpenSPP2",
@@ -38,6 +38,7 @@
3838
"post_init_hook": "post_init_hook",
3939
"data": [
4040
"security/ir.model.access.csv",
41+
"data/vocabulary_group_membership_type.xml",
4142
"data/demo_currencies.xml",
4243
"data/demo_constants.xml",
4344
"data/demo_personas.xml",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<odoo noupdate="1">
3+
<record id="code_membership_type_child" model="spp.vocabulary.code">
4+
<field name="vocabulary_id" ref="spp_vocabulary.vocab_group_membership_type" />
5+
<field name="code">child</field>
6+
<field name="display">Child</field>
7+
<field name="definition">Child member of the group/household.</field>
8+
<field name="sequence">2</field>
9+
</record>
10+
11+
<record id="code_membership_type_spouse" model="spp.vocabulary.code">
12+
<field name="vocabulary_id" ref="spp_vocabulary.vocab_group_membership_type" />
13+
<field name="code">spouse</field>
14+
<field name="display">Spouse</field>
15+
<field name="definition">Spouse or partner of the head of household.</field>
16+
<field name="sequence">3</field>
17+
</record>
18+
19+
<record id="code_membership_type_other" model="spp.vocabulary.code">
20+
<field name="vocabulary_id" ref="spp_vocabulary.vocab_group_membership_type" />
21+
<field name="code">other</field>
22+
<field name="display">Other</field>
23+
<field
24+
name="definition"
25+
>Other member of the group/household (e.g., relative, dependent).</field>
26+
<field name="sequence">10</field>
27+
</record>
28+
</odoo>

spp_mis_demo_v2/models/mis_demo_generator.py

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -784,19 +784,23 @@ def _create_story_registrant(self, story):
784784
return registrant
785785

786786
def _get_demographic_enricher(self):
787-
"""Get or create the demographic enricher for story registrants.
788-
789-
Uses a class-level cache since Odoo model instances don't support
790-
arbitrary attribute assignment.
787+
"""Build a fresh demographic enricher for the current generation run.
788+
789+
No caching: an enricher's internal `_bank_ids` / vocab / country
790+
caches are populated against the current cursor at construction
791+
time. A class-level cache survives TransactionCase savepoint
792+
rollbacks between tests, so by the time a later test re-uses the
793+
cached enricher its `_bank_ids` reference `res.bank` rows that
794+
no longer exist — the next `res.partner.bank` insert then raises
795+
a `res_partner_bank_bank_id_fkey` violation. `_ensure_banks` and
796+
`_cache_vocab_ids` are idempotent (search-then-create), so
797+
re-instantiating costs only a handful of SELECTs.
791798
"""
792-
cache_key = "_demo_enricher_cache"
793-
if not hasattr(type(self), cache_key) or getattr(type(self), cache_key) is None:
794-
from .demographic_enricher import DemographicEnricher
799+
from .demographic_enricher import DemographicEnricher
795800

796-
locale = self.env.context.get("demo_locale", "fil_PH")
797-
rng = random.Random(99) # Separate seed from volume generation
798-
setattr(type(self), cache_key, DemographicEnricher(self.env, locale, rng))
799-
return getattr(type(self), cache_key)
801+
locale = self.env.context.get("demo_locale", "fil_PH")
802+
rng = random.Random(99) # Separate seed from volume generation
803+
return DemographicEnricher(self.env, locale, rng)
800804

801805
def _enrich_all_story_registrants(self, stories):
802806
"""Enrich all story registrants with demographic data if not already set.
@@ -901,21 +905,27 @@ def _create_household_members(self, group, profile, days_back):
901905
registration_date = fields.Date.today() - datetime.timedelta(days=days_back)
902906
members_created = []
903907

908+
VocabCode = self.env["spp.vocabulary.code"]
909+
type_ids_by_code = {}
910+
for code in ("head", "spouse", "child", "other"):
911+
rec = VocabCode.get_code("urn:openspp:vocab:group-membership-type", code)
912+
type_ids_by_code[code] = rec.id if rec else False
913+
914+
def _membership_type_commands(code):
915+
tid = type_ids_by_code.get(code)
916+
return [Command.link(tid)] if tid else []
917+
904918
# Create head of household
905919
head_data = profile.get("head", {})
906920
if head_data:
907921
head = self._create_individual_member(head_data, registration_date)
908922
if head:
909923
members_created.append(head)
910-
# Add as head member
911-
head_membership_type = self.env["spp.vocabulary.code"].get_code(
912-
"urn:openspp:vocab:group-membership-type", "head"
913-
)
914924
self.env["spp.group.membership"].create(
915925
{
916926
"group": group.id,
917927
"individual": head.id,
918-
"membership_type_ids": [Command.link(head_membership_type.id)] if head_membership_type else [],
928+
"membership_type_ids": _membership_type_commands("head"),
919929
}
920930
)
921931

@@ -929,6 +939,7 @@ def _create_household_members(self, group, profile, days_back):
929939
{
930940
"group": group.id,
931941
"individual": spouse.id,
942+
"membership_type_ids": _membership_type_commands("spouse"),
932943
}
933944
)
934945

@@ -941,6 +952,7 @@ def _create_household_members(self, group, profile, days_back):
941952
{
942953
"group": group.id,
943954
"individual": adult.id,
955+
"membership_type_ids": _membership_type_commands("other"),
944956
}
945957
)
946958

@@ -953,6 +965,7 @@ def _create_household_members(self, group, profile, days_back):
953965
{
954966
"group": group.id,
955967
"individual": child.id,
968+
"membership_type_ids": _membership_type_commands("child"),
956969
}
957970
)
958971

@@ -2681,7 +2694,7 @@ def _get_demo_user(self, role):
26812694
"given_name": "Baby Morales",
26822695
"family_name": "Morales",
26832696
"birthdate": fields.Date.today(),
2684-
"relationship_xmlid": "spp_registry.group_membership_kind_child",
2697+
"relationship_xmlid": "spp_mis_demo_v2.code_membership_type_child",
26852698
},
26862699
},
26872700
# Phase 5.1: Add remove_member CR

spp_mis_demo_v2/models/seeded_volume_generator.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def __init__(self, env, locale, seed=42):
7272

7373
# Caches
7474
self._gender_cache = {}
75-
self._head_type_id = None
75+
self._membership_type_cache = {}
7676
self._group_type_id = None
7777

7878
# =========================================================================
@@ -177,7 +177,13 @@ def generate_all_households(self, blueprints):
177177
# Phase 4: Create memberships and link to groups
178178
_logger.info("Phase 4/%d: Creating %d memberships...", 4, len(individuals))
179179
membership_vals_list = []
180-
head_type_id = self._get_head_type_id()
180+
role_to_type_code = {
181+
"head": "head",
182+
"spouse": "spouse",
183+
"child": "child",
184+
"adult": "other",
185+
"elderly": "other",
186+
}
181187

182188
current_group = None
183189
has_head_for_current_group = False
@@ -196,8 +202,17 @@ def generate_all_households(self, blueprints):
196202
"start_date": group_record.registration_date,
197203
}
198204

199-
if member_spec["role"] == "head" and not has_head_for_current_group and head_type_id:
200-
mval["membership_type_ids"] = [(4, head_type_id)]
205+
role = member_spec["role"]
206+
if role == "head" and has_head_for_current_group:
207+
type_code = "other"
208+
else:
209+
type_code = role_to_type_code.get(role, "other")
210+
211+
type_id = self._get_membership_type_id(type_code)
212+
if type_id:
213+
mval["membership_type_ids"] = [(4, type_id)]
214+
215+
if role == "head" and not has_head_for_current_group:
201216
has_head_for_current_group = True
202217
# Update group name to head's family name
203218
group_record.name = individual.family_name or individual.name
@@ -483,12 +498,12 @@ def _get_gender_id(self, gender):
483498
self._gender_cache[gender] = code.id if code else False
484499
return self._gender_cache[gender]
485500

486-
def _get_head_type_id(self):
487-
"""Get the 'head' membership type ID, with caching."""
488-
if self._head_type_id is None:
489-
head_type = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", "head")
490-
self._head_type_id = head_type.id if head_type else False
491-
return self._head_type_id
501+
def _get_membership_type_id(self, code):
502+
"""Get a group-membership-type vocabulary code ID, with caching."""
503+
if code not in self._membership_type_cache:
504+
rec = self.env["spp.vocabulary.code"].get_code("urn:openspp:vocab:group-membership-type", code)
505+
self._membership_type_cache[code] = rec.id if rec else False
506+
return self._membership_type_cache[code]
492507

493508
def _get_group_type_id(self):
494509
"""Get a default group type ID, with caching."""

spp_mis_demo_v2/tests/test_mis_demo_generator.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,52 @@ def test_head_of_household_has_correct_membership_type(self):
645645
len(head_membership), 1, f"Household '{story_name}' should have exactly one head of household"
646646
)
647647

648+
def test_non_head_members_have_membership_types(self):
649+
"""Spouse, child, and other adult members each get the matching membership type."""
650+
self._run_generator_for_stories()
651+
652+
VocabCode = self.env["spp.vocabulary.code"]
653+
ns = "urn:openspp:vocab:group-membership-type"
654+
types = {code: VocabCode.get_code(ns, code) for code in ("head", "spouse", "child", "other")}
655+
656+
if not all(types.values()):
657+
self.skipTest("Group-membership-type vocabulary codes not configured")
658+
659+
for story_name in ["Bautista", "Navarro", "Morales"]:
660+
group = self.env["res.partner"].search([("name", "=", story_name), ("is_group", "=", True)], limit=1)
661+
if not group:
662+
continue
663+
664+
memberships = self.env["spp.group.membership"].search([("group", "=", group.id)])
665+
self.assertTrue(memberships, f"{story_name} should have memberships")
666+
667+
# Every membership should carry exactly one type from our set
668+
for m in memberships:
669+
assigned = m.membership_type_ids & (types["head"] | types["spouse"] | types["child"] | types["other"])
670+
self.assertEqual(
671+
len(assigned),
672+
1,
673+
f"Membership for {m.individual.name} in '{story_name}' should have exactly one "
674+
f"group-membership-type code, got {m.membership_type_ids.mapped('code')}",
675+
)
676+
677+
# At most one spouse per household
678+
spouse_memberships = memberships.filtered(lambda x: types["spouse"] in x.membership_type_ids)
679+
self.assertLessEqual(len(spouse_memberships), 1, f"{story_name} should have at most one spouse")
680+
681+
# 'child' members are younger than the household head
682+
head_membership = memberships.filtered(lambda x: types["head"] in x.membership_type_ids)
683+
if head_membership and head_membership.individual.birthdate:
684+
head_birthdate = head_membership.individual.birthdate
685+
for m in memberships.filtered(lambda x: types["child"] in x.membership_type_ids):
686+
if m.individual.birthdate:
687+
self.assertGreater(
688+
m.individual.birthdate,
689+
head_birthdate,
690+
f"Member {m.individual.name} tagged as 'child' in '{story_name}' "
691+
f"should be younger than the head",
692+
)
693+
648694
def test_idempotent_member_creation(self):
649695
"""Test that running generator twice doesn't duplicate members."""
650696
# Run generator first time

0 commit comments

Comments
 (0)