From f1793cbe052a0989008b1afeb387a43fd4451d6e Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 2 Jul 2026 22:29:48 +0800 Subject: [PATCH 1/8] feat(spp_gis): re-land geofence targeting for programs with tag migration (from #76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-lands the PR #76 description scope: spp_gis complex-geometry operators (MultiPolygon/GeometryCollection + distance buffering), OSM fallback when no MapTiler key is configured, renderer/edit-widget lifecycle fixes, geofence uuid in GeoJSON output, the spp.gis.geofence.tag model, and the new spp_program_geofence module (geofence eligibility manager, program UI, creation wizard). Adds what the original PR lacked: a migration pair remapping existing vocabulary-based geofence tag links (release v19.0.2.0.0 schema) onto spp.gis.geofence.tag records — the rel table is reused, so legacy rows are parked pre-upgrade and restored post-upgrade. Includes regression tests. --- spp_gis/__manifest__.py | 4 +- spp_gis/controllers/main.py | 3 + .../migrations/19.0.2.1.0/post-migration.py | 56 ++ .../migrations/19.0.2.1.0/pre-migration.py | 71 ++ spp_gis/models/geofence.py | 16 +- spp_gis/operators.py | 34 +- spp_gis/readme/HISTORY.md | 7 + spp_gis/security/ir.model.access.csv | 3 + .../static/src/js/osm_fallback_style.esm.js | 32 + .../gis/gis_renderer/gis_renderer.esm.js | 324 ++++++---- .../gis_edit_map/field_gis_edit_map.esm.js | 175 ++--- spp_gis/tests/__init__.py | 1 + spp_gis/tests/test_geo_fields.py | 225 +++++++ spp_gis/tests/test_geofence.py | 16 +- spp_gis/tests/test_migration_geofence_tags.py | 105 +++ spp_program_geofence/README.rst | 95 +++ spp_program_geofence/__init__.py | 3 + spp_program_geofence/__manifest__.py | 28 + spp_program_geofence/models/__init__.py | 3 + .../models/eligibility_manager.py | 253 ++++++++ spp_program_geofence/models/program.py | 35 + spp_program_geofence/pyproject.toml | 3 + spp_program_geofence/readme/DESCRIPTION.md | 27 + .../security/ir.model.access.csv | 9 + .../static/description/index.html | 13 + spp_program_geofence/tests/__init__.py | 3 + .../tests/test_create_program_wizard.py | 146 +++++ .../tests/test_geofence_eligibility.py | 604 ++++++++++++++++++ .../views/eligibility_manager_view.xml | 101 +++ spp_program_geofence/views/geofence_view.xml | 139 ++++ spp_program_geofence/views/program_view.xml | 82 +++ spp_program_geofence/wizard/__init__.py | 2 + .../wizard/create_program_wizard.py | 52 ++ .../wizard/create_program_wizard.xml | 47 ++ 34 files changed, 2470 insertions(+), 247 deletions(-) create mode 100644 spp_gis/migrations/19.0.2.1.0/post-migration.py create mode 100644 spp_gis/migrations/19.0.2.1.0/pre-migration.py create mode 100644 spp_gis/static/src/js/osm_fallback_style.esm.js create mode 100644 spp_gis/tests/test_migration_geofence_tags.py create mode 100644 spp_program_geofence/README.rst create mode 100644 spp_program_geofence/__init__.py create mode 100644 spp_program_geofence/__manifest__.py create mode 100644 spp_program_geofence/models/__init__.py create mode 100644 spp_program_geofence/models/eligibility_manager.py create mode 100644 spp_program_geofence/models/program.py create mode 100644 spp_program_geofence/pyproject.toml create mode 100644 spp_program_geofence/readme/DESCRIPTION.md create mode 100644 spp_program_geofence/security/ir.model.access.csv create mode 100644 spp_program_geofence/static/description/index.html create mode 100644 spp_program_geofence/tests/__init__.py create mode 100644 spp_program_geofence/tests/test_create_program_wizard.py create mode 100644 spp_program_geofence/tests/test_geofence_eligibility.py create mode 100644 spp_program_geofence/views/eligibility_manager_view.xml create mode 100644 spp_program_geofence/views/geofence_view.xml create mode 100644 spp_program_geofence/views/program_view.xml create mode 100644 spp_program_geofence/wizard/__init__.py create mode 100644 spp_program_geofence/wizard/create_program_wizard.py create mode 100644 spp_program_geofence/wizard/create_program_wizard.xml diff --git a/spp_gis/__manifest__.py b/spp_gis/__manifest__.py index f22d35a85..d57d1c4f6 100644 --- a/spp_gis/__manifest__.py +++ b/spp_gis/__manifest__.py @@ -4,14 +4,14 @@ { "name": "OpenSPP GIS", "category": "OpenSPP/Core", - "version": "19.0.2.0.0", + "version": "19.0.2.1.0", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", "license": "LGPL-3", "development_status": "Production/Stable", "maintainers": ["jeremi", "gonzalesedwin1123", "reichie020212"], - "depends": ["base", "web", "contacts", "spp_security", "spp_area", "spp_vocabulary", "spp_registry"], + "depends": ["base", "web", "contacts", "spp_security", "spp_area", "spp_registry"], "external_dependencies": {"python": ["shapely", "pyproj", "geojson"]}, "data": [ "data/res_config_data.xml", diff --git a/spp_gis/controllers/main.py b/spp_gis/controllers/main.py index acc51b692..dd0924614 100644 --- a/spp_gis/controllers/main.py +++ b/spp_gis/controllers/main.py @@ -7,6 +7,9 @@ class MainController(http.Controller): def get_maptiler_api_key(self): # nosemgrep: odoo-sudo-without-context map_tiler_api_key = request.env["ir.config_parameter"].sudo().get_param("spp_gis.map_tiler_api_key") + # Treat the default placeholder as "not configured" + if map_tiler_api_key == "YOUR_MAPTILER_API_KEY_HERE": + map_tiler_api_key = False # nosemgrep: odoo-sudo-without-context web_base_url = request.env["ir.config_parameter"].sudo().get_param("web.base.url") return {"mapTilerKey": map_tiler_api_key, "webBaseUrl": web_base_url} diff --git a/spp_gis/migrations/19.0.2.1.0/post-migration.py b/spp_gis/migrations/19.0.2.1.0/post-migration.py new file mode 100644 index 000000000..d49379bb8 --- /dev/null +++ b/spp_gis/migrations/19.0.2.1.0/post-migration.py @@ -0,0 +1,56 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Recreate geofence tag links parked by the matching pre-migration. + +Creates one spp.gis.geofence.tag per distinct legacy vocabulary name and +restores the geofence links, then drops the aux table. See pre-migration.py +for the full story. +""" + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags") + +AUX_TABLE = "spp_gis_geofence_tag_legacy_migration" + + +def _table_exists(cr, table): + cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name = %s", (table,)) + return bool(cr.fetchone()) + + +def migrate(cr, version): + if not _table_exists(cr, AUX_TABLE): + return + + env = api.Environment(cr, SUPERUSER_ID, {}) + Tag = env["spp.gis.geofence.tag"] + + cr.execute(f"SELECT DISTINCT vocab_name FROM {AUX_TABLE}") + tag_ids_by_name = {} + for (name,) in cr.fetchall(): + tag = Tag.search([("name", "=", name)], limit=1) + if not tag: + tag = Tag.create({"name": name}) + tag_ids_by_name[name] = tag.id + + restored = 0 + cr.execute(f"SELECT geofence_id, vocab_name FROM {AUX_TABLE}") + for geofence_id, name in cr.fetchall(): + cr.execute( + """ + INSERT INTO spp_gis_geofence_tag_rel (geofence_id, tag_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (geofence_id, tag_ids_by_name[name]), + ) + restored += cr.rowcount + + cr.execute(f"DROP TABLE {AUX_TABLE}") + _logger.info( + "spp_gis geofence tag migration: created %s tags, restored %s links", + len(tag_ids_by_name), + restored, + ) diff --git a/spp_gis/migrations/19.0.2.1.0/pre-migration.py b/spp_gis/migrations/19.0.2.1.0/pre-migration.py new file mode 100644 index 000000000..551da9f21 --- /dev/null +++ b/spp_gis/migrations/19.0.2.1.0/pre-migration.py @@ -0,0 +1,71 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Park legacy vocabulary-based geofence tag links before the schema swap. + +Up to v19.0.2.0.0 (release Biliran), spp.gis.geofence.tag_ids pointed at +spp.vocabulary through spp_gis_geofence_tag_rel. The field now points at the +new spp.gis.geofence.tag model over the SAME rel table. During upgrade the ORM +re-targets the rel table's tag_id foreign key at the new table; legacy rows +referencing vocabulary ids would make that constraint swap fail. + +This pre-migration moves legacy rows (with the referenced vocabulary's name) +into an aux table; the matching post-migration recreates them as +spp.gis.geofence.tag links and drops the aux table. +""" + +import logging + +_logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags") + +AUX_TABLE = "spp_gis_geofence_tag_legacy_migration" + + +def _table_exists(cr, table): + cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name = %s", (table,)) + return bool(cr.fetchone()) + + +def migrate(cr, version): + if not _table_exists(cr, "spp_gis_geofence_tag_rel"): + return + + # A row is a valid new-style link if its tag_id exists in the new tag + # table (only possible when this migration reruns or in tests); it is + # legacy if it references a vocabulary instead. Checking the new table + # first resolves numeric id collisions in favor of valid links. + new_table_exists = _table_exists(cr, "spp_gis_geofence_tag") + valid_clause = ( + "AND rel.tag_id NOT IN (SELECT id FROM spp_gis_geofence_tag)" if new_table_exists else "" + ) + + cr.execute( + f""" + CREATE TABLE IF NOT EXISTS {AUX_TABLE} ( + geofence_id integer NOT NULL, + vocab_name varchar NOT NULL + ) + """ + ) + cr.execute( + f""" + INSERT INTO {AUX_TABLE} (geofence_id, vocab_name) + SELECT rel.geofence_id, + -- name is a translated (jsonb) field: prefer en_US, else any value + COALESCE(v.name->>'en_US', (SELECT t.value FROM jsonb_each_text(v.name) t LIMIT 1)) + FROM spp_gis_geofence_tag_rel rel + JOIN spp_vocabulary v ON v.id = rel.tag_id + WHERE true {valid_clause} + """ + ) + parked = cr.rowcount + cr.execute( + f""" + DELETE FROM spp_gis_geofence_tag_rel rel + USING spp_vocabulary v + WHERE v.id = rel.tag_id {valid_clause} + """ + ) + _logger.info( + "spp_gis geofence tag migration: parked %s legacy vocabulary tag links (%s rows removed)", + parked, + cr.rowcount, + ) diff --git a/spp_gis/models/geofence.py b/spp_gis/models/geofence.py index a54f0f2c7..683ed8ffc 100644 --- a/spp_gis/models/geofence.py +++ b/spp_gis/models/geofence.py @@ -15,6 +15,18 @@ _logger = logging.getLogger(__name__) +class GisGeofenceTag(models.Model): + """Tags for classifying geofences.""" + + _name = "spp.gis.geofence.tag" + _description = "Geofence Tag" + _order = "name" + + name = fields.Char(required=True, translate=True) + color = fields.Integer(string="Color Index") + active = fields.Boolean(default=True) + + class GisGeofence(models.Model): """Saved Geographic Areas of Interest. @@ -63,7 +75,7 @@ class GisGeofence(models.Model): # Tags for flexible classification tag_ids = fields.Many2many( - "spp.vocabulary", + "spp.gis.geofence.tag", "spp_gis_geofence_tag_rel", "geofence_id", "tag_id", @@ -167,6 +179,7 @@ def to_geojson(self): if not self.geometry: return { "type": "Feature", + "id": self.uuid, "geometry": None, "properties": self._get_geojson_properties(), } @@ -180,6 +193,7 @@ def to_geojson(self): return { "type": "Feature", + "id": self.uuid, "geometry": geometry_dict, "properties": self._get_geojson_properties(), } diff --git a/spp_gis/operators.py b/spp_gis/operators.py index ee996bbff..03a1c7b78 100644 --- a/spp_gis/operators.py +++ b/spp_gis/operators.py @@ -93,6 +93,8 @@ class Operator: "Point": "point", "LineString": "line", "Polygon": "polygon", + "MultiPolygon": "multipolygon", + "GeometryCollection": "geometrycollection", } def __init__(self, field, table_alias=None): @@ -256,6 +258,18 @@ def create_polygon(self, coordinates, srid): polygon = self.st_makepolygon(points) return self.st_setsrid(polygon, srid) + def create_from_geojson(self, geojson_dict, srid): + """Create geometry from full GeoJSON using ST_GeomFromGeoJSON. + + Used for complex geometry types (MultiPolygon, GeometryCollection) + that cannot be easily constructed from coordinates. + + Returns a SQL object with the GeoJSON string as a bound parameter + to avoid SQL injection via string interpolation. + """ + geojson_str = json.dumps(geojson_dict) + return SQL("ST_SetSRID(ST_GeomFromGeoJSON(%s), %s)", geojson_str, srid) + def validate_coordinates_for_point(self, coordinates): """ The function `validate_coordinates_for_point` checks if a set of coordinates represents a valid @@ -454,7 +468,10 @@ def validate_geojson(self, geojson): to validate the structure of the GeoJSON using the `shape` function """ if geojson.get("type") not in self.ALLOWED_LAYER_TYPE: - raise ValueError("Invalid geojson type. Allowed types are Point, LineString, and Polygon.") + raise ValueError( + "Invalid geojson type. Allowed types are Point, LineString, " + "Polygon, MultiPolygon, and GeometryCollection." + ) try: shape(geojson) except Exception as e: @@ -487,6 +504,19 @@ def domain_query(self, operator, value): operation = self.OPERATION_TO_RELATION[operator] layer_type = self.ALLOWED_LAYER_TYPE[geojson_val["type"]] - coordinates = geojson_val["coordinates"] + if layer_type in ("multipolygon", "geometrycollection"): + # Complex types use ST_GeomFromGeoJSON directly + geom = self.create_from_geojson(geojson_val, self.field.srid) + postgis_fn = self.POSTGIS_SPATIAL_RELATION[operation] + right = SQL(self.qualified_field_name) + if distance: + left = geom + if self.field.srid == 4326: + left = SQL("ST_Transform(%s, %s)", geom, 3857) + right = SQL("ST_Transform(%s, %s)", right, 3857) + return SQL("%s(ST_Buffer(%s, %s), %s)", SQL(postgis_fn), left, distance, right) + return SQL("%s(%s, %s)", SQL(postgis_fn), geom, right) + + coordinates = geojson_val["coordinates"] return SQL(self.get_postgis_query(operation, coordinates, distance=distance, layer_type=layer_type)) diff --git a/spp_gis/readme/HISTORY.md b/spp_gis/readme/HISTORY.md index 4aaf9afef..4d10b22fd 100644 --- a/spp_gis/readme/HISTORY.md +++ b/spp_gis/readme/HISTORY.md @@ -1,3 +1,10 @@ +### 19.0.2.1.0 + +- feat: spatial operators support MultiPolygon and GeometryCollection, including distance buffering (re-land from #76). +- feat: OSM style fallback in map renderer/edit widgets when no MapTiler API key is configured; placeholder key treated as unconfigured (re-land from #76). +- feat: geofence GeoJSON output includes the record uuid as feature id; new `spp.gis.geofence.tag` model replaces vocabulary-based geofence tags (re-land from #76). +- feat: migration remaps existing vocabulary-based geofence tag links onto `spp.gis.geofence.tag` records when upgrading from 19.0.2.0.x. + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_gis/security/ir.model.access.csv b/spp_gis/security/ir.model.access.csv index 16070167b..a17c06fd4 100644 --- a/spp_gis/security/ir.model.access.csv +++ b/spp_gis/security/ir.model.access.csv @@ -6,6 +6,9 @@ access_spp_raster_layer_admin,Raster Layer Admin,spp_gis.model_spp_gis_raster_la access_spp_raster_layer_type_admin,Raster Layer Type Admin,spp_gis.model_spp_gis_raster_layer_type,spp_security.group_spp_admin,1,1,1,1 access_spp_data_layer_read,Data Layer Read,spp_gis.model_spp_gis_data_layer,spp_registry.group_registry_read,1,0,0,0 access_spp_raster_layer_read,Raster Layer Read,spp_gis.model_spp_gis_raster_layer,spp_registry.group_registry_read,1,0,0,0 +access_spp_gis_geofence_tag_admin,Geofence Tag Admin,spp_gis.model_spp_gis_geofence_tag,spp_security.group_spp_admin,1,1,1,1 +access_spp_gis_geofence_tag_manager,Geofence Tag Manager,spp_gis.model_spp_gis_geofence_tag,spp_registry.group_registry_manager,1,1,1,0 +access_spp_gis_geofence_tag_read,Geofence Tag Read,spp_gis.model_spp_gis_geofence_tag,spp_registry.group_registry_read,1,0,0,0 access_spp_gis_geofence_admin,Geofence Admin,spp_gis.model_spp_gis_geofence,spp_security.group_spp_admin,1,1,1,1 access_spp_gis_geofence_manager,Geofence Manager,spp_gis.model_spp_gis_geofence,spp_registry.group_registry_manager,1,1,1,1 access_spp_gis_geofence_officer,Geofence Officer,spp_gis.model_spp_gis_geofence,spp_registry.group_registry_officer,1,1,1,0 diff --git a/spp_gis/static/src/js/osm_fallback_style.esm.js b/spp_gis/static/src/js/osm_fallback_style.esm.js new file mode 100644 index 000000000..5a1635d79 --- /dev/null +++ b/spp_gis/static/src/js/osm_fallback_style.esm.js @@ -0,0 +1,32 @@ +/** @odoo-module */ + +/** + * MapLibre/MapTiler GL style for an OpenStreetMap raster fallback, used when no + * MapTiler API key is configured. Shared by the GIS renderer and the geo-edit + * map field widget so the fallback stays consistent in one place. + * + * @returns {Object} A MapLibre GL style object backed by OSM raster tiles. + */ +export function osmFallbackStyle() { + return { + version: 8, + sources: { + osm: { + type: "raster", + tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], + tileSize: 256, + attribution: + '© OpenStreetMap contributors', + }, + }, + layers: [ + { + id: "osm-tiles", + type: "raster", + source: "osm", + minzoom: 0, + maxzoom: 19, + }, + ], + }; +} diff --git a/spp_gis/static/src/js/views/gis/gis_renderer/gis_renderer.esm.js b/spp_gis/static/src/js/views/gis/gis_renderer/gis_renderer.esm.js index 34fbb8f5b..b3182b6af 100644 --- a/spp_gis/static/src/js/views/gis/gis_renderer/gis_renderer.esm.js +++ b/spp_gis/static/src/js/views/gis/gis_renderer/gis_renderer.esm.js @@ -16,6 +16,7 @@ import {loadCSS, loadJS} from "@web/core/assets"; import {LayersPanel} from "../layers_panel/layers_panel.esm"; import {RelationalModel} from "@web/model/relational_model/relational_model"; import {dataLayersStore} from "../../../data_layers_store.esm"; +import {osmFallbackStyle} from "../../../osm_fallback_style.esm"; import {parseXML} from "@web/core/utils/xml"; import {rasterLayersStore} from "../../../raster_layers_store.esm"; import {registry} from "@web/core/registry"; @@ -91,7 +92,9 @@ export class GisRenderer extends Component { }); onMounted(() => { - maptilersdk.config.apiKey = this.mapTilerKey; + if (this.mapTilerKey) { + maptilersdk.config.apiKey = this.mapTilerKey; + } this.setupSourceAndLayer(); this.renderMap(); @@ -106,14 +109,11 @@ export class GisRenderer extends Component { async getMapTilerKey() { try { const response = await this.rpc("/get_maptiler_api_key"); - this.mapTilerKey = response.mapTilerKey; if (response.mapTilerKey) { this.mapTilerKey = response.mapTilerKey; - } else { - console.log("Error: Api Key not found."); } } catch (error) { - console.error("Error fetching environment variable:", error); + console.warn("Could not fetch MapTiler API key:", error); } } @@ -131,7 +131,8 @@ export class GisRenderer extends Component { try { domainArray = typeof domain === "string" ? JSON.parse(domain) : domain; } catch { - return true; // Invalid domain, show all records + // Invalid domain, show all records + return true; } if (!Array.isArray(domainArray) || domainArray.length === 0) return true; @@ -140,42 +141,42 @@ export class GisRenderer extends Component { if (!Array.isArray(condition) || condition.length !== 3) continue; const [field, operator, expected] = condition; - const actual = values[field]; - - let match = false; - switch (operator) { - case "=": - match = actual === expected; - break; - case "!=": - match = actual !== expected; - break; - case "<": - match = actual < expected; - break; - case ">": - match = actual > expected; - break; - case "<=": - match = actual <= expected; - break; - case ">=": - match = actual >= expected; - break; - case "in": - match = Array.isArray(expected) && expected.includes(actual); - break; - case "not in": - match = !Array.isArray(expected) || !expected.includes(actual); - break; - default: - match = true; - } - if (!match) return false; + if (!this._matchesCondition(values[field], operator, expected)) + return false; } return true; } + /** + * Evaluate a single domain condition operator. + * @param {*} actual - Record value for the field + * @param {String} operator - Domain operator + * @param {*} expected - Expected value + * @returns {Boolean} True if the value satisfies the operator + */ + _matchesCondition(actual, operator, expected) { + switch (operator) { + case "=": + return actual === expected; + case "!=": + return actual !== expected; + case "<": + return actual < expected; + case ">": + return actual > expected; + case "<=": + return actual <= expected; + case ">=": + return actual >= expected; + case "in": + return Array.isArray(expected) && expected.includes(actual); + case "not in": + return !Array.isArray(expected) || !expected.includes(actual); + default: + return true; + } + } + /** * Load records for a specific model via RPC. * @param {String} model - Model name (e.g., "stock.warehouse") @@ -416,7 +417,7 @@ export class GisRenderer extends Component { let defaultMapStyle = this.getMapStyle(); - if (this.defaultRaster) { + if (this.mapTilerKey && this.defaultRaster) { if (this.defaultRaster.raster_style.includes("-")) { const rasterStyleArray = this.defaultRaster.raster_style .toUpperCase() @@ -459,11 +460,18 @@ export class GisRenderer extends Component { this.addMouseInteraction(); - const gc = new maptilersdkMaptilerGeocoder.GeocodingControl({}); - this.map.addControl(gc, "top-left"); + if (this.mapTilerKey) { + const gc = new maptilersdkMaptilerGeocoder.GeocodingControl({}); + this.map.addControl(gc, "top-left"); + } } getMapStyle(layer) { + if (!this.mapTilerKey) { + // Fallback: OSM raster tiles (no API key required) + return osmFallbackStyle(); + } + let mapStyle = maptilersdk.MapStyle.STREETS; if (layer) { @@ -550,11 +558,12 @@ export class GisRenderer extends Component { { type: "image", url: layer.image_url, + // Corners: top-left, top-right, bottom-right, bottom-left coordinates: [ - [layer.x_min, layer.y_max], // Top-left - [layer.x_max, layer.y_max], // Top-right - [layer.x_max, layer.y_min], // Bottom-right - [layer.x_min, layer.y_min], // Bottom-left + [layer.x_min, layer.y_max], + [layer.x_max, layer.y_max], + [layer.x_max, layer.y_min], + [layer.x_min, layer.y_min], ], }, ]); @@ -640,8 +649,9 @@ export class GisRenderer extends Component { return layer.begin_color || "#3388ff"; } - // Build step expression - const steps = [colors[0]]; // Default color for values below first break + // Build step expression. + // colors[0] is the default color for values below the first break. + const steps = [colors[0]]; breaks.forEach((breakValue, index) => { steps.push(breakValue); steps.push(colors[Math.min(index + 1, colors.length - 1)]); @@ -956,11 +966,12 @@ export class GisRenderer extends Component { if (source) { source.updateImage({ url: layer.image_url, + // Corners: top-left, top-right, bottom-right, bottom-left coordinates: [ - [layer.x_min, layer.y_max], // Top-left - [layer.x_max, layer.y_max], // Top-right - [layer.x_max, layer.y_min], // Bottom-right - [layer.x_min, layer.y_min], // Bottom-left + [layer.x_min, layer.y_max], + [layer.x_max, layer.y_max], + [layer.x_max, layer.y_min], + [layer.x_min, layer.y_min], ], }); this.map.setLayoutProperty(sourceId, "visibility", visibility); @@ -1004,93 +1015,126 @@ export class GisRenderer extends Component { continue; } - const config = layer.choropleth_config; - const legendDiv = document.createElement("div"); - legendDiv.className = "choropleth-legend"; + this._renderModelChoroplethLegend(legendContainer, layer); + } + } - // Title - const titleDiv = document.createElement("div"); - titleDiv.className = "choropleth-legend-title"; - titleDiv.textContent = - config.legend_title || config.field_label || layer.name; - legendDiv.appendChild(titleDiv); + /** + * Render the legend for a single model-based choropleth layer. + * @param {HTMLElement} legendContainer - Container to append the legend to + * @param {Object} layer - Layer with a choropleth_config + */ + _renderModelChoroplethLegend(legendContainer, layer) { + const config = layer.choropleth_config; + const legendDiv = document.createElement("div"); + legendDiv.className = "choropleth-legend"; - const colors = config.color_ramp || ["#00ff00", "#ff0000"]; - const minValue = config.min_value || 0; - const maxValue = config.max_value || 100; + // Title + const titleDiv = document.createElement("div"); + titleDiv.className = "choropleth-legend-title"; + titleDiv.textContent = config.legend_title || config.field_label || layer.name; + legendDiv.appendChild(titleDiv); - if (config.classification === "linear" || colors.length <= 3) { - // Render gradient legend - const gradientDiv = document.createElement("div"); - gradientDiv.className = "choropleth-legend-gradient"; - gradientDiv.style.background = `linear-gradient(to right, ${colors.join(", ")})`; - legendDiv.appendChild(gradientDiv); + const colors = config.color_ramp || ["#00ff00", "#ff0000"]; + const minValue = config.min_value || 0; + const maxValue = config.max_value || 100; - const labelsDiv = document.createElement("div"); - labelsDiv.className = "choropleth-legend-labels"; + if (config.classification === "linear" || colors.length <= 3) { + this._buildGradientLegend(legendDiv, colors, minValue, maxValue); + } else { + this._buildStepLegend(legendDiv, config, colors, minValue, maxValue); + } - const minLabel = document.createElement("span"); - minLabel.textContent = this._formatLegendValue(minValue); - labelsDiv.appendChild(minLabel); + legendContainer.appendChild(legendDiv); + } - const maxLabel = document.createElement("span"); - maxLabel.textContent = this._formatLegendValue(maxValue); - labelsDiv.appendChild(maxLabel); + /** + * Append a gradient (linear) legend to the legend element. + */ + _buildGradientLegend(legendDiv, colors, minValue, maxValue) { + const gradientDiv = document.createElement("div"); + gradientDiv.className = "choropleth-legend-gradient"; + gradientDiv.style.background = `linear-gradient(to right, ${colors.join(", ")})`; + legendDiv.appendChild(gradientDiv); - legendDiv.appendChild(labelsDiv); - } else { - // Render step legend for manual breaks or quantile - const stepsDiv = document.createElement("div"); - stepsDiv.className = "choropleth-legend-steps"; - - let breaks = []; - if (config.classification === "manual" && config.manual_breaks) { - breaks = config.manual_breaks - .split(",") - .map((b) => parseFloat(b.trim())) - .filter((b) => !isNaN(b)); - } else { - // Generate breaks for quantile/equal-interval - const classCount = config.class_count || 5; - const stepSize = (maxValue - minValue) / classCount; - for (let i = 0; i < classCount; i++) { - breaks.push(minValue + stepSize * i); - } - } + const labelsDiv = document.createElement("div"); + labelsDiv.className = "choropleth-legend-labels"; - // Create step items - for (let i = 0; i <= breaks.length; i++) { - const stepDiv = document.createElement("div"); - stepDiv.className = "choropleth-legend-step"; - - const colorDiv = document.createElement("div"); - colorDiv.className = "choropleth-legend-step-color"; - const colorIndex = Math.min(i, colors.length - 1); - colorDiv.style.backgroundColor = colors[colorIndex]; - stepDiv.appendChild(colorDiv); - - const labelDiv = document.createElement("div"); - labelDiv.className = "choropleth-legend-step-label"; - - let rangeText = ""; - if (i === 0) { - rangeText = `< ${this._formatLegendValue(breaks[0] || maxValue)}`; - } else if (i === breaks.length) { - rangeText = `≥ ${this._formatLegendValue(breaks[i - 1])}`; - } else { - rangeText = `${this._formatLegendValue(breaks[i - 1])} - ${this._formatLegendValue(breaks[i])}`; - } - labelDiv.textContent = rangeText; - stepDiv.appendChild(labelDiv); + const minLabel = document.createElement("span"); + minLabel.textContent = this._formatLegendValue(minValue); + labelsDiv.appendChild(minLabel); - stepsDiv.appendChild(stepDiv); - } + const maxLabel = document.createElement("span"); + maxLabel.textContent = this._formatLegendValue(maxValue); + labelsDiv.appendChild(maxLabel); - legendDiv.appendChild(stepsDiv); - } + legendDiv.appendChild(labelsDiv); + } + + /** + * Compute the break values for a step legend. + * @returns {Array} Break values + */ + _computeLegendBreaks(config, minValue, maxValue) { + if (config.classification === "manual" && config.manual_breaks) { + return config.manual_breaks + .split(",") + .map((b) => parseFloat(b.trim())) + .filter((b) => !isNaN(b)); + } + // Generate breaks for quantile/equal-interval + const breaks = []; + const classCount = config.class_count || 5; + const stepSize = (maxValue - minValue) / classCount; + for (let i = 0; i < classCount; i++) { + breaks.push(minValue + stepSize * i); + } + return breaks; + } + + /** + * Append a step legend (manual breaks or quantile) to the legend element. + */ + _buildStepLegend(legendDiv, config, colors, minValue, maxValue) { + const stepsDiv = document.createElement("div"); + stepsDiv.className = "choropleth-legend-steps"; + + const breaks = this._computeLegendBreaks(config, minValue, maxValue); + + // Create step items + for (let i = 0; i <= breaks.length; i++) { + const stepDiv = document.createElement("div"); + stepDiv.className = "choropleth-legend-step"; + + const colorDiv = document.createElement("div"); + colorDiv.className = "choropleth-legend-step-color"; + const colorIndex = Math.min(i, colors.length - 1); + colorDiv.style.backgroundColor = colors[colorIndex]; + stepDiv.appendChild(colorDiv); - legendContainer.appendChild(legendDiv); + const labelDiv = document.createElement("div"); + labelDiv.className = "choropleth-legend-step-label"; + labelDiv.textContent = this._formatStepRange(i, breaks, maxValue); + stepDiv.appendChild(labelDiv); + + stepsDiv.appendChild(stepDiv); + } + + legendDiv.appendChild(stepsDiv); + } + + /** + * Format the range label for step `i` of a step legend. + * @returns {String} Human-readable range label + */ + _formatStepRange(i, breaks, maxValue) { + if (i === 0) { + return `< ${this._formatLegendValue(breaks[0] || maxValue)}`; + } + if (i === breaks.length) { + return `≥ ${this._formatLegendValue(breaks[i - 1])}`; } + return `${this._formatLegendValue(breaks[i - 1])} - ${this._formatLegendValue(breaks[i])}`; } /** @@ -1124,7 +1168,8 @@ export class GisRenderer extends Component { const labelDiv = document.createElement("div"); labelDiv.className = "choropleth-legend-step-label"; - labelDiv.textContent = item.label; + const range = this._formatLegendRange(item.min_value, item.max_value); + labelDiv.textContent = range ? `${item.label} (${range})` : item.label; stepDiv.appendChild(labelDiv); stepsDiv.appendChild(stepDiv); @@ -1134,6 +1179,31 @@ export class GisRenderer extends Component { container.appendChild(legendDiv); } + /** + * Format a threshold range as a human-readable string. + * @param {Number|null} minValue - Minimum value (inclusive) + * @param {Number|null} maxValue - Maximum value (exclusive) + * @returns {String} Formatted range, or empty string if no values + */ + _formatLegendRange(minValue, maxValue) { + const hasMin = + minValue !== null && minValue !== undefined && minValue !== false; + const hasMax = + maxValue !== null && maxValue !== undefined && maxValue !== false; + if (!hasMin && !hasMax) { + return ""; + } + const fmtMin = hasMin ? this._formatLegendValue(minValue) : ""; + const fmtMax = hasMax ? this._formatLegendValue(maxValue) : ""; + if (hasMin && hasMax) { + return `${fmtMin} – ${fmtMax}`; + } + if (hasMin) { + return `≥ ${fmtMin}`; + } + return `< ${fmtMax}`; + } + /** * Format a numeric value for display in the legend. * @param {Number} value - The value to format diff --git a/spp_gis/static/src/js/widgets/gis_edit_map/field_gis_edit_map.esm.js b/spp_gis/static/src/js/widgets/gis_edit_map/field_gis_edit_map.esm.js index 74e8d3f10..ba2c811f3 100644 --- a/spp_gis/static/src/js/widgets/gis_edit_map/field_gis_edit_map.esm.js +++ b/spp_gis/static/src/js/widgets/gis_edit_map/field_gis_edit_map.esm.js @@ -3,6 +3,7 @@ import {Component, onMounted, onPatched, onWillStart} from "@odoo/owl"; import {loadCSS, loadJS} from "@web/core/assets"; +import {osmFallbackStyle} from "../../osm_fallback_style.esm"; import {registry} from "@web/core/registry"; import {rpc} from "@web/core/network/rpc"; import {standardFieldProps} from "@web/views/fields/standard_field_props"; @@ -32,7 +33,9 @@ export class FieldGisEditMap extends Component { }); onMounted(async () => { - maptilersdk.config.apiKey = this.mapTilerKey; + if (this.mapTilerKey) { + maptilersdk.config.apiKey = this.mapTilerKey; + } const editInfo = await this.orm.call( this.props.record.resModel, "get_edit_info_for_gis_column", @@ -49,14 +52,12 @@ export class FieldGisEditMap extends Component { }); this.renderMap(); - this.onLoadMap(); this.addDrawInteraction(); }); onPatched(() => { this.defaultZoom = this.map.getZoom(); this.renderMap(); - this.onLoadMap(); this.addDrawInteraction(); }); } @@ -67,23 +68,18 @@ export class FieldGisEditMap extends Component { if (response.mapTilerKey) { this.mapTilerKey = response.mapTilerKey; this.webBaseUrl = response.webBaseUrl; - } else { - console.log("Error: Api Key not found."); } } catch (error) { - console.error("Error fetching environment variable:", error); + console.warn("Could not fetch MapTiler API key:", error); } } - onLoadMap() { - if (this.props.record.data[this.props.name]) { - this.map.on("load", async () => { - this.addSourceAndLayer( - this.sourceId, - this.props.record.data[this.props.name] - ); - }); + _getMapStyle() { + if (this.mapTilerKey) { + return maptilersdk.MapStyle.STREETS; } + // Fallback: OSM raster tiles (no API key required) + return osmFallbackStyle(); } renderMap() { @@ -104,9 +100,14 @@ export class FieldGisEditMap extends Component { this.defaultZoom = 10; } + if (this.map) { + this.draw = null; + this.map.remove(); + } + this.map = new maptilersdk.Map({ container: this.id, - style: maptilersdk.MapStyle.STREETS, + style: this._getMapStyle(), center: this.defaultCenter, zoom: this.defaultZoom, }); @@ -138,96 +139,55 @@ export class FieldGisEditMap extends Component { return null; } - addSourceAndLayer(sourceId, jsonString) { - if (!this.map.getSource(sourceId)) { - this.addSource(sourceId, jsonString); - this.addLayer(sourceId); - } + onUIChange() { + this.addDrawInteraction(); } - addSource(sourceId, jsonString) { - const obj = JSON.parse(jsonString); - const centroid = turf.centroid(obj); + addDrawInteraction() { + const self = this; + const hasData = Boolean(this.props.record.data[this.props.name]); - this.map.addSource(sourceId, { - type: "geojson", - data: obj, - }); - this.map.setCenter(centroid.geometry.coordinates); + // Remove previous event listeners to prevent stacking on onUIChange() calls + if (this._drawHandlers) { + this.map.off("draw.create", this._drawHandlers.update); + this.map.off("draw.update", this._drawHandlers.update); + this.map.off("draw.delete", this._drawHandlers.delete); + } - this.source = this.map.getSource(sourceId); - } + this._drawHandlers = { + update(e) { + const eventFeature = e?.features?.[0]; + if (eventFeature?.geometry) { + self.props.record.update({ + [self.props.name]: JSON.stringify(eventFeature.geometry), + }); + return; + } - addLayer(sourceId) { - // Polygon - this.map.addLayer({ - id: `${sourceId}-polygon-layerid`, - type: "fill", - source: sourceId, - filter: ["all", ["==", "$type", "Polygon"], ["!=", "mode", "static"]], - layout: {}, - paint: { - "fill-color": "#98b", - "fill-opacity": 0.8, + const allFeatures = self.draw.getAll()?.features || []; + if (allFeatures.length > 0 && allFeatures[0].geometry) { + self.props.record.update({ + [self.props.name]: JSON.stringify(allFeatures[0].geometry), + }); + } }, - }); - - // Point - this.map.addLayer({ - id: `${sourceId}-point-layerid`, - type: "circle", - source: sourceId, - filter: ["all", ["==", "$type", "Point"], ["!=", "mode", "static"]], - layout: {}, - paint: { - "circle-color": "#FF680A", + delete() { + self.props.record.update({[self.props.name]: null}); }, - }); - - // Linestring - this.map.addLayer({ - id: `${sourceId}-linestring-layerid`, - type: "line", - source: sourceId, - filter: ["all", ["==", "$type", "LineString"], ["!=", "mode", "static"]], - layout: {}, - paint: { - "line-color": "#e11", - "line-width": 4, - }, - }); - } - - removeSourceAndLayer(source) { - this.map.removeLayer(source); - this.map.removeSource(source); - } - - onUIChange() { - this.removeSourceAndLayer(this.sourceId); - this.onLoadMap(); - this.addDrawInteraction(); - } - - addDrawInteraction() { - const self = this; + }; - function updateArea(e) { - console.log(e); - var data = self.draw.getAll(); - self.props.record.update({ - [self.props.name]: JSON.stringify(data.features[0].geometry), - }); + if (this.draw) { + this.map.removeControl(this.draw); } this.draw = new MapboxDraw({ displayControlsDefault: false, controls: { - [this.drawControl]: !this.props.record.data[this.props.name], - trash: Boolean(this.props.record.data[this.props.name]), + [this.drawControl]: !hasData, + trash: hasData, }, styles: this.addDrawInteractionStyle(), - defaultMode: "custom_mode", + defaultMode: hasData ? "simple_select" : "custom_mode", modes: Object.assign( { custom_mode: this.addDrawCustomModes(), @@ -244,19 +204,27 @@ export class FieldGisEditMap extends Component { elem.classList.add("maplibregl-ctrl", "maplibregl-ctrl-group"); }); - this.map.on("draw.create", updateArea); - this.map.on("draw.update", updateArea); - - const url = `/spp_gis/static/src/images/laos_farm.png`; + // Load existing geometry into MapboxDraw so it's interactive + // (clickable, editable, deletable) instead of a static layer + if (hasData) { + const loadExisting = () => { + const geom = JSON.parse(this.props.record.data[this.props.name]); + this.draw.add({ + type: "Feature", + geometry: geom, + properties: {}, + }); + }; + if (this.map.loaded()) { + loadExisting(); + } else { + this.map.on("load", loadExisting); + } + } - this.map.on("click", `${this.sourceId}-polygon-layerid`, (e) => { - new maptilersdk.Popup() - .setLngLat(e.lngLat) - .setHTML( - `Placeholder Image` - ) - .addTo(this.map); - }); + this.map.on("draw.create", this._drawHandlers.update); + this.map.on("draw.update", this._drawHandlers.update); + this.map.on("draw.delete", this._drawHandlers.delete); } addDrawInteractionStyle() { @@ -369,8 +337,7 @@ export class FieldGisEditMap extends Component { addDrawCustomModes() { const customMode = {}; const self = this; - customMode.onTrash = function (state) { - console.log(state); + customMode.onTrash = function () { self.props.record.update({[self.props.name]: null}); }; diff --git a/spp_gis/tests/__init__.py b/spp_gis/tests/__init__.py index 0a495a1af..d7fdd9246 100644 --- a/spp_gis/tests/__init__.py +++ b/spp_gis/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_color_scheme from . import test_geo_fields from . import test_geofence +from . import test_migration_geofence_tags diff --git a/spp_gis/tests/test_geo_fields.py b/spp_gis/tests/test_geo_fields.py index 124f7f776..e0072b679 100644 --- a/spp_gis/tests/test_geo_fields.py +++ b/spp_gis/tests/test_geo_fields.py @@ -246,3 +246,228 @@ def test_get_postgis_query_with_alias_and_distance(self): ) self.assertIn('"spp_area"."geo_polygon"', result) + + +class TestOperatorMultiPolygon(TransactionCase): + """Test that the Operator handles MultiPolygon and GeometryCollection types.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + test_queue_job_no_delay=True, + ) + ) + + def _make_field(self, name="geo_polygon", srid=4326): + """Create a mock field for the Operator.""" + from odoo.addons.spp_gis.fields import GeoPolygonField + + field = GeoPolygonField() + field.name = name + field.srid = srid + return field + + def test_multipolygon_domain_query(self): + """domain_query accepts MultiPolygon GeoJSON and generates ST_GeomFromGeoJSON SQL.""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field) + + geojson = { + "type": "MultiPolygon", + "coordinates": [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + [[[10, 10], [11, 10], [11, 11], [10, 11], [10, 10]]], + ], + } + + result = operator.domain_query("gis_intersects", geojson) + sql_string = str(result) + + self.assertIn("ST_Intersects", sql_string) + self.assertIn("ST_GeomFromGeoJSON", sql_string) + self.assertIn("MultiPolygon", sql_string) + + def test_multipolygon_domain_query_with_alias(self): + """domain_query for MultiPolygon uses table-qualified column names.""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field, table_alias="spp_area") + + geojson = { + "type": "MultiPolygon", + "coordinates": [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + [[[10, 10], [11, 10], [11, 11], [10, 11], [10, 10]]], + ], + } + + result = operator.domain_query("gis_within", geojson) + sql_string = str(result) + + self.assertIn("ST_Within", sql_string) + self.assertIn('"spp_area"."geo_polygon"', sql_string) + self.assertIn("ST_GeomFromGeoJSON", sql_string) + + def test_multipolygon_from_geojson_string(self): + """domain_query accepts MultiPolygon as a JSON string.""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field) + + geojson_str = json.dumps( + { + "type": "MultiPolygon", + "coordinates": [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + [[[10, 10], [11, 10], [11, 11], [10, 11], [10, 10]]], + ], + } + ) + + result = operator.domain_query("gis_contains", geojson_str) + sql_string = str(result) + + self.assertIn("ST_Contains", sql_string) + self.assertIn("ST_GeomFromGeoJSON", sql_string) + + def test_geometrycollection_domain_query(self): + """domain_query accepts GeometryCollection GeoJSON.""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field) + + geojson = { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + {"type": "Point", "coordinates": [5, 5]}, + ], + } + + result = operator.domain_query("gis_intersects", geojson) + sql_string = str(result) + + self.assertIn("ST_Intersects", sql_string) + self.assertIn("ST_GeomFromGeoJSON", sql_string) + self.assertIn("GeometryCollection", sql_string) + + def test_multipolygon_distance_uses_buffer(self): + """Distance operand on MultiPolygon uses ST_Buffer path.""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field) + + geojson = { + "type": "MultiPolygon", + "coordinates": [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + ], + } + + result = operator.domain_query("gis_intersects", (geojson, 1000)) + sql_string = str(result) + + self.assertIn("ST_Intersects", sql_string) + self.assertIn("ST_Buffer", sql_string) + self.assertIn("ST_Transform", sql_string) + + def test_geometrycollection_distance_uses_buffer(self): + """Distance operand on GeometryCollection uses ST_Buffer path.""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field) + + geojson = { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + } + ], + } + + result = operator.domain_query("gis_intersects", (geojson, 500)) + sql_string = str(result) + + self.assertIn("ST_Intersects", sql_string) + self.assertIn("ST_Buffer", sql_string) + self.assertIn("ST_Transform", sql_string) + + def test_multipolygon_from_shapely(self): + """domain_query accepts a shapely MultiPolygon object.""" + from shapely.geometry import MultiPolygon, Polygon + + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field) + + poly1 = Polygon([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]) + poly2 = Polygon([(10, 10), (11, 10), (11, 11), (10, 11), (10, 10)]) + multi = MultiPolygon([poly1, poly2]) + + result = operator.domain_query("gis_intersects", multi) + sql_string = str(result) + + self.assertIn("ST_Intersects", sql_string) + self.assertIn("ST_GeomFromGeoJSON", sql_string) + + def test_validate_geojson_accepts_multipolygon(self): + """validate_geojson accepts MultiPolygon type.""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field) + + geojson = { + "type": "MultiPolygon", + "coordinates": [ + [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + ], + } + # Should not raise + operator.validate_geojson(geojson) + + def test_validate_geojson_rejects_invalid_type(self): + """validate_geojson rejects unsupported geometry types.""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field) + + geojson = {"type": "InvalidType", "coordinates": []} + with self.assertRaises(ValueError): + operator.validate_geojson(geojson) + + def test_polygon_still_uses_coordinate_construction(self): + """Regular Polygon queries still use the coordinate-based path (not ST_GeomFromGeoJSON).""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field) + + geojson = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + } + + result = operator.domain_query("gis_intersects", geojson) + sql_string = str(result) + + self.assertIn("ST_Intersects", sql_string) + self.assertIn("ST_MakePolygon", sql_string) + self.assertNotIn("ST_GeomFromGeoJSON", sql_string) diff --git a/spp_gis/tests/test_geofence.py b/spp_gis/tests/test_geofence.py index 6d3412a13..15347849d 100644 --- a/spp_gis/tests/test_geofence.py +++ b/spp_gis/tests/test_geofence.py @@ -61,18 +61,12 @@ def setUpClass(cls): ], } - # Create test vocabulary for tags - cls.tag1 = cls.env["spp.vocabulary"].search( - [("namespace_uri", "=", "urn:openspp:concept:geofence_tag_1")], - limit=1, + # Create test tag for geofences + cls.tag1 = cls.env["spp.gis.geofence.tag"].create( + { + "name": "Test Tag 1", + } ) - if not cls.tag1: - cls.tag1 = cls.env["spp.vocabulary"].create( - { - "name": "Test Tag 1", - "namespace_uri": "urn:openspp:concept:geofence_tag_1", - } - ) def test_create_geofence_basic(self): """Test creating a basic geofence.""" diff --git a/spp_gis/tests/test_migration_geofence_tags.py b/spp_gis/tests/test_migration_geofence_tags.py new file mode 100644 index 000000000..07ca73d5e --- /dev/null +++ b/spp_gis/tests/test_migration_geofence_tags.py @@ -0,0 +1,105 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import importlib.util +from pathlib import Path + +from odoo.tests.common import TransactionCase + +MIGRATIONS_DIR = Path(__file__).parent.parent / "migrations" / "19.0.2.1.0" + + +def _load(script): + spec = importlib.util.spec_from_file_location(f"spp_gis_tag_migration_{script}", MIGRATIONS_DIR / script) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestGeofenceTagMigration(TransactionCase): + """Exercise the 19.0.2.1.0 migration pair that remaps geofence tags. + + Up to v19.0.2.0.0 (release Biliran) geofence tag_ids pointed at + spp.vocabulary through spp_gis_geofence_tag_rel. The field now points at + spp.gis.geofence.tag over the SAME rel table. The pre-migration parks + legacy rows in an aux table (so the FK swap during upgrade succeeds); the + post-migration creates one tag per legacy vocabulary and restores links. + + Tests simulate the legacy state by dropping the new FK inside the test + transaction (Postgres DDL rolls back with the test). + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.geofence = cls.env["spp.gis.geofence"].create( + { + "name": "Migration Geofence", + "geometry": '{"type": "Polygon", "coordinates": [[[0, 0], [0, 1], [1, 1], [0, 0]]]}', + } + ) + cls.vocab_a = cls.env["spp.vocabulary"].create( + {"name": "Legacy Tag A", "namespace_uri": "urn:test:legacy-tag-a"} + ) + cls.vocab_b = cls.env["spp.vocabulary"].create( + {"name": "Legacy Tag B", "namespace_uri": "urn:test:legacy-tag-b"} + ) + + def _simulate_legacy_links(self, vocab_ids): + cr = self.env.cr + cr.execute( + "ALTER TABLE spp_gis_geofence_tag_rel DROP CONSTRAINT IF EXISTS spp_gis_geofence_tag_rel_tag_id_fkey" + ) + for vid in vocab_ids: + cr.execute( + "INSERT INTO spp_gis_geofence_tag_rel (geofence_id, tag_id) VALUES (%s, %s)", + (self.geofence.id, vid), + ) + + def _run_both(self): + cr = self.env.cr + _load("pre-migration.py").migrate(cr, "19.0.2.0.0") + _load("post-migration.py").migrate(cr, "19.0.2.0.0") + self.env.invalidate_all() + + def test_01_legacy_links_remapped_to_new_tags(self): + self._simulate_legacy_links([self.vocab_a.id, self.vocab_b.id]) + + self._run_both() + + tags = self.geofence.tag_ids + self.assertEqual(sorted(tags.mapped("name")), ["Legacy Tag A", "Legacy Tag B"]) + # links point at real spp.gis.geofence.tag records + self.assertTrue(all(t._name == "spp.gis.geofence.tag" for t in tags)) + + def test_02_shared_vocabulary_creates_single_tag(self): + other = self.env["spp.gis.geofence"].create( + { + "name": "Migration Geofence 2", + "geometry": '{"type": "Polygon", "coordinates": [[[0, 0], [0, 2], [2, 2], [0, 0]]]}', + } + ) + cr = self.env.cr + self._simulate_legacy_links([self.vocab_a.id]) + cr.execute( + "INSERT INTO spp_gis_geofence_tag_rel (geofence_id, tag_id) VALUES (%s, %s)", + (other.id, self.vocab_a.id), + ) + + self._run_both() + + tags_a = self.env["spp.gis.geofence.tag"].search([("name", "=", "Legacy Tag A")]) + self.assertEqual(len(tags_a), 1, "one tag per legacy vocabulary, shared across geofences") + self.assertIn(tags_a, self.geofence.tag_ids) + self.assertIn(tags_a, other.tag_ids) + + def test_03_valid_new_links_survive(self): + """Rel rows that already reference a real tag are left untouched.""" + tag = self.env["spp.gis.geofence.tag"].create({"name": "Already New"}) + self.geofence.tag_ids = [(4, tag.id)] + + self._run_both() + + self.assertIn(tag, self.geofence.tag_ids) + + def test_04_noop_on_fresh_database(self): + self._run_both() + self.assertFalse(self.geofence.tag_ids) diff --git a/spp_program_geofence/README.rst b/spp_program_geofence/README.rst new file mode 100644 index 000000000..9c765f652 --- /dev/null +++ b/spp_program_geofence/README.rst @@ -0,0 +1,95 @@ +======================== +OpenSPP Program Geofence +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:cfdab1ae1a690a6eab9b52d5afa860a84b9fd0c8bb69c43a7cf822ef68f77728 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_program_geofence + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +OpenSPP Program Geofence +======================== + +Adds geofence-based geographic targeting to OpenSPP programs. + +Features +-------- + +- **Program Geofences**: Define geographic boundaries (geofences) on + programs to scope their geographic coverage. Geofences are configured + on the program's Overview tab. + +- **Geofence Eligibility Manager**: A pluggable eligibility manager that + determines registrant eligibility based on their location relative to + the program's geofences. Works alongside other eligibility managers + using AND logic. + +- **Hybrid Two-Tier Targeting**: + + - **Tier 1 (GPS)**: Matches registrants whose GPS coordinates fall + within the geofence polygons. + - **Tier 2 (Area Fallback)**: For registrants without GPS coordinates, + matches those whose administrative area intersects the geofence. + This fallback can be disabled per manager. + +- **Preview**: Preview how many registrants match the current geofences + before importing. + +Known Limitations +----------------- + +- Groups (households) typically lack GPS coordinates. Enable the area + fallback to match them by administrative area. +- Changing geofences after enrollment does not retroactively affect + existing memberships. Use cycle eligibility verification for ongoing + checks. +- Archived geofences are excluded from spatial queries. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_program_geofence/__init__.py b/spp_program_geofence/__init__.py new file mode 100644 index 000000000..0450a0c80 --- /dev/null +++ b/spp_program_geofence/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import models +from . import wizard diff --git a/spp_program_geofence/__manifest__.py b/spp_program_geofence/__manifest__.py new file mode 100644 index 000000000..f678ea818 --- /dev/null +++ b/spp_program_geofence/__manifest__.py @@ -0,0 +1,28 @@ +# pylint: disable=pointless-statement +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +{ + "name": "OpenSPP Program Geofence", + "summary": "Geofence-based geographic targeting for programs using spatial queries.", + "category": "OpenSPP", + "version": "19.0.1.0.0", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "depends": [ + "spp_programs", + "spp_gis", + "spp_registrant_gis", + "spp_area", + ], + "data": [ + "security/ir.model.access.csv", + "views/geofence_view.xml", + "views/eligibility_manager_view.xml", + "views/program_view.xml", + "wizard/create_program_wizard.xml", + ], + "application": False, + "installable": True, + "auto_install": False, +} diff --git a/spp_program_geofence/models/__init__.py b/spp_program_geofence/models/__init__.py new file mode 100644 index 000000000..39fe0f867 --- /dev/null +++ b/spp_program_geofence/models/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import eligibility_manager +from . import program diff --git a/spp_program_geofence/models/eligibility_manager.py b/spp_program_geofence/models/eligibility_manager.py new file mode 100644 index 000000000..75bab74bc --- /dev/null +++ b/spp_program_geofence/models/eligibility_manager.py @@ -0,0 +1,253 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import json +import logging + +from odoo import _, api, fields, models + +try: + from odoo.addons.job_worker.delay import group +except ImportError: + group = None + +try: + from shapely.geometry import mapping + from shapely.ops import unary_union +except ImportError: + mapping = None + unary_union = None + +_logger = logging.getLogger(__name__) + + +class GeofenceEligibilityManager(models.Model): + _inherit = "spp.eligibility.manager" + + @api.model + def _selection_manager_ref_id(self): + selection = super()._selection_manager_ref_id() + new_manager = ( + "spp.program.membership.manager.geofence", + "Geofence Eligibility", + ) + if new_manager not in selection: + selection.append(new_manager) + return selection + + +class GeofenceMembershipManager(models.Model): + _name = "spp.program.membership.manager.geofence" + _inherit = ["spp.program.membership.manager", "spp.manager.source.mixin"] + _description = "Geofence Eligibility Manager" + ASYNC_IMPORT_THRESHOLD = 1000 + IMPORT_CHUNK_SIZE = 10000 + + include_area_fallback = fields.Boolean( + default=True, + string="Include Area Fallback", + help="When enabled, registrants whose administrative area intersects the geofence " + "are included even if their coordinates are not set.", + ) + fallback_area_type_id = fields.Many2one( + "spp.area.type", + string="Fallback Area Type", + help="When set, only areas of this type are considered for the area fallback. " + "Use this to restrict matching to a specific administrative level (e.g. District) " + "and avoid overly broad matches from large provinces or regions.", + ) + program_geofence_ids = fields.Many2many( + "spp.gis.geofence", + related="program_id.geofence_ids", + readonly=True, + string="Program Geofences", + ) + preview_count = fields.Integer( + string="Preview Count", + readonly=True, + ) + preview_error = fields.Char( + string="Preview Error", + readonly=True, + ) + + def _get_combined_geometry(self): + """Return the union of all geofence geometries for this manager's program. + + Returns None if there are no geofences or if shapely is unavailable. + """ + self.ensure_one() + if unary_union is None: + _logger.warning("spp_program_geofence: shapely is not available; cannot compute combined geometry") + return None + + geofences = self.program_id.geofence_ids + if not geofences: + return None + + shapes = [gf.geometry for gf in geofences if gf.geometry] + if not shapes: + return None + + return unary_union(shapes) + + def _prepare_eligible_domain(self, membership=None): + """Build the base Odoo search domain for eligible registrants. + + Args: + membership: Optional recordset of spp.program.membership records. + When provided, results are restricted to partners in that set. + + Returns: + list: Odoo domain expression. + """ + domain = [] + if membership is not None: + ids = membership.partner_id.ids + domain += [("id", "in", ids)] + + # Exclude disabled registrants + domain += [("disabled", "=", False)] + + if self.program_id.target_type == "group": + domain += [("is_group", "=", True), ("is_registrant", "=", True)] + elif self.program_id.target_type == "individual": + domain += [("is_group", "=", False), ("is_registrant", "=", True)] + + return domain + + def _find_eligible_registrants(self, membership=None): + """Find all registrants that fall within the program's geofences. + + Uses a two-tier approach: + - Tier 1: registrants whose coordinates fall within the combined geofence geometry. + - Tier 2 (when include_area_fallback is True): registrants whose administrative + area intersects the combined geofence geometry and were not already found in tier 1. + + Args: + membership: Optional recordset restricting the search population. + + Returns: + res.partner recordset of eligible registrants. + """ + self.ensure_one() + geofences = self.program_id.geofence_ids + if not geofences: + return self.env["res.partner"].browse() + + combined = self._get_combined_geometry() + if combined is None or combined.is_empty: + return self.env["res.partner"].browse() + + combined_geojson = json.dumps(mapping(combined)) + base_domain = self._prepare_eligible_domain(membership) + + # Tier 1: registrants with coordinates inside the geofence + tier1_domain = base_domain + [("coordinates", "gis_intersects", combined_geojson)] + tier1 = self.env["res.partner"].search(tier1_domain) + + # Tier 2: registrants whose area intersects the geofence + if self.include_area_fallback: + area_domain = [("geo_polygon", "gis_intersects", combined_geojson)] + if self.fallback_area_type_id: + area_domain += [("area_type_id", "=", self.fallback_area_type_id.id)] + matching_areas = self.env["spp.area"].search(area_domain) + if matching_areas: + tier2_domain = base_domain + [ + ("area_id", "in", matching_areas.ids), + ("id", "not in", tier1.ids), + ] + tier2 = self.env["res.partner"].search(tier2_domain) + return tier1 | tier2 + + return tier1 + + def enroll_eligible_registrants(self, program_memberships): + self.ensure_one() + eligible = self._find_eligible_registrants(program_memberships) + return self.env["spp.program.membership"].search( + [ + ("partner_id", "in", eligible.ids), + ("program_id", "=", self.program_id.id), + ] + ) + + def verify_cycle_eligibility(self, cycle, membership): + self.ensure_one() + eligible = self._find_eligible_registrants(membership) + return self.env["spp.cycle.membership"].search( + [ + ("partner_id", "in", eligible.ids), + ("cycle_id", "=", cycle.id), + ] + ) + + def import_eligible_registrants(self, state="draft"): + self.ensure_one() + new_beneficiaries = self._find_eligible_registrants() + + # Exclude already-enrolled beneficiaries (match base manager's exclusion source) + existing_partner_ids = set(self.program_id.get_beneficiaries().mapped("partner_id").ids) + new_beneficiaries = new_beneficiaries.filtered(lambda r: r.id not in existing_partner_ids) + + ben_count = len(new_beneficiaries) + if ben_count < self.ASYNC_IMPORT_THRESHOLD: + self._import_registrants(new_beneficiaries, state=state, do_count=True) + else: + self._import_registrants_async(new_beneficiaries, state=state) + return ben_count + + def _import_registrants_async(self, new_beneficiaries, state="draft"): + self.ensure_one() + program = self.program_id + program.message_post(body=f"Import of {len(new_beneficiaries)} beneficiaries started.") + program.write({"is_locked": True, "locked_reason": "Importing beneficiaries"}) + + jobs = [] + for i in range(0, len(new_beneficiaries), self.IMPORT_CHUNK_SIZE): + jobs.append( + self.delayable( + channel="eligibility_manager", + identity_key=f"import_reg_{program.id}_{i}", + )._import_registrants(new_beneficiaries[i : i + self.IMPORT_CHUNK_SIZE], state) + ) + main_job = group(*jobs) + main_job.on_done(self.delayable(channel="statistics_refresh").mark_import_as_done()) + main_job.delay() + + def mark_import_as_done(self): + self.ensure_one() + self.program_id._compute_eligible_beneficiary_count() + self.program_id._compute_beneficiary_count() + self.program_id.is_locked = False + self.program_id.locked_reason = None + self.program_id.message_post(body=_("Import finished.")) + + def _import_registrants(self, new_beneficiaries, state="draft", do_count=False): + _logger.info("spp_program_geofence: Importing %s beneficiaries", len(new_beneficiaries)) + self.env["spp.program.membership"].create( + [{"program_id": self.program_id.id, "partner_id": b.id, "state": state} for b in new_beneficiaries] + ) + + if do_count: + self.program_id._compute_eligible_beneficiary_count() + self.program_id._compute_beneficiary_count() + + def action_preview_eligible(self): + self.ensure_one() + try: + eligible = self._find_eligible_registrants() + self.preview_count = len(eligible) + self.preview_error = False + except Exception: + _logger.exception("Geofence eligibility preview failed for manager %s", self.id) + self.preview_count = 0 + self.preview_error = "Preview failed. Check the server logs for details." + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "Preview Complete", + "message": f"{self.preview_count} registrants match the current geofences.", + "sticky": False, + "type": "success" if not self.preview_error else "warning", + }, + } diff --git a/spp_program_geofence/models/program.py b/spp_program_geofence/models/program.py new file mode 100644 index 000000000..c232f6fb3 --- /dev/null +++ b/spp_program_geofence/models/program.py @@ -0,0 +1,35 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from odoo import api, fields, models + + +class SPPProgram(models.Model): + _inherit = "spp.program" + + geofence_ids = fields.Many2many( + "spp.gis.geofence", + "spp_program_geofence_rel", + "program_id", + "geofence_id", + string="Geofences", + help="Geographic boundaries that define this program's scope.", + ) + geofence_count = fields.Integer( + compute="_compute_geofence_count", + string="Geofence Count", + ) + + @api.depends("geofence_ids") + def _compute_geofence_count(self): + for rec in self: + rec.geofence_count = len(rec.geofence_ids) + + def action_open_geofences(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": "Program Geofences", + "res_model": "spp.gis.geofence", + "view_mode": "list,form", + "domain": [("id", "in", self.geofence_ids.ids)], + "context": dict(self.env.context), + } diff --git a/spp_program_geofence/pyproject.toml b/spp_program_geofence/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/spp_program_geofence/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_program_geofence/readme/DESCRIPTION.md b/spp_program_geofence/readme/DESCRIPTION.md new file mode 100644 index 000000000..169f26a85 --- /dev/null +++ b/spp_program_geofence/readme/DESCRIPTION.md @@ -0,0 +1,27 @@ +# OpenSPP Program Geofence + +Adds geofence-based geographic targeting to OpenSPP programs. + +## Features + +- **Program Geofences**: Define geographic boundaries (geofences) on programs to scope their + geographic coverage. Geofences are configured on the program's Overview tab. + +- **Geofence Eligibility Manager**: A pluggable eligibility manager that determines registrant + eligibility based on their location relative to the program's geofences. Works alongside other + eligibility managers using AND logic. + +- **Hybrid Two-Tier Targeting**: + - **Tier 1 (GPS)**: Matches registrants whose GPS coordinates fall within the geofence polygons. + - **Tier 2 (Area Fallback)**: For registrants without GPS coordinates, matches those whose + administrative area intersects the geofence. This fallback can be disabled per manager. + +- **Preview**: Preview how many registrants match the current geofences before importing. + +## Known Limitations + +- Groups (households) typically lack GPS coordinates. Enable the area fallback to match them by + administrative area. +- Changing geofences after enrollment does not retroactively affect existing memberships. + Use cycle eligibility verification for ongoing checks. +- Archived geofences are excluded from spatial queries. diff --git a/spp_program_geofence/security/ir.model.access.csv b/spp_program_geofence/security/ir.model.access.csv new file mode 100644 index 000000000..a14632c9d --- /dev/null +++ b/spp_program_geofence/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_program_membership_manager_geofence_admin,Geofence Eligibility Admin,model_spp_program_membership_manager_geofence,spp_security.group_spp_admin,1,1,1,1 +access_spp_program_membership_manager_geofence_manager,Geofence Eligibility Manager,model_spp_program_membership_manager_geofence,spp_programs.group_programs_manager,1,1,1,0 +access_spp_program_membership_manager_geofence_officer,Geofence Eligibility Officer,model_spp_program_membership_manager_geofence,spp_programs.group_programs_officer,1,1,1,0 +access_spp_program_membership_manager_geofence_viewer,Geofence Eligibility Viewer,model_spp_program_membership_manager_geofence,spp_programs.group_programs_viewer,1,0,0,0 +access_spp_gis_geofence_programs_viewer,spp.gis.geofence programs viewer,spp_gis.model_spp_gis_geofence,spp_programs.group_programs_viewer,1,0,0,0 +access_spp_gis_geofence_programs_validator,spp.gis.geofence programs validator,spp_gis.model_spp_gis_geofence,spp_programs.group_programs_validator,1,0,0,0 +access_spp_gis_geofence_tag_programs_viewer,spp.gis.geofence.tag programs viewer,spp_gis.model_spp_gis_geofence_tag,spp_programs.group_programs_viewer,1,0,0,0 +access_spp_gis_geofence_tag_programs_validator,spp.gis.geofence.tag programs validator,spp_gis.model_spp_gis_geofence_tag,spp_programs.group_programs_validator,1,0,0,0 diff --git a/spp_program_geofence/static/description/index.html b/spp_program_geofence/static/description/index.html new file mode 100644 index 000000000..1b59204c5 --- /dev/null +++ b/spp_program_geofence/static/description/index.html @@ -0,0 +1,13 @@ +
+
+

OpenSPP Program Geofence

+

Geofence-based geographic targeting for programs

+
+

+ This module adds geofence-based geographic targeting to OpenSPP programs. + Programs can define geofences (geographic boundaries) and use them to + automatically identify and enroll eligible registrants based on their location. +

+
+
+
diff --git a/spp_program_geofence/tests/__init__.py b/spp_program_geofence/tests/__init__.py new file mode 100644 index 000000000..8c8b05ef6 --- /dev/null +++ b/spp_program_geofence/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import test_create_program_wizard +from . import test_geofence_eligibility diff --git a/spp_program_geofence/tests/test_create_program_wizard.py b/spp_program_geofence/tests/test_create_program_wizard.py new file mode 100644 index 000000000..7805f1047 --- /dev/null +++ b/spp_program_geofence/tests/test_create_program_wizard.py @@ -0,0 +1,146 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for program creation wizard with geofence eligibility.""" + +import json +import uuid + +from odoo import Command +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestCreateProgramWizardGeofence(TransactionCase): + """Verify the wizard creates a geofence eligibility manager when geofences are set.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + queue_job__no_delay=True, + tracking_disable=True, + ) + ) + + cls.geofence = cls.env["spp.gis.geofence"].create( + { + "name": "Wizard Test Geofence", + "geometry": json.dumps( + { + "type": "Polygon", + "coordinates": [[[100, 0], [101, 0], [101, 1], [100, 1], [100, 0]]], + } + ), + "geofence_type": "custom", + } + ) + + # Create a test product for entitlement items (required by wizard) + cls.product = cls.env["product.product"].create( + { + "name": "Test Product [TEST]", + "type": "consu", + } + ) + + def _create_wizard(self, geofence_ids=None, **kwargs): + """Helper to create a wizard with sensible defaults.""" + vals = { + "name": f"Program {uuid.uuid4().hex[:8]} [TEST]", + "rrule_type": "monthly", + "eligibility_domain": "[]", + "cycle_duration": 1, + "currency_id": self.env.company.currency_id.id, + "entitlement_type": "cash", + "entitlement_cash_item_ids": [Command.create({"amount": 100.0})], + } + if geofence_ids is not None: + vals["geofence_ids"] = [Command.set(geofence_ids)] + vals.update(kwargs) + return self.env["spp.program.create.wizard"].create(vals) + + def test_wizard_with_geofence_creates_geofence_manager(self): + """When geofences are configured, the wizard should create a geofence eligibility manager.""" + wiz = self._create_wizard(geofence_ids=self.geofence.ids) + action = wiz.create_program() + + program = self.env["spp.program"].browse(action["params"]["program_id"]) + self.assertTrue(program.geofence_ids, "Program should have geofences linked") + + # The eligibility manager should be a geofence manager, not default + managers = program.get_managers(program.MANAGER_ELIGIBILITY) + self.assertEqual(len(managers), 1, "Should have exactly one eligibility manager") + self.assertEqual( + managers[0]._name, + "spp.program.membership.manager.geofence", + "Eligibility manager should be geofence type, not default", + ) + + def test_wizard_without_geofence_creates_default_manager(self): + """When no geofences are configured, the wizard should create the default eligibility manager.""" + wiz = self._create_wizard() + action = wiz.create_program() + + program = self.env["spp.program"].browse(action["params"]["program_id"]) + managers = program.get_managers(program.MANAGER_ELIGIBILITY) + self.assertEqual(len(managers), 1) + self.assertEqual( + managers[0]._name, + "spp.program.membership.manager.default", + "Eligibility manager should be default type when no geofences", + ) + + def test_wizard_geofence_manager_has_correct_program(self): + """The geofence manager should reference the correct program.""" + wiz = self._create_wizard(geofence_ids=self.geofence.ids) + action = wiz.create_program() + + program = self.env["spp.program"].browse(action["params"]["program_id"]) + manager = program.get_managers(program.MANAGER_ELIGIBILITY)[0] + self.assertEqual(manager.program_id, program) + + def test_wizard_geofence_manager_defaults(self): + """The geofence manager should have sensible defaults.""" + wiz = self._create_wizard(geofence_ids=self.geofence.ids) + action = wiz.create_program() + + program = self.env["spp.program"].browse(action["params"]["program_id"]) + manager = program.get_managers(program.MANAGER_ELIGIBILITY)[0] + self.assertTrue( + manager.include_area_fallback, + "Area fallback should be enabled by default", + ) + + def test_wizard_passes_area_fallback_disabled(self): + """When area fallback is disabled in the wizard, the manager should reflect that.""" + wiz = self._create_wizard( + geofence_ids=self.geofence.ids, + include_area_fallback=False, + ) + action = wiz.create_program() + + program = self.env["spp.program"].browse(action["params"]["program_id"]) + manager = program.get_managers(program.MANAGER_ELIGIBILITY)[0] + self.assertFalse( + manager.include_area_fallback, + "Area fallback should be disabled when wizard sets it to False", + ) + + def test_wizard_passes_fallback_area_type(self): + """When a fallback area type is selected, the manager should have it.""" + area_type = self.env["spp.area.type"].create({"name": "Test Municipality"}) + wiz = self._create_wizard( + geofence_ids=self.geofence.ids, + include_area_fallback=True, + fallback_area_type_id=area_type.id, + ) + action = wiz.create_program() + + program = self.env["spp.program"].browse(action["params"]["program_id"]) + manager = program.get_managers(program.MANAGER_ELIGIBILITY)[0] + self.assertEqual( + manager.fallback_area_type_id, + area_type, + "Fallback area type should be passed through from wizard", + ) diff --git a/spp_program_geofence/tests/test_geofence_eligibility.py b/spp_program_geofence/tests/test_geofence_eligibility.py new file mode 100644 index 000000000..2bd668d2e --- /dev/null +++ b/spp_program_geofence/tests/test_geofence_eligibility.py @@ -0,0 +1,604 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for geofence-based eligibility manager.""" + +import json +from unittest.mock import patch + +from odoo import Command, fields +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestGeofenceEligibility(TransactionCase): + """Test the geofence eligibility manager with spatial queries.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + queue_job__no_delay=True, + tracking_disable=True, + ) + ) + + # -- Geofence: a box from (100, 0) to (101, 1) -- + cls.geofence_geojson = json.dumps( + { + "type": "Polygon", + "coordinates": [[[100, 0], [101, 0], [101, 1], [100, 1], [100, 0]]], + } + ) + cls.geofence = cls.env["spp.gis.geofence"].create( + { + "name": "Test Geofence", + "geometry": cls.geofence_geojson, + "geofence_type": "custom", + } + ) + + # -- Second geofence: a box from (110, 10) to (111, 11) -- + cls.geofence2_geojson = json.dumps( + { + "type": "Polygon", + "coordinates": [[[110, 10], [111, 10], [111, 11], [110, 11], [110, 10]]], + } + ) + cls.geofence2 = cls.env["spp.gis.geofence"].create( + { + "name": "Test Geofence 2", + "geometry": cls.geofence2_geojson, + "geofence_type": "custom", + } + ) + + # -- Area types -- + cls.area_type = cls.env["spp.area.type"].create({"name": "Test District"}) + cls.area_type_province = cls.env["spp.area.type"].create({"name": "Test Province"}) + + # -- Area that overlaps the first geofence -- + cls.area_inside = cls.env["spp.area"].create( + { + "draft_name": "Area Inside", + "code": "AREA_IN", + "area_type_id": cls.area_type.id, + "geo_polygon": json.dumps( + { + "type": "Polygon", + "coordinates": [ + [ + [100.2, 0.2], + [100.8, 0.2], + [100.8, 0.8], + [100.2, 0.8], + [100.2, 0.2], + ] + ], + } + ), + } + ) + + # -- Area that does NOT overlap the first geofence -- + cls.area_outside = cls.env["spp.area"].create( + { + "draft_name": "Area Outside", + "code": "AREA_OUT", + "area_type_id": cls.area_type.id, + "geo_polygon": json.dumps( + { + "type": "Polygon", + "coordinates": [ + [ + [50, 50], + [51, 50], + [51, 51], + [50, 51], + [50, 50], + ] + ], + } + ), + } + ) + + # -- Area (province type) that also overlaps the first geofence -- + cls.area_province = cls.env["spp.area"].create( + { + "draft_name": "Province Overlap", + "code": "AREA_PROV", + "area_type_id": cls.area_type_province.id, + "geo_polygon": json.dumps( + { + "type": "Polygon", + "coordinates": [ + [ + [99, -1], + [102, -1], + [102, 2], + [99, 2], + [99, -1], + ] + ], + } + ), + } + ) + + # -- Registrants -- + point_inside = json.dumps({"type": "Point", "coordinates": [100.5, 0.5]}) + point_outside = json.dumps({"type": "Point", "coordinates": [50, 50]}) + point_in_geofence2 = json.dumps({"type": "Point", "coordinates": [110.5, 10.5]}) + + cls.reg_inside = cls.env["res.partner"].create( + { + "name": "Inside Registrant", + "is_registrant": True, + "is_group": False, + "coordinates": point_inside, + } + ) + cls.reg_outside = cls.env["res.partner"].create( + { + "name": "Outside Registrant", + "is_registrant": True, + "is_group": False, + "coordinates": point_outside, + } + ) + cls.reg_no_coords_in_area = cls.env["res.partner"].create( + { + "name": "No Coords In Area", + "is_registrant": True, + "is_group": False, + "area_id": cls.area_inside.id, + } + ) + cls.reg_no_coords_out_area = cls.env["res.partner"].create( + { + "name": "No Coords Out Area", + "is_registrant": True, + "is_group": False, + "area_id": cls.area_outside.id, + } + ) + cls.reg_in_province = cls.env["res.partner"].create( + { + "name": "No Coords In Province", + "is_registrant": True, + "is_group": False, + "area_id": cls.area_province.id, + } + ) + cls.reg_in_geofence2 = cls.env["res.partner"].create( + { + "name": "In Geofence 2", + "is_registrant": True, + "is_group": False, + "coordinates": point_in_geofence2, + } + ) + cls.reg_disabled = cls.env["res.partner"].create( + { + "name": "Disabled Registrant", + "is_registrant": True, + "is_group": False, + "disabled": fields.Datetime.now(), + "coordinates": point_inside, + } + ) + # Registrant with BOTH coordinates inside AND area inside + cls.reg_both = cls.env["res.partner"].create( + { + "name": "Both Coords And Area", + "is_registrant": True, + "is_group": False, + "coordinates": point_inside, + "area_id": cls.area_inside.id, + } + ) + # Group registrant (for target_type tests) + cls.group_reg = cls.env["res.partner"].create( + { + "name": "Group Registrant", + "is_registrant": True, + "is_group": True, + "area_id": cls.area_inside.id, + } + ) + + # -- Program -- + cls.program = cls.env["spp.program"].create( + { + "name": "Geofence Test Program", + "target_type": "individual", + "geofence_ids": [Command.set([cls.geofence.id])], + } + ) + + # -- Geofence Eligibility Manager -- + cls.manager = cls.env["spp.program.membership.manager.geofence"].create( + { + "name": "Test Geofence Manager", + "program_id": cls.program.id, + } + ) + + # --- Manager registration --- + + def test_manager_selection_registered(self): + """Geofence Eligibility appears in the manager selection.""" + selection = self.env["spp.eligibility.manager"]._selection_manager_ref_id() + manager_names = [s[0] for s in selection] + self.assertIn("spp.program.membership.manager.geofence", manager_names) + + # --- Tier 1: coordinate-based --- + + def test_tier1_coordinates_inside(self): + """Registrant with coordinates inside geofence is eligible.""" + eligible = self.manager._find_eligible_registrants() + self.assertIn(self.reg_inside, eligible) + + def test_tier1_coordinates_outside(self): + """Registrant with coordinates outside geofence is not eligible.""" + eligible = self.manager._find_eligible_registrants() + self.assertNotIn(self.reg_outside, eligible) + + def test_tier1_no_coordinates_not_matched(self): + """Registrant without coordinates is not matched by Tier 1 alone.""" + self.manager.include_area_fallback = False + eligible = self.manager._find_eligible_registrants() + self.assertNotIn(self.reg_no_coords_in_area, eligible) + # Restore + self.manager.include_area_fallback = True + + # --- Tier 2: area fallback --- + + def test_tier2_area_intersects(self): + """Registrant in intersecting area is eligible via fallback.""" + eligible = self.manager._find_eligible_registrants() + self.assertIn(self.reg_no_coords_in_area, eligible) + + def test_tier2_area_no_intersection(self): + """Registrant in non-intersecting area is not eligible.""" + eligible = self.manager._find_eligible_registrants() + self.assertNotIn(self.reg_no_coords_out_area, eligible) + + def test_tier2_disabled(self): + """When include_area_fallback=False, Tier 2 is skipped.""" + self.manager.include_area_fallback = False + eligible = self.manager._find_eligible_registrants() + self.assertNotIn(self.reg_no_coords_in_area, eligible) + # Restore + self.manager.include_area_fallback = True + + # --- Tier 2: area type filter --- + + def test_tier2_area_type_filter_includes_matching_type(self): + """When fallback_area_type_id is set, only areas of that type are matched.""" + self.manager.fallback_area_type_id = self.area_type + eligible = self.manager._find_eligible_registrants() + # District area matches, so registrant in district area is eligible + self.assertIn(self.reg_no_coords_in_area, eligible) + # Province area does NOT match the filter, so registrant in province is excluded + self.assertNotIn(self.reg_in_province, eligible) + # Restore + self.manager.fallback_area_type_id = False + + def test_tier2_area_type_filter_excludes_non_matching(self): + """When fallback_area_type_id is set to a type with no matching areas, Tier 2 is empty.""" + # Set filter to province type; but our geofence is small enough that + # the province area also overlaps. The point is that district registrants + # should be excluded. + self.manager.fallback_area_type_id = self.area_type_province + eligible = self.manager._find_eligible_registrants() + # Province area overlaps, so province registrant IS eligible + self.assertIn(self.reg_in_province, eligible) + # District registrant is NOT eligible (wrong area type) + self.assertNotIn(self.reg_no_coords_in_area, eligible) + # Restore + self.manager.fallback_area_type_id = False + + def test_tier2_no_area_type_filter_includes_all(self): + """When fallback_area_type_id is not set, all area types are matched.""" + self.manager.fallback_area_type_id = False + eligible = self.manager._find_eligible_registrants() + # Both district and province registrants should be eligible + self.assertIn(self.reg_no_coords_in_area, eligible) + self.assertIn(self.reg_in_province, eligible) + + # --- Hybrid union --- + + def test_hybrid_no_duplicates(self): + """Registrant matched by both tiers appears only once.""" + eligible = self.manager._find_eligible_registrants() + count = len([r for r in eligible if r.id == self.reg_both.id]) + self.assertEqual(count, 1) + + # --- Multiple geofences --- + + def test_multiple_geofences(self): + """Registrant in second geofence is eligible.""" + self.program.geofence_ids = [Command.set([self.geofence.id, self.geofence2.id])] + eligible = self.manager._find_eligible_registrants() + self.assertIn(self.reg_in_geofence2, eligible) + self.assertIn(self.reg_inside, eligible) + # Restore + self.program.geofence_ids = [Command.set([self.geofence.id])] + + # --- No geofences --- + + def test_no_geofences_empty_result(self): + """Program with no geofences returns no eligible registrants.""" + self.program.geofence_ids = [Command.clear()] + eligible = self.manager._find_eligible_registrants() + self.assertEqual(len(eligible), 0) + # Restore + self.program.geofence_ids = [Command.set([self.geofence.id])] + + # --- Enrollment pipeline --- + + def test_enroll_eligible_registrants(self): + """enroll_eligible_registrants filters memberships to eligible partners.""" + membership = self.env["spp.program.membership"].create( + { + "partner_id": self.reg_inside.id, + "program_id": self.program.id, + "state": "draft", + } + ) + membership_outside = self.env["spp.program.membership"].create( + { + "partner_id": self.reg_outside.id, + "program_id": self.program.id, + "state": "draft", + } + ) + result = self.manager.enroll_eligible_registrants(membership | membership_outside) + self.assertIn(membership, result) + self.assertNotIn(membership_outside, result) + + def test_import_eligible_registrants(self): + """import_eligible_registrants creates memberships for eligible registrants.""" + # Use a fresh program to avoid pre-existing memberships + program2 = self.env["spp.program"].create( + { + "name": "Import Test Program", + "target_type": "individual", + "geofence_ids": [Command.set([self.geofence.id])], + } + ) + manager2 = self.env["spp.program.membership.manager.geofence"].create( + { + "name": "Import Manager", + "program_id": program2.id, + } + ) + count = manager2.import_eligible_registrants() + self.assertGreater(count, 0) + enrolled_partners = program2.program_membership_ids.mapped("partner_id") + self.assertIn(self.reg_inside, enrolled_partners) + self.assertNotIn(self.reg_outside, enrolled_partners) + + def test_import_excludes_already_enrolled(self): + """import_eligible_registrants does not duplicate existing memberships.""" + program3 = self.env["spp.program"].create( + { + "name": "Dedup Test Program", + "target_type": "individual", + "geofence_ids": [Command.set([self.geofence.id])], + } + ) + manager3 = self.env["spp.program.membership.manager.geofence"].create( + { + "name": "Dedup Manager", + "program_id": program3.id, + } + ) + # First import + manager3.import_eligible_registrants() + count1 = len(program3.program_membership_ids) + + # Second import should add nothing + manager3.import_eligible_registrants() + count2 = len(program3.program_membership_ids) + self.assertEqual(count1, count2) + + # --- Disabled registrants --- + + def test_disabled_registrants_excluded(self): + """Disabled registrants are never eligible.""" + eligible = self.manager._find_eligible_registrants() + self.assertNotIn(self.reg_disabled, eligible) + + # --- Target type --- + + def test_target_type_individual(self): + """When target_type is 'individual', groups are excluded.""" + eligible = self.manager._find_eligible_registrants() + self.assertNotIn(self.group_reg, eligible) + + def test_target_type_group(self): + """When target_type is 'group', individuals are excluded.""" + self.program.target_type = "group" + eligible = self.manager._find_eligible_registrants() + self.assertIn(self.group_reg, eligible) + self.assertNotIn(self.reg_inside, eligible) + # Restore + self.program.target_type = "individual" + + # --- MultiPolygon geometry --- + + def test_multipolygon_geofence(self): + """Program with multiple non-overlapping geofences uses MultiPolygon via unary_union.""" + self.program.geofence_ids = [Command.set([self.geofence.id, self.geofence2.id])] + eligible = self.manager._find_eligible_registrants() + # Both registrants from different geofences should be eligible + self.assertIn(self.reg_inside, eligible) + self.assertIn(self.reg_in_geofence2, eligible) + # Outside registrant should not be + self.assertNotIn(self.reg_outside, eligible) + # Restore + self.program.geofence_ids = [Command.set([self.geofence.id])] + + # --- Program geofence field --- + + def test_program_geofence_count(self): + """geofence_count is computed correctly.""" + self.assertEqual(self.program.geofence_count, 1) + self.program.geofence_ids = [Command.set([self.geofence.id, self.geofence2.id])] + self.assertEqual(self.program.geofence_count, 2) + # Restore + self.program.geofence_ids = [Command.set([self.geofence.id])] + + +@tagged("post_install", "-at_install") +class TestGeofenceEligibilityOfficer(TransactionCase): + """Test geofence eligibility with programs_officer user context.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + queue_job__no_delay=True, + tracking_disable=True, + ) + ) + + # Create officer user + cls.officer_user = cls.env["res.users"].create( + { + "name": "Test Officer", + "login": "test_geofence_officer", + "group_ids": [ + Command.link(cls.env.ref("spp_programs.group_programs_officer").id), + Command.link(cls.env.ref("base.group_user").id), + ], + } + ) + + # Create test data as admin + cls.geofence = cls.env["spp.gis.geofence"].create( + { + "name": "Officer Test Geofence", + "geometry": json.dumps( + { + "type": "Polygon", + "coordinates": [[[100, 0], [101, 0], [101, 1], [100, 1], [100, 0]]], + } + ), + "geofence_type": "custom", + } + ) + + cls.program = cls.env["spp.program"].create( + { + "name": "Officer Test Program", + "target_type": "individual", + "geofence_ids": [Command.set([cls.geofence.id])], + } + ) + + cls.manager = cls.env["spp.program.membership.manager.geofence"].create( + { + "name": "Officer Manager", + "program_id": cls.program.id, + } + ) + + def test_officer_can_read_manager(self): + """Programs officer can read the geofence eligibility manager.""" + manager_as_officer = self.manager.with_user(self.officer_user) + self.assertEqual(manager_as_officer.name, "Officer Manager") + + def test_officer_can_create_manager(self): + """Programs officer can create a geofence eligibility manager.""" + new_manager = ( + self.env["spp.program.membership.manager.geofence"] + .with_user(self.officer_user) + .create( + { + "name": "Officer Created Manager", + "program_id": self.program.id, + } + ) + ) + self.assertTrue(new_manager.id) + + +@tagged("post_install", "-at_install") +class TestGeofenceAsyncChannelRouting(TransactionCase): + """Test that _import_registrants_async passes identity_key and correct channels. + + Mirrors the pattern in spp_programs/tests/test_concurrency.py. + """ + + def setUp(self): + super().setUp() + self.program = self.env["spp.program"].create( + { + "name": "Async Channel Test Program", + "target_type": "individual", + } + ) + self.manager = self.env["spp.program.membership.manager.geofence"].create( + { + "name": "Async Channel Manager", + "program_id": self.program.id, + } + ) + + def test_import_registrants_async_uses_identity_key(self): + """_import_registrants_async must pass identity_key with 'import_reg_' to delayable.""" + partners = self.env["res.partner"].create( + [{"name": f"Async Registrant {i}", "is_registrant": True} for i in range(3)] + ) + + delayable_calls = [] + original_delayable = type(self.manager).delayable + + def mock_delayable(self_inner, **kwargs): + delayable_calls.append(kwargs) + return original_delayable(self_inner, **kwargs) + + with patch.object(type(self.manager), "delayable", mock_delayable): + try: + self.manager._import_registrants_async(partners, "draft") + except Exception: + pass + + identity_keys = [c.get("identity_key", "") for c in delayable_calls] + has_import_key = any("import_reg_" in k for k in identity_keys) + self.assertTrue( + has_import_key, + f"Expected identity_key with 'import_reg_', got: {identity_keys}", + ) + + def test_import_registrants_async_on_done_uses_statistics_refresh(self): + """_import_registrants_async on_done job must use channel='statistics_refresh'.""" + partners = self.env["res.partner"].create( + [{"name": f"Async Registrant SR {i}", "is_registrant": True} for i in range(3)] + ) + + delayable_calls = [] + original_delayable = type(self.manager).delayable + + def mock_delayable(self_inner, **kwargs): + delayable_calls.append(kwargs) + return original_delayable(self_inner, **kwargs) + + with patch.object(type(self.manager), "delayable", mock_delayable): + try: + self.manager._import_registrants_async(partners, "draft") + except Exception: + pass + + channels = [c.get("channel", "") for c in delayable_calls] + self.assertIn( + "statistics_refresh", + channels, + f"Expected 'statistics_refresh' channel for on_done job, got: {channels}", + ) diff --git a/spp_program_geofence/views/eligibility_manager_view.xml b/spp_program_geofence/views/eligibility_manager_view.xml new file mode 100644 index 000000000..af39aea0d --- /dev/null +++ b/spp_program_geofence/views/eligibility_manager_view.xml @@ -0,0 +1,101 @@ + + + + + spp.program.membership.manager.geofence.form + spp.program.membership.manager.geofence + +
+ +
+

+ +

+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + registrants match the current geographic scope. +
+ +
+
+ +
+
+
diff --git a/spp_program_geofence/views/geofence_view.xml b/spp_program_geofence/views/geofence_view.xml new file mode 100644 index 000000000..260a7ff19 --- /dev/null +++ b/spp_program_geofence/views/geofence_view.xml @@ -0,0 +1,139 @@ + + + + + spp.gis.geofence.list + spp.gis.geofence + + + + + + + + + + + + + + spp.gis.geofence.form + spp.gis.geofence + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + spp.gis.geofence.search + spp.gis.geofence + + + + + + + + + + + + + + spp.gis.geofence.gis + + spp.gis.geofence + + + + + + + + + + + + + Geofence Polygons + + + basic + + 0.8 + #FF680A + + + + osm + Default + + + + + + Geofences + spp.gis.geofence + list,form + + + + + +
diff --git a/spp_program_geofence/views/program_view.xml b/spp_program_geofence/views/program_view.xml new file mode 100644 index 000000000..14ee82372 --- /dev/null +++ b/spp_program_geofence/views/program_view.xml @@ -0,0 +1,82 @@ + + + + + spp.program.form.geofence + spp.program + + 101 + + + + + + + + +
+
+
+ +
+
Geographic Scope
+ Define where this program operates +
+
+
+ + + geofence(s) + +
+
+
+ + + + + + + +
+
+
+
+
+
diff --git a/spp_program_geofence/wizard/__init__.py b/spp_program_geofence/wizard/__init__.py new file mode 100644 index 000000000..d35ad02ca --- /dev/null +++ b/spp_program_geofence/wizard/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import create_program_wizard diff --git a/spp_program_geofence/wizard/create_program_wizard.py b/spp_program_geofence/wizard/create_program_wizard.py new file mode 100644 index 000000000..29f59b95d --- /dev/null +++ b/spp_program_geofence/wizard/create_program_wizard.py @@ -0,0 +1,52 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from odoo import Command, fields, models + + +class SPPCreateNewProgramWizGeofence(models.TransientModel): + _inherit = "spp.program.create.wizard" + + geofence_ids = fields.Many2many( + "spp.gis.geofence", + string="Geofences", + help="Define the geographic scope for this program.", + ) + include_area_fallback = fields.Boolean( + default=True, + string="Fall back to admin area", + help="When enabled, registrants whose administrative area intersects the geofence " + "are included even if their GPS coordinates are not set.", + ) + fallback_area_type_id = fields.Many2one( + "spp.area.type", + string="Area level", + help="When set, only areas of this type are considered for the area fallback. " + "Use this to restrict matching to a specific administrative level (e.g. District).", + ) + + def get_program_vals(self): + vals = super().get_program_vals() + if self.geofence_ids: + vals["geofence_ids"] = [Command.set(self.geofence_ids.ids)] + return vals + + def _get_eligibility_manager(self, program_id): + if not self.geofence_ids: + return super()._get_eligibility_manager(program_id) + + # Create a geofence eligibility manager instead of the default one + geofence_mgr = self.env["spp.program.membership.manager.geofence"].create( + { + "name": "Geofence", + "program_id": program_id, + "include_area_fallback": self.include_area_fallback, + "fallback_area_type_id": self.fallback_area_type_id.id or False, + } + ) + + parent_mgr = self.env["spp.eligibility.manager"].create( + self._get_eligibility_managers_val(program_id, geofence_mgr) + ) + + return { + "eligibility_manager_ids": [Command.link(parent_mgr.id)], + } diff --git a/spp_program_geofence/wizard/create_program_wizard.xml b/spp_program_geofence/wizard/create_program_wizard.xml new file mode 100644 index 000000000..92e7b9695 --- /dev/null +++ b/spp_program_geofence/wizard/create_program_wizard.xml @@ -0,0 +1,47 @@ + + + + + Create Program Wizard - Geofence + spp.program.create.wizard + 15 + + + + + + + + + + + + + + + + + + + + + + From 99633474175ec60814d952f0e4c1200670fa10e5 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 2 Jul 2026 23:06:36 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20address=20CI=20findings=20=E2=80=94?= =?UTF-8?q?=20psycopg2.sql=20composition=20in=20migrations,=20regenerate?= =?UTF-8?q?=20spp=5Fgis=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semgrep flagged f-string SQL in the tag migration scripts; identifiers were constants but composed SQL via psycopg2.sql removes the pattern entirely. README.rst regenerated from the updated HISTORY fragment (in-scope module only). --- spp_gis/README.rst | 14 +++++ .../migrations/19.0.2.1.0/post-migration.py | 9 ++- .../migrations/19.0.2.1.0/pre-migration.py | 56 +++++++++++-------- spp_gis/static/description/index.html | 15 +++++ 4 files changed, 67 insertions(+), 27 deletions(-) diff --git a/spp_gis/README.rst b/spp_gis/README.rst index 5f046fb4d..fb45da36c 100644 --- a/spp_gis/README.rst +++ b/spp_gis/README.rst @@ -131,6 +131,20 @@ External Python libraries: ``shapely``, ``pyproj``, ``geojson`` Changelog ========= +19.0.2.1.0 +~~~~~~~~~~ + +- feat: spatial operators support MultiPolygon and GeometryCollection, + including distance buffering (re-land from #76). +- feat: OSM style fallback in map renderer/edit widgets when no MapTiler + API key is configured; placeholder key treated as unconfigured + (re-land from #76). +- feat: geofence GeoJSON output includes the record uuid as feature id; + new ``spp.gis.geofence.tag`` model replaces vocabulary-based geofence + tags (re-land from #76). +- feat: migration remaps existing vocabulary-based geofence tag links + onto ``spp.gis.geofence.tag`` records when upgrading from 19.0.2.0.x. + 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_gis/migrations/19.0.2.1.0/post-migration.py b/spp_gis/migrations/19.0.2.1.0/post-migration.py index d49379bb8..3c1bc8b16 100644 --- a/spp_gis/migrations/19.0.2.1.0/post-migration.py +++ b/spp_gis/migrations/19.0.2.1.0/post-migration.py @@ -8,6 +8,8 @@ import logging +from psycopg2 import sql + from odoo import SUPERUSER_ID, api _logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags") @@ -26,8 +28,9 @@ def migrate(cr, version): env = api.Environment(cr, SUPERUSER_ID, {}) Tag = env["spp.gis.geofence.tag"] + aux = sql.Identifier(AUX_TABLE) - cr.execute(f"SELECT DISTINCT vocab_name FROM {AUX_TABLE}") + cr.execute(sql.SQL("SELECT DISTINCT vocab_name FROM {aux}").format(aux=aux)) tag_ids_by_name = {} for (name,) in cr.fetchall(): tag = Tag.search([("name", "=", name)], limit=1) @@ -36,7 +39,7 @@ def migrate(cr, version): tag_ids_by_name[name] = tag.id restored = 0 - cr.execute(f"SELECT geofence_id, vocab_name FROM {AUX_TABLE}") + cr.execute(sql.SQL("SELECT geofence_id, vocab_name FROM {aux}").format(aux=aux)) for geofence_id, name in cr.fetchall(): cr.execute( """ @@ -48,7 +51,7 @@ def migrate(cr, version): ) restored += cr.rowcount - cr.execute(f"DROP TABLE {AUX_TABLE}") + cr.execute(sql.SQL("DROP TABLE {aux}").format(aux=aux)) _logger.info( "spp_gis geofence tag migration: created %s tags, restored %s links", len(tag_ids_by_name), diff --git a/spp_gis/migrations/19.0.2.1.0/pre-migration.py b/spp_gis/migrations/19.0.2.1.0/pre-migration.py index 551da9f21..ce4523f0e 100644 --- a/spp_gis/migrations/19.0.2.1.0/pre-migration.py +++ b/spp_gis/migrations/19.0.2.1.0/pre-migration.py @@ -14,6 +14,8 @@ import logging +from psycopg2 import sql + _logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags") AUX_TABLE = "spp_gis_geofence_tag_legacy_migration" @@ -32,37 +34,43 @@ def migrate(cr, version): # table (only possible when this migration reruns or in tests); it is # legacy if it references a vocabulary instead. Checking the new table # first resolves numeric id collisions in favor of valid links. - new_table_exists = _table_exists(cr, "spp_gis_geofence_tag") - valid_clause = ( - "AND rel.tag_id NOT IN (SELECT id FROM spp_gis_geofence_tag)" if new_table_exists else "" - ) + if _table_exists(cr, "spp_gis_geofence_tag"): + valid_clause = sql.SQL("AND rel.tag_id NOT IN (SELECT id FROM spp_gis_geofence_tag)") + else: + valid_clause = sql.SQL("") + aux = sql.Identifier(AUX_TABLE) cr.execute( - f""" - CREATE TABLE IF NOT EXISTS {AUX_TABLE} ( - geofence_id integer NOT NULL, - vocab_name varchar NOT NULL - ) - """ + sql.SQL( + """ + CREATE TABLE IF NOT EXISTS {aux} ( + geofence_id integer NOT NULL, + vocab_name varchar NOT NULL + ) + """ + ).format(aux=aux) ) cr.execute( - f""" - INSERT INTO {AUX_TABLE} (geofence_id, vocab_name) - SELECT rel.geofence_id, - -- name is a translated (jsonb) field: prefer en_US, else any value - COALESCE(v.name->>'en_US', (SELECT t.value FROM jsonb_each_text(v.name) t LIMIT 1)) - FROM spp_gis_geofence_tag_rel rel - JOIN spp_vocabulary v ON v.id = rel.tag_id - WHERE true {valid_clause} - """ + sql.SQL( + """ + INSERT INTO {aux} (geofence_id, vocab_name) + SELECT rel.geofence_id, + COALESCE(v.name->>'en_US', (SELECT t.value FROM jsonb_each_text(v.name) t LIMIT 1)) + FROM spp_gis_geofence_tag_rel rel + JOIN spp_vocabulary v ON v.id = rel.tag_id + WHERE true {valid_clause} + """ + ).format(aux=aux, valid_clause=valid_clause) ) parked = cr.rowcount cr.execute( - f""" - DELETE FROM spp_gis_geofence_tag_rel rel - USING spp_vocabulary v - WHERE v.id = rel.tag_id {valid_clause} - """ + sql.SQL( + """ + DELETE FROM spp_gis_geofence_tag_rel rel + USING spp_vocabulary v + WHERE v.id = rel.tag_id {valid_clause} + """ + ).format(valid_clause=valid_clause) ) _logger.info( "spp_gis geofence tag migration: parked %s legacy vocabulary tag links (%s rows removed)", diff --git a/spp_gis/static/description/index.html b/spp_gis/static/description/index.html index d332aae91..29de99d4f 100644 --- a/spp_gis/static/description/index.html +++ b/spp_gis/static/description/index.html @@ -507,6 +507,21 @@

Changelog

+

19.0.2.1.0

+
    +
  • feat: spatial operators support MultiPolygon and GeometryCollection, +including distance buffering (re-land from #76).
  • +
  • feat: OSM style fallback in map renderer/edit widgets when no MapTiler +API key is configured; placeholder key treated as unconfigured +(re-land from #76).
  • +
  • feat: geofence GeoJSON output includes the record uuid as feature id; +new spp.gis.geofence.tag model replaces vocabulary-based geofence +tags (re-land from #76).
  • +
  • feat: migration remaps existing vocabulary-based geofence tag links +onto spp.gis.geofence.tag records when upgrading from 19.0.2.0.x.
  • +
+
+

19.0.2.0.0

  • Initial migration to OpenSPP2
  • From 88e48ff980b5f8765c1f455e15dd32548f5a9d18 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 01:56:03 +0800 Subject: [PATCH 3/8] refactor: literal parameterized SQL in geofence tag migrations Replaces psycopg2.sql identifier composition with prebuilt literal queries (the aux table name and the valid-link filter are compile-time constants), clearing the odoo-sql-injection-string-format code-scanning alerts. Values remain parameterized; behavior unchanged (spp_gis suite 92/0). --- .../migrations/19.0.2.1.0/post-migration.py | 15 ++- .../migrations/19.0.2.1.0/pre-migration.py | 93 ++++++++++--------- 2 files changed, 57 insertions(+), 51 deletions(-) diff --git a/spp_gis/migrations/19.0.2.1.0/post-migration.py b/spp_gis/migrations/19.0.2.1.0/post-migration.py index 3c1bc8b16..bd0d063d6 100644 --- a/spp_gis/migrations/19.0.2.1.0/post-migration.py +++ b/spp_gis/migrations/19.0.2.1.0/post-migration.py @@ -4,18 +4,16 @@ Creates one spp.gis.geofence.tag per distinct legacy vocabulary name and restores the geofence links, then drops the aux table. See pre-migration.py for the full story. + +All SQL is literal and value-parameterized; identifiers are never composed. """ import logging -from psycopg2 import sql - from odoo import SUPERUSER_ID, api _logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags") -AUX_TABLE = "spp_gis_geofence_tag_legacy_migration" - def _table_exists(cr, table): cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name = %s", (table,)) @@ -23,14 +21,13 @@ def _table_exists(cr, table): def migrate(cr, version): - if not _table_exists(cr, AUX_TABLE): + if not _table_exists(cr, "spp_gis_geofence_tag_legacy_migration"): return env = api.Environment(cr, SUPERUSER_ID, {}) Tag = env["spp.gis.geofence.tag"] - aux = sql.Identifier(AUX_TABLE) - cr.execute(sql.SQL("SELECT DISTINCT vocab_name FROM {aux}").format(aux=aux)) + cr.execute("SELECT DISTINCT vocab_name FROM spp_gis_geofence_tag_legacy_migration") tag_ids_by_name = {} for (name,) in cr.fetchall(): tag = Tag.search([("name", "=", name)], limit=1) @@ -39,7 +36,7 @@ def migrate(cr, version): tag_ids_by_name[name] = tag.id restored = 0 - cr.execute(sql.SQL("SELECT geofence_id, vocab_name FROM {aux}").format(aux=aux)) + cr.execute("SELECT geofence_id, vocab_name FROM spp_gis_geofence_tag_legacy_migration") for geofence_id, name in cr.fetchall(): cr.execute( """ @@ -51,7 +48,7 @@ def migrate(cr, version): ) restored += cr.rowcount - cr.execute(sql.SQL("DROP TABLE {aux}").format(aux=aux)) + cr.execute("DROP TABLE spp_gis_geofence_tag_legacy_migration") _logger.info( "spp_gis geofence tag migration: created %s tags, restored %s links", len(tag_ids_by_name), diff --git a/spp_gis/migrations/19.0.2.1.0/pre-migration.py b/spp_gis/migrations/19.0.2.1.0/pre-migration.py index ce4523f0e..3031660d8 100644 --- a/spp_gis/migrations/19.0.2.1.0/pre-migration.py +++ b/spp_gis/migrations/19.0.2.1.0/pre-migration.py @@ -10,15 +10,55 @@ This pre-migration moves legacy rows (with the referenced vocabulary's name) into an aux table; the matching post-migration recreates them as spp.gis.geofence.tag links and drops the aux table. + +All SQL is literal and value-parameterized; identifiers are never composed. """ import logging -from psycopg2 import sql - _logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags") -AUX_TABLE = "spp_gis_geofence_tag_legacy_migration" +_CREATE_AUX = """ + CREATE TABLE IF NOT EXISTS spp_gis_geofence_tag_legacy_migration ( + geofence_id integer NOT NULL, + vocab_name varchar NOT NULL + ) +""" + +# name is a translated (jsonb) field: prefer en_US, else any value. +_PARK_LEGACY = """ + INSERT INTO spp_gis_geofence_tag_legacy_migration (geofence_id, vocab_name) + SELECT rel.geofence_id, + COALESCE(v.name->>'en_US', (SELECT t.value FROM jsonb_each_text(v.name) t LIMIT 1)) + FROM spp_gis_geofence_tag_rel rel + JOIN spp_vocabulary v ON v.id = rel.tag_id +""" + +_DELETE_LEGACY = """ + DELETE FROM spp_gis_geofence_tag_rel rel + USING spp_vocabulary v + WHERE v.id = rel.tag_id +""" + +# Variants used when the new tag table already exists (migration rerun or +# tests): a row is a valid new-style link if its tag_id exists there; it is +# legacy if it references a vocabulary instead. Checking the new table first +# resolves numeric id collisions in favor of valid links. +_PARK_LEGACY_SKIP_VALID = """ + INSERT INTO spp_gis_geofence_tag_legacy_migration (geofence_id, vocab_name) + SELECT rel.geofence_id, + COALESCE(v.name->>'en_US', (SELECT t.value FROM jsonb_each_text(v.name) t LIMIT 1)) + FROM spp_gis_geofence_tag_rel rel + JOIN spp_vocabulary v ON v.id = rel.tag_id + WHERE rel.tag_id NOT IN (SELECT id FROM spp_gis_geofence_tag) +""" + +_DELETE_LEGACY_SKIP_VALID = """ + DELETE FROM spp_gis_geofence_tag_rel rel + USING spp_vocabulary v + WHERE v.id = rel.tag_id + AND rel.tag_id NOT IN (SELECT id FROM spp_gis_geofence_tag) +""" def _table_exists(cr, table): @@ -30,48 +70,17 @@ def migrate(cr, version): if not _table_exists(cr, "spp_gis_geofence_tag_rel"): return - # A row is a valid new-style link if its tag_id exists in the new tag - # table (only possible when this migration reruns or in tests); it is - # legacy if it references a vocabulary instead. Checking the new table - # first resolves numeric id collisions in favor of valid links. if _table_exists(cr, "spp_gis_geofence_tag"): - valid_clause = sql.SQL("AND rel.tag_id NOT IN (SELECT id FROM spp_gis_geofence_tag)") + park_query = _PARK_LEGACY_SKIP_VALID + delete_query = _DELETE_LEGACY_SKIP_VALID else: - valid_clause = sql.SQL("") - - aux = sql.Identifier(AUX_TABLE) - cr.execute( - sql.SQL( - """ - CREATE TABLE IF NOT EXISTS {aux} ( - geofence_id integer NOT NULL, - vocab_name varchar NOT NULL - ) - """ - ).format(aux=aux) - ) - cr.execute( - sql.SQL( - """ - INSERT INTO {aux} (geofence_id, vocab_name) - SELECT rel.geofence_id, - COALESCE(v.name->>'en_US', (SELECT t.value FROM jsonb_each_text(v.name) t LIMIT 1)) - FROM spp_gis_geofence_tag_rel rel - JOIN spp_vocabulary v ON v.id = rel.tag_id - WHERE true {valid_clause} - """ - ).format(aux=aux, valid_clause=valid_clause) - ) + park_query = _PARK_LEGACY + delete_query = _DELETE_LEGACY + + cr.execute(_CREATE_AUX) + cr.execute(park_query) parked = cr.rowcount - cr.execute( - sql.SQL( - """ - DELETE FROM spp_gis_geofence_tag_rel rel - USING spp_vocabulary v - WHERE v.id = rel.tag_id {valid_clause} - """ - ).format(valid_clause=valid_clause) - ) + cr.execute(delete_query) _logger.info( "spp_gis geofence tag migration: parked %s legacy vocabulary tag links (%s rows removed)", parked, From 0c90a912d275dfe8a139f09e12563ae1e2c15cc0 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 02:06:51 +0800 Subject: [PATCH 4/8] docs(spp_program_geofence): add initial HISTORY fragment Every module carries a readme/HISTORY.md; the module was created in #76 without one. README.rst History section will be aligned to CI's renderer output in a follow-up commit. --- spp_program_geofence/readme/HISTORY.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 spp_program_geofence/readme/HISTORY.md diff --git a/spp_program_geofence/readme/HISTORY.md b/spp_program_geofence/readme/HISTORY.md new file mode 100644 index 000000000..4e6bece44 --- /dev/null +++ b/spp_program_geofence/readme/HISTORY.md @@ -0,0 +1,3 @@ +### 19.0.1.0.0 + +- Initial release: geofence-based program targeting and eligibility management (Tier 1 coordinate intersection, Tier 2 area-intersection fallback), program configuration UI, and program creation wizard support. From 9d9afdb6b589cb5a09b610fc801eb7c19c73e48d Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 02:22:20 +0800 Subject: [PATCH 5/8] docs+i18n(spp_program_geofence): CI-rendered README History; wrap user-facing strings in _() - README.rst History section applied verbatim from CI's pre-commit diff. - Address gemini-code-assist review: translatable message_post/lock-reason and preview notification strings (i18n). --- spp_program_geofence/README.rst | 11 +++++++++++ spp_program_geofence/models/eligibility_manager.py | 10 +++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/spp_program_geofence/README.rst b/spp_program_geofence/README.rst index 9c765f652..dd5071f26 100644 --- a/spp_program_geofence/README.rst +++ b/spp_program_geofence/README.rst @@ -69,6 +69,17 @@ Known Limitations .. contents:: :local: +Changelog +========= + +19.0.1.0.0 +~~~~~~~~~~ + +- Initial release: geofence-based program targeting and eligibility + management (Tier 1 coordinate intersection, Tier 2 area-intersection + fallback), program configuration UI, and program creation wizard + support. + Bug Tracker =========== diff --git a/spp_program_geofence/models/eligibility_manager.py b/spp_program_geofence/models/eligibility_manager.py index 75bab74bc..c437b75bd 100644 --- a/spp_program_geofence/models/eligibility_manager.py +++ b/spp_program_geofence/models/eligibility_manager.py @@ -198,8 +198,8 @@ def import_eligible_registrants(self, state="draft"): def _import_registrants_async(self, new_beneficiaries, state="draft"): self.ensure_one() program = self.program_id - program.message_post(body=f"Import of {len(new_beneficiaries)} beneficiaries started.") - program.write({"is_locked": True, "locked_reason": "Importing beneficiaries"}) + program.message_post(body=_("Import of %s beneficiaries started.") % len(new_beneficiaries)) + program.write({"is_locked": True, "locked_reason": _("Importing beneficiaries")}) jobs = [] for i in range(0, len(new_beneficiaries), self.IMPORT_CHUNK_SIZE): @@ -240,13 +240,13 @@ def action_preview_eligible(self): except Exception: _logger.exception("Geofence eligibility preview failed for manager %s", self.id) self.preview_count = 0 - self.preview_error = "Preview failed. Check the server logs for details." + self.preview_error = _("Preview failed. Check the server logs for details.") return { "type": "ir.actions.client", "tag": "display_notification", "params": { - "title": "Preview Complete", - "message": f"{self.preview_count} registrants match the current geofences.", + "title": _("Preview Complete"), + "message": _("%s registrants match the current geofences.") % self.preview_count, "sticky": False, "type": "success" if not self.preview_error else "warning", }, From 0f5b1878930a22a83ae10a787709312219de53da Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 02:26:23 +0800 Subject: [PATCH 6/8] docs(spp_gis): describe geofences, complex-geometry queries, OSM fallback DESCRIPTION.md predates the geofence move (#74) and did not mention the capabilities this PR adds. README will be aligned to CI's renderer output in a follow-up commit. --- spp_gis/readme/DESCRIPTION.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/spp_gis/readme/DESCRIPTION.md b/spp_gis/readme/DESCRIPTION.md index 6ff354a2d..0970b34af 100644 --- a/spp_gis/readme/DESCRIPTION.md +++ b/spp_gis/readme/DESCRIPTION.md @@ -4,9 +4,10 @@ PostGIS integration for geospatial data management, map visualization, and spati - Define geo fields (`geo_point`, `geo_line`, `geo_polygon`) on any model using PostGIS spatial types - Visualize records on interactive maps via the GIS view type -- Configure background raster layers (OpenStreetMap, WMS, satellite imagery) +- Configure background raster layers (OpenStreetMap, WMS, satellite imagery); map widgets fall back to OpenStreetMap styling when no MapTiler API key is configured - Configure data layers with basic or choropleth (color-by-value) rendering -- Perform spatial queries (intersects, contains, within, distance-based) via `gis_locational_query()` +- Perform spatial queries (intersects, contains, within, distance-based) via `gis_locational_query()`, including complex geometries (`MultiPolygon`, `GeometryCollection`) with distance buffering +- Save geographic areas of interest as geofences, classify them with tags, and export them as GeoJSON features - Import area boundaries from GeoJSON/shapefiles via area import wizard - Manage color schemes for thematic mapping with sequential, diverging, or qualitative palettes @@ -15,6 +16,8 @@ PostGIS integration for geospatial data management, map visualization, and spati | Model | Description | | ----------------------------- | ---------------------------------------------------------- | | `spp.gis.raster.layer` | Background map layers (OSM, WMS, image) | +| `spp.gis.geofence` | Saved geographic areas of interest (GeoJSON in/out) | +| `spp.gis.geofence.tag` | Tags for classifying geofences | | `spp.gis.data.layer` | Vector data layers referencing geo fields from any model | | `spp.gis.color.scheme` | Color palettes for choropleth and thematic visualizations | | `spp.gis.raster.layer.type` | Raster layer type definitions (WMS services) | From b7f9fa406b4a03abacbcbf3ea16ab2f3e2b8c4e5 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 02:40:51 +0800 Subject: [PATCH 7/8] docs(spp_program_geofence): HISTORY heading level matches DESCRIPTION ladder The module's fragments use #/## headings; ### produced an RST title-level skip that crashed oca-gen-addon-readme. --- spp_program_geofence/readme/HISTORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spp_program_geofence/readme/HISTORY.md b/spp_program_geofence/readme/HISTORY.md index 4e6bece44..ae25b1472 100644 --- a/spp_program_geofence/readme/HISTORY.md +++ b/spp_program_geofence/readme/HISTORY.md @@ -1,3 +1,3 @@ -### 19.0.1.0.0 +## 19.0.1.0.0 - Initial release: geofence-based program targeting and eligibility management (Tier 1 coordinate intersection, Tier 2 area-intersection fallback), program configuration UI, and program creation wizard support. From 123c700b20441e94c41931906401d75bf192da61 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 02:48:55 +0800 Subject: [PATCH 8/8] docs: apply CI-rendered README output for spp_gis and spp_program_geofence READMEs regenerated by CI's pinned generator from the updated DESCRIPTION and new HISTORY fragments; applied verbatim from its pre-commit diff. --- spp_gis/README.rst | 13 +++++++++++-- spp_gis/static/description/index.html | 15 +++++++++++++-- spp_program_geofence/README.rst | 2 +- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/spp_gis/README.rst b/spp_gis/README.rst index fb45da36c..1263d1ecd 100644 --- a/spp_gis/README.rst +++ b/spp_gis/README.rst @@ -35,11 +35,15 @@ Key Capabilities any model using PostGIS spatial types - Visualize records on interactive maps via the GIS view type - Configure background raster layers (OpenStreetMap, WMS, satellite - imagery) + imagery); map widgets fall back to OpenStreetMap styling when no + MapTiler API key is configured - Configure data layers with basic or choropleth (color-by-value) rendering - Perform spatial queries (intersects, contains, within, distance-based) - via ``gis_locational_query()`` + via ``gis_locational_query()``, including complex geometries + (``MultiPolygon``, ``GeometryCollection``) with distance buffering +- Save geographic areas of interest as geofences, classify them with + tags, and export them as GeoJSON features - Import area boundaries from GeoJSON/shapefiles via area import wizard - Manage color schemes for thematic mapping with sequential, diverging, or qualitative palettes @@ -53,6 +57,11 @@ Key Models | ``spp.gis.raster.layer`` | Background map layers (OSM, WMS, | | | image) | +-------------------------------+--------------------------------------+ +| ``spp.gis.geofence`` | Saved geographic areas of interest | +| | (GeoJSON in/out) | ++-------------------------------+--------------------------------------+ +| ``spp.gis.geofence.tag`` | Tags for classifying geofences | ++-------------------------------+--------------------------------------+ | ``spp.gis.data.layer`` | Vector data layers referencing geo | | | fields from any model | +-------------------------------+--------------------------------------+ diff --git a/spp_gis/static/description/index.html b/spp_gis/static/description/index.html index 29de99d4f..c4d333a6b 100644 --- a/spp_gis/static/description/index.html +++ b/spp_gis/static/description/index.html @@ -382,11 +382,15 @@

    Key Capabilities

    any model using PostGIS spatial types
  • Visualize records on interactive maps via the GIS view type
  • Configure background raster layers (OpenStreetMap, WMS, satellite -imagery)
  • +imagery); map widgets fall back to OpenStreetMap styling when no +MapTiler API key is configured
  • Configure data layers with basic or choropleth (color-by-value) rendering
  • Perform spatial queries (intersects, contains, within, distance-based) -via gis_locational_query()
  • +via gis_locational_query(), including complex geometries +(MultiPolygon, GeometryCollection) with distance buffering +
  • Save geographic areas of interest as geofences, classify them with +tags, and export them as GeoJSON features
  • Import area boundaries from GeoJSON/shapefiles via area import wizard
  • Manage color schemes for thematic mapping with sequential, diverging, or qualitative palettes
  • @@ -409,6 +413,13 @@

    Key Models

    Background map layers (OSM, WMS, image) +spp.gis.geofence +Saved geographic areas of interest +(GeoJSON in/out) + +spp.gis.geofence.tag +Tags for classifying geofences + spp.gis.data.layer Vector data layers referencing geo fields from any model diff --git a/spp_program_geofence/README.rst b/spp_program_geofence/README.rst index dd5071f26..3f1140526 100644 --- a/spp_program_geofence/README.rst +++ b/spp_program_geofence/README.rst @@ -73,7 +73,7 @@ Changelog ========= 19.0.1.0.0 -~~~~~~~~~~ +---------- - Initial release: geofence-based program targeting and eligibility management (Tier 1 coordinate intersection, Tier 2 area-intersection