From 0a11b276f3f3b779a4fabe4567bc44fdd709ee3e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 26 Jul 2026 22:53:14 +0700 Subject: [PATCH 01/13] fix(spp_api_v2_gis): bind coordinate query params in SQL order The filter placeholders live inside {where_clause}, which precedes the geometry placeholder, but the params list was built geometry-first and then rebuilt into the identical list. With an is_group filter the GeoJSON string was bound to p.is_group and PostgreSQL rejected the statement. Signed-off-by: Jeremi Joslin --- .../services/spatial_query_service.py | 8 +- spp_api_v2_gis/tests/__init__.py | 1 + .../tests/test_spatial_query_coordinates.py | 139 ++++++++++++++++++ 3 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 spp_api_v2_gis/tests/test_spatial_query_coordinates.py diff --git a/spp_api_v2_gis/services/spatial_query_service.py b/spp_api_v2_gis/services/spatial_query_service.py index 6c49aba85..c06938e7f 100644 --- a/spp_api_v2_gis/services/spatial_query_service.py +++ b/spp_api_v2_gis/services/spatial_query_service.py @@ -176,11 +176,11 @@ def _query_by_coordinates(self, geometry_json, filters): """ # Build WHERE clause from filters where_clauses = ["p.is_registrant = true"] - params = [geometry_json] + filter_params = [] if filters.get("is_group") is not None: where_clauses.append("p.is_group = %s") - params.append(filters["is_group"]) + filter_params.append(filters["is_group"]) if filters.get("disabled") is not None: if filters["disabled"]: @@ -209,8 +209,8 @@ def _query_by_coordinates(self, geometry_json, filters): ) """ # nosec B608 - SQL clauses built from hardcoded fragments, data uses %s params - # Add geometry parameter at the beginning - params = [geometry_json] + params[1:] + # Params ordered to match SQL: filter params (in where_clause) then geometry + params = filter_params + [geometry_json] self.env.cr.execute(query, params) registrant_ids = [row[0] for row in self.env.cr.fetchall()] diff --git a/spp_api_v2_gis/tests/__init__.py b/spp_api_v2_gis/tests/__init__.py index 06c22da07..fa6fbdb5c 100644 --- a/spp_api_v2_gis/tests/__init__.py +++ b/spp_api_v2_gis/tests/__init__.py @@ -6,6 +6,7 @@ from . import test_ogc_features from . import test_ogc_http from . import test_qml_template_service +from . import test_spatial_query_coordinates from . import test_spatial_query_service from . import test_statistics_endpoint from . import test_batch_query diff --git a/spp_api_v2_gis/tests/test_spatial_query_coordinates.py b/spp_api_v2_gis/tests/test_spatial_query_coordinates.py new file mode 100644 index 000000000..cc03d8571 --- /dev/null +++ b/spp_api_v2_gis/tests/test_spatial_query_coordinates.py @@ -0,0 +1,139 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for the coordinate-based branch of the spatial query service. + +``res.partner.coordinates`` is added by ``spp_registrant_gis``, which is not a +dependency of ``spp_api_v2_gis``. These tests create the PostGIS column and +declare the field on the model for the duration of the test so the coordinate +branch can be exercised without adding a module dependency. +""" + +import json +from contextlib import contextmanager +from types import MappingProxyType +from unittest.mock import patch + +from odoo import fields +from odoo.tests.common import TransactionCase + +# Polygon covering roughly lon 27.9..28.1 / lat -2.1..-1.9 (East Africa). +QUERY_POLYGON = { + "type": "Polygon", + "coordinates": [[[27.9, -2.1], [28.1, -2.1], [28.1, -1.9], [27.9, -1.9], [27.9, -2.1]]], +} + + +@contextmanager +def declared_coordinates_field(env): + """Declare ``coordinates`` on res.partner as ``spp_registrant_gis`` would. + + ``_query_by_coordinates`` refuses to run when the field is absent, so the + field has to be visible on the model while the query runs. ``_fields`` is a + read-only mapping, so the whole mapping is swapped for a widened copy. + """ + partner_cls = type(env["res.partner"]) + widened = MappingProxyType({**partner_cls._fields, "coordinates": fields.GeoPointField()}) + with patch.object(partner_cls, "_fields", widened): + yield + + +class TestQueryByCoordinates(TransactionCase): + """Coordinate query must bind its parameters in the order the SQL expects.""" + + @classmethod + def setUpClass(cls): + """Add the coordinates column and create registrants inside/outside the polygon.""" + super().setUpClass() + + # Mirrors the geometry(Point, 4326) column created by GeoPointField. + cls.env.cr.execute("ALTER TABLE res_partner ADD COLUMN coordinates geometry(Point, 4326)") + + cls.group_inside = cls.env["res.partner"].create( + { + "name": "Coordinates Household Inside", + "is_registrant": True, + "is_group": True, + } + ) + cls.individual_inside = cls.env["res.partner"].create( + { + "name": "Coordinates Individual Inside", + "is_registrant": True, + "is_group": False, + } + ) + cls.group_outside = cls.env["res.partner"].create( + { + "name": "Coordinates Household Outside", + "is_registrant": True, + "is_group": True, + } + ) + + cls._set_coordinates(cls.group_inside, 28.0, -2.0) + cls._set_coordinates(cls.individual_inside, 28.01, -2.01) + cls._set_coordinates(cls.group_outside, 32.0, -5.0) + + @classmethod + def _set_coordinates(cls, partner, longitude, latitude): + """Write a point into the raw coordinates column.""" + cls.env.cr.execute( + "UPDATE res_partner SET coordinates = ST_SetSRID(ST_MakePoint(%s, %s), 4326) WHERE id = %s", + [longitude, latitude, partner.id], + ) + + def _get_service(self): + """Create a SpatialQueryService instance.""" + from ..services.spatial_query_service import SpatialQueryService + + return SpatialQueryService(self.env) + + def test_query_without_filters(self): + """Without filters, every registrant inside the polygon is returned.""" + service = self._get_service() + + with declared_coordinates_field(self.env): + result = service._query_by_coordinates(json.dumps(QUERY_POLYGON), {}) + + self.assertEqual(result["query_method"], "coordinates") + self.assertIn(self.group_inside.id, result["registrant_ids"]) + self.assertIn(self.individual_inside.id, result["registrant_ids"]) + self.assertNotIn(self.group_outside.id, result["registrant_ids"]) + + def test_is_group_true_filter(self): + """The is_group filter must be bound to p.is_group, not to the geometry.""" + service = self._get_service() + + with declared_coordinates_field(self.env): + result = service._query_by_coordinates(json.dumps(QUERY_POLYGON), {"is_group": True}) + + self.assertEqual(result["query_method"], "coordinates") + self.assertIn(self.group_inside.id, result["registrant_ids"]) + self.assertNotIn(self.individual_inside.id, result["registrant_ids"]) + self.assertNotIn(self.group_outside.id, result["registrant_ids"]) + + def test_is_group_false_filter(self): + """is_group=False returns individuals inside the polygon only.""" + service = self._get_service() + + with declared_coordinates_field(self.env): + result = service._query_by_coordinates(json.dumps(QUERY_POLYGON), {"is_group": False}) + + self.assertEqual(result["query_method"], "coordinates") + self.assertIn(self.individual_inside.id, result["registrant_ids"]) + self.assertNotIn(self.group_inside.id, result["registrant_ids"]) + self.assertNotIn(self.group_outside.id, result["registrant_ids"]) + + def test_is_group_filter_combined_with_disabled_filter(self): + """The disabled filter adds no placeholder and must not shift the params.""" + service = self._get_service() + + with declared_coordinates_field(self.env): + result = service._query_by_coordinates( + json.dumps(QUERY_POLYGON), + {"is_group": True, "disabled": False}, + ) + + self.assertEqual(result["query_method"], "coordinates") + self.assertIn(self.group_inside.id, result["registrant_ids"]) + self.assertNotIn(self.individual_inside.id, result["registrant_ids"]) + self.assertNotIn(self.group_outside.id, result["registrant_ids"]) From bfb6f8f5ab7a3bd06a0cd77db44505a7948e8024 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 26 Jul 2026 22:55:38 +0700 Subject: [PATCH 02/13] fix(spp_api_v2_gis): run the coordinate query inside a savepoint query_statistics catches a failing coordinate query and retries with the area query on the same cursor. Without a savepoint the first failure had already aborted the transaction, so the fallback raised InFailedSqlTransaction and the endpoint returned 500 instead of degrading. Signed-off-by: Jeremi Joslin --- .../services/spatial_query_service.py | 6 +- spp_api_v2_gis/tests/__init__.py | 1 + .../tests/test_spatial_query_fallback.py | 96 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 spp_api_v2_gis/tests/test_spatial_query_fallback.py diff --git a/spp_api_v2_gis/services/spatial_query_service.py b/spp_api_v2_gis/services/spatial_query_service.py index c06938e7f..0341cc8a1 100644 --- a/spp_api_v2_gis/services/spatial_query_service.py +++ b/spp_api_v2_gis/services/spatial_query_service.py @@ -134,7 +134,11 @@ def query_statistics(self, geometry, filters=None, variables=None): # Try coordinate-based query first (preferred method) try: - result = self._query_by_coordinates(geometry_json, filters) + # A failed statement aborts the whole transaction, which would make + # the area fallback below fail too. The savepoint contains it. + # flush=False keeps unrelated pending ORM writes out of the rollback. + with self.env.cr.savepoint(flush=False): + result = self._query_by_coordinates(geometry_json, filters) if result["total_count"] > 0: _logger.info( "Spatial query using coordinates: %s registrants found", diff --git a/spp_api_v2_gis/tests/__init__.py b/spp_api_v2_gis/tests/__init__.py index fa6fbdb5c..ef5342c6c 100644 --- a/spp_api_v2_gis/tests/__init__.py +++ b/spp_api_v2_gis/tests/__init__.py @@ -7,6 +7,7 @@ from . import test_ogc_http from . import test_qml_template_service from . import test_spatial_query_coordinates +from . import test_spatial_query_fallback from . import test_spatial_query_service from . import test_statistics_endpoint from . import test_batch_query diff --git a/spp_api_v2_gis/tests/test_spatial_query_fallback.py b/spp_api_v2_gis/tests/test_spatial_query_fallback.py new file mode 100644 index 000000000..723cb9e7f --- /dev/null +++ b/spp_api_v2_gis/tests/test_spatial_query_fallback.py @@ -0,0 +1,96 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for the area fallback taken when the coordinate query fails. + +``query_statistics`` catches failures from the coordinate-based query and +retries with the area-based query on the same cursor. A failed statement +aborts the PostgreSQL transaction, so the coordinate query has to run inside a +savepoint for the fallback to be reachable at all. +""" + +import json +from unittest.mock import patch + +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + +SERVICE_LOGGER = "odoo.addons.spp_api_v2_gis.services.spatial_query_service" + +# Polygon covering roughly lon 27.9..28.1 / lat -2.1..-1.9 (East Africa). +QUERY_POLYGON = { + "type": "Polygon", + "coordinates": [[[27.9, -2.1], [28.1, -2.1], [28.1, -1.9], [27.9, -1.9], [27.9, -2.1]]], +} + + +def _failing_coordinate_query(self, geometry_json, filters): + """Stand-in for a coordinate query that dies inside PostgreSQL.""" + self.env.cr.execute("SELECT id FROM spp_table_that_does_not_exist") + + +class TestCoordinateQueryFallback(TransactionCase): + """The failed coordinate attempt must not poison the area fallback.""" + + @classmethod + def setUpClass(cls): + """Create an area covering the query polygon plus a registrant in it.""" + super().setUpClass() + + cls.area = cls.env["spp.area"].create( + { + "draft_name": "Fallback Test Area", + "code": "FALLBACK-AREA-001", + } + ) + cls.env.cr.execute( + """ + UPDATE spp_area + SET geo_polygon = ST_SetSRID(ST_GeomFromGeoJSON(%s), 4326) + WHERE id = %s + """, + [json.dumps(QUERY_POLYGON), cls.area.id], + ) + + cls.group = cls.env["res.partner"].create( + { + "name": "Fallback Test Household", + "is_registrant": True, + "is_group": True, + "area_id": cls.area.id, + } + ) + + def test_area_fallback_runs_after_failed_coordinate_query(self): + """A SQL error in the coordinate query degrades to the area query.""" + from ..services.spatial_query_service import SpatialQueryService + + service = SpatialQueryService(self.env) + + with ( + patch.object(SpatialQueryService, "_query_by_coordinates", _failing_coordinate_query), + mute_logger("odoo.sql_db"), + self.assertLogs(SERVICE_LOGGER, level="WARNING") as captured, + ): + result = service.query_statistics(geometry=QUERY_POLYGON) + + self.assertEqual(result["query_method"], "area_fallback") + self.assertIn(self.group.id, result["registrant_ids"]) + self.assertTrue( + any("Coordinate-based query failed" in message for message in captured.output), + f"expected a fallback warning, got {captured.output}", + ) + + def test_cursor_stays_usable_after_failed_coordinate_query(self): + """The transaction is still usable once the fallback has completed.""" + from ..services.spatial_query_service import SpatialQueryService + + service = SpatialQueryService(self.env) + + with ( + patch.object(SpatialQueryService, "_query_by_coordinates", _failing_coordinate_query), + mute_logger("odoo.sql_db"), + self.assertLogs(SERVICE_LOGGER, level="WARNING"), + ): + service.query_statistics(geometry=QUERY_POLYGON) + + self.env.cr.execute("SELECT id FROM res_partner WHERE id = %s", [self.group.id]) + self.assertEqual(self.env.cr.fetchall(), [(self.group.id,)]) From c03ede50b55893d282a29952515e8d8075ff7b0f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 26 Jul 2026 22:56:55 +0700 Subject: [PATCH 03/13] fix(spp_api_v2_gis): add geofence and incident scope actions routers/geofence.py gates create and delete on has_scope("gis", "geofence"), but geofence was not a selectable action, so only clients holding gis:all could reach those endpoints and no client could ever be granted the intended scope. Signed-off-by: Jeremi Joslin --- spp_api_v2_gis/models/api_client_scope.py | 8 ++ spp_api_v2_gis/tests/__init__.py | 1 + spp_api_v2_gis/tests/test_api_client_scope.py | 96 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 spp_api_v2_gis/tests/test_api_client_scope.py diff --git a/spp_api_v2_gis/models/api_client_scope.py b/spp_api_v2_gis/models/api_client_scope.py index a9c767de1..40ce1ae51 100644 --- a/spp_api_v2_gis/models/api_client_scope.py +++ b/spp_api_v2_gis/models/api_client_scope.py @@ -16,3 +16,11 @@ class ApiClientScope(models.Model): ], ondelete={"gis": "cascade", "statistics": "cascade"}, ) + + action = fields.Selection( + selection_add=[ + ("geofence", "Geofence Management"), + ("incident", "Incident Management"), + ], + ondelete={"geofence": "cascade", "incident": "cascade"}, + ) diff --git a/spp_api_v2_gis/tests/__init__.py b/spp_api_v2_gis/tests/__init__.py index ef5342c6c..bb18c8162 100644 --- a/spp_api_v2_gis/tests/__init__.py +++ b/spp_api_v2_gis/tests/__init__.py @@ -1,4 +1,5 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import test_api_client_scope from . import test_catalog_service from . import test_export_service from . import test_geofence_model diff --git a/spp_api_v2_gis/tests/test_api_client_scope.py b/spp_api_v2_gis/tests/test_api_client_scope.py new file mode 100644 index 000000000..9706909d8 --- /dev/null +++ b/spp_api_v2_gis/tests/test_api_client_scope.py @@ -0,0 +1,96 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for the GIS-specific actions on spp.api.client.scope. + +``routers/geofence.py`` gates create/delete on ``has_scope("gis", "geofence")``, +so ``geofence`` has to be a selectable value of the ``action`` field. Otherwise +the only client able to reach those endpoints is one holding ``action = all``. +""" + +from odoo.tests.common import TransactionCase + + +class TestGisActionScopes(TransactionCase): + """The GIS module must contribute its own scope actions.""" + + @classmethod + def setUpClass(cls): + """Set up an organization and type usable by API clients.""" + super().setUpClass() + cls.ApiClient = cls.env["spp.api.client"] + cls.ApiScope = cls.env["spp.api.client.scope"] + + cls.test_partner = cls.env["res.partner"].create({"name": "Geofence Scope Organization"}) + + cls.org_type = cls.env.ref("spp_consent.org_type_government", raise_if_not_found=False) + if not cls.org_type: + cls.org_type = cls.env["spp.consent.org.type"].search([("code", "=", "government")], limit=1) + if not cls.org_type: + cls.org_type = cls.env["spp.consent.org.type"].create({"name": "Government", "code": "government"}) + + def _create_client_with_scopes(self, scopes): + """Create an API client holding the given (resource, action) scopes.""" + client = self.ApiClient.create( + { + "name": f"Geofence Scope Client {id(scopes)}", + "client_id": f"test_geofence_client_{id(scopes)}", + "partner_id": self.test_partner.id, + "organization_type_id": self.org_type.id, + } + ) + for resource, action in scopes: + self.ApiScope.create( + { + "client_id": client.id, + "resource": resource, + "action": action, + } + ) + return client + + def test_geofence_action_is_selectable(self): + """The geofence action is offered by the action selection.""" + selection = dict(self.ApiScope.fields_get(["action"])["action"]["selection"]) + self.assertIn("geofence", selection) + + def test_incident_action_is_selectable(self): + """The incident action is offered by the action selection.""" + selection = dict(self.ApiScope.fields_get(["action"])["action"]["selection"]) + self.assertIn("incident", selection) + + def test_geofence_scope_can_be_stored(self): + """A gis:geofence scope record can be created.""" + client = self._create_client_with_scopes([("gis", "geofence")]) + + scope = client.scope_ids.filtered(lambda s: s.resource == "gis") + self.assertEqual(scope.action, "geofence") + + def test_geofence_scope_grants_access(self): + """A client holding gis:geofence passes the endpoint check.""" + client = self._create_client_with_scopes([("gis", "geofence")]) + + self.assertTrue(client.has_scope("gis", "geofence")) + + def test_read_scope_does_not_grant_geofence_access(self): + """gis:read is not enough to manage geofences.""" + client = self._create_client_with_scopes([("gis", "read")]) + + self.assertFalse(client.has_scope("gis", "geofence")) + + def test_all_action_still_grants_geofence_access(self): + """gis:all keeps granting geofence access.""" + client = self._create_client_with_scopes([("gis", "all")]) + + self.assertTrue(client.has_scope("gis", "geofence")) + + def test_geofence_scope_does_not_grant_other_actions(self): + """gis:geofence does not widen into unrelated actions.""" + client = self._create_client_with_scopes([("gis", "geofence")]) + + self.assertFalse(client.has_scope("gis", "read")) + self.assertFalse(client.has_scope("gis", "delete")) + + def test_incident_scope_can_be_stored(self): + """A gis:incident scope record can be created.""" + client = self._create_client_with_scopes([("gis", "incident")]) + + self.assertTrue(client.has_scope("gis", "incident")) From 544132bb53b1f6e49ab45fa5a6e4c045d839fbc3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 26 Jul 2026 22:58:39 +0700 Subject: [PATCH 04/13] chore(spp_api_v2_gis): bump to 19.0.2.0.1 No migration script: selection_add only widens the allowed set of action values, every stored value stays valid, the column stays varchar and there is no SQL constraint on it. The version bump is what makes Odoo re-register the selection on update. Signed-off-by: Jeremi Joslin --- spp_api_v2_gis/README.rst | 43 ++++++++++++-------- spp_api_v2_gis/__manifest__.py | 2 +- spp_api_v2_gis/readme/HISTORY.md | 6 +++ spp_api_v2_gis/static/description/index.html | 27 ++++++++---- 4 files changed, 52 insertions(+), 26 deletions(-) diff --git a/spp_api_v2_gis/README.rst b/spp_api_v2_gis/README.rst index 961e531d3..191c07749 100644 --- a/spp_api_v2_gis/README.rst +++ b/spp_api_v2_gis/README.rst @@ -53,23 +53,23 @@ API Endpoints **OGC API - Features (primary interface)** -+-------------------------------------------+--------+-----------------------------+ -| Endpoint | Method | Description | -+===========================================+========+=============================+ -| ``/gis/ogc/`` | GET | OGC API landing page | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/conformance`` | GET | OGC conformance classes | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections`` | GET | List feature collections | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}`` | GET | Collection metadata | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}/items`` | GET | Feature items (GeoJSON) | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}/items/{fid}`` | GET | Single feature | -+-------------------------------------------+--------+-----------------------------+ -| ``/gis/ogc/collections/{id}/qml`` | GET | QGIS style file (extension) | -+-------------------------------------------+--------+-----------------------------+ ++-------------------------------------------+--------+------------------------------+ +| Endpoint | Method | Description | ++===========================================+========+==============================+ +| ``/gis/ogc/`` | GET | OGC API landing page | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/conformance`` | GET | OGC conformance classes | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/collections`` | GET | List feature collections | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/collections/{id}`` | GET | Collection metadata | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/collections/{id}/items`` | GET | Feature items (GeoJSON) | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/collections/{id}/items/{fid}`` | GET | Single feature | ++-------------------------------------------+--------+------------------------------+ +| ``/gis/ogc/collections/{id}/qml`` | GET | QGIS style file (extension) | ++-------------------------------------------+--------+------------------------------+ **Additional endpoints** @@ -156,6 +156,15 @@ Dependencies Changelog ========= +19.0.2.0.1 +~~~~~~~~~~ + +- fix: bind coordinate query parameters in the order the SQL expects +- fix: run the coordinate query inside a savepoint so the area fallback + stays reachable +- fix: add ``geofence`` and ``incident`` scope actions so geofence + endpoints can be granted + 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_api_v2_gis/__manifest__.py b/spp_api_v2_gis/__manifest__.py index 2aad21224..e8616fc42 100644 --- a/spp_api_v2_gis/__manifest__.py +++ b/spp_api_v2_gis/__manifest__.py @@ -2,7 +2,7 @@ { "name": "OpenSPP GIS API", "category": "OpenSPP/Integration", - "version": "19.0.2.0.0", + "version": "19.0.2.0.1", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_api_v2_gis/readme/HISTORY.md b/spp_api_v2_gis/readme/HISTORY.md index 4aaf9afef..921acfaf1 100644 --- a/spp_api_v2_gis/readme/HISTORY.md +++ b/spp_api_v2_gis/readme/HISTORY.md @@ -1,3 +1,9 @@ +### 19.0.2.0.1 + +- fix: bind coordinate query parameters in the order the SQL expects +- fix: run the coordinate query inside a savepoint so the area fallback stays reachable +- fix: add `geofence` and `incident` scope actions so geofence endpoints can be granted + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_api_v2_gis/static/description/index.html b/spp_api_v2_gis/static/description/index.html index b7c6f7e27..82bcf734c 100644 --- a/spp_api_v2_gis/static/description/index.html +++ b/spp_api_v2_gis/static/description/index.html @@ -401,9 +401,9 @@

API Endpoints

OGC API - Features (primary interface)

-+-+ @@ -557,24 +557,35 @@

Dependencies

Changelog

-

19.0.2.0.0

+

19.0.2.0.1

+
    +
  • fix: bind coordinate query parameters in the order the SQL expects
  • +
  • fix: run the coordinate query inside a savepoint so the area fallback +stays reachable
  • +
  • fix: add geofence and incident scope actions so geofence +endpoints can be granted
  • +
+
+
+

19.0.2.0.0

  • Initial migration to OpenSPP2
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -582,7 +593,7 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

From 3e6da2aa1e5c243345d576b335b89e69fcb8e564 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 26 Jul 2026 23:08:19 +0700 Subject: [PATCH 05/13] fix(spp_api_v2_gis): run the proximity query inside a savepoint query_proximity has the same defect as query_statistics: it catches a failing coordinate query and retries with the area query on the same cursor, but the first failure had already aborted the transaction, so _create_proximity_temp_table raised InFailedSqlTransaction and the endpoint returned 500 instead of degrading. Signed-off-by: Jeremi Joslin --- spp_api_v2_gis/README.rst | 6 +- spp_api_v2_gis/readme/HISTORY.md | 3 +- .../services/spatial_query_service.py | 6 +- spp_api_v2_gis/static/description/index.html | 6 +- .../tests/test_spatial_query_fallback.py | 88 +++++++++++++++++-- 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/spp_api_v2_gis/README.rst b/spp_api_v2_gis/README.rst index 191c07749..342694091 100644 --- a/spp_api_v2_gis/README.rst +++ b/spp_api_v2_gis/README.rst @@ -160,8 +160,10 @@ Changelog ~~~~~~~~~~ - fix: bind coordinate query parameters in the order the SQL expects -- fix: run the coordinate query inside a savepoint so the area fallback - stays reachable +- fix: run the coordinate statistics query inside a savepoint so the + area fallback stays reachable +- fix: run the coordinate proximity query inside a savepoint so the area + fallback stays reachable - fix: add ``geofence`` and ``incident`` scope actions so geofence endpoints can be granted diff --git a/spp_api_v2_gis/readme/HISTORY.md b/spp_api_v2_gis/readme/HISTORY.md index 921acfaf1..407d9cced 100644 --- a/spp_api_v2_gis/readme/HISTORY.md +++ b/spp_api_v2_gis/readme/HISTORY.md @@ -1,7 +1,8 @@ ### 19.0.2.0.1 - fix: bind coordinate query parameters in the order the SQL expects -- fix: run the coordinate query inside a savepoint so the area fallback stays reachable +- fix: run the coordinate statistics query inside a savepoint so the area fallback stays reachable +- fix: run the coordinate proximity query inside a savepoint so the area fallback stays reachable - fix: add `geofence` and `incident` scope actions so geofence endpoints can be granted ### 19.0.2.0.0 diff --git a/spp_api_v2_gis/services/spatial_query_service.py b/spp_api_v2_gis/services/spatial_query_service.py index 0341cc8a1..f56fa9455 100644 --- a/spp_api_v2_gis/services/spatial_query_service.py +++ b/spp_api_v2_gis/services/spatial_query_service.py @@ -489,7 +489,11 @@ def query_proximity(self, reference_points, radius_km, relation="within", filter # Try coordinate-based query first try: - result = self._proximity_by_coordinates(reference_points, radius_meters, relation, filters) + # A failed statement aborts the whole transaction, which would make + # the area fallback below fail too. The savepoint contains it. + # flush=False keeps unrelated pending ORM writes out of the rollback. + with self.env.cr.savepoint(flush=False): + result = self._proximity_by_coordinates(reference_points, radius_meters, relation, filters) if result["total_count"] > 0: _logger.info( "Proximity query (%s, %.1f km) using coordinates: %s registrants found", diff --git a/spp_api_v2_gis/static/description/index.html b/spp_api_v2_gis/static/description/index.html index 82bcf734c..c73a0b7b1 100644 --- a/spp_api_v2_gis/static/description/index.html +++ b/spp_api_v2_gis/static/description/index.html @@ -571,8 +571,10 @@

Changelog

19.0.2.0.1

  • fix: bind coordinate query parameters in the order the SQL expects
  • -
  • fix: run the coordinate query inside a savepoint so the area fallback -stays reachable
  • +
  • fix: run the coordinate statistics query inside a savepoint so the +area fallback stays reachable
  • +
  • fix: run the coordinate proximity query inside a savepoint so the area +fallback stays reachable
  • fix: add geofence and incident scope actions so geofence endpoints can be granted
diff --git a/spp_api_v2_gis/tests/test_spatial_query_fallback.py b/spp_api_v2_gis/tests/test_spatial_query_fallback.py index 723cb9e7f..29f6cc2db 100644 --- a/spp_api_v2_gis/tests/test_spatial_query_fallback.py +++ b/spp_api_v2_gis/tests/test_spatial_query_fallback.py @@ -1,10 +1,10 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -"""Tests for the area fallback taken when the coordinate query fails. +"""Tests for the area fallbacks taken when a coordinate query fails. -``query_statistics`` catches failures from the coordinate-based query and -retries with the area-based query on the same cursor. A failed statement -aborts the PostgreSQL transaction, so the coordinate query has to run inside a -savepoint for the fallback to be reachable at all. +``query_statistics`` and ``query_proximity`` both catch failures from their +coordinate-based query and retry with the area-based query on the same cursor. +A failed statement aborts the PostgreSQL transaction, so the coordinate query +has to run inside a savepoint for either fallback to be reachable at all. """ import json @@ -22,11 +22,20 @@ } +# Centre of QUERY_POLYGON, used as the proximity reference point. +REFERENCE_POINTS = [{"longitude": 28.0, "latitude": -2.0}] + + def _failing_coordinate_query(self, geometry_json, filters): """Stand-in for a coordinate query that dies inside PostgreSQL.""" self.env.cr.execute("SELECT id FROM spp_table_that_does_not_exist") +def _failing_proximity_query(self, reference_points, radius_meters, relation, filters): + """Stand-in for a proximity query that dies inside PostgreSQL.""" + self.env.cr.execute("SELECT id FROM spp_table_that_does_not_exist") + + class TestCoordinateQueryFallback(TransactionCase): """The failed coordinate attempt must not poison the area fallback.""" @@ -94,3 +103,72 @@ def test_cursor_stays_usable_after_failed_coordinate_query(self): self.env.cr.execute("SELECT id FROM res_partner WHERE id = %s", [self.group.id]) self.assertEqual(self.env.cr.fetchall(), [(self.group.id,)]) + + +class TestProximityQueryFallback(TransactionCase): + """query_proximity has the same fallback, and needs the same savepoint.""" + + @classmethod + def setUpClass(cls): + """Create an area covering the reference point plus a registrant in it.""" + super().setUpClass() + + cls.area = cls.env["spp.area"].create( + { + "draft_name": "Proximity Fallback Test Area", + "code": "FALLBACK-AREA-002", + } + ) + cls.env.cr.execute( + """ + UPDATE spp_area + SET geo_polygon = ST_SetSRID(ST_GeomFromGeoJSON(%s), 4326) + WHERE id = %s + """, + [json.dumps(QUERY_POLYGON), cls.area.id], + ) + + cls.group = cls.env["res.partner"].create( + { + "name": "Proximity Fallback Test Household", + "is_registrant": True, + "is_group": True, + "area_id": cls.area.id, + } + ) + + def test_area_fallback_runs_after_failed_proximity_query(self): + """A SQL error in the proximity query degrades to the area query.""" + from ..services.spatial_query_service import SpatialQueryService + + service = SpatialQueryService(self.env) + + with ( + patch.object(SpatialQueryService, "_proximity_by_coordinates", _failing_proximity_query), + mute_logger("odoo.sql_db"), + self.assertLogs(SERVICE_LOGGER, level="WARNING") as captured, + ): + result = service.query_proximity(reference_points=REFERENCE_POINTS, radius_km=10) + + self.assertEqual(result["query_method"], "area_fallback") + self.assertIn(self.group.id, result["registrant_ids"]) + self.assertTrue( + any("Coordinate-based proximity query failed" in message for message in captured.output), + f"expected a fallback warning, got {captured.output}", + ) + + def test_cursor_stays_usable_after_failed_proximity_query(self): + """The transaction is still usable once the fallback has completed.""" + from ..services.spatial_query_service import SpatialQueryService + + service = SpatialQueryService(self.env) + + with ( + patch.object(SpatialQueryService, "_proximity_by_coordinates", _failing_proximity_query), + mute_logger("odoo.sql_db"), + self.assertLogs(SERVICE_LOGGER, level="WARNING"), + ): + service.query_proximity(reference_points=REFERENCE_POINTS, radius_km=10) + + self.env.cr.execute("SELECT id FROM res_partner WHERE id = %s", [self.group.id]) + self.assertEqual(self.env.cr.fetchall(), [(self.group.id,)]) From 005d8383de098bd0532ef24282d56da60ffec295 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 27 Jul 2026 01:30:25 +0700 Subject: [PATCH 06/13] fix(spp_api_v2_gis): run the area fallback query inside a savepoint query_statistics wrapped its coordinate attempt in a savepoint but left the area fallback's raw SQL unguarded. A genuine database error there (not the ValueError path used when res.partner.coordinates is absent) aborted the whole transaction, and since opening a savepoint itself requires a live transaction, every later geometry in the same query_statistics_batch call failed too. One bad geometry degraded the entire batch response to total_count: 0, query_method: "error" for everything after it, with nothing indicating why. This completes the savepoint coverage the branch already started for the coordinate-query fallbacks in query_statistics and query_proximity. Signed-off-by: Jeremi Joslin --- .../services/spatial_query_service.py | 7 +- spp_api_v2_gis/tests/test_batch_query.py | 86 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/spp_api_v2_gis/services/spatial_query_service.py b/spp_api_v2_gis/services/spatial_query_service.py index f56fa9455..633156b02 100644 --- a/spp_api_v2_gis/services/spatial_query_service.py +++ b/spp_api_v2_gis/services/spatial_query_service.py @@ -155,7 +155,12 @@ def query_statistics(self, geometry, filters=None, variables=None): ) # Fall back to area-based query - result = self._query_by_area(geometry_json, filters) + # A failed statement here would otherwise leave the transaction aborted + # for every subsequent query on this cursor, including later geometries + # in query_statistics_batch. The savepoint contains it. + # flush=False keeps unrelated pending ORM writes out of the rollback. + with self.env.cr.savepoint(flush=False): + result = self._query_by_area(geometry_json, filters) _logger.info( f"Spatial query using area fallback: {result['total_count']} registrants in {result['areas_matched']} areas" ) diff --git a/spp_api_v2_gis/tests/test_batch_query.py b/spp_api_v2_gis/tests/test_batch_query.py index 19cb9fa66..1dce5d202 100644 --- a/spp_api_v2_gis/tests/test_batch_query.py +++ b/spp_api_v2_gis/tests/test_batch_query.py @@ -1,9 +1,17 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Tests for batch spatial query service.""" +import json from datetime import date from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + +# Polygon covering roughly lon 27.9..28.1 / lat -2.1..-1.9 (East Africa). +QUERY_POLYGON = { + "type": "Polygon", + "coordinates": [[[27.9, -2.1], [28.1, -2.1], [28.1, -1.9], [27.9, -1.9], [27.9, -2.1]]], +} class TestBatchSpatialQueryService(TransactionCase): @@ -235,6 +243,84 @@ def test_batch_query_empty_geometries_list(self): self.assertEqual(result["summary"]["geometries_queried"], 0) +class TestBatchQueryDoesNotPoisonLaterGeometries(TransactionCase): + """A geometry that fails inside PostgreSQL must not abort the rest of the batch. + + ``query_statistics`` only wraps its coordinate attempt in a savepoint; the + area fallback it calls into runs raw SQL unguarded. If that fallback hits a + genuine database error (not a Python-level ``ValueError``), the whole + transaction is left aborted, and every later geometry in the same batch + call fails too, since even opening a new savepoint requires a live + transaction. + """ + + @classmethod + def setUpClass(cls): + """Create an area covering the query polygon plus a registrant in it.""" + super().setUpClass() + + cls.area = cls.env["spp.area"].create( + { + "draft_name": "Batch Poison Test Area", + "code": "BATCH-POISON-001", + } + ) + cls.env.cr.execute( + """ + UPDATE spp_area + SET geo_polygon = ST_SetSRID(ST_GeomFromGeoJSON(%s), 4326) + WHERE id = %s + """, + [json.dumps(QUERY_POLYGON), cls.area.id], + ) + + cls.group = cls.env["res.partner"].create( + { + "name": "Batch Poison Test Household", + "is_registrant": True, + "is_group": True, + "area_id": cls.area.id, + } + ) + + def test_invalid_geometry_does_not_abort_later_geometries(self): + """A DB-level failure on one geometry must not poison the geometries after it.""" + from ..services.spatial_query_service import SpatialQueryService + + service = SpatialQueryService(self.env) + + geometries = [ + { + "id": "invalid", + # Genuinely invalid GeoJSON: ST_GeomFromGeoJSON rejects the type at + # the database level, so this fails the same way a real bad request + # would, rather than being simulated with a mock. + "geometry": {"type": "InvalidType", "coordinates": []}, + }, + { + "id": "valid", + "geometry": QUERY_POLYGON, + }, + ] + + with mute_logger("odoo.sql_db"): + result = service.query_statistics_batch(geometries=geometries) + + self.assertEqual(len(result["results"]), 2) + + invalid_result = next(r for r in result["results"] if r["id"] == "invalid") + self.assertEqual(invalid_result["query_method"], "error") + + valid_result = next(r for r in result["results"] if r["id"] == "valid") + self.assertEqual( + valid_result["query_method"], + "area_fallback", + "a DB error on an earlier geometry aborted the transaction for the rest of the batch", + ) + self.assertEqual(valid_result["total_count"], 1) + self.assertEqual(valid_result["areas_matched"], 1) + + class TestBatchSpatialQuerySchemas(TransactionCase): """Test batch query Pydantic schemas.""" From 6378e39a75681b7357abcaf9969ec4915bf29a04 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 27 Jul 2026 02:22:20 +0700 Subject: [PATCH 07/13] fix(spp_api_v2_gis): run the proximity area fallback inside a savepoint query_proximity wraps its coordinate attempt in a savepoint but left the area fallback's raw SQL unguarded. A genuine database error there aborted the whole transaction, leaving every later query on the same cursor unusable, since even opening a new savepoint requires a live transaction. This completes the savepoint coverage started in 2bfefcb4 (coordinate query) and 9ff662f1 (query_statistics's own area fallback), applying the same fix to query_proximity's area fallback. Signed-off-by: Jeremi Joslin --- .../services/spatial_query_service.py | 6 +- .../tests/test_spatial_query_fallback.py | 59 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/spp_api_v2_gis/services/spatial_query_service.py b/spp_api_v2_gis/services/spatial_query_service.py index 633156b02..9c77013d4 100644 --- a/spp_api_v2_gis/services/spatial_query_service.py +++ b/spp_api_v2_gis/services/spatial_query_service.py @@ -520,7 +520,11 @@ def query_proximity(self, reference_points, radius_km, relation="within", filter ) # Fall back to area-based query - result = self._proximity_by_area(reference_points, radius_meters, relation, filters) + # A failed statement here would otherwise leave the transaction aborted + # for every subsequent query on this cursor. The savepoint contains it. + # flush=False keeps unrelated pending ORM writes out of the rollback. + with self.env.cr.savepoint(flush=False): + result = self._proximity_by_area(reference_points, radius_meters, relation, filters) _logger.info( "Proximity query (%s, %.1f km) using area fallback: %s registrants in %s areas", relation, diff --git a/spp_api_v2_gis/tests/test_spatial_query_fallback.py b/spp_api_v2_gis/tests/test_spatial_query_fallback.py index 29f6cc2db..d011b77d5 100644 --- a/spp_api_v2_gis/tests/test_spatial_query_fallback.py +++ b/spp_api_v2_gis/tests/test_spatial_query_fallback.py @@ -172,3 +172,62 @@ def test_cursor_stays_usable_after_failed_proximity_query(self): self.env.cr.execute("SELECT id FROM res_partner WHERE id = %s", [self.group.id]) self.assertEqual(self.env.cr.fetchall(), [(self.group.id,)]) + + +class TestProximityAreaFallbackDoesNotPoisonTransaction(TransactionCase): + """query_proximity's own area fallback must not leave the transaction aborted. + + ``query_proximity`` wraps its coordinate attempt in a savepoint, but the + area fallback it calls into afterwards (``_proximity_by_area``) runs + unguarded. If that fallback hits a genuine database error, the whole + transaction is left aborted, and every later query on the same cursor + fails too, since even opening a new savepoint requires a live + transaction. query_proximity has no batch caller to observe this + through today, so it surfaces as an exception from query_proximity + itself followed by a poisoned cursor for whatever runs next. + """ + + def test_non_finite_radius_does_not_abort_the_transaction(self): + """A DB-level failure in the area fallback must not poison later queries.""" + from ..services.spatial_query_service import SpatialQueryService + + service = SpatialQueryService(self.env) + + # _proximity_by_coordinates raises a plain ValueError before it ever + # touches the database, since res.partner has no "coordinates" field + # in this module's own test environment (spp_registrant_gis is not + # installed). That failure is caught and recovered by the + # coordinate leg's own savepoint, exactly as intended. + # + # query_proximity then falls back to _proximity_by_area, which + # shares the same temp-table helper and is therefore handed the same + # non-finite radius. ST_Buffer rejects a non-finite distance + # argument at the database level ("distance must be a finite + # value"), a genuine, deterministic PostGIS error, not a simulated + # one: unlike an out-of-range or non-finite *coordinate*, which + # PostGIS/GEOS only coerces or fails on inconsistently depending on + # the exact computation involved, a non-finite *buffer distance* is + # rejected by a straightforward argument check every time. Only the + # coordinate leg is savepoint-protected, so this second failure (in + # the fallback) aborts the transaction. + reference_points = [{"longitude": 28.0, "latitude": -2.0}] + + # Deliberately not self.assertRaises: TransactionCase overrides + # assertRaises (see BaseCase._assertRaises in odoo/tests/common.py) + # to wrap the block in its own savepoint and roll it back on the + # expected exception. That would recover the transaction as a side + # effect of the assertion itself, hiding exactly the bug this test + # exists to catch. A plain try/except leaves the transaction exactly + # as query_proximity left it, for the assertion below to see. + raised = None + with mute_logger("odoo.sql_db"): + try: + service.query_proximity(reference_points=reference_points, radius_km=float("nan")) + except Exception as exc: + raised = exc + self.assertIsNotNone(raised, "query_proximity should raise when the area fallback hits a DB error") + + # An ordinary ORM query must still work; without the savepoint around + # the area fallback, the aborted transaction raises + # InFailedSqlTransaction here instead. + self.env["res.partner"].search_count([]) From c9d69238461d2a2d5740e3746728665312bb4d7c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 27 Jul 2026 15:46:36 +0700 Subject: [PATCH 08/13] chore(spp_api_v2_gis): regenerate README with the CI toolchain The committed README.rst and index.html were generated on a developer machine whose docutils/pandoc renders RST tables one column wider than CI's, so pre-commit rewrote both files on every run and failed the job. Take CI's output verbatim: the OGC endpoints table's Description column is 29 characters, matching its widest cell ("QGIS style file (extension)", 27) plus padding. The generator is not reproducible across machines even though .pre-commit-config.yaml pins docutils and markdown-it-py, because pandoc is a system binary the pin cannot reach. Tracked separately. Signed-off-by: Jeremi Joslin --- spp_api_v2_gis/README.rst | 34 ++++++++++---------- spp_api_v2_gis/static/description/index.html | 4 +-- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/spp_api_v2_gis/README.rst b/spp_api_v2_gis/README.rst index 342694091..1ab11bfda 100644 --- a/spp_api_v2_gis/README.rst +++ b/spp_api_v2_gis/README.rst @@ -53,23 +53,23 @@ API Endpoints **OGC API - Features (primary interface)** -+-------------------------------------------+--------+------------------------------+ -| Endpoint | Method | Description | -+===========================================+========+==============================+ -| ``/gis/ogc/`` | GET | OGC API landing page | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/conformance`` | GET | OGC conformance classes | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/collections`` | GET | List feature collections | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/collections/{id}`` | GET | Collection metadata | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/collections/{id}/items`` | GET | Feature items (GeoJSON) | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/collections/{id}/items/{fid}`` | GET | Single feature | -+-------------------------------------------+--------+------------------------------+ -| ``/gis/ogc/collections/{id}/qml`` | GET | QGIS style file (extension) | -+-------------------------------------------+--------+------------------------------+ ++-------------------------------------------+--------+-----------------------------+ +| Endpoint | Method | Description | ++===========================================+========+=============================+ +| ``/gis/ogc/`` | GET | OGC API landing page | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/conformance`` | GET | OGC conformance classes | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/collections`` | GET | List feature collections | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/collections/{id}`` | GET | Collection metadata | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/collections/{id}/items`` | GET | Feature items (GeoJSON) | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/collections/{id}/items/{fid}`` | GET | Single feature | ++-------------------------------------------+--------+-----------------------------+ +| ``/gis/ogc/collections/{id}/qml`` | GET | QGIS style file (extension) | ++-------------------------------------------+--------+-----------------------------+ **Additional endpoints** diff --git a/spp_api_v2_gis/static/description/index.html b/spp_api_v2_gis/static/description/index.html index c73a0b7b1..7a53ae494 100644 --- a/spp_api_v2_gis/static/description/index.html +++ b/spp_api_v2_gis/static/description/index.html @@ -401,9 +401,9 @@

API Endpoints

OGC API - Features (primary interface)

Endpoint
-+-+ From d451edaa127e08f7201c28913a83d8af2dba1f4b Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 24 Aug 2026 15:12:22 +0800 Subject: [PATCH 09/13] fix(spp_api_v2_gis): contain batch and statistics failures with savepoints Run each batch geometry and the batch summary inside their own savepoint, so a statistics failure on one geometry cannot abort the transaction for the rest of the batch, and a summary failure degrades to an empty summary instead of discarding the per-geometry results. Narrow the coordinate-attempt try blocks to the savepoint itself: a statistics failure is not a spatial failure, so it now propagates instead of being logged as a coordinate-query failure and pointlessly retried through the area fallback. --- .../services/spatial_query_service.py | 80 ++++++---- spp_api_v2_gis/tests/test_batch_query.py | 145 +++++++++++++++++- 2 files changed, 190 insertions(+), 35 deletions(-) diff --git a/spp_api_v2_gis/services/spatial_query_service.py b/spp_api_v2_gis/services/spatial_query_service.py index 9c77013d4..29d8d96c3 100644 --- a/spp_api_v2_gis/services/spatial_query_service.py +++ b/spp_api_v2_gis/services/spatial_query_service.py @@ -53,11 +53,17 @@ def query_statistics_batch(self, geometries, filters=None, variables=None): geometry = item["geometry"] try: - result = self.query_statistics( - geometry=geometry, - filters=filters, - variables=variables, - ) + # Each iteration runs inside its own savepoint: the spatial + # legs guard themselves, but a statistics failure would + # otherwise abort the transaction for every later geometry + # and for the summary below. + # flush=False keeps unrelated pending ORM writes out of the rollback. + with self.env.cr.savepoint(flush=False): + result = self.query_statistics( + geometry=geometry, + filters=filters, + variables=variables, + ) # Collect registrant IDs for deduplication in summary registrant_ids = result.pop("registrant_ids", []) all_registrant_ids.update(registrant_ids) @@ -92,7 +98,15 @@ def query_statistics_batch(self, geometries, filters=None, variables=None): # Compute summary by aggregating unique registrants with metadata summary_stats_with_metadata = {"statistics": {}} if all_registrant_ids: - summary_stats_with_metadata = self._compute_statistics(list(all_registrant_ids), variables or []) + try: + # Same guard as the loop: a failure degrades to an empty + # summary instead of discarding the per-geometry results. + # flush=False keeps unrelated pending ORM writes out of the rollback. + with self.env.cr.savepoint(flush=False): + summary_stats_with_metadata = self._compute_statistics(list(all_registrant_ids), variables or []) + except Exception as e: + _logger.warning("Batch summary statistics failed: %s", e) + summary_stats_with_metadata = {"statistics": {}} summary = { "total_count": len(all_registrant_ids), @@ -133,27 +147,31 @@ def query_statistics(self, geometry, filters=None, variables=None): geometry_json = json.dumps(geometry) # Try coordinate-based query first (preferred method) + result = None try: # A failed statement aborts the whole transaction, which would make # the area fallback below fail too. The savepoint contains it. # flush=False keeps unrelated pending ORM writes out of the rollback. with self.env.cr.savepoint(flush=False): result = self._query_by_coordinates(geometry_json, filters) - if result["total_count"] > 0: - _logger.info( - "Spatial query using coordinates: %s registrants found", - result["total_count"], - ) - # Compute statistics for the matched registrants with metadata - stats_with_metadata = self._compute_statistics(result["registrant_ids"], variables) - result.update(stats_with_metadata) - return result except Exception as e: _logger.warning( "Coordinate-based query failed: %s, falling back to area-based query", e, ) + # Only the coordinate query itself is retried via the fallback; a + # statistics failure is not a spatial failure and must propagate. + if result is not None and result["total_count"] > 0: + _logger.info( + "Spatial query using coordinates: %s registrants found", + result["total_count"], + ) + # Compute statistics for the matched registrants with metadata + stats_with_metadata = self._compute_statistics(result["registrant_ids"], variables) + result.update(stats_with_metadata) + return result + # Fall back to area-based query # A failed statement here would otherwise leave the transaction aborted # for every subsequent query on this cursor, including later geometries @@ -493,32 +511,36 @@ def query_proximity(self, reference_points, radius_km, relation="within", filter radius_meters = radius_km * 1000 # Try coordinate-based query first + result = None try: # A failed statement aborts the whole transaction, which would make # the area fallback below fail too. The savepoint contains it. # flush=False keeps unrelated pending ORM writes out of the rollback. with self.env.cr.savepoint(flush=False): result = self._proximity_by_coordinates(reference_points, radius_meters, relation, filters) - if result["total_count"] > 0: - _logger.info( - "Proximity query (%s, %.1f km) using coordinates: %s registrants found", - relation, - radius_km, - result["total_count"], - ) - registrant_ids = result["registrant_ids"] - stats_with_metadata = self._compute_statistics(registrant_ids, variables) - result.update(stats_with_metadata) - result["reference_points_count"] = len(reference_points) - result["radius_km"] = radius_km - result["relation"] = relation - return result except Exception as e: _logger.warning( "Coordinate-based proximity query failed: %s, falling back to area-based", e, ) + # Only the proximity query itself is retried via the fallback; a + # statistics failure is not a spatial failure and must propagate. + if result is not None and result["total_count"] > 0: + _logger.info( + "Proximity query (%s, %.1f km) using coordinates: %s registrants found", + relation, + radius_km, + result["total_count"], + ) + registrant_ids = result["registrant_ids"] + stats_with_metadata = self._compute_statistics(registrant_ids, variables) + result.update(stats_with_metadata) + result["reference_points_count"] = len(reference_points) + result["radius_km"] = radius_km + result["relation"] = relation + return result + # Fall back to area-based query # A failed statement here would otherwise leave the transaction aborted # for every subsequent query on this cursor. The savepoint contains it. diff --git a/spp_api_v2_gis/tests/test_batch_query.py b/spp_api_v2_gis/tests/test_batch_query.py index 1dce5d202..9d2d97d6f 100644 --- a/spp_api_v2_gis/tests/test_batch_query.py +++ b/spp_api_v2_gis/tests/test_batch_query.py @@ -3,10 +3,13 @@ import json from datetime import date +from unittest.mock import patch from odoo.tests.common import TransactionCase from odoo.tools import mute_logger +SERVICE_LOGGER = "odoo.addons.spp_api_v2_gis.services.spatial_query_service" + # Polygon covering roughly lon 27.9..28.1 / lat -2.1..-1.9 (East Africa). QUERY_POLYGON = { "type": "Polygon", @@ -246,12 +249,11 @@ def test_batch_query_empty_geometries_list(self): class TestBatchQueryDoesNotPoisonLaterGeometries(TransactionCase): """A geometry that fails inside PostgreSQL must not abort the rest of the batch. - ``query_statistics`` only wraps its coordinate attempt in a savepoint; the - area fallback it calls into runs raw SQL unguarded. If that fallback hits a - genuine database error (not a Python-level ``ValueError``), the whole - transaction is left aborted, and every later geometry in the same batch - call fails too, since even opening a new savepoint requires a live - transaction. + Both spatial legs of ``query_statistics`` run raw SQL, so a genuine + database error (not a Python-level ``ValueError``) aborts the transaction + unless the failing statement ran inside a savepoint. Without that guard, + every later geometry in the same batch call fails too, since even opening + a new savepoint requires a live transaction. """ @classmethod @@ -321,6 +323,137 @@ def test_invalid_geometry_does_not_abort_later_geometries(self): self.assertEqual(valid_result["areas_matched"], 1) +class TestBatchStatisticsFailureDoesNotPoisonBatch(TransactionCase): + """A statistics failure on one geometry must not abort the rest of the batch. + + The spatial legs guard themselves with savepoints, but the statistics + computation the matched registrants are handed to can also fail inside + PostgreSQL. Each batch iteration (and the batch summary) therefore runs + inside its own savepoint, so a poisoned transaction is rolled back before + the next geometry — or the summary — touches the cursor. + """ + + @classmethod + def setUpClass(cls): + """Create an area covering the query polygon plus a registrant in it.""" + super().setUpClass() + + cls.area = cls.env["spp.area"].create( + { + "draft_name": "Batch Stats Poison Test Area", + "code": "BATCH-STATS-POISON-001", + } + ) + cls.env.cr.execute( + """ + UPDATE spp_area + SET geo_polygon = ST_SetSRID(ST_GeomFromGeoJSON(%s), 4326) + WHERE id = %s + """, + [json.dumps(QUERY_POLYGON), cls.area.id], + ) + + cls.group = cls.env["res.partner"].create( + { + "name": "Batch Stats Poison Test Household", + "is_registrant": True, + "is_group": True, + "area_id": cls.area.id, + } + ) + + def _failing_then_real_statistics(self, failing_call_numbers): + """Build a ``_compute_statistics`` stand-in that dies on the given calls. + + The failure is a genuine database error, so the transaction is aborted + exactly the way a real statistics failure would abort it. + """ + from ..services.spatial_query_service import SpatialQueryService + + original = SpatialQueryService._compute_statistics + calls = [] + + def compute_statistics(service_self, registrant_ids, variables): + calls.append(len(registrant_ids)) + if len(calls) in failing_call_numbers: + service_self.env.cr.execute("SELECT id FROM spp_table_that_does_not_exist") + return original(service_self, registrant_ids, variables) + + return compute_statistics + + def test_statistics_failure_does_not_abort_later_geometries(self): + """A DB error while computing one geometry's statistics spares the rest.""" + from ..services.spatial_query_service import SpatialQueryService + + service = SpatialQueryService(self.env) + + geometries = [ + {"id": "poisoned", "geometry": QUERY_POLYGON}, + {"id": "healthy", "geometry": QUERY_POLYGON}, + ] + + with ( + patch.object( + SpatialQueryService, + "_compute_statistics", + self._failing_then_real_statistics({1}), + ), + mute_logger("odoo.sql_db"), + self.assertLogs(SERVICE_LOGGER, level="WARNING") as captured, + ): + result = service.query_statistics_batch(geometries=geometries) + + poisoned = next(r for r in result["results"] if r["id"] == "poisoned") + self.assertEqual(poisoned["query_method"], "error") + + healthy = next(r for r in result["results"] if r["id"] == "healthy") + self.assertEqual( + healthy["query_method"], + "area_fallback", + "a statistics DB error on an earlier geometry aborted the transaction for the rest of the batch", + ) + self.assertEqual(healthy["total_count"], 1) + self.assertTrue( + any("Batch query failed for geometry 'poisoned'" in message for message in captured.output), + f"expected a batch failure warning, got {captured.output}", + ) + + def test_summary_statistics_failure_degrades_to_empty_summary(self): + """A DB error in the summary statistics keeps the per-geometry results.""" + from ..services.spatial_query_service import SpatialQueryService + + service = SpatialQueryService(self.env) + + geometries = [{"id": "only", "geometry": QUERY_POLYGON}] + + # Call 1 computes the geometry's own statistics; call 2 is the summary. + with ( + patch.object( + SpatialQueryService, + "_compute_statistics", + self._failing_then_real_statistics({2}), + ), + mute_logger("odoo.sql_db"), + self.assertLogs(SERVICE_LOGGER, level="WARNING") as captured, + ): + result = service.query_statistics_batch(geometries=geometries) + + only = next(r for r in result["results"] if r["id"] == "only") + self.assertEqual(only["query_method"], "area_fallback") + self.assertEqual(only["total_count"], 1) + + self.assertEqual(result["summary"]["total_count"], 1) + self.assertEqual(result["summary"]["statistics"], {}) + self.assertTrue( + any("Batch summary statistics failed" in message for message in captured.output), + f"expected a summary failure warning, got {captured.output}", + ) + + # The cursor must still be usable after the degraded summary. + self.env.cr.execute("SELECT id FROM res_partner WHERE id = %s", [self.group.id]) + self.assertEqual(self.env.cr.fetchall(), [(self.group.id,)]) + + class TestBatchSpatialQuerySchemas(TransactionCase): """Test batch query Pydantic schemas.""" From f6880c0d77c8a15b1e25c76d193b51623afa5c5d Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 24 Aug 2026 15:12:23 +0800 Subject: [PATCH 10/13] fix(spp_api_v2_gis): make coordinate tests safe when spp_registrant_gis is installed The full SP-MIS stack (ci-full) installs spp_registrant_gis, which defines the real res.partner.coordinates column and field. Create the test column with IF NOT EXISTS and skip the _fields widening when the field already exists, so setUpClass no longer dies with DuplicateColumn and the un-set-up stand-in field never shadows the real one. Also cover the restructured coordinate paths end to end: query_statistics and query_proximity preferring the coordinate method, and a statistics failure propagating instead of being retried via the area fallback. --- .../tests/test_spatial_query_coordinates.py | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/spp_api_v2_gis/tests/test_spatial_query_coordinates.py b/spp_api_v2_gis/tests/test_spatial_query_coordinates.py index cc03d8571..29d295fc7 100644 --- a/spp_api_v2_gis/tests/test_spatial_query_coordinates.py +++ b/spp_api_v2_gis/tests/test_spatial_query_coordinates.py @@ -15,6 +15,8 @@ from odoo import fields from odoo.tests.common import TransactionCase +SERVICE_LOGGER = "odoo.addons.spp_api_v2_gis.services.spatial_query_service" + # Polygon covering roughly lon 27.9..28.1 / lat -2.1..-1.9 (East Africa). QUERY_POLYGON = { "type": "Polygon", @@ -29,8 +31,14 @@ def declared_coordinates_field(env): ``_query_by_coordinates`` refuses to run when the field is absent, so the field has to be visible on the model while the query runs. ``_fields`` is a read-only mapping, so the whole mapping is swapped for a widened copy. + + When ``spp_registrant_gis`` is installed (e.g. the full SP-MIS stack), the + real field is already there and must not be shadowed by an un-set-up copy. """ partner_cls = type(env["res.partner"]) + if "coordinates" in partner_cls._fields: + yield + return widened = MappingProxyType({**partner_cls._fields, "coordinates": fields.GeoPointField()}) with patch.object(partner_cls, "_fields", widened): yield @@ -45,7 +53,8 @@ def setUpClass(cls): super().setUpClass() # Mirrors the geometry(Point, 4326) column created by GeoPointField. - cls.env.cr.execute("ALTER TABLE res_partner ADD COLUMN coordinates geometry(Point, 4326)") + # IF NOT EXISTS: the column is real when spp_registrant_gis is installed. + cls.env.cr.execute("ALTER TABLE res_partner ADD COLUMN IF NOT EXISTS coordinates geometry(Point, 4326)") cls.group_inside = cls.env["res.partner"].create( { @@ -137,3 +146,61 @@ def test_is_group_filter_combined_with_disabled_filter(self): self.assertIn(self.group_inside.id, result["registrant_ids"]) self.assertNotIn(self.individual_inside.id, result["registrant_ids"]) self.assertNotIn(self.group_outside.id, result["registrant_ids"]) + + def test_query_statistics_uses_coordinates(self): + """End to end, query_statistics prefers the coordinate method.""" + service = self._get_service() + + with declared_coordinates_field(self.env): + result = service.query_statistics(geometry=QUERY_POLYGON) + + self.assertEqual(result["query_method"], "coordinates") + self.assertIn(self.group_inside.id, result["registrant_ids"]) + self.assertIn(self.individual_inside.id, result["registrant_ids"]) + self.assertNotIn(self.group_outside.id, result["registrant_ids"]) + + def test_query_proximity_uses_coordinates(self): + """End to end, query_proximity prefers the coordinate method.""" + service = self._get_service() + + with declared_coordinates_field(self.env): + result = service.query_proximity( + reference_points=[{"longitude": 28.0, "latitude": -2.0}], + radius_km=10, + ) + + self.assertEqual(result["query_method"], "coordinates") + self.assertIn(self.group_inside.id, result["registrant_ids"]) + self.assertIn(self.individual_inside.id, result["registrant_ids"]) + self.assertNotIn(self.group_outside.id, result["registrant_ids"]) + self.assertEqual(result["relation"], "within") + self.assertEqual(result["radius_km"], 10) + + def test_statistics_failure_propagates_instead_of_retrying_via_fallback(self): + """A statistics failure after a successful coordinate query propagates. + + It is not a spatial failure, so it must not be logged as a + coordinate-query failure and silently retried through the area + fallback (which would recompute the same statistics anyway). + """ + from ..services.spatial_query_service import SpatialQueryService + + service = self._get_service() + + def exploding_statistics(service_self, registrant_ids, variables): + raise RuntimeError("statistics exploded") + + # Deliberately not self.assertRaises: TransactionCase overrides it to + # wrap the block in a *flushing* savepoint, and the flush runs pending + # precommit hooks against the temporarily widened _fields mapping. + raised = None + with ( + declared_coordinates_field(self.env), + patch.object(SpatialQueryService, "_compute_statistics", exploding_statistics), + self.assertNoLogs(SERVICE_LOGGER, level="WARNING"), + ): + try: + service.query_statistics(geometry=QUERY_POLYGON) + except RuntimeError as exc: + raised = exc + self.assertIsNotNone(raised, "a statistics failure must propagate to the caller") From 35a9921f00fc11e3361aa52824abb8ccda2b9582 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 24 Aug 2026 15:12:33 +0800 Subject: [PATCH 11/13] fix(spp_api_v2_gis): use uuid4 for unique test client ids id(scopes) on a throwaway list is not a stable unique key: CPython reuses freed addresses, so two clients created in the same test could collide on the client_id unique constraint. Surfaced as a real failure in test_either_scope_accepted_by_check once neighbouring tests shifted allocation patterns. --- spp_api_v2_gis/tests/test_api_client_scope.py | 9 +++++++-- spp_api_v2_gis/tests/test_statistics_endpoint.py | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/spp_api_v2_gis/tests/test_api_client_scope.py b/spp_api_v2_gis/tests/test_api_client_scope.py index 9706909d8..645cc5968 100644 --- a/spp_api_v2_gis/tests/test_api_client_scope.py +++ b/spp_api_v2_gis/tests/test_api_client_scope.py @@ -6,6 +6,8 @@ the only client able to reach those endpoints is one holding ``action = all``. """ +from uuid import uuid4 + from odoo.tests.common import TransactionCase @@ -29,10 +31,13 @@ def setUpClass(cls): def _create_client_with_scopes(self, scopes): """Create an API client holding the given (resource, action) scopes.""" + # uuid4, not id(scopes): CPython reuses freed addresses, so two + # throwaway scope lists can collide on the unique client_id. + unique = uuid4().hex client = self.ApiClient.create( { - "name": f"Geofence Scope Client {id(scopes)}", - "client_id": f"test_geofence_client_{id(scopes)}", + "name": f"Geofence Scope Client {unique}", + "client_id": f"test_geofence_client_{unique}", "partner_id": self.test_partner.id, "organization_type_id": self.org_type.id, } diff --git a/spp_api_v2_gis/tests/test_statistics_endpoint.py b/spp_api_v2_gis/tests/test_statistics_endpoint.py index 3a4e7ca8e..b57c39467 100644 --- a/spp_api_v2_gis/tests/test_statistics_endpoint.py +++ b/spp_api_v2_gis/tests/test_statistics_endpoint.py @@ -1,6 +1,8 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. """Tests for statistics discovery endpoint.""" +from uuid import uuid4 + from odoo.tests.common import TransactionCase @@ -250,7 +252,9 @@ def _create_client_with_scopes(self, scopes): client = self.ApiClient.create( { "name": f"Test Client {len(scopes)}", - "client_id": f"test_client_{id(scopes)}", + # uuid4, not id(scopes): CPython reuses freed addresses, so two + # throwaway scope lists can collide on the unique client_id. + "client_id": f"test_client_{uuid4().hex}", "partner_id": self.test_partner.id, "organization_type_id": self.org_type.id, } From 7145c53c2036b64dff42cbfea02c1b77da7aae70 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 24 Aug 2026 15:12:33 +0800 Subject: [PATCH 12/13] docs(spp_api_v2_gis): true up changelog, scope docs, and test comments HISTORY now names the area-fallback, batch, and summary savepoints and the statistics-failure propagation, not just the coordinate legs, and notes that the incident scope action prepares for the incidents API re-land. DESCRIPTION documents gis:incident as reserved. Test docstrings that described the pre-fix behaviour (area fallback running unguarded, spp_registrant_gis never installed) now state the invariant instead. --- spp_api_v2_gis/readme/DESCRIPTION.md | 1 + spp_api_v2_gis/readme/HISTORY.md | 5 ++- .../tests/test_spatial_query_fallback.py | 31 +++++++++---------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/spp_api_v2_gis/readme/DESCRIPTION.md b/spp_api_v2_gis/readme/DESCRIPTION.md index b9bfd937f..c1b29ac76 100644 --- a/spp_api_v2_gis/readme/DESCRIPTION.md +++ b/spp_api_v2_gis/readme/DESCRIPTION.md @@ -48,6 +48,7 @@ Follows thin client architecture where QGIS displays data and OpenSPP performs a |-------|--------|-------------| | `gis:read` | Read-only | View collections, layers, statistics, export data | | `gis:geofence` | Read + Write | Create and archive geofences (also requires `gis:read` for listing) | +| `gis:incident` | Reserved | No endpoint checks it yet; selectable so incident-scoped clients can be provisioned ahead of the incidents API | **What data is exposed** diff --git a/spp_api_v2_gis/readme/HISTORY.md b/spp_api_v2_gis/readme/HISTORY.md index 407d9cced..bfdcdc497 100644 --- a/spp_api_v2_gis/readme/HISTORY.md +++ b/spp_api_v2_gis/readme/HISTORY.md @@ -3,7 +3,10 @@ - fix: bind coordinate query parameters in the order the SQL expects - fix: run the coordinate statistics query inside a savepoint so the area fallback stays reachable - fix: run the coordinate proximity query inside a savepoint so the area fallback stays reachable -- fix: add `geofence` and `incident` scope actions so geofence endpoints can be granted +- fix: run the area fallback queries inside savepoints so a failed geometry cannot abort the transaction +- fix: run each batch geometry and the batch summary inside savepoints so one failed geometry cannot poison the rest of the batch +- fix: propagate statistics failures instead of mislabelling them as coordinate-query failures and retrying via the area fallback +- fix: add `geofence` and `incident` scope actions so geofence endpoints can be granted (`incident` prepares for the incidents API re-land) ### 19.0.2.0.0 diff --git a/spp_api_v2_gis/tests/test_spatial_query_fallback.py b/spp_api_v2_gis/tests/test_spatial_query_fallback.py index d011b77d5..12e56392c 100644 --- a/spp_api_v2_gis/tests/test_spatial_query_fallback.py +++ b/spp_api_v2_gis/tests/test_spatial_query_fallback.py @@ -177,14 +177,12 @@ def test_cursor_stays_usable_after_failed_proximity_query(self): class TestProximityAreaFallbackDoesNotPoisonTransaction(TransactionCase): """query_proximity's own area fallback must not leave the transaction aborted. - ``query_proximity`` wraps its coordinate attempt in a savepoint, but the - area fallback it calls into afterwards (``_proximity_by_area``) runs - unguarded. If that fallback hits a genuine database error, the whole - transaction is left aborted, and every later query on the same cursor - fails too, since even opening a new savepoint requires a live - transaction. query_proximity has no batch caller to observe this - through today, so it surfaces as an exception from query_proximity - itself followed by a poisoned cursor for whatever runs next. + Both legs of ``query_proximity`` run raw SQL. The area fallback + (``_proximity_by_area``) has to run inside a savepoint just like the + coordinate attempt: a genuine database error there still raises out of + query_proximity, but the savepoint keeps the cursor usable for whatever + runs next, instead of every later query failing with + ``InFailedSqlTransaction``. """ def test_non_finite_radius_does_not_abort_the_transaction(self): @@ -193,11 +191,12 @@ def test_non_finite_radius_does_not_abort_the_transaction(self): service = SpatialQueryService(self.env) - # _proximity_by_coordinates raises a plain ValueError before it ever - # touches the database, since res.partner has no "coordinates" field - # in this module's own test environment (spp_registrant_gis is not - # installed). That failure is caught and recovered by the - # coordinate leg's own savepoint, exactly as intended. + # The coordinate attempt fails whether or not res.partner has a real + # "coordinates" field: without one (this module's own test stack) it + # raises a plain ValueError before touching the database, with one + # (e.g. spp_registrant_gis installed) ST_Buffer rejects the + # non-finite radius. Either way the coordinate leg's savepoint + # recovers it, exactly as intended. # # query_proximity then falls back to _proximity_by_area, which # shares the same temp-table helper and is therefore handed the same @@ -207,9 +206,9 @@ def test_non_finite_radius_does_not_abort_the_transaction(self): # one: unlike an out-of-range or non-finite *coordinate*, which # PostGIS/GEOS only coerces or fails on inconsistently depending on # the exact computation involved, a non-finite *buffer distance* is - # rejected by a straightforward argument check every time. Only the - # coordinate leg is savepoint-protected, so this second failure (in - # the fallback) aborts the transaction. + # rejected by a straightforward argument check every time. Without a + # savepoint around the fallback, this second failure would abort the + # transaction. reference_points = [{"longitude": 28.0, "latitude": -2.0}] # Deliberately not self.assertRaises: TransactionCase overrides From 1c3683fa32ba335a8485bc3b49fe8d6ea1cb2650 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 24 Aug 2026 15:20:24 +0800 Subject: [PATCH 13/13] chore(spp_api_v2_gis): regenerate README with the CI toolchain --- spp_api_v2_gis/README.rst | 14 +++++++++++++- spp_api_v2_gis/static/description/index.html | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/spp_api_v2_gis/README.rst b/spp_api_v2_gis/README.rst index 1ab11bfda..08d131107 100644 --- a/spp_api_v2_gis/README.rst +++ b/spp_api_v2_gis/README.rst @@ -96,6 +96,11 @@ Scopes and Data Privacy | ``gis:geofence`` | Read + Write | Create and archive geofences (also | | | | requires ``gis:read`` for listing) | +------------------+--------------+------------------------------------+ +| ``gis:incident`` | Reserved | No endpoint checks it yet; | +| | | selectable so incident-scoped | +| | | clients can be provisioned ahead | +| | | of the incidents API | ++------------------+--------------+------------------------------------+ **What data is exposed** @@ -164,8 +169,15 @@ Changelog area fallback stays reachable - fix: run the coordinate proximity query inside a savepoint so the area fallback stays reachable +- fix: run the area fallback queries inside savepoints so a failed + geometry cannot abort the transaction +- fix: run each batch geometry and the batch summary inside savepoints + so one failed geometry cannot poison the rest of the batch +- fix: propagate statistics failures instead of mislabelling them as + coordinate-query failures and retrying via the area fallback - fix: add ``geofence`` and ``incident`` scope actions so geofence - endpoints can be granted + endpoints can be granted (``incident`` prepares for the incidents API + re-land) 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_api_v2_gis/static/description/index.html b/spp_api_v2_gis/static/description/index.html index 7a53ae494..5e78ccec4 100644 --- a/spp_api_v2_gis/static/description/index.html +++ b/spp_api_v2_gis/static/description/index.html @@ -501,6 +501,13 @@

Scopes and Data Privacy

+ + + +
EndpointCreate and archive geofences (also requires gis:read for listing)
gis:incidentReservedNo endpoint checks it yet; +selectable so incident-scoped +clients can be provisioned ahead +of the incidents API

What data is exposed

@@ -575,8 +582,15 @@

19.0.2.0.1

area fallback stays reachable
  • fix: run the coordinate proximity query inside a savepoint so the area fallback stays reachable
  • +
  • fix: run the area fallback queries inside savepoints so a failed +geometry cannot abort the transaction
  • +
  • fix: run each batch geometry and the batch summary inside savepoints +so one failed geometry cannot poison the rest of the batch
  • +
  • fix: propagate statistics failures instead of mislabelling them as +coordinate-query failures and retrying via the area fallback
  • fix: add geofence and incident scope actions so geofence -endpoints can be granted
  • +endpoints can be granted (incident prepares for the incidents API +re-land)