Skip to content

feat: geofence-based geographic targeting for programs#76

Draft
jeremi wants to merge 12 commits into19.0from
feature/program-geofence
Draft

feat: geofence-based geographic targeting for programs#76
jeremi wants to merge 12 commits into19.0from
feature/program-geofence

Conversation

@jeremi
Copy link
Member

@jeremi jeremi commented Mar 6, 2026

Summary

  • Add spp_program_geofence module: geofence-based eligibility manager for programs with hybrid two-tier spatial targeting (GPS coordinates + administrative area fallback)
  • Add MultiPolygon/GeometryCollection support to spp_gis operators, enabling unary_union of multiple geofences
  • Make MapTiler API key optional across all map widgets with OSM raster tile fallback
  • Fix pre-existing map widget bugs (WebGL context leaks, draw control stacking, broken layer removal)
  • Fix SQL injection in GeoJSON operator by using parameterized queries

New module: spp_program_geofence

Programs can now define geographic scope via geofences on the Overview tab. The geofence eligibility manager uses a two-tier approach:

  1. Tier 1 (precise): Registrants whose GPS coordinates intersect the combined geofence geometry (via PostGIS ST_Intersects)
  2. Tier 2 (fallback): Registrants whose administrative area intersects the geofence, with optional area type filter (e.g. restrict to District level only)

Includes standalone geofence management UI under the Area menu, preview button on the manager form, and 23 tests covering all spatial query paths.

spp_gis improvements

  • operators.py: MultiPolygon/GeometryCollection support via ST_GeomFromGeoJSON with proper SQL parameterization
  • field_gis_edit_map.esm.js: Fixed WebGL context leak on onPatched, draw control stacking, removeSourceAndLayer bug, removed debug console.log and placeholder image popup
  • gis_renderer.esm.js: Added OSM fallback, conditional geocoding control
  • Both map widgets: OSM raster tiles when no MapTiler API key configured

Test plan

  • ./spp t spp_gis (86 passed)
  • ./spp t spp_program_geofence (23 passed)
  • Linters pass (ruff, ruff-format, prettier)
  • Manual UI test: create program with geofences, configure geofence eligibility manager, preview and import registrants
  • Verify map display works with and without MapTiler API key

jeremi added 8 commits March 6, 2026 14:54
…operators

The Operator class only supported Point, LineString, and Polygon types
in domain queries. When shapely's unary_union creates a MultiPolygon
from non-overlapping polygons, the operator validation silently rejected
it, returning SQL("FALSE") and matching zero registrants.

Add ST_GeomFromGeoJSON path for complex geometry types that cannot be
easily constructed from coordinates.
New module that adds geofence-based geographic targeting to programs:
- Program-level geofence_ids field on Overview tab
- Geofence eligibility manager with hybrid two-tier spatial queries
  (GPS coordinates + administrative area fallback)
- Preview button showing matched registrant count
- Composable with other eligibility managers via AND logic
…ibility

- Use gis_intersects instead of gis_within for Tier 1 spatial query;
  gis_within generates ST_Within(value, field) which is backwards for
  point-in-polygon checks, while gis_intersects is symmetric
- Use disabled=None instead of disabled=False in domain (Datetime field)
- Use fields.Datetime.now() for disabled test data (not Boolean True)
- Use group_ids with Command.link() for Odoo 19 compatibility in tests
- Escape single quotes in create_from_geojson to prevent SQL injection
- Make preview_count/preview_error regular fields instead of computed;
  spatial queries now only run when the Preview button is clicked
- Use elif instead of two independent if statements for target_type
- Simplify _import_registrants loop to list comprehension
…ent UI

- Add fallback_area_type_id field to restrict Tier 2 area fallback to a
  specific administrative level (e.g. District), preventing overly broad
  matches from large provinces or regions
- Add geofence list/form/search views with menu under Area top-level,
  so users can browse and manage geofences independently
- Allow inline geofence creation from the program form
- Add 3 tests for area type filter behavior
When no MapTiler API key is configured, the map widget now falls back
to OpenStreetMap raster tiles instead of failing silently. This makes
the GIS features work out of the box without requiring a third-party
API key. Users who want vector tiles can still configure a MapTiler key.
…back

- Fix WebGL context leak: destroy previous map before creating new one
  in renderMap() to prevent accumulating WebGL contexts on onPatched
- Fix draw control stacking: remove previous MapboxDraw control before
  adding a new one in addDrawInteraction()
- Fix removeSourceAndLayer: remove all three layer IDs (polygon, point,
  linestring) instead of the source ID which doesn't match any layer
- Remove console.log debug statements from updateArea and onTrash
- Remove hardcoded laos_farm.png placeholder popup on polygon click
- Fix SQL injection in create_from_geojson: use SQL() with bound
  parameters instead of manual string escaping
- Apply OSM raster tile fallback to gis_renderer (matching edit widget)
- Guard GeocodingControl behind API key check in renderer
- Fix early-return-in-loop in eligibility manager methods with
  ensure_one()
- Log exceptions in preview instead of silently swallowing them
- Use efficient set lookup for beneficiary exclusion
- Use Command.set()/Command.clear() instead of tuple syntax in tests
@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a robust geofence-based geographic targeting system for programs, allowing for precise and flexible definition of eligibility zones. It significantly enhances the underlying GIS infrastructure by adding support for complex geometric types and improving the reliability and user experience of map widgets. These changes collectively empower programs with more advanced spatial management tools and ensure a more secure and resilient mapping environment.

Highlights

  • New Geofence-Based Targeting Module: Introduced a new spp_program_geofence module that enables programs to define geographic boundaries (geofences) for registrant eligibility.
  • Hybrid Two-Tier Spatial Targeting: Implemented a sophisticated eligibility manager that uses a two-tier approach: precise GPS coordinates and an administrative area fallback for registrants without GPS data.
  • Enhanced GIS Capabilities: Upgraded spp_gis to support MultiPolygon and GeometryCollection GeoJSON types, significantly expanding the flexibility of spatial queries.
  • Map Widget Improvements and Fixes: Made MapTiler API key optional with an OpenStreetMap (OSM) raster tile fallback, and resolved several map widget bugs including WebGL context leaks, draw control stacking, and layer removal issues.
  • SQL Injection Prevention: Addressed potential SQL injection vulnerabilities in GeoJSON operators by implementing parameterized queries for geometry creation.
Changelog
  • spp_gis/operators.py
    • Added support for 'MultiPolygon' and 'GeometryCollection' types in ALLOWED_LAYER_TYPE.
    • Introduced create_from_geojson method to safely construct geometry from GeoJSON using parameterized SQL queries.
    • Updated validate_geojson to accept 'MultiPolygon' and 'GeometryCollection' types.
    • Modified domain_query to utilize create_from_geojson for complex geometry types.
  • spp_gis/static/src/js/views/gis/gis_renderer/gis_renderer.esm.js
    • Made MapTiler API key configuration conditional, allowing map rendering without it.
    • Implemented OpenStreetMap (OSM) raster tile fallback when a MapTiler API key is not provided.
    • Made GeocodingControl conditional on the presence of a MapTiler API key.
    • Changed API key fetching error logging from error to warn.
  • spp_gis/static/src/js/widgets/gis_edit_map/field_gis_edit_map.esm.js
    • Made MapTiler API key configuration conditional.
    • Introduced _getMapStyle method to provide OSM raster tile fallback when a MapTiler API key is not available.
    • Added this.map.remove() before creating a new map instance to prevent WebGL context leaks.
    • Updated removeSourceAndLayer to correctly remove specific polygon, point, and linestring layers.
    • Added this.map.removeControl(this.draw) to prevent draw control stacking.
    • Removed debug console.log statements and a placeholder image popup on map click.
    • Changed API key fetching error logging from error to warn.
  • spp_gis/tests/test_geo_fields.py
    • Added TestOperatorMultiPolygon class to test MultiPolygon and GeometryCollection types.
    • Verified domain_query correctly handles MultiPolygon and GeometryCollection GeoJSON, including parameterized SQL and table-qualified column names.
    • Confirmed domain_query accepts GeoJSON strings and Shapely objects for complex types.
    • Ensured validate_geojson accepts new geometry types and rejects invalid ones.
    • Verified that Polygon queries continue to use coordinate-based construction.
  • spp_program_geofence/DESCRIPTION.md
    • Added a detailed description of the new module's features and limitations.
  • spp_program_geofence/init.py
    • Initialized the Python package for the new module.
  • spp_program_geofence/manifest.py
    • Defined the module's metadata, dependencies, and data files.
  • spp_program_geofence/models/init.py
    • Imported eligibility_manager and program models.
  • spp_program_geofence/models/eligibility_manager.py
    • Registered 'Geofence Eligibility' as a new manager type.
    • Implemented GeofenceMembershipManager with include_area_fallback and fallback_area_type_id fields.
    • Added _get_combined_geometry to union geofence geometries using shapely.ops.unary_union.
    • Implemented _find_eligible_registrants for two-tier spatial eligibility (GPS coordinates and administrative area fallback).
    • Provided methods for enroll_eligible_registrants, verify_cycle_eligibility, and import_eligible_registrants (including asynchronous import for large datasets).
    • Added action_preview_eligible for UI preview of eligible registrants.
  • spp_program_geofence/models/program.py
    • Extended spp.program model with geofence_ids (Many2many) and geofence_count fields.
    • Added _compute_geofence_count and action_open_geofences methods.
  • spp_program_geofence/security/ir.model.access.csv
    • Defined access control rules for the spp.program.membership.manager.geofence model for different user roles.
  • spp_program_geofence/static/description/index.html
    • Added a brief HTML description for the module.
  • spp_program_geofence/tests/init.py
    • Initialized the test suite for the module.
  • spp_program_geofence/tests/test_geofence_eligibility.py
    • Added TestGeofenceEligibility class with comprehensive tests for the geofence eligibility manager's logic.
    • Tested Tier 1 (coordinates) and Tier 2 (area fallback) eligibility, including various scenarios like multiple geofences, no geofences, disabled registrants, and target types.
    • Included tests for MultiPolygon geofences and the geofence_count field.
    • Added TestGeofenceEligibilityOfficer to verify access rights for program officers.
  • spp_program_geofence/views/eligibility_manager_view.xml
    • Defined the form view for the geofence eligibility manager, including settings for area fallback, program geofence display, and a preview section.
  • spp_program_geofence/views/geofence_view.xml
    • Defined list, form, and search views for spp.gis.geofence and added a menu item under 'Area'.
  • spp_program_geofence/views/program_view.xml
    • Extended the program form view to include a stat button for geofence count and a section on the 'Overview' tab for managing geofence_ids.
Activity
  • All 86 tests for spp_gis passed successfully.
  • All 23 tests for spp_program_geofence passed successfully.
  • Code quality checks (linters: ruff, ruff-format, prettier) passed.
  • Manual UI testing was performed to verify geofence creation, configuration of the geofence eligibility manager, previewing, and importing registrants.
  • Map display functionality was verified with and without the MapTiler API key.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a major new feature for geofence-based geographic targeting, including a new spp_program_geofence module and extensive tests. It also enhances the spp_gis module by adding support for MultiPolygon and GeometryCollection, and crucially fixes a potential SQL injection vulnerability by using parameterized queries. The map widgets are improved by making the MapTiler API key optional with an OpenStreetMap fallback, and several pre-existing bugs related to WebGL context leaks and control stacking are fixed. The changes are well-structured and of high quality. I have a few suggestions to improve maintainability and robustness.

Comment on lines 249 to 254
function updateArea(e) {
console.log(e);
var data = self.draw.getAll();
self.props.record.update({
[self.props.name]: JSON.stringify(data.features[0].geometry),
});
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The updateArea function assumes that self.draw.getAll().features will always contain at least one feature. If it's empty for any reason, data.features[0] will be undefined and accessing .geometry will cause a runtime error. It's safer to add a check.

Additionally, the draw.create and draw.update events pass the affected features in the event object e, which is more direct and efficient to use than calling getAll().

        function updateArea(e) {
            const features = e.features;
            if (features && features.length > 0) {
                self.props.record.update({
                    [self.props.name]: JSON.stringify(features[0].geometry),
                });
            }
        }

Comment on lines +190 to +193
if ben_count < 1000:
self._import_registrants(new_beneficiaries, state=state, do_count=True)
else:
self._import_registrants_async(new_beneficiaries, state=state)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The threshold 1000 for switching to asynchronous import, and the chunk size 10000 used in _import_registrants_async (line 203), are magic numbers. It would be better to define them as constants at the class or module level, for example ASYNC_IMPORT_THRESHOLD = 1000 and IMPORT_CHUNK_SIZE = 10000. This improves readability and makes these values easier to find and adjust.

Comment on lines +89 to +115
_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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
},
layers: [
{
id: "osm-tiles",
type: "raster",
source: "osm",
minzoom: 0,
maxzoom: 19,
},
],
};
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The code to generate the fallback OpenStreetMap map style object is duplicated from spp_gis/static/src/js/views/gis/gis_renderer/gis_renderer.esm.js. To improve maintainability and avoid inconsistencies, this object could be defined as a constant or returned by a utility function in a shared file, and then imported in both field_gis_edit_map.esm.js and gis_renderer.esm.js.

@codecov
Copy link

codecov bot commented Mar 6, 2026

Codecov Report

❌ Patch coverage is 71.51899% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.36%. Comparing base (140d95e) to head (7fb4faf).

Files with missing lines Patch % Lines
spp_program_geofence/models/eligibility_manager.py 69.46% 40 Missing ⚠️
spp_gis/controllers/main.py 0.00% 2 Missing ⚠️
spp_program_geofence/models/program.py 83.33% 2 Missing ⚠️
spp_program_geofence/__manifest__.py 0.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0      #76      +/-   ##
==========================================
+ Coverage   66.31%   66.36%   +0.05%     
==========================================
  Files         369      374       +5     
  Lines       22311    22467     +156     
==========================================
+ Hits        14795    14911     +116     
- Misses       7516     7556      +40     
Flag Coverage Δ
spp_api_v2_gis 71.52% <ø> (ø)
spp_area_hdx 81.43% <ø> (ø)
spp_base_common 90.26% <ø> (ø)
spp_dci_demo 69.23% <ø> (ø)
spp_drims 79.55% <ø> (ø)
spp_drims_sl_demo 68.91% <ø> (ø)
spp_gis 74.69% <81.81%> (+0.59%) ⬆️
spp_gis_indicators 88.04% <ø> (ø)
spp_gis_report 82.60% <ø> (ø)
spp_hazard 92.51% <ø> (ø)
spp_hxl_area 63.74% <ø> (ø)
spp_mis_demo_v2 67.08% <ø> (ø)
spp_program_geofence 70.74% <70.74%> (?)
spp_programs 45.43% <ø> (ø)
spp_security 66.66% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_gis/operators.py 78.83% <100.00%> (+4.98%) ⬆️
spp_program_geofence/__init__.py 100.00% <100.00%> (ø)
spp_program_geofence/models/__init__.py 100.00% <100.00%> (ø)
spp_program_geofence/__manifest__.py 0.00% <0.00%> (ø)
spp_gis/controllers/main.py 50.00% <0.00%> (-12.50%) ⬇️
spp_program_geofence/models/program.py 83.33% <83.33%> (ø)
spp_program_geofence/models/eligibility_manager.py 69.46% <69.46%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

jeremi added 4 commits March 6, 2026 21:29
The default system parameter value "YOUR_MAPTILER_API_KEY_HERE" was
being returned as a valid key, causing 403 errors from MapTiler instead
of falling back to OSM tiles.
renderMap() was overriding the OSM fallback style with a MapTiler
style reference when defaultRaster was set, even without an API key.
Guard the raster style override behind mapTilerKey check.
…nu order

The GeoPolygonField edit widget requires a GIS view (ir.ui.view with
type=gis) with data and raster layers to render the map. Without it,
opening a geofence form raised "No GIS view defined".

Also moved the Geofences menu item to sequence 200 so it appears last
in the Area menu.
When renderMap() destroys the old map, the MapboxDraw control's
internal map reference becomes null. Later, addDrawInteraction()
tried to removeControl(this.draw) from the new map, but the draw
control called this.map.off() on its now-null internal reference.

Fix: set this.draw = null before map.remove() so addDrawInteraction
skips the removeControl call for stale controls.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant