diff --git a/test-requirements.txt b/test-requirements.txt index 66bc2cbae..12e71cf03 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,2 @@ odoo_test_helper +odoo-addon-partner_store @ git+https://github.com/OCA/partner-contact.git@refs/pull/2417/head#subdirectory=partner_store diff --git a/website_geoengine/README.rst b/website_geoengine/README.rst new file mode 100644 index 000000000..62e41c499 --- /dev/null +++ b/website_geoengine/README.rst @@ -0,0 +1,89 @@ +================== +Geospatial Website +================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c50ec63ee1157d56ef50a5eec4e8f755a6845da87e67cbeea8b64fe36db8f6c3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fgeospatial-lightgray.png?logo=github + :target: https://github.com/OCA/geospatial/tree/18.0/website_geoengine + :alt: OCA/geospatial +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/geospatial-18-0/geospatial-18-0-website_geoengine + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/geospatial&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the ``website`` odoo module, to allow add endpoints +in order to exchange geospatial data with the frontend. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +No configuration needed. Just install the module and you are ready to +go. + +Usage +===== + + + +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 +------- + +* Camptocamp + +Contributors +------------ + +- Stéphane Brunner +- Hadrien Huvelle + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/geospatial `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_geoengine/__init__.py b/website_geoengine/__init__.py new file mode 100644 index 000000000..91c5580fe --- /dev/null +++ b/website_geoengine/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/website_geoengine/__manifest__.py b/website_geoengine/__manifest__.py new file mode 100644 index 000000000..6e43fdf50 --- /dev/null +++ b/website_geoengine/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2011-2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +{ + "name": "Geospatial Website", + "version": "18.0.1.0.0", + "category": "GeoBI", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/geospatial", + "depends": ["base_geoengine", "website", "partner_store"], + "data": [], + "installable": True, + "application": True, +} diff --git a/website_geoengine/controllers/__init__.py b/website_geoengine/controllers/__init__.py new file mode 100644 index 000000000..91fed54d4 --- /dev/null +++ b/website_geoengine/controllers/__init__.py @@ -0,0 +1 @@ +from . import res_partner diff --git a/website_geoengine/controllers/res_partner.py b/website_geoengine/controllers/res_partner.py new file mode 100644 index 000000000..dfc9da59c --- /dev/null +++ b/website_geoengine/controllers/res_partner.py @@ -0,0 +1,19 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import http + + +class ResPartner(http.Controller): + @http.route("/website-geoengine/tags", type="json", auth="public", cors="*") + def tags(self, **kw): + tags = kw.get("tags", {}) + lang = kw.get("lang", "en_US") + return http.request.env["res.partner"].get_search_tags(tags, lang) + + @http.route("/website-geoengine/partners", type="json", auth="public", cors="*") + def partners(self, **kw): + tags = kw.get("tags", {}) + lang = kw.get("lang", "en_US") + max_results = kw.get("max_results", "200") + return http.request.env["res.partner"].fetch_partner_geoengine( + tags, lang, max_results + ) diff --git a/website_geoengine/i18n/it.po b/website_geoengine/i18n/it.po new file mode 100644 index 000000000..c589ffa42 --- /dev/null +++ b/website_geoengine/i18n/it.po @@ -0,0 +1,35 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_geoengine +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-11-03 17:06+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.6.2\n" + +#. module: website_geoengine +#: model:ir.model,name:website_geoengine.model_res_partner +msgid "Contact" +msgstr "Contatto" + +#. module: website_geoengine +#: model:ir.model.fields,field_description:website_geoengine.field_res_partner__opening_hours +#: model:ir.model.fields,field_description:website_geoengine.field_res_users__opening_hours +msgid "Opening hours" +msgstr "Ore apertura" + +#. module: website_geoengine +#. odoo-python +#: code:addons/website_geoengine/models/res_partner.py:0 +#, python-format +msgid "Unauthorized field" +msgstr "Campo non autorizzato" diff --git a/website_geoengine/i18n/website_geoengine.pot b/website_geoengine/i18n/website_geoengine.pot new file mode 100644 index 000000000..a016651fa --- /dev/null +++ b/website_geoengine/i18n/website_geoengine.pot @@ -0,0 +1,32 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_geoengine +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_geoengine +#: model:ir.model,name:website_geoengine.model_res_partner +msgid "Contact" +msgstr "" + +#. module: website_geoengine +#: model:ir.model.fields,field_description:website_geoengine.field_res_partner__opening_hours +#: model:ir.model.fields,field_description:website_geoengine.field_res_users__opening_hours +msgid "Opening hours" +msgstr "" + +#. module: website_geoengine +#. odoo-python +#: code:addons/website_geoengine/models/res_partner.py:0 +#, python-format +msgid "Unauthorized field" +msgstr "" diff --git a/website_geoengine/models/__init__.py b/website_geoengine/models/__init__.py new file mode 100644 index 000000000..91fed54d4 --- /dev/null +++ b/website_geoengine/models/__init__.py @@ -0,0 +1 @@ +from . import res_partner diff --git a/website_geoengine/models/res_partner.py b/website_geoengine/models/res_partner.py new file mode 100644 index 000000000..93f5cb02b --- /dev/null +++ b/website_geoengine/models/res_partner.py @@ -0,0 +1,119 @@ +# Copyright 2011-2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, models +from odoo.exceptions import ValidationError + + +class ResPartner(models.Model): + _inherit = "res.partner" + + AUTHORIZED_FIELDS = ["name", "city", "zip", "street", "street2", "tag"] + + @api.model + def get_search_tags(self, search, lang): + sql = """ + WITH + names as ( + SELECT + DISTINCT 'name' as column, + name as value + FROM + res_partner + WHERE + type='store'), + cities as ( + SELECT + DISTINCT 'city' as column, + city as value + FROM + res_partner + WHERE + type='store'), + zips as ( + SELECT + DISTINCT 'zip' as column, + zip as value + FROM + res_partner + WHERE + type='store'), + streets as ( + SELECT + DISTINCT 'street' as column, + concat(street, street2) as value + FROM + res_partner + WHERE + type='store'), + tags as ( + SELECT + DISTINCT 'tag' as column, + res_partner_category.name->>%s as value + FROM + res_partner_category, + res_partner_res_partner_category_rel, + res_partner + WHERE + res_partner_res_partner_category_rel.partner_id = res_partner.id + AND + res_partner_res_partner_category_rel.category_id = + res_partner_category.id + AND res_partner.type='store' + ), + all_tags as ( + SELECT * FROM names + UNION SELECT * FROM cities + UNION SELECT * FROM zips + UNION SELECT * FROM streets + UNION SELECT * FROM tags ) + + + SELECT * FROM all_tags WHERE value ILIKE %s; + """ + self._cr.execute(sql, (lang, f"%{search}%")) + results = self._cr.fetchall() + return results + + @api.model + def fetch_partner_geoengine(self, tags, lang, max_results): + domain = [("type", "=", "store")] + for tag in tags: + field, value = tag.values() + if field not in self.AUTHORIZED_FIELDS: + raise ValidationError(self.env._("Unauthorized field")) + domain.append((field.replace("tag", "category_id.name"), "ilike", value)) + + partners = self.sudo().search(domain) + features = [] + + if len(partners) > int(max_results): + return { + "error": "Too many results", + "message": f"Too many results: {len(partners)}", + } + + for partner in partners: + features.append( + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + partner.partner_longitude, + partner.partner_latitude, + ], + }, + "properties": { + "id": partner.id or None, + "name": partner.name or "", + "zip": partner.zip or "", + "city": partner.city or "", + "street": partner.street or "", + "street2": partner.street2 or "", + "tags": partner.category_id.mapped("name") or "", + "opening_hours": partner.opening_hours or "", + }, + } + ) + return features diff --git a/website_geoengine/pyproject.toml b/website_geoengine/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/website_geoengine/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_geoengine/readme/CONFIGURE.md b/website_geoengine/readme/CONFIGURE.md new file mode 100644 index 000000000..66c20ecb3 --- /dev/null +++ b/website_geoengine/readme/CONFIGURE.md @@ -0,0 +1,2 @@ +No configuration needed. Just install the module and you are ready to +go. diff --git a/website_geoengine/readme/CONTRIBUTORS.md b/website_geoengine/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..626972ad6 --- /dev/null +++ b/website_geoengine/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Stéphane Brunner \ +- Hadrien Huvelle \ diff --git a/website_geoengine/readme/DESCRIPTION.md b/website_geoengine/readme/DESCRIPTION.md new file mode 100644 index 000000000..f8c6923c4 --- /dev/null +++ b/website_geoengine/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module extends the `website` odoo module, to allow add endpoints in +order to exchange geospatial data with the frontend. diff --git a/website_geoengine/readme/USAGE.md b/website_geoengine/readme/USAGE.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/website_geoengine/readme/USAGE.md @@ -0,0 +1 @@ + diff --git a/website_geoengine/static/description/icon.png b/website_geoengine/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/website_geoengine/static/description/icon.png differ diff --git a/website_geoengine/static/description/index.html b/website_geoengine/static/description/index.html new file mode 100644 index 000000000..1a64ac057 --- /dev/null +++ b/website_geoengine/static/description/index.html @@ -0,0 +1,435 @@ + + + + + +Geospatial Website + + + +
+

Geospatial Website

+ + +

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

+

This module extends the website odoo module, to allow add endpoints +in order to exchange geospatial data with the frontend.

+

Table of contents

+ +
+

Configuration

+

No configuration needed. Just install the module and you are ready to +go.

+
+
+

Usage

+
+
+

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

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/geospatial project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/website_geoengine/tests/__init__.py b/website_geoengine/tests/__init__.py new file mode 100644 index 000000000..c04c1163f --- /dev/null +++ b/website_geoengine/tests/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_res_partner +from . import test_res_partner_controller diff --git a/website_geoengine/tests/test_res_partner.py b/website_geoengine/tests/test_res_partner.py new file mode 100644 index 000000000..deb77860f --- /dev/null +++ b/website_geoengine/tests/test_res_partner.py @@ -0,0 +1,130 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestResPartnerGeoengine(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.category_bakery = cls.env["res.partner.category"].create({"name": "Bakery"}) + cls.category_grocery = cls.env["res.partner.category"].create( + {"name": "Grocery"} + ) + + cls.partner_store_1 = cls.env["res.partner"].create( + { + "name": "Supermarket Central", + "type": "store", + "city": "Brussels", + "zip": "1000", + "street": "Grand Place ", + "street2": "1", + "opening_hours": "08:00 - 20:00", + "partner_longitude": 4.3517, + "partner_latitude": 50.8503, + "category_id": [(6, 0, [cls.category_grocery.id])], + } + ) + + cls.partner_store_2 = cls.env["res.partner"].create( + { + "name": "Local Bakery", + "type": "store", + "city": "Antwerp", + "zip": "2000", + "street": "Main Street ", + "street2": "10", + "opening_hours": "06:00 - 18:00", + "partner_longitude": 4.4025, + "partner_latitude": 51.2194, + "category_id": [(6, 0, [cls.category_bakery.id])], + } + ) + + cls.partner_contact = cls.env["res.partner"].create( + { + "name": "John Contact", + "type": "contact", + "city": "Brussels", + "zip": "1000", + } + ) + + def test_get_search_tags_name(self): + tags = self.env["res.partner"].get_search_tags("Supermarket", "en_US") + self.assertIn(("name", "Supermarket Central"), tags) + + def test_get_search_tags_city(self): + tags = self.env["res.partner"].get_search_tags("Brussels", "en_US") + self.assertIn(("city", "Brussels"), tags) + + def test_get_search_tags_zip(self): + tags = self.env["res.partner"].get_search_tags("2000", "en_US") + self.assertIn(("zip", "2000"), tags) + + def test_get_search_tags_street(self): + tags = self.env["res.partner"].get_search_tags("Grand Place", "en_US") + self.assertIn(("street", "Grand Place 1"), tags) + + def test_get_search_tags_category_tag(self): + tags = self.env["res.partner"].get_search_tags("Bakery", "en_US") + self.assertIn(("tag", "Bakery"), tags) + + def test_get_search_tags_excludes_non_store(self): + tags = self.env["res.partner"].get_search_tags("John Contact", "en_US") + self.assertEqual(tags, []) + + def test_fetch_partner_geoengine_basic(self): + tags = [{"column": "name", "value": "Supermarket Central"}] + results = self.env["res.partner"].fetch_partner_geoengine(tags, "en_US", 200) + self.assertEqual(len(results), 1) + feature = results[0] + self.assertEqual(feature["type"], "Feature") + self.assertEqual(feature["geometry"]["type"], "Point") + self.assertEqual(feature["geometry"]["coordinates"], [4.3517, 50.8503]) + props = feature["properties"] + self.assertEqual(props["id"], self.partner_store_1.id) + self.assertEqual(props["name"], "Supermarket Central") + self.assertEqual(props["city"], "Brussels") + self.assertEqual(props["zip"], "1000") + self.assertEqual(props["street"], "Grand Place ") + self.assertEqual(props["street2"], "1") + self.assertEqual(props["tags"], ["Grocery"]) + self.assertEqual(props["opening_hours"], "08:00 - 20:00") + + def test_fetch_partner_geoengine_filter_by_tag(self): + tags = [{"column": "tag", "value": "Bakery"}] + results = self.env["res.partner"].fetch_partner_geoengine(tags, "en_US", 200) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["properties"]["id"], self.partner_store_2.id) + + def test_fetch_partner_geoengine_multiple_tags(self): + tags = [ + {"column": "city", "value": "Brussels"}, + {"column": "name", "value": "Supermarket"}, + ] + results = self.env["res.partner"].fetch_partner_geoengine(tags, "en_US", 200) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["properties"]["id"], self.partner_store_1.id) + + def test_fetch_partner_geoengine_unauthorized_field(self): + tags = [{"column": "opening_hours", "value": "08:00"}] + with self.assertRaises(ValidationError): + self.env["res.partner"].fetch_partner_geoengine(tags, "en_US", 200) + + def test_fetch_partner_geoengine_max_results_exceeded(self): + tags = [] + res = self.env["res.partner"].fetch_partner_geoengine(tags, "en_US", 1) + self.assertIsInstance(res, dict) + self.assertEqual(res.get("error"), "Too many results") + self.assertIn("Too many results", res.get("message", "")) + + def test_fetch_partner_geoengine_no_results(self): + tags = [{"column": "name", "value": "Nonexistent Store"}] + results = self.env["res.partner"].fetch_partner_geoengine(tags, "en_US", 200) + self.assertEqual(results, []) diff --git a/website_geoengine/tests/test_res_partner_controller.py b/website_geoengine/tests/test_res_partner_controller.py new file mode 100644 index 000000000..86bbe5d22 --- /dev/null +++ b/website_geoengine/tests/test_res_partner_controller.py @@ -0,0 +1,75 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json + +from odoo.tests.common import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestResPartnerController(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.category_test = cls.env["res.partner.category"].create( + {"name": "Controller Test Tag"} + ) + cls.store_partner = cls.env["res.partner"].create( + { + "name": "Geo Controller Store", + "type": "store", + "city": "Liège", + "zip": "4000", + "street": "Rue de la Cathédrale 1", + "opening_hours": "09:00 - 17:00", + "partner_longitude": 5.5797, + "partner_latitude": 50.6412, + "category_id": [(6, 0, [cls.category_test.id])], + } + ) + + def test_controller_tags_route(self): + payload = { + "jsonrpc": "2.0", + "method": "call", + "params": {"tags": "Geo Controller", "lang": "en_US"}, + "id": 1, + } + response = self.url_open( + "/website-geoengine/tags", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + res_json = response.json() + self.assertNotIn("error", res_json) + self.assertIn("result", res_json) + tags = res_json["result"] + self.assertTrue( + any(t[0] == "name" and t[1] == "Geo Controller Store" for t in tags) + ) + + def test_controller_partners_route(self): + payload = { + "jsonrpc": "2.0", + "method": "call", + "params": { + "tags": [{"column": "name", "value": "Geo Controller Store"}], + "lang": "en_US", + "maxResults": "200", + }, + "id": 1, + } + response = self.url_open( + "/website-geoengine/partners", + data=json.dumps(payload), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + res_json = response.json() + self.assertNotIn("error", res_json) + self.assertIn("result", res_json) + features = res_json["result"] + self.assertEqual(len(features), 1) + self.assertEqual(features[0]["properties"]["name"], "Geo Controller Store")