diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc4a57ad..4c034de3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,7 @@ jobs: needs: [detect-changes, build] if: needs.detect-changes.outputs.has_modules == 'true' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 strategy: fail-fast: false matrix: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3e0c5bea..619f69bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -304,4 +304,4 @@ repos: # Only scan OpenSPP spp_* modules (not scripts, endpoint handlers, etc.) files: ^spp_ # Exclude test files, migrations, and demo-only modules - exclude: ^(tests/|scripts/tests/|.*/tests/.*|.*/migrations/.*|spp_4ps_demo/|spp_case_demo/|spp_grm_demo/) + exclude: ^(tests/|scripts/tests/|.*/tests/.*|.*/migrations/.*|spp_4ps_demo/|spp_case_demo/|spp_grm_demo/|spp_mis_demo_v2/) diff --git a/spp_demo/data/res_country.xml b/spp_demo/data/res_country.xml index b90d8951..bf3ad71b 100644 --- a/spp_demo/data/res_country.xml +++ b/spp_demo/data/res_country.xml @@ -1372,8 +1372,8 @@ 21.3210 116.9540 126.6040 - en_US - False + fil_PH + True -25.0800 @@ -1652,8 +1652,8 @@ 9.8345 79.6952 81.8813 - en_US - False + si_LK + True 8.6840 @@ -1748,8 +1748,8 @@ 11.1390 -0.1470 1.7790 - en_US - False + fr_TG + True -9.5000 diff --git a/spp_demo/locale_providers/__init__.py b/spp_demo/locale_providers/__init__.py index 93d0aa0b..f12ac0a0 100644 --- a/spp_demo/locale_providers/__init__.py +++ b/spp_demo/locale_providers/__init__.py @@ -1,6 +1,8 @@ from faker import Faker from faker.config import AVAILABLE_LOCALES from .en_KE import Provider as EnKeProvider +from .fil_PH import Provider as FilPhProvider +from .fr_TG import Provider as FrTgProvider from .lo_LA import Provider as LoLaProvider from .si_LK import Provider as SiLkProvider from .sw_KE import Provider as SwKeProvider @@ -21,6 +23,10 @@ def get_faker_provider(lang): """ if lang == "en_KE": return EnKeProvider + if lang == "fil_PH": + return FilPhProvider + if lang == "fr_TG": + return FrTgProvider if lang == "lo_LA": return LoLaProvider if lang == "si_LK": diff --git a/spp_demo/locale_providers/fil_PH/__init__.py b/spp_demo/locale_providers/fil_PH/__init__.py new file mode 100644 index 00000000..bba4e471 --- /dev/null +++ b/spp_demo/locale_providers/fil_PH/__init__.py @@ -0,0 +1,316 @@ +from faker.providers.person import Provider as PersonProvider + + +class Provider(PersonProvider): + formats = ["{{first_name}} {{last_name}}"] + + first_names_male = [ + "Juan", + "Jose", + "Pedro", + "Carlos", + "Miguel", + "Ramon", + "Antonio", + "Roberto", + "Eduardo", + "Francisco", + "Ricardo", + "Fernando", + "Andres", + "Ernesto", + "Arturo", + "Leonardo", + "Gabriel", + "Rafael", + "Manuel", + "Alejandro", + "Marco", + "Paolo", + "Angelo", + "Benedict", + "Christian", + "Daniel", + "Enrique", + "Federico", + "Gregorio", + "Hector", + "Ignacio", + "Joaquin", + "Kevin", + "Lorenzo", + "Mariano", + "Nathaniel", + "Orlando", + "Patrick", + "Quirino", + "Reynaldo", + "Salvador", + "Teodoro", + "Ulysses", + "Vicente", + "William", + "Xander", + "Yohan", + "Zandro", + "Arjay", + "Bryan", + "Cedric", + "Darwin", + "Elmer", + "Francis", + "Gerald", + "Harold", + "Ivan", + "Jerome", + "Kenneth", + "Lester", + "Mark", + "Neil", + "Oliver", + "Philip", + "Rodel", + "Samuel", + "Tomas", + "Virgilio", + "Wesley", + "Ariel", + "Benigno", + "Crisanto", + "Dionisio", + "Emilio", + "Feliciano", + "Gaudencio", + "Herminio", + "Isidro", + "Juanito", + "Lauro", + "Macario", + "Norberto", + "Olimpio", + "Placido", + "Renato", + "Sergio", + "Teofilo", + "Urbano", + "Venancio", + "Wilfredo", + "Abelardo", + "Bayani", + "Cornelio", + "Delfin", + "Efren", + "Florencio", + "Geronimo", + "Hilario", + "Ireneo", + "Jovito", + ] + + first_names_female = [ + "Maria", + "Ana", + "Rosa", + "Elena", + "Sofia", + "Carmen", + "Isabel", + "Teresa", + "Luz", + "Gloria", + "Corazon", + "Remedios", + "Lourdes", + "Milagros", + "Rosario", + "Esperanza", + "Carmelita", + "Josefina", + "Magdalena", + "Concepcion", + "Angela", + "Beatriz", + "Catalina", + "Diana", + "Evangeline", + "Felicidad", + "Gemma", + "Helen", + "Imelda", + "Jocelyn", + "Kristine", + "Luisa", + "Marilou", + "Nora", + "Olivia", + "Patricia", + "Rosalinda", + "Sarah", + "Trinidad", + "Urduja", + "Virginia", + "Wanda", + "Yolanda", + "Zenaida", + "Aida", + "Bella", + "Cynthia", + "Dolores", + "Erlinda", + "Fe", + "Gracia", + "Herminia", + "Irene", + "Jasmine", + "Karen", + "Linda", + "Michelle", + "Nancy", + "Ofelia", + "Perla", + "Queen", + "Riza", + "Susan", + "Teresita", + "Vilma", + "Wilma", + "Yvonne", + "Zara", + "Aileen", + "Beverly", + "Cherry", + "Divina", + "Edith", + "Florencia", + "Gina", + "Hazel", + "Ivy", + "Joy", + "Kathleen", + "Lorna", + "Myrna", + "Nimfa", + "Pacita", + "Rizalina", + "Soledad", + "Thelma", + "Ursula", + "Violeta", + "Winona", + "Alma", + "Benilda", + "Clarita", + "Delia", + "Estela", + "Fidela", + "Glenda", + "Heidi", + "Inday", + "Julieta", + ] + + first_names = first_names_female + first_names_male + + last_names = [ + "Santos", + "Dela Cruz", + "Garcia", + "Reyes", + "Mendoza", + "Gonzales", + "Bautista", + "Villanueva", + "Aquino", + "Cruz", + "Torres", + "Ramos", + "Rivera", + "Flores", + "Lopez", + "Hernandez", + "Perez", + "Rodriguez", + "Martinez", + "Castillo", + "Diaz", + "Fernandez", + "Soriano", + "Tolentino", + "Manalo", + "Pascual", + "Navarro", + "Aguilar", + "Santiago", + "Castro", + "Salvador", + "Mercado", + "Jimenez", + "Rosales", + "Magno", + "De Leon", + "Valdez", + "Estrada", + "Villegas", + "Lim", + "Tan", + "Chua", + "Ong", + "Go", + "Sy", + "Co", + "Ang", + "Yu", + "Chan", + "Uy", + "Dimaculangan", + "Macaraig", + "Pangilinan", + "Lacsamana", + "Bayani", + "Malabanan", + "De Guzman", + "Del Rosario", + "De Jesus", + "Dela Pena", + "De Castro", + "Dela Rosa", + "Del Valle", + "De Vera", + "Delos Santos", + "Delos Reyes", + "Del Mundo", + "De Ocampo", + "Enriquez", + "Espiritu", + "Galang", + "Gutierrez", + "Ignacio", + "Joaquin", + "Lazaro", + "Legaspi", + "Luna", + "Magsaysay", + "Ocampo", + "Palma", + "Quizon", + "Rizal", + "Salazar", + "Tinio", + "Urbano", + "Vega", + "Zamora", + "Arevalo", + "Buenaventura", + "Corpus", + "Datu", + "Evangelista", + "Felipe", + "Gorospe", + "Hidalgo", + "Ilagan", + "Jalandoni", + "Katigbak", + "Laurel", + "Manalang", + "Natividad", + ] diff --git a/spp_demo/locale_providers/fr_TG/__init__.py b/spp_demo/locale_providers/fr_TG/__init__.py new file mode 100644 index 00000000..e17b9c12 --- /dev/null +++ b/spp_demo/locale_providers/fr_TG/__init__.py @@ -0,0 +1,315 @@ +from faker.providers.person import Provider as PersonProvider + + +class Provider(PersonProvider): + formats = ["{{first_name}} {{last_name}}"] + + first_names_male = [ + "Koffi", + "Kodjo", + "Yao", + "Kofi", + "Komlan", + "Messan", + "Edem", + "Kokou", + "Kwami", + "Kossi", + "Komi", + "Kwaku", + "Fiifi", + "Mensah", + "Agbeko", + "Atsu", + "Sena", + "Etsri", + "Afi", + "Dodji", + "Sewu", + "Ahiagble", + "Akakpo", + "Amevor", + "Ayivi", + "Deladem", + "Dzifa", + "Efo", + "Folly", + "Gbedemah", + "Koku", + "Lani", + "Mawuli", + "Nayo", + "Nukunu", + "Semekor", + "Togbe", + "Yaovi", + "Yawovi", + "Kossivi", + "Tchala", + "Bawubadi", + "Essowaza", + "Lardja", + "Piyabalo", + "Sassou", + "Tchakpide", + "Walidou", + "Djibril", + "Abdou", + "Moussa", + "Ibrahim", + "Issouf", + "Alassane", + "Mamadou", + "Ousmane", + "Seydou", + "Amadou", + "Hamidou", + "Habib", + "Rachid", + "Farid", + "Karim", + "Latif", + "Nassirou", + "Salifou", + "Brice", + "Fabrice", + "Parfait", + "Sylvestre", + "Prosper", + "Ambroise", + "Celestin", + "Germain", + "Hippolyte", + "Jules", + "Lucien", + "Maxime", + "Norbert", + "Pascal", + "Roger", + "Sebastien", + "Valentin", + "Xavier", + "Augustin", + "Blaise", + "Clement", + "Denis", + "Emile", + "Florent", + "Gaston", + "Henri", + "Jacques", + "Leon", + "Michel", + "Nicolas", + "Pierre", + "Rene", + "Victor", + ] + + first_names_female = [ + "Ama", + "Afua", + "Adjoa", + "Akossiwa", + "Afiwa", + "Esi", + "Kafui", + "Ablavi", + "Ayele", + "Mawusi", + "Akpene", + "Dzidzor", + "Enam", + "Eyram", + "Senam", + "Dzigbordi", + "Yawa", + "Abla", + "Ami", + "Dede", + "Efua", + "Kekeli", + "Lolo", + "Mawuena", + "Sitsofe", + "Woefa", + "Akoko", + "Atsou", + "Dotsevi", + "Elom", + "Fafa", + "Kokoe", + "Makafui", + "Naki", + "Sefakor", + "Tsidi", + "Tchilalo", + "Essowazina", + "Gnininwie", + "Kayi", + "Mazalo", + "Minawoe", + "Nassira", + "Poyodi", + "Samira", + "Aissatou", + "Aminata", + "Fatoumata", + "Kadiatou", + "Mariama", + "Ramatou", + "Fati", + "Memuna", + "Oumou", + "Salamatou", + "Brigitte", + "Colette", + "Delphine", + "Felicite", + "Genevieve", + "Henriette", + "Isabelle", + "Josephine", + "Laurence", + "Marguerite", + "Monique", + "Odette", + "Paulette", + "Rosalie", + "Simone", + "Therese", + "Veronique", + "Yvette", + "Angele", + "Bernadette", + "Claudine", + "Ernestine", + "Francoise", + "Germaine", + "Helene", + "Jacqueline", + "Lucienne", + "Madeleine", + "Nadine", + "Pascaline", + "Regine", + "Sylvie", + "Viviane", + "Albertine", + "Celestine", + "Dominique", + "Eugenie", + "Florentine", + "Gratienne", + "Honorine", + "Justine", + "Leontine", + "Martine", + "Nathalie", + "Patricia", + ] + + first_names = first_names_female + first_names_male + + last_names = [ + "Mensah", + "Agbeko", + "Adzomada", + "Kpovie", + "Amouzou", + "Togbe", + "Ayite", + "Dosseh", + "Sodji", + "Amoussou", + "Assogba", + "Baba", + "Dossou", + "Gbeassor", + "Koffi", + "Lawson", + "Nyuiadzi", + "Olympio", + "Tetteh", + "Afanou", + "Agbetiafa", + "Akueson", + "Amendah", + "Anani", + "Apedoh", + "Atakpah", + "Avedzi", + "Bankole", + "Degboe", + "Deku", + "Djossou", + "Ekue", + "Foli", + "Gaba", + "Gbadoe", + "Gnassingbe", + "Hodabalo", + "Kpabia", + "Kpodar", + "Kudzo", + "Kuevi", + "Kwassi", + "Lamboni", + "Maglo", + "Mensa", + "Nayo", + "Nutsukpui", + "Oladokun", + "Paka", + "Salaou", + "Tchamdja", + "Teko", + "Yovo", + "Abalo", + "Adjamagbo", + "Afande", + "Agossou", + "Akakpo", + "Akolly", + "Amedegnato", + "Amegan", + "Apelete", + "Attiso", + "Bakpe", + "Biteniwu", + "Dakou", + "Dossavi", + "Dzakpasu", + "Edoh", + "Ekoue", + "Gagli", + "Gakpo", + "Gamado", + "Gbandi", + "Guenou", + "Hounsa", + "Kama", + "Kodjovi", + "Koudossou", + "Kouevi", + "Lare", + "Mawu", + "Mensavi", + "Midahuen", + "Napo", + "Ouro", + "Peki", + "Quashie", + "Sakyi", + "Segbe", + "Sowu", + "Tchassim", + "Tekpor", + "Tomavo", + "Tsogbe", + "Walla", + "Wobeto", + "Yawovi", + "Zankli", + "Zinsou", + ] diff --git a/spp_demo/models/demo_stories.py b/spp_demo/models/demo_stories.py index 7e648af7..fc16124e 100644 --- a/spp_demo/models/demo_stories.py +++ b/spp_demo/models/demo_stories.py @@ -8,8 +8,11 @@ - CI/Testing verification Each story demonstrates a specific workflow or feature set. +Names are locale-aware: fil_PH (default), si_LK, fr_TG. """ +import copy + # Reserved names that should not be used for random volume generation RESERVED_NAMES = [ "Maria Santos", @@ -100,8 +103,7 @@ "farm_size_hectares": 2.5, # CEL: Input Subsidy eligibility "farm_type": "crop", "main_crop": "rice", - "area_ref": "spp_demo.area_phl_quezon_city", - "area_kind": "municipality", + "district": "Northern District", "marital_status": "married", "household_size": 5, }, @@ -244,8 +246,7 @@ "farm_size_hectares": 8.0, # CEL: Large livestock farm "farm_type": "livestock", "main_livestock": "dairy", - "area_ref": "spp_demo.area_phl_calamba", - "area_kind": "municipality", + "district": "Central District", "marital_status": "married", "household_size": 6, "role": "cooperative_chairman", @@ -280,8 +281,7 @@ "farm_size_hectares": 3.0, # CEL: Youth farmer eligibility "farm_type": "crop", "main_crop": "mixed_vegetables", - "area_ref": "spp_demo.area_phl_antipolo", - "area_kind": "municipality", + "district": "Eastern District", "marital_status": "single", "household_size": 2, "registration_channel": "mobile_app", @@ -318,8 +318,7 @@ "farm_size": 2.0, "farm_size_hectares": 2.0, # CEL: Household farm size "child_count": 3, # CEL: Child benefit eligibility - "area_ref": "spp_demo.area_phl_santa_rosa", - "area_kind": "municipality", + "district": "Southern District", }, "journey": [ {"action": "register_household", "days_back": 150}, @@ -357,8 +356,7 @@ "vulnerability": ["single_parent", "low_income", "female_headed"], "vulnerability_score": 80, # CEL: High vulnerability - single parent household "child_count": 3, # CEL: Child benefit eligibility - "area_ref": "spp_demo.area_phl_makati", - "area_kind": "municipality", + "district": "Western District", }, "journey": [ {"action": "register_household", "days_back": 180}, @@ -403,8 +401,7 @@ "farm_size": 5.0, "farm_size_hectares": 5.0, # CEL: Multi-generational household farm "child_count": 3, # CEL: Children under 18 (excluding 18-year-old) - "area_ref": "spp_demo.area_phl_quezon_city", - "area_kind": "municipality", + "district": "Northern District", "vulnerability": ["elderly_members"], }, "journey": [ @@ -481,8 +478,7 @@ "child_count": 3, # CEL: Children under 18 (Xiao, Yan, Bo) "farm_type": "crop", "main_crop": "rice", - "area_ref": "spp_demo.area_phl_antipolo", - "area_kind": "municipality", + "district": "Eastern District", }, "journey": [ {"action": "register_household", "days_back": 200}, @@ -525,8 +521,7 @@ "vulnerability": ["elderly", "health_issues", "limited_mobility"], "vulnerability_score": 70, # CEL: Elderly couple vulnerability "has_formal_pension": False, # CEL: Elderly pension eligibility - "area_ref": "spp_demo.area_phl_calamba", - "area_kind": "municipality", + "district": "Central District", }, "journey": [ {"action": "register_household", "days_back": 250}, @@ -574,8 +569,7 @@ "farm_size": 6.0, "farm_size_hectares": 6.0, # CEL: Extended family farm "farm_type": "mixed", - "area_ref": "spp_demo.area_phl_santa_rosa", - "area_kind": "municipality", + "district": "Southern District", "vulnerability": ["disability"], "vulnerability_score": 65, # CEL: Disability in household "disabled_count": 1, # CEL: Member with disability @@ -690,8 +684,7 @@ "farm_size_hectares": 1.5, # CEL: Small farm household "disabled_count": 1, # CEL: Disability Support Grant eligibility "child_count": 1, - "area_ref": "spp_demo.area_phl_makati", - "area_kind": "municipality", + "district": "Western District", }, "journey": [ {"action": "register_household", "days_back": 120}, @@ -924,6 +917,385 @@ ] +# --------------------------------------------------------------------------- +# Locale-specific name overrides +# --------------------------------------------------------------------------- +# Each locale maps story_id → {"name": ..., "profile_names": {...}} +# profile_names keys: "head", "spouse", "children" (list), "adults" (list) +# fil_PH is the default — names are already in the story dicts above. + +LOCALE_NAMES = { + "fil_PH": {}, # Default locale — no overrides needed + # ----------------------------------------------------------------------- + # Sri Lanka — Sinhalese names + # ----------------------------------------------------------------------- + "si_LK": { + # DEMO_STORIES + "maria_santos": {"name": "Kumari Perera"}, + "juan_dela_cruz": {"name": "Nimal Bandara"}, + "rosa_garcia": {"name": "Malini Silva"}, + "pedro_reyes": {"name": "Saman Jayawardena"}, + "ana_mendoza": {"name": "Sachini Dissanayake"}, + "carlos_elena_morales": { + "name": "Kasun Fernando", + "profile_names": { + "head": "Kasun Fernando", + "spouse": "Dilani Fernando", + "children": ["Nuwan Fernando", "Nethmi Fernando", "Chamara Fernando"], + }, + }, + "amina_osman_household": { + "name": "Anoma Herath", + "profile_names": { + "head": "Anoma Herath", + "children": ["Lahiru Herath", "Hiruni Herath", "Dinesh Herath"], + }, + }, + "jose_reyes_multigenerational": { + "name": "Kamal Rathnayake", + "profile_names": { + "head": "Kamal Rathnayake", + "spouse": "Ramya Rathnayake", + "adults": ["Ajith Rathnayake", "Sanduni Rathnayake"], + "children": [ + "Pradeep Rathnayake", + "Wasana Rathnayake", + "Ruwan Rathnayake", + "Nimali Rathnayake", + ], + }, + }, + "chen_large_family": { + "name": "Thilak Gunasekara", + "profile_names": { + "head": "Thilak Gunasekara", + "spouse": "Kusum Gunasekara", + "children": [ + "Gayani Gunasekara", + "Ashan Gunasekara", + "Chathurika Gunasekara", + "Ruwanthi Gunasekara", + "Mahesh Gunasekara", + ], + }, + }, + "manuel_gloria_elderly": { + "name": "Sunil Wijesinghe", + "profile_names": { + "head": "Sunil Wijesinghe", + "spouse": "Sirima Wijesinghe", + }, + }, + "nguyen_extended_family": { + "name": "Ranjith Amarasinghe", + "profile_names": { + "head": "Ranjith Amarasinghe", + "adults": [ + "Champa Amarasinghe", + "Chandana Amarasinghe", + "Nadeesha Amarasinghe", + ], + }, + }, + "ibrahim_hassan": {"name": "Asanka Kumara"}, + "fatima_al_rahman": {"name": "Ishara Senanayake"}, + "david_sofia_martinez": { + "name": "Sanjeewa Wickramasinghe", + "profile_names": { + "head": "Sanjeewa Wickramasinghe", + "spouse": "Nisansala Wickramasinghe", + "children": ["Charitha Wickramasinghe"], + }, + }, + # BACKGROUND_STORIES + "luis_fernandez": {"name": "Dinesh Rajapaksa"}, + "mary_johnson": {"name": "Priyanka Mendis"}, + "ahmed_said": {"name": "Ruwan Weerasinghe"}, + "grace_okonkwo": {"name": "Sanduni Karunaratne"}, + "david_kim": {"name": "Mahesh Gamage"}, + # TUTORIAL_STORIES + "tutorial_garcia_family": { + "name": "Pathirana Family", + "profile_names": { + "head": "Chaminda Pathirana", + "spouse": "Mala Pathirana", + "children": ["Kavinda Pathirana"], + }, + }, + "tutorial_santos_family": { + "name": "De Silva Family", + "profile_names": { + "head": "Rohan De Silva", + "spouse": "Dilini De Silva", + "children": ["Senuri De Silva"], + }, + }, + "tutorial_cruz_family": { + "name": "Cooray Family", + "profile_names": { + "head": "Upul Cooray", + "spouse": "Manel Cooray", + "children": ["Tharindu Cooray", "Rashmi Cooray"], + }, + }, + "tutorial_reyes_family": { + "name": "Gunawardena Family", + "profile_names": { + "head": "Sampath Gunawardena", + "spouse": "Harshani Gunawardena", + "children": ["Kaveesha Gunawardena"], + }, + }, + "tutorial_ramos_family": { + "name": "Senaratne Family", + "profile_names": { + "head": "Jagath Senaratne", + "spouse": "Priyadarshani Senaratne", + "children": ["Lakshan Senaratne", "Imalsha Senaratne"], + }, + }, + }, + # ----------------------------------------------------------------------- + # Togo — Ewe / French names + # ----------------------------------------------------------------------- + "fr_TG": { + # DEMO_STORIES + "maria_santos": {"name": "Ama Koffi"}, + "juan_dela_cruz": {"name": "Kofi Mensah"}, + "rosa_garcia": {"name": "Adzo Amegah"}, + "pedro_reyes": {"name": "Yao Dossou"}, + "ana_mendoza": {"name": "Akua Ayivi"}, + "carlos_elena_morales": { + "name": "Kodjo Agbeko", + "profile_names": { + "head": "Kodjo Agbeko", + "spouse": "Esi Agbeko", + "children": ["Komla Agbeko", "Ablavi Agbeko", "Koku Agbeko"], + }, + }, + "amina_osman_household": { + "name": "Adjoa Tetteh", + "profile_names": { + "head": "Adjoa Tetteh", + "children": ["Messan Tetteh", "Akossiwa Tetteh", "Edem Tetteh"], + }, + }, + "jose_reyes_multigenerational": { + "name": "Kwame Lawson", + "profile_names": { + "head": "Kwame Lawson", + "spouse": "Afia Lawson", + "adults": ["Kossi Lawson", "Ayoko Lawson"], + "children": [ + "Dela Lawson", + "Dzidzor Lawson", + "Kokou Lawson", + "Ewoenam Lawson", + ], + }, + }, + "chen_large_family": { + "name": "Mawuli Akakpo", + "profile_names": { + "head": "Mawuli Akakpo", + "spouse": "Kafui Akakpo", + "children": [ + "Dede Akakpo", + "Yaovi Akakpo", + "Yawa Akakpo", + "Abla Akakpo", + "Komi Akakpo", + ], + }, + }, + "manuel_gloria_elderly": { + "name": "Atsu Amouzou", + "profile_names": { + "head": "Atsu Amouzou", + "spouse": "Akpene Amouzou", + }, + }, + "nguyen_extended_family": { + "name": "Selom Gbeho", + "profile_names": { + "head": "Selom Gbeho", + "adults": ["Mawusi Gbeho", "Senyo Gbeho", "Ayele Gbeho"], + }, + }, + "ibrahim_hassan": {"name": "Kosi Deku"}, + "fatima_al_rahman": {"name": "Afia Sossou"}, + "david_sofia_martinez": { + "name": "Ata Koudawo", + "profile_names": { + "head": "Ata Koudawo", + "spouse": "Ama Koudawo", + "children": ["Kofi Koudawo"], + }, + }, + # BACKGROUND_STORIES + "luis_fernandez": {"name": "Messan Ameganvi"}, + "mary_johnson": {"name": "Ablavi Gbeassor"}, + "ahmed_said": {"name": "Komla Agbodjan"}, + "grace_okonkwo": {"name": "Akossiwa Adjakly"}, + "david_kim": {"name": "Yaovi Assignon"}, + # TUTORIAL_STORIES + "tutorial_garcia_family": { + "name": "Famille Agbo", + "profile_names": { + "head": "Komi Agbo", + "spouse": "Dede Agbo", + "children": ["Edem Agbo"], + }, + }, + "tutorial_santos_family": { + "name": "Famille Sodji", + "profile_names": { + "head": "Kodjo Sodji", + "spouse": "Esi Sodji", + "children": ["Ewoenam Sodji"], + }, + }, + "tutorial_cruz_family": { + "name": "Famille Nyaku", + "profile_names": { + "head": "Kwame Nyaku", + "spouse": "Adjoa Nyaku", + "children": ["Yao Nyaku", "Dzidzor Nyaku"], + }, + }, + "tutorial_reyes_family": { + "name": "Famille Bamezon", + "profile_names": { + "head": "Kossi Bamezon", + "spouse": "Ayoko Bamezon", + "children": ["Kafui Bamezon"], + }, + }, + "tutorial_ramos_family": { + "name": "Famille Djossou", + "profile_names": { + "head": "Mawuli Djossou", + "spouse": "Abla Djossou", + "children": ["Dela Djossou", "Yawa Djossou"], + }, + }, + }, +} + + +# --------------------------------------------------------------------------- +# Localization helpers +# --------------------------------------------------------------------------- + + +def _apply_locale_to_story(story, locale_entry): + """Apply locale name overrides to a deep-copied story dict.""" + story["name"] = locale_entry["name"] + profile = story.get("profile", {}) + pnames = locale_entry.get("profile_names", {}) + + # Head of household + if "head" in pnames and "head" in profile: + profile["head"]["name"] = pnames["head"] + + # Spouse + if "spouse" in pnames and "spouse" in profile: + profile["spouse"]["name"] = pnames["spouse"] + + # Children (positional replacement) + if "children" in pnames and "children" in profile: + for idx, child_name in enumerate(pnames["children"]): + if idx < len(profile["children"]): + profile["children"][idx]["name"] = child_name + + # Adults (positional replacement) + if "adults" in pnames and "adults" in profile: + for idx, adult_name in enumerate(pnames["adults"]): + if idx < len(profile["adults"]): + profile["adults"][idx]["name"] = adult_name + + # Also update journey references that mention member names + # (e.g., disability_assessment member field) + if "children" in pnames: + for step in story.get("journey", []): + if "member" in step: + # Find matching child by position + orig_children = get_story_by_id(story["id"]) + if orig_children: + orig_profile = orig_children.get("profile", {}) + for idx, child in enumerate(orig_profile.get("children", [])): + if child.get("name") == step["member"] and idx < len(pnames["children"]): + step["member"] = pnames["children"][idx] + break + + return story + + +def get_localized_stories(locale=None): + """Return all stories with names replaced for the given locale. + + If locale is None or "fil_PH", returns the original stories unchanged. + Otherwise, deep-copies all stories and applies LOCALE_NAMES overrides. + Stories without locale overrides keep their original names. + """ + all_stories = DEMO_STORIES + BACKGROUND_STORIES + TUTORIAL_STORIES + if not locale or locale == "fil_PH" or locale not in LOCALE_NAMES: + return all_stories + + locale_map = LOCALE_NAMES[locale] + result = [] + for story in all_stories: + if story["id"] in locale_map: + localized = copy.deepcopy(story) + _apply_locale_to_story(localized, locale_map[story["id"]]) + result.append(localized) + else: + result.append(story) + return result + + +def get_localized_reserved_names(locale=None): + """Return the RESERVED_NAMES list for the given locale. + + Collects all character names from localized stories. + """ + if not locale or locale == "fil_PH" or locale not in LOCALE_NAMES: + return RESERVED_NAMES + + stories = get_localized_stories(locale) + names = [] + for story in stories: + names.append(story["name"]) + profile = story.get("profile", {}) + if "head" in profile: + names.append(profile["head"]["name"]) + if "spouse" in profile: + names.append(profile["spouse"]["name"]) + for child in profile.get("children", []): + names.append(child["name"]) + for adult in profile.get("adults", []): + names.append(adult["name"]) + # Deduplicate while preserving order + seen = set() + unique = [] + for n in names: + if n not in seen: + seen.add(n) + unique.append(n) + return unique + + +def get_localized_name(story_id, locale=None): + """Get the localized primary name for a single story.""" + if locale and locale != "fil_PH" and locale in LOCALE_NAMES: + locale_map = LOCALE_NAMES[locale] + if story_id in locale_map: + return locale_map[story_id]["name"] + # Fallback to original + story = get_story_by_id(story_id) + return story["name"] if story else None + + def get_all_stories(): """Return all demo stories (main + background + tutorial).""" return DEMO_STORIES + BACKGROUND_STORIES + TUTORIAL_STORIES diff --git a/spp_demo/tests/test_demo_stories.py b/spp_demo/tests/test_demo_stories.py index bd764281..12de6876 100644 --- a/spp_demo/tests/test_demo_stories.py +++ b/spp_demo/tests/test_demo_stories.py @@ -317,3 +317,142 @@ def test_15_story_registration_dates(self): self.assertIsNotNone(maria.registration_date) # Registration should be in the past self.assertLess(maria.registration_date, expected_date) + + # ------------------------------------------------------------------ + # Locale versioning tests + # ------------------------------------------------------------------ + + def test_16_get_localized_stories_default(self): + """Test that get_localized_stories returns originals for fil_PH.""" + from odoo.addons.spp_demo.models import demo_stories + + # None locale returns originals + stories_none = demo_stories.get_localized_stories(None) + stories_ph = demo_stories.get_localized_stories("fil_PH") + all_stories = demo_stories.get_all_stories() + + self.assertEqual(len(stories_none), len(all_stories)) + self.assertEqual(len(stories_ph), len(all_stories)) + + # Names should be unchanged + self.assertEqual(stories_none[0]["name"], "Maria Santos") + self.assertEqual(stories_ph[0]["name"], "Maria Santos") + + def test_17_get_localized_stories_sri_lanka(self): + """Test that si_LK locale returns Sinhalese names.""" + from odoo.addons.spp_demo.models import demo_stories + + stories = demo_stories.get_localized_stories("si_LK") + + self.assertEqual(len(stories), len(demo_stories.get_all_stories())) + + # Find maria_santos story + maria = next(s for s in stories if s["id"] == "maria_santos") + self.assertEqual(maria["name"], "Kumari Perera") + + # Find household story (carlos_elena_morales) + carlos = next(s for s in stories if s["id"] == "carlos_elena_morales") + self.assertEqual(carlos["name"], "Kasun Fernando") + self.assertEqual(carlos["profile"]["head"]["name"], "Kasun Fernando") + self.assertEqual(carlos["profile"]["spouse"]["name"], "Dilani Fernando") + self.assertEqual(carlos["profile"]["children"][0]["name"], "Nuwan Fernando") + + def test_18_get_localized_stories_togo(self): + """Test that fr_TG locale returns Togolese names.""" + from odoo.addons.spp_demo.models import demo_stories + + stories = demo_stories.get_localized_stories("fr_TG") + + maria = next(s for s in stories if s["id"] == "maria_santos") + self.assertEqual(maria["name"], "Ama Koffi") + + carlos = next(s for s in stories if s["id"] == "carlos_elena_morales") + self.assertEqual(carlos["name"], "Kodjo Agbeko") + self.assertEqual(carlos["profile"]["head"]["name"], "Kodjo Agbeko") + self.assertEqual(carlos["profile"]["spouse"]["name"], "Esi Agbeko") + + def test_19_get_localized_reserved_names(self): + """Test that reserved names are locale-aware.""" + from odoo.addons.spp_demo.models import demo_stories + + ph_names = demo_stories.get_localized_reserved_names("fil_PH") + lk_names = demo_stories.get_localized_reserved_names("si_LK") + tg_names = demo_stories.get_localized_reserved_names("fr_TG") + + # Default should return RESERVED_NAMES + self.assertEqual(ph_names, demo_stories.RESERVED_NAMES) + + # si_LK should have Sinhalese names + self.assertIn("Kumari Perera", lk_names) + self.assertNotIn("Maria Santos", lk_names) + + # fr_TG should have Togolese names + self.assertIn("Ama Koffi", tg_names) + self.assertNotIn("Maria Santos", tg_names) + + def test_20_get_localized_name(self): + """Test single name lookup by story ID and locale.""" + from odoo.addons.spp_demo.models import demo_stories + + # Default/Filipino + self.assertEqual(demo_stories.get_localized_name("maria_santos"), "Maria Santos") + self.assertEqual(demo_stories.get_localized_name("maria_santos", "fil_PH"), "Maria Santos") + + # Sri Lanka + self.assertEqual(demo_stories.get_localized_name("maria_santos", "si_LK"), "Kumari Perera") + self.assertEqual(demo_stories.get_localized_name("juan_dela_cruz", "si_LK"), "Nimal Bandara") + + # Togo + self.assertEqual(demo_stories.get_localized_name("maria_santos", "fr_TG"), "Ama Koffi") + + # Unknown story ID falls back to None + self.assertIsNone(demo_stories.get_localized_name("nonexistent", "si_LK")) + + def test_21_locale_names_cover_all_stories(self): + """Test that all story IDs have entries in each locale.""" + from odoo.addons.spp_demo.models import demo_stories + + all_stories = demo_stories.get_all_stories() + story_ids = {s["id"] for s in all_stories} + + for locale in ("si_LK", "fr_TG"): + locale_map = demo_stories.LOCALE_NAMES[locale] + mapped_ids = set(locale_map.keys()) + missing = story_ids - mapped_ids + self.assertFalse(missing, f"Locale {locale} missing entries for: {missing}") + + def test_22_localized_stories_preserve_structure(self): + """Test that localized stories keep non-name fields intact.""" + from odoo.addons.spp_demo.models import demo_stories + + orig_maria = demo_stories.get_story_by_id("maria_santos") + lk_stories = demo_stories.get_localized_stories("si_LK") + lk_maria = next(s for s in lk_stories if s["id"] == "maria_santos") + + # Journey, profile (except names), demo_points should be unchanged + self.assertEqual(lk_maria["journey"], orig_maria["journey"]) + self.assertEqual(lk_maria["profile"]["age"], orig_maria["profile"]["age"]) + self.assertEqual(lk_maria["profile"]["gender"], orig_maria["profile"]["gender"]) + + def test_23_localized_stories_no_name_collisions(self): + """Test that localized names don't collide within a locale.""" + from odoo.addons.spp_demo.models import demo_stories + + for locale in ("si_LK", "fr_TG"): + names = demo_stories.get_localized_reserved_names(locale) + unique = set(names) + dupes = [n for n in names if names.count(n) > 1] + self.assertEqual(len(names), len(unique), f"Duplicate names in {locale}: {dupes}") + + def test_24_localized_stories_dont_mutate_originals(self): + """Test that calling get_localized_stories doesn't mutate the originals.""" + from odoo.addons.spp_demo.models import demo_stories + + # Get original name + orig_name = demo_stories.DEMO_STORIES[0]["name"] + + # Call localized + demo_stories.get_localized_stories("si_LK") + + # Original should be unchanged + self.assertEqual(demo_stories.DEMO_STORIES[0]["name"], orig_name) diff --git a/spp_mis_demo_v2/__manifest__.py b/spp_mis_demo_v2/__manifest__.py index 44b20aa1..6b4d2736 100644 --- a/spp_mis_demo_v2/__manifest__.py +++ b/spp_mis_demo_v2/__manifest__.py @@ -32,7 +32,7 @@ "spp_claim_169", # Demo-specific extensions ], - "external_dependencies": {"python": ["faker", "requests"]}, + "external_dependencies": {"python": ["requests"]}, "post_init_hook": "post_init_hook", "data": [ "security/ir.model.access.csv", diff --git a/spp_mis_demo_v2/data/demo_currencies.xml b/spp_mis_demo_v2/data/demo_currencies.xml index 0fede3aa..d183f08d 100644 --- a/spp_mis_demo_v2/data/demo_currencies.xml +++ b/spp_mis_demo_v2/data/demo_currencies.xml @@ -7,9 +7,21 @@ for testing financial workflows. --> - + + + + + + + + + + + + + diff --git a/spp_mis_demo_v2/docs/USE_CASES.md b/spp_mis_demo_v2/docs/USE_CASES.md index df19daf6..49fbeaea 100644 --- a/spp_mis_demo_v2/docs/USE_CASES.md +++ b/spp_mis_demo_v2/docs/USE_CASES.md @@ -6,24 +6,72 @@ module and how to use them effectively for sales demos, training, and testing. ## Table of Contents 1. [Overview](#overview) -2. [Demo Programs](#demo-programs) -3. [Demo Stories](#demo-stories) -4. [Formula Library Demo](#formula-library-demo) -5. [Use Cases by Audience](#use-cases-by-audience) -6. [Demo Scenarios](#demo-scenarios) -7. [Feature Demonstrations](#feature-demonstrations) +2. [Blueprint Architecture](#blueprint-architecture) +3. [Country / Locale Support](#country--locale-support) +4. [Demo Programs](#demo-programs) +5. [Demo Stories](#demo-stories) +6. [Formula Library Demo](#formula-library-demo) +7. [Use Cases by Audience](#use-cases-by-audience) +8. [Demo Scenarios](#demo-scenarios) +9. [Feature Demonstrations](#feature-demonstrations) --- ## Overview The MIS Demo V2 module provides realistic demo data that showcases OpenSPP's -capabilities for social protection program management. It follows the "Fixed Stories + -Volume" architecture: +capabilities for social protection program management. It follows the **Blueprint + +Seeded RNG** architecture: -- **Fixed Stories**: 8 named personas with predefined program journeys -- **Volume Data**: Random enrollments for realistic dashboards -- **Demo Programs**: 7 programs covering different social protection scenarios +- **Fixed Stories**: 8 named personas with predefined program journeys (unchanged) +- **Deterministic Volume**: ~730 households with ~2,500 members from 28 blueprint + templates +- **Demo Programs**: 6 programs covering different social protection scenarios +- **100% Reproducible**: Same country selection = identical output every run +- **Country-aware Names**: Names change by locale (Philippines, Sri Lanka, Togo) + +--- + +## Blueprint Architecture + +Volume data is generated from **28 household blueprint templates** — deterministic +definitions that specify household composition (members, ages, genders), income bracket, +and program eligibility. Blueprints are multiplied by their `count` to reach the target +volume. + +**Key properties:** + +- All structural choices (ages, incomes, genders for "any" specs) use + `random.Random(42)` — deterministic +- Names are generated by `random.Random(42).choice()` from locale-specific arrays — + deterministic per locale +- Reserved story names are never used for volume records +- Each blueprint defines eligibility flags per program, ensuring consistent enrollment + +| Category | Blueprints | Households | Description | +| ------------------- | ---------- | ---------- | ------------------------------------------ | +| Young Families | 6 | ~195 | Couples/single parents with young children | +| Middle-age Families | 6 | ~150 | Families with teens, mixed ages | +| Elderly Households | 5 | ~110 | Seniors, grandparent-headed families | +| Working-age | 6 | ~125 | Control groups, extended families | +| Special Cases | 5 | ~100 | Displaced, disability, multi-program | +| **Total** | **28** | **~680** | **~2,500 individual members** | + +--- + +## Country / Locale Support + +The wizard lets you choose a country. This determines the locale for name generation and +the company currency. + +| Country | Locale | Currency | Name Examples | +| ----------- | -------- | ---------------------------- | ---------------------------- | +| Philippines | `fil_PH` | PHP (Philippine Peso) | Juan Santos, Maria Dela Cruz | +| Sri Lanka | `si_LK` | LKR (Sri Lankan Rupee) | Kasun Perera, Ishara Silva | +| Togo | `fr_TG` | XOF (West African CFA Franc) | Koffi Mensah, Ama Dosseh | + +**Reproducibility guarantee**: Selecting the same country always produces the exact same +set of records. --- diff --git a/spp_mis_demo_v2/models/__init__.py b/spp_mis_demo_v2/models/__init__.py index 572e7df6..9873609c 100644 --- a/spp_mis_demo_v2/models/__init__.py +++ b/spp_mis_demo_v2/models/__init__.py @@ -2,5 +2,8 @@ from . import demo_programs from . import demo_variables +from . import household_blueprints from . import indicator_providers from . import mis_demo_generator +from . import res_company +from . import seeded_volume_generator diff --git a/spp_mis_demo_v2/models/household_blueprints.py b/spp_mis_demo_v2/models/household_blueprints.py new file mode 100644 index 00000000..a6bb5f33 --- /dev/null +++ b/spp_mis_demo_v2/models/household_blueprints.py @@ -0,0 +1,467 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +""" +Household Blueprint Definitions for Deterministic Demo Data Generation + +Each blueprint defines the structure of a household type: +- Number and composition of members (roles, genders, age ranges) +- Income bracket and geographic zone +- Program eligibility flags + +Blueprints are 100% deterministic — no randomness in definitions. +The SeededVolumeGenerator multiplies each blueprint by its `count` +to produce the target number of households. + +Total: ~28 blueprints, ~730 households, ~2555 members +""" + +# Program IDs matching DEMO_PROGRAMS in demo_programs.py +_UCG = "universal_child_grant" +_ESP = "elderly_social_pension" +_ERF = "emergency_relief_fund" +_CTP = "cash_transfer_program" +_DSG = "disability_support_grant" +_FA = "food_assistance" + +HOUSEHOLD_BLUEPRINTS = [ + # ========================================================================= + # Young Families (6 blueprints, ~195 households) + # ========================================================================= + { + "id": "bp_01_young_couple_1child_urban_low", + "label": "Young couple, 1 toddler, urban, low income", + "count": 40, + "zone": "urban", + "income_bracket": "low", + "income_range": (8000, 15000), + "members": [ + {"role": "head", "gender": "male", "age_range": (25, 35)}, + {"role": "spouse", "gender": "female", "age_range": (23, 33)}, + {"role": "child", "gender": "any", "age_range": (1, 4)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: False, _DSG: False}, + }, + { + "id": "bp_02_young_couple_2children_rural_vlow", + "label": "Young couple, 2 children, rural, very low income", + "count": 45, + "zone": "rural", + "income_bracket": "very_low", + "income_range": (3000, 8000), + "members": [ + {"role": "head", "gender": "male", "age_range": (27, 38)}, + {"role": "spouse", "gender": "female", "age_range": (25, 36)}, + {"role": "child", "gender": "any", "age_range": (3, 7)}, + {"role": "child", "gender": "any", "age_range": (6, 10)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: True, _DSG: False}, + }, + { + "id": "bp_03_single_mother_2children_urban_low", + "label": "Single mother, 2 children, urban, low income", + "count": 35, + "zone": "urban", + "income_bracket": "low", + "income_range": (6000, 12000), + "is_female_headed": True, + "members": [ + {"role": "head", "gender": "female", "age_range": (25, 40)}, + {"role": "child", "gender": "any", "age_range": (2, 6)}, + {"role": "child", "gender": "any", "age_range": (5, 9)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: True, _DSG: False}, + }, + { + "id": "bp_04_young_couple_3children_rural_low", + "label": "Young couple, 3 children, rural, low income", + "count": 30, + "zone": "rural", + "income_bracket": "low", + "income_range": (5000, 10000), + "members": [ + {"role": "head", "gender": "male", "age_range": (28, 40)}, + {"role": "spouse", "gender": "female", "age_range": (26, 38)}, + {"role": "child", "gender": "any", "age_range": (1, 4)}, + {"role": "child", "gender": "any", "age_range": (4, 8)}, + {"role": "child", "gender": "any", "age_range": (8, 12)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: False, _DSG: False}, + }, + { + "id": "bp_05_single_father_1child_periurban_mod", + "label": "Single father, 1 child, peri-urban, moderate income", + "count": 20, + "zone": "peri_urban", + "income_bracket": "moderate", + "income_range": (15000, 25000), + "members": [ + {"role": "head", "gender": "male", "age_range": (30, 45)}, + {"role": "child", "gender": "any", "age_range": (3, 8)}, + ], + "eligibility": {_UCG: True, _CTP: False, _ERF: False, _DSG: False}, + }, + { + "id": "bp_06_young_couple_newborn_rural_vlow", + "label": "Young couple with newborn + toddler, rural, very low income", + "count": 25, + "zone": "rural", + "income_bracket": "very_low", + "income_range": (2000, 6000), + "members": [ + {"role": "head", "gender": "male", "age_range": (22, 30)}, + {"role": "spouse", "gender": "female", "age_range": (20, 28)}, + {"role": "child", "gender": "any", "age_range": (0, 1)}, + {"role": "child", "gender": "any", "age_range": (1, 3)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: True, _DSG: False}, + }, + # ========================================================================= + # Middle-age Families (6 blueprints, ~150 households) + # ========================================================================= + { + "id": "bp_07_couple_teen_children_urban_mod", + "label": "Couple with 2 teenagers, urban, moderate income", + "count": 40, + "zone": "urban", + "income_bracket": "moderate", + "income_range": (18000, 30000), + "members": [ + {"role": "head", "gender": "male", "age_range": (40, 50)}, + {"role": "spouse", "gender": "female", "age_range": (38, 48)}, + {"role": "child", "gender": "any", "age_range": (13, 16)}, + {"role": "child", "gender": "any", "age_range": (15, 17)}, + ], + "eligibility": {_UCG: True, _CTP: False, _ERF: False, _DSG: False}, + }, + { + "id": "bp_08_couple_mixed_ages_rural_low", + "label": "Couple with children (5-17), rural, low income", + "count": 35, + "zone": "rural", + "income_bracket": "low", + "income_range": (7000, 14000), + "members": [ + {"role": "head", "gender": "male", "age_range": (35, 48)}, + {"role": "spouse", "gender": "female", "age_range": (33, 46)}, + {"role": "child", "gender": "any", "age_range": (4, 7)}, + {"role": "child", "gender": "any", "age_range": (10, 14)}, + {"role": "child", "gender": "any", "age_range": (15, 17)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: False, _DSG: False}, + }, + { + "id": "bp_09_couple_adult_child_periurban_mod", + "label": "Couple with adult child + teen, peri-urban, moderate income", + "count": 25, + "zone": "peri_urban", + "income_bracket": "moderate", + "income_range": (16000, 28000), + "members": [ + {"role": "head", "gender": "male", "age_range": (42, 55)}, + {"role": "spouse", "gender": "female", "age_range": (40, 53)}, + {"role": "adult", "gender": "any", "age_range": (19, 23)}, + {"role": "child", "gender": "any", "age_range": (13, 17)}, + ], + "eligibility": {_UCG: True, _CTP: False, _ERF: False, _DSG: False}, + }, + { + "id": "bp_10_large_family_6members_rural_vlow", + "label": "Large family, 4 children, rural, very low income", + "count": 20, + "zone": "rural", + "income_bracket": "very_low", + "income_range": (2000, 7000), + "members": [ + {"role": "head", "gender": "male", "age_range": (35, 50)}, + {"role": "spouse", "gender": "female", "age_range": (33, 48)}, + {"role": "child", "gender": "any", "age_range": (2, 5)}, + {"role": "child", "gender": "any", "age_range": (5, 9)}, + {"role": "child", "gender": "any", "age_range": (9, 13)}, + {"role": "child", "gender": "any", "age_range": (13, 17)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: True, _DSG: False}, + }, + { + "id": "bp_11_single_mother_disabled_child_urban_low", + "label": "Single mother, disabled child, urban, low income", + "count": 15, + "zone": "urban", + "income_bracket": "low", + "income_range": (6000, 12000), + "is_female_headed": True, + "has_disabled_member": True, + "members": [ + {"role": "head", "gender": "female", "age_range": (30, 45)}, + {"role": "child", "gender": "any", "age_range": (8, 14), "is_disabled": True}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: False, _DSG: True}, + }, + { + "id": "bp_12_couple_disabled_spouse_rural_low", + "label": "Couple, disabled spouse, 2 children, rural, low income", + "count": 15, + "zone": "rural", + "income_bracket": "low", + "income_range": (5000, 11000), + "has_disabled_member": True, + "members": [ + {"role": "head", "gender": "male", "age_range": (35, 50)}, + {"role": "spouse", "gender": "female", "age_range": (33, 48), "is_disabled": True}, + {"role": "child", "gender": "any", "age_range": (6, 10)}, + {"role": "child", "gender": "any", "age_range": (10, 14)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: False, _DSG: True}, + }, + # ========================================================================= + # Elderly Households (5 blueprints, ~110 households) + # ========================================================================= + { + "id": "bp_13_elderly_couple_urban_low", + "label": "Elderly couple, urban, low income", + "count": 30, + "zone": "urban", + "income_bracket": "low", + "income_range": (4000, 10000), + "members": [ + {"role": "head", "gender": "male", "age_range": (65, 78)}, + {"role": "spouse", "gender": "female", "age_range": (63, 76)}, + ], + "eligibility": {_UCG: False, _ESP: True, _CTP: False, _ERF: False, _DSG: False}, + }, + { + "id": "bp_14_elderly_single_rural_vlow", + "label": "Single elderly, rural, very low income", + "count": 25, + "zone": "rural", + "income_bracket": "very_low", + "income_range": (1000, 5000), + "members": [ + {"role": "head", "gender": "any", "age_range": (68, 82)}, + ], + "eligibility": {_UCG: False, _ESP: True, _CTP: False, _ERF: True, _DSG: False}, + }, + { + "id": "bp_15_elderly_couple_grandchild_periurban_low", + "label": "Elderly couple raising grandchild, peri-urban, low income", + "count": 20, + "zone": "peri_urban", + "income_bracket": "low", + "income_range": (5000, 12000), + "members": [ + {"role": "head", "gender": "male", "age_range": (65, 75)}, + {"role": "spouse", "gender": "female", "age_range": (63, 73)}, + {"role": "child", "gender": "any", "age_range": (5, 12)}, + ], + "eligibility": {_UCG: True, _ESP: True, _CTP: False, _ERF: False, _DSG: False}, + }, + { + "id": "bp_16_elderly_disabled_urban_vlow", + "label": "Single elderly, disabled, urban, very low income", + "count": 15, + "zone": "urban", + "income_bracket": "very_low", + "income_range": (1000, 4000), + "has_disabled_member": True, + "members": [ + {"role": "head", "gender": "any", "age_range": (72, 85), "is_disabled": True}, + ], + "eligibility": {_UCG: False, _ESP: True, _CTP: False, _ERF: False, _DSG: True}, + }, + { + "id": "bp_17_elderly_with_adult_child_rural_mod", + "label": "Elderly with adult child, rural, moderate income", + "count": 20, + "zone": "rural", + "income_bracket": "moderate", + "income_range": (12000, 22000), + "members": [ + {"role": "head", "gender": "any", "age_range": (66, 75)}, + {"role": "adult", "gender": "any", "age_range": (35, 48)}, + ], + "eligibility": {_UCG: False, _ESP: True, _CTP: False, _ERF: False, _DSG: False}, + }, + # ========================================================================= + # Working-age Households (6 blueprints, ~125 households) + # ========================================================================= + { + "id": "bp_18_couple_no_children_urban_above_mod", + "label": "Working couple, no children, urban, above moderate (control)", + "count": 30, + "zone": "urban", + "income_bracket": "above_moderate", + "income_range": (30000, 60000), + "members": [ + {"role": "head", "gender": "male", "age_range": (28, 45)}, + {"role": "spouse", "gender": "female", "age_range": (26, 43)}, + ], + "eligibility": {_UCG: False, _CTP: False, _ERF: False, _DSG: False}, + }, + { + "id": "bp_19_couple_no_children_rural_mod", + "label": "Working couple, no children, rural, moderate (borderline)", + "count": 25, + "zone": "rural", + "income_bracket": "moderate", + "income_range": (14000, 24000), + "members": [ + {"role": "head", "gender": "male", "age_range": (30, 50)}, + {"role": "spouse", "gender": "female", "age_range": (28, 48)}, + ], + "eligibility": {_UCG: False, _CTP: False, _ERF: False, _DSG: False}, + }, + { + "id": "bp_20_single_adult_urban_above_mod", + "label": "Single working adult, urban (control)", + "count": 20, + "zone": "urban", + "income_bracket": "above_moderate", + "income_range": (25000, 50000), + "members": [ + {"role": "head", "gender": "any", "age_range": (25, 50)}, + ], + "eligibility": {_UCG: False, _CTP: False, _ERF: False, _DSG: False}, + }, + { + "id": "bp_21_extended_family_rural_low", + "label": "Extended family (head+spouse+elder+2 children), rural, low income", + "count": 15, + "zone": "rural", + "income_bracket": "low", + "income_range": (6000, 13000), + "members": [ + {"role": "head", "gender": "male", "age_range": (35, 48)}, + {"role": "spouse", "gender": "female", "age_range": (33, 46)}, + {"role": "elderly", "gender": "any", "age_range": (65, 80)}, + {"role": "child", "gender": "any", "age_range": (4, 9)}, + {"role": "child", "gender": "any", "age_range": (8, 13)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: False, _DSG: False}, + }, + { + "id": "bp_22_multi_gen_disabled_elder_rural_vlow", + "label": "Multi-gen with disabled elder, rural, very low income", + "count": 10, + "zone": "rural", + "income_bracket": "very_low", + "income_range": (2000, 6000), + "has_disabled_member": True, + "members": [ + {"role": "head", "gender": "male", "age_range": (35, 50)}, + {"role": "spouse", "gender": "female", "age_range": (33, 48)}, + {"role": "elderly", "gender": "any", "age_range": (68, 80), "is_disabled": True}, + {"role": "child", "gender": "any", "age_range": (3, 8)}, + {"role": "child", "gender": "any", "age_range": (7, 14)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: True, _DSG: True}, + }, + { + "id": "bp_23_couple_1child_periurban_low", + "label": "Standard family, 1 child, peri-urban, low income", + "count": 25, + "zone": "peri_urban", + "income_bracket": "low", + "income_range": (8000, 16000), + "members": [ + {"role": "head", "gender": "male", "age_range": (30, 45)}, + {"role": "spouse", "gender": "female", "age_range": (28, 43)}, + {"role": "child", "gender": "any", "age_range": (8, 14)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: False, _DSG: False}, + }, + # ========================================================================= + # Special Cases (5 blueprints, ~100 households) + # ========================================================================= + { + "id": "bp_24_emergency_displaced_rural_vlow", + "label": "Displaced family, high vulnerability, rural, very low income", + "count": 20, + "zone": "rural", + "income_bracket": "very_low", + "income_range": (1000, 4000), + "is_female_headed": True, + "members": [ + {"role": "head", "gender": "female", "age_range": (28, 45)}, + {"role": "child", "gender": "any", "age_range": (2, 6)}, + {"role": "child", "gender": "any", "age_range": (5, 10)}, + {"role": "child", "gender": "any", "age_range": (9, 15)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: True, _DSG: False}, + }, + { + "id": "bp_25_food_only_individual_urban_vlow", + "label": "Single individual, urban, very low income (food assistance only)", + "count": 30, + "zone": "urban", + "income_bracket": "very_low", + "income_range": (1000, 5000), + "members": [ + {"role": "head", "gender": "any", "age_range": (20, 55)}, + ], + "eligibility": {_UCG: False, _CTP: False, _ERF: False, _DSG: False}, + "individual_food_assistance": True, + }, + { + "id": "bp_26_food_only_individual_rural_vlow", + "label": "Single individual, rural, very low income (food assistance only)", + "count": 25, + "zone": "rural", + "income_bracket": "very_low", + "income_range": (500, 4000), + "members": [ + {"role": "head", "gender": "any", "age_range": (22, 60)}, + ], + "eligibility": {_UCG: False, _CTP: False, _ERF: False, _DSG: False}, + "individual_food_assistance": True, + }, + { + "id": "bp_27_disability_household_3disabled_urban_low", + "label": "Family with 2 disabled members, 1 child, urban, low income", + "count": 10, + "zone": "urban", + "income_bracket": "low", + "income_range": (5000, 11000), + "has_disabled_member": True, + "members": [ + {"role": "head", "gender": "male", "age_range": (35, 50), "is_disabled": True}, + {"role": "spouse", "gender": "female", "age_range": (33, 48), "is_disabled": True}, + {"role": "child", "gender": "any", "age_range": (6, 14)}, + ], + "eligibility": {_UCG: True, _CTP: True, _ERF: False, _DSG: True}, + }, + { + "id": "bp_28_multi_program_household_rural_vlow", + "label": "Maximum eligibility household, rural, very low income", + "count": 15, + "zone": "rural", + "income_bracket": "very_low", + "income_range": (1000, 5000), + "is_female_headed": True, + "has_disabled_member": True, + "members": [ + {"role": "head", "gender": "female", "age_range": (30, 45)}, + {"role": "child", "gender": "any", "age_range": (2, 6)}, + {"role": "child", "gender": "any", "age_range": (5, 10), "is_disabled": True}, + {"role": "elderly", "gender": "any", "age_range": (68, 80)}, + ], + "eligibility": {_UCG: True, _ESP: True, _CTP: True, _ERF: True, _DSG: True}, + }, +] + + +def get_all_blueprints(): + """Return all household blueprint definitions.""" + return HOUSEHOLD_BLUEPRINTS + + +def get_total_household_count(): + """Return total number of households across all blueprints.""" + return sum(bp["count"] for bp in HOUSEHOLD_BLUEPRINTS) + + +def get_total_member_estimate(): + """Return estimated total number of individual members.""" + return sum(bp["count"] * len(bp["members"]) for bp in HOUSEHOLD_BLUEPRINTS) + + +def get_blueprints_by_eligibility(program_id): + """Return blueprints where households are eligible for the given program.""" + return [bp for bp in HOUSEHOLD_BLUEPRINTS if bp["eligibility"].get(program_id)] diff --git a/spp_mis_demo_v2/models/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py index 317c3172..34282d2c 100644 --- a/spp_mis_demo_v2/models/mis_demo_generator.py +++ b/spp_mis_demo_v2/models/mis_demo_generator.py @@ -5,15 +5,13 @@ Generates demo data for SP-MIS programs following the V2 architecture: 1. Fixed Demo Programs - Predictable programs aligned with demo stories 2. Story-based Enrollments - Enrolls demo personas with payment history -3. Random Volume Data - Additional enrollments for realistic dashboards +3. Deterministic Volume Data - Blueprint-based households for realistic dashboards """ import datetime import logging import random -from faker import Faker - from odoo import Command, _, api, fields, models from odoo.exceptions import UserError, ValidationError from odoo.tools import config @@ -79,38 +77,11 @@ class SPPMISDemoGenerator(models.TransientModel): help="Create payment history for demo stories (entitlements and payments)", ) - # Volume generation options + # Volume generation options (uses deterministic blueprints) generate_volume = fields.Boolean( string="Generate Volume Data", default=True, - help="Generate additional random enrollments for realistic dashboards", - ) - volume_enrollments = fields.Integer( - string="Random Enrollments", - default=50, - help="Number of random program enrollments to generate", - ) - - # Random group/household generation - generate_random_groups = fields.Boolean( - string="Generate Random Groups", - default=True, - help="Generate random households/groups with members to supplement story data", - ) - random_groups_count = fields.Integer( - string="Number of Groups", - default=20, - help="Number of random groups/households to generate", - ) - members_per_group_min = fields.Integer( - string="Min Members per Group", - default=2, - help="Minimum number of members per random group", - ) - members_per_group_max = fields.Integer( - string="Max Members per Group", - default=6, - help="Maximum number of members per random group", + help="Generate deterministic households from blueprints (~730 households, ~2500 members)", ) # Cycle and payment options @@ -195,7 +166,8 @@ class SPPMISDemoGenerator(models.TransientModel): ], string="Country", default="phl", - help="Country for geographic data (areas and GIS shapes)", + required=True, + help="Determines locale, currency, and geographic data", ) # Locale settings @@ -203,7 +175,7 @@ class SPPMISDemoGenerator(models.TransientModel): "res.country", string="Locale Origin", default=lambda self: self.env.user.company_id.country_id or self.env.ref("base.us"), - help="Country for Faker locale", + help="Country for locale-specific name generation", ) # State tracking @@ -228,25 +200,11 @@ def action_ensure_demo_user_groups(self): rec._ensure_demo_user_groups() return True - @api.constrains( - "volume_enrollments", - "cycles_per_program", - "random_groups_count", - "members_per_group_min", - "members_per_group_max", - ) + @api.constrains("cycles_per_program") def _check_positive_integers(self): for rec in self: - if rec.volume_enrollments < 0: - raise ValidationError(_("Volume enrollments must be zero or positive")) if rec.cycles_per_program < 0: raise ValidationError(_("Cycles per program must be zero or positive")) - if rec.random_groups_count < 0: - raise ValidationError(_("Number of groups must be zero or positive")) - if rec.members_per_group_min < 1: - raise ValidationError(_("Minimum members per group must be at least 1")) - if rec.members_per_group_max < rec.members_per_group_min: - raise ValidationError(_("Maximum members must be greater than or equal to minimum")) @api.onchange("demo_mode") def _onchange_demo_mode(self): @@ -257,9 +215,6 @@ def _onchange_demo_mode(self): "enroll_demo_stories": True, "create_story_payments": True, "generate_volume": True, - "volume_enrollments": 50, - "generate_random_groups": True, - "random_groups_count": 20, "create_cycles": True, "cycles_per_program": 2, "create_event_data": True, @@ -281,9 +236,6 @@ def _onchange_demo_mode(self): "enroll_demo_stories": True, "create_story_payments": True, "generate_volume": True, - "volume_enrollments": 200, - "generate_random_groups": True, - "random_groups_count": 50, "create_cycles": True, "cycles_per_program": 3, "create_event_data": True, @@ -305,9 +257,6 @@ def _onchange_demo_mode(self): "enroll_demo_stories": True, "create_story_payments": True, "generate_volume": True, - "volume_enrollments": 5000, - "generate_random_groups": True, - "random_groups_count": 500, "create_cycles": True, "cycles_per_program": 3, "create_event_data": True, @@ -329,9 +278,6 @@ def _onchange_demo_mode(self): "enroll_demo_stories": True, "create_story_payments": True, "generate_volume": True, - "volume_enrollments": 500, - "generate_random_groups": True, - "random_groups_count": 100, "create_cycles": True, "cycles_per_program": 3, "create_event_data": True, @@ -353,6 +299,27 @@ def _onchange_demo_mode(self): for field_name, value in defaults.items(): setattr(self, field_name, value) + # Country configuration mapping (keyed by country_code 3-letter ISO) + COUNTRY_CONFIG = { + "phl": {"xmlid": "base.ph", "locale": "fil_PH", "currency_xmlid": "base.PHP"}, + "lka": {"xmlid": "base.lk", "locale": "si_LK", "currency_xmlid": "base.LKR"}, + "tgo": {"xmlid": "base.tg", "locale": "fr_TG", "currency_xmlid": "base.XOF"}, + } + + def _get_country_config(self): + """Get country, locale, and currency based on selected country_code.""" + config = self.COUNTRY_CONFIG.get(self.country_code, self.COUNTRY_CONFIG["phl"]) + country = self.env.ref(config["xmlid"], raise_if_not_found=False) + currency = self.env.ref(config["currency_xmlid"], raise_if_not_found=False) + # Ensure currency is active + if currency and not currency.active: + currency.active = True + return { + "country": country, + "locale": config["locale"], + "currency": currency, + } + def _install_logic_packs(self): """Install Logic Packs used by demo programs. @@ -361,7 +328,7 @@ def _install_logic_packs(self): """ from .demo_programs import get_demo_pack_codes - Pack = self.env["spp.studio.pack"].sudo() # nosemgrep: odoo-sudo-without-context + Pack = self.env["spp.studio.pack"].sudo() installed = [] for code in get_demo_pack_codes(): @@ -388,7 +355,7 @@ def _create_test_personas(self): """ # Test personas are loaded from demo_personas.xml # This method ensures they're created if module data wasn't loaded - Persona = self.env["spp.studio.test.persona"].sudo() # nosemgrep: odoo-sudo-without-context + Persona = self.env["spp.studio.test.persona"].sudo() # Check if personas already exist existing = Persona.search([("name", "ilike", "Maria Santos")], limit=1) @@ -443,132 +410,16 @@ def action_generate(self): "events_created": 0, "change_requests_created": 0, "missing_registrants": [], - "volume_skipped": 0, } try: - # Initialize Faker - faker_locale = self.locale_origin.faker_locale or "en_US" - fake = Faker(faker_locale) - - created_data = { - "programs": [], - "enrollments": [], - "cycles": [], - "payments": [], - "events": [], - "change_requests": [], - } - - # Step 0: Ensure security groups are assigned FIRST (ALWAYS) - # This is critical: menu visibility is cached at login time based on user groups. - # Groups must be assigned BEFORE any user logs in, otherwise the menu won't appear - # until the user logs out and back in (or cache is cleared). - self._ensure_demo_user_groups() - - # Step 0.25: Install Logic Packs (if enabled) - if self.install_logic_packs: - _logger.info("Installing Logic Packs for demo programs...") - installed_packs = self._install_logic_packs() - stats["logic_packs_installed"] = len(installed_packs) - - # Step 0.35: Create test personas (if enabled) - if self.include_test_personas: - _logger.info("Creating test personas for Logic Studio...") - self._create_test_personas() - stats["test_personas_created"] = True - - # Step 0.4: Load geographic data (if enabled) - if self.load_geographic_data: - _logger.info(f"Loading geographic data for {self.country_code}...") - geo_result = self._load_geographic_data(stats) - if geo_result: - stats["areas_loaded"] = geo_result.get("shapes_loaded", 0) - - # Step 0.5: Ensure demo stories exist (auto-generate if needed) - stories_created = self._ensure_demo_stories_exist(stats) - if stories_created: - _logger.info("Auto-generated %d demo story registrants", stories_created) - - # Step 0.75: Generate random groups/households - if self.generate_random_groups and self.random_groups_count > 0: - _logger.info(f"Generating {self.random_groups_count} random groups...") - self._generate_random_groups(fake, stats) - - # Step 1: Create demo programs - if self.create_demo_programs: - _logger.info("Creating demo programs...") - programs_result = self._create_demo_programs(stats) - created_data["programs"] = programs_result - - # Step 2: Enroll demo story personas - if self.enroll_demo_stories: - _logger.info("Enrolling demo story personas...") - story_result = self._enroll_demo_stories(stats) - created_data["enrollments"] = story_result.get("enrollments", []) - created_data["payments"] = story_result.get("payments", []) - created_data["batches"] = story_result.get("batches", []) - - # Step 3: Generate volume data - if self.generate_volume and self.volume_enrollments > 0: - _logger.info("Generating %d random enrollments...", self.volume_enrollments) - volume_result = self._generate_volume_enrollments(fake, stats) - created_data["enrollments"].extend(volume_result) - - # Step 4: Create cycles - if self.create_cycles: - _logger.info("Creating program cycles...") - created_data["cycles"] = self._create_program_cycles(fake, stats) - - # Step 5: Create event data - if self.create_event_data: - _logger.info("Creating event data for demo stories...") - created_data["events"] = self._create_story_events(stats) - - # Step 6: Create change requests - if self.create_change_requests: - _logger.info("Creating change requests for demo stories...") - created_data["change_requests"] = self._create_story_change_requests(stats) - - # Step 7: Create fairness analysis demo data - if self.create_fairness_analysis: - _logger.info("Creating fairness analysis demo data...") - self._create_fairness_analysis_demo(stats) - - # Step 8: Generate GRM demo data (if module installed) - if self.generate_grm_demo: - _logger.info("Generating GRM demo data...") - grm_result = self._generate_grm_demo(stats) - if grm_result: - stats["grm_tickets_created"] = grm_result.get("tickets", 0) - - # Step 9: Generate Case demo data (if module installed) - if self.generate_case_demo: - _logger.info("Generating Case demo data...") - case_result = self._generate_case_demo(stats) - if case_result: - stats["cases_created"] = case_result.get("cases", 0) - - # Step 10: Generate Claim 169 demo data (signing key, issuer, credentials) - if self.generate_claim169_demo: - _logger.info("Generating Claim 169 demo data...") - self._generate_claim169_demo(stats) - - # Step 11: Assign areas and generate GPS coordinates (if geographic data loaded) - if self.load_geographic_data: - _logger.info("Assigning areas to registrants...") - self._assign_registrant_areas(stats) - _logger.info("Generating GPS coordinates for registrants...") - self._generate_coordinates(stats) - - # Step 12: Refresh GIS reports so map data is available immediately - self._refresh_gis_reports(stats) - - # Step 13: Create PRISM API client with known credentials - self._create_prism_api_client(stats) + demo_locale, created_data = self._run_generation_steps(stats) self.state = "completed" + # Mark company as having loaded MIS demo data + self.env.company.mis_demo_loaded = True + # Return success notification with detailed summary return self._show_success_notification(stats) @@ -577,14 +428,163 @@ def action_generate(self): self.state = "draft" raise UserError(_("Error generating demo data: %s") % e) from e + def _run_generation_steps(self, stats): + """Execute all generation steps. Returns (demo_locale, created_data).""" + # Resolve country configuration (locale, currency) + country_cfg = self._get_country_config() + demo_locale = country_cfg["locale"] + # Store locale in context for use by story methods (locale-aware names) + self = self.with_context(demo_locale=demo_locale) + + # Set company country and currency + if country_cfg["country"]: + self.env.company.write({"country_id": country_cfg["country"].id}) + if country_cfg["currency"]: + self.env.company.write({"currency_id": country_cfg["currency"].id}) + + _logger.info( + "Country: %s, Locale: %s, Currency: %s", + self.country_code, + demo_locale, + country_cfg["currency"].name if country_cfg["currency"] else "N/A", + ) + + created_data = { + "programs": [], + "enrollments": [], + "cycles": [], + "payments": [], + "events": [], + "change_requests": [], + } + + # Step 0: Ensure security groups are assigned FIRST (ALWAYS) + self._ensure_demo_user_groups() + + # Step 0.25: Install Logic Packs (if enabled) + if self.install_logic_packs: + _logger.info("Installing Logic Packs for demo programs...") + installed_packs = self._install_logic_packs() + stats["logic_packs_installed"] = len(installed_packs) + + # Step 0.35: Create test personas (if enabled) + if self.include_test_personas: + _logger.info("Creating test personas for Logic Studio...") + self._create_test_personas() + stats["test_personas_created"] = True + + # Step 0.4: Load geographic data (if enabled) + if self.load_geographic_data: + _logger.info("Loading geographic data for %s...", self.country_code) + geo_result = self._load_geographic_data(stats) + if geo_result: + stats["areas_loaded"] = geo_result.get("shapes_loaded", 0) + + # Step 0.5: Ensure demo stories exist (auto-generate if needed) + stories_created = self._ensure_demo_stories_exist(stats) + if stories_created: + _logger.info("Auto-generated %d demo story registrants", stories_created) + + # Step 0.75: Generate deterministic households from blueprints + volume_households = [] + if self.generate_volume: + from .household_blueprints import HOUSEHOLD_BLUEPRINTS + from .seeded_volume_generator import SeededVolumeGenerator + + _logger.info("Generating deterministic households from %d blueprints...", len(HOUSEHOLD_BLUEPRINTS)) + generator = SeededVolumeGenerator(self.env, demo_locale, seed=42) + volume_households = generator.generate_all_households(HOUSEHOLD_BLUEPRINTS) + stats["random_groups_created"] = len(volume_households) + stats["random_individuals_created"] = sum(len(hh["members"]) for hh in volume_households) + + # Step 1: Create demo programs + if self.create_demo_programs: + _logger.info("Creating demo programs...") + programs_result = self._create_demo_programs(stats) + created_data["programs"] = programs_result + + # Step 2: Enroll demo story personas + if self.enroll_demo_stories: + _logger.info("Enrolling demo story personas...") + story_result = self._enroll_demo_stories(stats) + created_data["enrollments"] = story_result.get("enrollments", []) + created_data["payments"] = story_result.get("payments", []) + created_data["batches"] = story_result.get("batches", []) + + # Step 3: Enroll blueprint households in programs + if self.generate_volume and volume_households and created_data["programs"]: + _logger.info("Enrolling blueprint households in programs...") + program_map = {} + for prog in created_data["programs"]: + for prog_def in demo_programs.get_all_demo_programs(): + if prog_def["name"] == prog.name: + program_map[prog_def["id"]] = prog + break + generator.enroll_in_programs(volume_households, program_map) + + # Step 4: Create cycles + if self.create_cycles: + _logger.info("Creating program cycles...") + created_data["cycles"] = self._create_program_cycles(stats) + + # Step 5: Create event data + if self.create_event_data: + _logger.info("Creating event data for demo stories...") + created_data["events"] = self._create_story_events(stats) + + # Step 6: Create change requests + if self.create_change_requests: + _logger.info("Creating change requests for demo stories...") + created_data["change_requests"] = self._create_story_change_requests(stats) + + # Step 7: Create fairness analysis demo data + if self.create_fairness_analysis: + _logger.info("Creating fairness analysis demo data...") + self._create_fairness_analysis_demo(stats) + + # Step 8: Generate GRM demo data (if module installed) + if self.generate_grm_demo: + _logger.info("Generating GRM demo data...") + grm_result = self._generate_grm_demo(stats) + if grm_result: + stats["grm_tickets_created"] = grm_result.get("tickets", 0) + + # Step 9: Generate Case demo data (if module installed) + if self.generate_case_demo: + _logger.info("Generating Case demo data...") + case_result = self._generate_case_demo(stats) + if case_result: + stats["cases_created"] = case_result.get("cases", 0) + + # Step 10: Generate Claim 169 demo data (signing key, issuer, credentials) + if self.generate_claim169_demo: + _logger.info("Generating Claim 169 demo data...") + self._generate_claim169_demo(stats) + + # Step 11: Assign areas and generate GPS coordinates (if geographic data loaded) + if self.load_geographic_data: + _logger.info("Assigning areas to registrants...") + self._assign_registrant_areas(stats) + _logger.info("Generating GPS coordinates for registrants...") + self._generate_coordinates(stats) + + # Step 12: Refresh GIS reports so map data is available immediately + self._refresh_gis_reports(stats) + + # Step 13: Create PRISM API client with known credentials + self._create_prism_api_client(stats) + + return demo_locale, created_data + def _ensure_demo_stories_exist(self, stats): """Check if demo story registrants exist and create them if not.""" try: # Import story data from spp_demo from odoo.addons.spp_demo.models import demo_stories - # Check if story registrants exist - stories = demo_stories.get_all_stories() + # Check if story registrants exist (locale-aware) + locale = self.env.context.get("demo_locale") + stories = demo_stories.get_localized_stories(locale) story_names = [s["name"] for s in stories] existing = self.env["res.partner"].search( @@ -647,10 +647,7 @@ def _ensure_demo_stories_exist(self, stats): # Create members for existing groups that are missing them if groups_needing_members: - _logger.info( - "Creating members for %d existing groups...", - len(groups_needing_members), - ) + _logger.info("Creating members for %d existing groups...", len(groups_needing_members)) for group, story in groups_needing_members: try: profile = story.get("profile", {}) @@ -659,12 +656,7 @@ def _ensure_demo_stories_exist(self, stats): ( j for j in journey - if j.get("action") - in ( - "register", - "register_household", - "emergency_register", - ) + if j.get("action") in ("register", "register_household", "emergency_register") ), {"days_back": 90}, ) @@ -776,11 +768,7 @@ def _create_story_registrant(self, story): (registration_date, registrant.id), ) - _logger.info( - "Created story registrant (partner_id=%s, story_id=%s)", - registrant.id, - story.get("id", "unknown"), - ) + _logger.info("Created story registrant (partner_id=%s, story_id=%s)", registrant.id, story.get("id", "unknown")) # For households, create family members if is_group and "head" in profile: @@ -919,173 +907,6 @@ def _create_individual_member(self, member_data, registration_date): return member - def _generate_random_groups(self, fake, stats): - """Generate random groups/households with members.""" - try: - from odoo.addons.spp_demo.models import demo_stories - - reserved_names = demo_stories.RESERVED_NAMES - except ImportError: - reserved_names = [] - - groups_created = [] - head_membership_type = self.env["spp.vocabulary.code"].get_code( - "urn:openspp:vocab:group-membership-type", "head" - ) - - for i in range(self.random_groups_count): - try: - # Generate family with head of household - head_gender = random.choice(["male", "female"]) - head_first = fake.first_name_male() if head_gender == "male" else fake.first_name_female() - head_last = fake.last_name() - head_name = f"{head_first} {head_last}" - - # Skip if name is reserved - if head_name in reserved_names: - continue - - head_age = random.randint(25, 65) - - # Create the group (use family name only to distinguish from individuals) - registration_date = fake.date_between(start_date="-365d", end_date="-30d") - DemoGenerator = self.env["spp.demo.data.generator"] - group = DemoGenerator.create_group_from_params(head_last) - groups_created.append(group) - stats["random_groups_created"] += 1 - - # Backdate group creation - self.env.cr.execute( - "UPDATE res_partner SET create_date = %s WHERE id = %s", - (registration_date, group.id), - ) - - # Create head of household - head = self._create_random_individual( - fake, - head_name, - head_gender, - head_age, - registration_date, - reserved_names, - ) - if head: - stats["random_individuals_created"] += 1 - 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 [], - } - ) - - # Determine number of additional members - num_members = random.randint(self.members_per_group_min - 1, self.members_per_group_max - 1) - - # Sometimes add spouse - if num_members > 0 and random.random() < 0.7: - spouse_gender = "female" if head_gender == "male" else "male" - spouse_first = fake.first_name_female() if spouse_gender == "female" else fake.first_name_male() - spouse_name = f"{spouse_first} {head_last}" - spouse_age = head_age + random.randint(-5, 5) - - if spouse_name not in reserved_names: - spouse = self._create_random_individual( - fake, - spouse_name, - spouse_gender, - spouse_age, - registration_date, - reserved_names, - ) - if spouse: - stats["random_individuals_created"] += 1 - self.env["spp.group.membership"].create( - { - "group": group.id, - "individual": spouse.id, - } - ) - num_members -= 1 - - # Add children or other members - for _j in range(num_members): - member_age = random.randint(3, 22) if random.random() < 0.6 else random.randint(60, 85) - member_gender = random.choice(["male", "female"]) - member_first = fake.first_name_male() if member_gender == "male" else fake.first_name_female() - member_name = f"{member_first} {head_last}" - - if member_name not in reserved_names: - member = self._create_random_individual( - fake, - member_name, - member_gender, - member_age, - registration_date, - reserved_names, - ) - if member: - stats["random_individuals_created"] += 1 - self.env["spp.group.membership"].create( - { - "group": group.id, - "individual": member.id, - } - ) - - except Exception as e: - _logger.warning("Error creating random group %s: %s", i, e) - - _logger.info( - "Created %s random groups with %s individuals", - stats["random_groups_created"], - stats["random_individuals_created"], - ) - return groups_created - - def _create_random_individual(self, fake, name, gender, age, registration_date, reserved_names): - """Create a random individual registrant with realistic demographic data. - - Uses SPPDemoDataGenerator utility method for consistent individual creation, - adding MIS-specific fields (income, disability) via extra_vals. - - Includes income and disability status for proper variable calculation: - - Adults (18+): Random income between 1000-8000 (most below poverty line) - - ~5% chance of disability (realistic population rate) - """ - if name in reserved_names: - return None - - # Build MIS-specific extra values - extra_vals = {} - - # Monthly income for adults (for hh_total_income aggregate) - # Most households should be below poverty_line (2500) to be eligible - if age >= 18: - # 70% low income (500-2000), 25% moderate (2000-4000), 5% higher (4000-8000) - income_tier = random.random() - if income_tier < 0.70: - extra_vals["income"] = float(random.randint(500, 2000)) - elif income_tier < 0.95: - extra_vals["income"] = float(random.randint(2000, 4000)) - else: - extra_vals["income"] = float(random.randint(4000, 8000)) - - # Disability status (~5% of population for realistic demo data) - # Use SPPDemoDataGenerator utility for consistent individual creation - DemoGenerator = self.env["spp.demo.data.generator"] - individual = DemoGenerator.create_individual_from_params(name, gender, age, extra_vals) - - # Backdate creation - self.env.cr.execute( - "UPDATE res_partner SET create_date = %s WHERE id = %s", - (registration_date, individual.id), - ) - - return individual - def _create_demo_programs(self, stats): """Create demo programs from definitions.""" created_programs = [] @@ -1140,16 +961,8 @@ def _create_demo_programs(self, stats): if program_def.get("cycle_duration"): self._configure_cycle_manager(program, program_def) - # Configure compliance manager if compliance CEL expression specified - if program_def.get("compliance_cel_expression"): - self._configure_compliance_manager(program, program_def) - except Exception as e: - _logger.error( - "Error creating program (program_id=%s): %s", - program_def.get("id", "unknown"), - e, - ) + _logger.error("Error creating program (program_id=%s): %s", program_def.get("id", "unknown"), e) return created_programs @@ -1173,10 +986,7 @@ def _configure_entitlement_manager(self, program, program_def): "amount_per_individual_in_group": 0, } ) - _logger.info( - "Configured in-kind entitlement for program (program_id=%s)", - program.id, - ) + _logger.info("Configured in-kind entitlement for program (program_id=%s)", program.id) else: # For cash programs - configure CEL formula if available amount = program_def.get("entitlement_amount", 100) @@ -1218,18 +1028,10 @@ def _configure_entitlement_manager(self, program, program_def): entitlement_formula, ) - _logger.info( - "Configured cash entitlement $%.2f for program (program_id=%s)", - amount, - program.id, - ) + _logger.info("Configured cash entitlement $%.2f for program (program_id=%s)", amount, program.id) except Exception as e: - _logger.warning( - "Could not configure entitlement manager for program (program_id=%s): %s", - program.id, - e, - ) + _logger.warning("Could not configure entitlement manager for program (program_id=%s): %s", program.id, e) def _configure_cycle_manager(self, program, program_def): """Configure the cycle manager for a program.""" @@ -1242,55 +1044,7 @@ def _configure_cycle_manager(self, program, program_def): } ) except Exception as e: - _logger.warning( - "Could not configure cycle manager for program (program_id=%s): %s", - program.id, - e, - ) - - def _configure_compliance_manager(self, program, program_def): - """Configure the compliance manager with a CEL expression. - - Sets the compliance CEL expression for ongoing beneficiary verification. - """ - try: - compliance_manager = program.get_manager(program.MANAGER_COMPLIANCE) - if not compliance_manager: - _logger.warning( - "No compliance manager found for program (program_id=%s)", - program.id, - ) - return - - cel_expression = program_def.get("compliance_cel_expression") - if not cel_expression: - return - - if "compliance_cel_expression" not in compliance_manager._fields: - _logger.info( - "Compliance CEL not available for program (program_id=%s)", - program.id, - ) - return - - compliance_manager.write( - { - "compliance_cel_mode": "cel", - "compliance_cel_expression": cel_expression, - } - ) - _logger.info( - "Configured compliance CEL for program (program_id=%s): %s", - program.id, - cel_expression, - ) - - except Exception as e: - _logger.warning( - "Could not configure compliance manager for program (program_id=%s): %s", - program.id, - e, - ) + _logger.warning("Could not configure cycle manager for program (program_id=%s): %s", program.id, e) def _configure_eligibility_manager(self, program, program_def): """Configure the eligibility manager with CEL expression. @@ -1515,11 +1269,12 @@ def _enroll_demo_stories(self, stats): # Track payments by cycle for batch creation payments_by_cycle = {} - # Get demo stories from spp_demo + # Get demo stories from spp_demo (locale-aware) try: from odoo.addons.spp_demo.models import demo_stories - stories = demo_stories.get_all_stories() + locale = self.env.context.get("demo_locale") + stories = demo_stories.get_localized_stories(locale) except ImportError: _logger.warning("Could not import demo_stories from spp_demo") return result @@ -1535,10 +1290,7 @@ def _enroll_demo_stories(self, stats): ) if not registrant: - _logger.warning( - "Registrant not found for story (story_id=%s), skipping enrollment...", - story_id, - ) + _logger.warning("Registrant not found for story (story_id=%s), skipping enrollment...", story_id) stats["missing_registrants"].append(story_name) continue @@ -1599,10 +1351,7 @@ def _enroll_demo_stories(self, stats): # Track payments by cycle for batch creation if cycle and payments: if cycle.id not in payments_by_cycle: - payments_by_cycle[cycle.id] = { - "cycle": cycle, - "payments": [], - } + payments_by_cycle[cycle.id] = {"cycle": cycle, "payments": []} payments_by_cycle[cycle.id]["payments"].extend(payments) # Create in-kind entitlements if defined @@ -1690,10 +1439,7 @@ def _create_story_payments(self, registrant, program, membership, enrollment_def # Get the journal for entitlements journal = program.journal_id if not journal: - _logger.warning( - "No journal configured for program (program_id=%s), skipping payments", - program.id, - ) + _logger.warning("No journal configured for program (program_id=%s), skipping payments", program.id) return created_payments, cycle # Create cycle membership for the registrant (required before entitlements) @@ -1818,11 +1564,7 @@ def _create_payment_batches(self, payments_by_cycle, stats): ) except Exception as e: - _logger.warning( - "Could not create payment batch for cycle (cycle_id=%s): %s", - cycle_id, - e, - ) + _logger.warning("Could not create payment batch for cycle (cycle_id=%s): %s", cycle_id, e) return created_batches @@ -1982,178 +1724,166 @@ def _get_or_create_demo_cycle(self, program): _logger.error("Could not create demo cycle: %s", e) return None - def _generate_volume_enrollments(self, fake, stats): - """Generate random volume enrollments with tracking.""" - enrollments = [] - - # Get available programs - programs = self.env["spp.program"].search([("state", "=", "active")]) - if not programs: - _logger.warning("No active programs found for volume generation") - return enrollments - - # Separate programs by target type - group_programs = programs.filtered(lambda p: p.target_type == "group") - individual_programs = programs.filtered(lambda p: p.target_type == "individual") + def _create_program_cycles(self, stats): + """Create cycles with beneficiaries and entitlements for all programs. - # Get available registrants (excluding demo story names) - try: - from odoo.addons.spp_demo.models import demo_stories - - reserved_names = demo_stories.RESERVED_NAMES - except ImportError: - reserved_names = [] - - # Get groups and individuals separately - groups = self.env["res.partner"].search( - [ - ("is_registrant", "=", True), - ("is_group", "=", True), - ("name", "not in", reserved_names), - ], - limit=300, - ) - - individuals = self.env["res.partner"].search( + Uses direct record creation (same pattern as _create_story_payments) + because the full ORM workflow requires approval definitions and fund + balances that are not set up in demo data. + """ + cycles = [] + programs = self.env["spp.program"].search( [ - ("is_registrant", "=", True), - ("is_group", "=", False), - ("name", "not in", reserved_names), - ], - limit=300, + ("state", "=", "active"), + ("has_members", "=", True), + ] ) - if not groups and not individuals: - _logger.warning("No registrants found for volume generation") - return enrollments + today = fields.Date.today() - attempts = 0 - max_attempts = self.volume_enrollments * 3 # Allow retries + for program in programs: + try: + # Get or reuse existing cycle (created by story payments) + cycle = self.env["spp.cycle"].search( + [("program_id", "=", program.id)], + limit=1, + order="sequence desc", + ) - while len(enrollments) < self.volume_enrollments and attempts < max_attempts: - attempts += 1 + if not cycle: + # Create cycle with today as start_date (_check_dates requires >= today) + cycle = self.env["spp.cycle"].create( + { + "name": f"{program.name} - Demo Cycle 1", + "program_id": program.id, + "start_date": today, + "end_date": today + datetime.timedelta(days=30), + "sequence": 1, + "state": "draft", + } + ) + stats["cycles_created"] += 1 - # Match program type to registrant type - use_group = random.choice([True, False]) + # Get all enrolled program members not already in cycle + enrolled_members = self.env["spp.program.membership"].search( + [ + ("program_id", "=", program.id), + ("state", "=", "enrolled"), + ] + ) + existing_cycle_partner_ids = set(cycle.cycle_membership_ids.mapped("partner_id.id")) + _existing = existing_cycle_partner_ids # bind for lambda + new_members = enrolled_members.filtered(lambda m, ex=_existing: m.partner_id.id not in ex) - if use_group and group_programs and groups: - program = random.choice(group_programs) - registrant = random.choice(groups) - elif not use_group and individual_programs and individuals: - program = random.choice(individual_programs) - registrant = random.choice(individuals) - else: - # Fallback - if group_programs and groups: - program = random.choice(group_programs) - registrant = random.choice(groups) - elif individual_programs and individuals: - program = random.choice(individual_programs) - registrant = random.choice(individuals) - else: + if not new_members: + _logger.info("Program ID=%s: all %d members already in cycle", program.id, len(enrolled_members)) + cycles.append(cycle) continue - # Check if already enrolled - existing = self.env["spp.program.membership"].search( - [ - ("partner_id", "=", registrant.id), - ("program_id", "=", program.id), - ], - limit=1, - ) + # Batch-create cycle memberships (use partner's registration date) + cm_vals = [ + { + "cycle_id": cycle.id, + "partner_id": m.partner_id.id, + "enrollment_date": m.partner_id.registration_date or today, + "state": "enrolled", + } + for m in new_members + ] + for i in range(0, len(cm_vals), 200): + self.env["spp.cycle.membership"].create(cm_vals[i : i + 200]) + stats["cycle_members_created"] = stats.get("cycle_members_created", 0) + len(cm_vals) - if existing: - stats["volume_skipped"] += 1 - continue + # Determine entitlement amount from entitlement manager config + base_amount = self._get_entitlement_amount(program) - # Create enrollment - try: - enrollment_date = fake.date_between(start_date="-180d", end_date="-10d") - state = random.choices( - ["draft", "enrolled", "paused", "exited"], - weights=[10, 60, 10, 20], - k=1, - )[0] - - membership = self.env["spp.program.membership"].create( + # Batch-create entitlements in approved state (bypasses approval workflow) + ent_vals = [ { - "partner_id": registrant.id, - "program_id": program.id, - "state": state, + "cycle_id": cycle.id, + "partner_id": m.partner_id.id, + "initial_amount": base_amount, + "state": "approved", + "is_cash_entitlement": True, + "valid_from": cycle.start_date, + "valid_until": cycle.end_date, } - ) + for m in new_members + ] + for i in range(0, len(ent_vals), 200): + self.env["spp.entitlement"].create(ent_vals[i : i + 200]) + stats["entitlements_created"] = stats.get("entitlements_created", 0) + len(ent_vals) - if state in ("enrolled", "exited"): - self.env.cr.execute( - "UPDATE spp_program_membership SET enrollment_date = %s WHERE id = %s", - (enrollment_date, membership.id), + # Ensure cycle is in approved state + if cycle.state == "draft": + cycle.write( + { + "state": "approved", + "approved_date": fields.Datetime.now(), + "approved_by": self.env.user.id, + } ) - enrollments.append(membership) - stats["enrollments_created"] += 1 - - except Exception as e: - _logger.warning("Could not create volume enrollment: %s", e) - stats["volume_skipped"] += 1 - - _logger.info( - "Volume generation: %d created, %d skipped", - len(enrollments), - stats["volume_skipped"], - ) - - return enrollments - - def _create_program_cycles(self, fake, stats): - """Create cycles for programs with enrolled beneficiaries.""" - cycles = [] - programs = self.env["spp.program"].search( - [ - ("state", "=", "active"), - ("has_members", "=", True), - ] - ) + # Backdate start_date via SQL (bypass _check_dates ORM constraint) + backdated_start = today - datetime.timedelta(days=180) + self.env.cr.execute( + "UPDATE spp_cycle SET start_date = %s WHERE id = %s", + (backdated_start, cycle.id), + ) + cycle.invalidate_recordset(["start_date"]) - for program in programs: - # Check if program already has cycles - existing_cycles = self.env["spp.cycle"].search_count([("program_id", "=", program.id)]) - cycles_to_create = max(0, self.cycles_per_program - existing_cycles) + # Create program fund to cover entitlements (with 20% buffer) + total_entitlement_amount = base_amount * len(new_members) + self._create_program_fund(program, total_entitlement_amount * 1.2) - if cycles_to_create == 0: + cycles.append(cycle) _logger.info( - "Program (program_id=%s) already has %d cycles", - program.id, - existing_cycles, + "Cycle '%s': %d beneficiaries, %d entitlements for program '%s'", + cycle.name, + len(cm_vals), + len(ent_vals), + program.name, ) - continue - for _i in range(cycles_to_create): - try: - # For demo purposes, ensure a cycle exists even if managers are not configured - cycle = self._get_or_create_demo_cycle(program) - - if cycle: - cycles.append(cycle) - stats["cycles_created"] += 1 - _logger.info( - "Created cycle (cycle_id=%s) for program (program_id=%s)", - cycle.id, - program.id, - ) + except Exception as e: + _logger.warning("Could not create cycle for program ID=%s: %s", program.id, e) - # Prepare entitlements - cycle_manager = program.get_manager(program.MANAGER_CYCLE) - if cycle_manager: - cycle_manager.prepare_entitlements(cycle) + return cycles - except Exception as e: - _logger.warning( - "Could not create cycle for program (program_id=%s): %s", - program.id, - e, - ) + def _create_program_fund(self, program, amount): + """Create a posted program fund entry to cover entitlements.""" + try: + fund = self.env["spp.program.fund"].create( + { + "name": f"Initial Fund - {program.name}", + "program_id": program.id, + "amount": amount, + "date_posted": fields.Date.today(), + "state": "draft", + } + ) + fund.post_fund() + _logger.info( + "Created program fund: %s = %.2f for '%s'", + fund.name, + amount, + program.name, + ) + except Exception as e: + _logger.warning("Could not create fund for program ID=%s: %s", program.id, e) - return cycles + def _get_entitlement_amount(self, program): + """Get the entitlement amount from program's entitlement manager config.""" + try: + ent_manager = program.get_manager(program.MANAGER_ENTITLEMENT) + if ent_manager and hasattr(ent_manager, "manager_ref_id"): + mgr_ref = ent_manager.manager_ref_id + if hasattr(mgr_ref, "amount_per_cycle") and mgr_ref.amount_per_cycle: + return mgr_ref.amount_per_cycle + except Exception: + pass + # Fallback: sensible demo default + return 150.0 # ══════════════════════════════════════════════════════════════ # EVENT DATA GENERATION @@ -2291,47 +2021,52 @@ def _create_single_event(self, registrant, event_def, stats): event = self.env["spp.event.data"].create(event_vals) stats["events_created"] += 1 - _logger.info( - "Created %s event (event_id=%s, partner_id=%s)", - event_type_code, - event.id, - registrant.id, - ) + _logger.info("Created %s event (event_id=%s, partner_id=%s)", event_type_code, event.id, registrant.id) return event def _get_story_name(self, story_id): - """Convert story ID to registrant name. + """Convert story ID to registrant name (locale-aware). Looks up the correct registrant name for a story ID by: - 1. Checking the demo_stories module for the canonical story name + 1. Checking the demo_stories module for the localized story name 2. Falling back to a mapping for CR-specific IDs that reference stories 3. Using title-case conversion only as a last resort """ + locale = self.env.context.get("demo_locale") + # First, try to get the name from demo_stories (canonical source) try: from odoo.addons.spp_demo.models import demo_stories - story = demo_stories.get_story_by_id(story_id) - if story: - return story["name"] + name = demo_stories.get_localized_name(story_id, locale) + if name: + return name except ImportError: pass # Mapping for CR-specific IDs that reference existing stories # These are not actual story IDs but CR scenario identifiers - cr_id_mapping = { - "amina_osman_draft": "Amina Osman", - "chen_large_family_split": "Chen Wei", - "luis_fernandez_merge": "Luis Fernandez", - "maria_santos_conflict_1": "Maria Santos", - "maria_santos_conflict_2": "Maria Santos", - "carlos_elena_morales_remove": "Carlos Morales", - "grace_okonkwo_create_group": "Grace Okonkwo", + # Resolve via the base story ID to get locale-aware names + cr_id_to_story = { + "amina_osman_draft": "amina_osman_household", + "chen_large_family_split": "chen_large_family", + "luis_fernandez_merge": "luis_fernandez", + "maria_santos_conflict_1": "maria_santos", + "maria_santos_conflict_2": "maria_santos", + "carlos_elena_morales_remove": "carlos_elena_morales", + "grace_okonkwo_create_group": "grace_okonkwo", } - if story_id in cr_id_mapping: - return cr_id_mapping[story_id] + if story_id in cr_id_to_story: + try: + from odoo.addons.spp_demo.models import demo_stories + + name = demo_stories.get_localized_name(cr_id_to_story[story_id], locale) + if name: + return name + except ImportError: + pass # Last resort: title-case the ID (for truly unknown IDs) # Log a warning since this may indicate a missing story definition @@ -2370,19 +2105,10 @@ def _ensure_demo_user_groups(self): "spp_demo.demo_viewer", [ ("spp_registry", "group_registry_viewer"), # Registry access - ( - "spp_service_points", - "group_service_points_viewer", - ), # Registrant form reads service points - ( - "spp_vocabulary", - "group_vocabulary_viewer", - ), # Vocabulary read access for forms + ("spp_service_points", "group_service_points_viewer"), # Registrant form reads service points + ("spp_vocabulary", "group_vocabulary_viewer"), # Vocabulary read access for forms ("spp_programs", "group_programs_viewer"), - ( - "spp_change_request_v2", - "group_cr_user", - ), # Needs user to see menu + ("spp_change_request_v2", "group_cr_user"), # Needs user to see menu ("spp_grm", "group_grm_viewer"), ("spp_case_base", "group_case_viewer"), ], @@ -2393,10 +2119,7 @@ def _ensure_demo_user_groups(self): [ ("spp_registry", "group_registry_officer"), # Registry access ("spp_service_points", "group_service_points_officer"), - ( - "spp_vocabulary", - "group_vocabulary_officer", - ), # Vocabulary for editing/creating + ("spp_vocabulary", "group_vocabulary_officer"), # Vocabulary for editing/creating ("spp_programs", "group_programs_officer"), ("spp_change_request_v2", "group_cr_user"), ("spp_grm", "group_grm_officer"), @@ -2407,10 +2130,7 @@ def _ensure_demo_user_groups(self): ( "spp_demo.demo_supervisor", [ - ( - "spp_registry", - "group_registry_officer", - ), # Registry access (officer level) + ("spp_registry", "group_registry_officer"), # Registry access (officer level) ("spp_service_points", "group_service_points_officer"), ("spp_vocabulary", "group_vocabulary_officer"), ("spp_programs", "group_programs_officer"), @@ -2424,23 +2144,14 @@ def _ensure_demo_user_groups(self): "spp_demo.demo_manager", [ ("spp_registry", "group_registry_manager"), # Registry access - ( - "spp_registry_search", - "group_registry_auditor", - ), # Browse-all audit access + ("spp_registry_search", "group_registry_auditor"), # Browse-all audit access ("spp_service_points", "group_service_points_manager"), - ( - "spp_vocabulary", - "group_vocabulary_manager", - ), # Full vocabulary access + ("spp_vocabulary", "group_vocabulary_manager"), # Full vocabulary access ("spp_programs", "group_programs_manager"), ("spp_change_request_v2", "group_cr_manager"), ("spp_grm", "group_grm_manager"), ("spp_case_base", "group_case_manager"), - ( - "spp_cel_domain", - "group_cel_domain_manager", - ), # CEL Domain Manager access + ("spp_cel_domain", "group_cel_domain_manager"), # CEL Domain Manager access ("spp_studio", "group_studio_manager"), # Studio Manager access ], ), @@ -2756,33 +2467,135 @@ def _create_story_change_requests(self, stats): _logger.info("Creating CRs as demo_officer (user_id=%s)", demo_officer.id) for story_id, cr_def in self.STORY_CHANGE_REQUESTS.items(): - registrant = self._ensure_story_registrant(story_id, cr_def) + # Localize CR definition for current locale + localized_def = self._localize_cr_def(story_id, cr_def) + registrant = self._ensure_story_registrant(story_id, localized_def) if not registrant: _logger.warning("Registrant not found for change request (story_id=%s)", story_id) continue try: - cr = self._create_single_change_request(registrant, cr_def, stats, demo_user=demo_officer) + cr = self._create_single_change_request(registrant, localized_def, stats, demo_user=demo_officer) if cr: created_crs.append(cr) except Exception as e: - _logger.error( - "Error creating change request for story (story_id=%s): %s", - story_id, - e, - ) + _logger.error("Error creating change request for story (story_id=%s): %s", story_id, e) return created_crs + def _localize_cr_def(self, story_id, cr_def): + """Localize CR definition names for the current locale. + + Replaces hardcoded member names in proposed_changes with locale-aware + versions by looking up member names from the localized story profiles. + """ + locale = self.env.context.get("demo_locale") + if not locale or locale == "fil_PH": + return cr_def + + try: + from odoo.addons.spp_demo.models import demo_stories + except ImportError: + return cr_def + + locale_map = demo_stories.LOCALE_NAMES.get(locale, {}) + if not locale_map: + return cr_def + + import copy as _copy + + localized = _copy.deepcopy(cr_def) + changes = localized.get("proposed_changes", {}) + + # Map CR story_id to base story_id for profile lookups + cr_to_base = { + "amina_osman_draft": "amina_osman_household", + "chen_large_family_split": "chen_large_family", + "luis_fernandez_merge": "luis_fernandez", + "maria_santos_conflict_1": "maria_santos", + "maria_santos_conflict_2": "maria_santos", + "carlos_elena_morales_remove": "carlos_elena_morales", + "grace_okonkwo_create_group": "grace_okonkwo", + } + base_id = cr_to_base.get(story_id, story_id) + entry = locale_map.get(base_id, {}) + pnames = entry.get("profile_names", {}) + localized_name = entry.get("name", "") + + # Localize member_name (transfer/remove member — matches child or adult) + if "member_name" in changes and pnames.get("children"): + # Find which child index matches the original name + orig_story = demo_stories.get_story_by_id(base_id) + if orig_story: + orig_children = orig_story.get("profile", {}).get("children", []) + for idx, child in enumerate(orig_children): + if child["name"] == changes["member_name"] and idx < len(pnames["children"]): + changes["member_name"] = pnames["children"][idx] + break + + # Localize new_head_name (change_hoh — matches adult) + if "new_head_name" in changes and pnames.get("adults"): + orig_story = demo_stories.get_story_by_id(base_id) + if orig_story: + orig_adults = orig_story.get("profile", {}).get("adults", []) + for idx, adult in enumerate(orig_adults): + if adult["name"] == changes["new_head_name"] and idx < len(pnames["adults"]): + changes["new_head_name"] = pnames["adults"][idx] + break + + # Localize head_name and group_name (create_group) + if "head_name" in changes and localized_name: + changes["head_name"] = localized_name + if "group_name" in changes and localized_name: + # Derive group name from localized surname + surname = localized_name.split()[-1] if localized_name else "" + changes["group_name"] = f"{surname} Household" + + # Localize new_group_name (split_household) + if "new_group_name" in changes and localized_name: + surname = localized_name.split()[-1] if localized_name else "" + changes["new_group_name"] = f"{surname} Family - Unit B" + + # Localize given_name / family_name (add_member — new baby) + if "family_name" in changes and localized_name: + surname = localized_name.split()[-1] if localized_name else "" + changes["family_name"] = surname + if "given_name" in changes: + changes["given_name"] = f"Baby {surname}" + + # Localize primary_registrant / duplicate_registrant (merge) + if "primary_registrant" in changes and localized_name: + changes["primary_registrant"] = localized_name + if "duplicate_registrant" in changes and localized_name: + changes["duplicate_registrant"] = f"{localized_name} (Mobile)" + + # Localize members_to_transfer list + if "members_to_transfer" in changes and pnames.get("children"): + orig_story = demo_stories.get_story_by_id(base_id) + if orig_story: + orig_children = orig_story.get("profile", {}).get("children", []) + localized_list = [] + for orig_name in changes["members_to_transfer"]: + found = False + for idx, child in enumerate(orig_children): + if child["name"] == orig_name and idx < len(pnames["children"]): + localized_list.append(pnames["children"][idx]) + found = True + break + if not found: + localized_list.append(orig_name) + changes["members_to_transfer"] = localized_list + + return localized + def _ensure_story_registrant(self, story_id, cr_def): """Ensure registrant exists for a story; create minimal if missing. - If cr_def contains 'registrant_name', use that instead of deriving from story_id. - This allows multiple CRs to target the same registrant (e.g., conflict detection). + Uses _get_story_name() for locale-aware name resolution. """ - # Use explicit registrant_name if provided, otherwise derive from story_id - story_name = cr_def.get("registrant_name") or self._get_story_name(story_id) + # Always use locale-aware name resolution (handles CR-specific IDs too) + story_name = self._get_story_name(story_id) registrant = self.env["res.partner"].search( [("name", "=", story_name), ("is_registrant", "=", True)], @@ -2841,10 +2654,7 @@ def _create_single_change_request(self, registrant, cr_def, stats, demo_user=Non # Use with_user() only for demo data generation to simulate # different request owners; demo_user is a controlled user # provided by the test/demo setup. - cr_model = cr_model.with_user( # nosemgrep: odoo-with-user-unvalidated - # Demo-only generator, demo_user is not user input. - demo_user - ) + cr_model = cr_model.with_user(demo_user) # nosemgrep: odoo-with-user-unvalidated cr = cr_model.create(cr_vals) # Backdate the creation @@ -2858,7 +2668,7 @@ def _create_single_change_request(self, registrant, cr_def, stats, demo_user=Non detail.write(detail_vals) # Backdate detail creation for timeline consistency self.env.cr.execute( - f"UPDATE {detail._table} SET create_date = %s WHERE id = %s", # nosec B608 — _table from Odoo model, not user input + f"UPDATE {detail._table} SET create_date = %s WHERE id = %s", # nosec B608 (request_date, detail.id), ) @@ -2877,12 +2687,7 @@ def _create_single_change_request(self, registrant, cr_def, stats, demo_user=Non self._set_cr_state(cr, "revision", revision_notes=revision_notes) stats["change_requests_created"] += 1 - _logger.info( - "Created %s change request (cr_id=%s, partner_id=%s)", - target_state, - cr.id, - registrant.id, - ) + _logger.info("Created %s change request (cr_id=%s, partner_id=%s)", target_state, cr.id, registrant.id) return cr @@ -2907,26 +2712,26 @@ def _set_cr_state(self, cr, target_state, apply=False, rejection_reason=None, re """ try: if target_state == "pending": - cr.sudo().action_submit_for_approval() # nosemgrep: odoo-sudo-without-context + cr.sudo().action_submit_for_approval() elif target_state == "approved": - cr.sudo().action_submit_for_approval() # nosemgrep: odoo-sudo-without-context - cr.sudo().action_approve() # nosemgrep: odoo-sudo-without-context + cr.sudo().action_submit_for_approval() + cr.sudo().action_approve() elif target_state == "rejected": # Submit first, then reject - cr.sudo().action_submit_for_approval() # nosemgrep: odoo-sudo-without-context + cr.sudo().action_submit_for_approval() if hasattr(cr, "action_reject"): - cr.sudo().action_reject() # nosemgrep: odoo-sudo-without-context + cr.sudo().action_reject() # Set rejection reason if field exists if rejection_reason and "rejection_reason" in cr._fields: - cr.sudo().write({"rejection_reason": rejection_reason}) # nosemgrep: odoo-sudo-without-context + cr.sudo().write({"rejection_reason": rejection_reason}) elif target_state == "revision": # Submit first, then request revision - cr.sudo().action_submit_for_approval() # nosemgrep: odoo-sudo-without-context + cr.sudo().action_submit_for_approval() if hasattr(cr, "action_request_revision"): - cr.sudo().action_request_revision() # nosemgrep: odoo-sudo-without-context + cr.sudo().action_request_revision() # Set revision notes if field exists if revision_notes and "revision_notes" in cr._fields: - cr.sudo().write({"revision_notes": revision_notes}) # nosemgrep: odoo-sudo-without-context + cr.sudo().write({"revision_notes": revision_notes}) except Exception as e: # Don't fall back to direct state write for states that require approval reviews. # This would create an inconsistent state (approval_state=pending but no pending reviews). @@ -2941,10 +2746,10 @@ def _set_cr_state(self, cr, target_state, apply=False, rejection_reason=None, re if apply: try: - cr.sudo().action_apply() # nosemgrep: odoo-sudo-without-context + cr.sudo().action_apply() except Exception as e: _logger.warning("Apply step failed, setting applied flags directly: %s", e) - cr.sudo().write( # nosemgrep: odoo-sudo-without-context + cr.sudo().write( { "approval_state": "approved", "is_applied": True, @@ -2996,13 +2801,7 @@ def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_d "given_name": proposed_changes.get("given_name"), "family_name": proposed_changes.get("family_name"), "member_name": " ".join( - filter( - None, - [ - proposed_changes.get("given_name"), - proposed_changes.get("family_name"), - ], - ) + filter(None, [proposed_changes.get("given_name"), proposed_changes.get("family_name")]) ), "birthdate": proposed_changes.get("birthdate"), "relationship_id": relationship_id, @@ -3030,11 +2829,7 @@ def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_d target_name = self._get_story_name(target_story) # Search with ilike for case-insensitive match target_group = self.env["res.partner"].search( - [ - ("name", "ilike", target_name), - ("is_group", "=", True), - ("is_registrant", "=", True), - ], + [("name", "ilike", target_name), ("is_group", "=", True), ("is_registrant", "=", True)], limit=1, ) vals.update( @@ -3118,18 +2913,13 @@ def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_d if primary_name: primary = self.env["res.partner"].search( - [("name", "ilike", primary_name), ("is_registrant", "=", True)], - limit=1, + [("name", "ilike", primary_name), ("is_registrant", "=", True)], limit=1 ) primary_id = primary.id if primary else False if duplicate_name: duplicate = self.env["res.partner"].search( - [ - ("name", "ilike", duplicate_name), - ("is_registrant", "=", True), - ], - limit=1, + [("name", "ilike", duplicate_name), ("is_registrant", "=", True)], limit=1 ) duplicate_id = duplicate.id if duplicate else False @@ -3258,11 +3048,7 @@ def _create_fairness_analysis_demo(self, stats): stats["fairness_analysis_created"] = created_count stats["fairness_snapshots_created"] = snapshot_count - _logger.info( - "Created %d fairness analysis records and %d snapshots", - created_count, - snapshot_count, - ) + _logger.info("Created %d fairness analysis records and %d snapshots", created_count, snapshot_count) def _get_fairness_demo_data(self): """Get demo data structure for fairness analysis.""" @@ -3405,7 +3191,7 @@ def _generate_claim169_demo(self, stats): try: # Step 1: Ensure default key provider exists - ProviderRegistry = self.env["spp.key.provider.registry"].sudo() # nosemgrep: odoo-sudo-without-context + ProviderRegistry = self.env["spp.key.provider.registry"].sudo() default_provider = ProviderRegistry.search([("is_default", "=", True)], limit=1) if not default_provider: default_provider = ProviderRegistry.create( @@ -3418,7 +3204,7 @@ def _generate_claim169_demo(self, stats): _logger.info("[spp.mis.demo] Created default key provider") # Step 2: Create Ed25519 signing key (if not exists) - AsymmetricKey = self.env["spp.asymmetric.key"].sudo() # nosemgrep: odoo-sudo-without-context + AsymmetricKey = self.env["spp.asymmetric.key"].sudo() signing_key = AsymmetricKey.search( [("name", "=", "Demo Claim 169 Signing Key")], limit=1, @@ -3438,7 +3224,7 @@ def _generate_claim169_demo(self, stats): _logger.info("[spp.mis.demo] Using existing signing key: %s", signing_key.kid) # Step 3: Create issuer configuration (if not exists) - IssuerConfig = self.env["spp.claim169.issuer.config"].sudo() # nosemgrep: odoo-sudo-without-context + IssuerConfig = self.env["spp.claim169.issuer.config"].sudo() issuer = IssuerConfig.search( [("name", "=", "Demo National ID")], limit=1, @@ -3454,9 +3240,9 @@ def _generate_claim169_demo(self, stats): } ) result["issuer_created"] = True - _logger.info("[spp.mis.demo] Created issuer config ID %s", issuer.id) + _logger.info("[spp.mis.demo] Created issuer config ID=%s", issuer.id) else: - _logger.info("[spp.mis.demo] Using existing issuer config ID %s", issuer.id) + _logger.info("[spp.mis.demo] Using existing issuer config ID=%s", issuer.id) # Step 4: Generate credentials for demo story personas if self.generate_credentials_for_stories: @@ -3486,7 +3272,8 @@ def _generate_story_credentials(self, issuer, stats): try: from odoo.addons.spp_demo.models import demo_stories - stories = demo_stories.get_all_stories() + locale = self.env.context.get("demo_locale") + stories = demo_stories.get_localized_stories(locale) story_names = [s["name"] for s in stories] # Find story partners @@ -3501,7 +3288,7 @@ def _generate_story_credentials(self, issuer, stats): _logger.warning("[spp.mis.demo] No demo story partners found for credentials") return 0 - Credential = self.env["spp.claim169.credential"].sudo() # nosemgrep: odoo-sudo-without-context + Credential = self.env["spp.claim169.credential"].sudo() credentials_created = 0 for partner in partners: @@ -3515,7 +3302,7 @@ def _generate_story_credentials(self, issuer, stats): ) if existing: _logger.debug( - "[spp.mis.demo] Credential already exists for %s", + "[spp.mis.demo] Credential already exists for partner ID=%s", partner.id, ) continue @@ -3538,9 +3325,9 @@ def _generate_story_credentials(self, issuer, stats): credential.generate_credential() credentials_created += 1 _logger.debug( - "[spp.mis.demo] Generated credential for %s: %s", + "[spp.mis.demo] Generated credential for partner ID=%s: %s", partner.id, - credential.id, + credential.name, ) _logger.info( @@ -3559,18 +3346,13 @@ def _show_success_notification(self, stats): # Stories (auto-generated) if stats.get("stories_created", 0) > 0: - message_parts.append( - _( - "Stories: %(count)s registrants auto-created", - count=stats["stories_created"], - ) - ) + message_parts.append(_("Stories: %(count)s registrants auto-created", count=stats["stories_created"])) - # Random groups (auto-generated) + # Blueprint households (deterministic volume) if stats.get("random_groups_created", 0) > 0: message_parts.append( _( - "Random Groups: %(groups)s groups, %(individuals)s members created", + "Blueprint Households: %(groups)s households, %(individuals)s members created", groups=stats["random_groups_created"], individuals=stats["random_individuals_created"], ) @@ -3592,7 +3374,7 @@ def _show_success_notification(self, stats): _( "Enrollments: %(created)s created, %(skipped)s skipped", created=stats["enrollments_created"], - skipped=stats["enrollments_skipped"] + stats["volume_skipped"], + skipped=stats["enrollments_skipped"], ) ) @@ -3606,7 +3388,14 @@ def _show_success_notification(self, stats): # Cycles if self.create_cycles: - message_parts.append(_("Cycles: %(count)s created", count=stats["cycles_created"])) + cycle_msg = _("Cycles: %(count)s created", count=stats["cycles_created"]) + if stats.get("cycle_members_created"): + cycle_msg += _( + " (%(members)s beneficiaries, %(ents)s entitlements)", + members=stats["cycle_members_created"], + ents=stats.get("entitlements_created", 0), + ) + message_parts.append(cycle_msg) # Events if self.create_event_data and stats["events_created"] > 0: @@ -3614,20 +3403,12 @@ def _show_success_notification(self, stats): # Change Requests if self.create_change_requests and stats["change_requests_created"] > 0: - message_parts.append( - _( - "Change Requests: %(count)s created", - count=stats["change_requests_created"], - ) - ) + message_parts.append(_("Change Requests: %(count)s created", count=stats["change_requests_created"])) # Fairness Analysis if self.create_fairness_analysis and stats.get("fairness_analysis_created", 0) > 0: message_parts.append( - _( - "Fairness Analysis: %(count)s records created", - count=stats["fairness_analysis_created"], - ) + _("Fairness Analysis: %(count)s records created", count=stats["fairness_analysis_created"]) ) # GRM Tickets @@ -3642,10 +3423,7 @@ def _show_success_notification(self, stats): if self.generate_claim169_demo: if stats.get("claim169_skipped"): message_parts.append( - _( - "QR Credentials: skipped (%(reason)s)", - reason=stats.get("claim169_skip_reason", "unknown"), - ) + _("QR Credentials: skipped (%(reason)s)", reason=stats.get("claim169_skip_reason", "unknown")) ) else: claim169_parts = [] @@ -3654,12 +3432,7 @@ def _show_success_notification(self, stats): if stats.get("claim169_issuer_created"): claim169_parts.append(_("issuer config")) if stats.get("claim169_credentials_created", 0) > 0: - claim169_parts.append( - _( - "%(count)s credentials", - count=stats["claim169_credentials_created"], - ) - ) + claim169_parts.append(_("%(count)s credentials", count=stats["claim169_credentials_created"])) if claim169_parts: message_parts.append(_("QR Credentials: %s created") % ", ".join(claim169_parts)) @@ -3670,10 +3443,7 @@ def _show_success_notification(self, stats): if stats["missing_registrants"]: message_parts.append("") message_parts.append( - _( - "Warning: Missing registrants: %(names)s", - names=", ".join(stats["missing_registrants"]), - ) + _("Warning: Missing registrants: %(names)s", names=", ".join(stats["missing_registrants"])) ) message_parts.append(_("Run 'Generate Stories' in spp_demo first.")) @@ -3689,7 +3459,7 @@ def _show_success_notification(self, stats): # Redirect to Programs list after loading demo data action = self.env.ref("spp_programs.action_program_list", raise_if_not_found=False) if action: - result = action.sudo().read()[0] # nosemgrep: odoo-sudo-without-context + result = action.sudo().read()[0] result["target"] = "main" return result @@ -3966,6 +3736,32 @@ class SPPMISDemoWizard(models.TransientModel): _description = "MIS Demo Data Wizard" _inherit = "spp.mis.demo.generator" + mis_demo_loaded = fields.Boolean( + related="company_id.mis_demo_loaded", + string="Demo Already Loaded", + ) + company_id = fields.Many2one( + "res.company", + default=lambda self: self.env.company, + ) + has_grm_demo = fields.Boolean(compute="_compute_has_optional_demos") + has_case_demo = fields.Boolean(compute="_compute_has_optional_demos") + + def _compute_has_optional_demos(self): + installed_names = set( + self.env["ir.module.module"] + .search( + [ + ("name", "in", ["spp_grm_demo", "spp_case_demo"]), + ("state", "=", "installed"), + ] + ) + .mapped("name") + ) + for rec in self: + rec.has_grm_demo = "spp_grm_demo" in installed_names + rec.has_case_demo = "spp_case_demo" in installed_names + def action_generate_demo_data(self): """Action to generate demo data from wizard.""" return self.action_generate() diff --git a/spp_mis_demo_v2/models/res_company.py b/spp_mis_demo_v2/models/res_company.py new file mode 100644 index 00000000..f9cb5dec --- /dev/null +++ b/spp_mis_demo_v2/models/res_company.py @@ -0,0 +1,13 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + mis_demo_loaded = fields.Boolean( + string="MIS Demo Data Loaded", + default=False, + help="Indicates that MIS demo data has already been generated for this company.", + ) diff --git a/spp_mis_demo_v2/models/seeded_volume_generator.py b/spp_mis_demo_v2/models/seeded_volume_generator.py new file mode 100644 index 00000000..cf5f1a5e --- /dev/null +++ b/spp_mis_demo_v2/models/seeded_volume_generator.py @@ -0,0 +1,481 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +""" +Seeded Volume Generator for Deterministic Demo Data + +Generates households and members from blueprint definitions using: +- random.Random(seed) for all structural choices (ages, incomes, genders, names) + +Same seed = identical output every run. +Different locale = different names but same household structure. + +Performance optimized with: +- Batched create() calls (~200 records per batch) +- Context flags to disable tracking/mail +- Deferred recomputation +""" + +import datetime +import logging +import random + +from odoo import fields + +from odoo.addons.spp_demo.locale_providers import get_faker_provider +from odoo.addons.spp_demo.models.demo_stories import get_localized_reserved_names + +_logger = logging.getLogger(__name__) + +BATCH_SIZE = 200 + + +class SeededVolumeGenerator: + """Deterministic household/member generator using seeded RNG. + + Not an ORM model — a utility class instantiated by the wizard. + """ + + def __init__(self, env, locale, seed=42): + self.env = env + self.locale = locale + self.seed = seed + self.rng = random.Random(seed) + self.reserved_names = set(get_localized_reserved_names(locale)) + + # Load locale-specific name arrays from provider (no Faker dependency) + provider = get_faker_provider(locale) + if provider: + self._male_names = list(provider.first_names_male) + self._female_names = list(provider.first_names_female) + self._last_names = list(provider.last_names) + else: + # Fallback: generic English names + self._male_names = ["John", "James", "Robert", "Michael", "David", "William"] + self._female_names = ["Mary", "Patricia", "Jennifer", "Linda", "Elizabeth", "Susan"] + self._last_names = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia"] + + # Caches + self._gender_cache = {} + self._head_type_id = None + self._group_type_id = None + + # ========================================================================= + # Public API + # ========================================================================= + + def generate_all_households(self, blueprints): + """Generate all households from blueprint definitions. + + Returns: + list[dict]: Each dict has 'group' (partner record), + 'members' (list of partner records), + 'blueprint' (original blueprint dict) + """ + total_hh = sum(bp["count"] for bp in blueprints) + total_members = sum(bp["count"] * len(bp["members"]) for bp in blueprints) + _logger.info( + "Starting volume generation: %d blueprints, %d households, ~%d members", + len(blueprints), + total_hh, + total_members, + ) + + households = [] + group_vals_list = [] + member_specs = [] # (blueprint, member_index, group_index_in_batch) + + # Phase 1: Prepare all group values + _logger.info("Phase 1/%d: Preparing %d household records...", 4, total_hh) + for bp in blueprints: + for i in range(bp["count"]): + group_name = self._generate_group_name() + income = self.rng.randint(*bp["income_range"]) + gps = self._generate_gps_for_zone(bp["zone"]) + + gvals = { + "name": group_name, + "is_registrant": True, + "is_group": True, + "registration_date": self._random_registration_date(), + } + if self._get_group_type_id(): + gvals["group_type_id"] = self._get_group_type_id() + + partner_fields = self.env["res.partner"]._fields + if "income" in partner_fields: + gvals["income"] = income + if "household_size" in partner_fields: + gvals["household_size"] = len(bp["members"]) + if gps and "gps_coordinates" in partner_fields: + gvals["gps_coordinates"] = gps + + group_vals_list.append(gvals) + member_specs.append((bp, i)) + + # Phase 2: Batch-create groups + _logger.info("Phase 2/%d: Creating %d groups in batches...", 4, len(group_vals_list)) + groups = self._batch_create("res.partner", group_vals_list) + + # Phase 3: Prepare and batch-create all individual members + _logger.info("Phase 3/%d: Preparing individual members...", 4) + all_individual_vals = [] + individual_to_group = [] # (group_record, member_spec_from_blueprint) + + for group_idx, (bp, _instance_idx) in enumerate(member_specs): + group_record = groups[group_idx] + for member_spec in bp["members"]: + gender = self._resolve_gender(member_spec.get("gender", "any")) + age = self.rng.randint(*member_spec["age_range"]) + given_name, family_name = self._generate_member_name(gender) + + # Compute name in standard format + name_parts = [ + f"{family_name}," if family_name and given_name else family_name or "", + given_name, + ] + computed_name = " ".join(filter(None, name_parts)).upper() + + birthdate = self._birthdate_from_age(age, group_record.registration_date) + + ival = { + "name": computed_name, + "given_name": given_name, + "family_name": family_name, + "is_registrant": True, + "is_group": False, + "gender_id": self._get_gender_id(gender), + "birthdate": birthdate, + "registration_date": group_record.registration_date, + } + + partner_fields = self.env["res.partner"]._fields + if "income" in partner_fields and member_spec["role"] in ("head", "spouse", "adult"): + ival["income"] = self.rng.randint(0, 30000) + + all_individual_vals.append(ival) + individual_to_group.append((group_record, member_spec)) + + _logger.info("Phase 3/%d: Creating %d individuals in batches...", 4, len(all_individual_vals)) + individuals = self._batch_create("res.partner", all_individual_vals) + + # 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() + + current_group = None + has_head_for_current_group = False + + for ind_idx, individual in enumerate(individuals): + group_record, member_spec = individual_to_group[ind_idx] + + # Track head assignment per group + if group_record != current_group: + current_group = group_record + has_head_for_current_group = False + + mval = { + "group": group_record.id, + "individual": individual.id, + "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)] + has_head_for_current_group = True + # Update group name to head's family name + group_record.name = individual.family_name or individual.name + + membership_vals_list.append(mval) + + self._batch_create("spp.group.membership", membership_vals_list) + + # Build result list + group_households = {} + for ind_idx, individual in enumerate(individuals): + group_record = individual_to_group[ind_idx][0] + if group_record.id not in group_households: + group_households[group_record.id] = { + "group": group_record, + "members": [], + "blueprint": member_specs[list(groups).index(group_record)][0], + } + group_households[group_record.id]["members"].append(individual) + + households = list(group_households.values()) + _logger.info( + "Volume generation complete: %d households, %d individuals", + len(groups), + len(individuals), + ) + return households + + def enroll_in_programs(self, households, program_map): + """Enroll households in programs based on eligibility flags. + + Handles both group-target and individual-target programs: + - Group programs (UCG, CTP, ERF, DSG): enroll the household group + - Individual programs (ESP): enroll qualifying individual members + - Food Assistance: enroll individual members from flagged blueprints + + After creation, backdates enrollment_date via SQL (it's a computed + field that always sets Datetime.now()) and adds state variety for realism. + """ + # Identify individual-target programs + individual_programs = set() + for prog_id, program in program_map.items(): + if program.target_type == "individual": + individual_programs.add(prog_id) + + enrollment_vals = [] + # Track (partner_id, registration_date) for backdating + enrollment_dates = [] + + for hh in households: + bp = hh["blueprint"] + group = hh["group"] + reg_date = group.registration_date or fields.Date.today() + + for prog_id, is_eligible in bp.get("eligibility", {}).items(): + if not is_eligible: + continue + program = program_map.get(prog_id) + if not program: + continue + + if prog_id in individual_programs: + # Individual-target program: enroll qualifying members + for member in hh["members"]: + # ESP: only enroll elderly members (age >= 60 from blueprint) + if prog_id == "elderly_social_pension": + member_spec = self._find_member_spec(bp, member) + if not member_spec or member_spec.get("age_range", (0, 0))[0] < 60: + continue + enrollment_vals.append( + { + "program_id": program.id, + "partner_id": member.id, + "state": "enrolled", + } + ) + enrollment_dates.append(member.registration_date or reg_date) + else: + # Group-target program: enroll the household + enrollment_vals.append( + { + "program_id": program.id, + "partner_id": group.id, + "state": "enrolled", + } + ) + enrollment_dates.append(reg_date) + + # Individual-level food assistance + if bp.get("individual_food_assistance"): + fa_program = program_map.get("food_assistance") + if fa_program: + for member in hh["members"]: + enrollment_vals.append( + { + "program_id": fa_program.id, + "partner_id": member.id, + "state": "enrolled", + } + ) + enrollment_dates.append(member.registration_date or reg_date) + + if not enrollment_vals: + return + + _logger.info("Enrolling %d program memberships...", len(enrollment_vals)) + memberships = self._batch_create("spp.program.membership", enrollment_vals) + + # Add state variety and backdate enrollment dates in one pass. + # enrollment_date is @api.depends("state") so we must do BOTH via SQL + # after all ORM operations are complete, to prevent recomputation. + self.env.flush_all() + self._apply_membership_realism(memberships, enrollment_dates) + + def _find_member_spec(self, blueprint, member_record): + """Find the blueprint member spec that matches a created member record.""" + members = blueprint.get("members", []) + # Match by index in the household — members were created in blueprint order + for spec in members: + if spec.get("role") in ("head", "elderly") and spec.get("age_range", (0, 0))[0] >= 60: + return spec + return None + + def _apply_membership_realism(self, memberships, enrollment_dates): + """Apply state variety and backdate enrollment dates via SQL. + + enrollment_date is @api.depends("state") — any ORM state change triggers + recomputation to Datetime.now(). To prevent this, we: + 1. flush_all() to commit ORM state + 2. Apply state variety + date backdating together in raw SQL + 3. Invalidate the cache so ORM sees our changes + """ + if not memberships: + return + + membership_ids = memberships.ids + exited_count = paused_count = not_eligible_count = 0 + + for idx, mem_id in enumerate(membership_ids): + # Determine state + roll = self.rng.random() + state = "enrolled" + if roll < 0.02: + state = "not_eligible" + not_eligible_count += 1 + elif roll < 0.05: + state = "paused" + paused_count += 1 + elif roll < 0.10: + state = "exited" + exited_count += 1 + + # Determine enrollment date from registration date + if idx < len(enrollment_dates): + reg_date = enrollment_dates[idx] + enrollment_dt = datetime.datetime.combine(reg_date, datetime.time(8, 0, 0)) + else: + enrollment_dt = datetime.datetime.now() + + # Single SQL update for both state and enrollment_date + self.env.cr.execute( + "UPDATE spp_program_membership SET state = %s, enrollment_date = %s WHERE id = %s", + (state, enrollment_dt, mem_id), + ) + + memberships.invalidate_recordset(["state", "enrollment_date"]) + _logger.info( + "Realism for %d memberships: %d exited, %d paused, %d not_eligible, dates backdated", + len(membership_ids), + exited_count, + paused_count, + not_eligible_count, + ) + + # ========================================================================= + # Internal helpers + # ========================================================================= + + def _batch_create(self, model_name, vals_list): + """Create records in batches for performance.""" + if not vals_list: + return self.env[model_name] + + all_records = self.env[model_name] + for i in range(0, len(vals_list), BATCH_SIZE): + batch = vals_list[i : i + BATCH_SIZE] + records = self.env[model_name].create(batch) + all_records |= records + if len(vals_list) > BATCH_SIZE: + _logger.info( + " %s: batch %d/%d (%d records)", + model_name, + (i // BATCH_SIZE) + 1, + (len(vals_list) + BATCH_SIZE - 1) // BATCH_SIZE, + len(batch), + ) + return all_records + + def _generate_group_name(self): + """Generate a household name from seeded RNG.""" + family_name = self.rng.choice(self._last_names) + return f"{family_name} Household" + + def _generate_member_name(self, gender): + """Generate a (given_name, family_name) tuple, avoiding reserved names.""" + max_attempts = 20 + for _ in range(max_attempts): + if gender == "male": + given = self.rng.choice(self._male_names) + else: + given = self.rng.choice(self._female_names) + family = self.rng.choice(self._last_names) + full_name = f"{given} {family}" + if full_name not in self.reserved_names: + return given, family + # After max attempts, return anyway (extremely unlikely collision) + return given, family + + def _resolve_gender(self, gender_spec): + """Resolve 'any' gender to 'male' or 'female' deterministically.""" + if gender_spec == "any": + return self.rng.choice(["male", "female"]) + return gender_spec + + def _get_gender_id(self, gender): + """Look up gender vocabulary code ID, with caching.""" + if gender not in self._gender_cache: + gender_code_map = {"male": "1", "female": "2"} + iso_code = gender_code_map.get(gender, "1") + VocabCode = self.env["spp.vocabulary.code"] + code = VocabCode.get_code("urn:iso:std:iso:5218", iso_code) + 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_group_type_id(self): + """Get a default group type ID, with caching.""" + if self._group_type_id is None: + group_types = self.env["spp.vocabulary.code"].search( + [("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:group-type")], + limit=1, + ) + self._group_type_id = group_types[0].id if group_types else False + return self._group_type_id + + def _birthdate_from_age(self, age, reference_date=None): + """Calculate a deterministic birthdate from age using seeded RNG. + + Uses reference_date (registration date) to ensure birthdate < registration_date. + """ + ref = reference_date or fields.Date.today() + birth_year = ref.year - age - 1 + birth_month = self.rng.randint(1, 12) + birth_day = self.rng.randint(1, 28) + return datetime.date(birth_year, birth_month, birth_day) + + def _random_registration_date(self): + """Generate a registration date within the last 2 years.""" + days_back = self.rng.randint(30, 730) + return fields.Date.today() - datetime.timedelta(days=days_back) + + def _generate_gps_for_zone(self, zone): + """Generate GPS coordinates based on zone type. + + Uses the company's country GPS bounds if available. + """ + country = self.env.company.country_id + if not country or not all([country.lat_min, country.lat_max, country.lon_min, country.lon_max]): + return None + + lat_min, lat_max = country.lat_min, country.lat_max + lon_min, lon_max = country.lon_min, country.lon_max + + # Narrow the range for urban zones (center of country) + if zone == "urban": + lat_center = (lat_min + lat_max) / 2 + lon_center = (lon_min + lon_max) / 2 + lat_range = (lat_max - lat_min) * 0.15 + lon_range = (lon_max - lon_min) * 0.15 + lat_min, lat_max = lat_center - lat_range, lat_center + lat_range + lon_min, lon_max = lon_center - lon_range, lon_center + lon_range + elif zone == "peri_urban": + lat_center = (lat_min + lat_max) / 2 + lon_center = (lon_min + lon_max) / 2 + lat_range = (lat_max - lat_min) * 0.3 + lon_range = (lon_max - lon_min) * 0.3 + lat_min, lat_max = lat_center - lat_range, lat_center + lat_range + lon_min, lon_max = lon_center - lon_range, lon_center + lon_range + + lat = round(self.rng.uniform(lat_min, lat_max), 6) + lon = round(self.rng.uniform(lon_min, lon_max), 6) + return f"{lat}, {lon}" diff --git a/spp_mis_demo_v2/tests/__init__.py b/spp_mis_demo_v2/tests/__init__.py index 44ca7fb5..0cca2b44 100644 --- a/spp_mis_demo_v2/tests/__init__.py +++ b/spp_mis_demo_v2/tests/__init__.py @@ -3,6 +3,7 @@ from . import test_access_control from . import test_access_control_case from . import test_access_control_grm +from . import test_blueprint_reproducibility from . import test_claim169_demo from . import test_demo_programs from . import test_formula_configuration diff --git a/spp_mis_demo_v2/tests/test_blueprint_reproducibility.py b/spp_mis_demo_v2/tests/test_blueprint_reproducibility.py new file mode 100644 index 00000000..fdc3082e --- /dev/null +++ b/spp_mis_demo_v2/tests/test_blueprint_reproducibility.py @@ -0,0 +1,141 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +""" +Tests for Blueprint + Seeded RNG reproducibility. + +Verifies that: +1. Same seed + same locale produces identical output +2. Different locales produce different names but same structure +3. Blueprint counts meet the 1000+ registrant target +4. Volume names don't collide with reserved story names +""" + +from odoo.tests import TransactionCase + +from ..models.household_blueprints import ( + HOUSEHOLD_BLUEPRINTS, + get_total_household_count, + get_total_member_estimate, +) +from ..models.seeded_volume_generator import SeededVolumeGenerator + + +class TestBlueprintReproducibility(TransactionCase): + """Test deterministic generation from blueprints.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + tracking_disable=True, + mail_create_nolog=True, + no_reset_password=True, + ) + ) + + def test_total_count_over_1000_registrants(self): + """Blueprint system generates 1000+ total registrants.""" + total_hh = get_total_household_count() + total_members = get_total_member_estimate() + total_registrants = total_hh + total_members + + self.assertGreater(total_hh, 500, "Should have at least 500 households") + self.assertGreater(total_registrants, 1000, "Should have over 1000 total registrants") + + def test_blueprint_structure_integrity(self): + """Each blueprint has required fields and valid data.""" + for bp in HOUSEHOLD_BLUEPRINTS: + self.assertIn("id", bp, "Blueprint missing 'id'") + self.assertIn("count", bp, f"Blueprint {bp.get('id')} missing 'count'") + self.assertIn("members", bp, f"Blueprint {bp.get('id')} missing 'members'") + self.assertIn("eligibility", bp, f"Blueprint {bp.get('id')} missing 'eligibility'") + self.assertGreater(bp["count"], 0, f"Blueprint {bp['id']} count must be positive") + self.assertGreater(len(bp["members"]), 0, f"Blueprint {bp['id']} must have members") + + for member in bp["members"]: + self.assertIn("role", member) + self.assertIn("gender", member) + self.assertIn("age_range", member) + self.assertEqual(len(member["age_range"]), 2) + self.assertLessEqual(member["age_range"][0], member["age_range"][1]) + + def test_same_locale_identical_output(self): + """Two runs with same seed + locale produce identical names.""" + # Use a small subset for speed + subset = [bp for bp in HOUSEHOLD_BLUEPRINTS if bp["count"] <= 15][:3] + + gen1 = SeededVolumeGenerator(self.env, "fil_PH", seed=42) + result1 = gen1.generate_all_households(subset) + + gen2 = SeededVolumeGenerator(self.env, "fil_PH", seed=42) + result2 = gen2.generate_all_households(subset) + + self.assertEqual(len(result1), len(result2), "Same number of households") + + for hh1, hh2 in zip(result1, result2, strict=False): + self.assertEqual(len(hh1["members"]), len(hh2["members"]), "Same number of members") + for m1, m2 in zip(hh1["members"], hh2["members"], strict=False): + self.assertEqual(m1.name, m2.name, "Same name for same seed+locale") + self.assertEqual(m1.birthdate, m2.birthdate, "Same birthdate for same seed") + + def test_different_locale_different_names_same_structure(self): + """Different locales produce different names but same household structure.""" + # Use smallest blueprint + subset = [bp for bp in HOUSEHOLD_BLUEPRINTS if bp["count"] <= 10][:1] + if not subset: + subset = [HOUSEHOLD_BLUEPRINTS[0].copy()] + subset[0]["count"] = 2 + + gen_ph = SeededVolumeGenerator(self.env, "fil_PH", seed=42) + result_ph = gen_ph.generate_all_households(subset) + + gen_lk = SeededVolumeGenerator(self.env, "si_LK", seed=42) + result_lk = gen_lk.generate_all_households(subset) + + self.assertEqual(len(result_ph), len(result_lk), "Same number of households") + + # Structure should be the same + for hh_ph, hh_lk in zip(result_ph, result_lk, strict=False): + self.assertEqual( + len(hh_ph["members"]), + len(hh_lk["members"]), + "Same number of members regardless of locale", + ) + + def test_no_reserved_name_collisions(self): + """Volume names don't collide with reserved story names.""" + from odoo.addons.spp_demo.models.demo_stories import get_localized_reserved_names + + subset = [bp for bp in HOUSEHOLD_BLUEPRINTS if bp["count"] <= 15][:2] + + gen = SeededVolumeGenerator(self.env, "fil_PH", seed=42) + result = gen.generate_all_households(subset) + + all_names = set() + for hh in result: + for member in hh["members"]: + full_name = f"{member.given_name} {member.family_name}" + all_names.add(full_name) + + collisions = all_names & set(get_localized_reserved_names("fil_PH")) + self.assertEqual(len(collisions), 0, f"Name collisions with reserved names: {collisions}") + + def test_eligibility_flags_exist_for_known_programs(self): + """Each blueprint's eligibility flags reference valid program IDs.""" + valid_program_ids = { + "universal_child_grant", + "elderly_social_pension", + "emergency_relief_fund", + "cash_transfer_program", + "disability_support_grant", + "food_assistance", + } + + for bp in HOUSEHOLD_BLUEPRINTS: + for prog_id in bp.get("eligibility", {}): + self.assertIn( + prog_id, + valid_program_ids, + f"Blueprint {bp['id']} references unknown program: {prog_id}", + ) 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 28bb1ec1..e51123d0 100644 --- a/spp_mis_demo_v2/tests/test_mis_demo_generator.py +++ b/spp_mis_demo_v2/tests/test_mis_demo_generator.py @@ -25,7 +25,6 @@ def test_generator_creation(self): self.assertTrue(generator.create_demo_programs) self.assertTrue(generator.enroll_demo_stories) self.assertTrue(generator.generate_volume) - self.assertEqual(generator.volume_enrollments, 50) self.assertEqual(generator.state, "draft") def test_wizard_creation(self): @@ -101,7 +100,6 @@ def test_volume_generation_without_registrants(self): "create_demo_programs": True, "enroll_demo_stories": False, "generate_volume": True, - "volume_enrollments": 10, "create_cycles": False, "locale_origin": self.test_country.id, } @@ -392,7 +390,6 @@ def test_volume_generation_with_registrants(self): "create_demo_programs": True, "enroll_demo_stories": False, "generate_volume": True, - "volume_enrollments": 5, "create_cycles": False, "locale_origin": self.test_country.id, } @@ -433,7 +430,6 @@ def test_enrollment_duplicate_prevention(self): "create_demo_programs": False, "enroll_demo_stories": False, "generate_volume": True, - "volume_enrollments": 50, "create_cycles": False, "locale_origin": self.test_country.id, } @@ -481,7 +477,6 @@ def _run_generator_for_stories(self): "create_demo_programs": False, "enroll_demo_stories": False, "generate_volume": False, - "generate_random_groups": False, "create_cycles": False, "create_event_data": False, "create_change_requests": False, @@ -644,9 +639,7 @@ def test_head_of_household_has_correct_membership_type(self): ) self.assertEqual( - len(head_membership), - 1, - f"Household '{story_name}' should have exactly one head of household", + len(head_membership), 1, f"Household '{story_name}' should have exactly one head of household" ) def test_idempotent_member_creation(self): @@ -670,11 +663,7 @@ def test_idempotent_member_creation(self): # Count should be the same second_count = self.env["spp.group.membership"].search_count([("group", "=", group.id)]) - self.assertEqual( - first_count, - second_count, - "Running generator twice should not duplicate members", - ) + self.assertEqual(first_count, second_count, "Running generator twice should not duplicate members") def test_individual_members_have_correct_attributes(self): """Test that created individual members have correct attributes.""" diff --git a/spp_mis_demo_v2/tests/test_registry_variables.py b/spp_mis_demo_v2/tests/test_registry_variables.py index 8e3b8b3a..6425f7ff 100644 --- a/spp_mis_demo_v2/tests/test_registry_variables.py +++ b/spp_mis_demo_v2/tests/test_registry_variables.py @@ -174,105 +174,3 @@ def test_create_individual_member_defaults(self): # Should not have income set self.assertEqual(member.income, 0.0, "income should default to 0") - - def test_random_individual_has_income_for_adults(self): - """Test _create_random_individual sets income for adults.""" - from datetime import date - - try: - from faker import Faker - except ImportError: - self.skipTest("faker not installed") - - fake = Faker() - generator = self.env["spp.mis.demo.generator"].create( - { - "name": "Test Random Income", - "locale_origin": self.test_country.id, - } - ) - - registration_date = date.today() - - # Create adult (age >= 18) - should have income - adult = generator._create_random_individual(fake, "John Smith", "male", 35, registration_date, []) - self.assertGreater( - adult.income, - 0, - "Adult should have income set", - ) - self.assertLessEqual( - adult.income, - 8000, - "income should be within expected range", - ) - - def test_random_individual_no_income_for_children(self): - """Test _create_random_individual doesn't set income for children.""" - from datetime import date - - try: - from faker import Faker - except ImportError: - self.skipTest("faker not installed") - - fake = Faker() - generator = self.env["spp.mis.demo.generator"].create( - { - "name": "Test Child Income", - "locale_origin": self.test_country.id, - } - ) - - registration_date = date.today() - - # Create child (age < 18) - should not have income - child = generator._create_random_individual(fake, "Jane Smith", "female", 10, registration_date, []) - self.assertEqual( - child.income, - 0.0, - "Child should not have income set", - ) - - def test_income_distribution_tiers(self): - """Test that income is distributed across expected tiers. - - Expected: 70% low (500-2000), 25% moderate (2000-4000), 5% high (4000-8000) - """ - from datetime import date - - try: - from faker import Faker - except ImportError: - self.skipTest("faker not installed") - - fake = Faker() - generator = self.env["spp.mis.demo.generator"].create( - { - "name": "Test Income Tiers", - "locale_origin": self.test_country.id, - } - ) - - registration_date = date.today() - sample_size = 100 - low_count = 0 - moderate_count = 0 - high_count = 0 - - for i in range(sample_size): - member = generator._create_random_individual(fake, f"Person{i} Test", "male", 30, registration_date, []) - income = member.income - if income <= 2000: - low_count += 1 - elif income <= 4000: - moderate_count += 1 - else: - high_count += 1 - - # Check low tier is majority (allow for variance) - self.assertGreater( - low_count, - sample_size * 0.5, # At least 50% low income - f"Low income tier ({low_count}/{sample_size}) should be majority", - ) diff --git a/spp_mis_demo_v2/views/mis_demo_wizard_view.xml b/spp_mis_demo_v2/views/mis_demo_wizard_view.xml index 3872451d..696da44b 100644 --- a/spp_mis_demo_v2/views/mis_demo_wizard_view.xml +++ b/spp_mis_demo_v2/views/mis_demo_wizard_view.xml @@ -7,7 +7,25 @@
-