From 8fd4f273b9849b511586e66f7315194b3e211da3 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Mon, 2 Mar 2026 17:29:30 +0800 Subject: [PATCH 1/3] feat(spp_mis_demo_v2,spp_demo): replace random volume gen with deterministic blueprints - Add household blueprints with ~730 deterministic households - Add SeededVolumeGenerator for reproducible demo data - Add locale-aware Filipino/Togolese name providers in spp_demo - Populate cycle beneficiaries and entitlements - Improve demo data realism (enrollment dates, payment history) - Add blueprint reproducibility tests --- spp_demo/data/res_country.xml | 3984 ++++++++--------- spp_demo/locale_providers/__init__.py | 6 + spp_demo/locale_providers/fil_PH/__init__.py | 316 ++ spp_demo/locale_providers/fr_TG/__init__.py | 315 ++ spp_demo/models/demo_stories.py | 412 +- spp_demo/tests/test_demo_stories.py | 139 + spp_mis_demo_v2/data/demo_currencies.xml | 28 +- spp_mis_demo_v2/docs/USE_CASES.md | 104 +- spp_mis_demo_v2/models/__init__.py | 2 + .../models/household_blueprints.py | 467 ++ spp_mis_demo_v2/models/mis_demo_generator.py | 847 ++-- .../models/seeded_volume_generator.py | 472 ++ spp_mis_demo_v2/tests/__init__.py | 1 + .../tests/test_blueprint_reproducibility.py | 141 + .../tests/test_mis_demo_generator.py | 17 +- .../views/mis_demo_wizard_view.xml | 42 +- 16 files changed, 4802 insertions(+), 2491 deletions(-) create mode 100644 spp_demo/locale_providers/fil_PH/__init__.py create mode 100644 spp_demo/locale_providers/fr_TG/__init__.py create mode 100644 spp_mis_demo_v2/models/household_blueprints.py create mode 100644 spp_mis_demo_v2/models/seeded_volume_generator.py create mode 100644 spp_mis_demo_v2/tests/test_blueprint_reproducibility.py diff --git a/spp_demo/data/res_country.xml b/spp_demo/data/res_country.xml index b90d8951..03040792 100644 --- a/spp_demo/data/res_country.xml +++ b/spp_demo/data/res_country.xml @@ -1,1994 +1,1994 @@ - - 29.3772 - 38.4911 - 60.5284 - 74.8899 - en_US - False - - - 39.6449 - 42.6611 - 19.1246 - 21.0574 - en_US - False - - - 18.9681 - 37.0937 - -8.6676 - 11.9973 - en_US - False - - - -14.3757 - -11.0496 - -170.8496 - -168.1433 - en_US - False - - - 42.4284 - 42.6550 - 1.4130 - 1.7867 - en_US - False - - - -18.0382 - -4.3881 - 11.6792 - 24.0821 - en_US - False - - - 18.1600 - 18.2766 - -63.1625 - -62.9625 - en_US - False - - - -90.0000 - -60.0000 - -180.0000 - 180.0000 - en_US - False - - - 16.9970 - 17.7290 - -61.9066 - -61.6730 - en_US - False - - - -55.0613 - -21.7811 - -73.5604 - -53.6374 - es_AR - True - - - 38.8403 - 41.3018 - 43.4471 - 46.6342 - hy_AM - True - - - 12.4226 - 12.6300 - -70.0648 - -69.8762 - en_US - False - - - -43.6346 - -10.6682 - 113.3389 - 153.5695 - en_US - False - - - 46.3723 - 49.0205 - 9.5308 - 17.1602 - de_AT - True - - - 38.3922 - 41.9503 - 44.7936 - 50.3928 - az_AZ - True - - - 20.9131 - 27.0409 - -78.0982 - -72.6956 - en_US - False - - - 25.7963 - 26.3469 - 50.3578 - 50.8037 - en_US - False - - - 20.7430 - 26.6278 - 88.0118 - 92.6737 - bn_BD - True - - - 13.0399 - 13.3365 - -59.6539 - -59.4170 - en_US - False - - - 51.2627 - 56.1674 - 23.1783 - 32.7628 - en_US - False - - - 49.4969 - 51.5055 - 2.5416 - 6.4038 - nl_BE - True - - - 15.8857 - 18.4966 - -89.2272 - -87.7762 - en_US - False - - - 6.2250 - 12.4092 - 0.7723 - 3.7976 - en_US - False - - - 32.2460 - 32.3961 - -64.8877 - -64.6512 - en_US - False - - - 26.7025 - 28.3359 - 88.7465 - 92.1252 - en_US - False - - - -22.8984 - -9.6806 - -69.6440 - -57.4538 - en_US - False - - - 12.0060 - 17.6350 - -68.4200 - -62.9830 - en_US - False - - - 42.5613 - 45.2766 - 15.7280 - 19.6237 - en_US - False - - - -26.9075 - -17.7781 - 19.9986 - 29.3753 - en_US - False - - - -54.4200 - -54.4000 - 3.3000 - 3.4000 - en_US - False - - - -33.7500 - 5.2718 - -73.9855 - -34.7931 - pt_BR - True - - - -7.4320 - -5.2000 - 71.2600 - 72.6000 - en_US - False - - - 4.0025 - 5.0450 - 114.0750 - 115.3630 - en_US - False - - - 41.2354 - 44.2349 - 22.3571 - 28.6117 - en_US - False - - - 9.4105 - 15.0828 - -5.5136 - 2.4085 - en_US - False - - - -4.4693 - -2.3097 - 29.0249 - 30.8496 - en_US - False - - - 10.4150 - 14.6900 - 102.3480 - 107.6270 - en_US - False - - - 1.6547 - 13.0833 - 8.4886 - 16.1919 - en_US - False - - - 41.6766 - 83.1139 - -141.0 - -52.6481 - en_US - False - - - 14.8030 - 17.2000 - -25.3600 - -22.6700 - en_US - False - - - 19.2630 - 19.7500 - -81.4100 - -79.7500 - en_US - False - - - 2.2200 - 11.0000 - 14.4150 - 27.4580 - en_US - False - - - 7.4411 - 23.4521 - 13.4735 - 24.0000 - en_US - False - - - -55.9830 - -17.4986 - -75.6440 - -66.9598 - es_CL - True - - - 18.1617 - 53.5609 - 73.4994 - 134.7728 - zh_CN - True - - - -10.5700 - -10.4100 - 105.6300 - 105.7300 - en_US - False - - - -12.2100 - -11.8400 - 96.8200 - 96.9300 - en_US - False - - - -4.2276 - 12.4373 - -79.0247 - -66.8472 - es_CO - True - - - -12.4670 - -11.3610 - 43.2250 - 44.5350 - en_US - False - - - -5.0278 - -1.4000 - 11.0048 - 18.6500 - en_US - False - - - -21.9440 - -10.0200 - -161.0930 - -157.3120 - en_US - False - - - 8.0320 - 11.2170 - -85.9500 - -82.5462 - en_US - False - - - 42.3903 - 46.5547 - 13.4930 - 19.4270 - hr_HR - True - - - 19.8555 - 23.2260 - -84.9570 - -74.1310 - en_US - False - - - 12.0320 - 12.3840 - -69.1580 - -68.7300 - en_US - False - - - 34.5720 - 35.1730 - 32.2720 - 34.5750 - en_US - False - - - 48.5510 - 51.0550 - 12.0900 - 18.8600 - cs_CZ - True - - - 4.3580 - 10.7350 - -8.6010 - -2.4930 - en_US - False - - - -13.4590 - 5.3860 - 12.0390 - 31.3050 - en_US - False - - - 54.5590 - 57.7510 - 8.0760 - 15.0419 - da_DK - True - - - 10.9140 - 12.7130 - 41.7710 - 43.4920 - en_US - False - - - 15.2060 - 15.6330 - -61.4840 - -61.2460 - en_US - False - - - 17.5410 - 19.9320 - -71.9450 - -68.3220 - en_US - False - - - -4.9591 - 1.3800 - -81.0780 - -75.2330 - en_US - False - - - 22.0000 - 31.6700 - 25.0000 - 35.0000 - en_US - False - - - 13.1630 - 14.4500 - -90.0950 - -87.6900 - en_US - False - - - -1.4670 - 3.7790 - 5.4170 - 11.3330 - en_US - False - - - 12.3600 - 18.0000 - 36.4380 - 43.1080 - en_US - False - - - 57.5090 - 59.9380 - 21.8330 - 28.2100 - en_US - False - - - -27.3175 - -25.7180 - 30.7900 - 32.1340 - en_US - False - - - 3.4221 - 14.8949 - 32.9985 - 48.0033 - en_US - False - - - -52.4060 - -51.2660 - -61.3600 - -57.7500 - en_US - False - - - 61.3910 - 62.3940 - -7.6880 - -6.2560 - en_US - False - - - -18.2870 - -16.0200 - 177.1290 - -178.4500 - en_US - False - - - 59.8080 - 70.0920 - 19.0830 - 31.5860 - fi_FI - True - - - 41.3423 - 51.0890 - -5.1422 - 9.5600 - fr_FR - True - - - 2.1120 - 5.7500 - -54.5390 - -51.6340 - en_US - False - - - -27.6530 - -7.9070 - -154.7670 - -134.9470 - en_US - False - - - -49.7200 - -37.8000 - 39.6000 - 77.6000 - en_US - False - - - -3.9788 - 2.3181 - 8.6979 - 14.5396 - en_US - False - - - 13.0620 - 13.7970 - -16.8240 - -13.7920 - en_US - False - - - 41.0550 - 43.5860 - 40.0100 - 46.6940 - ka_GE - True - - - 47.2701 - 55.0581 - 5.8663 - 15.0419 - de_DE - True - - - 4.7100 - 11.1740 - -3.2600 - 1.1990 - en_US - False - - - 36.1070 - 36.1550 - -5.3660 - -5.3380 - en_US - False - - - 34.8020 - 41.7480 - 19.3730 - 28.2470 - el_GR - True - - - 59.7740 - 83.6270 - -73.0420 - -12.2080 - en_US - False - - - 12.0000 - 12.3100 - -61.7800 - -61.3700 - en_US - False - - - 15.8320 - 16.5140 - -61.8090 - -61.0000 - en_US - False - - - 13.2340 - 13.6540 - 144.6180 - 144.9520 - en_US - False - - - 13.7350 - 17.8160 - -92.2460 - -88.2250 - en_US - False - - - 49.3970 - 49.5090 - -2.6750 - -2.5010 - en_US - False - - - 7.1930 - 12.5860 - -15.1320 - -7.6410 - en_US - False - - - 11.7780 - 12.6860 - -16.7170 - -13.6360 - en_US - False - - - 1.1850 - 8.5560 - -61.3960 - -56.4810 - en_US - False - - - 18.0220 - 19.9330 - -74.4800 - -71.6240 - en_US - False - - - -53.1000 - -52.9000 - 72.6000 - 73.5000 - en_US - False - - - 41.9000 - 41.9030 - 12.4450 - 12.4580 - en_US - False - - - 12.9800 - 16.5100 - -89.3500 - -83.1500 - en_US - False - - - 22.1530 - 22.5610 - 113.8370 - 114.4320 - en_US - False - - - 45.7370 - 48.5850 - 16.1130 - 22.8970 - hu_HU - True - - - 63.3930 - 66.5360 - -24.5460 - -13.4950 - en_US - False - - - 6.5546 - 35.6745 - 68.1114 - 97.3956 - hi_IN - True - - - -11.0086 - 6.0761 - 95.2930 - 141.0194 - id_ID - True - - - 24.8465 - 39.7810 - 44.0310 - 63.3330 - fa_IR - True - - - 29.0580 - 37.3850 - 38.7930 - 48.6340 - en_US - False - - - 51.4450 - 55.3820 - -10.4800 - -5.3400 - en_US - False - - - 54.0530 - 54.4170 - -4.7100 - -4.3100 - en_US - False - - - 29.4900 - 33.2800 - 34.2670 - 35.8960 - he_IL - True - - - 35.4920 - 47.0920 - 6.6270 - 18.7840 - it_IT - True - - - 17.7010 - 18.5240 - -78.3660 - -76.1870 - en_US - False - - - 24.3963 - 45.5515 - 122.9346 - 153.9869 - ja_JP - True - - - 49.1620 - 49.2620 - -2.2540 - -2.0110 - en_US - False - - - 29.1860 - 33.3750 - 34.9590 - 39.1960 - en_US - False - - - 40.5680 - 55.4410 - 46.4910 - 87.3150 - en_US - False - - - -4.6796 - 4.6200 - 33.9090 - 41.8990 - en_US - False - - - 59.9060 - 60.4880 - 19.5170 - 21.0110 - en_US - False - - - 39.1800 - 43.2650 - 69.2710 - 80.2830 - en_US - False - - - 13.9090 - 22.5000 - 100.9980 - 107.6350 - en_US - False - - - 55.6740 - 58.0850 - 20.9680 - 28.2410 - en_US - False - - - 33.0550 - 34.6920 - 35.1260 - 36.6110 - en_US - False - - - -30.6750 - -28.5700 - 27.0280 - 29.4550 - en_US - False - - - 4.3530 - 8.5510 - -11.4950 - -7.3690 - en_US - False - - - 19.5000 - 33.2360 - 9.3910 - 25.1500 - en_US - False - - - 47.0480 - 47.2700 - 9.4710 - 9.6350 - en_US - False - - - 53.8960 - 56.4500 - 21.0550 - 26.8350 - en_US - False - - - 49.4470 - 50.1820 - 5.7350 - 6.5280 - en_US - False - - - 22.1110 - 22.2170 - 113.5280 - 113.6050 - en_US - False - - - 40.8530 - 42.3730 - 20.4520 - 23.0340 - en_US - False - - - -25.6070 - -11.9510 - 43.2200 - 50.4760 - en_US - False - - - -17.1290 - -9.3670 - 32.6700 - 35.9180 - en_US - False - - - 0.8530 - 7.3630 - 99.6400 - 119.2670 - en_US - False - - - -0.6740 - 7.1030 - 72.6930 - 73.7530 - en_US - False - - - 10.1410 - 25.0000 - -12.2400 - 4.2440 - en_US - False - - - 35.7990 - 36.0820 - 14.1830 - 14.5670 - en_US - False - - - 4.5830 - 14.6040 - 160.8190 - 172.0290 - en_US - False - - - 14.3920 - 14.8780 - -61.2290 - -60.8150 - en_US - False - - - 14.7210 - 27.2980 - -17.0680 - -4.8330 - en_US - False - - - -20.5250 - -19.9820 - 57.3070 - 63.5000 - en_US - False - - - -13.0000 - -12.6360 - 45.0180 - 45.2990 - en_US - False - - - 14.5388 - 32.7187 - -118.3640 - -86.7100 - es_MX - True - - - 1.0220 - 9.6130 - 138.0610 - 163.0410 - en_US - False - - - 45.4670 - 48.4910 - 26.6180 - 30.1630 - en_US - False - - - 43.7240 - 43.7510 - 7.4090 - 7.4390 - en_US - False - - - 41.5810 - 52.1480 - 87.7510 - 119.9310 - en_US - False - - - 41.8480 - 43.5580 - 18.4330 - 20.3580 - en_US - False - - - 16.6740 - 16.8240 - -62.2420 - -62.1440 - en_US - False - - - 21.4200 - 35.8970 - -17.0200 - -1.1240 - en_US - False - - - -26.8600 - -10.4710 - 30.2150 - 40.8490 - en_US - False - - - 9.7840 - 28.5470 - 92.1710 - 101.1700 - en_US - False - - - -28.9690 - -17.4710 - 11.7340 - 25.2610 - en_US - False - - - -0.5540 - -0.5020 - 166.9090 - 166.9580 - en_US - False - - - 26.3470 - 30.4460 - 80.0580 - 88.2010 - ne_NP - True - - - 50.7500 - 53.5310 - 3.3580 - 7.2270 - nl_NL - True - - - -22.6680 - -19.6260 - 163.5640 - 167.2770 - en_US - False - - - -47.2860 - -34.3920 - 166.5090 - 178.5170 - en_NZ - True - - - 10.7070 - 15.0330 - -87.6680 - -83.1470 - en_US - False - - - 11.6930 - 23.5170 - 0.1680 - 15.9960 - en_US - False - - - 4.2720 - 13.8920 - 2.6760 - 14.6770 - en_US - False - - - -19.0830 - -18.9510 - -169.9300 - -169.7790 - en_US - False - - - -29.1000 - -28.9830 - 167.9120 - 167.9970 - en_US - False - - - 14.1100 - 20.6160 - 144.8860 - 145.8500 - en_US - False - - - 57.9800 - 71.1850 - 4.9920 - 31.2930 - no_NO - True - - - 16.6510 - 26.3950 - 52.0000 - 60.3030 - en_US - False - - - 23.6345 - 37.0841 - 60.8720 - 77.8375 - en_US - False - - - 2.7530 - 8.0950 - 131.1140 - 134.6450 - en_US - False - - - 31.2200 - 32.5520 - 34.2080 - 35.5730 - en_US - False - - - 7.2020 - 9.6110 - -83.0510 - -77.1740 - en_US - False - - - -11.6340 - -1.3460 - 140.8420 - 156.0190 - en_US - False - - - -27.6060 - -19.2870 - -62.6440 - -54.2580 - en_US - False - - - -18.3490 - -0.0380 - -81.3420 - -68.6520 - en_US - False - - - 4.2158 - 21.3210 - 116.9540 - 126.6040 - en_US - False - - - -25.0800 - -24.3300 - -130.1100 - -128.3300 - en_US - False - - - 49.0020 - 54.8350 - 14.1220 - 24.1450 - pl_PL - True - - - 36.8380 - 42.1540 - -9.5000 - -6.1890 - pt_PT - True - - - 17.9220 - 18.5150 - -67.2710 - -65.5890 - en_US - False - - - 24.3960 - 26.1600 - 50.7500 - 51.6130 - en_US - False - - - -21.3890 - -20.8710 - 55.2160 - 55.8360 - en_US - False - - - 43.6180 - 48.2650 - 20.2610 - 29.6260 - ro_RO - True - - - 41.1850 - 81.8570 - 19.6380 - 180.0000 - ru_RU - True - - - -2.8390 - -1.0470 - 28.8610 - 30.8990 - en_US - False - - - 17.8960 - 17.9330 - -62.8700 - -62.8050 - en_US - False - - - -16.0200 - -5.6500 - -14.4200 - -5.6400 - en_US - False - - - 17.2200 - 17.4200 - -62.8600 - -62.5500 - en_US - False - - - 13.7120 - 14.1000 - -61.0700 - -60.8900 - en_US - False - - - 18.0400 - 18.1300 - -63.1600 - -62.9900 - en_US - False - - - 46.7800 - 47.1000 - -56.4000 - -56.2000 - en_US - False - - - 12.5830 - 13.3830 - -61.4800 - -61.1300 - en_US - False - - - -14.0520 - -13.2380 - -172.7840 - -171.4440 - en_US - False - - - 43.8930 - 43.9860 - 12.4030 - 12.5160 - en_US - False - - - -0.0130 - 1.7010 - 6.4700 - 7.4650 - en_US - False - - - 16.3750 - 32.1540 - 34.4950 - 55.6660 - en_US - False - - - 12.3070 - 16.6920 - -17.5360 - -11.3670 - en_US - False - - - 42.2320 - 46.1900 - 18.8140 - 23.0060 - en_US - False - - - -4.6200 - -4.2830 - 55.2250 - 56.2970 - en_US - False - - - 6.9190 - 10.0470 - -13.3070 - -10.2840 - en_US - False - - - 1.1300 - 1.4700 - 103.6200 - 104.0100 - en_US - False - - - 18.0200 - 18.0700 - -63.1300 - -62.9900 - en_US - False - - - 47.7310 - 49.6130 - 16.8440 - 22.5650 - sk_SK - True - - - 45.4210 - 46.8760 - 13.3750 - 16.6090 - sl_SI - True - - - -11.8600 - -6.5990 - 155.3420 - 168.0450 - en_US - False - - - -1.6830 - 11.9780 - 40.9890 - 51.6170 - en_US - False - - - -34.8330 - -22.1260 - 16.4510 - 32.8910 - en_US - False - - - -54.7500 - -53.7500 - -38.0000 - -35.5000 - en_US - False - - - 3.4880 - 12.2360 - 24.1330 - 35.8920 - en_US - False - - - 27.6370 - 43.7920 - -18.1600 - 4.3270 - es_ES - True - - - 5.9167 - 9.8345 - 79.6952 - 81.8813 - en_US - False - - - 8.6840 - 22.2230 - 21.8140 - 38.6140 - en_US - False - - - 1.8310 - 6.0110 - -58.0700 - -53.9860 - en_US - False - - - 76.0000 - 80.0000 - 10.0000 - 35.0000 - en_US - False - - - 55.3370 - 69.0600 - 11.0270 - 24.1660 - sv_SE - True - - - 45.8170 - 47.8080 - 5.9560 - 10.4920 - de_CH - True - - - 32.3120 - 37.3190 - 35.7000 - 42.0000 - en_US - False - - - 21.9020 - 25.2930 - 119.5340 - 122.0060 - zh_TW - True - - - 36.6710 - 41.0450 - 67.3420 - 75.1530 - en_US - False - - - -11.7450 - -0.9850 - 29.3210 - 40.4490 - en_US - False - - - 5.6120 - 20.4630 - 97.3430 - 105.6360 - th_TH - True - - - -9.4630 - -8.1260 - 124.0400 - 127.3430 - en_US - False - - - 6.1110 - 11.1390 - -0.1470 - 1.7790 - en_US - False - - - -9.5000 - -8.5000 - -172.2330 - -171.8330 - en_US - False - - - -21.4630 - -15.5590 - -175.6340 - -173.7630 - en_US - False - - - 10.0420 - 11.3420 - -61.9290 - -60.8950 - en_US - False - - - 30.2300 - 37.5340 - 7.5240 - 11.5830 - en_US - False - - - 35.8080 - 42.1070 - 25.6680 - 44.7930 - en_US - False - - - 35.1290 - 42.7970 - 52.4440 - 66.6870 - en_US - False - - - 21.4990 - 21.9570 - -72.4790 - -71.6270 - en_US - False - - - -8.5420 - -5.6420 - 179.0830 - 179.3640 - en_US - False - - - -1.4770 - 4.2340 - 29.5730 - 35.0000 - en_US - False - - - 44.3860 - 52.3790 - 22.1370 - 40.2270 - uk_UA - True - - - 22.6330 - 26.0760 - 51.5830 - 56.3960 - en_US - False - - - 24.3963 - 49.3845 - -125.0 - -66.9346 - en_US - True - - - -0.3830 - 28.2190 - -177.3830 - 166.6520 - en_US - False - - - -34.9780 - -30.0850 - -58.4420 - -53.2090 - en_US - False - - - 37.1820 - 45.5900 - 55.9980 - 73.1330 - en_US - False - - - -16.5970 - -13.0720 - 166.5250 - 170.2310 - en_US - False - - - 0.6470 - 12.2010 - -73.3520 - -59.8050 - en_US - False - - - 8.1950 - 23.3930 - 102.1440 - 109.4640 - vi_VN - True - - - 18.3830 - 18.7500 - -64.7000 - -64.2680 - en_US - False - - - 17.6230 - 18.4640 - -65.0850 - -64.5650 - en_US - False - - - -14.3140 - -13.0820 - -178.1870 - -176.1590 - en_US - False - - - 20.7640 - 27.6690 - -17.1050 - -8.6700 - en_US - False - - - 12.1110 - 19.0000 - 42.5390 - 54.5320 - en_US - False - - - -18.0760 - -8.2380 - 21.9990 - 33.7010 - en_US - False - - - -22.4210 - -15.6090 - 25.2370 - 33.0680 - en_US - False - - - -4.7218 - 4.7231 - -174.543 - -157.312 - en_US - False - - - 41.856 - 43.269 - 19.983 - 21.801 - en_US - False - - - 28.524 - 30.096 - 46.553 - 48.430 - en_US - False - - - 33.115 - 38.612 - 125.887 - 131.872 - ko_KR - True - - - 49.959 - 60.860 - -8.649 - 1.768 - en_GB - True - + + 29.3772 + 38.4911 + 60.5284 + 74.8899 + en_US + False + + + 39.6449 + 42.6611 + 19.1246 + 21.0574 + en_US + False + + + 18.9681 + 37.0937 + -8.6676 + 11.9973 + en_US + False + + + -14.3757 + -11.0496 + -170.8496 + -168.1433 + en_US + False + + + 42.4284 + 42.6550 + 1.4130 + 1.7867 + en_US + False + + + -18.0382 + -4.3881 + 11.6792 + 24.0821 + en_US + False + + + 18.1600 + 18.2766 + -63.1625 + -62.9625 + en_US + False + + + -90.0000 + -60.0000 + -180.0000 + 180.0000 + en_US + False + + + 16.9970 + 17.7290 + -61.9066 + -61.6730 + en_US + False + + + -55.0613 + -21.7811 + -73.5604 + -53.6374 + es_AR + True + + + 38.8403 + 41.3018 + 43.4471 + 46.6342 + hy_AM + True + + + 12.4226 + 12.6300 + -70.0648 + -69.8762 + en_US + False + + + -43.6346 + -10.6682 + 113.3389 + 153.5695 + en_US + False + + + 46.3723 + 49.0205 + 9.5308 + 17.1602 + de_AT + True + + + 38.3922 + 41.9503 + 44.7936 + 50.3928 + az_AZ + True + + + 20.9131 + 27.0409 + -78.0982 + -72.6956 + en_US + False + + + 25.7963 + 26.3469 + 50.3578 + 50.8037 + en_US + False + + + 20.7430 + 26.6278 + 88.0118 + 92.6737 + bn_BD + True + + + 13.0399 + 13.3365 + -59.6539 + -59.4170 + en_US + False + + + 51.2627 + 56.1674 + 23.1783 + 32.7628 + en_US + False + + + 49.4969 + 51.5055 + 2.5416 + 6.4038 + nl_BE + True + + + 15.8857 + 18.4966 + -89.2272 + -87.7762 + en_US + False + + + 6.2250 + 12.4092 + 0.7723 + 3.7976 + en_US + False + + + 32.2460 + 32.3961 + -64.8877 + -64.6512 + en_US + False + + + 26.7025 + 28.3359 + 88.7465 + 92.1252 + en_US + False + + + -22.8984 + -9.6806 + -69.6440 + -57.4538 + en_US + False + + + 12.0060 + 17.6350 + -68.4200 + -62.9830 + en_US + False + + + 42.5613 + 45.2766 + 15.7280 + 19.6237 + en_US + False + + + -26.9075 + -17.7781 + 19.9986 + 29.3753 + en_US + False + + + -54.4200 + -54.4000 + 3.3000 + 3.4000 + en_US + False + + + -33.7500 + 5.2718 + -73.9855 + -34.7931 + pt_BR + True + + + -7.4320 + -5.2000 + 71.2600 + 72.6000 + en_US + False + + + 4.0025 + 5.0450 + 114.0750 + 115.3630 + en_US + False + + + 41.2354 + 44.2349 + 22.3571 + 28.6117 + en_US + False + + + 9.4105 + 15.0828 + -5.5136 + 2.4085 + en_US + False + + + -4.4693 + -2.3097 + 29.0249 + 30.8496 + en_US + False + + + 10.4150 + 14.6900 + 102.3480 + 107.6270 + en_US + False + + + 1.6547 + 13.0833 + 8.4886 + 16.1919 + en_US + False + + + 41.6766 + 83.1139 + -141.0 + -52.6481 + en_US + False + + + 14.8030 + 17.2000 + -25.3600 + -22.6700 + en_US + False + + + 19.2630 + 19.7500 + -81.4100 + -79.7500 + en_US + False + + + 2.2200 + 11.0000 + 14.4150 + 27.4580 + en_US + False + + + 7.4411 + 23.4521 + 13.4735 + 24.0000 + en_US + False + + + -55.9830 + -17.4986 + -75.6440 + -66.9598 + es_CL + True + + + 18.1617 + 53.5609 + 73.4994 + 134.7728 + zh_CN + True + + + -10.5700 + -10.4100 + 105.6300 + 105.7300 + en_US + False + + + -12.2100 + -11.8400 + 96.8200 + 96.9300 + en_US + False + + + -4.2276 + 12.4373 + -79.0247 + -66.8472 + es_CO + True + + + -12.4670 + -11.3610 + 43.2250 + 44.5350 + en_US + False + + + -5.0278 + -1.4000 + 11.0048 + 18.6500 + en_US + False + + + -21.9440 + -10.0200 + -161.0930 + -157.3120 + en_US + False + + + 8.0320 + 11.2170 + -85.9500 + -82.5462 + en_US + False + + + 42.3903 + 46.5547 + 13.4930 + 19.4270 + hr_HR + True + + + 19.8555 + 23.2260 + -84.9570 + -74.1310 + en_US + False + + + 12.0320 + 12.3840 + -69.1580 + -68.7300 + en_US + False + + + 34.5720 + 35.1730 + 32.2720 + 34.5750 + en_US + False + + + 48.5510 + 51.0550 + 12.0900 + 18.8600 + cs_CZ + True + + + 4.3580 + 10.7350 + -8.6010 + -2.4930 + en_US + False + + + -13.4590 + 5.3860 + 12.0390 + 31.3050 + en_US + False + + + 54.5590 + 57.7510 + 8.0760 + 15.0419 + da_DK + True + + + 10.9140 + 12.7130 + 41.7710 + 43.4920 + en_US + False + + + 15.2060 + 15.6330 + -61.4840 + -61.2460 + en_US + False + + + 17.5410 + 19.9320 + -71.9450 + -68.3220 + en_US + False + + + -4.9591 + 1.3800 + -81.0780 + -75.2330 + en_US + False + + + 22.0000 + 31.6700 + 25.0000 + 35.0000 + en_US + False + + + 13.1630 + 14.4500 + -90.0950 + -87.6900 + en_US + False + + + -1.4670 + 3.7790 + 5.4170 + 11.3330 + en_US + False + + + 12.3600 + 18.0000 + 36.4380 + 43.1080 + en_US + False + + + 57.5090 + 59.9380 + 21.8330 + 28.2100 + en_US + False + + + -27.3175 + -25.7180 + 30.7900 + 32.1340 + en_US + False + + + 3.4221 + 14.8949 + 32.9985 + 48.0033 + en_US + False + + + -52.4060 + -51.2660 + -61.3600 + -57.7500 + en_US + False + + + 61.3910 + 62.3940 + -7.6880 + -6.2560 + en_US + False + + + -18.2870 + -16.0200 + 177.1290 + -178.4500 + en_US + False + + + 59.8080 + 70.0920 + 19.0830 + 31.5860 + fi_FI + True + + + 41.3423 + 51.0890 + -5.1422 + 9.5600 + fr_FR + True + + + 2.1120 + 5.7500 + -54.5390 + -51.6340 + en_US + False + + + -27.6530 + -7.9070 + -154.7670 + -134.9470 + en_US + False + + + -49.7200 + -37.8000 + 39.6000 + 77.6000 + en_US + False + + + -3.9788 + 2.3181 + 8.6979 + 14.5396 + en_US + False + + + 13.0620 + 13.7970 + -16.8240 + -13.7920 + en_US + False + + + 41.0550 + 43.5860 + 40.0100 + 46.6940 + ka_GE + True + + + 47.2701 + 55.0581 + 5.8663 + 15.0419 + de_DE + True + + + 4.7100 + 11.1740 + -3.2600 + 1.1990 + en_US + False + + + 36.1070 + 36.1550 + -5.3660 + -5.3380 + en_US + False + + + 34.8020 + 41.7480 + 19.3730 + 28.2470 + el_GR + True + + + 59.7740 + 83.6270 + -73.0420 + -12.2080 + en_US + False + + + 12.0000 + 12.3100 + -61.7800 + -61.3700 + en_US + False + + + 15.8320 + 16.5140 + -61.8090 + -61.0000 + en_US + False + + + 13.2340 + 13.6540 + 144.6180 + 144.9520 + en_US + False + + + 13.7350 + 17.8160 + -92.2460 + -88.2250 + en_US + False + + + 49.3970 + 49.5090 + -2.6750 + -2.5010 + en_US + False + + + 7.1930 + 12.5860 + -15.1320 + -7.6410 + en_US + False + + + 11.7780 + 12.6860 + -16.7170 + -13.6360 + en_US + False + + + 1.1850 + 8.5560 + -61.3960 + -56.4810 + en_US + False + + + 18.0220 + 19.9330 + -74.4800 + -71.6240 + en_US + False + + + -53.1000 + -52.9000 + 72.6000 + 73.5000 + en_US + False + + + 41.9000 + 41.9030 + 12.4450 + 12.4580 + en_US + False + + + 12.9800 + 16.5100 + -89.3500 + -83.1500 + en_US + False + + + 22.1530 + 22.5610 + 113.8370 + 114.4320 + en_US + False + + + 45.7370 + 48.5850 + 16.1130 + 22.8970 + hu_HU + True + + + 63.3930 + 66.5360 + -24.5460 + -13.4950 + en_US + False + + + 6.5546 + 35.6745 + 68.1114 + 97.3956 + hi_IN + True + + + -11.0086 + 6.0761 + 95.2930 + 141.0194 + id_ID + True + + + 24.8465 + 39.7810 + 44.0310 + 63.3330 + fa_IR + True + + + 29.0580 + 37.3850 + 38.7930 + 48.6340 + en_US + False + + + 51.4450 + 55.3820 + -10.4800 + -5.3400 + en_US + False + + + 54.0530 + 54.4170 + -4.7100 + -4.3100 + en_US + False + + + 29.4900 + 33.2800 + 34.2670 + 35.8960 + he_IL + True + + + 35.4920 + 47.0920 + 6.6270 + 18.7840 + it_IT + True + + + 17.7010 + 18.5240 + -78.3660 + -76.1870 + en_US + False + + + 24.3963 + 45.5515 + 122.9346 + 153.9869 + ja_JP + True + + + 49.1620 + 49.2620 + -2.2540 + -2.0110 + en_US + False + + + 29.1860 + 33.3750 + 34.9590 + 39.1960 + en_US + False + + + 40.5680 + 55.4410 + 46.4910 + 87.3150 + en_US + False + + + -4.6796 + 4.6200 + 33.9090 + 41.8990 + en_US + False + + + 59.9060 + 60.4880 + 19.5170 + 21.0110 + en_US + False + + + 39.1800 + 43.2650 + 69.2710 + 80.2830 + en_US + False + + + 13.9090 + 22.5000 + 100.9980 + 107.6350 + en_US + False + + + 55.6740 + 58.0850 + 20.9680 + 28.2410 + en_US + False + + + 33.0550 + 34.6920 + 35.1260 + 36.6110 + en_US + False + + + -30.6750 + -28.5700 + 27.0280 + 29.4550 + en_US + False + + + 4.3530 + 8.5510 + -11.4950 + -7.3690 + en_US + False + + + 19.5000 + 33.2360 + 9.3910 + 25.1500 + en_US + False + + + 47.0480 + 47.2700 + 9.4710 + 9.6350 + en_US + False + + + 53.8960 + 56.4500 + 21.0550 + 26.8350 + en_US + False + + + 49.4470 + 50.1820 + 5.7350 + 6.5280 + en_US + False + + + 22.1110 + 22.2170 + 113.5280 + 113.6050 + en_US + False + + + 40.8530 + 42.3730 + 20.4520 + 23.0340 + en_US + False + + + -25.6070 + -11.9510 + 43.2200 + 50.4760 + en_US + False + + + -17.1290 + -9.3670 + 32.6700 + 35.9180 + en_US + False + + + 0.8530 + 7.3630 + 99.6400 + 119.2670 + en_US + False + + + -0.6740 + 7.1030 + 72.6930 + 73.7530 + en_US + False + + + 10.1410 + 25.0000 + -12.2400 + 4.2440 + en_US + False + + + 35.7990 + 36.0820 + 14.1830 + 14.5670 + en_US + False + + + 4.5830 + 14.6040 + 160.8190 + 172.0290 + en_US + False + + + 14.3920 + 14.8780 + -61.2290 + -60.8150 + en_US + False + + + 14.7210 + 27.2980 + -17.0680 + -4.8330 + en_US + False + + + -20.5250 + -19.9820 + 57.3070 + 63.5000 + en_US + False + + + -13.0000 + -12.6360 + 45.0180 + 45.2990 + en_US + False + + + 14.5388 + 32.7187 + -118.3640 + -86.7100 + es_MX + True + + + 1.0220 + 9.6130 + 138.0610 + 163.0410 + en_US + False + + + 45.4670 + 48.4910 + 26.6180 + 30.1630 + en_US + False + + + 43.7240 + 43.7510 + 7.4090 + 7.4390 + en_US + False + + + 41.5810 + 52.1480 + 87.7510 + 119.9310 + en_US + False + + + 41.8480 + 43.5580 + 18.4330 + 20.3580 + en_US + False + + + 16.6740 + 16.8240 + -62.2420 + -62.1440 + en_US + False + + + 21.4200 + 35.8970 + -17.0200 + -1.1240 + en_US + False + + + -26.8600 + -10.4710 + 30.2150 + 40.8490 + en_US + False + + + 9.7840 + 28.5470 + 92.1710 + 101.1700 + en_US + False + + + -28.9690 + -17.4710 + 11.7340 + 25.2610 + en_US + False + + + -0.5540 + -0.5020 + 166.9090 + 166.9580 + en_US + False + + + 26.3470 + 30.4460 + 80.0580 + 88.2010 + ne_NP + True + + + 50.7500 + 53.5310 + 3.3580 + 7.2270 + nl_NL + True + + + -22.6680 + -19.6260 + 163.5640 + 167.2770 + en_US + False + + + -47.2860 + -34.3920 + 166.5090 + 178.5170 + en_NZ + True + + + 10.7070 + 15.0330 + -87.6680 + -83.1470 + en_US + False + + + 11.6930 + 23.5170 + 0.1680 + 15.9960 + en_US + False + + + 4.2720 + 13.8920 + 2.6760 + 14.6770 + en_US + False + + + -19.0830 + -18.9510 + -169.9300 + -169.7790 + en_US + False + + + -29.1000 + -28.9830 + 167.9120 + 167.9970 + en_US + False + + + 14.1100 + 20.6160 + 144.8860 + 145.8500 + en_US + False + + + 57.9800 + 71.1850 + 4.9920 + 31.2930 + no_NO + True + + + 16.6510 + 26.3950 + 52.0000 + 60.3030 + en_US + False + + + 23.6345 + 37.0841 + 60.8720 + 77.8375 + en_US + False + + + 2.7530 + 8.0950 + 131.1140 + 134.6450 + en_US + False + + + 31.2200 + 32.5520 + 34.2080 + 35.5730 + en_US + False + + + 7.2020 + 9.6110 + -83.0510 + -77.1740 + en_US + False + + + -11.6340 + -1.3460 + 140.8420 + 156.0190 + en_US + False + + + -27.6060 + -19.2870 + -62.6440 + -54.2580 + en_US + False + + + -18.3490 + -0.0380 + -81.3420 + -68.6520 + en_US + False + + + 4.2158 + 21.3210 + 116.9540 + 126.6040 + fil_PH + True + + + -25.0800 + -24.3300 + -130.1100 + -128.3300 + en_US + False + + + 49.0020 + 54.8350 + 14.1220 + 24.1450 + pl_PL + True + + + 36.8380 + 42.1540 + -9.5000 + -6.1890 + pt_PT + True + + + 17.9220 + 18.5150 + -67.2710 + -65.5890 + en_US + False + + + 24.3960 + 26.1600 + 50.7500 + 51.6130 + en_US + False + + + -21.3890 + -20.8710 + 55.2160 + 55.8360 + en_US + False + + + 43.6180 + 48.2650 + 20.2610 + 29.6260 + ro_RO + True + + + 41.1850 + 81.8570 + 19.6380 + 180.0000 + ru_RU + True + + + -2.8390 + -1.0470 + 28.8610 + 30.8990 + en_US + False + + + 17.8960 + 17.9330 + -62.8700 + -62.8050 + en_US + False + + + -16.0200 + -5.6500 + -14.4200 + -5.6400 + en_US + False + + + 17.2200 + 17.4200 + -62.8600 + -62.5500 + en_US + False + + + 13.7120 + 14.1000 + -61.0700 + -60.8900 + en_US + False + + + 18.0400 + 18.1300 + -63.1600 + -62.9900 + en_US + False + + + 46.7800 + 47.1000 + -56.4000 + -56.2000 + en_US + False + + + 12.5830 + 13.3830 + -61.4800 + -61.1300 + en_US + False + + + -14.0520 + -13.2380 + -172.7840 + -171.4440 + en_US + False + + + 43.8930 + 43.9860 + 12.4030 + 12.5160 + en_US + False + + + -0.0130 + 1.7010 + 6.4700 + 7.4650 + en_US + False + + + 16.3750 + 32.1540 + 34.4950 + 55.6660 + en_US + False + + + 12.3070 + 16.6920 + -17.5360 + -11.3670 + en_US + False + + + 42.2320 + 46.1900 + 18.8140 + 23.0060 + en_US + False + + + -4.6200 + -4.2830 + 55.2250 + 56.2970 + en_US + False + + + 6.9190 + 10.0470 + -13.3070 + -10.2840 + en_US + False + + + 1.1300 + 1.4700 + 103.6200 + 104.0100 + en_US + False + + + 18.0200 + 18.0700 + -63.1300 + -62.9900 + en_US + False + + + 47.7310 + 49.6130 + 16.8440 + 22.5650 + sk_SK + True + + + 45.4210 + 46.8760 + 13.3750 + 16.6090 + sl_SI + True + + + -11.8600 + -6.5990 + 155.3420 + 168.0450 + en_US + False + + + -1.6830 + 11.9780 + 40.9890 + 51.6170 + en_US + False + + + -34.8330 + -22.1260 + 16.4510 + 32.8910 + en_US + False + + + -54.7500 + -53.7500 + -38.0000 + -35.5000 + en_US + False + + + 3.4880 + 12.2360 + 24.1330 + 35.8920 + en_US + False + + + 27.6370 + 43.7920 + -18.1600 + 4.3270 + es_ES + True + + + 5.9167 + 9.8345 + 79.6952 + 81.8813 + si_LK + True + + + 8.6840 + 22.2230 + 21.8140 + 38.6140 + en_US + False + + + 1.8310 + 6.0110 + -58.0700 + -53.9860 + en_US + False + + + 76.0000 + 80.0000 + 10.0000 + 35.0000 + en_US + False + + + 55.3370 + 69.0600 + 11.0270 + 24.1660 + sv_SE + True + + + 45.8170 + 47.8080 + 5.9560 + 10.4920 + de_CH + True + + + 32.3120 + 37.3190 + 35.7000 + 42.0000 + en_US + False + + + 21.9020 + 25.2930 + 119.5340 + 122.0060 + zh_TW + True + + + 36.6710 + 41.0450 + 67.3420 + 75.1530 + en_US + False + + + -11.7450 + -0.9850 + 29.3210 + 40.4490 + en_US + False + + + 5.6120 + 20.4630 + 97.3430 + 105.6360 + th_TH + True + + + -9.4630 + -8.1260 + 124.0400 + 127.3430 + en_US + False + + + 6.1110 + 11.1390 + -0.1470 + 1.7790 + fr_TG + True + + + -9.5000 + -8.5000 + -172.2330 + -171.8330 + en_US + False + + + -21.4630 + -15.5590 + -175.6340 + -173.7630 + en_US + False + + + 10.0420 + 11.3420 + -61.9290 + -60.8950 + en_US + False + + + 30.2300 + 37.5340 + 7.5240 + 11.5830 + en_US + False + + + 35.8080 + 42.1070 + 25.6680 + 44.7930 + en_US + False + + + 35.1290 + 42.7970 + 52.4440 + 66.6870 + en_US + False + + + 21.4990 + 21.9570 + -72.4790 + -71.6270 + en_US + False + + + -8.5420 + -5.6420 + 179.0830 + 179.3640 + en_US + False + + + -1.4770 + 4.2340 + 29.5730 + 35.0000 + en_US + False + + + 44.3860 + 52.3790 + 22.1370 + 40.2270 + uk_UA + True + + + 22.6330 + 26.0760 + 51.5830 + 56.3960 + en_US + False + + + 24.3963 + 49.3845 + -125.0 + -66.9346 + en_US + True + + + -0.3830 + 28.2190 + -177.3830 + 166.6520 + en_US + False + + + -34.9780 + -30.0850 + -58.4420 + -53.2090 + en_US + False + + + 37.1820 + 45.5900 + 55.9980 + 73.1330 + en_US + False + + + -16.5970 + -13.0720 + 166.5250 + 170.2310 + en_US + False + + + 0.6470 + 12.2010 + -73.3520 + -59.8050 + en_US + False + + + 8.1950 + 23.3930 + 102.1440 + 109.4640 + vi_VN + True + + + 18.3830 + 18.7500 + -64.7000 + -64.2680 + en_US + False + + + 17.6230 + 18.4640 + -65.0850 + -64.5650 + en_US + False + + + -14.3140 + -13.0820 + -178.1870 + -176.1590 + en_US + False + + + 20.7640 + 27.6690 + -17.1050 + -8.6700 + en_US + False + + + 12.1110 + 19.0000 + 42.5390 + 54.5320 + en_US + False + + + -18.0760 + -8.2380 + 21.9990 + 33.7010 + en_US + False + + + -22.4210 + -15.6090 + 25.2370 + 33.0680 + en_US + False + + + -4.7218 + 4.7231 + -174.543 + -157.312 + en_US + False + + + 41.856 + 43.269 + 19.983 + 21.801 + en_US + False + + + 28.524 + 30.096 + 46.553 + 48.430 + en_US + False + + + 33.115 + 38.612 + 125.887 + 131.872 + ko_KR + True + + + 49.959 + 60.860 + -8.649 + 1.768 + en_GB + True + 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 caafee49..5b67f775 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/data/demo_currencies.xml b/spp_mis_demo_v2/data/demo_currencies.xml index 0fede3aa..3428ce3c 100644 --- a/spp_mis_demo_v2/data/demo_currencies.xml +++ b/spp_mis_demo_v2/data/demo_currencies.xml @@ -1,4 +1,4 @@ - + - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/spp_mis_demo_v2/docs/USE_CASES.md b/spp_mis_demo_v2/docs/USE_CASES.md index df19daf6..d724a807 100644 --- a/spp_mis_demo_v2/docs/USE_CASES.md +++ b/spp_mis_demo_v2/docs/USE_CASES.md @@ -1,29 +1,70 @@ # OpenSPP MIS Demo V2 - Use Cases Guide -This document describes the different demo use cases available in the `spp_mis_demo_v2` -module and how to use them effectively for sales demos, training, and testing. +This document describes the different demo use cases available in the `spp_mis_demo_v2` 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: +The MIS Demo V2 module provides realistic demo data that showcases OpenSPP's capabilities for social protection program +management. It follows the **Blueprint + Seeded Faker** 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 `Faker(locale, seed=42)` — 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. --- @@ -399,8 +440,8 @@ Volume" architecture: ## Formula Library Demo -The MIS Demo includes comprehensive formula library data demonstrating versioned, -auditable business rules for eligibility, entitlements, and scoring. +The MIS Demo includes comprehensive formula library data demonstrating versioned, auditable business rules for +eligibility, entitlements, and scoring. ### Formula Categories @@ -431,10 +472,9 @@ auditable business rules for eligibility, entitlements, and scoring. household.pmt_score < 45.0 && household.member_count > 0 ``` -**Test Cases:** | Persona | PMT Score | Expected | Result | -|---------|-----------|----------|--------| | Maria Santos | 38 | `true` | ✓ Eligible | -| Rosa Garcia | 42 | `true` | ✓ Eligible | | Carlos Morales | 48 | `false` | ✗ Not -eligible | | Ibrahim Hassan | 35 | `true` | ✓ Eligible | +**Test Cases:** | Persona | PMT Score | Expected | Result | |---------|-----------|----------|--------| | Maria Santos | +38 | `true` | ✓ Eligible | | Rosa Garcia | 42 | `true` | ✓ Eligible | | Carlos Morales | 48 | `false` | ✗ Not eligible | +| Ibrahim Hassan | 35 | `true` | ✓ Eligible | --- @@ -453,9 +493,8 @@ eligible | | Ibrahim Hassan | 35 | `true` | ✓ Eligible | individual.age >= 65 && !individual.receives_other_pension ``` -**Test Cases:** | Persona | Age | Other Pension | Expected | -|---------|-----|---------------|----------| | Rosa Garcia | 72 | No | `true` ✓ | | -Maria Santos | 45 | No | `false` ✗ | | Pedro Reyes | 55 | No | `false` ✗ | +**Test Cases:** | Persona | Age | Other Pension | Expected | |---------|-----|---------------|----------| | Rosa Garcia +| 72 | No | `true` ✓ | | Maria Santos | 45 | No | `false` ✗ | | Pedro Reyes | 55 | No | `false` ✗ | --- @@ -474,9 +513,8 @@ Maria Santos | 45 | No | `false` ✗ | | Pedro Reyes | 55 | No | `false` ✗ | 150.0 + (household.child_count * 50.0) * (household.is_rural ? 1.2 : 1.0) ``` -**Test Cases:** | Persona | Children | Rural | Expected Amount | -|---------|----------|-------|-----------------| | Maria Santos | 2 | Yes | $270 | | -Carlos Morales | 3 | No | $300 | | Ana Mendoza | 1 | Yes | $210 | +**Test Cases:** | Persona | Children | Rural | Expected Amount | |---------|----------|-------|-----------------| | +Maria Santos | 2 | Yes | $270 | | Carlos Morales | 3 | No | $300 | | Ana Mendoza | 1 | Yes | $210 | --- @@ -499,9 +537,8 @@ min(40, (100 - household.pmt_score) * 0.5) ``` **Test Cases:** | Persona | Female-Headed | Disabled | Dep. Ratio | PMT | Score | -|---------|---------------|----------|------------|-----|-------| | Rosa Garcia | No | -No | 0.0 | 42 | 29 | | Maria Santos | Yes | No | 0.4 | 38 | 51 | | Ibrahim Hassan | No | -Yes | 0.6 | 35 | 72 | +|---------|---------------|----------|------------|-----|-------| | Rosa Garcia | No | No | 0.0 | 42 | 29 | | Maria +Santos | Yes | No | 0.4 | 38 | 51 | | Ibrahim Hassan | No | Yes | 0.6 | 35 | 72 | --- @@ -550,8 +587,7 @@ vulnerability_score(household) * 0.6 + **Objective:** Demonstrate formula versioning for policy changes -**Story:** Government decides to expand Cash Transfer coverage by raising PMT threshold -from 45 to 50. +**Story:** Government decides to expand Cash Transfer coverage by raising PMT threshold from 45 to 50. **Steps:** @@ -781,8 +817,7 @@ from 45 to 50. ## Event Data -The MIS Demo generates event records that track beneficiary interactions with the -system. +The MIS Demo generates event records that track beneficiary interactions with the system. ### Event Types @@ -879,8 +914,7 @@ This ensures proper integration between modules and complete demo scenarios. ### GRM Integration -The GRM demo creates specific tickets for story personas that integrate with their MIS -journeys: +The GRM demo creates specific tickets for story personas that integrate with their MIS journeys: | Story | GRM Ticket | Links To | | ---------------- | -------------------- | -------------------------- | diff --git a/spp_mis_demo_v2/models/__init__.py b/spp_mis_demo_v2/models/__init__.py index 572e7df6..f3ea43e7 100644 --- a/spp_mis_demo_v2/models/__init__.py +++ b/spp_mis_demo_v2/models/__init__.py @@ -2,5 +2,7 @@ from . import demo_programs from . import demo_variables +from . import household_blueprints from . import indicator_providers from . import mis_demo_generator +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 83efec58..3a43f2a7 100644 --- a/spp_mis_demo_v2/models/mis_demo_generator.py +++ b/spp_mis_demo_v2/models/mis_demo_generator.py @@ -12,12 +12,12 @@ 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 +from odoo.addons.spp_demo.locale_providers import create_faker + from . import demo_programs _logger = logging.getLogger(__name__) @@ -29,6 +29,19 @@ class SPPMISDemoGenerator(models.TransientModel): name = fields.Char(string="Name", default="MIS Demo Data V2", required=True) + # Country selection for locale and currency + country_id = fields.Selection( + [ + ("ph", "Philippines"), + ("lk", "Sri Lanka"), + ("tg", "Togo"), + ], + string="Country", + default="ph", + required=True, + help="Determines locale for names and company currency", + ) + # Demo mode selection (simplified UI always uses "complete") demo_mode = fields.Selection( [ @@ -79,38 +92,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 @@ -211,25 +197,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): @@ -240,9 +212,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, @@ -262,9 +231,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, @@ -284,9 +250,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, @@ -306,9 +269,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, @@ -328,6 +288,27 @@ def _onchange_demo_mode(self): for field_name, value in defaults.items(): setattr(self, field_name, value) + # Country configuration mapping + COUNTRY_CONFIG = { + "ph": {"xmlid": "base.ph", "locale": "fil_PH", "currency_xmlid": "base.PHP"}, + "lk": {"xmlid": "base.lk", "locale": "si_LK", "currency_xmlid": "base.LKR"}, + "tg": {"xmlid": "base.tg", "locale": "fr_TG", "currency_xmlid": "base.XOF"}, + } + + def _get_country_config(self): + """Get country, locale, and currency based on selected country_id.""" + config = self.COUNTRY_CONFIG.get(self.country_id, self.COUNTRY_CONFIG["ph"]) + 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. @@ -336,7 +317,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(): @@ -363,7 +344,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) @@ -418,13 +399,30 @@ 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) + # Resolve country configuration (locale, currency) + country_cfg = self._get_country_config() + faker_locale = country_cfg["locale"] + # Store locale in context for use by story methods (locale-aware names) + self = self.with_context(demo_locale=faker_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_id, + faker_locale, + country_cfg["currency"].name if country_cfg["currency"] else "N/A", + ) + + # Initialize Faker (for story generation and other non-blueprint uses) + fake = create_faker(faker_locale) created_data = { "programs": [], @@ -436,9 +434,6 @@ def action_generate(self): } # 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) @@ -458,10 +453,17 @@ def action_generate(self): 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 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, faker_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: @@ -477,11 +479,16 @@ def action_generate(self): 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 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: @@ -538,8 +545,9 @@ def _ensure_demo_stories_exist(self, stats): # 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( @@ -602,10 +610,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", {}) @@ -614,12 +619,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}, ) @@ -731,11 +731,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: @@ -879,7 +875,8 @@ def _generate_random_groups(self, fake, stats): try: from odoo.addons.spp_demo.models import demo_stories - reserved_names = demo_stories.RESERVED_NAMES + locale = self.env.context.get("demo_locale") + reserved_names = demo_stories.get_localized_reserved_names(locale) except ImportError: reserved_names = [] @@ -917,12 +914,7 @@ def _generate_random_groups(self, fake, stats): # Create head of household head = self._create_random_individual( - fake, - head_name, - head_gender, - head_age, - registration_date, - reserved_names, + fake, head_name, head_gender, head_age, registration_date, reserved_names ) if head: stats["random_individuals_created"] += 1 @@ -948,12 +940,7 @@ def _generate_random_groups(self, fake, stats): if spouse_name not in reserved_names: spouse = self._create_random_individual( - fake, - spouse_name, - spouse_gender, - spouse_age, - registration_date, - reserved_names, + fake, spouse_name, spouse_gender, spouse_age, registration_date, reserved_names ) if spouse: stats["random_individuals_created"] += 1 @@ -974,12 +961,7 @@ def _generate_random_groups(self, fake, stats): if member_name not in reserved_names: member = self._create_random_individual( - fake, - member_name, - member_gender, - member_age, - registration_date, - reserved_names, + fake, member_name, member_gender, member_age, registration_date, reserved_names ) if member: stats["random_individuals_created"] += 1 @@ -1095,16 +1077,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 @@ -1128,10 +1102,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) @@ -1173,18 +1144,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.""" @@ -1197,55 +1160,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. @@ -1269,7 +1184,7 @@ def _configure_eligibility_manager(self, program, program_def): # Check if the model supports CEL mode (spp_programs CEL features) if "eligibility_mode" not in eligibility_manager._fields: _logger.info( - "CEL mode not available (spp_programs CEL not configured) for program (program_id=%s)", + "CEL mode not available (spp_programs CEL not configured) " "for program (program_id=%s)", program.id, ) return @@ -1470,11 +1385,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 @@ -1490,10 +1406,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 @@ -1554,10 +1467,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 @@ -1645,10 +1555,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) @@ -1773,11 +1680,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 @@ -1955,7 +1858,8 @@ def _generate_volume_enrollments(self, fake, stats): try: from odoo.addons.spp_demo.models import demo_stories - reserved_names = demo_stories.RESERVED_NAMES + locale = self.env.context.get("demo_locale") + reserved_names = demo_stories.get_localized_reserved_names(locale) except ImportError: reserved_names = [] @@ -2024,11 +1928,7 @@ def _generate_volume_enrollments(self, fake, stats): # 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] + state = random.choices(["draft", "enrolled", "paused", "exited"], weights=[10, 60, 10, 20], k=1)[0] membership = self.env["spp.program.membership"].create( { @@ -2051,16 +1951,17 @@ def _generate_volume_enrollments(self, fake, stats): _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"], - ) + _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.""" + """Create cycles with beneficiaries and entitlements for all programs. + + 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( [ @@ -2069,47 +1970,151 @@ def _create_program_cycles(self, fake, stats): ] ) + today = fields.Date.today() + 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) + 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", + ) - if cycles_to_create == 0: - _logger.info( - "Program (program_id=%s) already has %d cycles", - program.id, - existing_cycles, + 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 + + # Get all enrolled program members not already in cycle + enrolled_members = self.env["spp.program.membership"].search( + [ + ("program_id", "=", program.id), + ("state", "=", "enrolled"), + ] ) - continue + existing_cycle_partner_ids = set(cycle.cycle_membership_ids.mapped("partner_id.id")) + new_members = enrolled_members.filtered(lambda m: m.partner_id.id not in existing_cycle_partner_ids) - 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, - ) + if not new_members: + _logger.info("Program '%s': all %d members already in cycle", program.name, len(enrolled_members)) + cycles.append(cycle) + continue + + # 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) - # Prepare entitlements - cycle_manager = program.get_manager(program.MANAGER_CYCLE) - if cycle_manager: - cycle_manager.prepare_entitlements(cycle) + # Determine entitlement amount from entitlement manager config + base_amount = self._get_entitlement_amount(program) - except Exception as e: - _logger.warning( - "Could not create cycle for program (program_id=%s): %s", - program.id, - e, + # Batch-create entitlements in approved state (bypasses approval workflow) + ent_vals = [ + { + "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) + + # 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, + } ) + # 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"]) + + # 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) + + cycles.append(cycle) + _logger.info( + "Cycle '%s': %d beneficiaries, %d entitlements for program '%s'", + cycle.name, + len(cm_vals), + len(ent_vals), + program.name, + ) + + except Exception as e: + _logger.warning("Could not create cycle for program '%s': %s", program.name, e) + return cycles + 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 '%s': %s", program.name, e) + + 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 # ══════════════════════════════════════════════════════════════ @@ -2246,47 +2251,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 @@ -2325,19 +2335,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"), ], @@ -2348,10 +2349,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"), @@ -2362,10 +2360,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"), @@ -2379,23 +2374,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 ], ), @@ -2711,33 +2697,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)], @@ -2796,8 +2884,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. + cr_model = cr_model.with_user( # nosemgrep: odoo-with-user-unvalidated - Demo-only generator, demo_user is not user input. demo_user ) cr = cr_model.create(cr_vals) @@ -2813,7 +2900,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", (request_date, detail.id), ) @@ -2832,12 +2919,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 @@ -2862,26 +2944,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). @@ -2896,10 +2978,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, @@ -2951,13 +3033,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, @@ -2985,11 +3061,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( @@ -3073,18 +3145,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 @@ -3213,11 +3280,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.""" @@ -3360,7 +3423,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( @@ -3373,7 +3436,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, @@ -3393,7 +3456,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, @@ -3409,9 +3472,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: %s", issuer.name) else: - _logger.info("[spp.mis.demo] Using existing issuer config ID %s", issuer.id) + _logger.info("[spp.mis.demo] Using existing issuer config: %s", issuer.name) # Step 4: Generate credentials for demo story personas if self.generate_credentials_for_stories: @@ -3441,7 +3504,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 @@ -3456,7 +3520,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: @@ -3471,7 +3535,7 @@ def _generate_story_credentials(self, issuer, stats): if existing: _logger.debug( "[spp.mis.demo] Credential already exists for %s", - partner.id, + partner.name, ) continue @@ -3494,8 +3558,8 @@ def _generate_story_credentials(self, issuer, stats): credentials_created += 1 _logger.debug( "[spp.mis.demo] Generated credential for %s: %s", - partner.id, - credential.id, + partner.name, + credential.name, ) _logger.info( @@ -3514,18 +3578,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"], ) @@ -3547,7 +3606,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"], ) ) @@ -3561,7 +3620,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: @@ -3569,20 +3635,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 @@ -3597,10 +3655,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 = [] @@ -3609,12 +3664,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)) @@ -3622,10 +3672,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.")) @@ -3641,7 +3688,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 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..009b9099 --- /dev/null +++ b/spp_mis_demo_v2/models/seeded_volume_generator.py @@ -0,0 +1,472 @@ +# 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) +- Faker(locale, seed) for locale-appropriate names + +Same seed + same locale = 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 create_faker +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 and Faker. + + 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.faker = create_faker(locale) + self.faker.seed_instance(seed) + self.reserved_names = set(get_localized_reserved_names(locale)) + + # 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 Faker.""" + family_name = self.faker.last_name() + 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.faker.first_name_male() + else: + given = self.faker.first_name_female() + family = self.faker.last_name() + 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 5205e81a..ffdace39 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..99a27f5c --- /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 Faker 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..6abf739c 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, @@ -514,7 +509,7 @@ def test_household_stories_created_with_members(self): self.assertEqual( member_count, expected_count, - f"Household '{story_name}' should have {expected_count} members, but has {member_count}", + f"Household '{story_name}' should have {expected_count} members, " f"but has {member_count}", ) def test_chen_family_has_all_members(self): @@ -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/views/mis_demo_wizard_view.xml b/spp_mis_demo_v2/views/mis_demo_wizard_view.xml index 928c4992..1836679a 100644 --- a/spp_mis_demo_v2/views/mis_demo_wizard_view.xml +++ b/spp_mis_demo_v2/views/mis_demo_wizard_view.xml @@ -1,4 +1,4 @@ - + @@ -7,32 +7,28 @@
+ + +
-
@@ -44,17 +40,15 @@ spp.mis.demo.wizard form new - +
- + Date: Wed, 4 Mar 2026 14:52:57 +0800 Subject: [PATCH 2/3] refactor(spp_mis_demo_v2): remove Faker dependency, use pure seeded RNG for name generation Replace all Faker usage with deterministic random.Random(seed).choice() from locale-specific name arrays. This ensures fully reproducible output across runs with the same seed. - Remove create_faker import and fake variable from mis_demo_generator - Delete dead code: _generate_random_groups, _create_random_individual, _generate_volume_enrollments - Remove fake parameter from _create_program_cycles - Remove faker from external_dependencies in manifest - Remove tests that called deleted _create_random_individual method - Update docs and docstrings to reflect seeded RNG approach --- spp_mis_demo_v2/__manifest__.py | 2 +- spp_mis_demo_v2/docs/USE_CASES.md | 66 ++-- spp_mis_demo_v2/models/mis_demo_generator.py | 291 +----------------- .../models/seeded_volume_generator.py | 33 +- .../tests/test_blueprint_reproducibility.py | 2 +- .../tests/test_registry_variables.py | 102 ------ 6 files changed, 72 insertions(+), 424 deletions(-) diff --git a/spp_mis_demo_v2/__manifest__.py b/spp_mis_demo_v2/__manifest__.py index e7acd270..1c1baf5c 100644 --- a/spp_mis_demo_v2/__manifest__.py +++ b/spp_mis_demo_v2/__manifest__.py @@ -24,7 +24,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/docs/USE_CASES.md b/spp_mis_demo_v2/docs/USE_CASES.md index d724a807..49fbeaea 100644 --- a/spp_mis_demo_v2/docs/USE_CASES.md +++ b/spp_mis_demo_v2/docs/USE_CASES.md @@ -1,7 +1,7 @@ # OpenSPP MIS Demo V2 - Use Cases Guide -This document describes the different demo use cases available in the `spp_mis_demo_v2` module and how to use them -effectively for sales demos, training, and testing. +This document describes the different demo use cases available in the `spp_mis_demo_v2` +module and how to use them effectively for sales demos, training, and testing. ## Table of Contents @@ -19,11 +19,13 @@ effectively for sales demos, training, and testing. ## Overview -The MIS Demo V2 module provides realistic demo data that showcases OpenSPP's capabilities for social protection program -management. It follows the **Blueprint + Seeded Faker** architecture: +The MIS Demo V2 module provides realistic demo data that showcases OpenSPP's +capabilities for social protection program management. It follows the **Blueprint + +Seeded RNG** architecture: - **Fixed Stories**: 8 named personas with predefined program journeys (unchanged) -- **Deterministic Volume**: ~730 households with ~2,500 members from 28 blueprint templates +- **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) @@ -32,14 +34,17 @@ management. It follows the **Blueprint + Seeded Faker** architecture: ## 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. +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 `Faker(locale, seed=42)` — deterministic per locale +- 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 @@ -56,7 +61,8 @@ composition (members, ages, genders), income bracket, and program eligibility. B ## Country / Locale Support -The wizard lets you choose a country. This determines the locale for name generation and the company currency. +The wizard lets you choose a country. This determines the locale for name generation and +the company currency. | Country | Locale | Currency | Name Examples | | ----------- | -------- | ---------------------------- | ---------------------------- | @@ -64,7 +70,8 @@ The wizard lets you choose a country. This determines the locale for name genera | 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. +**Reproducibility guarantee**: Selecting the same country always produces the exact same +set of records. --- @@ -440,8 +447,8 @@ The wizard lets you choose a country. This determines the locale for name genera ## Formula Library Demo -The MIS Demo includes comprehensive formula library data demonstrating versioned, auditable business rules for -eligibility, entitlements, and scoring. +The MIS Demo includes comprehensive formula library data demonstrating versioned, +auditable business rules for eligibility, entitlements, and scoring. ### Formula Categories @@ -472,9 +479,10 @@ eligibility, entitlements, and scoring. household.pmt_score < 45.0 && household.member_count > 0 ``` -**Test Cases:** | Persona | PMT Score | Expected | Result | |---------|-----------|----------|--------| | Maria Santos | -38 | `true` | ✓ Eligible | | Rosa Garcia | 42 | `true` | ✓ Eligible | | Carlos Morales | 48 | `false` | ✗ Not eligible | -| Ibrahim Hassan | 35 | `true` | ✓ Eligible | +**Test Cases:** | Persona | PMT Score | Expected | Result | +|---------|-----------|----------|--------| | Maria Santos | 38 | `true` | ✓ Eligible | +| Rosa Garcia | 42 | `true` | ✓ Eligible | | Carlos Morales | 48 | `false` | ✗ Not +eligible | | Ibrahim Hassan | 35 | `true` | ✓ Eligible | --- @@ -493,8 +501,9 @@ household.pmt_score < 45.0 && household.member_count > 0 individual.age >= 65 && !individual.receives_other_pension ``` -**Test Cases:** | Persona | Age | Other Pension | Expected | |---------|-----|---------------|----------| | Rosa Garcia -| 72 | No | `true` ✓ | | Maria Santos | 45 | No | `false` ✗ | | Pedro Reyes | 55 | No | `false` ✗ | +**Test Cases:** | Persona | Age | Other Pension | Expected | +|---------|-----|---------------|----------| | Rosa Garcia | 72 | No | `true` ✓ | | +Maria Santos | 45 | No | `false` ✗ | | Pedro Reyes | 55 | No | `false` ✗ | --- @@ -513,8 +522,9 @@ individual.age >= 65 && !individual.receives_other_pension 150.0 + (household.child_count * 50.0) * (household.is_rural ? 1.2 : 1.0) ``` -**Test Cases:** | Persona | Children | Rural | Expected Amount | |---------|----------|-------|-----------------| | -Maria Santos | 2 | Yes | $270 | | Carlos Morales | 3 | No | $300 | | Ana Mendoza | 1 | Yes | $210 | +**Test Cases:** | Persona | Children | Rural | Expected Amount | +|---------|----------|-------|-----------------| | Maria Santos | 2 | Yes | $270 | | +Carlos Morales | 3 | No | $300 | | Ana Mendoza | 1 | Yes | $210 | --- @@ -537,8 +547,9 @@ min(40, (100 - household.pmt_score) * 0.5) ``` **Test Cases:** | Persona | Female-Headed | Disabled | Dep. Ratio | PMT | Score | -|---------|---------------|----------|------------|-----|-------| | Rosa Garcia | No | No | 0.0 | 42 | 29 | | Maria -Santos | Yes | No | 0.4 | 38 | 51 | | Ibrahim Hassan | No | Yes | 0.6 | 35 | 72 | +|---------|---------------|----------|------------|-----|-------| | Rosa Garcia | No | +No | 0.0 | 42 | 29 | | Maria Santos | Yes | No | 0.4 | 38 | 51 | | Ibrahim Hassan | No | +Yes | 0.6 | 35 | 72 | --- @@ -587,7 +598,8 @@ vulnerability_score(household) * 0.6 + **Objective:** Demonstrate formula versioning for policy changes -**Story:** Government decides to expand Cash Transfer coverage by raising PMT threshold from 45 to 50. +**Story:** Government decides to expand Cash Transfer coverage by raising PMT threshold +from 45 to 50. **Steps:** @@ -817,7 +829,8 @@ vulnerability_score(household) * 0.6 + ## Event Data -The MIS Demo generates event records that track beneficiary interactions with the system. +The MIS Demo generates event records that track beneficiary interactions with the +system. ### Event Types @@ -914,7 +927,8 @@ This ensures proper integration between modules and complete demo scenarios. ### GRM Integration -The GRM demo creates specific tickets for story personas that integrate with their MIS journeys: +The GRM demo creates specific tickets for story personas that integrate with their MIS +journeys: | Story | GRM Ticket | Links To | | ---------------- | -------------------- | -------------------------- | diff --git a/spp_mis_demo_v2/models/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py index 3a43f2a7..490681fc 100644 --- a/spp_mis_demo_v2/models/mis_demo_generator.py +++ b/spp_mis_demo_v2/models/mis_demo_generator.py @@ -5,7 +5,7 @@ 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 @@ -16,8 +16,6 @@ from odoo.exceptions import UserError, ValidationError from odoo.tools import config -from odoo.addons.spp_demo.locale_providers import create_faker - from . import demo_programs _logger = logging.getLogger(__name__) @@ -172,7 +170,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 @@ -404,9 +402,9 @@ def action_generate(self): try: # Resolve country configuration (locale, currency) country_cfg = self._get_country_config() - faker_locale = country_cfg["locale"] + demo_locale = country_cfg["locale"] # Store locale in context for use by story methods (locale-aware names) - self = self.with_context(demo_locale=faker_locale) + self = self.with_context(demo_locale=demo_locale) # Set company country and currency if country_cfg["country"]: @@ -417,13 +415,10 @@ def action_generate(self): _logger.info( "Country: %s, Locale: %s, Currency: %s", self.country_id, - faker_locale, + demo_locale, country_cfg["currency"].name if country_cfg["currency"] else "N/A", ) - # Initialize Faker (for story generation and other non-blueprint uses) - fake = create_faker(faker_locale) - created_data = { "programs": [], "enrollments": [], @@ -460,7 +455,7 @@ def action_generate(self): from .seeded_volume_generator import SeededVolumeGenerator _logger.info("Generating deterministic households from %d blueprints...", len(HOUSEHOLD_BLUEPRINTS)) - generator = SeededVolumeGenerator(self.env, faker_locale, seed=42) + 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) @@ -493,7 +488,7 @@ def action_generate(self): # Step 4: Create cycles if self.create_cycles: _logger.info("Creating program cycles...") - created_data["cycles"] = self._create_program_cycles(fake, stats) + created_data["cycles"] = self._create_program_cycles(stats) # Step 5: Create event data if self.create_event_data: @@ -870,159 +865,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 - - locale = self.env.context.get("demo_locale") - reserved_names = demo_stories.get_localized_reserved_names(locale) - 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 = [] @@ -1184,7 +1026,7 @@ def _configure_eligibility_manager(self, program, program_def): # Check if the model supports CEL mode (spp_programs CEL features) if "eligibility_mode" not in eligibility_manager._fields: _logger.info( - "CEL mode not available (spp_programs CEL not configured) " "for program (program_id=%s)", + "CEL mode not available (spp_programs CEL not configured) for program (program_id=%s)", program.id, ) return @@ -1840,122 +1682,7 @@ 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") - - # Get available registrants (excluding demo story names) - try: - from odoo.addons.spp_demo.models import demo_stories - - locale = self.env.context.get("demo_locale") - reserved_names = demo_stories.get_localized_reserved_names(locale) - 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( - [ - ("is_registrant", "=", True), - ("is_group", "=", False), - ("name", "not in", reserved_names), - ], - limit=300, - ) - - if not groups and not individuals: - _logger.warning("No registrants found for volume generation") - return enrollments - - attempts = 0 - max_attempts = self.volume_enrollments * 3 # Allow retries - - while len(enrollments) < self.volume_enrollments and attempts < max_attempts: - attempts += 1 - - # Match program type to registrant type - use_group = random.choice([True, False]) - - 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: - continue - - # Check if already enrolled - existing = self.env["spp.program.membership"].search( - [ - ("partner_id", "=", registrant.id), - ("program_id", "=", program.id), - ], - limit=1, - ) - - if existing: - stats["volume_skipped"] += 1 - continue - - # 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( - { - "partner_id": registrant.id, - "program_id": program.id, - "state": state, - } - ) - - if state in ("enrolled", "exited"): - self.env.cr.execute( - "UPDATE spp_program_membership SET enrollment_date = %s WHERE id = %s", - (enrollment_date, membership.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): + def _create_program_cycles(self, stats): """Create cycles with beneficiaries and entitlements for all programs. Uses direct record creation (same pattern as _create_story_payments) diff --git a/spp_mis_demo_v2/models/seeded_volume_generator.py b/spp_mis_demo_v2/models/seeded_volume_generator.py index 009b9099..cf5f1a5e 100644 --- a/spp_mis_demo_v2/models/seeded_volume_generator.py +++ b/spp_mis_demo_v2/models/seeded_volume_generator.py @@ -3,10 +3,9 @@ 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) -- Faker(locale, seed) for locale-appropriate names +- random.Random(seed) for all structural choices (ages, incomes, genders, names) -Same seed + same locale = identical output every run. +Same seed = identical output every run. Different locale = different names but same household structure. Performance optimized with: @@ -21,7 +20,7 @@ from odoo import fields -from odoo.addons.spp_demo.locale_providers import create_faker +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__) @@ -30,7 +29,7 @@ class SeededVolumeGenerator: - """Deterministic household/member generator using seeded RNG and Faker. + """Deterministic household/member generator using seeded RNG. Not an ORM model — a utility class instantiated by the wizard. """ @@ -40,10 +39,20 @@ def __init__(self, env, locale, seed=42): self.locale = locale self.seed = seed self.rng = random.Random(seed) - self.faker = create_faker(locale) - self.faker.seed_instance(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 @@ -371,8 +380,8 @@ def _batch_create(self, model_name, vals_list): return all_records def _generate_group_name(self): - """Generate a household name from seeded Faker.""" - family_name = self.faker.last_name() + """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): @@ -380,10 +389,10 @@ def _generate_member_name(self, gender): max_attempts = 20 for _ in range(max_attempts): if gender == "male": - given = self.faker.first_name_male() + given = self.rng.choice(self._male_names) else: - given = self.faker.first_name_female() - family = self.faker.last_name() + 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 diff --git a/spp_mis_demo_v2/tests/test_blueprint_reproducibility.py b/spp_mis_demo_v2/tests/test_blueprint_reproducibility.py index 99a27f5c..fdc3082e 100644 --- a/spp_mis_demo_v2/tests/test_blueprint_reproducibility.py +++ b/spp_mis_demo_v2/tests/test_blueprint_reproducibility.py @@ -1,6 +1,6 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """ -Tests for Blueprint + Seeded Faker reproducibility. +Tests for Blueprint + Seeded RNG reproducibility. Verifies that: 1. Same seed + same locale produces identical output 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", - ) From cd0fedea42c83ad187a14e8bc6509102017178b7 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 5 Mar 2026 10:01:46 +0800 Subject: [PATCH 3/3] feat(spp_mis_demo_v2): prevent duplicate demo data loading Add mis_demo_loaded boolean on res.company, set on successful generation. Wizard shows warning and hides Load button if already triggered. Also show GRM tickets / Case records bullets conditionally based on whether spp_grm_demo / spp_case_demo are installed. --- spp_mis_demo_v2/models/__init__.py | 1 + spp_mis_demo_v2/models/mis_demo_generator.py | 29 +++++++ spp_mis_demo_v2/models/res_company.py | 13 +++ .../views/mis_demo_wizard_view.xml | 80 +++++++++++++++---- 4 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 spp_mis_demo_v2/models/res_company.py diff --git a/spp_mis_demo_v2/models/__init__.py b/spp_mis_demo_v2/models/__init__.py index f3ea43e7..9873609c 100644 --- a/spp_mis_demo_v2/models/__init__.py +++ b/spp_mis_demo_v2/models/__init__.py @@ -5,4 +5,5 @@ 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/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py index 490681fc..1aca1ff1 100644 --- a/spp_mis_demo_v2/models/mis_demo_generator.py +++ b/spp_mis_demo_v2/models/mis_demo_generator.py @@ -526,6 +526,9 @@ def action_generate(self): 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) @@ -3442,6 +3445,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/views/mis_demo_wizard_view.xml b/spp_mis_demo_v2/views/mis_demo_wizard_view.xml index 1836679a..e1e4bb7a 100644 --- a/spp_mis_demo_v2/views/mis_demo_wizard_view.xml +++ b/spp_mis_demo_v2/views/mis_demo_wizard_view.xml @@ -1,4 +1,4 @@ - + @@ -7,28 +7,72 @@
- + + + + + -