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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion spp_gis/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
21 changes: 17 additions & 4 deletions spp_gis/operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
"""
Expand Down
92 changes: 92 additions & 0 deletions spp_gis/tests/test_geo_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'(?<!")\bgeo_polygon\b(?!")')

def test_domain_query_without_alias_uses_bare_name(self):
"""domain_query without alias uses bare field name for backward compat."""
from odoo.addons.spp_gis.operators import Operator

field = self._make_field()
operator = Operator(field)

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("geo_polygon", sql_string)

def test_get_postgis_query_with_alias_and_distance(self):
"""get_postgis_query with distance also uses qualified field name."""
from odoo.addons.spp_gis.operators import Operator

field = self._make_field()
operator = Operator(field, table_alias="spp_area")

result = operator.get_postgis_query(
operation="intersects",
coordinates=[79.86, 6.93],
distance=1000,
layer_type="point",
)

self.assertIn('"spp_area"."geo_polygon"', result)
85 changes: 67 additions & 18 deletions spp_gis_report/models/gis_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,8 +467,31 @@ def _compute_base_aggregation(self):
_logger.info("No areas at level %s found", self.base_area_level)
return {}

# Add area filter to domain
domain.append((area_field, "in", base_areas.ids))
# Build mapping from descendant areas to their base-level ancestor.
# Registrants may be assigned to areas more granular than base_area_level
# (e.g., barangays when base level is municipality). We include all
# descendant areas in the query and aggregate results back to the
# base-level parent.
#
# Fetch all descendants of all base areas in a single query, then build
# the child_to_base mapping in memory to avoid an N+1 query pattern.
child_to_base = {base_area.id: base_area.id for base_area in base_areas}
all_descendants = self.env["spp.area"].search(
[
("id", "child_of", base_areas.ids),
("id", "not in", base_areas.ids),
]
)
# For each descendant, walk up its parent chain to find the base-level ancestor
for desc in all_descendants:
ancestor = desc.parent_id
while ancestor and ancestor.id not in child_to_base:
ancestor = ancestor.parent_id
if ancestor:
child_to_base[desc.id] = child_to_base[ancestor.id]

# Add area filter to domain (base areas + all descendants)
domain.append((area_field, "in", list(child_to_base.keys())))

# Initialize results for all base areas with 0
# This ensures areas with no matching records get 0 instead of being missing
Expand All @@ -484,12 +507,11 @@ def _compute_base_aggregation(self):
for group in groups:
if group[area_field]:
area_id = group[area_field][0]
base_id = child_to_base.get(area_id, area_id)
count = group[f"{area_field}_count"]
results[area_id] = {
"raw": count,
"count": count,
"weight": count,
}
results[base_id]["raw"] += count
results[base_id]["count"] += count
results[base_id]["weight"] += count

elif self.aggregation_method in ("sum", "avg", "min", "max"):
if not self.aggregation_field:
Expand All @@ -506,13 +528,37 @@ def _compute_base_aggregation(self):
for group in groups:
if group[area_field]:
area_id = group[area_field][0]
base_id = child_to_base.get(area_id, area_id)
value = group.get(self.aggregation_field) or 0
count = group[f"{area_field}_count"]
results[area_id] = {
"raw": value,
"count": count,
"weight": count,
}
if agg_func == "avg":
# Accumulate weighted sum so we can compute a proper
# weighted average across subgroups when rolling up.
results[base_id]["raw"] += value * count
elif agg_func == "sum":
# read_group already returns the sum for the subgroup;
# multiplying by count would inflate the total.
results[base_id]["raw"] += value
elif agg_func == "min":
# Keep the lowest value seen across subgroups.
if results[base_id]["count"] == 0:
results[base_id]["raw"] = value
else:
results[base_id]["raw"] = min(results[base_id]["raw"], value)
elif agg_func == "max":
# Keep the highest value seen across subgroups.
if results[base_id]["count"] == 0:
results[base_id]["raw"] = value
else:
results[base_id]["raw"] = max(results[base_id]["raw"], value)
results[base_id]["count"] += count
results[base_id]["weight"] += count

# For avg: convert accumulated weighted sum back to weighted average
if agg_func == "avg":
for data in results.values():
if data["count"] > 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:
Expand Down Expand Up @@ -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."
Expand Down
129 changes: 129 additions & 0 deletions spp_registrant_gis/README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://odoo-community.org/page/development-status>`_

**Table of contents**

.. contents::
:local:

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OpenSPP/OpenSPP2/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 <https://github.com/OpenSPP/OpenSPP2/issues/new?body=module:%20spp_registrant_gis%0Aversion:%2019.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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 <https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_registrant_gis>`_ project on GitHub.

You are welcome to contribute.
2 changes: 2 additions & 0 deletions spp_registrant_gis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from . import models
Loading
Loading