diff --git a/spp_gis/fields.py b/spp_gis/fields.py index ceb99cbb..26bb0ef5 100644 --- a/spp_gis/fields.py +++ b/spp_gis/fields.py @@ -142,7 +142,7 @@ def condition_to_sql(self, field_expr, operator, value, model, alias, query): # Check if this is a GIS operator if operator in self._gis_operators: try: - operator_obj = Operator(self) + operator_obj = Operator(self, table_alias=alias) return operator_obj.domain_query(operator, value) except Exception as e: _logger.error(f"Failed to generate GIS SQL for operator {operator}: {e}") diff --git a/spp_gis/operators.py b/spp_gis/operators.py index 07400494..ee996bbf 100644 --- a/spp_gis/operators.py +++ b/spp_gis/operators.py @@ -95,8 +95,21 @@ class Operator: "Polygon": "polygon", } - def __init__(self, field): + def __init__(self, field, table_alias=None): self.field = field + self.table_alias = table_alias + + @property + def qualified_field_name(self): + """Return the field name qualified with table alias for use in SQL. + + When a table_alias is provided (from Odoo's condition_to_sql), + the field name is qualified to avoid ambiguous column references + in queries that involve JOINs (e.g., from model inheritance). + """ + if self.table_alias: + return f'"{self.table_alias}"."{self.field.name}"' + return self.field.name def st_makepoint(self, longitude, latitude): """ @@ -367,16 +380,16 @@ def get_postgis_query( if distance: left = geom - right = self.field.name + right = self.qualified_field_name # Need to transform srid to 3857 for distance calculation if self.field.srid == 4326: left = self.st_transform(geom, 3857) - right = self.st_transform(self.field.name, 3857) + right = self.st_transform(self.qualified_field_name, 3857) return f"{self.POSTGIS_SPATIAL_RELATION[operation]}(ST_Buffer({left}, {distance}), {right})" else: - return f"{self.POSTGIS_SPATIAL_RELATION[operation]}({geom}, {self.field.name})" + return f"{self.POSTGIS_SPATIAL_RELATION[operation]}({geom}, {self.qualified_field_name})" def validate_and_extract_value(self, value): """ diff --git a/spp_gis/tests/test_geo_fields.py b/spp_gis/tests/test_geo_fields.py index ca8c97a3..164f7b75 100644 --- a/spp_gis/tests/test_geo_fields.py +++ b/spp_gis/tests/test_geo_fields.py @@ -154,3 +154,95 @@ class MockRecord: invalid_geojson = json.dumps({"invalid": "data"}) with self.assertRaises(ValidationError): field.convert_to_column(invalid_geojson, MockRecord(), validate=True) + + +class TestOperatorTableAlias(TransactionCase): + """Test that the Operator generates table-qualified column names in SQL.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + test_queue_job_no_delay=True, + ) + ) + + def _make_field(self, name="geo_polygon", srid=4326): + """Create a mock field for the Operator.""" + from odoo.addons.spp_gis.fields import GeoPolygonField + + field = GeoPolygonField() + field.name = name + field.srid = srid + return field + + def test_operator_without_alias_uses_bare_field_name(self): + """Operator without table_alias uses bare field name (backward compat).""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field) + self.assertEqual(operator.qualified_field_name, "geo_polygon") + + def test_operator_with_alias_qualifies_field_name(self): + """Operator with table_alias generates table-qualified column reference.""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field, table_alias="spp_area") + self.assertEqual(operator.qualified_field_name, '"spp_area"."geo_polygon"') + + def test_domain_query_with_alias_uses_qualified_name(self): + """domain_query generates SQL with table-qualified column names.""" + from odoo.addons.spp_gis.operators import Operator + + field = self._make_field() + operator = Operator(field, table_alias="spp_area") + + geojson = { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + } + + result = operator.domain_query("gis_intersects", geojson) + sql_string = str(result) + + self.assertIn("ST_Intersects", sql_string) + self.assertIn('"spp_area"."geo_polygon"', sql_string) + self.assertNotRegex(sql_string, r'(? 0: + data["raw"] = data["raw"] / data["count"] # Fill in None for areas with no data (distinguishes "no data" from "zero count") for area in base_areas: @@ -1478,14 +1524,17 @@ def _to_geojson( # Add geometry if requested if include_geometry and data.area_id.geo_polygon: - # TODO: Use PostGIS ST_AsGeoJSON for performance - # For now, use Odoo's geometry field (WKT format) try: - from shapely import wkt + geo = data.area_id.geo_polygon + # GeoPolygonField may return a Shapely geometry object + # or a WKT/WKB string depending on the spp_gis version. + if hasattr(geo, "__geo_interface__"): + feature["geometry"] = geo.__geo_interface__ + else: + from shapely import wkt - shape = wkt.loads(data.area_id.geo_polygon) - # __geo_interface__ returns a dict that's already JSON-serializable - feature["geometry"] = shape.__geo_interface__ + shape = wkt.loads(geo) + feature["geometry"] = shape.__geo_interface__ except ImportError: _logger.warning( "shapely not available, geometry export limited. Install shapely for full geometry support." diff --git a/spp_registrant_gis/README.rst b/spp_registrant_gis/README.rst new file mode 100644 index 00000000..9a839935 --- /dev/null +++ b/spp_registrant_gis/README.rst @@ -0,0 +1,129 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +====================== +OpenSPP Registrant GIS +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3f603a69c4731312b90dc10708243432cd74193750065d7fe86c2364732c1e9a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_registrant_gis + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Extends registrants with GPS coordinates for spatial queries and +geographic analysis. Adds a PostGIS point field to both individuals and +groups, enabling proximity-based targeting and mapping. + +Key Capabilities +~~~~~~~~~~~~~~~~ + +- Store latitude/longitude coordinates on any registrant (individual or + group) +- Query registrants by geographic location using PostGIS spatial + operators +- Visualize registrant locations on maps via GIS widgets + +Key Models +~~~~~~~~~~ + +This module extends existing models, no new models added: + +=============== ================================== +Model Extension +=============== ================================== +``res.partner`` Adds ``coordinates`` GeoPointField +=============== ================================== + +UI Location +~~~~~~~~~~~ + +- **Individual Form**: Located in Profile tab under "Location" section + (after phone numbers) +- **Group Form**: Located in Profile tab under "Location" section (after + phone numbers) +- Field is read-only when registrant is disabled + +Security +~~~~~~~~ + +No new models or security groups. Uses existing ``res.partner`` +permissions from ``spp_registry``. + +Technical Details +~~~~~~~~~~~~~~~~~ + +- Field type: ``fields.GeoPointField`` (from ``spp_gis``) +- Storage: PostGIS POINT geometry with SRID 4326 (WGS84) +- Supports spatial operators: ``gis_intersects``, ``gis_within``, + ``gis_contains``, ``gis_distance``, etc. +- Widget: ``geo_point`` for coordinate input/display + +Dependencies +~~~~~~~~~~~~ + +``spp_gis``, ``spp_registry`` + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 +.. |maintainer-reichie020212| image:: https://github.com/reichie020212.png?size=40px + :target: https://github.com/reichie020212 + :alt: reichie020212 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| |maintainer-reichie020212| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. diff --git a/spp_registrant_gis/__init__.py b/spp_registrant_gis/__init__.py new file mode 100644 index 00000000..d3361032 --- /dev/null +++ b/spp_registrant_gis/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import models diff --git a/spp_registrant_gis/__manifest__.py b/spp_registrant_gis/__manifest__.py new file mode 100644 index 00000000..661d5a06 --- /dev/null +++ b/spp_registrant_gis/__manifest__.py @@ -0,0 +1,27 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +{ + "name": "OpenSPP Registrant GIS", + "category": "OpenSPP", + "version": "19.0.2.0.0", + "sequence": 1, + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi", "gonzalesedwin1123", "reichie020212"], + "depends": [ + "spp_gis", + "spp_registry", + ], + "data": [ + "security/ir.model.access.csv", + "views/res_partner_views.xml", + ], + "assets": {}, + "demo": [], + "images": [], + "application": False, + "installable": True, + "auto_install": False, + "summary": "Adds GPS coordinates to registrants for spatial queries", +} diff --git a/spp_registrant_gis/models/__init__.py b/spp_registrant_gis/models/__init__.py new file mode 100644 index 00000000..5ef8758d --- /dev/null +++ b/spp_registrant_gis/models/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import res_partner diff --git a/spp_registrant_gis/models/res_partner.py b/spp_registrant_gis/models/res_partner.py new file mode 100644 index 00000000..1ba8b3ad --- /dev/null +++ b/spp_registrant_gis/models/res_partner.py @@ -0,0 +1,14 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class ResPartner(models.Model): + """Extend res.partner to add GIS coordinates for registrants.""" + + _inherit = "res.partner" + + coordinates = fields.GeoPointField( + string="GPS Coordinates", + help="Geographic coordinates (latitude/longitude) for spatial queries and mapping. " + "Used for proximity-based targeting and geographic analysis of registrants.", + ) diff --git a/spp_registrant_gis/pyproject.toml b/spp_registrant_gis/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_registrant_gis/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_registrant_gis/readme/DESCRIPTION.md b/spp_registrant_gis/readme/DESCRIPTION.md new file mode 100644 index 00000000..8c2e58d5 --- /dev/null +++ b/spp_registrant_gis/readme/DESCRIPTION.md @@ -0,0 +1,36 @@ +Extends registrants with GPS coordinates for spatial queries and geographic analysis. Adds a PostGIS point field to both individuals and groups, enabling proximity-based targeting and mapping. + +### Key Capabilities + +- Store latitude/longitude coordinates on any registrant (individual or group) +- Query registrants by geographic location using PostGIS spatial operators +- Visualize registrant locations on maps via GIS widgets + +### Key Models + +This module extends existing models, no new models added: + +| Model | Extension | +| ------------- | --------------------------------- | +| `res.partner` | Adds `coordinates` GeoPointField | + +### UI Location + +- **Individual Form**: Located in Profile tab under "Location" section (after phone numbers) +- **Group Form**: Located in Profile tab under "Location" section (after phone numbers) +- Field is read-only when registrant is disabled + +### Security + +No new models or security groups. Uses existing `res.partner` permissions from `spp_registry`. + +### Technical Details + +- Field type: `fields.GeoPointField` (from `spp_gis`) +- Storage: PostGIS POINT geometry with SRID 4326 (WGS84) +- Supports spatial operators: `gis_intersects`, `gis_within`, `gis_contains`, `gis_distance`, etc. +- Widget: `geo_point` for coordinate input/display + +### Dependencies + +`spp_gis`, `spp_registry` diff --git a/spp_registrant_gis/security/ir.model.access.csv b/spp_registrant_gis/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/spp_registrant_gis/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/spp_registrant_gis/static/description/index.html b/spp_registrant_gis/static/description/index.html new file mode 100644 index 00000000..d85b4cbe --- /dev/null +++ b/spp_registrant_gis/static/description/index.html @@ -0,0 +1,484 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

OpenSPP Registrant GIS

+ +

Alpha License: LGPL-3 OpenSPP/OpenSPP2

+

Extends registrants with GPS coordinates for spatial queries and +geographic analysis. Adds a PostGIS point field to both individuals and +groups, enabling proximity-based targeting and mapping.

+
+

Key Capabilities

+
    +
  • Store latitude/longitude coordinates on any registrant (individual or +group)
  • +
  • Query registrants by geographic location using PostGIS spatial +operators
  • +
  • Visualize registrant locations on maps via GIS widgets
  • +
+
+
+

Key Models

+

This module extends existing models, no new models added:

+ ++++ + + + + + + + + + + +
ModelExtension
res.partnerAdds coordinates GeoPointField
+
+
+

UI Location

+
    +
  • Individual Form: Located in Profile tab under “Location” section +(after phone numbers)
  • +
  • Group Form: Located in Profile tab under “Location” section (after +phone numbers)
  • +
  • Field is read-only when registrant is disabled
  • +
+
+
+

Security

+

No new models or security groups. Uses existing res.partner +permissions from spp_registry.

+
+
+

Technical Details

+
    +
  • Field type: fields.GeoPointField (from spp_gis)
  • +
  • Storage: PostGIS POINT geometry with SRID 4326 (WGS84)
  • +
  • Supports spatial operators: gis_intersects, gis_within, +gis_contains, gis_distance, etc.
  • +
  • Widget: geo_point for coordinate input/display
  • +
+
+
+

Dependencies

+

spp_gis, spp_registry

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

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 +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123 reichie020212

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+
+ + diff --git a/spp_registrant_gis/tests/__init__.py b/spp_registrant_gis/tests/__init__.py new file mode 100644 index 00000000..e7f663cf --- /dev/null +++ b/spp_registrant_gis/tests/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import test_registrant_gis diff --git a/spp_registrant_gis/tests/test_registrant_gis.py b/spp_registrant_gis/tests/test_registrant_gis.py new file mode 100644 index 00000000..c815820e --- /dev/null +++ b/spp_registrant_gis/tests/test_registrant_gis.py @@ -0,0 +1,81 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import json + +from odoo.tests import TransactionCase + + +class TestRegistrantGIS(TransactionCase): + """Test GIS coordinates on registrants.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_model = cls.env["res.partner"] + + def test_coordinates_field_exists(self): + """Test that coordinates field is available on res.partner.""" + # Create a test registrant + registrant = self.partner_model.create( + { + "name": "Test Registrant", + "is_registrant": True, + } + ) + + # Verify coordinates field exists and can be written + self.assertIn("coordinates", registrant._fields) + + # Test setting coordinates as GeoJSON + test_coords = json.dumps({"type": "Point", "coordinates": [121.0, 14.0]}) + registrant.write({"coordinates": test_coords}) + + # Verify coordinates can be read back + self.assertTrue(registrant.coordinates) + + def test_coordinates_on_individual(self): + """Test coordinates on individual registrant.""" + individual = self.partner_model.create( + { + "name": "DOE, John", + "family_name": "Doe", + "given_name": "John", + "is_registrant": True, + "is_group": False, + } + ) + + # Set coordinates + coords = json.dumps({"type": "Point", "coordinates": [120.5, 15.5]}) + individual.write({"coordinates": coords}) + + # Verify + self.assertTrue(individual.coordinates) + + def test_coordinates_on_group(self): + """Test coordinates on group registrant.""" + group = self.partner_model.create( + { + "name": "Test Household", + "is_registrant": True, + "is_group": True, + } + ) + + # Set coordinates + coords = json.dumps({"type": "Point", "coordinates": [122.0, 13.0]}) + group.write({"coordinates": coords}) + + # Verify + self.assertTrue(group.coordinates) + + def test_coordinates_empty_by_default(self): + """Test that coordinates is empty by default.""" + registrant = self.partner_model.create( + { + "name": "New Registrant", + "is_registrant": True, + } + ) + + # Coordinates should be False/empty by default + self.assertFalse(registrant.coordinates) diff --git a/spp_registrant_gis/views/res_partner_views.xml b/spp_registrant_gis/views/res_partner_views.xml new file mode 100644 index 00000000..e1ff4344 --- /dev/null +++ b/spp_registrant_gis/views/res_partner_views.xml @@ -0,0 +1,40 @@ + + + + + spp_registrant_gis.view_individuals_form_gis + res.partner + + + + + + + + + + + + + + + spp_registrant_gis.view_groups_form_gis + res.partner + + + + + + + + + + + +