feat: geofence-based geographic targeting for programs#76
feat: geofence-based geographic targeting for programs#76
Conversation
Summary of ChangesHello, 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
Changelog
Activity
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
| function updateArea(e) { | ||
| console.log(e); | ||
| var data = self.draw.getAll(); | ||
| self.props.record.update({ | ||
| [self.props.name]: JSON.stringify(data.features[0].geometry), | ||
| }); | ||
| } |
There was a problem hiding this comment.
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),
});
}
}| if ben_count < 1000: | ||
| self._import_registrants(new_beneficiaries, state=state, do_count=True) | ||
| else: | ||
| self._import_registrants_async(new_beneficiaries, state=state) |
There was a problem hiding this comment.
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.
| _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: | ||
| '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', | ||
| }, | ||
| }, | ||
| layers: [ | ||
| { | ||
| id: "osm-tiles", | ||
| type: "raster", | ||
| source: "osm", | ||
| minzoom: 0, | ||
| maxzoom: 19, | ||
| }, | ||
| ], | ||
| }; | ||
| } |
There was a problem hiding this comment.
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 Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 19.0 #76 +/- ##
==========================================
+ Coverage 70.39% 70.41% +0.01%
==========================================
Files 674 691 +17
Lines 37005 37266 +261
==========================================
+ Hits 26051 26240 +189
- Misses 10954 11026 +72
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
|
Addressed Gemini review feedback in commit
Kept one medium suggestion as follow-up (optional): deduplicate identical OSM fallback style object between |
…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
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.
Move the Geographic Scope card from Overview to Configuration tab, matching the card-based UI pattern. Add geofence_ids field to the program creation wizard so geofences can be set during initial setup.
Existing geometry was added as a static map source/layer, making it non-interactive: shapes couldn't be clicked, selected, or edited. Now geometry is loaded into the MapboxDraw control via draw.add(), enabling click-to-select, vertex editing, and trash deletion. Also handles draw.delete event to clear the field value.
The tag_ids field on spp.gis.geofence was pointing to spp.vocabulary, which is a generic vocabulary model containing all tag categories (Country, Currency, etc.). Replace with a dedicated spp.gis.geofence.tag model so the tags dropdown only shows geofence-specific tags.
- Remove dead methods from field_gis_edit_map (onLoadMap, addSourceAndLayer,
addSource, addLayer, removeSourceAndLayer) no longer called after draw
refactor
- Fix event listener stacking in addDrawInteraction: store handler refs
and remove previous listeners before adding new ones, preventing
duplicate record.update() calls when onUIChange() is called
- Fix disabled registrant filter: use ("disabled", "=", False) for
consistency with DefaultEligibilityManager
46c90b6 to
8e77302
Compare
Add foundational components for OGC API - Processes (Part 1: Core): - spp.gis.process.job model for async job tracking with job_worker - Pydantic schemas for process list, description, execution, status - ProcessRegistry service with dynamic indicator enum from spp.indicator - Cron job for cleanup of stale/expired process jobs - ACL entries for the new model
- processes.py: GET /gis/ogc/processes, GET /gis/ogc/processes/{id},
POST /gis/ogc/processes/{id}/execution with sync/async support
- jobs.py: GET /gis/ogc/jobs, GET /gis/ogc/jobs/{id},
GET /gis/ogc/jobs/{id}/results, DELETE /gis/ogc/jobs/{id}
- Wire routers into FastAPI endpoint registry
- Update OGC conformance to declare Processes classes
- Add processes link to OGC landing page
- Refactor statistics endpoint to delegate to ProcessRegistry
Tests cover: - ProcessRegistry: list, describe, unknown ID, indicator enum, x-openspp-statistics - Pydantic schemas: ProcessSummary, ExecuteRequest, StatusInfo, JobList - spp.gis.process.job model: create, dismiss, stale cleanup, cron - Input validation: single/batch geometry, bare arrays, limits, proximity - HTTP integration: process list/describe, execution, async flow, job scoping, dismiss, conformance, landing page links
- Remove 'icon' field from test category creation (spp.metric.category does not have an icon field; Odoo 19 raises ValueError on unknown fields) - Fix Odoo False-vs-None for empty Text fields: use `message or None` in _build_status_info to prevent Pydantic ValidationError when job.message is False instead of None - Apply fix in both processes.py and jobs.py routers
- Restrict ACL: base.group_user gets read-only, base.group_system gets full CRUD (routers use sudo() for all mutations) - Add UNIQUE constraint on job_id field - Sanitize exception messages in job records to prevent leaking internals - Extract shared helpers (_helpers.py): build_status_info, check_gis_scope, get_base_url used by both processes and jobs routers - Extract process execution logic (process_execution.py): run_spatial_statistics, run_proximity_statistics used by both sync (router) and async (model) paths - Add safe int parsing for ir.config_parameter values with fallback defaults - Use SPATIAL_STATISTICS/PROXIMITY_STATISTICS constants instead of string literals
…OGC Processes Address consumer feedback from the QGIS plugin team: - Always include geometries_failed in batch summary responses (#1) - Always populate computed_at with current UTC timestamp, even for empty results (#2) - Add Retry-After: 5 header to async 201 responses and in-progress job status (#3/#8) - Track batch progress per-geometry via on_progress callback in job execution (#4) - Add x-openspp-batch-limit: 100 extension to spatial-statistics process description (#5)
…nsion_ids in demo data
Hash the project root path to deterministically assign a port in the 18000-18999 range. Each worktree gets its own stable port, avoiding conflicts when running multiple instances in parallel. Override priority: ODOO_HOST_PORT env var > stable_port in ~/.spp.toml > path hash (automatic).
…ls in dimensions Two bugs in demographic dimension evaluation: 1. Empty Many2one fields (e.g. unset gender_id) returned "False" instead of the configured default_value. The code checked `hasattr(value, "id")` which is True for empty Odoo recordsets, then accessed `.code` which returns False. Fix: check `hasattr(value, "_name")` and test truthiness of the recordset before accessing attributes. 2. Field-based dimensions pointing to Many2one fields (e.g. area_id) used the record's code as both raw value and display label when value_labels_json had no mapping. Fix: add _lookup_m2o_label() that dynamically searches the related model by code to return display_name.
Add to_sql_case() on spp.cel.translator that compiles CEL ternary expressions to SQL CASE WHEN value expressions. Handles age_years() date arithmetic, null comparisons (IS NULL/IS NOT NULL), field references, literals, and boolean connectives. Unsupported nodes (BinOp, Neg, InOp) gracefully return None for Python fallback. Add case_when() and comparison() builder methods to SQLBuilder with operator whitelist for injection prevention.
…pilation Add SQLColumnResult dataclass and to_sql_column() method on spp.demographic.dimension that compiles dimensions to SQL expressions. Handles simple fields (CAST), Many2one with dotted paths (LEFT JOIN), direct Many2one (LEFT JOIN + code lookup), and CEL expressions (delegates to to_sql_case()). Returns None for unsupported dimensions to enable Python fallback.
Replace Python-based _compute_disaggregation() with SQL-based approach: - Add member_expansion field (none/expand) for group-to-individual analysis - Build area_mapping CTE + CROSS JOIN LATERAL VALUES for efficient unpivoting - Support member expansion via spp_group_membership JOIN with DISTINCT ON - Python fallback for dimensions that can't compile to SQL - Fix CEL parser right-associativity for nested ternaries (PRECEDENCE-1) - Flatten nested ternary chains into single CASE WHEN with multiple clauses - Add applies_to filtering in to_sql_column() for dimension targeting - Fix ValidationError import (odoo.exceptions, not models)
…reports Set member_expansion="expand" on Beneficiary Distribution and Density reports so gender disaggregation looks at individual members within groups.
- Use SQL.identifier() for 'individual' and 'is_ended' columns (security) - Replace (6,0,[]) tuples with Command.set() in tests (Odoo 19 compat) - Add invisible condition on member_expansion for non-partner models (UX) - Narrow broad except Exception to specific types in to_sql_case (quality) - Replace early return with self.skipTest() in tests (test hygiene) - Strengthen dedup assertion to assertEqual(total, 1) (test precision)
…nService When any dimension has applies_to="individuals", BreakdownService now automatically expands group IDs to their individual members via active spp.group.membership records. Results are deduplicated so individuals in multiple groups are counted once. This fixes OGC API spatial queries returning "unknown" for individual-level dimensions like gender/age_group when the source data contains group (household) records.
Gender dimension: - Set applies_to="individuals" so BreakdownService expands groups to members (was "all", causing all-unknown results for group queries) - Set field_path="gender_id.code" for correct SQL path resolution Age group dimension: - Split into 5 UNICEF/WHO-aligned buckets: under_5, child (5-14), adolescent (15-17), adult (18-59), elderly (60+) - Was only 3 coarse buckets: child (0-17), adult (18-59), elderly (60+)
…embers The disabled_members variable used `m.disabled != null` which checks Odoo's built-in user account disabled field, not OpenSPP's disability_id. Renamed to pwd_members and fixed filter to `m.disability_id != null`.
…up_by dimensions Remove 5 published statistics now covered by group_by dimensions: - children_under_5, children_under_18, elderly_60_plus (covered by age_group dimension) - female_members, male_members (covered by gender dimension) Remaining published stats: total_households, total_members, pwd_members, enrolled_any_program. The QGIS plugin will pick up the slimmer catalog automatically from /statistics and spatial-statistics process description.
…ring Add population_filter input to spatial-statistics and proximity-statistics OGC processes. This enables filtering registrants by program enrollment and/or CEL expression criteria, including "gap" mode (eligible but not enrolled). - ProcessRegistry: dynamic discovery of programs and CEL expressions via x-openspp-programs and x-openspp-expressions metadata - SpatialQueryService: _build_population_filter_sql() translates filter to SQL with program membership JOIN and CEL domain materialization - Supports and/or/gap combination modes - Input validation for mode and program_id type - 22 new tests covering SQL generation, combined modes, gap analysis, batch queries, and process description discovery - Fix pre-existing param ordering bug in _query_by_coordinates
Replace hardcoded area_level == 3 with a dynamic query that finds the highest (most specific) area level with geo_polygon data. This makes area assignment work with any hierarchy depth: 4-level (curated set with barangays) or 3-level (Luzon municipalities).
Add scripts/prepare_phl_geodata.py that downloads administrative boundary shapefiles and population projections from HDX (OCHA COD-AB), filters to Luzon, simplifies geometries, and generates static data files. Base tier (spp_demo): NCR + CALABARZON with 2 regions, 9 provinces, 159 municipalities. Replaces curated 28-area set with HDX-sourced data using standard PSGC p-codes. Luzon tier (spp_demo_phl_luzon): 8 regions, 42 provinces, 771 municipalities with population weights for realistic distribution. Update demo story area_ref values to use new p-code-based XML IDs.
…ighted demo generation - New spp_demo_phl_luzon module: 8 regions, 42 provinces, 771 municipalities with HDX-sourced boundary polygons and population weights CSV - Generator: population-weighted area assignment via random.choices() - Generator: batch membership creation (flush every 500) and executemany for create_date backdating - Generator: chunked coordinate generation (2000/chunk) with polygon cache - Add "mis-luzon" demo profile to spp CLI
…andling - Add spp.demo.luzon.area.loader that loads areas via convert_file after spp_demo area_kinds are available (not at install time) - Pre-link overlapping area codes (NCR, CALABARZON) via ir.model.data to avoid UNIQUE constraint violations - MIS generator calls Luzon loader after base area loading - Add tests for population weights (9 tests) and area loader (6 tests)
… query The area_level field is computed (store=True) but after bulk-loading areas via convert_file, the ORM may not have flushed the computed values to the database. The raw SQL MAX(area_level) query returned NULL, causing area assignment to be skipped entirely.
When random_groups_count > 500 and queue_job is available, the group creation is dispatched as a background job that commits in chunks of 500. This prevents transaction timeouts and OOM for large volumes (25K+ registrants), and provides progress visibility through the job queue. Area assignment and coordinate generation run after all chunks complete.
Legend items now include min_value/max_value so labels display as "Very Low (0 - 50)" instead of just "Very Low". Applied to: - UI legend (area_ext.py + gis_renderer.esm.js) - GIS API GeoJSON per-feature bucket (gis_report.py _to_geojson) - GIS API single-feature endpoint (layers_service.py)
Individual-level CEL expressions (e.g. senior_citizens_60) returned 0 results because the spatial query always compiled them with the registry_groups profile and matched against groups. Now reads the expression's context_type to select the correct profile, and for individual-context expressions, resolves matched individuals to their group IDs via spp_group_membership.
Age distribution: was 60% ages 3-22, 40% ages 60-85 (no working-age adults, 100% of households matched has_children/has_elderly). Now: 15% infant (0-4), 30% child (5-17), 40% adult (18-59), 15% elderly. Performance: area assignment and coordinate generation now use executemany and bulk UPDATE+JOIN instead of per-record ORM writes, cutting time from minutes to seconds at 25K+ registrants.
Add "volume" demo mode with 7K groups, geodata enabled, and non-GIS features (GRM, cases, change requests) disabled for faster generation. Map misluzon CLI profile to volume mode so that `spp start --demo misluzon --generate --wipe -y` produces the full 25K+ registrant dataset automatically.
_wait_for_module_installed was passing the full comma-separated module string (e.g. "spp_mis_demo_v2,spp_demo_phl_luzon") to _get_module_state, which queries for a single module name. This caused an infinite wait loop after successful installation. Now checks the last module in the list, which is installed last by Odoo's dependency resolver.
queue_job dispatch via with_delay() doesn't work in Odoo shell context (no HTTP request, transaction may not commit). Detect UI vs CLI by checking odoo.http.request, and run _run_volume_generation_job inline with chunked commits when in CLI mode. Also add explicit cr.commit() in CLI generator script to ensure all data is persisted.
7000 groups takes ~15 minutes to generate inline. The previous 600s timeout was insufficient, killing the process at ~4500 groups.
Replace per-record create() calls with batch create() in chunks of 100 groups. Key changes: - Cache gender IDs upfront (2 lookups instead of ~28K) - Build val dicts directly instead of calling helper methods - Batch-create groups, then batch-create all their individuals - Batch-create memberships per flush cycle - ~3-5x faster due to Odoo batching SQL INSERTs and triggers The old _create_random_individual method is preserved for tests and non-volume use cases.
Age 0-1 infants could get future birthdates (e.g., 2026-11 when today is 2026-03), which violates the registration_date >= birthdate constraint on res.partner. This was the silent failure causing volume generation to abort.
Summary
spp_program_geofencemodule for geofence-based program targeting and eligibility management.spp_gisspatial operators to supportMultiPolygonandGeometryCollection.Follow-up Fixes From Review
MultiPolygon/GeometryCollection,(geojson, distance)now appliesST_Buffer(...)correctly instead of ignoring the distance operand.spp_program_geofenceforspp.gis.geofenceandspp.gis.geofence.tagfor Programs Viewer/Validator roles.groupsrestriction on the Geofences menu to avoid exposing menu items to users lacking read access.Notable Functional Additions
spp_program_geofencegeofence_idsandgeofence_count.spp.program.membership.manager.geofence) with:spp_gisST_GeomFromGeoJSON.YOUR_MAPTILER_API_KEY_HEREtreated as unconfigured).Test Plan
MultiPolygon/GeometryCollectionSQL generation.python3 -m py_compile spp_gis/operators.py spp_gis/tests/test_geo_fields.py./spp t ...is environment-blocked here (tomllibmissing in local Python, and fallback script depends onshuf).