diff --git a/spp_mis_demo_v2/__manifest__.py b/spp_mis_demo_v2/__manifest__.py
index 0d9a7f970..dfd5afd9d 100644
--- a/spp_mis_demo_v2/__manifest__.py
+++ b/spp_mis_demo_v2/__manifest__.py
@@ -4,7 +4,7 @@
"name": "OpenSPP MIS Demo V2",
"summary": "Demo Generator V2 for SP-MIS programs with fixed stories and volume generation",
"category": "OpenSPP",
- "version": "19.0.2.0.1",
+ "version": "19.0.2.1.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
@@ -34,7 +34,7 @@
"spp_banking",
# Demo-specific extensions
],
- "external_dependencies": {"python": ["requests"]},
+ "external_dependencies": {"python": ["requests", "faker"]},
"post_init_hook": "post_init_hook",
"data": [
"security/ir.model.access.csv",
diff --git a/spp_mis_demo_v2/data/demo_api_client.xml b/spp_mis_demo_v2/data/demo_api_client.xml
index 72fb1dd6e..3d64d9dde 100644
--- a/spp_mis_demo_v2/data/demo_api_client.xml
+++ b/spp_mis_demo_v2/data/demo_api_client.xml
@@ -35,13 +35,13 @@
>Read access to GIS layers, reports catalog, and spatial statistics queries.
-
+
gis
all
Full GIS access including creating and managing geofences.
+ >Full GIS access including creating and managing geofences and incidents.
diff --git a/spp_mis_demo_v2/data/demo_gis_reports.xml b/spp_mis_demo_v2/data/demo_gis_reports.xml
index 17ee11cae..cffac7a16 100644
--- a/spp_mis_demo_v2/data/demo_gis_reports.xml
+++ b/spp_mis_demo_v2/data/demo_gis_reports.xml
@@ -24,14 +24,18 @@
>[('is_registrant', '=', True), ('is_group', '=', True)]
count
2
- raw
+ per_population
True
sum
count
auto_quartile
5
- True
+
+ expand
scheduled
daily
@@ -58,9 +62,13 @@
weighted_avg
area_sqkm
- auto_jenks
+ auto_quartile
5
- True
+
+ expand
scheduled
daily
@@ -89,8 +97,10 @@
auto_quartile
4
- True
- True
+
scheduled
weekly
diff --git a/spp_mis_demo_v2/data/demo_statistics.xml b/spp_mis_demo_v2/data/demo_statistics.xml
index 521eb6faf..e108a61dd 100644
--- a/spp_mis_demo_v2/data/demo_statistics.xml
+++ b/spp_mis_demo_v2/data/demo_statistics.xml
@@ -29,46 +29,14 @@
10
-
-
-
-
- demo_children_under_18
- children_under_18
- aggregate
- count
- members
- age_years(m.birthdate) < 18
- number
- group
-
- active
- 30
-
-
-
-
- demo_elderly_60_plus
- elderly_60_plus
- aggregate
- count
- members
- age_years(m.birthdate) >= 60
- number
- group
-
- active
- 40
-
-
-
+
- demo_disabled_members
- disabled_members
+ demo_pwd_members
+ pwd_members
aggregate
count
members
- m.disabled != null
+ m.disability_id != null
number
group
@@ -76,36 +44,6 @@
50
-
-
- demo_female_members
- female_members
- aggregate
- count
- members
- is_female(m.gender_id)
- number
- group
-
- active
- 45
-
-
-
-
- demo_male_members
- male_members
- aggregate
- count
- members
- is_male(m.gender_id)
- number
- group
-
- active
- 46
-
-
demo_total_households
@@ -165,81 +103,13 @@
-
-
- children_under_5
- Children Under 5
- Count of children under 5 years old
-
- count
- children
-
- 20
-
-
-
-
-
-
- children_under_18
- Children Under 18
- Count of children under 18 years old
-
- count
- children
-
- 30
-
-
-
-
-
-
- elderly_60_plus
- Elderly (60+)
- Count of elderly persons aged 60 and above
-
- count
- people
-
- 40
-
-
-
-
-
-
- female_members
- Female Members
- Count of female household members
-
- count
- people
-
- 45
-
-
-
-
-
-
- male_members
- Male Members
- Count of male household members
-
- count
- people
-
- 46
-
-
-
-
-
+
- disabled_members
- Disabled Members
- Count of household members with disabilities
+ pwd_members
+ Members with Disability
+ Count of household members with a recorded disability
count
people
diff --git a/spp_mis_demo_v2/models/indicator_providers.py b/spp_mis_demo_v2/models/indicator_providers.py
index e06a6b2da..3a32ad349 100644
--- a/spp_mis_demo_v2/models/indicator_providers.py
+++ b/spp_mis_demo_v2/models/indicator_providers.py
@@ -34,6 +34,11 @@
_logger = logging.getLogger(__name__)
+DEBUG_HH_POINTS_LAYER_NAME = "HH Points (Debug)"
+DEBUG_HH_POINTS_LAYER_DOMAIN = (
+ "[('is_registrant', '=', True), ('is_group', '=', True), ('coordinates', '!=', False), ('active', '=', True)]"
+)
+
# Standard variables from spp_studio to activate (module.xml_id format)
STANDARD_VARIABLES = [
# Demographics (computed)
@@ -128,6 +133,89 @@ def _activate_variables(env, xml_ids, source_name):
return activated, skipped, errors
+def _find_debug_layer_view(env):
+ """Find the most useful GIS view for debug household points."""
+ view_model = env["ir.ui.view"]
+
+ # Prefer geofence map when available (best fit for geofence query debugging)
+ geofence_view = view_model.search(
+ [
+ ("model", "=", "spp.gis.geofence"),
+ ("type", "=", "gis"),
+ ],
+ limit=1,
+ )
+ if geofence_view:
+ return geofence_view
+
+ # Fallback to the standard area map
+ return view_model.search(
+ [
+ ("model", "=", "spp.area"),
+ ("type", "=", "gis"),
+ ],
+ limit=1,
+ )
+
+
+def _ensure_household_points_debug_layer(env):
+ """Create/update the HH points debug GIS layer (hidden on startup)."""
+ coordinates_field = env["ir.model.fields"].search(
+ [
+ ("model", "=", "res.partner"),
+ ("name", "=", "coordinates"),
+ ],
+ limit=1,
+ )
+ if not coordinates_field:
+ _logger.info("[spp.mis.demo] Skipping HH debug layer: res.partner.coordinates not available")
+ return False
+
+ view = _find_debug_layer_view(env)
+ if not view:
+ _logger.info("[spp.mis.demo] Skipping HH debug layer: no GIS view found for geofence/area")
+ return False
+
+ layer_model = env["spp.gis.data.layer"]
+ layer = layer_model.search(
+ [
+ ("name", "=", DEBUG_HH_POINTS_LAYER_NAME),
+ ("geo_field_id", "=", coordinates_field.id),
+ ("view_id", "=", view.id),
+ ],
+ limit=1,
+ )
+
+ vals = {
+ "name": DEBUG_HH_POINTS_LAYER_NAME,
+ "geo_field_id": coordinates_field.id,
+ "view_id": view.id,
+ "geo_repr": "basic",
+ "active_on_startup": False,
+ "layer_opacity": 0.9,
+ "begin_color": "#0057B8",
+ "sequence": 25,
+ "domain": DEBUG_HH_POINTS_LAYER_DOMAIN,
+ }
+
+ if layer:
+ layer.write(vals)
+ _logger.info(
+ "[spp.mis.demo] Updated HH debug layer '%s' on view %s",
+ layer.name,
+ view.display_name,
+ )
+ return layer
+
+ layer = layer_model.create(vals)
+ _logger.info(
+ "[spp.mis.demo] Created HH debug layer '%s' on view %s",
+ layer.name,
+ view.display_name,
+ )
+ return layer
+
+
def post_init_hook(env_or_cr, registry=None):
"""Post-initialization hook for demo module.
@@ -162,3 +250,5 @@ def post_init_hook(env_or_cr, registry=None):
total_skipped,
total_errors,
)
+
+ _ensure_household_points_debug_layer(env)
diff --git a/spp_mis_demo_v2/models/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py
index 4500f79e3..f770f350a 100644
--- a/spp_mis_demo_v2/models/mis_demo_generator.py
+++ b/spp_mis_demo_v2/models/mis_demo_generator.py
@@ -34,6 +34,7 @@ class SPPMISDemoGenerator(models.TransientModel):
("training", "Partner Training"),
("testing", "Developer Testing"),
("complete", "Complete Demo"),
+ ("volume", "Volume (GIS)"),
],
string="Demo Mode",
default="complete",
@@ -42,7 +43,8 @@ class SPPMISDemoGenerator(models.TransientModel):
"- Sales: Fixed stories + minimal programs (fast)\n"
"- Training: Full programs + Logic Packs (comprehensive)\n"
"- Testing: Volume data + random generation (scale testing)\n"
- "- Complete: All features enabled",
+ "- Complete: All features enabled\n"
+ "- Volume: Large-scale GIS data (7K+ households, geodata)",
)
# Logic Studio integration
@@ -83,6 +85,35 @@ class SPPMISDemoGenerator(models.TransientModel):
default=True,
help="Generate deterministic households from blueprints (~730 households, ~2500 members)",
)
+ volume_enrollments = fields.Integer(
+ string="Random Enrollments",
+ default=50,
+ help="Number of random program enrollments to generate",
+ )
+
+ # Random group/household generation (Luzon-style GIS volume demo).
+ # When enabled (e.g. by the "Volume (GIS)" demo mode), households are
+ # generated with random/faker data instead of the deterministic blueprints.
+ generate_random_groups = fields.Boolean(
+ string="Generate Random Groups",
+ default=False,
+ help="Generate random households/groups with members instead of deterministic blueprints",
+ )
+ 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",
+ )
# Cycle and payment options
create_cycles = fields.Boolean(
@@ -200,11 +231,25 @@ def action_ensure_demo_user_groups(self):
rec._ensure_demo_user_groups()
return True
- @api.constrains("cycles_per_program")
+ @api.constrains(
+ "volume_enrollments",
+ "cycles_per_program",
+ "random_groups_count",
+ "members_per_group_min",
+ "members_per_group_max",
+ )
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):
@@ -294,6 +339,30 @@ def _onchange_demo_mode(self):
"load_geographic_data": True,
"country_code": "phl",
},
+ "volume": {
+ "create_demo_programs": True,
+ "enroll_demo_stories": True,
+ "create_story_payments": True,
+ "generate_volume": True,
+ "volume_enrollments": 5000,
+ "generate_random_groups": True,
+ "random_groups_count": 10000,
+ "create_cycles": True,
+ "cycles_per_program": 3,
+ "create_event_data": False,
+ "create_change_requests": False,
+ "create_fairness_analysis": False,
+ "install_logic_packs": False,
+ "include_test_personas": False,
+ "generate_grm_demo": False,
+ "grm_volume_tickets": 0,
+ "generate_case_demo": False,
+ "case_volume_count": 0,
+ "generate_claim169_demo": False,
+ "generate_credentials_for_stories": True,
+ "load_geographic_data": True,
+ "country_code": "phl",
+ },
}
defaults = mode_defaults.get(self.demo_mode, mode_defaults["sales"])
for field_name, value in defaults.items():
@@ -413,7 +482,16 @@ def action_generate(self):
}
try:
- demo_locale, created_data = self._run_generation_steps(stats)
+ result = self._run_generation_steps(stats)
+ # When large-volume generation is dispatched to a background job,
+ # _run_generation_steps returns a client action (notification)
+ # instead of the (demo_locale, created_data) tuple. In that case
+ # the remaining steps run inside the background job.
+ if isinstance(result, dict):
+ self.state = "completed"
+ self.env.company.mis_demo_loaded = True
+ return result
+ demo_locale, created_data = result
self.state = "completed"
@@ -428,8 +506,200 @@ def action_generate(self):
self.state = "draft"
raise UserError(_("Error generating demo data: %s") % e) from e
+ def _generate_random_volume(self, stats):
+ """Generate households using random/faker data (Luzon-style GIS demo).
+
+ For large volumes the work is either dispatched to a background job
+ (in a UI/web context) or run inline in committed chunks (in a CLI/shell
+ context). Both large-volume paths also assign areas, generate GPS
+ coordinates, ensure GIS layers, and refresh reports.
+
+ Returns:
+ bool: True if the work was dispatched to a background job (in which
+ case the caller should stop and let the job finish the remaining
+ steps); False if generation ran synchronously.
+ """
+ from faker import Faker
+
+ faker_locale = self.locale_origin.faker_locale or "en_US"
+ fake = Faker(faker_locale)
+ try:
+ from odoo.http import request as http_request
+
+ has_ui = bool(http_request and http_request.env)
+ except Exception:
+ has_ui = False
+
+ if self.random_groups_count > 500 and has_ui and hasattr(self, "with_delay"):
+ # Large volume in a UI/web context: dispatch to a background job.
+ # The job handles group creation (chunked), area assignment,
+ # coordinate generation, GIS layers, and report refresh.
+ _logger.info(
+ "Dispatching %d random groups to background job (chunked)...",
+ self.random_groups_count,
+ )
+ self._dispatch_volume_job(fake)
+ return True
+
+ if self.random_groups_count > 500:
+ # CLI/shell context: run chunked generation inline with commits.
+ # The inline job creates households (and assigns areas/coordinates
+ # and refreshes GIS) in committed chunks; the remaining steps in
+ # _run_generation_steps (programs, enrollments, cycles) still run
+ # normally afterwards.
+ _logger.info(
+ "Generating %d random groups inline (chunked)...",
+ self.random_groups_count,
+ )
+ locale = fake.locales[0] if fake.locales else "en_US"
+ self._run_volume_generation_job(locale)
+ return False
+
+ _logger.info("Generating %d random groups...", self.random_groups_count)
+ self._generate_random_groups(fake, stats)
+ return False
+
+ def _enroll_blueprint_households(self, generator, volume_households, programs):
+ """Enroll deterministic blueprint households into the created programs."""
+ _logger.info("Enrolling blueprint households in programs...")
+ program_map = {}
+ for prog in 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)
+
+ def _dispatch_volume_job(self, fake):
+ """Dispatch large-volume generation as a background job.
+
+ This runs before programs/enrollments/cycles are created (those run
+ synchronously in action_generate before this is reached). The job
+ handles: group creation (in chunks), area assignment, and coordinate
+ generation, committing after each chunk.
+ """
+ self.with_delay(
+ priority=5,
+ description=f"Generate {self.random_groups_count} demo households",
+ max_retries=0,
+ )._run_volume_generation_job(fake.locales[0] if fake.locales else "en_US")
+
+ def _run_volume_generation_job(self, faker_locale):
+ """Background job: create groups in committed chunks, then assign areas.
+
+ Each chunk of CHUNK_SIZE groups is created and committed independently.
+ If one chunk fails, previous chunks are preserved.
+
+ Args:
+ faker_locale: Locale string for Faker (e.g., "en_US")
+ """
+ from faker import Faker
+
+ CHUNK_SIZE = 500
+ total = self.random_groups_count
+ fake = Faker(faker_locale)
+
+ _logger.info("[volume-job] Starting: %d groups in chunks of %d", total, CHUNK_SIZE)
+
+ stats = {
+ "random_groups_created": 0,
+ "random_individuals_created": 0,
+ }
+
+ offset = 0
+ while offset < total:
+ chunk_count = min(CHUNK_SIZE, total - offset)
+ chunk_stats = {
+ "random_groups_created": 0,
+ "random_individuals_created": 0,
+ }
+
+ # Temporarily set random_groups_count for the chunk
+ original_count = self.random_groups_count
+ self.random_groups_count = chunk_count
+ try:
+ self._generate_random_groups(fake, chunk_stats)
+ finally:
+ self.random_groups_count = original_count
+
+ stats["random_groups_created"] += chunk_stats["random_groups_created"]
+ stats["random_individuals_created"] += chunk_stats["random_individuals_created"]
+
+ # Commit this chunk so it's durable
+ # Skip commit in test mode to avoid breaking test isolation
+ if not config["test_enable"]:
+ self.env.cr.commit() # pylint: disable=invalid-commit
+
+ offset += chunk_count
+ _logger.info(
+ "[volume-job] Chunk committed: %d/%d groups (%d individuals so far)",
+ stats["random_groups_created"],
+ total,
+ stats["random_individuals_created"],
+ )
+
+ _logger.info(
+ "[volume-job] All groups created: %d groups, %d individuals",
+ stats["random_groups_created"],
+ stats["random_individuals_created"],
+ )
+
+ # Assign areas and generate coordinates (also in a committed step)
+ if self.load_geographic_data:
+ _logger.info("[volume-job] Assigning areas...")
+ self._assign_registrant_areas(stats)
+ self.env.cr.commit() # pylint: disable=invalid-commit
+
+ _logger.info("[volume-job] Generating coordinates...")
+ self._generate_coordinates(stats)
+ self.env.cr.commit() # pylint: disable=invalid-commit
+
+ # Refresh GIS reports
+ self._ensure_debug_gis_layers(stats)
+ self._refresh_gis_reports(stats)
+ self.env.cr.commit() # pylint: disable=invalid-commit
+
+ _logger.info("[volume-job] Complete: %s", stats)
+
+ def _show_volume_dispatched_notification(self):
+ """Return notification that volume generation was dispatched to background."""
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": _("Demo Data Generation Started"),
+ "message": _(
+ "Programs and stories created. Volume generation of %(count)d"
+ " households has been dispatched to a background job."
+ " Check the job queue for progress.",
+ count=self.random_groups_count,
+ ),
+ "type": "info",
+ "sticky": True,
+ },
+ }
+
+ def _ensure_debug_gis_layers(self, stats):
+ """Ensure optional debug GIS layers exist after demo generation."""
+ try:
+ from .indicator_providers import _ensure_household_points_debug_layer
+
+ layer = _ensure_household_points_debug_layer(self.env)
+ stats["debug_hh_points_layer_ready"] = bool(layer)
+ except Exception as e:
+ # Debug layer should never block demo generation.
+ _logger.warning("Could not ensure debug GIS layers: %s", e)
+ stats["debug_hh_points_layer_ready"] = False
+
def _run_generation_steps(self, stats):
- """Execute all generation steps. Returns (demo_locale, created_data)."""
+ """Execute all generation steps.
+
+ Returns either:
+ - (demo_locale, created_data) tuple for synchronous generation, or
+ - a client action dict when large-volume household generation has been
+ dispatched to a background job (the remaining steps then run inside
+ that job).
+ """
# Resolve country configuration (locale, currency)
country_cfg = self._get_country_config()
demo_locale = country_cfg["locale"]
@@ -485,9 +755,22 @@ def _run_generation_steps(self, stats):
if stories_created:
_logger.info("Auto-generated %d demo story registrants", stories_created)
- # Step 0.75: Generate deterministic households from blueprints
+ # Step 0.75: Generate households.
+ #
+ # Two strategies are supported:
+ # 1. Random/faker volume generation (Luzon-style GIS demo): when
+ # ``generate_random_groups`` is enabled with a positive count. For
+ # large volumes this can be dispatched to a background job.
+ # 2. Deterministic blueprint generation: the default, when only
+ # ``generate_volume`` is enabled.
volume_households = []
- if self.generate_volume:
+ generator = None
+ if self.generate_random_groups and self.random_groups_count > 0:
+ if self._generate_random_volume(stats):
+ # Dispatched to a background job; the remaining steps run there.
+ return self._show_volume_dispatched_notification()
+ elif self.generate_volume:
+ # Deterministic blueprint generation.
from .household_blueprints import HOUSEHOLD_BLUEPRINTS
from .seeded_volume_generator import SeededVolumeGenerator
@@ -513,14 +796,7 @@ def _run_generation_steps(self, stats):
# 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)
+ self._enroll_blueprint_households(generator, volume_households, created_data["programs"])
# Step 4: Create cycles
if self.create_cycles:
@@ -568,10 +844,13 @@ def _run_generation_steps(self, stats):
_logger.info("Generating GPS coordinates for registrants...")
self._generate_coordinates(stats)
- # Step 12: Refresh GIS reports so map data is available immediately
+ # Step 12: Ensure debug GIS layers are present (idempotent)
+ self._ensure_debug_gis_layers(stats)
+
+ # Step 13: Refresh GIS reports so map data is available immediately
self._refresh_gis_reports(stats)
- # Step 13: Create PRISM API client with known credentials
+ # Step 14: Create PRISM API client with known credentials
self._create_prism_api_client(stats)
return demo_locale, created_data
@@ -1040,6 +1319,278 @@ def _create_individual_member(self, member_data, registration_date):
return member
+ def _generate_random_groups(self, fake, stats):
+ """Generate random groups/households with members.
+
+ Uses batch ORM create() for groups and individuals, which is
+ significantly faster than per-record create() calls. Each batch
+ of BATCH_SIZE groups is created together, then their individuals
+ are batch-created and linked via memberships.
+ """
+ BATCH_SIZE = 100
+
+ try:
+ from odoo.addons.spp_demo.models import demo_stories
+
+ reserved_names = set(demo_stories.RESERVED_NAMES)
+ except ImportError:
+ reserved_names = set()
+
+ groups_created = []
+ head_membership_type = self.env["spp.vocabulary.code"].get_code(
+ "urn:openspp:vocab:group-membership-type", "head"
+ )
+ head_type_id = head_membership_type.id if head_membership_type else False
+
+ # Cache gender IDs upfront (avoids ORM search per individual)
+ DemoGenerator = self.env["spp.demo.data.generator"].with_context(tracking_disable=True)
+ gender_id_cache = {
+ "male": DemoGenerator.lookup_gender_id("Male"),
+ "female": DemoGenerator.lookup_gender_id("Female"),
+ }
+
+ Partner = self.env["res.partner"].with_context(tracking_disable=True)
+ today_year = fields.Date.today().year
+
+ # Collect all create_date updates for batch SQL at the end
+ backdate_updates = [] # list of (date, partner_id)
+
+ # Process groups in batches for batch ORM create()
+ pending_groups = [] # list of (group_vals, registration_date, member_specs)
+ # member_specs: list of (individual_vals, is_head)
+
+ for _i in range(self.random_groups_count):
+ 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}"
+
+ if head_name in reserved_names:
+ continue
+
+ head_age = random.randint(25, 65)
+ registration_date = fake.date_between(start_date="-365d", end_date="-30d")
+
+ group_vals = {
+ "name": head_last,
+ "is_registrant": True,
+ "is_group": True,
+ }
+
+ member_specs = []
+
+ # Head of household
+ head_vals = self._individual_vals(head_name, head_gender, head_age, gender_id_cache, today_year)
+ member_specs.append((head_vals, True))
+
+ # 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_vals = self._individual_vals(
+ spouse_name, spouse_gender, spouse_age, gender_id_cache, today_year
+ )
+ member_specs.append((spouse_vals, False))
+ num_members -= 1
+
+ # Children / other members
+ for _j in range(num_members):
+ age_roll = random.random()
+ if age_roll < 0.15:
+ member_age = random.randint(0, 4)
+ elif age_roll < 0.45:
+ member_age = random.randint(5, 17)
+ elif age_roll < 0.85:
+ member_age = random.randint(18, 59)
+ else:
+ member_age = 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:
+ m_vals = self._individual_vals(member_name, member_gender, member_age, gender_id_cache, today_year)
+ member_specs.append((m_vals, False))
+
+ pending_groups.append((group_vals, registration_date, member_specs))
+
+ # Flush batch
+ if len(pending_groups) >= BATCH_SIZE:
+ batch_result = self._flush_group_batch(Partner, pending_groups, head_type_id, backdate_updates)
+ groups_created.extend(batch_result["groups"])
+ stats["random_groups_created"] += batch_result["group_count"]
+ stats["random_individuals_created"] += batch_result["individual_count"]
+ pending_groups = []
+
+ if stats["random_groups_created"] % 500 == 0:
+ _logger.info(
+ "Generated %d/%d groups...",
+ stats["random_groups_created"],
+ self.random_groups_count,
+ )
+
+ # Flush remaining
+ if pending_groups:
+ batch_result = self._flush_group_batch(Partner, pending_groups, head_type_id, backdate_updates)
+ groups_created.extend(batch_result["groups"])
+ stats["random_groups_created"] += batch_result["group_count"]
+ stats["random_individuals_created"] += batch_result["individual_count"]
+
+ # Batch-update create_dates via raw SQL
+ if backdate_updates:
+ self.env.cr.executemany(
+ "UPDATE res_partner SET create_date = %s WHERE id = %s",
+ backdate_updates,
+ )
+
+ _logger.info(
+ "Created %s random groups with %s individuals",
+ stats["random_groups_created"],
+ stats["random_individuals_created"],
+ )
+ return groups_created
+
+ def _individual_vals(self, name, gender, age, gender_id_cache, today_year):
+ """Build a res.partner vals dict for an individual (no ORM calls)."""
+ name_parts = name.split(" ", 1)
+ given_name = name_parts[0]
+ family_name = name_parts[1] if len(name_parts) > 1 else ""
+
+ name_formatted = [
+ f"{family_name}," if family_name and given_name else family_name or "",
+ given_name,
+ ]
+ computed_name = " ".join(filter(None, name_formatted)).upper()
+
+ birth_year = today_year - age
+ birth_month = random.randint(1, 12)
+ birth_day = random.randint(1, 28)
+ today = fields.Date.today()
+ birthdate = datetime.date(birth_year, birth_month, birth_day)
+ # Ensure birthdate is not in the future (matters for age 0-1)
+ if birthdate > today:
+ birthdate = today
+
+ vals = {
+ "name": computed_name,
+ "family_name": family_name,
+ "given_name": given_name,
+ "is_registrant": True,
+ "is_group": False,
+ "gender_id": gender_id_cache.get(gender, False),
+ "birthdate": birthdate,
+ }
+
+ # Income for adults
+ if age >= 18:
+ income_tier = random.random()
+ if income_tier < 0.70:
+ vals["income"] = float(random.randint(500, 2000))
+ elif income_tier < 0.95:
+ vals["income"] = float(random.randint(2000, 4000))
+ else:
+ vals["income"] = float(random.randint(4000, 8000))
+
+ return vals
+
+ def _flush_group_batch(self, Partner, pending_groups, head_type_id, backdate_updates):
+ """Batch-create a set of groups with their individuals and memberships.
+
+ Args:
+ Partner: res.partner model (with tracking_disable context)
+ pending_groups: list of (group_vals, registration_date, member_specs)
+ head_type_id: ID of the "head" membership type vocabulary code
+ backdate_updates: list to append (date, partner_id) tuples to
+
+ Returns:
+ dict with groups (recordset), group_count, individual_count
+ """
+ # 1. Batch-create all groups
+ group_vals_list = [g[0] for g in pending_groups]
+ groups = Partner.create(group_vals_list)
+
+ # 2. Batch-create all individuals across all groups
+ all_individual_vals = []
+ # Track which individuals belong to which group and head status
+ # Each entry: (group_index_in_batch, is_head)
+ individual_mapping = []
+
+ for batch_idx, (_gv, _reg_date, member_specs) in enumerate(pending_groups):
+ for indiv_vals, is_head in member_specs:
+ all_individual_vals.append(indiv_vals)
+ individual_mapping.append((batch_idx, is_head))
+
+ individuals = Partner.create(all_individual_vals) if all_individual_vals else Partner.browse()
+
+ # 3. Build membership records and backdate updates
+ membership_vals = []
+ for indiv_idx, individual in enumerate(individuals):
+ batch_idx, is_head = individual_mapping[indiv_idx]
+ group = groups[batch_idx]
+ reg_date = pending_groups[batch_idx][1]
+
+ backdate_updates.append((reg_date, individual.id))
+
+ m_vals = {"group": group.id, "individual": individual.id}
+ if is_head and head_type_id:
+ m_vals["membership_type_ids"] = [Command.link(head_type_id)]
+ membership_vals.append(m_vals)
+
+ # Backdate groups too
+ for batch_idx, group in enumerate(groups):
+ reg_date = pending_groups[batch_idx][1]
+ backdate_updates.append((reg_date, group.id))
+
+ # 4. Batch-create all memberships
+ if membership_vals:
+ self.env["spp.group.membership"].with_context(tracking_disable=True).create(membership_vals)
+
+ return {
+ "groups": groups,
+ "group_count": len(groups),
+ "individual_count": len(individuals),
+ }
+
+ 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"].with_context(tracking_disable=True)
+ individual = DemoGenerator.create_individual_from_params(name, gender, age, extra_vals)
+
+ return individual
+
def _create_demo_programs(self, stats):
"""Create demo programs from definitions."""
created_programs = []
@@ -3847,6 +4398,7 @@ def _load_geographic_data(self, stats):
Uses the DemoAreaLoader from spp_demo to load country-specific
area hierarchies with GIS polygon data for spatial queries.
+ If spp_demo_phl_luzon is installed, also loads full Luzon data.
Args:
stats: Statistics dictionary to update
@@ -3862,82 +4414,168 @@ def _load_geographic_data(self, stats):
self.country_code,
result.get("shapes_loaded", 0),
)
- return result
except Exception as e:
_logger.warning("[spp.mis.demo] Failed to load geographic data: %s", e)
return None
+ # Load extended Luzon areas if the module is installed
+ if "spp.demo.luzon.area.loader" in self.env:
+ try:
+ luzon_result = self.env["spp.demo.luzon.area.loader"].load_luzon_areas(load_shapes=True)
+ _logger.info(
+ "[spp.mis.demo] Loaded Luzon geodata: %d areas, %d shapes",
+ luzon_result.get("areas_created", 0),
+ luzon_result.get("shapes_loaded", 0),
+ )
+ except Exception as e:
+ _logger.warning("[spp.mis.demo] Failed to load Luzon geodata: %s", e)
+
+ return result
+
+ def _populate_area_metadata(self, municipalities, weights_by_area_id):
+ """Populate area_sqkm and population on spp.area records.
+
+ GIS report normalization (per_area_sqkm, per_population) needs these
+ fields filled in. area_sqkm is computed from the geo_polygon geometry,
+ population is copied from the demo population weights CSV, then
+ both are rolled up to parent levels (province, region).
+
+ Args:
+ municipalities: spp.area recordset with geo_polygon
+ weights_by_area_id: dict mapping area_id to population count, or None
+ """
+ # Compute area_sqkm from geometry for ALL areas missing it (including parents)
+ self.env.cr.execute(
+ """
+ UPDATE spp_area
+ SET area_sqkm = ST_Area(geo_polygon::geography) / 1000000.0
+ WHERE (area_sqkm IS NULL OR area_sqkm = 0)
+ AND geo_polygon IS NOT NULL
+ """,
+ )
+ updated_sqkm = self.env.cr.rowcount
+ if updated_sqkm:
+ _logger.info("[spp.mis.demo] Computed area_sqkm for %d areas", updated_sqkm)
+
+ # Copy population from weights CSV into spp_area.population
+ if weights_by_area_id:
+ pop_updates = [
+ (pop, area_id) for area_id, pop in weights_by_area_id.items() if area_id in municipalities._ids
+ ]
+ if pop_updates:
+ self.env.cr.executemany(
+ "UPDATE spp_area SET population = %s WHERE id = %s AND (population IS NULL OR population = 0)",
+ pop_updates,
+ )
+ _logger.info("[spp.mis.demo] Set population for %d municipalities", len(pop_updates))
+
+ # Roll up population to parent areas (province, region)
+ self.env.cr.execute(
+ """
+ UPDATE spp_area parent
+ SET population = sub.total_pop
+ FROM (
+ SELECT child.parent_id as pid, SUM(child.population) as total_pop
+ FROM spp_area child
+ WHERE child.population > 0 AND child.parent_id IS NOT NULL
+ GROUP BY child.parent_id
+ ) sub
+ WHERE parent.id = sub.pid
+ AND (parent.population IS NULL OR parent.population = 0)
+ """,
+ )
+ rolled = self.env.cr.rowcount
+ if rolled:
+ _logger.info("[spp.mis.demo] Rolled up population to %d parent areas", rolled)
+ # Second pass for grandparent level (regions)
+ self.env.cr.execute(
+ """
+ UPDATE spp_area parent
+ SET population = sub.total_pop
+ FROM (
+ SELECT child.parent_id as pid, SUM(child.population) as total_pop
+ FROM spp_area child
+ WHERE child.population > 0 AND child.parent_id IS NOT NULL
+ GROUP BY child.parent_id
+ ) sub
+ WHERE parent.id = sub.pid
+ AND (parent.population IS NULL OR parent.population = 0)
+ """,
+ )
+
+ self.env.invalidate_all()
+
# Locale-aware area assignments for story registrants.
# Each story gets a specific area per country for consistent demo scenarios.
# Keys: story_id -> {locale: area_xmlid}
STORY_AREA_MAP = {
"juan_dela_cruz": {
- "fil_PH": "spp_demo.area_phl_calamba",
+ "fil_PH": "spp_demo.area_phl_ph0403405", # Calamba
"fr_TG": "spp_demo.area_tgo_lome_tokoin",
"si_LK": "spp_demo.area_lka_moratuwa",
},
"maria_santos": {
- "fil_PH": "spp_demo.area_phl_santa_rosa",
+ "fil_PH": "spp_demo.area_phl_ph0403428", # Santa Rosa
"fr_TG": "spp_demo.area_tgo_aflao",
"si_LK": "spp_demo.area_lka_kolonnawa",
},
"jose_reyes_multigenerational": {
- "fil_PH": "spp_demo.area_phl_san_pablo",
+ "fil_PH": "spp_demo.area_phl_ph0403424", # San Pablo
"fr_TG": "spp_demo.area_tgo_kpalime",
"si_LK": "spp_demo.area_lka_kandy_ds",
},
"ibrahim_hassan": {
- "fil_PH": "spp_demo.area_phl_antipolo",
+ "fil_PH": "spp_demo.area_phl_ph0405802", # Antipolo
"fr_TG": "spp_demo.area_tgo_sokode",
"si_LK": "spp_demo.area_lka_galle_ds",
},
"david_sofia_martinez": {
- "fil_PH": "spp_demo.area_phl_makati",
+ "fil_PH": "spp_demo.area_phl_ph1307602", # Makati
"fr_TG": "spp_demo.area_tgo_lome",
"si_LK": "spp_demo.area_lka_dehiwala",
},
"rosa_garcia": {
- "fil_PH": "spp_demo.area_phl_quezon_city",
+ "fil_PH": "spp_demo.area_phl_ph1307404", # Quezon City
"fr_TG": "spp_demo.area_tgo_lome_be",
"si_LK": "spp_demo.area_lka_colombo_fort",
},
"mary_johnson": {
- "fil_PH": "spp_demo.area_phl_pasig",
+ "fil_PH": "spp_demo.area_phl_ph1307403", # Pasig
"fr_TG": "spp_demo.area_tgo_lome_nyekonakpoe",
"si_LK": "spp_demo.area_lka_colombo_pettah",
},
"ahmed_said": {
- "fil_PH": "spp_demo.area_phl_taguig",
+ "fil_PH": "spp_demo.area_phl_ph1307607", # Taguig
"fr_TG": "spp_demo.area_tgo_lome_adidogome",
"si_LK": "spp_demo.area_lka_dehiwala_gn",
},
"nguyen_extended_family": {
- "fil_PH": "spp_demo.area_phl_bacoor",
+ "fil_PH": "spp_demo.area_phl_ph0402103", # Bacoor
"fr_TG": "spp_demo.area_tgo_baguida_centre",
"si_LK": "spp_demo.area_lka_hikkaduwa",
},
"amina_osman_household": {
- "fil_PH": "spp_demo.area_phl_manila",
+ "fil_PH": "spp_demo.area_phl_ph1303901", # City of Manila
"fr_TG": "spp_demo.area_tgo_kpalime_centre",
"si_LK": "spp_demo.area_lka_mount_lavinia_gn",
},
"carlos_elena_morales": {
- "fil_PH": "spp_demo.area_phl_dasmarinas",
+ "fil_PH": "spp_demo.area_phl_ph0402106", # DasmariƱas
"fr_TG": "spp_demo.area_tgo_kpalime_tove",
"si_LK": "spp_demo.area_lka_galle_fort",
},
"chen_large_family": {
- "fil_PH": "spp_demo.area_phl_qc_commonwealth",
+ "fil_PH": "spp_demo.area_phl_ph1307404", # Quezon City (Commonwealth brgy in curated data)
"fr_TG": "spp_demo.area_tgo_zio",
"si_LK": "spp_demo.area_lka_gampaha",
},
"grace_okonkwo": {
- "fil_PH": "spp_demo.area_phl_makati_poblacion",
+ "fil_PH": "spp_demo.area_phl_ph1307602", # Makati (Poblacion brgy in curated data)
"fr_TG": "spp_demo.area_tgo_ogou",
"si_LK": "spp_demo.area_lka_kalutara",
},
"luis_fernandez": {
- "fil_PH": "spp_demo.area_phl_calamba_real",
+ "fil_PH": "spp_demo.area_phl_ph0403405", # Calamba (Real brgy in curated data)
"fr_TG": "spp_demo.area_tgo_lacs",
"si_LK": "spp_demo.area_lka_matara",
},
@@ -3947,9 +4585,11 @@ def _assign_registrant_areas(self, stats):
"""Assign geographic areas to registrants.
Strategy:
- 1. Assign specific areas to story registrants (locale-aware)
- 2. Assign random municipalities to remaining groups
- 3. Individual members inherit area_id from their group
+ 1. Find the lowest (most specific) area level that has geo_polygon data
+ 2. Assign specific areas to story registrants (locale-aware)
+ 3. Assign one of those areas (population-weighted when available) to each
+ remaining group
+ 4. Individual members inherit area_id from their group
Args:
stats: Statistics dictionary to update
@@ -3957,19 +4597,38 @@ def _assign_registrant_areas(self, stats):
Area = self.env["spp.area"]
Partner = self.env["res.partner"]
- # Get all level 3 areas (municipalities) that have geo_polygon data
- municipalities = Area.search([("area_level", "=", 3), ("geo_polygon", "!=", False)])
+ # Find the lowest (most specific) area level that has geo_polygon data.
+ # This works for any hierarchy depth: 4-level (curated) or 3-level (Luzon).
+ # Flush to ensure computed area_level values are written to DB before raw SQL.
+ self.env.flush_all()
+ self.env.cr.execute("SELECT MAX(area_level) FROM spp_area WHERE geo_polygon IS NOT NULL")
+ result = self.env.cr.fetchone()
+ target_level = result[0] if result and result[0] is not None else None
- if not municipalities:
+ if target_level is None:
# Fall back to any level 3 areas even without polygons
municipalities = Area.search([("area_level", "=", 3)])
+ if not municipalities:
+ _logger.warning("[spp.mis.demo] No areas with GIS data found, skipping area assignment")
+ stats["areas_assigned"] = 0
+ return
+ _logger.info(
+ "[spp.mis.demo] Found %d municipalities for area assignment (no GIS polygons)",
+ len(municipalities),
+ )
+ else:
+ municipalities = Area.search([("area_level", "=", target_level), ("geo_polygon", "!=", False)])
- if not municipalities:
- _logger.warning("[spp.mis.demo] No municipalities found, skipping area assignment")
- stats["areas_assigned"] = 0
- return
+ if not municipalities:
+ _logger.warning("[spp.mis.demo] No areas with GIS data found at level %s", target_level)
+ stats["areas_assigned"] = 0
+ return
- _logger.info("[spp.mis.demo] Found %d municipalities for area assignment", len(municipalities))
+ _logger.info(
+ "[spp.mis.demo] Found %d areas with GIS data at level %d",
+ len(municipalities),
+ target_level,
+ )
# Step 1: Assign specific areas to story registrants
locale = self.env.context.get("demo_locale", "fil_PH")
@@ -3989,19 +4648,78 @@ def _assign_registrant_areas(self, stats):
stats["areas_assigned"] = story_assigned
return
- # Assign random municipality to each remaining group
- groups_assigned = story_assigned
- for group in groups:
- municipality = random.choice(municipalities)
- group.write({"area_id": municipality.id})
- groups_assigned += 1
+ # Check if population weights are available (spp_demo_phl_luzon installed)
+ weights_by_area_id = None
+ if "spp.demo.population.weights" in self.env:
+ try:
+ weights_by_area_id = self.env["spp.demo.population.weights"].get_weights_by_area_id()
+ except Exception as e:
+ _logger.warning("[spp.mis.demo] Could not load population weights: %s", e)
+
+ # Populate area metadata (area_sqkm from geometry, population from weights)
+ # so that GIS report normalization (per_area_sqkm, per_population) works correctly.
+ self._populate_area_metadata(municipalities, weights_by_area_id)
+
+ # Build weighted selection if population data available
+ muni_ids = municipalities.ids
+ if weights_by_area_id:
+ weights = [weights_by_area_id.get(mid, 1) for mid in muni_ids]
+ _logger.info("[spp.mis.demo] Using population-weighted area assignment")
+ else:
+ weights = None
+
+ # Pre-assign areas for all groups at once.
+ # Guarantee a minimum floor per area so no municipality ends up with zero groups
+ # (which would show as "No Data" gray on the map).
+ MIN_PER_AREA = 2
+ num_groups = len(groups)
+ floor_total = MIN_PER_AREA * len(muni_ids)
+
+ if num_groups >= floor_total:
+ # Assign minimum floor to every municipality, then distribute the rest by weight
+ assigned_ids = muni_ids * MIN_PER_AREA # each muni gets MIN_PER_AREA groups
+ remaining = num_groups - floor_total
+ if remaining > 0:
+ if weights:
+ assigned_ids += random.choices(muni_ids, weights=weights, k=remaining)
+ else:
+ assigned_ids += [random.choice(muni_ids) for _ in range(remaining)]
+ random.shuffle(assigned_ids)
+ elif weights:
+ assigned_ids = random.choices(muni_ids, weights=weights, k=num_groups)
+ else:
+ assigned_ids = [random.choice(muni_ids) for _ in range(num_groups)]
+
+ # Batch-assign areas to groups via raw SQL (avoids N+1 ORM writes)
+ group_area_pairs = list(zip(groups.ids, assigned_ids, strict=False))
+ if group_area_pairs:
+ self.env.cr.executemany(
+ "UPDATE res_partner SET area_id = %s WHERE id = %s",
+ [(area_id, group_id) for group_id, area_id in group_area_pairs],
+ )
+ _logger.info("[spp.mis.demo] Batch-assigned areas to %d groups", len(group_area_pairs))
+
+ # Propagate area_id to individual members via a single UPDATE + JOIN
+ self.env.cr.execute(
+ """
+ UPDATE res_partner p
+ SET area_id = g.area_id
+ FROM spp_group_membership gm
+ JOIN res_partner g ON g.id = gm."group"
+ WHERE gm.individual = p.id
+ AND gm.is_ended = false
+ AND g.is_group = true
+ AND g.area_id IS NOT NULL
+ """
+ )
+ members_updated = self.env.cr.rowcount
+ _logger.info("[spp.mis.demo] Propagated area_id to %d individual members", members_updated)
- # Members inherit area from group
- members = Partner.search([("group_membership_ids.group", "=", group.id)])
- if members:
- members.write({"area_id": municipality.id})
+ # Invalidate ORM cache after raw SQL updates
+ self.env.invalidate_all()
# Also assign areas to standalone individuals without area_id
+ # (registrants not attached to any group/household).
individuals = Partner.search(
[
("is_group", "=", False),
@@ -4014,8 +4732,13 @@ def _assign_registrant_areas(self, stats):
municipality = random.choice(municipalities)
ind.write({"area_id": municipality.id})
+ groups_assigned = story_assigned + len(group_area_pairs)
stats["areas_assigned"] = groups_assigned
- _logger.info("[spp.mis.demo] Assigned areas to %d groups (%d story-specific)", groups_assigned, story_assigned)
+ _logger.info(
+ "[spp.mis.demo] Assigned areas to %d groups (%d story-specific)",
+ groups_assigned,
+ story_assigned,
+ )
def _assign_story_areas(self, locale):
"""Assign locale-specific areas to story registrants.
@@ -4083,7 +4806,7 @@ def _generate_coordinates(self, stats):
generates a random point within the area polygon and sets
the coordinates field (if spp_registrant_gis is installed).
- Uses shapely to generate random points within polygons.
+ Processes in chunks to avoid excessive memory usage at scale (50K+).
Args:
stats: Statistics dictionary to update
@@ -4104,76 +4827,92 @@ def _generate_coordinates(self, stats):
Partner = self.env["res.partner"]
Area = self.env["spp.area"]
- # Get all registrants with an area_id
- registrants = Partner.search(
- [
- ("is_registrant", "=", True),
- ("area_id", "!=", False),
- ]
- )
+ # Count total registrants with area_id
+ total_count = Partner.search_count([("is_registrant", "=", True), ("area_id", "!=", False)])
- if not registrants:
+ if not total_count:
_logger.warning("[spp.mis.demo] No registrants with areas found")
stats["coordinates_generated"] = 0
return
- _logger.info("[spp.mis.demo] Generating coordinates for %d registrants", len(registrants))
+ _logger.info("[spp.mis.demo] Generating coordinates for %d registrants", total_count)
+ # Cache polygon data by area_id to avoid repeated reads
+ polygon_cache = {}
coordinates_generated = 0
+ CHUNK_SIZE = 2000
+
+ # Process registrants in chunks to limit memory usage
+ offset = 0
+ while offset < total_count:
+ registrants = Partner.search(
+ [("is_registrant", "=", True), ("area_id", "!=", False)],
+ limit=CHUNK_SIZE,
+ offset=offset,
+ )
+ if not registrants:
+ break
- # Group registrants by area to minimize queries
- registrants_by_area = {}
- for registrant in registrants:
- area_id = registrant.area_id.id
- if area_id not in registrants_by_area:
- registrants_by_area[area_id] = []
- registrants_by_area[area_id].append(registrant)
+ # Group this chunk by area
+ registrants_by_area = {}
+ for registrant in registrants:
+ area_id = registrant.area_id.id
+ if area_id not in registrants_by_area:
+ registrants_by_area[area_id] = []
+ registrants_by_area[area_id].append(registrant)
+
+ # Batch collect coordinate writes
+ write_batch = [] # list of (registrant_id, wkt_point)
+
+ for area_id, area_registrants in registrants_by_area.items():
+ # Get or cache polygon
+ if area_id not in polygon_cache:
+ area = Area.browse(area_id)
+ polygon_cache[area_id] = area.geo_polygon if area.geo_polygon else None
+
+ polygon = polygon_cache[area_id]
+ if polygon is None:
+ continue
- # Process each area
- for area_id, area_registrants in registrants_by_area.items():
- area = Area.browse(area_id)
+ try:
+ minx, miny, maxx, maxy = polygon.bounds
+
+ for registrant in area_registrants:
+ max_attempts = 10
+ for _attempt in range(max_attempts):
+ point_x = random.uniform(minx, maxx)
+ point_y = random.uniform(miny, maxy)
+ point = shape({"type": "Point", "coordinates": [point_x, point_y]})
+
+ if polygon.contains(point):
+ write_batch.append((registrant.id, f"POINT({point_x} {point_y})"))
+ break
+ else:
+ centroid = polygon.centroid
+ write_batch.append((registrant.id, f"POINT({centroid.x} {centroid.y})"))
- # Skip if no polygon data
- if not area.geo_polygon:
- continue
+ except Exception as e:
+ _logger.warning("[spp.mis.demo] Failed to generate coordinates for area %s: %s", area_id, e)
+ continue
- try:
- # The ORM returns geo_polygon as a Shapely geometry object
- polygon = area.geo_polygon
-
- # Generate random points for all registrants in this area
- minx, miny, maxx, maxy = polygon.bounds
-
- for registrant in area_registrants:
- # Generate random point within bounding box, retry if outside polygon
- max_attempts = 10
- for _attempt in range(max_attempts):
- point_x = random.uniform(minx, maxx)
- point_y = random.uniform(miny, maxy)
- point = shape({"type": "Point", "coordinates": [point_x, point_y]})
-
- if polygon.contains(point):
- # Set the coordinates field (GeoPointField expects WKB)
- registrant.write(
- {
- "coordinates": f"POINT({point_x} {point_y})",
- }
- )
- coordinates_generated += 1
- break
- else:
- # If we couldn't find a point inside after max_attempts, use centroid
- centroid = polygon.centroid
- registrant.write(
- {
- "coordinates": f"POINT({centroid.x} {centroid.y})",
- }
- )
- coordinates_generated += 1
+ # Batch write coordinates via raw SQL (much faster than ORM one-by-one)
+ if write_batch:
+ self.env.cr.executemany(
+ "UPDATE res_partner SET coordinates = ST_GeomFromText(%s, 4326) WHERE id = %s",
+ [(wkt_point, partner_id) for partner_id, wkt_point in write_batch],
+ )
+ coordinates_generated += len(write_batch)
- except Exception as e:
- _logger.warning("[spp.mis.demo] Failed to generate coordinates for area %s: %s", area.id, e)
- continue
+ offset += CHUNK_SIZE
+ _logger.info(
+ "[spp.mis.demo] Coordinate progress: %d / %d",
+ coordinates_generated,
+ total_count,
+ )
+
+ # Invalidate ORM cache after raw SQL coordinate updates
+ if coordinates_generated:
+ self.env.invalidate_all()
stats["coordinates_generated"] = coordinates_generated
_logger.info("[spp.mis.demo] Generated coordinates for %d registrants", coordinates_generated)
diff --git a/spp_mis_demo_v2/readme/HISTORY.md b/spp_mis_demo_v2/readme/HISTORY.md
index 4aaf9afef..78b4cc7e7 100644
--- a/spp_mis_demo_v2/readme/HISTORY.md
+++ b/spp_mis_demo_v2/readme/HISTORY.md
@@ -1,3 +1,7 @@
+### 19.0.2.1.0
+
+- feat: demo GIS reports, statistics, API client data and debug-layer coverage aligned with the GIS report disaggregation and OGC processes/incidents APIs (re-land from #76).
+
### 19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_mis_demo_v2/tests/__init__.py b/spp_mis_demo_v2/tests/__init__.py
index 0cca2b444..6fd04c3e3 100644
--- a/spp_mis_demo_v2/tests/__init__.py
+++ b/spp_mis_demo_v2/tests/__init__.py
@@ -7,6 +7,7 @@
from . import test_claim169_demo
from . import test_demo_programs
from . import test_formula_configuration
+from . import test_gis_debug_layer
from . import test_mis_demo_generator
from . import test_registry_variables
from . import test_demo_statistics
diff --git a/spp_mis_demo_v2/tests/test_demo_statistics.py b/spp_mis_demo_v2/tests/test_demo_statistics.py
index 1bf603776..df1a4f44d 100644
--- a/spp_mis_demo_v2/tests/test_demo_statistics.py
+++ b/spp_mis_demo_v2/tests/test_demo_statistics.py
@@ -23,17 +23,12 @@ def setUpClass(cls):
cls.required_stats = [
"total_households",
"total_members",
- "children_under_5",
- "children_under_18",
- "elderly_60_plus",
- "female_members",
- "male_members",
- "disabled_members",
+ "pwd_members",
"enrolled_any_program",
]
def test_all_demo_statistics_exist(self):
- """Verify all 9 demo statistics are in the database."""
+ """Verify all demo statistics are in the database."""
for stat_name in self.required_stats:
with self.subTest(statistic=stat_name):
stat = self.stat_model.search([("name", "=", stat_name)], limit=1)
@@ -72,7 +67,7 @@ def test_statistics_published_to_gis(self):
def test_statistics_have_valid_cel_accessors(self):
"""Verify statistics have variables with valid CEL accessors for aggregation."""
# Test a subset of statistics
- test_stats = ["total_households", "total_members", "children_under_5"]
+ test_stats = ["total_households", "total_members"]
for stat_name in test_stats:
with self.subTest(statistic=stat_name):
@@ -96,13 +91,8 @@ def test_statistics_categories_exist(self):
"demographics": [
"total_households",
"total_members",
- "children_under_5",
- "children_under_18",
- "elderly_60_plus",
- "female_members",
- "male_members",
],
- "vulnerability": ["disabled_members"],
+ "vulnerability": ["pwd_members"],
"programs": ["enrolled_any_program"],
}
diff --git a/spp_mis_demo_v2/tests/test_gis_debug_layer.py b/spp_mis_demo_v2/tests/test_gis_debug_layer.py
new file mode 100644
index 000000000..6d04ffba6
--- /dev/null
+++ b/spp_mis_demo_v2/tests/test_gis_debug_layer.py
@@ -0,0 +1,51 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Tests for MIS demo household-points debug GIS layer."""
+
+from odoo.tests import TransactionCase, tagged
+
+from odoo.addons.spp_mis_demo_v2.models.indicator_providers import (
+ DEBUG_HH_POINTS_LAYER_DOMAIN,
+ DEBUG_HH_POINTS_LAYER_NAME,
+ _ensure_household_points_debug_layer,
+)
+
+
+@tagged("post_install", "-at_install")
+class TestGISDebugLayer(TransactionCase):
+ """Validate HH points debug layer creation and defaults."""
+
+ def test_household_points_debug_layer_exists_and_disabled_on_startup(self):
+ """Post-init should provide HH points debug layer in a safe default state."""
+ layer = self.env["spp.gis.data.layer"].search(
+ [("name", "=", DEBUG_HH_POINTS_LAYER_NAME)],
+ limit=1,
+ )
+ self.assertTrue(layer, "Expected HH points debug layer to be created")
+ self.assertEqual(layer.geo_field_id.model, "res.partner")
+ self.assertEqual(layer.geo_field_id.name, "coordinates")
+ self.assertFalse(layer.active_on_startup, "Debug layer must be disabled by default")
+ self.assertEqual(layer.domain, DEBUG_HH_POINTS_LAYER_DOMAIN)
+
+ def test_household_points_debug_layer_setup_is_idempotent(self):
+ """Running helper repeatedly should update existing layer, not duplicate it."""
+ layer_model = self.env["spp.gis.data.layer"]
+ before_count = layer_model.search_count([("name", "=", DEBUG_HH_POINTS_LAYER_NAME)])
+
+ _ensure_household_points_debug_layer(self.env)
+
+ after_count = layer_model.search_count([("name", "=", DEBUG_HH_POINTS_LAYER_NAME)])
+ self.assertEqual(before_count, after_count)
+
+ def test_generator_reensures_household_points_debug_layer(self):
+ """Generator flow should re-ensure debug layer for --generate workflows."""
+ layer_model = self.env["spp.gis.data.layer"]
+ layer_model.search([("name", "=", DEBUG_HH_POINTS_LAYER_NAME)]).unlink()
+
+ generator = self.env["spp.mis.demo.generator"].create({"name": "GIS Debug Layer Ensure"})
+ stats = {}
+ generator._ensure_debug_gis_layers(stats)
+
+ layer = layer_model.search([("name", "=", DEBUG_HH_POINTS_LAYER_NAME)], limit=1)
+ self.assertTrue(layer)
+ self.assertFalse(layer.active_on_startup)
+ self.assertTrue(stats.get("debug_hh_points_layer_ready"))