diff --git a/spp_mis_demo_v2/__manifest__.py b/spp_mis_demo_v2/__manifest__.py
index e7acd270..f523755e 100644
--- a/spp_mis_demo_v2/__manifest__.py
+++ b/spp_mis_demo_v2/__manifest__.py
@@ -20,6 +20,14 @@
"spp_demo",
# GIS Reports for geographic visualization
"spp_gis_report",
+ # Registrant GPS coordinates for QGIS plugin demo
+ "spp_registrant_gis",
+ # Statistics and aggregation for demo indicators
+ "spp_statistic",
+ "spp_aggregation",
+ "spp_studio",
+ # NOTE: spp_api_v2_gis (GIS API for QGIS plugin) is a soft dependency.
+ # Install it separately to activate the 'gis' scopes on the demo API client.
# QR Credentials (Claim 169)
"spp_claim_169",
# Demo-specific extensions
@@ -38,6 +46,9 @@
"data/change_request_types.xml",
"data/demo_change_requests_ux.xml",
"data/demo_gis_reports.xml",
+ "data/demo_statistics.xml",
+ # NOTE: demo_api_client.xml requires spp_api_v2_gis (adds 'gis' resource).
+ # It is loaded conditionally in the post_init_hook when the module is available.
"views/mis_demo_wizard_view.xml",
],
"assets": {},
diff --git a/spp_mis_demo_v2/data/demo_api_client.xml b/spp_mis_demo_v2/data/demo_api_client.xml
new file mode 100644
index 00000000..72fb1dd6
--- /dev/null
+++ b/spp_mis_demo_v2/data/demo_api_client.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+ QGIS Demo Client
+ Demo API client for testing QGIS plugin integration. Grants read access to GIS layers, reports, and statistics queries.
+
+
+
+
+
+
+
+
+ gis
+ read
+ Read access to GIS layers, reports catalog, and spatial statistics queries.
+
+
+
+
+
+ gis
+ all
+ Full GIS access including creating and managing geofences.
+
+
diff --git a/spp_mis_demo_v2/data/demo_statistics.xml b/spp_mis_demo_v2/data/demo_statistics.xml
new file mode 100644
index 00000000..a59c977a
--- /dev/null
+++ b/spp_mis_demo_v2/data/demo_statistics.xml
@@ -0,0 +1,267 @@
+
+
+
+
+
+
+
+ demo_total_members
+ total_members
+ aggregate
+ count
+ members
+ true
+ number
+ group
+
+ active
+ 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
+ aggregate
+ count
+ members
+ m.disabled != null
+ number
+ group
+
+ active
+ 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
+ total_households
+ computed
+ true
+ number
+ group
+
+ active
+ 5
+
+
+
+
+ demo_enrolled_any_program
+ enrolled_any_program
+ computed
+ enrollments.exists(true)
+ number
+ group
+
+ active
+ 60
+
+
+
+
+
+
+ total_households
+ Total Households
+ Count of household groups in the selected area
+
+ count
+ households
+
+ 10
+
+
+
+
+
+
+
+ total_members
+ Total Members
+ Total count of individual household members
+
+ count
+ people
+
+ 15
+
+
+
+
+
+
+ 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
+
+ count
+ people
+
+ 50
+
+
+
+
+
+
+ enrolled_any_program
+ Enrolled (Any Program)
+ Count of households enrolled in at least one program
+
+ count
+ households
+
+ 60
+
+
+
+
diff --git a/spp_mis_demo_v2/models/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py
index 83efec58..e0a30b86 100644
--- a/spp_mis_demo_v2/models/mis_demo_generator.py
+++ b/spp_mis_demo_v2/models/mis_demo_generator.py
@@ -181,6 +181,23 @@ class SPPMISDemoGenerator(models.TransientModel):
help="Generate QR credentials for demo story personas (Maria Santos, etc.)",
)
+ # Geographic data options
+ load_geographic_data = fields.Boolean(
+ string="Load Geographic Data",
+ default=True,
+ help="Load area data with GIS shapes and assign GPS coordinates to registrants for QGIS plugin demo",
+ )
+ country_code = fields.Selection(
+ [
+ ("phl", "Philippines"),
+ ("lka", "Sri Lanka"),
+ ("tgo", "Togo"),
+ ],
+ string="Country",
+ default="phl",
+ help="Country for geographic data (areas and GIS shapes)",
+ )
+
# Locale settings
locale_origin = fields.Many2one(
"res.country",
@@ -256,6 +273,8 @@ def _onchange_demo_mode(self):
"case_volume_count": 10,
"generate_claim169_demo": True,
"generate_credentials_for_stories": True,
+ "load_geographic_data": True,
+ "country_code": "phl",
},
"training": {
"create_demo_programs": True,
@@ -278,6 +297,8 @@ def _onchange_demo_mode(self):
"case_volume_count": 25,
"generate_claim169_demo": True,
"generate_credentials_for_stories": True,
+ "load_geographic_data": True,
+ "country_code": "phl",
},
"testing": {
"create_demo_programs": True,
@@ -300,6 +321,8 @@ def _onchange_demo_mode(self):
"case_volume_count": 200,
"generate_claim169_demo": True,
"generate_credentials_for_stories": True,
+ "load_geographic_data": True,
+ "country_code": "phl",
},
"complete": {
"create_demo_programs": True,
@@ -322,6 +345,8 @@ def _onchange_demo_mode(self):
"case_volume_count": 50,
"generate_claim169_demo": True,
"generate_credentials_for_stories": True,
+ "load_geographic_data": True,
+ "country_code": "phl",
},
}
defaults = mode_defaults.get(self.demo_mode, mode_defaults["sales"])
@@ -453,6 +478,13 @@ def action_generate(self):
self._create_test_personas()
stats["test_personas_created"] = True
+ # Step 0.4: Load geographic data (if enabled)
+ if self.load_geographic_data:
+ _logger.info(f"Loading geographic data for {self.country_code}...")
+ geo_result = self._load_geographic_data(stats)
+ if geo_result:
+ stats["areas_loaded"] = geo_result.get("shapes_loaded", 0)
+
# Step 0.5: Ensure demo stories exist (auto-generate if needed)
stories_created = self._ensure_demo_stories_exist(stats)
if stories_created:
@@ -522,6 +554,16 @@ def action_generate(self):
_logger.info("Generating Claim 169 demo data...")
self._generate_claim169_demo(stats)
+ # Step 11: Assign areas and generate GPS coordinates (if geographic data loaded)
+ if self.load_geographic_data:
+ _logger.info("Assigning areas to registrants...")
+ self._assign_registrant_areas(stats)
+ _logger.info("Generating GPS coordinates for registrants...")
+ self._generate_coordinates(stats)
+
+ # Step 12: Refresh GIS reports so map data is available immediately
+ self._refresh_gis_reports(stats)
+
self.state = "completed"
# Return success notification with detailed summary
@@ -3618,6 +3660,9 @@ def _show_success_notification(self, stats):
if claim169_parts:
message_parts.append(_("QR Credentials: %s created") % ", ".join(claim169_parts))
+ # Geographic Data
+ self._append_geographic_summary(stats, message_parts)
+
# Warnings
if stats["missing_registrants"]:
message_parts.append("")
@@ -3660,6 +3705,216 @@ def _show_success_notification(self, stats):
},
}
+ def _append_geographic_summary(self, stats, message_parts):
+ """Append geographic data summary to notification message parts."""
+ if not self.load_geographic_data:
+ return
+ geo_parts = []
+ if stats.get("areas_loaded", 0) > 0:
+ geo_parts.append(_("%(count)s areas with GIS shapes", count=stats["areas_loaded"]))
+ if stats.get("areas_assigned", 0) > 0:
+ geo_parts.append(_("%(count)s groups assigned to areas", count=stats["areas_assigned"]))
+ if stats.get("coordinates_generated", 0) > 0:
+ geo_parts.append(_("%(count)s registrants with GPS coordinates", count=stats["coordinates_generated"]))
+ if geo_parts:
+ message_parts.append(_("Geographic Data: %s") % ", ".join(geo_parts))
+
+ def _load_geographic_data(self, stats):
+ """Load geographic area data with GIS shapes.
+
+ Uses the DemoAreaLoader from spp_demo to load country-specific
+ area hierarchies with GIS polygon data for spatial queries.
+
+ Args:
+ stats: Statistics dictionary to update
+
+ Returns:
+ dict: Result with counts of loaded data
+ """
+ try:
+ loader = self.env["spp.demo.area.loader"]
+ result = loader.load_country_areas(self.country_code, load_shapes=True)
+ _logger.info(
+ "[spp.mis.demo] Loaded geographic data for %s: %d areas with GIS shapes",
+ 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
+
+ def _assign_registrant_areas(self, stats):
+ """Assign geographic areas to registrants.
+
+ Strategy:
+ - Get all municipalities (level 3 areas) from the loaded country
+ - For each group, assign a random municipality to area_id
+ - Individual members inherit area_id from their group
+
+ Args:
+ stats: Statistics dictionary to update
+ """
+ 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)])
+
+ if not municipalities:
+ _logger.warning("[spp.mis.demo] No municipalities with GIS data found, skipping area assignment")
+ stats["areas_assigned"] = 0
+ return
+
+ _logger.info("[spp.mis.demo] Found %d municipalities with GIS data", len(municipalities))
+
+ # Get all groups (households)
+ groups = Partner.search([("is_group", "=", True), ("is_registrant", "=", True)])
+
+ if not groups:
+ _logger.warning("[spp.mis.demo] No groups found, skipping area assignment")
+ stats["areas_assigned"] = 0
+ return
+
+ # Assign random municipality to each group
+ groups_assigned = 0
+ for group in groups:
+ municipality = random.choice(municipalities)
+ group.write({"area_id": municipality.id})
+ groups_assigned += 1
+
+ # Members inherit area from group
+ members = Partner.search([("group_membership_ids.group", "=", group.id)])
+ if members:
+ members.write({"area_id": municipality.id})
+
+ stats["areas_assigned"] = groups_assigned
+ _logger.info("[spp.mis.demo] Assigned areas to %d groups", groups_assigned)
+
+ def _generate_coordinates(self, stats):
+ """Generate GPS coordinates for registrants.
+
+ For each registrant with an area_id that has geo_polygon data,
+ 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.
+
+ Args:
+ stats: Statistics dictionary to update
+ """
+ # Check if spp_registrant_gis is installed
+ if "coordinates" not in self.env["res.partner"]._fields:
+ _logger.info("[spp.mis.demo] spp_registrant_gis not installed, skipping coordinate generation")
+ stats["coordinates_generated"] = 0
+ return
+
+ try:
+ from shapely.geometry import shape
+ except ImportError:
+ _logger.warning("[spp.mis.demo] shapely not available, skipping coordinate generation")
+ stats["coordinates_generated"] = 0
+ return
+
+ 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),
+ ]
+ )
+
+ if not registrants:
+ _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))
+
+ coordinates_generated = 0
+
+ # 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)
+
+ # Process each area
+ for area_id, area_registrants in registrants_by_area.items():
+ area = Area.browse(area_id)
+
+ # Skip if no polygon data
+ if not area.geo_polygon:
+ 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
+
+ except Exception as e:
+ _logger.warning("[spp.mis.demo] Failed to generate coordinates for area %s: %s", area.id, e)
+ continue
+
+ stats["coordinates_generated"] = coordinates_generated
+ _logger.info("[spp.mis.demo] Generated coordinates for %d registrants", coordinates_generated)
+
+ def _refresh_gis_reports(self, stats):
+ """Refresh all active GIS reports so map data is available immediately."""
+ GISReport = self.env["spp.gis.report"]
+ reports = GISReport.search([("active", "=", True)])
+
+ if not reports:
+ _logger.info("[spp.mis.demo] No active GIS reports found to refresh")
+ stats["gis_reports_refreshed"] = 0
+ return
+
+ refreshed = 0
+ for report in reports:
+ try:
+ report._refresh_data()
+ refreshed += 1
+ _logger.info("[spp.mis.demo] Refreshed GIS report: %s", report.id)
+ except Exception:
+ _logger.exception("[spp.mis.demo] Failed to refresh GIS report: %s", report.id)
+
+ stats["gis_reports_refreshed"] = refreshed
+ _logger.info("[spp.mis.demo] Refreshed %d GIS reports", refreshed)
+
class SPPMISDemoWizard(models.TransientModel):
"""Wizard interface for MIS Demo Generator."""
diff --git a/spp_mis_demo_v2/tests/__init__.py b/spp_mis_demo_v2/tests/__init__.py
index 5205e81a..44ca7fb5 100644
--- a/spp_mis_demo_v2/tests/__init__.py
+++ b/spp_mis_demo_v2/tests/__init__.py
@@ -8,3 +8,4 @@
from . import test_formula_configuration
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
new file mode 100644
index 00000000..5d428879
--- /dev/null
+++ b/spp_mis_demo_v2/tests/test_demo_statistics.py
@@ -0,0 +1,140 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Test demo statistics configuration and accessibility.
+
+These tests verify that:
+1. All demo statistics are properly loaded into the database
+2. Each statistic has a valid CEL variable reference
+3. Statistics are published to GIS context
+4. Statistics can be computed via the aggregation service
+"""
+
+from odoo.tests.common import TransactionCase
+
+
+class TestDemoStatistics(TransactionCase):
+ """Test that demo statistics are properly loaded and accessible."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.stat_model = cls.env["spp.statistic"]
+
+ # Required statistics that should be in the database
+ cls.required_stats = [
+ "total_households",
+ "total_members",
+ "children_under_5",
+ "children_under_18",
+ "elderly_60_plus",
+ "female_members",
+ "male_members",
+ "disabled_members",
+ "enrolled_any_program",
+ ]
+
+ def test_all_demo_statistics_exist(self):
+ """Verify all 9 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)
+ self.assertTrue(
+ stat,
+ f"Statistic '{stat_name}' not found in database. Check that demo_statistics.xml was loaded.",
+ )
+
+ def test_statistics_have_variables(self):
+ """Verify each statistic has a valid CEL variable reference."""
+ for stat_name in self.required_stats:
+ with self.subTest(statistic=stat_name):
+ stat = self.stat_model.search([("name", "=", stat_name)], limit=1)
+ if stat: # Only test if statistic exists
+ self.assertTrue(
+ stat.variable_id,
+ f"Statistic '{stat_name}' has no variable_id",
+ )
+ self.assertEqual(
+ stat.variable_id.state,
+ "active",
+ f"Variable for '{stat_name}' is not active",
+ )
+
+ def test_statistics_published_to_gis(self):
+ """Verify statistics are published to GIS context."""
+ for stat_name in self.required_stats:
+ with self.subTest(statistic=stat_name):
+ stat = self.stat_model.search([("name", "=", stat_name)], limit=1)
+ if stat: # Only test if statistic exists
+ self.assertTrue(
+ stat.is_published_gis,
+ f"Statistic '{stat_name}' not published to GIS",
+ )
+
+ 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"]
+
+ for stat_name in test_stats:
+ with self.subTest(statistic=stat_name):
+ stat = self.stat_model.search([("name", "=", stat_name)], limit=1)
+ if not stat:
+ self.skipTest(f"Statistic '{stat_name}' not found in database")
+
+ self.assertTrue(
+ stat.variable_id,
+ f"Statistic '{stat_name}' has no variable_id",
+ )
+ self.assertTrue(
+ stat.variable_id.cel_accessor,
+ f"Variable for statistic '{stat_name}' has no cel_accessor. "
+ "A cel_accessor is required for aggregation computation.",
+ )
+
+ def test_statistics_categories_exist(self):
+ """Verify statistics are assigned to categories."""
+ category_mapping = {
+ "demographics": [
+ "total_households",
+ "total_members",
+ "children_under_5",
+ "children_under_18",
+ "elderly_60_plus",
+ "female_members",
+ "male_members",
+ ],
+ "vulnerability": ["disabled_members"],
+ "programs": ["enrolled_any_program"],
+ }
+
+ for category_code, stat_names in category_mapping.items():
+ for stat_name in stat_names:
+ with self.subTest(statistic=stat_name, category=category_code):
+ stat = self.stat_model.search([("name", "=", stat_name)], limit=1)
+ if stat: # Only test if statistic exists
+ self.assertTrue(
+ stat.category_id,
+ f"Statistic '{stat_name}' has no category",
+ )
+ self.assertEqual(
+ stat.category_id.code,
+ category_code,
+ f"Statistic '{stat_name}' in wrong category. "
+ f"Expected '{category_code}', got '{stat.category_id.code}'",
+ )
+
+ def test_gis_discovery_endpoint_returns_statistics(self):
+ """Verify GIS statistics discovery returns our demo statistics."""
+ # Get all GIS-published statistics
+ gis_stats = self.stat_model.get_published_for_context("gis")
+
+ # Extract names
+ gis_stat_names = [stat.name for stat in gis_stats]
+
+ # Verify our required statistics are included
+ for stat_name in self.required_stats:
+ with self.subTest(statistic=stat_name):
+ self.assertIn(
+ stat_name,
+ gis_stat_names,
+ f"Statistic '{stat_name}' not in GIS discovery endpoint. Check is_published_gis flag.",
+ )
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..3872451d 100644
--- a/spp_mis_demo_v2/views/mis_demo_wizard_view.xml
+++ b/spp_mis_demo_v2/views/mis_demo_wizard_view.xml
@@ -24,6 +24,13 @@
Fairness analysis data
+
+
+
+