From e297aebe60457edac2695100090ce4818eace63e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 17 Feb 2026 11:30:38 +0700 Subject: [PATCH 1/3] feat(spp_demo): add geographic demo data for Philippines, Sri Lanka, and Togo - Add curated area hierarchies (region, province, municipality) per country - Add GeoJSON boundary shapes for choropleth visualization - Add demo area loader model and wizard for on-demand area data loading - Conditionally load GIS shapes when spp_gis is installed - Integrate geographic areas into demo story generation --- spp_demo/data/countries/lka/area_kinds.xml | 25 ++ spp_demo/data/countries/lka/areas.xml | 170 +++++++ spp_demo/data/countries/phl/area_kinds.xml | 26 ++ spp_demo/data/countries/phl/areas.xml | 213 +++++++++ spp_demo/data/countries/tgo/area_kinds.xml | 25 ++ spp_demo/data/countries/tgo/areas.xml | 170 +++++++ spp_demo/data/shapes/lka_curated.geojson | 3 + spp_demo/data/shapes/phl_curated.geojson | 3 + spp_demo/data/shapes/tgo_curated.geojson | 3 + spp_demo/models/__init__.py | 1 + spp_demo/models/demo_area_loader.py | 297 +++++++++++++ spp_demo/models/demo_data_generator.py | 224 +++++++--- spp_demo/models/demo_stories.py | 470 ++++---------------- spp_demo/security/ir.model.access.csv | 5 + spp_demo/tests/__init__.py | 1 + spp_demo/tests/test_demo_area_loader.py | 222 +++++++++ spp_demo/tests/test_demo_data_generator.py | 142 ++++++ spp_demo/tests/test_demo_stories.py | 3 +- spp_demo/tests/test_res_config_settings.py | 25 +- spp_demo/tests/test_res_country.py | 9 +- spp_demo/tests/test_res_partner.py | 6 +- spp_demo/views/demo_data_generator_view.xml | 19 + spp_demo/wizard/demo_area_loader_view.xml | 45 ++ 23 files changed, 1626 insertions(+), 481 deletions(-) create mode 100644 spp_demo/data/countries/lka/area_kinds.xml create mode 100644 spp_demo/data/countries/lka/areas.xml create mode 100644 spp_demo/data/countries/phl/area_kinds.xml create mode 100644 spp_demo/data/countries/phl/areas.xml create mode 100644 spp_demo/data/countries/tgo/area_kinds.xml create mode 100644 spp_demo/data/countries/tgo/areas.xml create mode 100644 spp_demo/data/shapes/lka_curated.geojson create mode 100644 spp_demo/data/shapes/phl_curated.geojson create mode 100644 spp_demo/data/shapes/tgo_curated.geojson create mode 100644 spp_demo/models/demo_area_loader.py create mode 100644 spp_demo/tests/test_demo_area_loader.py create mode 100644 spp_demo/wizard/demo_area_loader_view.xml diff --git a/spp_demo/data/countries/lka/area_kinds.xml b/spp_demo/data/countries/lka/area_kinds.xml new file mode 100644 index 00000000..762555e4 --- /dev/null +++ b/spp_demo/data/countries/lka/area_kinds.xml @@ -0,0 +1,25 @@ + + + + + + Province + + + + District + + + + + DS Division + + + + + GN Division + + + diff --git a/spp_demo/data/countries/lka/areas.xml b/spp_demo/data/countries/lka/areas.xml new file mode 100644 index 00000000..309129e1 --- /dev/null +++ b/spp_demo/data/countries/lka/areas.xml @@ -0,0 +1,170 @@ + + + + + + + Western Province + LK-1 + + + + + Southern Province + LK-3 + + + + + Central Province + LK-2 + + + + + + Colombo + LK-11 + + + + + + Gampaha + LK-12 + + + + + + Kalutara + LK-13 + + + + + + + Galle + LK-31 + + + + + + Matara + LK-32 + + + + + + + Kandy + LK-21 + + + + + + + Colombo + LK-1103 + + + + + + Dehiwala Mount Lavinia + LK-1106 + + + + + + Moratuwa + LK-1107 + + + + + + Kolonnawa + LK-1108 + + + + + + + Galle Four Gravets + LK-3109 + + + + + + Hikkaduwa + LK-3110 + + + + + + + Kandy Four Gravets + LK-2105 + + + + + + + Fort + LK-1103-001 + + + + + + Pettah + LK-1103-002 + + + + + + Slave Island + LK-1103-003 + + + + + + + Dehiwala East + LK-1106-001 + + + + + + Mount Lavinia + LK-1106-002 + + + + + + + Galle Fort + LK-3109-001 + + + + diff --git a/spp_demo/data/countries/phl/area_kinds.xml b/spp_demo/data/countries/phl/area_kinds.xml new file mode 100644 index 00000000..68fa9869 --- /dev/null +++ b/spp_demo/data/countries/phl/area_kinds.xml @@ -0,0 +1,26 @@ + + + + + + Region + + + + Province + + + + + City/Municipality + + + + + Barangay + + + diff --git a/spp_demo/data/countries/phl/areas.xml b/spp_demo/data/countries/phl/areas.xml new file mode 100644 index 00000000..802ef432 --- /dev/null +++ b/spp_demo/data/countries/phl/areas.xml @@ -0,0 +1,213 @@ + + + + + + + National Capital Region + PH-00 + + + + + CALABARZON + PH-40 + + + + + + + Metro Manila + PH-00-MM + + + + + + Cavite + PH-40-21 + + + + + + Laguna + PH-40-34 + + + + + + Rizal + PH-40-58 + + + + + + + Quezon City + PH-00-74 + + + + + + City of Manila + PH-00-39 + + + + + + Makati City + PH-00-38 + + + + + + Taguig City + PH-00-79 + + + + + + Pasig City + PH-00-76 + + + + + + + Calamba City + PH-40-34-08 + + + + + + Santa Rosa City + PH-40-34-26 + + + + + + San Pablo City + PH-40-34-24 + + + + + + Antipolo City + PH-40-58-01 + + + + + + Bacoor City + PH-40-21-03 + + + + + + Dasmarinas City + PH-40-21-09 + + + + + + + Loyola Heights + PH-00-74-061 + + + + + + Commonwealth + PH-00-74-028 + + + + + + Fairview + PH-00-74-044 + + + + + + + Poblacion + PH-00-38-020 + + + + + + Bel-Air + PH-00-38-004 + + + + + + + Real + PH-40-34-08-034 + + + + + + Crossing + PH-40-34-08-011 + + + + + + + Balibago + PH-40-34-26-002 + + + + + + Tagapo + PH-40-34-26-017 + + + + + + + Dela Paz + PH-40-58-01-005 + + + + + + San Roque + PH-40-58-01-015 + + + + diff --git a/spp_demo/data/countries/tgo/area_kinds.xml b/spp_demo/data/countries/tgo/area_kinds.xml new file mode 100644 index 00000000..ad46ef43 --- /dev/null +++ b/spp_demo/data/countries/tgo/area_kinds.xml @@ -0,0 +1,25 @@ + + + + + + Region + + + + Prefecture + + + + + Canton + + + + + Village + + + diff --git a/spp_demo/data/countries/tgo/areas.xml b/spp_demo/data/countries/tgo/areas.xml new file mode 100644 index 00000000..56dc776f --- /dev/null +++ b/spp_demo/data/countries/tgo/areas.xml @@ -0,0 +1,170 @@ + + + + + + + Maritime + TG-M + + + + + Plateaux + TG-P + + + + + Centrale + TG-C + + + + + + Golfe + TG-M-GOL + + + + + + Lacs + TG-M-LAC + + + + + + Vo + TG-M-VO + + + + + + Zio + TG-M-ZIO + + + + + + + Ogou + TG-P-OGO + + + + + + Kloto + TG-P-KLO + + + + + + + Tchaoudjo + TG-C-TCH + + + + + + + Lome Commune + TG-M-GOL-LOM + + + + + + Aflao Sagbado + TG-M-GOL-AFL + + + + + + Baguida + TG-M-GOL-BAG + + + + + + + Kpalime + TG-P-KLO-KPA + + + + + + + Sokode + TG-C-TCH-SOK + + + + + + + Tokoin + TG-M-GOL-LOM-TOK + + + + + + Be + TG-M-GOL-LOM-BE + + + + + + Nyekonakpoe + TG-M-GOL-LOM-NYE + + + + + + Adidogome + TG-M-GOL-LOM-ADI + + + + + + + Baguida Centre + TG-M-GOL-BAG-CEN + + + + + + + Kpalime Centre + TG-P-KLO-KPA-CEN + + + + + + Tove + TG-P-KLO-KPA-TOV + + + + diff --git a/spp_demo/data/shapes/lka_curated.geojson b/spp_demo/data/shapes/lka_curated.geojson new file mode 100644 index 00000000..667498f4 --- /dev/null +++ b/spp_demo/data/shapes/lka_curated.geojson @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25dd1a0ddda148ead42b2c4ac2fd23aac17970a4ba98aab7d85834ffdf184e6d +size 4284 diff --git a/spp_demo/data/shapes/phl_curated.geojson b/spp_demo/data/shapes/phl_curated.geojson new file mode 100644 index 00000000..6f8f003e --- /dev/null +++ b/spp_demo/data/shapes/phl_curated.geojson @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b01896e654357c0a9dab66c6a5464f64ba9399ad492042eb996df56637ace15 +size 625794 diff --git a/spp_demo/data/shapes/tgo_curated.geojson b/spp_demo/data/shapes/tgo_curated.geojson new file mode 100644 index 00000000..768a099f --- /dev/null +++ b/spp_demo/data/shapes/tgo_curated.geojson @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54c8d790e8df5f06af922c8dd24a5b00af791bc78b64f7d178bdcb3680247574 +size 4772 diff --git a/spp_demo/models/__init__.py b/spp_demo/models/__init__.py index 73fd391a..6acaa1d2 100644 --- a/spp_demo/models/__init__.py +++ b/spp_demo/models/__init__.py @@ -4,3 +4,4 @@ from . import demo_data_generator from . import demo_data_generation_log from . import demo_stories +from . import demo_area_loader diff --git a/spp_demo/models/demo_area_loader.py b/spp_demo/models/demo_area_loader.py new file mode 100644 index 00000000..09ef28d7 --- /dev/null +++ b/spp_demo/models/demo_area_loader.py @@ -0,0 +1,297 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +""" +Demo Area Loader + +Handles loading curated geographic data (areas and shapes) for demo environments. +Supports multiple countries with conditional GIS shape loading. +""" + +import json +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.misc import file_path + +_logger = logging.getLogger(__name__) + + +class DemoAreaLoader(models.TransientModel): + """Load curated demo areas for supported countries.""" + + _name = "spp.demo.area.loader" + _description = "Demo Area Data Loader" + + SUPPORTED_COUNTRIES = [ + ("phl", "Philippines"), + ("lka", "Sri Lanka"), + ("tgo", "Togo"), + ] + + country_code = fields.Selection( + selection=SUPPORTED_COUNTRIES, + string="Country", + required=True, + default="phl", + ) + + load_shapes = fields.Boolean( + string="Load Shapes (GIS)", + default=True, + help="Load polygon shapes for GIS visualization. " + "Requires spp_gis module to be installed.", + ) + + @api.model + def _is_gis_installed(self): + """Check if spp_gis module is installed.""" + return bool( + self.env["ir.module.module"].search( + [("name", "=", "spp_gis"), ("state", "=", "installed")] + ) + ) + + @api.model + def _get_country_name(self, country_code): + """Get the display name for a country code.""" + for code, name in self.SUPPORTED_COUNTRIES: + if code == country_code: + return name + return country_code.upper() + + def action_load_areas(self): + """Load area kinds and areas for the selected country. + + This method: + 1. Loads area kinds (hierarchy definition) from XML via ir.model.data + 2. Loads curated areas from XML via ir.model.data + 3. Optionally loads GeoJSON shapes if spp_gis is installed + + Returns: + dict: Notification action showing results + """ + self.ensure_one() + country_code = self.country_code + + # Check if area data files exist + try: + file_path(f"spp_demo/data/countries/{country_code}/area_kinds.xml") + file_path(f"spp_demo/data/countries/{country_code}/areas.xml") + except FileNotFoundError: + raise UserError( + _("Area data files not found for country: %s") + % self._get_country_name(country_code) + ) + + # Count existing areas before loading + existing_count = self.env["spp.area"].search_count([]) + + # Load XML data files using convert_file + self._load_xml_data(country_code) + + # Count new areas + new_count = self.env["spp.area"].search_count([]) + areas_created = new_count - existing_count + + # Load shapes if requested and GIS is available + shapes_loaded = 0 + if self.load_shapes and self._is_gis_installed(): + shapes_loaded = self._load_shapes(country_code) + + # Build notification message + country_name = self._get_country_name(country_code) + message = _( + "Successfully loaded geographic data for %(country)s:\n" + "- %(areas)d areas created/updated\n" + ) % {"country": country_name, "areas": areas_created} + + if shapes_loaded > 0: + message += _("- %(shapes)d polygon shapes loaded\n") % {"shapes": shapes_loaded} + elif self.load_shapes and not self._is_gis_installed(): + message += _("- Shapes not loaded (spp_gis not installed)\n") + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Geographic Data Loaded"), + "message": message, + "type": "success", + "sticky": False, + }, + } + + def _load_xml_data(self, country_code): + """Load area kinds and areas XML files for a country. + + The XML files are structured to be loaded via Odoo's data loading mechanism. + We use convert_file to process them properly with noupdate handling. + + Args: + country_code: ISO 3166-1 alpha-3 country code (lowercase) + """ + from odoo.tools import convert_file + + # Load area kinds first (hierarchy definition) + area_kinds_path = f"data/countries/{country_code}/area_kinds.xml" + try: + convert_file( + self.env, + "spp_demo", + area_kinds_path, + idref={}, + mode="init", + noupdate=True, + ) + _logger.info("Loaded area kinds for %s", country_code) + except Exception as e: + _logger.warning("Could not load area kinds for %s: %s", country_code, e) + + # Load areas (the actual geographic entities) + areas_path = f"data/countries/{country_code}/areas.xml" + try: + convert_file( + self.env, + "spp_demo", + areas_path, + idref={}, + mode="init", + noupdate=False, + ) + _logger.info("Loaded areas for %s", country_code) + except Exception as e: + _logger.warning("Could not load areas for %s: %s", country_code, e) + + def _load_shapes(self, country_code): + """Load GeoJSON shapes for areas. + + Reads the curated GeoJSON file and updates matching spp.area records + with their polygon geometries. + + Args: + country_code: ISO 3166-1 alpha-3 country code (lowercase) + + Returns: + int: Number of shapes successfully loaded + """ + try: + geojson_path = file_path(f"spp_demo/data/shapes/{country_code}_curated.geojson") + except FileNotFoundError: + _logger.warning("GeoJSON file not found for %s", country_code) + return 0 + + try: + with open(geojson_path, encoding="utf-8") as f: + geojson_data = json.load(f) + except (OSError, json.JSONDecodeError) as e: + _logger.warning("Could not read GeoJSON file for %s: %s", country_code, e) + return 0 + + features = geojson_data.get("features", []) + shapes_loaded = 0 + + for feature in features: + properties = feature.get("properties", {}) + geometry = feature.get("geometry") + + if not properties.get("code") or not geometry: + continue + + area_code = properties["code"] + + # Find matching area by code + area = self.env["spp.area"].search([("code", "=", area_code)], limit=1) + if not area: + _logger.debug("Area not found for code: %s", area_code) + continue + + # Check if area has geo_polygon field (spp_gis installed) + if "geo_polygon" not in area._fields: + _logger.debug("geo_polygon field not available on spp.area") + break + + # Convert GeoJSON geometry to WKT format for PostGIS + try: + wkt = self._geojson_to_wkt(geometry) + if wkt: + area.write({"geo_polygon": wkt}) + shapes_loaded += 1 + except Exception as e: + _logger.warning("Could not set shape for %s: %s", area_code, e) + + _logger.info("Loaded %d shapes for %s", shapes_loaded, country_code) + return shapes_loaded + + def _geojson_to_wkt(self, geometry): + """Convert GeoJSON geometry to WKT format. + + Supports Polygon and MultiPolygon geometries. + + Args: + geometry: GeoJSON geometry dict + + Returns: + str: WKT string or None if conversion fails + """ + geom_type = geometry.get("type") + coordinates = geometry.get("coordinates") + + if not geom_type or not coordinates: + return None + + if geom_type == "Polygon": + return self._polygon_to_wkt(coordinates) + elif geom_type == "MultiPolygon": + polygons = [self._polygon_to_wkt(poly) for poly in coordinates] + polygons = [p for p in polygons if p] + if polygons: + # Extract just the coordinate parts from each polygon WKT + poly_coords = [p.replace("POLYGON", "").strip() for p in polygons] + return f"MULTIPOLYGON({','.join(poly_coords)})" + return None + + def _polygon_to_wkt(self, coordinates): + """Convert polygon coordinates to WKT. + + Args: + coordinates: List of rings (first is exterior, rest are holes) + + Returns: + str: WKT POLYGON string + """ + if not coordinates: + return None + + rings = [] + for ring in coordinates: + points = [f"{p[0]} {p[1]}" for p in ring] + rings.append(f"({','.join(points)})") + + return f"POLYGON({','.join(rings)})" + + @api.model + def load_country_areas(self, country_code, load_shapes=True): + """Programmatic method to load areas for a country. + + This method can be called from other modules or scripts + to load geographic data without going through the wizard. + + Args: + country_code: ISO 3166-1 alpha-3 country code (lowercase) + load_shapes: Whether to load GIS shapes (default True) + + Returns: + dict: Result with counts of loaded data + """ + loader = self.create({"country_code": country_code, "load_shapes": load_shapes}) + loader._load_xml_data(country_code) + + shapes_loaded = 0 + if load_shapes and self._is_gis_installed(): + shapes_loaded = loader._load_shapes(country_code) + + return { + "country_code": country_code, + "shapes_loaded": shapes_loaded, + "gis_available": self._is_gis_installed(), + } diff --git a/spp_demo/models/demo_data_generator.py b/spp_demo/models/demo_data_generator.py index 1a65438c..f5b72d38 100644 --- a/spp_demo/models/demo_data_generator.py +++ b/spp_demo/models/demo_data_generator.py @@ -69,20 +69,11 @@ def _default_days_back(self): is_remember_settings = fields.Boolean(string="Remember Settings", default=False) number_of_groups = fields.Integer(string="Number of Groups", default=_default_number_of_groups, required=True) members_range_from = fields.Integer( - string="Members per Group (From)", - default=_default_members_range_from, - required=True, - ) - members_range_to = fields.Integer( - string="Members per Group (To)", - default=_default_members_range_to, - required=True, + string="Members per Group (From)", default=_default_members_range_from, required=True ) + members_range_to = fields.Integer(string="Members per Group (To)", default=_default_members_range_to, required=True) locale_origin = fields.Many2one( - "res.country", - string="Locale Origin", - required=True, - default=_default_locale_origin, + "res.country", string="Locale Origin", required=True, default=_default_locale_origin ) locale_origin_faker_locale = fields.Char(string="Locale Origin Faker Locale", related="locale_origin.faker_locale") batch_size = fields.Integer(string="Batch Size", default=_default_batch_size, required=True) @@ -93,12 +84,7 @@ def _default_days_back(self): help="Generate data within the last N days. Used for time-based demo data generation.", ) state = fields.Selection( - [ - ("draft", "Draft"), - ("in_progress", "In Progress"), - ("completed", "Completed"), - ("cancelled", "Cancelled"), - ], + [("draft", "Draft"), ("in_progress", "In Progress"), ("completed", "Completed"), ("cancelled", "Cancelled")], string="State", default="draft", required=True, @@ -114,6 +100,23 @@ def _default_days_back(self): percentage_with_ids = fields.Integer(string="% with IDs", default=100, required=True) percentage_with_gps = fields.Integer(string="% with GPS Coordinates", default=100, required=True) + # Geographic demo data settings + demo_country = fields.Selection( + [ + ("phl", "Philippines"), + ("lka", "Sri Lanka"), + ("tgo", "Togo"), + ], + string="Demo Country", + default="phl", + help="Country for geographic demo data (areas, shapes)", + ) + load_geographic_data = fields.Boolean( + string="Load Geographic Data", + default=True, + help="Load curated areas for the selected country during demo generation", + ) + is_locked = fields.Boolean(string="Locked", default=False) locked_reason = fields.Text(string="Locked Reason") @@ -154,6 +157,11 @@ def _compute_generation_log_count(self): def generate_demo_data(self): self.ensure_one() + + # Load geographic data if enabled + if self.load_geographic_data and self.demo_country: + self._load_geographic_data() + faker_code = self.locale_origin.faker_locale or "en_US" fake = Faker(faker_code) if self.members_range_from > self.members_range_to: @@ -193,6 +201,22 @@ def generate_demo_data(self): }, } + def _load_geographic_data(self): + """Load curated geographic data for the selected country. + + Uses the DemoAreaLoader to load area kinds, areas, and optionally + GIS polygon shapes for the demo country. + """ + if not self.demo_country: + return + + loader = self.env["spp.demo.area.loader"] + try: + loader.load_country_areas(self.demo_country, load_shapes=True) + _logger.info("Loaded geographic data for %s", self.demo_country) + except Exception as e: + _logger.warning("Could not load geographic data for %s: %s", self.demo_country, e) + def _generate_demo_data(self, fake): group = self.generate_groups(fake) num_members = fake.random_int(self.members_range_from, self.members_range_to) @@ -207,6 +231,11 @@ def _generate_demo_data(self, fake): is_head_member = True individual = self.generate_individuals(fake) + + # Members inherit area from their group + if group.area_id and individual.area_id != group.area_id: + individual.write({"area_id": group.area_id.id}) + membership_vals = self.get_group_membership_vals(fake, group, individual) if is_head_member and not head_membership: have_head_member = True @@ -246,11 +275,36 @@ def _process_batch(self, batch): self._generate_demo_data(fake) def _mark_done(self): + # Refresh GIS reports if the module is installed + self._refresh_gis_reports() + self.state = "completed" self.is_locked = False message = "The data generation has been completed." self.locked_reason = message + def _refresh_gis_reports(self): + """Refresh all active GIS reports so map data is available immediately. + + Only runs if spp_gis_report is installed (not a hard dependency). + """ + if "spp.gis.report" not in self.env: + return + + GISReport = self.env["spp.gis.report"] + reports = GISReport.search([("active", "=", True)]) + + if not reports: + _logger.info("No active GIS reports found to refresh") + return + + for report in reports: + try: + report._refresh_data() + _logger.info("Refreshed GIS report: %s", report.name) + except Exception: + _logger.exception("Failed to refresh GIS report: %s", report.name) + def generate_groups(self, fake): group_vals = self.get_group_vals(fake) group = self.env["res.partner"].create(group_vals) @@ -306,6 +360,29 @@ def generate_individuals(self, fake): self.create_gps_coordinates(fake, individual) return individual + def _get_leaf_areas(self): + """Get leaf-level areas (areas with no children) for assignment. + + Prefers the deepest level of the area hierarchy so that + GIS reports can aggregate upward through parent areas. + + Returns: + recordset: spp.area records at the deepest available level + """ + Area = self.env["spp.area"] + all_areas = Area.search([]) + if not all_areas: + return Area.browse() + + # Find the maximum area_level (deepest in hierarchy) + max_level = max(all_areas.mapped("area_level")) + + # Get areas at the deepest level + leaf_areas = all_areas.filtered(lambda a: a.area_level == max_level) + + # Fallback: if no areas at max level, use all areas + return leaf_areas if leaf_areas else all_areas + def get_group_vals(self, fake): registration_date = self.get_random_date( fake, @@ -332,6 +409,11 @@ def get_group_vals(self, fake): if group_types: group_vals["group_type_id"] = random.choice(group_types).id + # Assign area (prefer leaf-level for proper GIS report aggregation) + leaf_areas = self._get_leaf_areas() + if leaf_areas: + group_vals["area_id"] = random.choice(leaf_areas).id + return group_vals def get_individual_vals(self, fake): @@ -382,12 +464,11 @@ def get_individual_vals(self, fake): if "email" in partner_fields: individual_vals["email"] = fake.email() - # District field (if exists) - if "district" in partner_fields: - # Try to get a random district from available districts - districts = self.env["spp.district"].search([]) - if districts: - individual_vals["district"] = random.choice(districts).id + # Area field — prefer leaf-level areas for proper GIS aggregation + if "area_id" in partner_fields: + leaf_areas = self._get_leaf_areas() + if leaf_areas: + individual_vals["area_id"] = random.choice(leaf_areas).id # Birth place if "birth_place" in partner_fields: @@ -500,14 +581,55 @@ def lookup_gender_id(self, gender): gender_code = VocabCode.get_code("urn:iso:std:iso:5218", iso_code) if not gender_code: gender_code = VocabCode.search( - [ - ("namespace_uri", "=", "urn:iso:std:iso:5218"), - ("display", "ilike", gender), - ], + [("namespace_uri", "=", "urn:iso:std:iso:5218"), ("display", "ilike", gender)], limit=1, ) return gender_code.id if gender_code else False + def _get_area_for_profile(self, profile): + """Get area_id based on profile configuration. + + Supports three ways to assign areas: + 1. area_ref: Direct XML ID reference (e.g., 'spp_demo.area_phl_ncr_quezon_city') + 2. area_kind: Pick random area of specified kind (e.g., 'municipality') + 3. Fallback: Pick any available area + + Args: + profile: Dict with optional 'area_ref' or 'area_kind' keys + + Returns: + int or False: area_id or False if no areas available + """ + if not profile: + return False + + # Priority 1: Explicit area reference + area_ref = profile.get("area_ref") + if area_ref: + area = self.env.ref(area_ref, raise_if_not_found=False) + if area: + return area.id + + # Priority 2: Random area of specified kind + area_kind_name = profile.get("area_kind") + if area_kind_name: + # Search for area kind by name (case-insensitive partial match) + kind = self.env["spp.area.type"].search( + [("name", "ilike", area_kind_name)], + limit=1, + ) + if kind: + areas = self.env["spp.area"].search([("area_type_id", "=", kind.id)]) + if areas: + return random.choice(areas).id + + # Priority 3: Any available area (limit search for performance) + areas = self.env["spp.area"].search([], limit=100) + if areas: + return random.choice(areas).id + + return False + @api.model def create_individual_from_params(self, name, gender, age, extra_vals=None): """Create an individual registrant from parameters. @@ -630,10 +752,7 @@ def _log_generation_failure( def get_id_type(self, target_type): if self.id_type_ids: id_type = self.env["spp.demo.data.id.types"].search( - [ - ("target_type", "=", target_type), - ("demo_data_generator_id", "=", self.id), - ] + [("target_type", "=", target_type), ("demo_data_generator_id", "=", self.id)] ) if id_type: if len(self.id_type_ids) == 1: @@ -901,10 +1020,7 @@ def create_ids(self, fake, registrant): def get_bank_type(self, target_type): if self.bank_type_ids: bank_type = self.env["spp.demo.data.bank.types"].search( - [ - ("target_type", "=", target_type), - ("demo_data_generator_id", "=", self.id), - ] + [("target_type", "=", target_type), ("demo_data_generator_id", "=", self.id)] ) if bank_type: if len(self.bank_type_ids) == 1: @@ -962,10 +1078,7 @@ def generate_phone_number(self, fake): attempt += 1 # Return None if we couldn't generate a valid phone number - _logger.warning( - "Failed to generate valid phone number after %s attempts. Returning None.", - max_attempts, - ) + _logger.warning("Failed to generate valid phone number after %s attempts. Returning None.", max_attempts) return None def create_phone_numbers(self, fake, registrant): @@ -1006,11 +1119,7 @@ def create_phone_numbers(self, fake, registrant): self.env["spp.phone.number"].create(phone_vals_list) if failed_count > 0: - _logger.warning( - "Failed to generate %d phone number(s) for registrant_id=%s", - failed_count, - registrant.id, - ) + _logger.warning("Failed to generate %d phone number(s) for registrant_id=%s", failed_count, registrant.id) registrant.phone_number_ids_change() @@ -1131,19 +1240,10 @@ def generate_stories(self): if partner: created_partners[story["id"]] = partner - _logger.info( - "Created demo story: %s (partner_id=%s)", - story["id"], - partner.id, - ) + _logger.info("Created demo story: %s (partner_id=%s)", story["id"], partner.id) except Exception as e: - _logger.error( - "Error creating story '%s': %s", - story.get("id", "unknown"), - e, - exc_info=True, - ) + _logger.error("Error creating story '%s': %s", story.get("id", "unknown"), e, exc_info=True) _logger.info(f"Demo story generation completed. Created {len(created_partners)} stories.") return created_partners @@ -1200,13 +1300,11 @@ def _create_individual_story(self, story): elif "email" in partner_fields: vals["email"] = fake.email() - # District field (if exists) - if "district" in partner_fields: - district_name = profile.get("district") - if district_name: - districts = self.env["spp.district"].search([("name", "=", district_name)]) - if districts: - vals["district"] = districts[0].id + # Area field (if spp_area is installed) + if "area_id" in partner_fields: + area_id = self._get_area_for_profile(profile) + if area_id: + vals["area_id"] = area_id # Birth place if "birth_place" in partner_fields and profile.get("birth_place"): diff --git a/spp_demo/models/demo_stories.py b/spp_demo/models/demo_stories.py index 60cfa312..7e648af7 100644 --- a/spp_demo/models/demo_stories.py +++ b/spp_demo/models/demo_stories.py @@ -100,7 +100,8 @@ "farm_size_hectares": 2.5, # CEL: Input Subsidy eligibility "farm_type": "crop", "main_crop": "rice", - "district": "Northern District", + "area_ref": "spp_demo.area_phl_quezon_city", + "area_kind": "municipality", "marital_status": "married", "household_size": 5, }, @@ -113,30 +114,11 @@ "cel_check": "farm_size", "days_back": 152, }, - { - "action": "enroll_program", - "program": "Input Subsidy Program", - "days_back": 150, - }, + {"action": "enroll_program", "program": "Input Subsidy Program", "days_back": 150}, {"action": "create_event", "event_type": "training", "days_back": 145}, - { - "action": "create_payment", - "amount": 200, - "status": "paid", - "days_back": 120, - }, - { - "action": "create_payment", - "amount": 200, - "status": "paid", - "days_back": 90, - }, - { - "action": "create_payment", - "amount": 200, - "status": "paid", - "days_back": 60, - }, + {"action": "create_payment", "amount": 200, "status": "paid", "days_back": 120}, + {"action": "create_payment", "amount": 200, "status": "paid", "days_back": 90}, + {"action": "create_payment", "amount": 200, "status": "paid", "days_back": 60}, {"action": "graduate_program", "days_back": 30}, ], "demo_points": [ @@ -165,23 +147,9 @@ }, "journey": [ {"action": "register", "days_back": 120}, - { - "action": "enroll_program", - "program": "Cash Transfer Program", - "days_back": 100, - }, - { - "action": "create_payment", - "amount": 150, - "status": "paid", - "days_back": 70, - }, - { - "action": "create_payment", - "amount": 150, - "status": "failed", - "days_back": 40, - }, + {"action": "enroll_program", "program": "Cash Transfer Program", "days_back": 100}, + {"action": "create_payment", "amount": 150, "status": "paid", "days_back": 70}, + {"action": "create_payment", "amount": 150, "status": "failed", "days_back": 40}, { "action": "create_grm_ticket", "title": "Payment not received", @@ -189,22 +157,9 @@ "days_back": 38, }, {"action": "assign_ticket", "days_back": 35}, - { - "action": "add_ticket_note", - "note": "Investigation: Bank details were incorrect", - "days_back": 32, - }, - { - "action": "resolve_ticket", - "resolution": "Bank details corrected", - "days_back": 30, - }, - { - "action": "create_payment", - "amount": 150, - "status": "paid", - "days_back": 25, - }, + {"action": "add_ticket_note", "note": "Investigation: Bank details were incorrect", "days_back": 32}, + {"action": "resolve_ticket", "resolution": "Bank details corrected", "days_back": 30}, + {"action": "create_payment", "amount": 150, "status": "paid", "days_back": 25}, ], "demo_points": [ "GRM ticket with full conversation history", @@ -237,16 +192,8 @@ "cel_check": "age_retirement", "days_back": 182, }, - { - "action": "enroll_program", - "program": "Elderly Pension", - "days_back": 180, - }, - { - "action": "enroll_program", - "program": "Food Assistance", - "days_back": 175, - }, + {"action": "enroll_program", "program": "Elderly Pension", "days_back": 180}, + {"action": "enroll_program", "program": "Food Assistance", "days_back": 175}, { "action": "create_payment", "amount": 100, @@ -297,7 +244,8 @@ "farm_size_hectares": 8.0, # CEL: Large livestock farm "farm_type": "livestock", "main_livestock": "dairy", - "district": "Central District", + "area_ref": "spp_demo.area_phl_calamba", + "area_kind": "municipality", "marital_status": "married", "household_size": 6, "role": "cooperative_chairman", @@ -306,27 +254,10 @@ {"action": "register", "days_back": 365}, {"action": "add_farm_details", "comprehensive": True, "days_back": 360}, {"action": "register_cooperative_leader", "days_back": 350}, - { - "action": "enroll_program", - "program": "Livestock Improvement Program", - "days_back": 300, - }, - { - "action": "create_event", - "event_type": "extension_visit", - "days_back": 250, - }, - { - "action": "create_event", - "event_type": "extension_visit", - "days_back": 200, - }, - { - "action": "create_in_kind", - "item": "Improved Breed Cattle", - "quantity": 2, - "days_back": 150, - }, + {"action": "enroll_program", "program": "Livestock Improvement Program", "days_back": 300}, + {"action": "create_event", "event_type": "extension_visit", "days_back": 250}, + {"action": "create_event", "event_type": "extension_visit", "days_back": 200}, + {"action": "create_in_kind", "item": "Improved Breed Cattle", "quantity": 2, "days_back": 150}, ], "demo_points": [ "Detailed farm profile (livestock, assets, land records)", @@ -349,7 +280,8 @@ "farm_size_hectares": 3.0, # CEL: Youth farmer eligibility "farm_type": "crop", "main_crop": "mixed_vegetables", - "district": "Eastern District", + "area_ref": "spp_demo.area_phl_antipolo", + "area_kind": "municipality", "marital_status": "single", "household_size": 2, "registration_channel": "mobile_app", @@ -358,17 +290,9 @@ "journey": [ {"action": "register", "channel": "mobile_app", "days_back": 90}, {"action": "add_farm_details", "with_gps": True, "days_back": 85}, - { - "action": "apply_program", - "program": "Input Subsidy Program", - "days_back": 80, - }, + {"action": "apply_program", "program": "Input Subsidy Program", "days_back": 80}, {"action": "verify_eligibility", "days_back": 75}, - { - "action": "enroll_program", - "program": "Input Subsidy Program", - "days_back": 70, - }, + {"action": "enroll_program", "program": "Input Subsidy Program", "days_back": 70}, {"action": "create_in_kind", "item": "Inputs Package", "days_back": 45}, ], "demo_points": [ @@ -394,7 +318,8 @@ "farm_size": 2.0, "farm_size_hectares": 2.0, # CEL: Household farm size "child_count": 3, # CEL: Child benefit eligibility - "district": "Southern District", + "area_ref": "spp_demo.area_phl_santa_rosa", + "area_kind": "municipality", }, "journey": [ {"action": "register_household", "days_back": 150}, @@ -405,23 +330,9 @@ "cel_check": "member_count", "days_back": 142, }, - { - "action": "enroll_program", - "program": "Child Support Grant", - "days_back": 140, - }, - { - "action": "create_payment", - "amount": 300, - "status": "paid", - "days_back": 100, - }, - { - "action": "create_payment", - "amount": 300, - "status": "paid", - "days_back": 10, - }, + {"action": "enroll_program", "program": "Child Support Grant", "days_back": 140}, + {"action": "create_payment", "amount": 300, "status": "paid", "days_back": 100}, + {"action": "create_payment", "amount": 300, "status": "paid", "days_back": 10}, ], "demo_points": [ "Household with multiple members", @@ -446,7 +357,8 @@ "vulnerability": ["single_parent", "low_income", "female_headed"], "vulnerability_score": 80, # CEL: High vulnerability - single parent household "child_count": 3, # CEL: Child benefit eligibility - "district": "Western District", + "area_ref": "spp_demo.area_phl_makati", + "area_kind": "municipality", }, "journey": [ {"action": "register_household", "days_back": 180}, @@ -457,28 +369,10 @@ "cel_check": "member_count", "days_back": 162, }, - { - "action": "enroll_program", - "program": "Child Support Grant", - "days_back": 160, - }, - { - "action": "enroll_program", - "program": "Food Assistance", - "days_back": 155, - }, - { - "action": "create_payment", - "amount": 350, - "status": "paid", - "days_back": 120, - }, - { - "action": "create_payment", - "amount": 350, - "status": "paid", - "days_back": 60, - }, + {"action": "enroll_program", "program": "Child Support Grant", "days_back": 160}, + {"action": "enroll_program", "program": "Food Assistance", "days_back": 155}, + {"action": "create_payment", "amount": 350, "status": "paid", "days_back": 120}, + {"action": "create_payment", "amount": 350, "status": "paid", "days_back": 60}, ], "demo_points": [ "Female-headed household", @@ -497,18 +391,8 @@ "head": {"name": "Jose Reyes Sr", "gender": "male", "age": 72}, "spouse": {"name": "Carmen Reyes", "gender": "female", "age": 68}, "adults": [ - { - "name": "Miguel Reyes", - "gender": "male", - "age": 45, - "relation": "son", - }, - { - "name": "Teresa Reyes", - "gender": "female", - "age": 42, - "relation": "daughter-in-law", - }, + {"name": "Miguel Reyes", "gender": "male", "age": 45, "relation": "son"}, + {"name": "Teresa Reyes", "gender": "female", "age": 42, "relation": "daughter-in-law"}, ], "children": [ {"name": "Jose Reyes Jr", "gender": "male", "age": 18}, @@ -519,7 +403,8 @@ "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) - "district": "Northern District", + "area_ref": "spp_demo.area_phl_quezon_city", + "area_kind": "municipality", "vulnerability": ["elderly_members"], }, "journey": [ @@ -531,22 +416,14 @@ "cel_check": "age_retirement", "days_back": 352, }, - { - "action": "enroll_program", - "program": "Elderly Pension", - "days_back": 350, - }, + {"action": "enroll_program", "program": "Elderly Pension", "days_back": 350}, { "action": "verify_eligibility", "program": "Child Support Grant", "cel_check": "member_count", "days_back": 342, }, - { - "action": "enroll_program", - "program": "Child Support Grant", - "days_back": 340, - }, + {"action": "enroll_program", "program": "Child Support Grant", "days_back": 340}, { "action": "create_payment", "amount": 200, @@ -604,7 +481,8 @@ "child_count": 3, # CEL: Children under 18 (Xiao, Yan, Bo) "farm_type": "crop", "main_crop": "rice", - "district": "Eastern District", + "area_ref": "spp_demo.area_phl_antipolo", + "area_kind": "municipality", }, "journey": [ {"action": "register_household", "days_back": 200}, @@ -616,35 +494,17 @@ "cel_check": "farm_size", "days_back": 182, }, - { - "action": "enroll_program", - "program": "Input Subsidy Program", - "days_back": 180, - }, + {"action": "enroll_program", "program": "Input Subsidy Program", "days_back": 180}, { "action": "verify_eligibility", "program": "Child Support Grant", "cel_check": "member_count", "days_back": 177, }, - { - "action": "enroll_program", - "program": "Child Support Grant", - "days_back": 175, - }, + {"action": "enroll_program", "program": "Child Support Grant", "days_back": 175}, {"action": "create_in_kind", "item": "Inputs Package", "days_back": 150}, - { - "action": "create_payment", - "amount": 450, - "status": "paid", - "days_back": 140, - }, - { - "action": "create_payment", - "amount": 450, - "status": "paid", - "days_back": 80, - }, + {"action": "create_payment", "amount": 450, "status": "paid", "days_back": 140}, + {"action": "create_payment", "amount": 450, "status": "paid", "days_back": 80}, ], "demo_points": [ "Large family (7 members)", @@ -665,7 +525,8 @@ "vulnerability": ["elderly", "health_issues", "limited_mobility"], "vulnerability_score": 70, # CEL: Elderly couple vulnerability "has_formal_pension": False, # CEL: Elderly pension eligibility - "district": "Central District", + "area_ref": "spp_demo.area_phl_calamba", + "area_kind": "municipality", }, "journey": [ {"action": "register_household", "days_back": 250}, @@ -676,41 +537,13 @@ "cel_check": "age_retirement", "days_back": 232, }, - { - "action": "enroll_program", - "program": "Elderly Pension", - "days_back": 230, - }, - { - "action": "enroll_program", - "program": "Food Assistance", - "days_back": 220, - }, - { - "action": "create_payment", - "amount": 200, - "status": "paid", - "days_back": 200, - }, + {"action": "enroll_program", "program": "Elderly Pension", "days_back": 230}, + {"action": "enroll_program", "program": "Food Assistance", "days_back": 220}, + {"action": "create_payment", "amount": 200, "status": "paid", "days_back": 200}, {"action": "create_in_kind", "item": "Food Basket", "days_back": 195}, - { - "action": "create_payment", - "amount": 200, - "status": "paid", - "days_back": 140, - }, - { - "action": "create_payment", - "amount": 200, - "status": "paid", - "days_back": 80, - }, - { - "action": "create_payment", - "amount": 200, - "status": "paid", - "days_back": 20, - }, + {"action": "create_payment", "amount": 200, "status": "paid", "days_back": 140}, + {"action": "create_payment", "amount": 200, "status": "paid", "days_back": 80}, + {"action": "create_payment", "amount": 200, "status": "paid", "days_back": 20}, ], "demo_points": [ "Small elderly household", @@ -728,12 +561,7 @@ "profile": { "head": {"name": "James Nguyen", "gender": "male", "age": 52}, "adults": [ - { - "name": "Linda Nguyen", - "gender": "female", - "age": 48, - "relation": "spouse", - }, + {"name": "Linda Nguyen", "gender": "female", "age": 48, "relation": "spouse"}, { "name": "Michael Nguyen", "gender": "male", @@ -741,17 +569,13 @@ "relation": "brother", "disability_status": "disabled", }, - { - "name": "Sarah Nguyen", - "gender": "female", - "age": 44, - "relation": "sister-in-law", - }, + {"name": "Sarah Nguyen", "gender": "female", "age": 44, "relation": "sister-in-law"}, ], "farm_size": 6.0, "farm_size_hectares": 6.0, # CEL: Extended family farm "farm_type": "mixed", - "district": "Southern District", + "area_ref": "spp_demo.area_phl_santa_rosa", + "area_kind": "municipality", "vulnerability": ["disability"], "vulnerability_score": 65, # CEL: Disability in household "disabled_count": 1, # CEL: Member with disability @@ -761,35 +585,11 @@ {"action": "register_household", "days_back": 300}, {"action": "add_household_members", "days_back": 295}, {"action": "vulnerability_assessment", "score": "medium", "days_back": 290}, - { - "action": "enroll_program", - "program": "Cash Transfer Program", - "days_back": 280, - }, - { - "action": "create_payment", - "amount": 400, - "status": "paid", - "days_back": 250, - }, - { - "action": "create_payment", - "amount": 400, - "status": "paid", - "days_back": 190, - }, - { - "action": "create_payment", - "amount": 400, - "status": "paid", - "days_back": 130, - }, - { - "action": "create_payment", - "amount": 400, - "status": "paid", - "days_back": 70, - }, + {"action": "enroll_program", "program": "Cash Transfer Program", "days_back": 280}, + {"action": "create_payment", "amount": 400, "status": "paid", "days_back": 250}, + {"action": "create_payment", "amount": 400, "status": "paid", "days_back": 190}, + {"action": "create_payment", "amount": 400, "status": "paid", "days_back": 130}, + {"action": "create_payment", "amount": 400, "status": "paid", "days_back": 70}, ], "demo_points": [ "Extended family structure", @@ -818,34 +618,16 @@ }, "journey": [ {"action": "emergency_register", "days_back": 60}, - { - "action": "vulnerability_assessment", - "score": "very_high", - "days_back": 58, - }, + {"action": "vulnerability_assessment", "score": "very_high", "days_back": 58}, { "action": "verify_eligibility", "program": "Emergency Cash Transfer", "cel_check": "vulnerability_metric", "days_back": 56, }, - { - "action": "enroll_program", - "program": "Emergency Cash Transfer", - "days_back": 55, - }, - { - "action": "create_payment", - "amount": 500, - "status": "paid", - "days_back": 50, - }, - { - "action": "create_payment", - "amount": 500, - "status": "paid", - "days_back": 35, - }, + {"action": "enroll_program", "program": "Emergency Cash Transfer", "days_back": 55}, + {"action": "create_payment", "amount": 500, "status": "paid", "days_back": 50}, + {"action": "create_payment", "amount": 500, "status": "paid", "days_back": 35}, { "action": "create_grm_ticket", "title": "Request for resettlement support", @@ -883,11 +665,7 @@ "ticket_type": "inquiry", "days_back": 45, }, - { - "action": "respond_ticket", - "response": "Program information provided", - "days_back": 43, - }, + {"action": "respond_ticket", "response": "Program information provided", "days_back": 43}, {"action": "apply_program", "program": "Food Assistance", "days_back": 40}, {"action": "enroll_program", "program": "Food Assistance", "days_back": 30}, ], @@ -906,56 +684,29 @@ "head": {"name": "David Martinez", "gender": "male", "age": 48}, "spouse": {"name": "Sofia Martinez", "gender": "female", "age": 45}, "children": [ - { - "name": "Miguel Martinez", - "gender": "male", - "age": 12, - "disability_status": "disabled", - }, + {"name": "Miguel Martinez", "gender": "male", "age": 12, "disability_status": "disabled"}, ], "farm_size": 1.5, "farm_size_hectares": 1.5, # CEL: Small farm household "disabled_count": 1, # CEL: Disability Support Grant eligibility "child_count": 1, - "district": "Western District", + "area_ref": "spp_demo.area_phl_makati", + "area_kind": "municipality", }, "journey": [ {"action": "register_household", "days_back": 120}, {"action": "add_household_members", "days_back": 115}, - { - "action": "disability_assessment", - "member": "Miguel Martinez", - "days_back": 110, - }, + {"action": "disability_assessment", "member": "Miguel Martinez", "days_back": 110}, { "action": "verify_eligibility", "program": "Disability Support Grant", "cel_check": "member_exists_disabled", "days_back": 102, }, - { - "action": "enroll_program", - "program": "Disability Support Grant", - "days_back": 100, - }, - { - "action": "create_payment", - "amount": 175, - "status": "paid", - "days_back": 90, - }, - { - "action": "create_payment", - "amount": 175, - "status": "paid", - "days_back": 60, - }, - { - "action": "create_payment", - "amount": 175, - "status": "paid", - "days_back": 30, - }, + {"action": "enroll_program", "program": "Disability Support Grant", "days_back": 100}, + {"action": "create_payment", "amount": 175, "status": "paid", "days_back": 90}, + {"action": "create_payment", "amount": 175, "status": "paid", "days_back": 60}, + {"action": "create_payment", "amount": 175, "status": "paid", "days_back": 30}, ], "demo_points": [ "Household with disabled member", @@ -977,12 +728,7 @@ "profile": {"gender": "male", "age": 40, "farm_size": 1.5}, "journey": [ {"action": "register", "days_back": 30}, - { - "action": "apply_program", - "program": "Input Subsidy Program", - "status": "pending", - "days_back": 25, - }, + {"action": "apply_program", "program": "Input Subsidy Program", "status": "pending", "days_back": 25}, ], }, { @@ -1012,11 +758,7 @@ "profile": {"gender": "male", "age": 45, "farm_size": 2.0}, "journey": [ {"action": "register", "days_back": 200}, - { - "action": "enroll_program", - "program": "Cash Transfer Program", - "days_back": 180, - }, + {"action": "enroll_program", "program": "Cash Transfer Program", "days_back": 180}, {"action": "create_grm_ticket", "title": "Ticket 1", "days_back": 150}, {"action": "create_grm_ticket", "title": "Ticket 2", "days_back": 100}, {"action": "create_grm_ticket", "title": "Ticket 3", "days_back": 50}, @@ -1039,12 +781,7 @@ "type": "farmer", "story_title": "Contract Farmer", "story_description": "Shows guaranteed market arrangement", - "profile": { - "gender": "male", - "age": 48, - "farm_size": 4.0, - "contract_farming": True, - }, + "profile": {"gender": "male", "age": 48, "farm_size": 4.0, "contract_farming": True}, "journey": [ {"action": "register", "days_back": 300}, {"action": "add_farm_details", "days_back": 295}, @@ -1066,12 +803,7 @@ "story_title": "Tutorial: Not Eligible (High Income)", "story_description": "Tutorial household with income above threshold - NOT ELIGIBLE", "profile": { - "head": { - "name": "Roberto Garcia", - "gender": "male", - "age": 42, - "income": 15000, - }, + "head": {"name": "Roberto Garcia", "gender": "male", "age": 42, "income": 15000}, "spouse": {"name": "Maria Garcia", "gender": "female", "age": 38}, "children": [ {"name": "Carlos Garcia", "gender": "male", "age": 12}, @@ -1096,19 +828,10 @@ "story_title": "Tutorial: Eligible (Low Income + Child Under 5)", "story_description": "Tutorial household meeting both criteria - ELIGIBLE", "profile": { - "head": { - "name": "Jose Santos", - "gender": "male", - "age": 35, - "income": 8000, - }, + "head": {"name": "Jose Santos", "gender": "male", "age": 35, "income": 8000}, "spouse": {"name": "Ana Santos", "gender": "female", "age": 32}, "children": [ - { - "name": "Mia Santos", - "gender": "female", - "age": 4, - }, # Born ~2021, under 5 + {"name": "Mia Santos", "gender": "female", "age": 4}, # Born ~2021, under 5 ], "child_count": 1, "district": "Northern District", @@ -1129,12 +852,7 @@ "story_title": "Tutorial: Not Eligible (Income Above Threshold)", "story_description": "Tutorial household with income above threshold - NOT ELIGIBLE", "profile": { - "head": { - "name": "Pedro Cruz", - "gender": "male", - "age": 45, - "income": 12000, - }, + "head": {"name": "Pedro Cruz", "gender": "male", "age": 45, "income": 12000}, "spouse": {"name": "Teresa Cruz", "gender": "female", "age": 42}, "children": [ {"name": "Juan Cruz", "gender": "male", "age": 15}, @@ -1160,19 +878,10 @@ "story_title": "Tutorial: Eligible (Low Income + Child Under 5)", "story_description": "Tutorial household meeting both criteria - ELIGIBLE", "profile": { - "head": { - "name": "Ramon Reyes", - "gender": "male", - "age": 30, - "income": 6000, - }, + "head": {"name": "Ramon Reyes", "gender": "male", "age": 30, "income": 6000}, "spouse": {"name": "Elena Reyes", "gender": "female", "age": 28}, "children": [ - { - "name": "Lucia Reyes", - "gender": "female", - "age": 2, - }, # Born ~2023, under 5 + {"name": "Lucia Reyes", "gender": "female", "age": 2}, # Born ~2023, under 5 ], "child_count": 1, "district": "Southern District", @@ -1193,12 +902,7 @@ "story_title": "Tutorial: Not Eligible (High Income)", "story_description": "Tutorial household with highest income - NOT ELIGIBLE", "profile": { - "head": { - "name": "Antonio Ramos", - "gender": "male", - "age": 48, - "income": 18000, - }, + "head": {"name": "Antonio Ramos", "gender": "male", "age": 48, "income": 18000}, "spouse": {"name": "Rosa Ramos", "gender": "female", "age": 45}, "children": [ {"name": "Diego Ramos", "gender": "male", "age": 18}, diff --git a/spp_demo/security/ir.model.access.csv b/spp_demo/security/ir.model.access.csv index 70643210..43e57ca9 100644 --- a/spp_demo/security/ir.model.access.csv +++ b/spp_demo/security/ir.model.access.csv @@ -24,3 +24,8 @@ access_spp_demo_data_id_types_create,Demo ID Types Create Access,spp_demo.model_ access_spp_demo_data_bank_types_create,Demo Bank Types Create Access,spp_demo.model_spp_demo_data_bank_types,spp_registry.group_registry_create,1,0,1,1 access_spp_apps_wizard_create,Apps Wizard Create Access,spp_demo.model_spp_apps_wizard,spp_registry.group_registry_create,1,0,1,0 access_spp_missing_module_create,Apps Missing Modules Create Access,spp_demo.model_spp_missing_module,spp_registry.group_registry_create,1,0,1,0 + +access_spp_demo_area_loader_admin,Demo Area Loader Admin Access,spp_demo.model_spp_demo_area_loader,base.group_system,1,1,1,1 +access_spp_demo_area_loader_read,Demo Area Loader Read Access,spp_demo.model_spp_demo_area_loader,spp_registry.group_registry_read,1,0,0,0 +access_spp_demo_area_loader_write,Demo Area Loader Write Access,spp_demo.model_spp_demo_area_loader,spp_registry.group_registry_write,1,1,0,0 +access_spp_demo_area_loader_create,Demo Area Loader Create Access,spp_demo.model_spp_demo_area_loader,spp_registry.group_registry_create,1,0,1,0 diff --git a/spp_demo/tests/__init__.py b/spp_demo/tests/__init__.py index 954a1827..52c240bb 100644 --- a/spp_demo/tests/__init__.py +++ b/spp_demo/tests/__init__.py @@ -5,3 +5,4 @@ from . import test_res_partner from . import test_apps_wizard from . import test_demo_stories +from . import test_demo_area_loader diff --git a/spp_demo/tests/test_demo_area_loader.py b/spp_demo/tests/test_demo_area_loader.py new file mode 100644 index 00000000..1c4127cf --- /dev/null +++ b/spp_demo/tests/test_demo_area_loader.py @@ -0,0 +1,222 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for the Demo Area Loader functionality.""" + +import logging + +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + + +class TestDemoAreaLoader(TransactionCase): + """Test cases for spp.demo.area.loader model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader_model = cls.env["spp.demo.area.loader"] + cls.area_model = cls.env["spp.area"] + cls.area_kind_model = cls.env["spp.area.type"] + + def test_supported_countries(self): + """Test that supported countries are defined.""" + supported = self.loader_model.SUPPORTED_COUNTRIES + self.assertIsInstance(supported, list) + self.assertGreater(len(supported), 0) + + # Check Philippines is supported + country_codes = [code for code, name in supported] + self.assertIn("phl", country_codes) + self.assertIn("lka", country_codes) + self.assertIn("tgo", country_codes) + + def test_get_country_name(self): + """Test country name lookup.""" + name = self.loader_model._get_country_name("phl") + self.assertEqual(name, "Philippines") + + name = self.loader_model._get_country_name("lka") + self.assertEqual(name, "Sri Lanka") + + name = self.loader_model._get_country_name("tgo") + self.assertEqual(name, "Togo") + + # Unknown country returns uppercase code + name = self.loader_model._get_country_name("xyz") + self.assertEqual(name, "XYZ") + + def test_is_gis_installed(self): + """Test GIS module detection.""" + # This is a basic test - actual result depends on installed modules + result = self.loader_model._is_gis_installed() + self.assertIsInstance(result, bool) + + def test_load_phl_areas(self): + """Test loading Philippines area data.""" + # Count areas before + initial_count = self.area_model.search_count([]) + + # Load Philippines areas + loader = self.loader_model.create({ + "country_code": "phl", + "load_shapes": False, + }) + loader._load_xml_data("phl") + + # Verify areas were created + final_count = self.area_model.search_count([]) + self.assertGreater(final_count, initial_count, "Areas should be created for Philippines") + + # Verify specific areas exist + ncr = self.area_model.search([("code", "=", "PH-00")], limit=1) + self.assertTrue(ncr, "NCR region should exist") + + quezon_city = self.area_model.search([("code", "=", "PH-00-74")], limit=1) + self.assertTrue(quezon_city, "Quezon City should exist") + + # Verify area hierarchy + metro_manila = self.area_model.search([("code", "=", "PH-00-MM")], limit=1) + if metro_manila and ncr: + self.assertEqual(metro_manila.parent_id.id, ncr.id, "Metro Manila should be child of NCR") + + def test_load_lka_areas(self): + """Test loading Sri Lanka area data.""" + # Load Sri Lanka areas + loader = self.loader_model.create({ + "country_code": "lka", + "load_shapes": False, + }) + loader._load_xml_data("lka") + + # Verify specific areas exist + western = self.area_model.search([("code", "=", "LK-1")], limit=1) + self.assertTrue(western, "Western Province should exist") + + colombo = self.area_model.search([("code", "=", "LK-11")], limit=1) + self.assertTrue(colombo, "Colombo District should exist") + + def test_load_tgo_areas(self): + """Test loading Togo area data.""" + # Load Togo areas + loader = self.loader_model.create({ + "country_code": "tgo", + "load_shapes": False, + }) + loader._load_xml_data("tgo") + + # Verify specific areas exist + maritime = self.area_model.search([("code", "=", "TG-M")], limit=1) + self.assertTrue(maritime, "Maritime region should exist") + + lome = self.area_model.search([("code", "=", "TG-M-GOL-LOM")], limit=1) + self.assertTrue(lome, "Lome canton should exist") + + def test_load_country_areas_programmatic(self): + """Test programmatic area loading method.""" + result = self.loader_model.load_country_areas("phl", load_shapes=False) + + self.assertIn("country_code", result) + self.assertEqual(result["country_code"], "phl") + self.assertIn("shapes_loaded", result) + self.assertIn("gis_available", result) + + def test_geojson_to_wkt_polygon(self): + """Test GeoJSON polygon to WKT conversion.""" + loader = self.loader_model.create({ + "country_code": "phl", + "load_shapes": False, + }) + + geometry = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + } + + wkt = loader._geojson_to_wkt(geometry) + self.assertIsNotNone(wkt) + self.assertTrue(wkt.startswith("POLYGON")) + + def test_geojson_to_wkt_invalid(self): + """Test GeoJSON conversion with invalid input.""" + loader = self.loader_model.create({ + "country_code": "phl", + "load_shapes": False, + }) + + # Empty geometry + wkt = loader._geojson_to_wkt({}) + self.assertIsNone(wkt) + + # Missing coordinates + wkt = loader._geojson_to_wkt({"type": "Polygon"}) + self.assertIsNone(wkt) + + +class TestDemoAreaLoaderAreaKinds(TransactionCase): + """Test area kinds creation for each country.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader_model = cls.env["spp.demo.area.loader"] + cls.area_kind_model = cls.env["spp.area.type"] + + def test_phl_area_kinds(self): + """Test Philippines area kinds hierarchy.""" + loader = self.loader_model.create({ + "country_code": "phl", + "load_shapes": False, + }) + loader._load_xml_data("phl") + + # Check region kind exists + region = self.area_kind_model.search([("name", "=", "Region")], limit=1) + self.assertTrue(region, "Region area kind should exist") + + # Check province kind exists and has region as parent + province = self.area_kind_model.search([("name", "=", "Province")], limit=1) + self.assertTrue(province, "Province area kind should exist") + + # Check municipality kind exists + municipality = self.area_kind_model.search([("name", "=", "City/Municipality")], limit=1) + self.assertTrue(municipality, "City/Municipality area kind should exist") + + # Check barangay kind exists + barangay = self.area_kind_model.search([("name", "=", "Barangay")], limit=1) + self.assertTrue(barangay, "Barangay area kind should exist") + + def test_lka_area_kinds(self): + """Test Sri Lanka area kinds hierarchy.""" + loader = self.loader_model.create({ + "country_code": "lka", + "load_shapes": False, + }) + loader._load_xml_data("lka") + + # Check DS Division kind exists + ds_division = self.area_kind_model.search([("name", "=", "DS Division")], limit=1) + self.assertTrue(ds_division, "DS Division area kind should exist") + + # Check GN Division kind exists + gn_division = self.area_kind_model.search([("name", "=", "GN Division")], limit=1) + self.assertTrue(gn_division, "GN Division area kind should exist") + + def test_tgo_area_kinds(self): + """Test Togo area kinds hierarchy.""" + loader = self.loader_model.create({ + "country_code": "tgo", + "load_shapes": False, + }) + loader._load_xml_data("tgo") + + # Check prefecture kind exists + prefecture = self.area_kind_model.search([("name", "=", "Prefecture")], limit=1) + self.assertTrue(prefecture, "Prefecture area kind should exist") + + # Check canton kind exists + canton = self.area_kind_model.search([("name", "=", "Canton")], limit=1) + self.assertTrue(canton, "Canton area kind should exist") + + # Check village kind exists + village = self.area_kind_model.search([("name", "=", "Village")], limit=1) + self.assertTrue(village, "Village area kind should exist") diff --git a/spp_demo/tests/test_demo_data_generator.py b/spp_demo/tests/test_demo_data_generator.py index 9e5dc8c2..5289f9d8 100644 --- a/spp_demo/tests/test_demo_data_generator.py +++ b/spp_demo/tests/test_demo_data_generator.py @@ -612,3 +612,145 @@ def test_27_edge_case_regex_generation_complex(self): result2 = generator.generate_id_from_regex(pattern2) self.assertIsNotNone(result2) self.assertEqual(len(result2), 5) + + def test_28_group_gets_area_id(self): + """Test that groups are assigned an area_id when areas exist.""" + # Create area hierarchy + area_type_region = self.env["spp.area.type"].create({"name": "Test Region"}) + area_type_barangay = self.env["spp.area.type"].create( + {"name": "Test Barangay", "parent_id": area_type_region.id} + ) + + region = self.env["spp.area"].create( + { + "draft_name": "Test Region", + "code": "TEST-REG", + "area_type_id": area_type_region.id, + } + ) + self.env["spp.area"].create( + { + "draft_name": "Test Barangay", + "code": "TEST-BRG", + "area_type_id": area_type_barangay.id, + "parent_id": region.id, + } + ) + + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Area Assignment Test", + "locale_origin": self.test_country.id, + } + ) + + from faker import Faker + + fake = Faker("en_US") + group_vals = generator.get_group_vals(fake) + + # Group should have area_id assigned + self.assertIn("area_id", group_vals) + self.assertTrue(group_vals["area_id"]) + + def test_29_group_prefers_leaf_areas(self): + """Test that groups are assigned to leaf-level areas (not regions).""" + # Create a hierarchy: region -> province -> barangay + area_type_region = self.env["spp.area.type"].create({"name": "Test Region L"}) + area_type_province = self.env["spp.area.type"].create( + {"name": "Test Province L", "parent_id": area_type_region.id} + ) + area_type_leaf = self.env["spp.area.type"].create({"name": "Test Leaf L", "parent_id": area_type_province.id}) + + region = self.env["spp.area"].create( + { + "draft_name": "Region Parent", + "code": "TEST-RP", + "area_type_id": area_type_region.id, + } + ) + province = self.env["spp.area"].create( + { + "draft_name": "Province Mid", + "code": "TEST-PM", + "area_type_id": area_type_province.id, + "parent_id": region.id, + } + ) + leaf1 = self.env["spp.area"].create( + { + "draft_name": "Leaf One", + "code": "TEST-L1", + "area_type_id": area_type_leaf.id, + "parent_id": province.id, + } + ) + leaf2 = self.env["spp.area"].create( + { + "draft_name": "Leaf Two", + "code": "TEST-L2", + "area_type_id": area_type_leaf.id, + "parent_id": province.id, + } + ) + + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Leaf Area Test", + "locale_origin": self.test_country.id, + } + ) + + from faker import Faker + + fake = Faker("en_US") + + # Generate multiple groups and verify they all get leaf areas + leaf_ids = {leaf1.id, leaf2.id} + for _ in range(10): + group_vals = generator.get_group_vals(fake) + self.assertIn( + group_vals["area_id"], + leaf_ids, + "Group should be assigned to a leaf-level area, not a parent area", + ) + + def test_30_members_inherit_group_area(self): + """Test that individual members inherit their group's area_id.""" + area_type = self.env["spp.area.type"].create({"name": "Test Area MIA"}) + self.env["spp.area"].create( + { + "draft_name": "Member Inherit Area", + "code": "TEST-MIA", + "area_type_id": area_type.id, + } + ) + + generator = self.env["spp.demo.data.generator"].create( + { + "name": "Member Inherit Test", + "number_of_groups": 1, + "members_range_from": 3, + "members_range_to": 3, + "locale_origin": self.test_country.id, + } + ) + + from faker import Faker + + fake = Faker("en_US") + generator._generate_demo_data(fake) + + # Find the created group + group = self.env["res.partner"].search([("demo_data_group_generator_id", "=", generator.id)], limit=1) + self.assertTrue(group.area_id, "Group should have an area_id") + + # All members should share the group's area + members = self.env["res.partner"].search([("demo_data_individual_generator_id", "=", generator.id)]) + self.assertTrue(len(members) >= 3) + for member in members: + self.assertEqual( + member.area_id, + group.area_id, + f"Member '{member.name}' should inherit group's area_id", + ) diff --git a/spp_demo/tests/test_demo_stories.py b/spp_demo/tests/test_demo_stories.py index 0d8e41d9..32f22205 100644 --- a/spp_demo/tests/test_demo_stories.py +++ b/spp_demo/tests/test_demo_stories.py @@ -61,8 +61,7 @@ def test_02_get_all_stories(self): tutorial_stories = demo_stories.get_tutorial_stories() self.assertEqual( - len(all_stories), - len(main_stories) + len(background_stories) + len(tutorial_stories), + len(all_stories), len(main_stories) + len(background_stories) + len(tutorial_stories) ) self.assertGreater(len(main_stories), 0) diff --git a/spp_demo/tests/test_res_config_settings.py b/spp_demo/tests/test_res_config_settings.py index 755fa080..399da3b3 100644 --- a/spp_demo/tests/test_res_config_settings.py +++ b/spp_demo/tests/test_res_config_settings.py @@ -131,26 +131,11 @@ def test_08_set_multiple_values(self): config.execute() # Verify all parameters were set - self.assertEqual( - int(self.env["ir.config_parameter"].sudo().get_param("spp_demo.number_of_groups")), - 100, - ) - self.assertEqual( - int(self.env["ir.config_parameter"].sudo().get_param("spp_demo.members_range_from")), - 2, - ) - self.assertEqual( - int(self.env["ir.config_parameter"].sudo().get_param("spp_demo.members_range_to")), - 12, - ) - self.assertEqual( - int(self.env["ir.config_parameter"].sudo().get_param("spp_demo.batch_size")), - 300, - ) - self.assertEqual( - int(self.env["ir.config_parameter"].sudo().get_param("spp_demo.queue_job_minimum_size")), - 2000, - ) + self.assertEqual(int(self.env["ir.config_parameter"].sudo().get_param("spp_demo.number_of_groups")), 100) + self.assertEqual(int(self.env["ir.config_parameter"].sudo().get_param("spp_demo.members_range_from")), 2) + self.assertEqual(int(self.env["ir.config_parameter"].sudo().get_param("spp_demo.members_range_to")), 12) + self.assertEqual(int(self.env["ir.config_parameter"].sudo().get_param("spp_demo.batch_size")), 300) + self.assertEqual(int(self.env["ir.config_parameter"].sudo().get_param("spp_demo.queue_job_minimum_size")), 2000) def test_09_default_values(self): """Test default values when no config parameters are set""" diff --git a/spp_demo/tests/test_res_country.py b/spp_demo/tests/test_res_country.py index 292f4eae..ea8ade2f 100644 --- a/spp_demo/tests/test_res_country.py +++ b/spp_demo/tests/test_res_country.py @@ -209,14 +209,7 @@ def test_10_inherit_model_check(self): self.assertIn("code", country._fields) # Verify our custom fields are there - custom_fields = [ - "lat_min", - "lat_max", - "lon_min", - "lon_max", - "faker_locale", - "is_faker_locale_available", - ] + custom_fields = ["lat_min", "lat_max", "lon_min", "lon_max", "faker_locale", "is_faker_locale_available"] for field in custom_fields: self.assertIn(field, country._fields) diff --git a/spp_demo/tests/test_res_partner.py b/spp_demo/tests/test_res_partner.py index e59978ed..570c3f3f 100644 --- a/spp_demo/tests/test_res_partner.py +++ b/spp_demo/tests/test_res_partner.py @@ -240,11 +240,7 @@ def test_12_inherit_model_check(self): self.assertIn("email", partner._fields) # Verify our custom fields are there - custom_fields = [ - "demo_data_group_generator_id", - "demo_data_individual_generator_id", - "gps_coordinates", - ] + custom_fields = ["demo_data_group_generator_id", "demo_data_individual_generator_id", "gps_coordinates"] for field in custom_fields: self.assertIn(field, partner._fields) diff --git a/spp_demo/views/demo_data_generator_view.xml b/spp_demo/views/demo_data_generator_view.xml index 94aafcc1..0793170f 100644 --- a/spp_demo/views/demo_data_generator_view.xml +++ b/spp_demo/views/demo_data_generator_view.xml @@ -250,6 +250,25 @@ + +
+
+
+

+ +
+
+

+ +
+
+
+ + + + spp.demo.area.loader.form + spp.demo.area.loader + +
+ + + + + + +
+
+
+
+
+ + + + Load Geographic Data + spp.demo.area.loader + form + new + {} + + + + +
From eca97d2c5c66afe66ee34399785f48806bf63a14 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 18 Feb 2026 16:43:09 +0700 Subject: [PATCH 2/3] fix(spp_demo): optimize _get_leaf_areas to query only leaf areas Replace the Python-level filter over all areas with a direct ORM query using the domain [('child_ids', '=', False)], which lets the database find leaf areas in a single SQL query instead of loading all records into memory. --- spp_demo/__manifest__.py | 2 + spp_demo/data/countries/lka/area_kinds.xml | 8 +- spp_demo/data/countries/lka/areas.xml | 84 +++++++-------- spp_demo/data/countries/phl/area_kinds.xml | 8 +- spp_demo/data/countries/phl/areas.xml | 110 ++++++++++---------- spp_demo/data/countries/tgo/area_kinds.xml | 8 +- spp_demo/data/countries/tgo/areas.xml | 84 +++++++-------- spp_demo/models/demo_area_loader.py | 24 ++--- spp_demo/models/demo_data_generator.py | 20 ++-- spp_demo/tests/test_demo_area_loader.py | 80 ++++++++------ spp_demo/tests/test_demo_stories.py | 4 +- spp_demo/views/demo_data_generator_view.xml | 24 ++++- spp_demo/wizard/demo_area_loader_view.xml | 8 +- 13 files changed, 243 insertions(+), 221 deletions(-) diff --git a/spp_demo/__manifest__.py b/spp_demo/__manifest__.py index 84025f81..b8fc5b4f 100644 --- a/spp_demo/__manifest__.py +++ b/spp_demo/__manifest__.py @@ -18,6 +18,7 @@ "spp_vocabulary", "queue_job", "spp_security", + "spp_area", ], "external_dependencies": { "python": ["faker"], @@ -34,6 +35,7 @@ "views/demo_data_generator_view.xml", # Wizards "wizard/apps_wizard_view.xml", + "wizard/demo_area_loader_view.xml", ], "assets": {}, "demo": [], diff --git a/spp_demo/data/countries/lka/area_kinds.xml b/spp_demo/data/countries/lka/area_kinds.xml index 762555e4..068c02ee 100644 --- a/spp_demo/data/countries/lka/area_kinds.xml +++ b/spp_demo/data/countries/lka/area_kinds.xml @@ -1,4 +1,4 @@ - + Colombo LK-11 - - + + Gampaha LK-12 - - + + Kalutara LK-13 - - + + Galle LK-31 - - + + Matara LK-32 - - + + Kandy LK-21 - - + + Colombo LK-1103 - - + + Dehiwala Mount Lavinia LK-1106 - - + + Moratuwa LK-1107 - - + + Kolonnawa LK-1108 - - + + Galle Four Gravets LK-3109 - - + + Hikkaduwa LK-3110 - - + + Kandy Four Gravets LK-2105 - - + + Fort LK-1103-001 - - + + Pettah LK-1103-002 - - + + Slave Island LK-1103-003 - - + + Dehiwala East LK-1106-001 - - + + Mount Lavinia LK-1106-002 - - + + Galle Fort LK-3109-001 - - + + diff --git a/spp_demo/data/countries/phl/area_kinds.xml b/spp_demo/data/countries/phl/area_kinds.xml index 68fa9869..cc3ca60d 100644 --- a/spp_demo/data/countries/phl/area_kinds.xml +++ b/spp_demo/data/countries/phl/area_kinds.xml @@ -1,4 +1,4 @@ - + @@ -25,189 +25,189 @@ Metro Manila PH-00-MM - - + + Cavite PH-40-21 - - + + Laguna PH-40-34 - - + + Rizal PH-40-58 - - + + Quezon City PH-00-74 - - + + City of Manila PH-00-39 - - + + Makati City PH-00-38 - - + + Taguig City PH-00-79 - - + + Pasig City PH-00-76 - - + + Calamba City PH-40-34-08 - - + + Santa Rosa City PH-40-34-26 - - + + San Pablo City PH-40-34-24 - - + + Antipolo City PH-40-58-01 - - + + Bacoor City PH-40-21-03 - - + + Dasmarinas City PH-40-21-09 - - + + Loyola Heights PH-00-74-061 - - + + Commonwealth PH-00-74-028 - - + + Fairview PH-00-74-044 - - + + Poblacion PH-00-38-020 - - + + Bel-Air PH-00-38-004 - - + + Real PH-40-34-08-034 - - + + Crossing PH-40-34-08-011 - - + + Balibago PH-40-34-26-002 - - + + Tagapo PH-40-34-26-017 - - + + Dela Paz PH-40-58-01-005 - - + + San Roque PH-40-58-01-015 - - + + diff --git a/spp_demo/data/countries/tgo/area_kinds.xml b/spp_demo/data/countries/tgo/area_kinds.xml index ad46ef43..1a9f1573 100644 --- a/spp_demo/data/countries/tgo/area_kinds.xml +++ b/spp_demo/data/countries/tgo/area_kinds.xml @@ -1,4 +1,4 @@ - + Golfe TG-M-GOL - - + + Lacs TG-M-LAC - - + + Vo TG-M-VO - - + + Zio TG-M-ZIO - - + + Ogou TG-P-OGO - - + + Kloto TG-P-KLO - - + + Tchaoudjo TG-C-TCH - - + + Lome Commune TG-M-GOL-LOM - - + + Aflao Sagbado TG-M-GOL-AFL - - + + Baguida TG-M-GOL-BAG - - + + Kpalime TG-P-KLO-KPA - - + + Sokode TG-C-TCH-SOK - - + + Tokoin TG-M-GOL-LOM-TOK - - + + Be TG-M-GOL-LOM-BE - - + + Nyekonakpoe TG-M-GOL-LOM-NYE - - + + Adidogome TG-M-GOL-LOM-ADI - - + + Baguida Centre TG-M-GOL-BAG-CEN - - + + Kpalime Centre TG-P-KLO-KPA-CEN - - + + Tove TG-P-KLO-KPA-TOV - - + + diff --git a/spp_demo/models/demo_area_loader.py b/spp_demo/models/demo_area_loader.py index 09ef28d7..d6a8352b 100644 --- a/spp_demo/models/demo_area_loader.py +++ b/spp_demo/models/demo_area_loader.py @@ -38,18 +38,13 @@ class DemoAreaLoader(models.TransientModel): load_shapes = fields.Boolean( string="Load Shapes (GIS)", default=True, - help="Load polygon shapes for GIS visualization. " - "Requires spp_gis module to be installed.", + help="Load polygon shapes for GIS visualization. Requires spp_gis module to be installed.", ) @api.model def _is_gis_installed(self): """Check if spp_gis module is installed.""" - return bool( - self.env["ir.module.module"].search( - [("name", "=", "spp_gis"), ("state", "=", "installed")] - ) - ) + return bool(self.env["ir.module.module"].search([("name", "=", "spp_gis"), ("state", "=", "installed")])) @api.model def _get_country_name(self, country_code): @@ -77,11 +72,10 @@ def action_load_areas(self): try: file_path(f"spp_demo/data/countries/{country_code}/area_kinds.xml") file_path(f"spp_demo/data/countries/{country_code}/areas.xml") - except FileNotFoundError: + except FileNotFoundError as exc: raise UserError( - _("Area data files not found for country: %s") - % self._get_country_name(country_code) - ) + _("Area data files not found for country: %s") % self._get_country_name(country_code) + ) from exc # Count existing areas before loading existing_count = self.env["spp.area"].search_count([]) @@ -100,10 +94,10 @@ def action_load_areas(self): # Build notification message country_name = self._get_country_name(country_code) - message = _( - "Successfully loaded geographic data for %(country)s:\n" - "- %(areas)d areas created/updated\n" - ) % {"country": country_name, "areas": areas_created} + message = _("Successfully loaded geographic data for %(country)s:\n- %(areas)d areas created/updated\n") % { + "country": country_name, + "areas": areas_created, + } if shapes_loaded > 0: message += _("- %(shapes)d polygon shapes loaded\n") % {"shapes": shapes_loaded} diff --git a/spp_demo/models/demo_data_generator.py b/spp_demo/models/demo_data_generator.py index f5b72d38..9ee4fdce 100644 --- a/spp_demo/models/demo_data_generator.py +++ b/spp_demo/models/demo_data_generator.py @@ -299,11 +299,12 @@ def _refresh_gis_reports(self): return for report in reports: + report_name = report.name try: report._refresh_data() - _logger.info("Refreshed GIS report: %s", report.name) + _logger.info("Refreshed GIS report: %s", report_name) except Exception: - _logger.exception("Failed to refresh GIS report: %s", report.name) + _logger.exception("Failed to refresh GIS report: %s", report_name) def generate_groups(self, fake): group_vals = self.get_group_vals(fake) @@ -370,18 +371,13 @@ def _get_leaf_areas(self): recordset: spp.area records at the deepest available level """ Area = self.env["spp.area"] - all_areas = Area.search([]) - if not all_areas: - return Area.browse() - # Find the maximum area_level (deepest in hierarchy) - max_level = max(all_areas.mapped("area_level")) + # Query only leaf areas directly: areas that have no children. + # This is a single SQL query rather than loading all areas into memory. + leaf_areas = Area.search([("child_ids", "=", False)]) - # Get areas at the deepest level - leaf_areas = all_areas.filtered(lambda a: a.area_level == max_level) - - # Fallback: if no areas at max level, use all areas - return leaf_areas if leaf_areas else all_areas + # Fallback: if no leaf areas found, return all areas + return leaf_areas if leaf_areas else Area.search([]) def get_group_vals(self, fake): registration_date = self.get_random_date( diff --git a/spp_demo/tests/test_demo_area_loader.py b/spp_demo/tests/test_demo_area_loader.py index 1c4127cf..585c2b0b 100644 --- a/spp_demo/tests/test_demo_area_loader.py +++ b/spp_demo/tests/test_demo_area_loader.py @@ -57,10 +57,12 @@ def test_load_phl_areas(self): initial_count = self.area_model.search_count([]) # Load Philippines areas - loader = self.loader_model.create({ - "country_code": "phl", - "load_shapes": False, - }) + loader = self.loader_model.create( + { + "country_code": "phl", + "load_shapes": False, + } + ) loader._load_xml_data("phl") # Verify areas were created @@ -82,10 +84,12 @@ def test_load_phl_areas(self): def test_load_lka_areas(self): """Test loading Sri Lanka area data.""" # Load Sri Lanka areas - loader = self.loader_model.create({ - "country_code": "lka", - "load_shapes": False, - }) + loader = self.loader_model.create( + { + "country_code": "lka", + "load_shapes": False, + } + ) loader._load_xml_data("lka") # Verify specific areas exist @@ -98,10 +102,12 @@ def test_load_lka_areas(self): def test_load_tgo_areas(self): """Test loading Togo area data.""" # Load Togo areas - loader = self.loader_model.create({ - "country_code": "tgo", - "load_shapes": False, - }) + loader = self.loader_model.create( + { + "country_code": "tgo", + "load_shapes": False, + } + ) loader._load_xml_data("tgo") # Verify specific areas exist @@ -122,10 +128,12 @@ def test_load_country_areas_programmatic(self): def test_geojson_to_wkt_polygon(self): """Test GeoJSON polygon to WKT conversion.""" - loader = self.loader_model.create({ - "country_code": "phl", - "load_shapes": False, - }) + loader = self.loader_model.create( + { + "country_code": "phl", + "load_shapes": False, + } + ) geometry = { "type": "Polygon", @@ -138,10 +146,12 @@ def test_geojson_to_wkt_polygon(self): def test_geojson_to_wkt_invalid(self): """Test GeoJSON conversion with invalid input.""" - loader = self.loader_model.create({ - "country_code": "phl", - "load_shapes": False, - }) + loader = self.loader_model.create( + { + "country_code": "phl", + "load_shapes": False, + } + ) # Empty geometry wkt = loader._geojson_to_wkt({}) @@ -163,10 +173,12 @@ def setUpClass(cls): def test_phl_area_kinds(self): """Test Philippines area kinds hierarchy.""" - loader = self.loader_model.create({ - "country_code": "phl", - "load_shapes": False, - }) + loader = self.loader_model.create( + { + "country_code": "phl", + "load_shapes": False, + } + ) loader._load_xml_data("phl") # Check region kind exists @@ -187,10 +199,12 @@ def test_phl_area_kinds(self): def test_lka_area_kinds(self): """Test Sri Lanka area kinds hierarchy.""" - loader = self.loader_model.create({ - "country_code": "lka", - "load_shapes": False, - }) + loader = self.loader_model.create( + { + "country_code": "lka", + "load_shapes": False, + } + ) loader._load_xml_data("lka") # Check DS Division kind exists @@ -203,10 +217,12 @@ def test_lka_area_kinds(self): def test_tgo_area_kinds(self): """Test Togo area kinds hierarchy.""" - loader = self.loader_model.create({ - "country_code": "tgo", - "load_shapes": False, - }) + loader = self.loader_model.create( + { + "country_code": "tgo", + "load_shapes": False, + } + ) loader._load_xml_data("tgo") # Check prefecture kind exists diff --git a/spp_demo/tests/test_demo_stories.py b/spp_demo/tests/test_demo_stories.py index 32f22205..caafee49 100644 --- a/spp_demo/tests/test_demo_stories.py +++ b/spp_demo/tests/test_demo_stories.py @@ -60,9 +60,7 @@ def test_02_get_all_stories(self): background_stories = demo_stories.get_background_stories() tutorial_stories = demo_stories.get_tutorial_stories() - self.assertEqual( - len(all_stories), len(main_stories) + len(background_stories) + len(tutorial_stories) - ) + self.assertEqual(len(all_stories), len(main_stories) + len(background_stories) + len(tutorial_stories)) self.assertGreater(len(main_stories), 0) def test_03_get_story_by_id(self): diff --git a/spp_demo/views/demo_data_generator_view.xml b/spp_demo/views/demo_data_generator_view.xml index 0793170f..10584e8f 100644 --- a/spp_demo/views/demo_data_generator_view.xml +++ b/spp_demo/views/demo_data_generator_view.xml @@ -251,17 +251,33 @@
-
-
+
+
-

+

+

-

+

+

+ @@ -8,8 +8,8 @@
- - + +
@@ -19,7 +19,7 @@ type="object" class="btn-primary" /> -
From 1e923598c2ad475b1c17f496a1a782a864f4a18b Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Fri, 20 Feb 2026 18:23:31 +0800 Subject: [PATCH 3/3] fix(spp_demo): use area_level filter instead of non-stored child_ids child_ids on spp.area is a computed non-stored One2many that cannot be used in search domains. Use area_level (stored) to find leaf areas. --- spp_demo/models/demo_data_generator.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/spp_demo/models/demo_data_generator.py b/spp_demo/models/demo_data_generator.py index 9ee4fdce..c0a26012 100644 --- a/spp_demo/models/demo_data_generator.py +++ b/spp_demo/models/demo_data_generator.py @@ -371,13 +371,11 @@ def _get_leaf_areas(self): recordset: spp.area records at the deepest available level """ Area = self.env["spp.area"] - - # Query only leaf areas directly: areas that have no children. - # This is a single SQL query rather than loading all areas into memory. - leaf_areas = Area.search([("child_ids", "=", False)]) - - # Fallback: if no leaf areas found, return all areas - return leaf_areas if leaf_areas else Area.search([]) + all_areas = Area.search([]) + if not all_areas: + return all_areas + max_level = max(all_areas.mapped("area_level")) + return all_areas.filtered(lambda a: a.area_level == max_level) def get_group_vals(self, fake): registration_date = self.get_random_date(