Skip to content

Commit f1793cb

Browse files
feat(spp_gis): re-land geofence targeting for programs with tag migration (from #76)
Re-lands the PR #76 description scope: spp_gis complex-geometry operators (MultiPolygon/GeometryCollection + distance buffering), OSM fallback when no MapTiler key is configured, renderer/edit-widget lifecycle fixes, geofence uuid in GeoJSON output, the spp.gis.geofence.tag model, and the new spp_program_geofence module (geofence eligibility manager, program UI, creation wizard). Adds what the original PR lacked: a migration pair remapping existing vocabulary-based geofence tag links (release v19.0.2.0.0 schema) onto spp.gis.geofence.tag records — the rel table is reused, so legacy rows are parked pre-upgrade and restored post-upgrade. Includes regression tests.
1 parent 47b12f1 commit f1793cb

34 files changed

Lines changed: 2470 additions & 247 deletions

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+
9+
import logging
10+
11+
from odoo import SUPERUSER_ID, api
12+
13+
_logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags")
14+
15+
AUX_TABLE = "spp_gis_geofence_tag_legacy_migration"
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, AUX_TABLE):
25+
return
26+
27+
env = api.Environment(cr, SUPERUSER_ID, {})
28+
Tag = env["spp.gis.geofence.tag"]
29+
30+
cr.execute(f"SELECT DISTINCT vocab_name FROM {AUX_TABLE}")
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(f"SELECT geofence_id, vocab_name FROM {AUX_TABLE}")
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(f"DROP TABLE {AUX_TABLE}")
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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
15+
import logging
16+
17+
_logger = logging.getLogger("odoo.addons.spp_gis.migrations.geofence_tags")
18+
19+
AUX_TABLE = "spp_gis_geofence_tag_legacy_migration"
20+
21+
22+
def _table_exists(cr, table):
23+
cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name = %s", (table,))
24+
return bool(cr.fetchone())
25+
26+
27+
def migrate(cr, version):
28+
if not _table_exists(cr, "spp_gis_geofence_tag_rel"):
29+
return
30+
31+
# A row is a valid new-style link if its tag_id exists in the new tag
32+
# table (only possible when this migration reruns or in tests); it is
33+
# legacy if it references a vocabulary instead. Checking the new table
34+
# first resolves numeric id collisions in favor of valid links.
35+
new_table_exists = _table_exists(cr, "spp_gis_geofence_tag")
36+
valid_clause = (
37+
"AND rel.tag_id NOT IN (SELECT id FROM spp_gis_geofence_tag)" if new_table_exists else ""
38+
)
39+
40+
cr.execute(
41+
f"""
42+
CREATE TABLE IF NOT EXISTS {AUX_TABLE} (
43+
geofence_id integer NOT NULL,
44+
vocab_name varchar NOT NULL
45+
)
46+
"""
47+
)
48+
cr.execute(
49+
f"""
50+
INSERT INTO {AUX_TABLE} (geofence_id, vocab_name)
51+
SELECT rel.geofence_id,
52+
-- name is a translated (jsonb) field: prefer en_US, else any value
53+
COALESCE(v.name->>'en_US', (SELECT t.value FROM jsonb_each_text(v.name) t LIMIT 1))
54+
FROM spp_gis_geofence_tag_rel rel
55+
JOIN spp_vocabulary v ON v.id = rel.tag_id
56+
WHERE true {valid_clause}
57+
"""
58+
)
59+
parked = cr.rowcount
60+
cr.execute(
61+
f"""
62+
DELETE FROM spp_gis_geofence_tag_rel rel
63+
USING spp_vocabulary v
64+
WHERE v.id = rel.tag_id {valid_clause}
65+
"""
66+
)
67+
_logger.info(
68+
"spp_gis geofence tag migration: parked %s legacy vocabulary tag links (%s rows removed)",
69+
parked,
70+
cr.rowcount,
71+
)

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/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
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/** @odoo-module */
2+
3+
/**
4+
* MapLibre/MapTiler GL style for an OpenStreetMap raster fallback, used when no
5+
* MapTiler API key is configured. Shared by the GIS renderer and the geo-edit
6+
* map field widget so the fallback stays consistent in one place.
7+
*
8+
* @returns {Object} A MapLibre GL style object backed by OSM raster tiles.
9+
*/
10+
export function osmFallbackStyle() {
11+
return {
12+
version: 8,
13+
sources: {
14+
osm: {
15+
type: "raster",
16+
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
17+
tileSize: 256,
18+
attribution:
19+
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
20+
},
21+
},
22+
layers: [
23+
{
24+
id: "osm-tiles",
25+
type: "raster",
26+
source: "osm",
27+
minzoom: 0,
28+
maxzoom: 19,
29+
},
30+
],
31+
};
32+
}

0 commit comments

Comments
 (0)