diff --git a/base_geoengine/expressions.py b/base_geoengine/expressions.py index 1e6113474..68c52958e 100644 --- a/base_geoengine/expressions.py +++ b/base_geoengine/expressions.py @@ -4,6 +4,7 @@ import random import string +from odoo import models from odoo.osv import expression from odoo.osv.expression import TERM_OPERATORS from odoo.tools import SQL, Query @@ -27,6 +28,31 @@ term_operators_list.append(op) expression.TERM_OPERATORS = tuple(term_operators_list) +_original_filtered_domain = models.BaseModel.filtered_domain + + +def _filtered_domain_geo(self, domain): + """Patch filtered_domain to support geo operators. + + The original method evaluates domains in Python and raises ValueError + for unknown operators. Geo operators can only be resolved via SQL + (PostGIS), so we fall back to a search() call — the same strategy Odoo + uses for child_of / parent_of. + """ + if not domain or not self: + return _original_filtered_domain(self, domain) + + has_geo = any( + isinstance(leaf, (list | tuple)) and len(leaf) == 3 and leaf[1] in GEO_OPERATORS + for leaf in domain + ) + if not has_geo: + return _original_filtered_domain(self, domain) + + return self.search([("id", "in", self.ids)] + domain, order="id") + + +models.BaseModel.filtered_domain = _filtered_domain_geo def __leaf_to_sql(self, leaf, model, alias): diff --git a/base_geoengine/tests/test_model.py b/base_geoengine/tests/test_model.py index 114212d0a..7bb122678 100644 --- a/base_geoengine/tests/test_model.py +++ b/base_geoengine/tests/test_model.py @@ -703,3 +703,86 @@ def test_deprecated_geo_search__intersect_for_zip_1169_with_dict(self): ] ) self.assertEqual(len(result), 2) + + def test_filtered_domain_with_geo_within(self): + """filtered_domain must not raise ValueError for geo_within.""" + retails = self.env["retail.machine"].search([]) + zip_yens = self.env["dummy.zip"].search([("city", "ilike", "Yens")]) + domain = [ + ( + "the_point", + "geo_within", + {"dummy.zip.the_geom": [("id", "=", zip_yens.id)]}, + ) + ] + result = retails.filtered_domain(domain) + self.assertEqual(len(result), 2) + self.assertEqual(set(result.mapped("name")), {"34", "33"}) + + def test_filtered_domain_with_geo_intersect(self): + """filtered_domain must not raise ValueError for geo_intersect.""" + retails = self.env["retail.machine"].search([]) + zip_mollens = self.env["dummy.zip"].search([("city", "ilike", "Mollens (VD))")]) + domain = [("the_point", "geo_intersect", zip_mollens.the_geom)] + result = retails.filtered_domain(domain) + self.assertEqual(len(result), 3) + + def test_filtered_domain_geo_with_negation(self): + """filtered_domain handles negated geo operators via SQL fallback.""" + retails = self.env["retail.machine"].search([]) + zip_yens = self.env["dummy.zip"].search([("city", "ilike", "Yens")]) + domain = [ + "!", + ( + "the_point", + "geo_within", + {"dummy.zip.the_geom": [("id", "=", zip_yens.id)]}, + ), + ] + result = retails.filtered_domain(domain) + self.assertEqual(len(result), 3) + self.assertFalse(set(result.mapped("name")) & {"34", "33"}) + + def test_filtered_domain_non_geo_unchanged(self): + """Non-geo domains still use the original Python evaluation.""" + retails = self.env["retail.machine"].search([]) + domain = [("name", "=", "34")] + result = retails.filtered_domain(domain) + self.assertEqual(len(result), 1) + self.assertEqual(result.name, "34") + + def test_filtered_domain_empty_domain(self): + """Empty domain returns self unchanged.""" + retails = self.env["retail.machine"].search([]) + result = retails.filtered_domain([]) + self.assertEqual(result, retails) + + def test_filtered_domain_empty_recordset(self): + """Empty recordset returns empty regardless of geo domain.""" + retails = self.env["retail.machine"].browse() + zip_yens = self.env["dummy.zip"].search([("city", "ilike", "Yens")]) + domain = [ + ( + "the_point", + "geo_within", + {"dummy.zip.the_geom": [("id", "=", zip_yens.id)]}, + ) + ] + result = retails.filtered_domain(domain) + self.assertFalse(result) + + def test_filtered_domain_mixed_geo_and_standard(self): + """Domain combining geo and standard operators works correctly.""" + retails = self.env["retail.machine"].search([]) + zip_yens = self.env["dummy.zip"].search([("city", "ilike", "Yens")]) + domain = [ + ("money_level", "=", "low"), + ( + "the_point", + "geo_within", + {"dummy.zip.the_geom": [("id", "=", zip_yens.id)]}, + ), + ] + result = retails.filtered_domain(domain) + self.assertEqual(len(result), 2) + self.assertTrue(all(r.money_level == "low" for r in result))