From e132a150828987a3f2f4cf49f8a940c01e131dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Tue, 25 Jul 2023 15:08:03 +0200 Subject: [PATCH] shopinvader_product_url: add module, shopinvader_base_url: improve module --- .../odoo/addons/shopinvader_product_url | 1 + setup/shopinvader_product_url/setup.py | 6 ++ shopinvader_base_url/__manifest__.py | 6 +- shopinvader_base_url/models/abstract_url.py | 55 +++++++++++++++++- shopinvader_base_url/models/url_url.py | 11 +++- .../security/ir.model.access.csv | 3 +- shopinvader_base_url/security/res_groups.xml | 12 ++++ shopinvader_base_url/views/url_view.xml | 13 +++-- shopinvader_product_url/README.rst | 0 shopinvader_product_url/__init__.py | 1 + shopinvader_product_url/__manifest__.py | 25 ++++++++ shopinvader_product_url/models/__init__.py | 2 + .../models/product_category.py | 33 +++++++++++ .../models/product_template.py | 13 +++++ .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 1 + shopinvader_product_url/tests/__init__.py | 1 + .../tests/test_category_url.py | 57 +++++++++++++++++++ 18 files changed, 231 insertions(+), 10 deletions(-) create mode 120000 setup/shopinvader_product_url/odoo/addons/shopinvader_product_url create mode 100644 setup/shopinvader_product_url/setup.py create mode 100644 shopinvader_base_url/security/res_groups.xml create mode 100644 shopinvader_product_url/README.rst create mode 100644 shopinvader_product_url/__init__.py create mode 100644 shopinvader_product_url/__manifest__.py create mode 100644 shopinvader_product_url/models/__init__.py create mode 100644 shopinvader_product_url/models/product_category.py create mode 100644 shopinvader_product_url/models/product_template.py create mode 100644 shopinvader_product_url/readme/CONTRIBUTORS.rst create mode 100644 shopinvader_product_url/readme/DESCRIPTION.rst create mode 100644 shopinvader_product_url/tests/__init__.py create mode 100644 shopinvader_product_url/tests/test_category_url.py diff --git a/setup/shopinvader_product_url/odoo/addons/shopinvader_product_url b/setup/shopinvader_product_url/odoo/addons/shopinvader_product_url new file mode 120000 index 0000000000..4317f29d49 --- /dev/null +++ b/setup/shopinvader_product_url/odoo/addons/shopinvader_product_url @@ -0,0 +1 @@ +../../../../shopinvader_product_url \ No newline at end of file diff --git a/setup/shopinvader_product_url/setup.py b/setup/shopinvader_product_url/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_product_url/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopinvader_base_url/__manifest__.py b/shopinvader_base_url/__manifest__.py index b39459b91e..88bc7ee0dd 100644 --- a/shopinvader_base_url/__manifest__.py +++ b/shopinvader_base_url/__manifest__.py @@ -14,7 +14,11 @@ # any module necessary for this one to work correctly "depends": ["base"], "external_dependencies": {"python": ["python-slugify"]}, - "data": ["views/url_view.xml", "security/ir.model.access.csv"], + "data": [ + "views/url_view.xml", + "security/res_groups.xml", + "security/ir.model.access.csv", + ], "url": "", "installable": True, } diff --git a/shopinvader_base_url/models/abstract_url.py b/shopinvader_base_url/models/abstract_url.py index 37adce33c4..ac92bbe57f 100644 --- a/shopinvader_base_url/models/abstract_url.py +++ b/shopinvader_base_url/models/abstract_url.py @@ -4,6 +4,8 @@ import logging +from lxml import etree + from odoo import _, api, fields, models from odoo.exceptions import UserError @@ -17,6 +19,18 @@ _logger.debug("Cannot `import slugify`.") +SMART_BUTTON = """ +""" + + class AbstractUrl(models.AbstractModel): _name = "abstract.url" _description = "Abstract Url" @@ -26,6 +40,20 @@ class AbstractUrl(models.AbstractModel): url_need_refresh = fields.Boolean( compute="_compute_url_need_refresh", store=True, readonly=False ) + count_url = fields.Integer(compute="_compute_count_url") + + def _compute_count_url(self): + res = self.env["url.url"].read_group( + domain=[ + ("res_id", "in", self.ids), + ("res_model", "=", self._name), + ], + fields=["res_id"], + groupby=["res_id"], + ) + id2count = {item["res_id"]: item["res_id_count"] for item in res} + for record in self: + record.count_url = id2count.get(record.id, 0) def _compute_url_need_refresh_depends(self): return self._get_keyword_fields() @@ -50,7 +78,7 @@ def _get_keyword_fields(self): # moreover for unicity you can put the default code of the product or ean13 return ["name"] - def _generate_url_key(self): + def _generate_url_key(self, referential, lang): def get(self, key_path): value = self for key_field in key_path.split("."): @@ -89,6 +117,7 @@ def _prepare_url(self, referential, lang, url_key): "res_id": self.id, "referential": referential, "lang_id": self.env["res.lang"]._lang_get_id(lang), + "manual": False, } def _reuse_url(self, existing_url): @@ -115,18 +144,18 @@ def _update_url_key(self, referential="global", lang=DEFAULT_LANG): # Before updating one specific url (referential + lang) # the flag is propagated on all valid url if record.url_need_refresh: - record.url_need_refresh = False record.url_ids.filtered( lambda s: not s.redirect and not s.manual ).write({"need_refresh": True}) if not current_url or current_url.need_refresh: current_url.need_refresh = False - url_key = record._generate_url_key() + url_key = record._generate_url_key(referential, lang) # maybe some change have been done but the url is the same # so check it if current_url.key != url_key: current_url.redirect = True record._add_url(referential, lang, url_key) + record.url_need_refresh = False def _add_url(self, referential, lang, url_key): self.ensure_one() @@ -179,3 +208,23 @@ def write(self, vals): if "active" in vals and not vals["active"]: self._redirect_existing_url("archived") return res + + @api.model + def _get_view(self, view_id=None, view_type="form", **options): + arch, view = super()._get_view(view_id=view_id, view_type=view_type, **options) + button_box = arch.xpath("//div[@name='button_box']") + if button_box: + button_box[0].append(etree.fromstring(SMART_BUTTON)) + return arch, view + + def open_url(self): + self.ensure_one() + action = self.env.ref("shopinvader_base_url.base_url_action_view").read()[0] + action["domain"] = [("res_model", "=", self._name), ("res_id", "in", self.ids)] + action["context"] = { + "hide_res_model": True, + "hide_res_id": True, + "default_res_model": self._name, + "default_res_id": self.id, + } + return action diff --git a/shopinvader_base_url/models/url_url.py b/shopinvader_base_url/models/url_url.py index cc9f46daf1..e7f2c72b38 100644 --- a/shopinvader_base_url/models/url_url.py +++ b/shopinvader_base_url/models/url_url.py @@ -14,7 +14,7 @@ class UrlUrl(models.Model): _description = "Url" _order = "res_model,res_id,redirect desc" - manual = fields.Boolean() + manual = fields.Boolean(default=True, readonly=True) key = fields.Char(required=True, index=True) res_id = fields.Many2oneReference( string="Record ID", @@ -31,6 +31,7 @@ class UrlUrl(models.Model): selection=lambda s: s._get_all_referential(), index=True, default="global", + required=True, ) lang_id = fields.Many2one("res.lang", "Lang", index=True, required=True) need_refresh = fields.Boolean() @@ -43,6 +44,14 @@ class UrlUrl(models.Model): ) ] + def init(self): + self.env.cr.execute( + f"""CREATE UNIQUE INDEX IF NOT EXISTS main_url_uniq + ON {self._table} (referential, lang_id, res_id, res_model) + WHERE redirect = False""" + ) + return super().init() + @tools.ormcache() @api.model def _get_model_with_url_selection(self): diff --git a/shopinvader_base_url/security/ir.model.access.csv b/shopinvader_base_url/security/ir.model.access.csv index 7f57c8c626..5396b2a22a 100644 --- a/shopinvader_base_url/security/ir.model.access.csv +++ b/shopinvader_base_url/security/ir.model.access.csv @@ -1,2 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_url_url,access_url_url,model_url_url,,1,0,0,0 +access_read_url_url,access_url_url,model_url_url,,1,0,0,0 +access_edit_url_url,access_url_url,model_url_url,group_edit_url,1,1,1,1 diff --git a/shopinvader_base_url/security/res_groups.xml b/shopinvader_base_url/security/res_groups.xml new file mode 100644 index 0000000000..cee814fc7d --- /dev/null +++ b/shopinvader_base_url/security/res_groups.xml @@ -0,0 +1,12 @@ + + + + + Edit url + + + + diff --git a/shopinvader_base_url/views/url_view.xml b/shopinvader_base_url/views/url_view.xml index 1f0096801f..ac42ba5dc1 100644 --- a/shopinvader_base_url/views/url_view.xml +++ b/shopinvader_base_url/views/url_view.xml @@ -9,9 +9,13 @@ - + - + + @@ -25,9 +29,10 @@ - + - + + diff --git a/shopinvader_product_url/README.rst b/shopinvader_product_url/README.rst new file mode 100644 index 0000000000..e69de29bb2 diff --git a/shopinvader_product_url/__init__.py b/shopinvader_product_url/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/shopinvader_product_url/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/shopinvader_product_url/__manifest__.py b/shopinvader_product_url/__manifest__.py new file mode 100644 index 0000000000..8c39235d5b --- /dev/null +++ b/shopinvader_product_url/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2023 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +{ + "name": "Shopinvader product url", + "summary": "Generate url for product and category", + "version": "16.0.1.0.0", + "development_status": "Alpha", + "category": "Shopinvader", + "website": "https://github.com/shopinvader/odoo-shopinvader", + "author": " Akretion", + "license": "AGPL-3", + "external_dependencies": { + "python": [], + "bin": [], + }, + "depends": [ + "shopinvader_base_url", + "product", + ], + "data": [], + "demo": [], +} diff --git a/shopinvader_product_url/models/__init__.py b/shopinvader_product_url/models/__init__.py new file mode 100644 index 0000000000..95337f6574 --- /dev/null +++ b/shopinvader_product_url/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_template +from . import product_category diff --git a/shopinvader_product_url/models/product_category.py b/shopinvader_product_url/models/product_category.py new file mode 100644 index 0000000000..4908457106 --- /dev/null +++ b/shopinvader_product_url/models/product_category.py @@ -0,0 +1,33 @@ +# Copyright 2023 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +from odoo.addons.shopinvader_base_url.models.abstract_url import DEFAULT_LANG + + +class ProductCategory(models.Model): + _inherit = ["product.category", "abstract.url"] + _name = "product.category" + + url_need_refresh = fields.Boolean(recursive=True) + + def _update_url_key(self, referential="global", lang=DEFAULT_LANG): + # Ensure that parent url is up to date before updating the current url + if self.parent_id: + self.parent_id._update_url_key(referential=referential, lang=lang) + return super()._update_url_key(referential=referential, lang=lang) + + def _generate_url_key(self, referential, lang): + url_key = super()._generate_url_key(referential, lang) + if self.parent_id: + parent_url = self.parent_id._get_main_url(referential, lang) + if parent_url: + return "/".join([parent_url.key, url_key]) + return url_key + + def _compute_url_need_refresh_depends(self): + return super()._compute_url_need_refresh_depends() + [ + "parent_id.url_need_refresh" + ] diff --git a/shopinvader_product_url/models/product_template.py b/shopinvader_product_url/models/product_template.py new file mode 100644 index 0000000000..48abda4f66 --- /dev/null +++ b/shopinvader_product_url/models/product_template.py @@ -0,0 +1,13 @@ +# Copyright 2023 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductTemplate(models.Model): + _inherit = ["product.template", "abstract.url"] + _name = "product.template" + + def _get_keyword_fields(self): + return super()._get_keyword_fields() + ["default_code"] diff --git a/shopinvader_product_url/readme/CONTRIBUTORS.rst b/shopinvader_product_url/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..4be8cbf0ae --- /dev/null +++ b/shopinvader_product_url/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Sebastien BEAU diff --git a/shopinvader_product_url/readme/DESCRIPTION.rst b/shopinvader_product_url/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..e427ad432b --- /dev/null +++ b/shopinvader_product_url/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Generate url for product and category diff --git a/shopinvader_product_url/tests/__init__.py b/shopinvader_product_url/tests/__init__.py new file mode 100644 index 0000000000..8c15e4db39 --- /dev/null +++ b/shopinvader_product_url/tests/__init__.py @@ -0,0 +1 @@ +from . import test_category_url diff --git a/shopinvader_product_url/tests/test_category_url.py b/shopinvader_product_url/tests/test_category_url.py new file mode 100644 index 0000000000..1ecb2bc077 --- /dev/null +++ b/shopinvader_product_url/tests/test_category_url.py @@ -0,0 +1,57 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.tests import TransactionCase + + +class TestCategoryUrl(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.lang_en = cls.env.ref("base.lang_en") + cls.lang_fr = cls.env.ref("base.lang_fr") + cls.lang_fr.active = True + cls.categ_1 = ( + cls.env["product.category"] + .with_context(lang="en_US") + .create({"name": "Root"}) + ) + cls.categ_2 = ( + cls.env["product.category"] + .with_context(lang="en_US") + .create({"name": "Level 1", "parent_id": cls.categ_1.id}) + ) + cls.categ_3 = ( + cls.env["product.category"] + .with_context(lang="en_US") + .create({"name": "Level 2", "parent_id": cls.categ_2.id}) + ) + + def _expect_url_for_lang(self, record, lang, url_key): + self.assertEqual(record._get_main_url("global", lang).key, url_key) + + def test_url_for_main_categ(self): + self.categ_1._update_url_key(lang="en_US") + self._expect_url_for_lang(self.categ_1, "en_US", "root") + + def test_url_for_child(self): + self.categ_3._update_url_key(lang="en_US") + self._expect_url_for_lang(self.categ_1, "en_US", "root") + self._expect_url_for_lang(self.categ_2, "en_US", "root/level-1") + self._expect_url_for_lang(self.categ_3, "en_US", "root/level-1/level-2") + + def test_update_main(self): + self.categ_3._update_url_key(lang="en_US") + self.categ_1.name = "New Root" + self.categ_3._update_url_key(lang="en_US") + self._expect_url_for_lang(self.categ_1, "en_US", "new-root") + self._expect_url_for_lang(self.categ_2, "en_US", "new-root/level-1") + self._expect_url_for_lang(self.categ_3, "en_US", "new-root/level-1/level-2") + + def test_update_child(self): + self.categ_3._update_url_key(lang="en_US") + self.categ_2.name = "New Level 1" + self.categ_3._update_url_key(lang="en_US") + self._expect_url_for_lang(self.categ_1, "en_US", "root") + self._expect_url_for_lang(self.categ_2, "en_US", "root/new-level-1") + self._expect_url_for_lang(self.categ_3, "en_US", "root/new-level-1/level-2")