diff --git a/spp_gis/README.rst b/spp_gis/README.rst
index 5f046fb4d..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 |
+-------------------------------+--------------------------------------+
@@ -131,6 +140,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/__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..bd0d063d6
--- /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.
+
+All SQL is literal and value-parameterized; identifiers are never composed.
+"""
+
+import logging
+
+from odoo import SUPERUSER_ID, api
+
+_logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags")
+
+
+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_legacy_migration"):
+ return
+
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ Tag = env["spp.gis.geofence.tag"]
+
+ 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)
+ if not tag:
+ tag = Tag.create({"name": name})
+ tag_ids_by_name[name] = tag.id
+
+ restored = 0
+ cr.execute("SELECT geofence_id, vocab_name FROM spp_gis_geofence_tag_legacy_migration")
+ 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("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),
+ 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..3031660d8
--- /dev/null
+++ b/spp_gis/migrations/19.0.2.1.0/pre-migration.py
@@ -0,0 +1,88 @@
+# 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.
+
+All SQL is literal and value-parameterized; identifiers are never composed.
+"""
+
+import logging
+
+_logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags")
+
+_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):
+ 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
+
+ if _table_exists(cr, "spp_gis_geofence_tag"):
+ park_query = _PARK_LEGACY_SKIP_VALID
+ delete_query = _DELETE_LEGACY_SKIP_VALID
+ else:
+ park_query = _PARK_LEGACY
+ delete_query = _DELETE_LEGACY
+
+ cr.execute(_CREATE_AUX)
+ cr.execute(park_query)
+ parked = cr.rowcount
+ cr.execute(delete_query)
+ _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/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) |
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/description/index.html b/spp_gis/static/description/index.html
index d332aae91..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
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.
+ 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
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+