Skip to content

Commit 628fc1a

Browse files
authored
Merge pull request #42 from OpenSPP/feat/gis-foundations
feat: GIS foundations and registrant GPS coordinates
2 parents ef62a5b + 18a18bb commit 628fc1a

File tree

16 files changed

+998
-23
lines changed

16 files changed

+998
-23
lines changed

spp_gis/fields.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def condition_to_sql(self, field_expr, operator, value, model, alias, query):
142142
# Check if this is a GIS operator
143143
if operator in self._gis_operators:
144144
try:
145-
operator_obj = Operator(self)
145+
operator_obj = Operator(self, table_alias=alias)
146146
return operator_obj.domain_query(operator, value)
147147
except Exception as e:
148148
_logger.error(f"Failed to generate GIS SQL for operator {operator}: {e}")

spp_gis/operators.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,21 @@ class Operator:
9595
"Polygon": "polygon",
9696
}
9797

98-
def __init__(self, field):
98+
def __init__(self, field, table_alias=None):
9999
self.field = field
100+
self.table_alias = table_alias
101+
102+
@property
103+
def qualified_field_name(self):
104+
"""Return the field name qualified with table alias for use in SQL.
105+
106+
When a table_alias is provided (from Odoo's condition_to_sql),
107+
the field name is qualified to avoid ambiguous column references
108+
in queries that involve JOINs (e.g., from model inheritance).
109+
"""
110+
if self.table_alias:
111+
return f'"{self.table_alias}"."{self.field.name}"'
112+
return self.field.name
100113

101114
def st_makepoint(self, longitude, latitude):
102115
"""
@@ -367,16 +380,16 @@ def get_postgis_query(
367380

368381
if distance:
369382
left = geom
370-
right = self.field.name
383+
right = self.qualified_field_name
371384

372385
# Need to transform srid to 3857 for distance calculation
373386
if self.field.srid == 4326:
374387
left = self.st_transform(geom, 3857)
375-
right = self.st_transform(self.field.name, 3857)
388+
right = self.st_transform(self.qualified_field_name, 3857)
376389

377390
return f"{self.POSTGIS_SPATIAL_RELATION[operation]}(ST_Buffer({left}, {distance}), {right})"
378391
else:
379-
return f"{self.POSTGIS_SPATIAL_RELATION[operation]}({geom}, {self.field.name})"
392+
return f"{self.POSTGIS_SPATIAL_RELATION[operation]}({geom}, {self.qualified_field_name})"
380393

381394
def validate_and_extract_value(self, value):
382395
"""

spp_gis/tests/test_geo_fields.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,95 @@ class MockRecord:
154154
invalid_geojson = json.dumps({"invalid": "data"})
155155
with self.assertRaises(ValidationError):
156156
field.convert_to_column(invalid_geojson, MockRecord(), validate=True)
157+
158+
159+
class TestOperatorTableAlias(TransactionCase):
160+
"""Test that the Operator generates table-qualified column names in SQL."""
161+
162+
@classmethod
163+
def setUpClass(cls):
164+
super().setUpClass()
165+
cls.env = cls.env(
166+
context=dict(
167+
cls.env.context,
168+
test_queue_job_no_delay=True,
169+
)
170+
)
171+
172+
def _make_field(self, name="geo_polygon", srid=4326):
173+
"""Create a mock field for the Operator."""
174+
from odoo.addons.spp_gis.fields import GeoPolygonField
175+
176+
field = GeoPolygonField()
177+
field.name = name
178+
field.srid = srid
179+
return field
180+
181+
def test_operator_without_alias_uses_bare_field_name(self):
182+
"""Operator without table_alias uses bare field name (backward compat)."""
183+
from odoo.addons.spp_gis.operators import Operator
184+
185+
field = self._make_field()
186+
operator = Operator(field)
187+
self.assertEqual(operator.qualified_field_name, "geo_polygon")
188+
189+
def test_operator_with_alias_qualifies_field_name(self):
190+
"""Operator with table_alias generates table-qualified column reference."""
191+
from odoo.addons.spp_gis.operators import Operator
192+
193+
field = self._make_field()
194+
operator = Operator(field, table_alias="spp_area")
195+
self.assertEqual(operator.qualified_field_name, '"spp_area"."geo_polygon"')
196+
197+
def test_domain_query_with_alias_uses_qualified_name(self):
198+
"""domain_query generates SQL with table-qualified column names."""
199+
from odoo.addons.spp_gis.operators import Operator
200+
201+
field = self._make_field()
202+
operator = Operator(field, table_alias="spp_area")
203+
204+
geojson = {
205+
"type": "Polygon",
206+
"coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
207+
}
208+
209+
result = operator.domain_query("gis_intersects", geojson)
210+
sql_string = str(result)
211+
212+
self.assertIn("ST_Intersects", sql_string)
213+
self.assertIn('"spp_area"."geo_polygon"', sql_string)
214+
self.assertNotRegex(sql_string, r'(?<!")\bgeo_polygon\b(?!")')
215+
216+
def test_domain_query_without_alias_uses_bare_name(self):
217+
"""domain_query without alias uses bare field name for backward compat."""
218+
from odoo.addons.spp_gis.operators import Operator
219+
220+
field = self._make_field()
221+
operator = Operator(field)
222+
223+
geojson = {
224+
"type": "Polygon",
225+
"coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
226+
}
227+
228+
result = operator.domain_query("gis_intersects", geojson)
229+
sql_string = str(result)
230+
231+
self.assertIn("ST_Intersects", sql_string)
232+
self.assertIn("geo_polygon", sql_string)
233+
234+
def test_get_postgis_query_with_alias_and_distance(self):
235+
"""get_postgis_query with distance also uses qualified field name."""
236+
from odoo.addons.spp_gis.operators import Operator
237+
238+
field = self._make_field()
239+
operator = Operator(field, table_alias="spp_area")
240+
241+
result = operator.get_postgis_query(
242+
operation="intersects",
243+
coordinates=[79.86, 6.93],
244+
distance=1000,
245+
layer_type="point",
246+
)
247+
248+
self.assertIn('"spp_area"."geo_polygon"', result)

spp_gis_report/models/gis_report.py

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -467,8 +467,31 @@ def _compute_base_aggregation(self):
467467
_logger.info("No areas at level %s found", self.base_area_level)
468468
return {}
469469

470-
# Add area filter to domain
471-
domain.append((area_field, "in", base_areas.ids))
470+
# Build mapping from descendant areas to their base-level ancestor.
471+
# Registrants may be assigned to areas more granular than base_area_level
472+
# (e.g., barangays when base level is municipality). We include all
473+
# descendant areas in the query and aggregate results back to the
474+
# base-level parent.
475+
#
476+
# Fetch all descendants of all base areas in a single query, then build
477+
# the child_to_base mapping in memory to avoid an N+1 query pattern.
478+
child_to_base = {base_area.id: base_area.id for base_area in base_areas}
479+
all_descendants = self.env["spp.area"].search(
480+
[
481+
("id", "child_of", base_areas.ids),
482+
("id", "not in", base_areas.ids),
483+
]
484+
)
485+
# For each descendant, walk up its parent chain to find the base-level ancestor
486+
for desc in all_descendants:
487+
ancestor = desc.parent_id
488+
while ancestor and ancestor.id not in child_to_base:
489+
ancestor = ancestor.parent_id
490+
if ancestor:
491+
child_to_base[desc.id] = child_to_base[ancestor.id]
492+
493+
# Add area filter to domain (base areas + all descendants)
494+
domain.append((area_field, "in", list(child_to_base.keys())))
472495

473496
# Initialize results for all base areas with 0
474497
# This ensures areas with no matching records get 0 instead of being missing
@@ -484,12 +507,11 @@ def _compute_base_aggregation(self):
484507
for group in groups:
485508
if group[area_field]:
486509
area_id = group[area_field][0]
510+
base_id = child_to_base.get(area_id, area_id)
487511
count = group[f"{area_field}_count"]
488-
results[area_id] = {
489-
"raw": count,
490-
"count": count,
491-
"weight": count,
492-
}
512+
results[base_id]["raw"] += count
513+
results[base_id]["count"] += count
514+
results[base_id]["weight"] += count
493515

494516
elif self.aggregation_method in ("sum", "avg", "min", "max"):
495517
if not self.aggregation_field:
@@ -506,13 +528,37 @@ def _compute_base_aggregation(self):
506528
for group in groups:
507529
if group[area_field]:
508530
area_id = group[area_field][0]
531+
base_id = child_to_base.get(area_id, area_id)
509532
value = group.get(self.aggregation_field) or 0
510533
count = group[f"{area_field}_count"]
511-
results[area_id] = {
512-
"raw": value,
513-
"count": count,
514-
"weight": count,
515-
}
534+
if agg_func == "avg":
535+
# Accumulate weighted sum so we can compute a proper
536+
# weighted average across subgroups when rolling up.
537+
results[base_id]["raw"] += value * count
538+
elif agg_func == "sum":
539+
# read_group already returns the sum for the subgroup;
540+
# multiplying by count would inflate the total.
541+
results[base_id]["raw"] += value
542+
elif agg_func == "min":
543+
# Keep the lowest value seen across subgroups.
544+
if results[base_id]["count"] == 0:
545+
results[base_id]["raw"] = value
546+
else:
547+
results[base_id]["raw"] = min(results[base_id]["raw"], value)
548+
elif agg_func == "max":
549+
# Keep the highest value seen across subgroups.
550+
if results[base_id]["count"] == 0:
551+
results[base_id]["raw"] = value
552+
else:
553+
results[base_id]["raw"] = max(results[base_id]["raw"], value)
554+
results[base_id]["count"] += count
555+
results[base_id]["weight"] += count
556+
557+
# For avg: convert accumulated weighted sum back to weighted average
558+
if agg_func == "avg":
559+
for data in results.values():
560+
if data["count"] > 0:
561+
data["raw"] = data["raw"] / data["count"]
516562

517563
# Fill in None for areas with no data (distinguishes "no data" from "zero count")
518564
for area in base_areas:
@@ -1478,14 +1524,17 @@ def _to_geojson(
14781524

14791525
# Add geometry if requested
14801526
if include_geometry and data.area_id.geo_polygon:
1481-
# TODO: Use PostGIS ST_AsGeoJSON for performance
1482-
# For now, use Odoo's geometry field (WKT format)
14831527
try:
1484-
from shapely import wkt
1528+
geo = data.area_id.geo_polygon
1529+
# GeoPolygonField may return a Shapely geometry object
1530+
# or a WKT/WKB string depending on the spp_gis version.
1531+
if hasattr(geo, "__geo_interface__"):
1532+
feature["geometry"] = geo.__geo_interface__
1533+
else:
1534+
from shapely import wkt
14851535

1486-
shape = wkt.loads(data.area_id.geo_polygon)
1487-
# __geo_interface__ returns a dict that's already JSON-serializable
1488-
feature["geometry"] = shape.__geo_interface__
1536+
shape = wkt.loads(geo)
1537+
feature["geometry"] = shape.__geo_interface__
14891538
except ImportError:
14901539
_logger.warning(
14911540
"shapely not available, geometry export limited. Install shapely for full geometry support."

spp_registrant_gis/README.rst

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
.. image:: https://odoo-community.org/readme-banner-image
2+
:target: https://odoo-community.org/get-involved?utm_source=readme
3+
:alt: Odoo Community Association
4+
5+
======================
6+
OpenSPP Registrant GIS
7+
======================
8+
9+
..
10+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
11+
!! This file is generated by oca-gen-addon-readme !!
12+
!! changes will be overwritten. !!
13+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
14+
!! source digest: sha256:3f603a69c4731312b90dc10708243432cd74193750065d7fe86c2364732c1e9a
15+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
16+
17+
.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
18+
:target: https://odoo-community.org/page/development-status
19+
:alt: Alpha
20+
.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png
21+
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
22+
:alt: License: LGPL-3
23+
.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github
24+
:target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_registrant_gis
25+
:alt: OpenSPP/OpenSPP2
26+
27+
|badge1| |badge2| |badge3|
28+
29+
Extends registrants with GPS coordinates for spatial queries and
30+
geographic analysis. Adds a PostGIS point field to both individuals and
31+
groups, enabling proximity-based targeting and mapping.
32+
33+
Key Capabilities
34+
~~~~~~~~~~~~~~~~
35+
36+
- Store latitude/longitude coordinates on any registrant (individual or
37+
group)
38+
- Query registrants by geographic location using PostGIS spatial
39+
operators
40+
- Visualize registrant locations on maps via GIS widgets
41+
42+
Key Models
43+
~~~~~~~~~~
44+
45+
This module extends existing models, no new models added:
46+
47+
=============== ==================================
48+
Model Extension
49+
=============== ==================================
50+
``res.partner`` Adds ``coordinates`` GeoPointField
51+
=============== ==================================
52+
53+
UI Location
54+
~~~~~~~~~~~
55+
56+
- **Individual Form**: Located in Profile tab under "Location" section
57+
(after phone numbers)
58+
- **Group Form**: Located in Profile tab under "Location" section (after
59+
phone numbers)
60+
- Field is read-only when registrant is disabled
61+
62+
Security
63+
~~~~~~~~
64+
65+
No new models or security groups. Uses existing ``res.partner``
66+
permissions from ``spp_registry``.
67+
68+
Technical Details
69+
~~~~~~~~~~~~~~~~~
70+
71+
- Field type: ``fields.GeoPointField`` (from ``spp_gis``)
72+
- Storage: PostGIS POINT geometry with SRID 4326 (WGS84)
73+
- Supports spatial operators: ``gis_intersects``, ``gis_within``,
74+
``gis_contains``, ``gis_distance``, etc.
75+
- Widget: ``geo_point`` for coordinate input/display
76+
77+
Dependencies
78+
~~~~~~~~~~~~
79+
80+
``spp_gis``, ``spp_registry``
81+
82+
.. IMPORTANT::
83+
This is an alpha version, the data model and design can change at any time without warning.
84+
Only for development or testing purpose, do not use in production.
85+
`More details on development status <https://odoo-community.org/page/development-status>`_
86+
87+
**Table of contents**
88+
89+
.. contents::
90+
:local:
91+
92+
Bug Tracker
93+
===========
94+
95+
Bugs are tracked on `GitHub Issues <https://github.com/OpenSPP/OpenSPP2/issues>`_.
96+
In case of trouble, please check there if your issue has already been reported.
97+
If you spotted it first, help us to smash it by providing a detailed and welcomed
98+
`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**>`_.
99+
100+
Do not contact contributors directly about support or help with technical issues.
101+
102+
Credits
103+
=======
104+
105+
Authors
106+
-------
107+
108+
* OpenSPP.org
109+
110+
Maintainers
111+
-----------
112+
113+
.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px
114+
:target: https://github.com/jeremi
115+
:alt: jeremi
116+
.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px
117+
:target: https://github.com/gonzalesedwin1123
118+
:alt: gonzalesedwin1123
119+
.. |maintainer-reichie020212| image:: https://github.com/reichie020212.png?size=40px
120+
:target: https://github.com/reichie020212
121+
:alt: reichie020212
122+
123+
Current maintainers:
124+
125+
|maintainer-jeremi| |maintainer-gonzalesedwin1123| |maintainer-reichie020212|
126+
127+
This module is part of the `OpenSPP/OpenSPP2 <https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_registrant_gis>`_ project on GitHub.
128+
129+
You are welcome to contribute.

spp_registrant_gis/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
from . import models

0 commit comments

Comments
 (0)