Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions spp_gis/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
+-------------------------------+--------------------------------------+
Expand Down Expand Up @@ -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
~~~~~~~~~~

Expand Down
4 changes: 2 additions & 2 deletions spp_gis/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions spp_gis/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
56 changes: 56 additions & 0 deletions spp_gis/migrations/19.0.2.1.0/post-migration.py
Original file line number Diff line number Diff line change
@@ -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,
)
88 changes: 88 additions & 0 deletions spp_gis/migrations/19.0.2.1.0/pre-migration.py
Original file line number Diff line number Diff line change
@@ -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,
)
16 changes: 15 additions & 1 deletion spp_gis/models/geofence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(),
}
Expand All @@ -180,6 +193,7 @@ def to_geojson(self):

return {
"type": "Feature",
"id": self.uuid,
"geometry": geometry_dict,
"properties": self._get_geojson_properties(),
}
Expand Down
34 changes: 32 additions & 2 deletions spp_gis/operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ class Operator:
"Point": "point",
"LineString": "line",
"Polygon": "polygon",
"MultiPolygon": "multipolygon",
"GeometryCollection": "geometrycollection",
}

def __init__(self, field, table_alias=None):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
7 changes: 5 additions & 2 deletions spp_gis/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) |
Expand Down
7 changes: 7 additions & 0 deletions spp_gis/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions spp_gis/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading