diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10184207da..d1bb653b06 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,6 @@ exclude: | (?x) # NOT INSTALLABLE ADDONS - ^base_url/| ^partner_contact_company/| ^product_online_category/| ^shopinvader/| diff --git a/base_url/models/abstract_url.py b/base_url/models/abstract_url.py deleted file mode 100644 index a553f2328c..0000000000 --- a/base_url/models/abstract_url.py +++ /dev/null @@ -1,230 +0,0 @@ -# Copyright (C) 2016 Akretion (http://www.akretion.com) -# @author EBII MonsieurB -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import logging -from collections import defaultdict - -from odoo import _, api, fields, models -from odoo.exceptions import UserError, ValidationError - -_logger = logging.getLogger(__name__) - -try: - from slugify import slugify -except ImportError: - _logger.debug("Cannot `import slugify`.") - - -def get_model_ref(record): - return "{},{}".format(record._name, record.id) - - -class AbstractUrl(models.AbstractModel): - _name = "abstract.url" - _description = "Abstract Url" - - url_builder = fields.Selection( - selection=[("auto", "Automatic"), ("manual", "Manual")], default="auto" - ) - automatic_url_key = fields.Char(compute="_compute_automatic_url_key", store=True) - manual_url_key = fields.Char() - url_key = fields.Char(string="Url key", compute="_compute_url_key", store=True) - url_url_ids = fields.One2many( - compute="_compute_url_url_ids", comodel_name="url.url" - ) - redirect_url_url_ids = fields.One2many( - compute="_compute_redirect_url_url_ids", comodel_name="url.url" - ) - lang_id = fields.Many2one("res.lang", string="Lang", required=True) - active = fields.Boolean(string="Active", default=True) - - @api.constrains("url_builder", "manual_url_key") - def _check_manual_url_key(self): - for rec in self: - if rec.url_builder == "manual" and not rec.manual_url_key: - raise ValidationError( - _("Manual url key is required if builder is set to manual") - ) - - @api.onchange("manual_url_key") - def on_url_key_change(self): - self.ensure_one() - if self.manual_url_key: - url = slugify(self.manual_url_key) - if url != self.manual_url_key: - self.manual_url_key = url - return { - "warning": { - "title": "Adapt text rules", - "message": "it will be adapted to %s" % url, - } - } - - def _get_url_keywords(self): - """This method return a list of keyword that will be concatenated - with '-' to generate the url - Ex: if you return ['foo', '42'] the url will be foo-42 - - Note the self already include in the context the lang of the record - """ - self.ensure_one() - # TODO: IMO we should add the ID here by default - # to make sure the URL is always unique - return [self.name] - - def _post_process_url_key(self, key): - """This method allow you to customized the url key. - you can use it to build full path be adding the url of parent record - Ex: key is 42 you can prefix it with "foo" and so return "foo/42" - - Note: the self do not include in the context the lang of the record - """ - self.ensure_one() - return key - - def _generic_compute_automatic_url_key(self): - records_by_lang = defaultdict(self.browse) - for record in self: - records_by_lang[record.lang_id] |= record - - key_by_id = {} - for lang_id, records in records_by_lang.items(): - for record in records.with_context(lang=lang_id.code): - if not isinstance(record.id, models.NewId): - key_by_id[record.id] = slugify("-".join(record._get_url_keywords())) - - for record in self: - if not isinstance(record.id, models.NewId): - record.automatic_url_key = record._post_process_url_key( - key_by_id[record.id] - ) - else: - record.automatic_url_key = False - - def _compute_automatic_url_key_depends(self): - return ["lang_id", "record_id.name"] - - @api.depends(lambda self: self._compute_automatic_url_key_depends()) - def _compute_automatic_url_key(self): - raise NotImplementedError( - "Automatic url key must be computed in concrete model" - ) - - @api.depends("manual_url_key", "automatic_url_key", "url_builder", "active") - def _compute_url_key(self): - for record in self: - if not record.active: - record.url_key = "" - record._redirect_existing_url() - else: - if record.url_builder == "manual": - new_url = record.manual_url_key - else: - new_url = record.automatic_url_key - if record.url_key != new_url: - record.url_key = new_url - record.set_url(record.url_key) - - @api.depends("url_key") - def _compute_redirect_url_url_ids(self): - self.flush() - for record in self: - record.redirect_url_url_ids = record.env["url.url"].search( - [ - ("model_id", "=", get_model_ref(record)), - ("redirect", "=", True), - ] - ) - - @api.depends("url_key") - def _compute_url_url_ids(self): - self.flush() - for record in self: - record.url_url_ids = record.env["url.url"].search( - [("model_id", "=", get_model_ref(record))] - ) - - @api.model - def _prepare_url(self, url_key): - return { - "url_key": url_key, - "redirect": False, - "model_id": get_model_ref(self), - } - - def _reuse_url(self, existing_url): - # TODO add user notification in the futur SEO dashboard - existing_url.write({"model_id": get_model_ref(self), "redirect": False}) - - def set_url(self, url_key): - """Se a new url - backup old url - - 1 find url redirect true and same model_id - if other model id refuse - 2 if exists set to False - - 3 write the new one - """ - self.ensure_one() - existing_url = self.env["url.url"].search( - [ - ("url_key", "=", url_key), - ("backend_id", "=", get_model_ref(self.backend_id)), - ("lang_id", "=", self.lang_id.id), - ] - ) - if existing_url: - if self != existing_url.model_id: - if existing_url.redirect: - self._reuse_url(existing_url) - else: - raise UserError( - _( - "Url_key already exist in other model" - "\n- name: %s\n - id: %s\n" - "- url_key: %s\n - url_key_id %s" - ) - % ( - existing_url.model_id.name, - existing_url.model_id.id, - existing_url.url_key, - existing_url.id, - ) - ) - else: - existing_url.write({"redirect": False}) - else: - # no existing key creating one if not empty - self.env["url.url"].create(self._prepare_url(url_key)) - # other url of object set redirect to True - redirect_urls = self.env["url.url"].search( - [ - ("model_id", "=", get_model_ref(self)), - ("url_key", "!=", url_key), - ("redirect", "=", False), - ] - ) - redirect_urls.write({"redirect": True}) - # we must explicitly invalidate the cache since there is no depends - # defined on this computed fields and this field could have already - # been loaded into the cache - self.invalidate_cache(fnames=["url_url_ids"], ids=self.ids) - - def _redirect_existing_url(self): - """ - This method is called when the record is deactivated to give a chance - to the concrete model to implement a redirect strategy - """ - return True - - def unlink(self): - for record in self: - # TODO we should propose to redirect the old url - urls = record.env["url.url"].search( - [("model_id", "=", get_model_ref(record))] - ) - urls.unlink() - self.flush() - return super(AbstractUrl, self).unlink() diff --git a/base_url/models/url_url.py b/base_url/models/url_url.py deleted file mode 100644 index 407843cf85..0000000000 --- a/base_url/models/url_url.py +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (C) 2016 Akretion (http://www.akretion.com) -# @author EBII MonsieurB -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -import logging - -from odoo import api, fields, models - -from .abstract_url import get_model_ref - -_logger = logging.getLogger(__name__) - - -class UrlUrl(models.Model): - - _name = "url.url" - _description = "Url" - - url_key = fields.Char(required=True) - model_id = fields.Reference( - selection="_selection_target_model", - help="The id of content linked to the url.", - readonly=True, - string="Model", - required=True, - index=True, - ) - redirect = fields.Boolean(help="If tick this url is a redirection to the new url") - backend_id = fields.Reference( - selection="_selection_target_model", - compute="_compute_related_fields", - store=True, - help="Backend linked to this URL", - string="Backend", - ) - lang_id = fields.Many2one( - "res.lang", "Lang", compute="_compute_related_fields", store=True - ) - - _sql_constraints = [ - ( - "unique_key_per_backend_per_lang", - "unique(url_key, backend_id, lang_id)", - "Already exists in database", - ) - ] - - @api.model - def _selection_target_model(self): - models = self.env["ir.model"].search([]) - return [(model.model, model.name) for model in models] - - @api.depends("model_id") - def _compute_related_fields(self): - for record in self: - record.backend_id = get_model_ref(record.model_id.backend_id) - record.lang_id = record.model_id.lang_id - - @api.model - def _reference_models(self): - return [] - - def _get_object(self, url): - """ - :return: return object attach to the url - """ - return self.search([("url_key", "=", url)]).model_id diff --git a/base_url/security/ir.model.access.csv b/base_url/security/ir.model.access.csv deleted file mode 100644 index 7f57c8c626..0000000000 --- a/base_url/security/ir.model.access.csv +++ /dev/null @@ -1,2 +0,0 @@ -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 diff --git a/base_url/tests/models.py b/base_url/tests/models.py deleted file mode 100644 index dc9c25176e..0000000000 --- a/base_url/tests/models.py +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright 2019 ACSONE SA/NV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -import logging - -from odoo import api, fields, models - -_logger = logging.getLogger(__name__) - - -class UrlBackendFake(models.Model): - _name = "url.backend.fake" - _description = "Url Backend" - - name = fields.Char(required=True) - - -class ResPartner(models.Model): - _inherit = "res.partner" - - binding_ids = fields.One2many("res.partner.addressable.fake", "record_id") - - -class ResPartnerAddressableFake(models.Model): - _name = "res.partner.addressable.fake" - _inherit = "abstract.url" - _inherits = {"res.partner": "record_id"} - _description = "Fake partner addressable" - - backend_id = fields.Many2one(comodel_name="url.backend.fake") - special_code = fields.Char() - - @api.depends("lang_id", "special_code", "record_id.name") - def _compute_automatic_url_key(self): - self._generic_compute_automatic_url_key() diff --git a/base_url/tests/models_mixin.py b/base_url/tests/models_mixin.py deleted file mode 100644 index 6ef504673b..0000000000 --- a/base_url/tests/models_mixin.py +++ /dev/null @@ -1,111 +0,0 @@ -# Copyright 2018 Simone Orsi - Camptocamp SA -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from operator import attrgetter - -import mock - - -class TestMixin(object): - """Mixin to setup fake models for tests. - - Usage - the model: - - class FakeModel(models.Model, TestMixin): - _name = 'fake.model' - - name = fields.Char() - - Usage - the test klass: - - @classmethod - def setUpClass(cls): - super().setUpClass() - FakeModel._test_setup_model(cls.env) - - @classmethod - def tearDownClass(cls): - FakeModel._test_teardown_model(cls.env) - super().tearDownClass() - """ - - # Generate xmlids - # This is needed if you want to load data tied to a test model via xid. - _test_setup_gen_xid = False - # If you extend a real model (ie: res.partner) you must enable this - # to not delete the model on tear down. - _test_teardown_no_delete = False - # You can add custom fields to real models (eg: res.partner). - # In this case you must delete them to leave registry and model clean. - # This is mandatory for relational fields that link a fake model. - _test_purge_fields = [] - - @classmethod - def _test_setup_model(cls, env): - """Initialize it.""" - with mock.patch.object(env.cr, "commit"): - cls._build_model(env.registry, env.cr) - env.registry.setup_models(env.cr) - ctx = dict(env.context, update_custom_fields=True) - if cls._test_setup_gen_xid: - ctx["module"] = cls._module - env.registry.init_models(env.cr, [cls._name], ctx) - - @classmethod - def _test_teardown_model(cls, env): - """Cleanup registry and real models.""" - - for fname in cls._test_purge_fields: - model = env[cls._name] - if fname in model: - model._pop_field(fname) - model._proper_fields.remove(fname) - - if not getattr(cls, "_test_teardown_no_delete", False): - del env.registry.models[cls._name] - # here we must remove the model from list of children of inherited - # models - parents = cls._inherit - parents = ( - [parents] if isinstance(parents, (str, bytes)) else (parents or []) - ) - # kepp a copy to be sure to not modify the original _inherit - parents = list(parents) - parents.extend(cls._inherits.keys()) - parents.append("base") - funcs = [ - attrgetter(kind + "_children") for kind in ["_inherits", "_inherit"] - ] - for parent in parents: - for func in funcs: - children = func(env.registry[parent]) - if cls._name in children: - # at this stage our cls is referenced as children of - # parent -> must un reference it - children.remove(cls._name) - - def _test_get_model_id(self): - self.env.cr.execute("SELECT id FROM ir_model WHERE model = %s", (self._name,)) - res = self.env.cr.fetchone() - return res[0] if res else None - - def _test_create_ACL(self, **kw): - model_id = self._test_get_model_id() - if not model_id: - self._reflect() - model_id = self._test_get_model_id() - if model_id: - vals = self._test_ACL_values(model_id) - vals.update(kw) - self.env["ir.model.access"].create(vals) - - def _test_ACL_values(self, model_id): - values = { - "name": "Fake ACL for %s" % self._name, - "model_id": model_id, - "perm_read": 1, - "perm_create": 1, - "perm_write": 1, - "perm_unlink": 1, - "active": True, - } - return values diff --git a/base_url/tests/test_abstract_url.py b/base_url/tests/test_abstract_url.py deleted file mode 100644 index 397b6cb2c9..0000000000 --- a/base_url/tests/test_abstract_url.py +++ /dev/null @@ -1,123 +0,0 @@ -# Copyright 2019 ACSONE SA/NV -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -import mock -from odoo_test_helper import FakeModelLoader - -from odoo.exceptions import ValidationError -from odoo.tests import SavepointCase - - -class TestAbstractUrl(SavepointCase, FakeModelLoader): - @classmethod - def setUpClass(cls): - super(TestAbstractUrl, cls).setUpClass() - cls.loader = FakeModelLoader(cls.env, cls.__module__) - cls.loader.backup_registry() - from .models import ResPartner, ResPartnerAddressableFake, UrlBackendFake - - cls.loader.update_registry( - (UrlBackendFake, ResPartner, ResPartnerAddressableFake) - ) - - cls.lang = cls.env.ref("base.lang_en") - cls.UrlUrl = cls.env["url.url"] - cls.ResPartnerAddressable = cls.env["res.partner.addressable.fake"] - cls.url_backend = cls.env["url.backend.fake"].create({"name": "fake backend"}) - cls.name = "partner name" - cls.auto_key = "partner-name" - - @classmethod - def tearDownClass(cls): - cls.loader.restore_registry() - super(TestAbstractUrl, cls).tearDownClass() - - def _get_default_partner_value(self): - return { - "name": self.name, - "lang_id": self.lang.id, - "url_builder": "auto", - "backend_id": self.url_backend.id, - } - - def _create_auto(self): - return self.ResPartnerAddressable.create(self._get_default_partner_value()) - - def _check_url_key(self, partner_addressable, key_name): - self.assertEqual(partner_addressable.url_key, key_name) - self.assertEqual(len(partner_addressable.url_url_ids), 1) - url_url = partner_addressable.url_url_ids - self.assertEqual(url_url.url_key, key_name) - self.assertEqual(url_url.lang_id, self.lang) - self.assertEqual(url_url.model_id, partner_addressable) - - def test_create_auto_url(self): - my_partner = self.ResPartnerAddressable.create( - self._get_default_partner_value() - ) - self._check_url_key(my_partner, self.auto_key) - - def test_create_manual_url_contrains(self): - value = self._get_default_partner_value() - value["url_builder"] = "manual" - with self.assertRaises(ValidationError): - self.ResPartnerAddressable.create(value) - value["manual_url_key"] = "my url key" - res = self.ResPartnerAddressable.create(value) - self.assertTrue(res) - - def test_create_manual_url(self): - value = self._get_default_partner_value() - manual_url_key = "manual-url key" - value.update({"url_builder": "manual", "manual_url_key": manual_url_key}) - my_partner = self.ResPartnerAddressable.create(value) - self.assertTrue(my_partner) - self._check_url_key(my_partner, manual_url_key) - - def test_write_url_builder_constrains(self): - my_partner = self._create_auto() - with self.assertRaises(ValidationError): - my_partner.url_builder = "manual" - my_partner.write({"url_builder": "manual", "manual_url_key": "manual url key"}) - - def test_write_url_builder(self): - # tests that a new url is created we update the url builder - my_partner = self._create_auto() - self._check_url_key(my_partner, "partner-name") - manual_url_key = "manual-url key" - my_partner.write({"url_builder": "manual", "manual_url_key": manual_url_key}) - url_keys = set(my_partner.mapped("url_url_ids.url_key")) - self.assertSetEqual(url_keys, {manual_url_key, self.auto_key}) - # if we reset the auto key, no new url.url should be created - my_partner.write({"url_builder": "auto"}) - self.assertEqual(2, len(my_partner.url_url_ids)) - url_keys = set(my_partner.mapped("url_url_ids.url_key")) - self.assertSetEqual(url_keys, {manual_url_key, self.auto_key}) - - def test_write_launching_automatic_url_key(self): - my_partner = self._create_auto() - # call flush to force to apply the recompute - my_partner.flush() - my_partner.name = "my new name" - self.assertEqual(2, len(my_partner.url_url_ids)) - url_keys = set(my_partner.mapped("url_url_ids.url_key")) - self.assertSetEqual(url_keys, {"my-new-name", self.auto_key}) - - def test_write_on_related_record_launching_automatic_url_key(self): - my_partner = self._create_auto() - # call flush to force to apply the recompute - my_partner.flush() - my_partner.record_id.name = "my new name" - self.assertEqual(2, len(my_partner.url_url_ids)) - url_keys = set(my_partner.mapped("url_url_ids.url_key")) - self.assertSetEqual(url_keys, {"my-new-name", self.auto_key}) - - def test_write_inactive(self): - my_partner = self._create_auto() - # when we deactivate a record, the redirect method should be called - with mock.patch.object( - self.ResPartnerAddressable.__class__, "_redirect_existing_url" - ) as mocked_redirect: - my_partner.active = False - # call flush to force to apply the recompute - my_partner.flush() - mocked_redirect.assert_called_once() diff --git a/requirements.txt b/requirements.txt index a0f722a6d1..2673b7ba79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ extendable_pydantic>=1.0.0 fastapi openupgradelib pydantic>=2.0.0 +python-slugify diff --git a/setup/shopinvader_base_url/odoo/addons/shopinvader_base_url b/setup/shopinvader_base_url/odoo/addons/shopinvader_base_url new file mode 120000 index 0000000000..3141ea0e7e --- /dev/null +++ b/setup/shopinvader_base_url/odoo/addons/shopinvader_base_url @@ -0,0 +1 @@ +../../../../shopinvader_base_url \ No newline at end of file diff --git a/setup/shopinvader_base_url/setup.py b/setup/shopinvader_base_url/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopinvader_base_url/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) 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/base_url/README.rst b/shopinvader_base_url/README.rst similarity index 100% rename from base_url/README.rst rename to shopinvader_base_url/README.rst diff --git a/base_url/__init__.py b/shopinvader_base_url/__init__.py similarity index 100% rename from base_url/__init__.py rename to shopinvader_base_url/__init__.py diff --git a/base_url/__manifest__.py b/shopinvader_base_url/__manifest__.py similarity index 77% rename from base_url/__manifest__.py rename to shopinvader_base_url/__manifest__.py index 89512475cb..88bc7ee0dd 100644 --- a/base_url/__manifest__.py +++ b/shopinvader_base_url/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Base Url", - "version": "14.0.1.0.2", + "version": "16.0.1.0.2", "category": "tools", "license": "AGPL-3", "summary": "keep history of url for products & categories ", @@ -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": False, + "installable": True, } diff --git a/base_url/i18n/base_url.pot b/shopinvader_base_url/i18n/base_url.pot similarity index 100% rename from base_url/i18n/base_url.pot rename to shopinvader_base_url/i18n/base_url.pot diff --git a/base_url/models/__init__.py b/shopinvader_base_url/models/__init__.py similarity index 100% rename from base_url/models/__init__.py rename to shopinvader_base_url/models/__init__.py diff --git a/shopinvader_base_url/models/abstract_url.py b/shopinvader_base_url/models/abstract_url.py new file mode 100644 index 0000000000..ac92bbe57f --- /dev/null +++ b/shopinvader_base_url/models/abstract_url.py @@ -0,0 +1,230 @@ +# Copyright (C) 2016 Akretion (http://www.akretion.com) +# @author EBII MonsieurB +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from lxml import etree + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +DEFAULT_LANG = "en_US" + +_logger = logging.getLogger(__name__) + +try: + from slugify import slugify +except ImportError: + _logger.debug("Cannot `import slugify`.") + + +SMART_BUTTON = """ +""" + + +class AbstractUrl(models.AbstractModel): + _name = "abstract.url" + _description = "Abstract Url" + _have_url = True + + url_ids = fields.One2many("url.url", "res_id") + 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() + + @api.depends(lambda self: self._compute_url_need_refresh_depends()) + def _compute_url_need_refresh(self): + for record in self: + record.url_need_refresh = True + + def _get_keyword_fields(self): + """This method return a list of field that will be concatenated + with '-' to generate the url + Ex: if you return ['name', 'code'] the url will be f"{record.name}-{record.code}" + + Note: the self already include in the context the lang of the record + Note: you can return key like in depends ex: ["categ_id.name", "code"] + """ + # TODO: IMO we should add the ID here by default + # to make sure the URL is always unique + # seb.beau: not sure, most of site do not have id in url + # the url have an seo impact so it's better to only put meaning information + # moreover for unicity you can put the default code of the product or ean13 + return ["name"] + + def _generate_url_key(self, referential, lang): + def get(self, key_path): + value = self + for key_field in key_path.split("."): + value = value[key_field] + return value + + self.ensure_one() + return slugify( + "-".join([get(self, k) for k in self._get_keyword_fields() if get(self, k)]) + ) + + def _get_redirect_urls(self, referential, lang): + self.ensure_one() + return self.url_ids.filtered( + lambda s: ( + s.lang_id.code == lang and s.referential == referential and s.redirect + ) + ) + + def _get_main_url(self, referential, lang): + self.ensure_one() + return self.url_ids.filtered( + lambda s: ( + s.lang_id.code == lang + and s.referential == referential + and not s.redirect + ) + ) + + @api.model + def _prepare_url(self, referential, lang, url_key): + return { + "key": url_key, + "redirect": False, + "res_model": self._name, + "res_id": self.id, + "referential": referential, + "lang_id": self.env["res.lang"]._lang_get_id(lang), + "manual": False, + } + + def _reuse_url(self, existing_url): + # TODO add user notification in the futur SEO dashboard + existing_url.write( + { + "res_model": self._name, + "res_id": self.id, + "redirect": False, + } + ) + + def _update_url_key(self, referential="global", lang=DEFAULT_LANG): + for record in self.with_context(lang=lang): + # TODO maybe we should have a computed field that flag the + # current url if the key used for building the url have changed + # so we can skip this check if nothing have changed + current_url = record._get_main_url(referential, lang) + if not current_url.manual: + # Updating an url is done for a specific context + # a lang and a referential + # if something have changed on the record the url_need_refresh + # is flagged. + # Before updating one specific url (referential + lang) + # the flag is propagated on all valid url + if record.url_need_refresh: + 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(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() + existing_url = self.env["url.url"].search( + [ + ("referential", "=", referential), + ("lang_id.code", "=", lang), + ("key", "=", url_key), + ] + ) + if existing_url: + if existing_url.redirect: + self._reuse_url(existing_url) + else: + raise UserError( + _( + "Url_key already exist in other model" + "\n- name: %(model_name)s\n - id: %(model_id)s\n" + "- url_key: %(url_key)s\n - url_key_id %(url_id)s" + ) + % dict( + model_name=existing_url.model_id.name, + model_id=existing_url.model_id.id, + url_key=existing_url.url_key, + url_id=existing_url.id, + ) + ) + + else: + vals = self._prepare_url(referential, lang, url_key) + self.env["url.url"].create(vals) + + def _redirect_existing_url(self, action): + """ + This method is called when the record is deactivated to give a chance + to the concrete model to implement a redirect strategy + action can be "archived" or "unlink" + """ + return True + + def unlink(self): + for record in self: + record._redirect_existing_url("unlink") + # Remove dead url that have been not redirected + record.url_ids.unlink() + return super().unlink() + + def write(self, vals): + res = super().write(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 new file mode 100644 index 0000000000..e7f2c72b38 --- /dev/null +++ b/shopinvader_base_url/models/url_url.py @@ -0,0 +1,73 @@ +# Copyright (C) 2016 Akretion (http://www.akretion.com) +# @author EBII MonsieurB +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, fields, models, tools + +_logger = logging.getLogger(__name__) + + +class UrlUrl(models.Model): + _name = "url.url" + _description = "Url" + _order = "res_model,res_id,redirect desc" + + manual = fields.Boolean(default=True, readonly=True) + key = fields.Char(required=True, index=True) + res_id = fields.Many2oneReference( + string="Record ID", + help="ID of the target record in the database", + model_field="res_model", + readonly=True, + index=True, + ) + res_model = fields.Selection( + selection=lambda s: s._get_model_with_url_selection(), readonly=True, index=True + ) + redirect = fields.Boolean(help="If tick this url is a redirection to the new url") + referential = fields.Selection( + 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() + + _sql_constraints = [ + ( + "unique_key_per_referential_per_lang", + "unique(key, referential, lang_id)", + "Already exists in database", + ) + ] + + 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): + return [ + (model, self.env[model]._description) + for model in self.env + if ( + hasattr(self.env[model], "_have_url") + and not self.env[model]._abstract + and not self.env[model]._transient + ) + ] + + def _get_all_referential(self): + """Return the list of referential for your url, by default it's global + but you can do your own implementation to have url per search engine + index for example + """ + return [("global", "Global")] diff --git a/shopinvader_base_url/security/ir.model.access.csv b/shopinvader_base_url/security/ir.model.access.csv new file mode 100644 index 0000000000..5396b2a22a --- /dev/null +++ b/shopinvader_base_url/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +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/base_url/tests/__init__.py b/shopinvader_base_url/tests/__init__.py similarity index 100% rename from base_url/tests/__init__.py rename to shopinvader_base_url/tests/__init__.py diff --git a/shopinvader_base_url/tests/models.py b/shopinvader_base_url/tests/models.py new file mode 100644 index 0000000000..7407fc65b3 --- /dev/null +++ b/shopinvader_base_url/tests/models.py @@ -0,0 +1,23 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class FakeProduct(models.Model): + _inherit = ["abstract.url"] + _name = "fake.product" + + code = fields.Char() + name = fields.Char(translate=True) + active = fields.Boolean(default=True) + categ_id = fields.Many2one("fake.categ") + + def _get_keyword_fields(self): + return ["categ_id.name"] + super()._get_keyword_fields() + ["code"] + + +class FakeCateg(models.Model): + _name = "fake.categ" + + name = fields.Char() diff --git a/shopinvader_base_url/tests/test_abstract_url.py b/shopinvader_base_url/tests/test_abstract_url.py new file mode 100644 index 0000000000..53ac4fec37 --- /dev/null +++ b/shopinvader_base_url/tests/test_abstract_url.py @@ -0,0 +1,123 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from unittest import mock + +from odoo_test_helper import FakeModelLoader + +from odoo.tests import TransactionCase + + +class TestAbstractUrl(TransactionCase, FakeModelLoader): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .models import FakeCateg, FakeProduct + + cls.loader.update_registry([FakeProduct, FakeCateg]) + + cls.lang_en = cls.env.ref("base.lang_en") + cls.lang_fr = cls.env.ref("base.lang_fr") + cls.lang_fr.active = True + cls.product = ( + cls.env["fake.product"] + .with_context(lang="en_US") + .create({"name": "My Product"}) + ) + cls.product.with_context(lang="fr_FR").name = "Mon Produit" + + def _expect_url_for_lang(self, lang, url_key): + self.assertEqual(self.product._get_main_url("global", lang).key, url_key) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + def test_update_url_key(self): + self.product._update_url_key("global", "en_US") + self._expect_url_for_lang("en_US", "my-product") + url = self.product.url_ids.filtered(lambda s: s.lang_id == self.lang_en) + self.assertEqual(len(url), 1) + self.assertFalse(url.manual) + self.assertFalse(url.redirect) + + def test_update_url_2_lang(self): + self.product._update_url_key("global", "en_US") + self.product._update_url_key("global", "fr_FR") + self._expect_url_for_lang("en_US", "my-product") + self._expect_url_for_lang("fr_FR", "mon-produit") + self.assertEqual(len(self.product.url_ids), 2) + + url = self.product.url_ids.filtered(lambda s: s.lang_id == self.lang_fr) + self.assertEqual(len(url), 1) + self.assertFalse(url.manual) + self.assertFalse(url.redirect) + + def test_update_no_translatable_field(self): + self.product._update_url_key("global", "en_US") + self.product._update_url_key("global", "fr_FR") + self.product.write({"code": "1234"}) + self.product._update_url_key("global", "en_US") + self.product._update_url_key("global", "fr_FR") + self.assertEqual(len(self.product.url_ids), 4) + redirects = self.product._get_redirect_urls("global", "en_US") + self._expect_url_for_lang("en_US", "my-product-1234") + self.assertEqual(len(redirects), 1) + redirects = self.product._get_redirect_urls("global", "fr_FR") + self.assertEqual(len(redirects), 1) + self._expect_url_for_lang("fr_FR", "mon-produit-1234") + + def test_update_translatable_field(self): + self.product._update_url_key("global", "en_US") + self.product._update_url_key("global", "fr_FR") + self.product.name = "My Product With Redirect" + self.product._update_url_key("global", "en_US") + self.product._update_url_key("global", "fr_FR") + self.assertEqual(len(self.product.url_ids), 3) + redirects = self.product._get_redirect_urls("global", "en_US") + self._expect_url_for_lang("en_US", "my-product-with-redirect") + self.assertEqual(len(redirects), 1) + + def test_update_product_never_generated(self): + self.product.name = "My product never had url generated" + self.product._update_url_key("global", "en_US") + self._expect_url_for_lang("en_US", "my-product-never-had-url-generated") + self.assertEqual(len(self.product.url_ids), 1) + + def test_update_with_relation(self): + self.product._update_url_key("global", "en_US") + categ = self.env["fake.categ"].create({"name": "Foo"}) + self.product.write({"categ_id": categ.id}) + self.product._update_url_key("global", "en_US") + self.assertEqual(len(self.product.url_ids), 2) + redirects = self.product._get_redirect_urls("global", "en_US") + self.assertEqual(len(redirects), 1) + self._expect_url_for_lang("en_US", "foo-my-product") + + def test_create_manual_url(self): + self.env["url.url"].create( + { + "manual": True, + "key": "my-custom-key", + "lang_id": self.lang_en.id, + "res_id": self.product.id, + "res_model": "fake.product", + "referential": "global", + } + ) + self._expect_url_for_lang("en_US", "my-custom-key") + self.assertEqual(len(self.product.url_ids), 1) + + self.product.name = "My name have change but my url is the same" + self.product._update_url_key("global", "en_US") + self._expect_url_for_lang("en_US", "my-custom-key") + + def test_write_inactive(self): + # when we deactivate a record, the redirect method should be called + with mock.patch.object( + self.product.__class__, "_redirect_existing_url" + ) as mocked_redirect: + self.product.active = False + mocked_redirect.assert_called_once() diff --git a/base_url/views/url_view.xml b/shopinvader_base_url/views/url_view.xml similarity index 61% rename from base_url/views/url_view.xml rename to shopinvader_base_url/views/url_view.xml index 3767cceed8..ac42ba5dc1 100644 --- a/base_url/views/url_view.xml +++ b/shopinvader_base_url/views/url_view.xml @@ -7,10 +7,15 @@
- + - - + + + + @@ -21,9 +26,13 @@ url.url - - - + + + + + + + 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")