diff --git a/spp_gis/README.rst b/spp_gis/README.rst index 5f046fb4d..1263d1ecd 100644 --- a/spp_gis/README.rst +++ b/spp_gis/README.rst @@ -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 @@ -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 | +-------------------------------+--------------------------------------+ @@ -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 ~~~~~~~~~~ diff --git a/spp_gis/__manifest__.py b/spp_gis/__manifest__.py index f22d35a85..d57d1c4f6 100644 --- a/spp_gis/__manifest__.py +++ b/spp_gis/__manifest__.py @@ -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", diff --git a/spp_gis/controllers/main.py b/spp_gis/controllers/main.py index acc51b692..dd0924614 100644 --- a/spp_gis/controllers/main.py +++ b/spp_gis/controllers/main.py @@ -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} diff --git a/spp_gis/migrations/19.0.2.1.0/post-migration.py b/spp_gis/migrations/19.0.2.1.0/post-migration.py new file mode 100644 index 000000000..bd0d063d6 --- /dev/null +++ b/spp_gis/migrations/19.0.2.1.0/post-migration.py @@ -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, + ) diff --git a/spp_gis/migrations/19.0.2.1.0/pre-migration.py b/spp_gis/migrations/19.0.2.1.0/pre-migration.py new file mode 100644 index 000000000..3031660d8 --- /dev/null +++ b/spp_gis/migrations/19.0.2.1.0/pre-migration.py @@ -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, + ) diff --git a/spp_gis/models/geofence.py b/spp_gis/models/geofence.py index a54f0f2c7..683ed8ffc 100644 --- a/spp_gis/models/geofence.py +++ b/spp_gis/models/geofence.py @@ -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. @@ -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", @@ -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(), } @@ -180,6 +193,7 @@ def to_geojson(self): return { "type": "Feature", + "id": self.uuid, "geometry": geometry_dict, "properties": self._get_geojson_properties(), } diff --git a/spp_gis/operators.py b/spp_gis/operators.py index ee996bbff..03a1c7b78 100644 --- a/spp_gis/operators.py +++ b/spp_gis/operators.py @@ -93,6 +93,8 @@ class Operator: "Point": "point", "LineString": "line", "Polygon": "polygon", + "MultiPolygon": "multipolygon", + "GeometryCollection": "geometrycollection", } def __init__(self, field, table_alias=None): @@ -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 @@ -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: @@ -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)) diff --git a/spp_gis/readme/DESCRIPTION.md b/spp_gis/readme/DESCRIPTION.md index 6ff354a2d..0970b34af 100644 --- a/spp_gis/readme/DESCRIPTION.md +++ b/spp_gis/readme/DESCRIPTION.md @@ -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 @@ -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) | diff --git a/spp_gis/readme/HISTORY.md b/spp_gis/readme/HISTORY.md index 4aaf9afef..4d10b22fd 100644 --- a/spp_gis/readme/HISTORY.md +++ b/spp_gis/readme/HISTORY.md @@ -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 diff --git a/spp_gis/security/ir.model.access.csv b/spp_gis/security/ir.model.access.csv index 16070167b..a17c06fd4 100644 --- a/spp_gis/security/ir.model.access.csv +++ b/spp_gis/security/ir.model.access.csv @@ -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 diff --git a/spp_gis/static/description/index.html b/spp_gis/static/description/index.html index d332aae91..c4d333a6b 100644 --- a/spp_gis/static/description/index.html +++ b/spp_gis/static/description/index.html @@ -382,11 +382,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
  • @@ -409,6 +413,13 @@

    Key Models

    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 @@ -507,6 +518,21 @@

    Changelog

    +

    19.0.2.1.0

    + +
    +

    19.0.2.0.0