diff --git a/spp_gis/controllers/main.py b/spp_gis/controllers/main.py index acc51b69..dd092461 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/operators.py b/spp_gis/operators.py index ee996bbf..a6847234 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,12 @@ 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] + return SQL("%s(%s, %s)", SQL(postgis_fn), geom, SQL(self.qualified_field_name)) + + coordinates = geojson_val["coordinates"] return SQL(self.get_postgis_query(operation, coordinates, distance=distance, layer_type=layer_type)) 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 34fbb8f5..d63ea27c 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 @@ -91,7 +91,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 +108,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); } } @@ -416,7 +415,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 +458,38 @@ 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 { + 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, + }, + ], + }; + } + let mapStyle = maptilersdk.MapStyle.STREETS; if (layer) { 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 74e8d3f1..8cf3d32e 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 @@ -32,7 +32,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", @@ -67,11 +69,9 @@ 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); } } @@ -86,6 +86,34 @@ export class FieldGisEditMap extends Component { } } + _getMapStyle() { + if (this.mapTilerKey) { + return maptilersdk.MapStyle.STREETS; + } + // Fallback: OSM raster tiles (no API key required) + 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, + }, + ], + }; + } + renderMap() { if (this.props.record.data[this.props.name]) { const obj = JSON.parse(this.props.record.data[this.props.name]); @@ -104,9 +132,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, }); @@ -199,7 +232,9 @@ export class FieldGisEditMap extends Component { } removeSourceAndLayer(source) { - this.map.removeLayer(source); + this.map.removeLayer(`${source}-polygon-layerid`); + this.map.removeLayer(`${source}-point-layerid`); + this.map.removeLayer(`${source}-linestring-layerid`); this.map.removeSource(source); } @@ -213,13 +248,16 @@ export class FieldGisEditMap extends Component { 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: { @@ -246,17 +284,6 @@ export class FieldGisEditMap extends Component { this.map.on("draw.create", updateArea); this.map.on("draw.update", updateArea); - - const url = `/spp_gis/static/src/images/laos_farm.png`; - - this.map.on("click", `${this.sourceId}-polygon-layerid`, (e) => { - new maptilersdk.Popup() - .setLngLat(e.lngLat) - .setHTML( - `Placeholder Image` - ) - .addTo(this.map); - }); } addDrawInteractionStyle() { @@ -370,7 +397,6 @@ export class FieldGisEditMap extends Component { const customMode = {}; const self = this; customMode.onTrash = function (state) { - console.log(state); self.props.record.update({[self.props.name]: null}); }; diff --git a/spp_gis/tests/test_geo_fields.py b/spp_gis/tests/test_geo_fields.py index 164f7b75..7c7c25b2 100644 --- a/spp_gis/tests/test_geo_fields.py +++ b/spp_gis/tests/test_geo_fields.py @@ -246,3 +246,183 @@ 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_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_program_geofence/DESCRIPTION.md b/spp_program_geofence/DESCRIPTION.md new file mode 100644 index 00000000..169f26a8 --- /dev/null +++ b/spp_program_geofence/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/__init__.py b/spp_program_geofence/__init__.py new file mode 100644 index 00000000..d3361032 --- /dev/null +++ b/spp_program_geofence/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import models diff --git a/spp_program_geofence/__manifest__.py b/spp_program_geofence/__manifest__.py new file mode 100644 index 00000000..b653255a --- /dev/null +++ b/spp_program_geofence/__manifest__.py @@ -0,0 +1,27 @@ +# 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", + ], + "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 00000000..39fe0f86 --- /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 00000000..b0a9be18 --- /dev/null +++ b/spp_program_geofence/models/eligibility_manager.py @@ -0,0 +1,249 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import json +import logging + +from odoo import Command, _, api, fields, models + +try: + from odoo.addons.queue_job.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" + + 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 (disabled is a Datetime field) + domain += [("disabled", "=", None)] + + 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: + 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 + existing_partner_ids = set(self.program_id.program_membership_ids.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 < 1000: + 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), 10000): + jobs.append( + self.delayable(channel="root_program.eligibility_manager")._import_registrants( + new_beneficiaries[i : i + 10000], state + ) + ) + main_job = group(*jobs) + main_job.on_done(self.delayable(channel="root_program.eligibility_manager").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)) + beneficiaries_val = [Command.create({"partner_id": b.id, "state": state}) for b in new_beneficiaries] + self.program_id.update({"program_membership_ids": beneficiaries_val}) + + 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 00000000..c232f6fb --- /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/security/ir.model.access.csv b/spp_program_geofence/security/ir.model.access.csv new file mode 100644 index 00000000..e60345ed --- /dev/null +++ b/spp_program_geofence/security/ir.model.access.csv @@ -0,0 +1,5 @@ +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 diff --git a/spp_program_geofence/static/description/index.html b/spp_program_geofence/static/description/index.html new file mode 100644 index 00000000..1b59204c --- /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 00000000..451dad01 --- /dev/null +++ b/spp_program_geofence/tests/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import test_geofence_eligibility 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 00000000..2aea6b92 --- /dev/null +++ b/spp_program_geofence/tests/test_geofence_eligibility.py @@ -0,0 +1,528 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for geofence-based eligibility manager.""" + +import json + +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, + test_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, + test_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) 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 00000000..fa1e985d --- /dev/null +++ b/spp_program_geofence/views/eligibility_manager_view.xml @@ -0,0 +1,98 @@ + + + + + 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 00000000..dc952348 --- /dev/null +++ b/spp_program_geofence/views/geofence_view.xml @@ -0,0 +1,141 @@ + + + + + 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 00000000..15d4c6ae --- /dev/null +++ b/spp_program_geofence/views/program_view.xml @@ -0,0 +1,50 @@ + + + + + spp.program.form.geofence + spp.program + + 99 + + + + + + + + + 0 + + + + + + + + + + + + + +