Skip to content

Commit f012e90

Browse files
Merge pull request #273 from OpenSPP/reland/gis-geofence
feat: geofence-based geographic targeting for programs (re-land from #76)
2 parents 5a67072 + 5cb9df5 commit f012e90

39 files changed

Lines changed: 2825 additions & 253 deletions

spp_gis/README.rst

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,15 @@ Key Capabilities
3535
any model using PostGIS spatial types
3636
- Visualize records on interactive maps via the GIS view type
3737
- Configure background raster layers (OpenStreetMap, WMS, satellite
38-
imagery)
38+
imagery); map widgets fall back to OpenStreetMap styling when no
39+
MapTiler API key is configured
3940
- Configure data layers with basic or choropleth (color-by-value)
4041
rendering
4142
- Perform spatial queries (intersects, contains, within, distance-based)
42-
via ``gis_locational_query()``
43+
via ``gis_locational_query()``, including complex geometries
44+
(``MultiPolygon``, ``GeometryCollection``) with distance buffering
45+
- Save geographic areas of interest as geofences, classify them with
46+
tags, and export them as GeoJSON features
4347
- Import area boundaries from GeoJSON/shapefiles via area import wizard
4448
- Manage color schemes for thematic mapping with sequential, diverging,
4549
or qualitative palettes
@@ -53,6 +57,11 @@ Key Models
5357
| ``spp.gis.raster.layer`` | Background map layers (OSM, WMS, |
5458
| | image) |
5559
+-------------------------------+--------------------------------------+
60+
| ``spp.gis.geofence`` | Saved geographic areas of interest |
61+
| | (GeoJSON in/out) |
62+
+-------------------------------+--------------------------------------+
63+
| ``spp.gis.geofence.tag`` | Tags for classifying geofences |
64+
+-------------------------------+--------------------------------------+
5665
| ``spp.gis.data.layer`` | Vector data layers referencing geo |
5766
| | fields from any model |
5867
+-------------------------------+--------------------------------------+
@@ -131,6 +140,20 @@ External Python libraries: ``shapely``, ``pyproj``, ``geojson``
131140
Changelog
132141
=========
133142

143+
19.0.2.1.0
144+
~~~~~~~~~~
145+
146+
- feat: spatial operators support MultiPolygon and GeometryCollection,
147+
including distance buffering (re-land from #76).
148+
- feat: OSM style fallback in map renderer/edit widgets when no MapTiler
149+
API key is configured; placeholder key treated as unconfigured
150+
(re-land from #76).
151+
- feat: geofence GeoJSON output includes the record uuid as feature id;
152+
new ``spp.gis.geofence.tag`` model replaces vocabulary-based geofence
153+
tags (re-land from #76).
154+
- feat: migration remaps existing vocabulary-based geofence tag links
155+
onto ``spp.gis.geofence.tag`` records when upgrading from 19.0.2.0.x.
156+
134157
19.0.2.0.0
135158
~~~~~~~~~~
136159

spp_gis/__manifest__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
{
55
"name": "OpenSPP GIS",
66
"category": "OpenSPP/Core",
7-
"version": "19.0.2.0.0",
7+
"version": "19.0.2.1.0",
88
"sequence": 1,
99
"author": "OpenSPP.org",
1010
"website": "https://github.com/OpenSPP/OpenSPP2",
1111
"license": "LGPL-3",
1212
"development_status": "Production/Stable",
1313
"maintainers": ["jeremi", "gonzalesedwin1123", "reichie020212"],
14-
"depends": ["base", "web", "contacts", "spp_security", "spp_area", "spp_vocabulary", "spp_registry"],
14+
"depends": ["base", "web", "contacts", "spp_security", "spp_area", "spp_registry"],
1515
"external_dependencies": {"python": ["shapely", "pyproj", "geojson"]},
1616
"data": [
1717
"data/res_config_data.xml",

spp_gis/controllers/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ class MainController(http.Controller):
77
def get_maptiler_api_key(self):
88
# nosemgrep: odoo-sudo-without-context
99
map_tiler_api_key = request.env["ir.config_parameter"].sudo().get_param("spp_gis.map_tiler_api_key")
10+
# Treat the default placeholder as "not configured"
11+
if map_tiler_api_key == "YOUR_MAPTILER_API_KEY_HERE":
12+
map_tiler_api_key = False
1013
# nosemgrep: odoo-sudo-without-context
1114
web_base_url = request.env["ir.config_parameter"].sudo().get_param("web.base.url")
1215
return {"mapTilerKey": map_tiler_api_key, "webBaseUrl": web_base_url}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Recreate geofence tag links parked by the matching pre-migration.
3+
4+
Creates one spp.gis.geofence.tag per distinct legacy vocabulary name and
5+
restores the geofence links, then drops the aux table. See pre-migration.py
6+
for the full story.
7+
8+
All SQL is literal and value-parameterized; identifiers are never composed.
9+
"""
10+
11+
import logging
12+
13+
from odoo import SUPERUSER_ID, api
14+
15+
_logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags")
16+
17+
18+
def _table_exists(cr, table):
19+
cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name = %s", (table,))
20+
return bool(cr.fetchone())
21+
22+
23+
def migrate(cr, version):
24+
if not _table_exists(cr, "spp_gis_geofence_tag_legacy_migration"):
25+
return
26+
27+
env = api.Environment(cr, SUPERUSER_ID, {})
28+
Tag = env["spp.gis.geofence.tag"]
29+
30+
cr.execute("SELECT DISTINCT vocab_name FROM spp_gis_geofence_tag_legacy_migration")
31+
tag_ids_by_name = {}
32+
for (name,) in cr.fetchall():
33+
tag = Tag.search([("name", "=", name)], limit=1)
34+
if not tag:
35+
tag = Tag.create({"name": name})
36+
tag_ids_by_name[name] = tag.id
37+
38+
restored = 0
39+
cr.execute("SELECT geofence_id, vocab_name FROM spp_gis_geofence_tag_legacy_migration")
40+
for geofence_id, name in cr.fetchall():
41+
cr.execute(
42+
"""
43+
INSERT INTO spp_gis_geofence_tag_rel (geofence_id, tag_id)
44+
VALUES (%s, %s)
45+
ON CONFLICT DO NOTHING
46+
""",
47+
(geofence_id, tag_ids_by_name[name]),
48+
)
49+
restored += cr.rowcount
50+
51+
cr.execute("DROP TABLE spp_gis_geofence_tag_legacy_migration")
52+
_logger.info(
53+
"spp_gis geofence tag migration: created %s tags, restored %s links",
54+
len(tag_ids_by_name),
55+
restored,
56+
)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Park legacy vocabulary-based geofence tag links before the schema swap.
3+
4+
Up to v19.0.2.0.0 (release Biliran), spp.gis.geofence.tag_ids pointed at
5+
spp.vocabulary through spp_gis_geofence_tag_rel. The field now points at the
6+
new spp.gis.geofence.tag model over the SAME rel table. During upgrade the ORM
7+
re-targets the rel table's tag_id foreign key at the new table; legacy rows
8+
referencing vocabulary ids would make that constraint swap fail.
9+
10+
This pre-migration moves legacy rows (with the referenced vocabulary's name)
11+
into an aux table; the matching post-migration recreates them as
12+
spp.gis.geofence.tag links and drops the aux table.
13+
14+
All SQL is literal and value-parameterized; identifiers are never composed.
15+
"""
16+
17+
import logging
18+
19+
_logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags")
20+
21+
_CREATE_AUX = """
22+
CREATE TABLE IF NOT EXISTS spp_gis_geofence_tag_legacy_migration (
23+
geofence_id integer NOT NULL,
24+
vocab_name varchar NOT NULL
25+
)
26+
"""
27+
28+
# name is a translated (jsonb) field: prefer en_US, else any value.
29+
_PARK_LEGACY = """
30+
INSERT INTO spp_gis_geofence_tag_legacy_migration (geofence_id, vocab_name)
31+
SELECT rel.geofence_id,
32+
COALESCE(v.name->>'en_US', (SELECT t.value FROM jsonb_each_text(v.name) t LIMIT 1))
33+
FROM spp_gis_geofence_tag_rel rel
34+
JOIN spp_vocabulary v ON v.id = rel.tag_id
35+
"""
36+
37+
_DELETE_LEGACY = """
38+
DELETE FROM spp_gis_geofence_tag_rel rel
39+
USING spp_vocabulary v
40+
WHERE v.id = rel.tag_id
41+
"""
42+
43+
# Variants used when the new tag table already exists (migration rerun or
44+
# tests): a row is a valid new-style link if its tag_id exists there; it is
45+
# legacy if it references a vocabulary instead. Checking the new table first
46+
# resolves numeric id collisions in favor of valid links.
47+
_PARK_LEGACY_SKIP_VALID = """
48+
INSERT INTO spp_gis_geofence_tag_legacy_migration (geofence_id, vocab_name)
49+
SELECT rel.geofence_id,
50+
COALESCE(v.name->>'en_US', (SELECT t.value FROM jsonb_each_text(v.name) t LIMIT 1))
51+
FROM spp_gis_geofence_tag_rel rel
52+
JOIN spp_vocabulary v ON v.id = rel.tag_id
53+
WHERE rel.tag_id NOT IN (SELECT id FROM spp_gis_geofence_tag)
54+
"""
55+
56+
_DELETE_LEGACY_SKIP_VALID = """
57+
DELETE FROM spp_gis_geofence_tag_rel rel
58+
USING spp_vocabulary v
59+
WHERE v.id = rel.tag_id
60+
AND rel.tag_id NOT IN (SELECT id FROM spp_gis_geofence_tag)
61+
"""
62+
63+
64+
def _table_exists(cr, table):
65+
cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name = %s", (table,))
66+
return bool(cr.fetchone())
67+
68+
69+
def migrate(cr, version):
70+
if not _table_exists(cr, "spp_gis_geofence_tag_rel"):
71+
return
72+
73+
if _table_exists(cr, "spp_gis_geofence_tag"):
74+
park_query = _PARK_LEGACY_SKIP_VALID
75+
delete_query = _DELETE_LEGACY_SKIP_VALID
76+
else:
77+
park_query = _PARK_LEGACY
78+
delete_query = _DELETE_LEGACY
79+
80+
cr.execute(_CREATE_AUX)
81+
cr.execute(park_query)
82+
parked = cr.rowcount
83+
cr.execute(delete_query)
84+
_logger.info(
85+
"spp_gis geofence tag migration: parked %s legacy vocabulary tag links (%s rows removed)",
86+
parked,
87+
cr.rowcount,
88+
)

spp_gis/models/geofence.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@
1515
_logger = logging.getLogger(__name__)
1616

1717

18+
class GisGeofenceTag(models.Model):
19+
"""Tags for classifying geofences."""
20+
21+
_name = "spp.gis.geofence.tag"
22+
_description = "Geofence Tag"
23+
_order = "name"
24+
25+
name = fields.Char(required=True, translate=True)
26+
color = fields.Integer(string="Color Index")
27+
active = fields.Boolean(default=True)
28+
29+
1830
class GisGeofence(models.Model):
1931
"""Saved Geographic Areas of Interest.
2032
@@ -63,7 +75,7 @@ class GisGeofence(models.Model):
6375

6476
# Tags for flexible classification
6577
tag_ids = fields.Many2many(
66-
"spp.vocabulary",
78+
"spp.gis.geofence.tag",
6779
"spp_gis_geofence_tag_rel",
6880
"geofence_id",
6981
"tag_id",
@@ -167,6 +179,7 @@ def to_geojson(self):
167179
if not self.geometry:
168180
return {
169181
"type": "Feature",
182+
"id": self.uuid,
170183
"geometry": None,
171184
"properties": self._get_geojson_properties(),
172185
}
@@ -180,6 +193,7 @@ def to_geojson(self):
180193

181194
return {
182195
"type": "Feature",
196+
"id": self.uuid,
183197
"geometry": geometry_dict,
184198
"properties": self._get_geojson_properties(),
185199
}

spp_gis/operators.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ class Operator:
9393
"Point": "point",
9494
"LineString": "line",
9595
"Polygon": "polygon",
96+
"MultiPolygon": "multipolygon",
97+
"GeometryCollection": "geometrycollection",
9698
}
9799

98100
def __init__(self, field, table_alias=None):
@@ -256,6 +258,18 @@ def create_polygon(self, coordinates, srid):
256258
polygon = self.st_makepolygon(points)
257259
return self.st_setsrid(polygon, srid)
258260

261+
def create_from_geojson(self, geojson_dict, srid):
262+
"""Create geometry from full GeoJSON using ST_GeomFromGeoJSON.
263+
264+
Used for complex geometry types (MultiPolygon, GeometryCollection)
265+
that cannot be easily constructed from coordinates.
266+
267+
Returns a SQL object with the GeoJSON string as a bound parameter
268+
to avoid SQL injection via string interpolation.
269+
"""
270+
geojson_str = json.dumps(geojson_dict)
271+
return SQL("ST_SetSRID(ST_GeomFromGeoJSON(%s), %s)", geojson_str, srid)
272+
259273
def validate_coordinates_for_point(self, coordinates):
260274
"""
261275
The function `validate_coordinates_for_point` checks if a set of coordinates represents a valid
@@ -454,7 +468,10 @@ def validate_geojson(self, geojson):
454468
to validate the structure of the GeoJSON using the `shape` function
455469
"""
456470
if geojson.get("type") not in self.ALLOWED_LAYER_TYPE:
457-
raise ValueError("Invalid geojson type. Allowed types are Point, LineString, and Polygon.")
471+
raise ValueError(
472+
"Invalid geojson type. Allowed types are Point, LineString, "
473+
"Polygon, MultiPolygon, and GeometryCollection."
474+
)
458475
try:
459476
shape(geojson)
460477
except Exception as e:
@@ -487,6 +504,19 @@ def domain_query(self, operator, value):
487504

488505
operation = self.OPERATION_TO_RELATION[operator]
489506
layer_type = self.ALLOWED_LAYER_TYPE[geojson_val["type"]]
490-
coordinates = geojson_val["coordinates"]
491507

508+
if layer_type in ("multipolygon", "geometrycollection"):
509+
# Complex types use ST_GeomFromGeoJSON directly
510+
geom = self.create_from_geojson(geojson_val, self.field.srid)
511+
postgis_fn = self.POSTGIS_SPATIAL_RELATION[operation]
512+
right = SQL(self.qualified_field_name)
513+
if distance:
514+
left = geom
515+
if self.field.srid == 4326:
516+
left = SQL("ST_Transform(%s, %s)", geom, 3857)
517+
right = SQL("ST_Transform(%s, %s)", right, 3857)
518+
return SQL("%s(ST_Buffer(%s, %s), %s)", SQL(postgis_fn), left, distance, right)
519+
return SQL("%s(%s, %s)", SQL(postgis_fn), geom, right)
520+
521+
coordinates = geojson_val["coordinates"]
492522
return SQL(self.get_postgis_query(operation, coordinates, distance=distance, layer_type=layer_type))

spp_gis/readme/DESCRIPTION.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ PostGIS integration for geospatial data management, map visualization, and spati
44

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

@@ -15,6 +16,8 @@ PostGIS integration for geospatial data management, map visualization, and spati
1516
| Model | Description |
1617
| ----------------------------- | ---------------------------------------------------------- |
1718
| `spp.gis.raster.layer` | Background map layers (OSM, WMS, image) |
19+
| `spp.gis.geofence` | Saved geographic areas of interest (GeoJSON in/out) |
20+
| `spp.gis.geofence.tag` | Tags for classifying geofences |
1821
| `spp.gis.data.layer` | Vector data layers referencing geo fields from any model |
1922
| `spp.gis.color.scheme` | Color palettes for choropleth and thematic visualizations |
2023
| `spp.gis.raster.layer.type` | Raster layer type definitions (WMS services) |

spp_gis/readme/HISTORY.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
### 19.0.2.1.0
2+
3+
- feat: spatial operators support MultiPolygon and GeometryCollection, including distance buffering (re-land from #76).
4+
- 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).
5+
- 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).
6+
- feat: migration remaps existing vocabulary-based geofence tag links onto `spp.gis.geofence.tag` records when upgrading from 19.0.2.0.x.
7+
18
### 19.0.2.0.0
29

310
- Initial migration to OpenSPP2

spp_gis/security/ir.model.access.csv

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ access_spp_raster_layer_admin,Raster Layer Admin,spp_gis.model_spp_gis_raster_la
66
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
77
access_spp_data_layer_read,Data Layer Read,spp_gis.model_spp_gis_data_layer,spp_registry.group_registry_read,1,0,0,0
88
access_spp_raster_layer_read,Raster Layer Read,spp_gis.model_spp_gis_raster_layer,spp_registry.group_registry_read,1,0,0,0
9+
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
10+
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
11+
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
912
access_spp_gis_geofence_admin,Geofence Admin,spp_gis.model_spp_gis_geofence,spp_security.group_spp_admin,1,1,1,1
1013
access_spp_gis_geofence_manager,Geofence Manager,spp_gis.model_spp_gis_geofence,spp_registry.group_registry_manager,1,1,1,1
1114
access_spp_gis_geofence_officer,Geofence Officer,spp_gis.model_spp_gis_geofence,spp_registry.group_registry_officer,1,1,1,0

0 commit comments

Comments
 (0)