diff --git a/connector_woocommerce_wpml/README.rst b/connector_woocommerce_wpml/README.rst new file mode 100644 index 000000000..a4bbcaeee --- /dev/null +++ b/connector_woocommerce_wpml/README.rst @@ -0,0 +1,96 @@ +========================== +Connector WooCommerce WMPL +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:99c6152a250b134a922160cc755712e7e4c7493967116e4ca199dd0b144c1c07 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-NuoBiT%2Fodoo--addons-lightgray.png?logo=github + :target: https://github.com/NuoBiT/odoo-addons/tree/18.0/connector_woocommerce_wpml + :alt: NuoBiT/odoo-addons + +|badge1| |badge2| |badge3| + +- This module works with plugin WordPress Multi Language. +- https://wpml.org/ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Required WooCommerce Plugin +--------------------------- + +This module requires the **WooCommerce WPML API REST Extension** plugin +to function properly. + +Plugin URL: +https://github.com/nuobit/woocommerce-wpml-api-rest-extension + +This plugin is necessary to work around several bugs in the WPML REST +API related to: + +- Language parameter handling +- Retrieving language-specific product data +- Setting and updating content per language + +Without this extension, the connector will not be able to properly +synchronize multilingual content between Odoo and WooCommerce. + +Known issues / Roadmap +====================== + +For a better maintenance, we can try to use a mixin component to define +the common methods and properties. + + - binding: woocommerce_lang field and sql_constrains + woocommerce_internal_uniq overwriting the common for all + - export_mapper: Include the translation_of and lang? + +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 +------- + +* NuoBiT Solutions SL + +Contributors +------------ + +- `NuoBiT `__: + + - Kilian Niubo kniubo@nuobit.com + - Deniz Gallo dgallo@nuobit.com + +Maintainers +----------- + +This module is part of the `NuoBiT/odoo-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/connector_woocommerce_wpml/__init__.py b/connector_woocommerce_wpml/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/connector_woocommerce_wpml/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/connector_woocommerce_wpml/__manifest__.py b/connector_woocommerce_wpml/__manifest__.py new file mode 100644 index 000000000..257197319 --- /dev/null +++ b/connector_woocommerce_wpml/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +{ + "name": "Connector WooCommerce WMPL", + "version": "18.0.1.0.0", + "author": "NuoBiT Solutions SL", + "license": "AGPL-3", + "category": "Connector", + "website": "https://github.com/NuoBiT/odoo-addons", + "external_dependencies": { + "python": [ + "woocommerce", + ], + }, + "depends": [ + "connector_woocommerce", + "connector_wordpress_wpml", + ], + "data": [ + "views/woocommerce_backend_view.xml", + "views/product_attribute_value.xml", + "views/product_product.xml", + "views/product_public_category.xml", + "views/product_template.xml", + ], +} diff --git a/connector_woocommerce_wpml/models/__init__.py b/connector_woocommerce_wpml/models/__init__.py new file mode 100644 index 000000000..a3677d54e --- /dev/null +++ b/connector_woocommerce_wpml/models/__init__.py @@ -0,0 +1,9 @@ +from . import product +from . import product_wpml_mixin +from . import backend +from . import binding +from . import product_attribute_value +from . import product_product +from . import product_public_category +from . import product_template +from . import sale_order diff --git a/connector_woocommerce_wpml/models/backend/__init__.py b/connector_woocommerce_wpml/models/backend/__init__.py new file mode 100644 index 000000000..baacd255d --- /dev/null +++ b/connector_woocommerce_wpml/models/backend/__init__.py @@ -0,0 +1 @@ +from . import backend diff --git a/connector_woocommerce_wpml/models/backend/backend.py b/connector_woocommerce_wpml/models/backend/backend.py new file mode 100644 index 000000000..dac4b85b4 --- /dev/null +++ b/connector_woocommerce_wpml/models/backend/backend.py @@ -0,0 +1,28 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, api, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class WooCommerceBackend(models.Model): + _inherit = "woocommerce.backend" + + @api.constrains("lang_ids") + def check_lang_ids(self): + for rec in self: + for lang in rec.lang_ids: + if not lang.wordpress_wpml_lang_code: + raise ValidationError( + _( + "The language %(lang)s has no WPML code, please define " + "this code in language before using it." + ) + % { + "lang": lang.name, + } + ) diff --git a/connector_woocommerce_wpml/models/binding/__init__.py b/connector_woocommerce_wpml/models/binding/__init__.py new file mode 100644 index 000000000..0fec82e8a --- /dev/null +++ b/connector_woocommerce_wpml/models/binding/__init__.py @@ -0,0 +1 @@ +from . import binding diff --git a/connector_woocommerce_wpml/models/binding/binding.py b/connector_woocommerce_wpml/models/binding/binding.py new file mode 100644 index 000000000..786ac8b83 --- /dev/null +++ b/connector_woocommerce_wpml/models/binding/binding.py @@ -0,0 +1,25 @@ +# Copyright 2025 NuoBiT Solutions - Eric Antones +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class WoocommerceWPMLBindingMixin(models.AbstractModel): + _name = "woocommerce.wpml.binding.mixin" + _description = "WooCommerce WPML Binding Mixin" + + woocommerce_lang = fields.Char( + string="Language", + required=True, + ) + + # overwrite the constraint of the parent with the lang field + # the names should be this one to be sure they overwrite the parents + _sql_constraints = [ + ( + "internal_uniq", + "unique(backend_id, odoo_id, woocommerce_lang)", + "A binding already exists with the same language, " + "hence with the same Internal (Odoo) ID.", + ), + ] diff --git a/connector_woocommerce_wpml/models/product/__init__.py b/connector_woocommerce_wpml/models/product/__init__.py new file mode 100644 index 000000000..34ea264d2 --- /dev/null +++ b/connector_woocommerce_wpml/models/product/__init__.py @@ -0,0 +1 @@ +from . import export_mapper diff --git a/connector_woocommerce_wpml/models/product/export_mapper.py b/connector_woocommerce_wpml/models/product/export_mapper.py new file mode 100644 index 000000000..a1d4be692 --- /dev/null +++ b/connector_woocommerce_wpml/models/product/export_mapper.py @@ -0,0 +1,11 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import AbstractComponent + + +class WooCommerceProductExportMapper(AbstractComponent): + _inherit = "woocommerce.product.export.mapper" + + def _get_lang_doc(self, obj): + return obj diff --git a/connector_woocommerce_wpml/models/product_attribute_value/__init__.py b/connector_woocommerce_wpml/models/product_attribute_value/__init__.py new file mode 100644 index 000000000..b6ea9e5ec --- /dev/null +++ b/connector_woocommerce_wpml/models/product_attribute_value/__init__.py @@ -0,0 +1,5 @@ +from . import adapter +from . import binder +from . import binding +from . import export_mapper +from . import exporter diff --git a/connector_woocommerce_wpml/models/product_attribute_value/adapter.py b/connector_woocommerce_wpml/models/product_attribute_value/adapter.py new file mode 100644 index 000000000..1b0ef1f2d --- /dev/null +++ b/connector_woocommerce_wpml/models/product_attribute_value/adapter.py @@ -0,0 +1,21 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceProductAttributeValueAdapter(Component): + _name = "woocommerce.product.attribute.value.adapter" + _inherit = [ + "woocommerce.product.attribute.value.adapter", + "woocommerce.product.wpml.mixin.adapter", + ] + + def _get_search_fields(self): + return self.wpml_get_search_fields() + + # def _domain_to_normalized_dict(self, real_domain): + # return self.wpml_domain_to_normalized_dict(real_domain) + + def _extract_domain_clauses(self, domain, search_fields): + return self.wpml_extract_domain_clauses(domain, search_fields) diff --git a/connector_woocommerce_wpml/models/product_attribute_value/binder.py b/connector_woocommerce_wpml/models/product_attribute_value/binder.py new file mode 100644 index 000000000..f6da0062d --- /dev/null +++ b/connector_woocommerce_wpml/models/product_attribute_value/binder.py @@ -0,0 +1,28 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceProductAttributeValueBinder(Component): + _name = "woocommerce.product.attribute.value.binder" + _inherit = [ + "woocommerce.product.attribute.value.binder", + "woocommerce.product.wpml.mixin.binder", + ] + + @property + def external_alt_id(self): + return super().external_alt_id + ["lang"] + + def get_binding_domain(self, record): + return self.wpml_get_binding_domain(record) + + def _additional_external_binding_fields(self, external_data, relation): + return self.wpml_additional_external_binding_fields(external_data, relation) + + def wpml_additional_external_binding_fields(self, external_data, relation): + return { + **super().wpml_additional_external_binding_fields(external_data, relation), + "woocommerce_master_lang": relation._context["first_lang"], + } diff --git a/connector_woocommerce_wpml/models/product_attribute_value/binding.py b/connector_woocommerce_wpml/models/product_attribute_value/binding.py new file mode 100644 index 000000000..5022ba040 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_attribute_value/binding.py @@ -0,0 +1,42 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# Copyright 2025 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from ..binding.binding import WoocommerceWPMLBindingMixin + + +class WooCommerceProductAttributeValue(models.Model): + _name = "woocommerce.product.attribute.value" + _inherit = [ + "woocommerce.product.attribute.value", + "woocommerce.wpml.binding.mixin", + ] + + # TODO: add this to the mixin + woocommerce_master_lang = fields.Boolean( + string="WooCommerce Master Language", + readonly=True, + required=True, + default=False, + ) + + _sql_constraints = WoocommerceWPMLBindingMixin._sql_constraints + + @api.constrains("woocommerce_master_lang", "backend_id", "odoo_id") + def _check_woocommerce_master_lang(self): + for rec in self: + master_bindings = rec.odoo_id.woocommerce_bind_ids.filtered( + lambda x, rec=rec: x.backend_id == rec.backend_id + and x.woocommerce_master_lang + ) + if len(master_bindings) != 1: + raise ValidationError( + _( + "It should always be one and exactly one binding with" + " master language enabled" + ) + ) diff --git a/connector_woocommerce_wpml/models/product_attribute_value/export_mapper.py b/connector_woocommerce_wpml/models/product_attribute_value/export_mapper.py new file mode 100644 index 000000000..e2b8cfa69 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_attribute_value/export_mapper.py @@ -0,0 +1,128 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import changed_by, mapping +from odoo.addons.connector_extension.components.mapper import required + + +class WooCommerceProductAttributeValueExportMapper(Component): + _inherit = "woocommerce.product.attribute.value.export.mapper" + + # TODO: Make this only_create??? + @changed_by("lang") + @mapping + def lang(self, record): + # TODO: unify this code. Probably do a function in res lang + odoo_lang = record._context.get("lang") + if not odoo_lang: + raise ValidationError(_("Language must be always set")) + wc_lang = self.env["res.lang"]._get_wpml_code_from_iso_code(odoo_lang) + return {"lang": wc_lang} + + # @only_create + # @mapping + # def translation_of(self, record): + # odoo_lang_code = record._context.get("lang") + # if odoo_lang_code: + # wpml_lang_code = self.env["res.lang"]._get_wpml_code_from_iso_code( + # odoo_lang_code + # ) + # default_woml_lang_code = ( + # self.env["res.lang"]._get_wpml_code_from_iso_code( + # self.backend_record.language_id.code + # )) + # other_binding_backend = record.woocommerce_bind_ids.filtered( + # lambda x: x.backend_id == self.backend_record + # and x.woocommerce_lang != wpml_lang_code + # ).sorted( + # lambda x: ( + # x.woocommerce_lang != default_woml_lang_code, + # x.woocommerce_lang, + # ) + # ) + # translation_of = None + # if other_binding_backend: + # translation_of = other_binding_backend[0].woocommerce_idattributevalue + # return {"translation_of": translation_of} + + # TODO: Make this only_create??? + @mapping + def translation_of(self, record): + binding = self.options["binding"] if self.options else None + if not binding: + if not record._context.get("lang"): + raise ValidationError(_("Language must be always set")) + if "first_lang" not in record._context: + raise ValidationError(_("First lang must be always set on context")) + if not isinstance(record._context["first_lang"], bool): + raise ValidationError(_("First lang must be a boolean")) + first_lang = record._context["first_lang"] + if first_lang: + odoo_lang = record._context["lang"] + if odoo_lang != self.backend_record.language_id.code: + raise ValidationError( + _( + "Unexpected!! The first language on creation should be " + "always the same defined in the backend." + ) + ) + else: + other_bindings = record.woocommerce_bind_ids.filtered( + lambda x: x.backend_id == self.backend_record + ) + if not other_bindings: + raise ValidationError( + _( + "Unexpected. No other bindings found when it should because" + " this is not the first language!!" + ) + ) + master_binding = other_bindings.filtered( + lambda x: x.woocommerce_master_lang + ) + if not master_binding: + raise ValidationError( + _( + "Unexpected. No Master language found! On creation " + "of additional languages it shoould " + "always already exists the master language should be " + "always the same defined in the backend." + ) + ) + + return {"translation_of": master_binding.woocommerce_idattributevalue} + + @required("name") + @changed_by("name") + @mapping + def name(self, record): + dict_name = super().name(record) + if "name" in dict_name: + if dict_name["name"] != record.name: + dict_name["name"] = record.name + return dict_name + + # @required("parent_id") + # @changed_by("attribute_id") + # @mapping + # def parent_id(self, record): + # parent_dict = super().parent_id(record) + # if "parent_id" in parent_dict: + # parent_dict["parent_id"] = parent_dict["parent_id"] + # binder = self.binder_for("woocommerce.product.attribute") + # values = binder.get_external_dict_ids(record.attribute_id) + # return {"parent_id": values["id"] or None} + + @changed_by("parent_name") + @mapping + def parent_name(self, record): + dict_name = super().parent_name(record) + if "parent_name" in dict_name: + if dict_name["parent_name"] != record.attribute_id.name: + dict_name["parent_name"] = record.attribute_id.name + return dict_name diff --git a/connector_woocommerce_wpml/models/product_attribute_value/exporter.py b/connector_woocommerce_wpml/models/product_attribute_value/exporter.py new file mode 100644 index 000000000..1d4714270 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_attribute_value/exporter.py @@ -0,0 +1,15 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceProductAttributeValueExporter(Component): + _name = "woocommerce.product.attribute.value.record.direct.exporter" + _inherit = [ + "woocommerce.product.attribute.value.record.direct.exporter", + "woocommerce.product.wpml.mixin.record.direct.exporter", + ] + + def run(self, relation, always=True, internal_fields=None): + return self.wpml_run(relation, always=always, internal_fields=internal_fields) diff --git a/connector_woocommerce_wpml/models/product_product/__init__.py b/connector_woocommerce_wpml/models/product_product/__init__.py new file mode 100644 index 000000000..b6ea9e5ec --- /dev/null +++ b/connector_woocommerce_wpml/models/product_product/__init__.py @@ -0,0 +1,5 @@ +from . import adapter +from . import binder +from . import binding +from . import export_mapper +from . import exporter diff --git a/connector_woocommerce_wpml/models/product_product/adapter.py b/connector_woocommerce_wpml/models/product_product/adapter.py new file mode 100644 index 000000000..839d8c278 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_product/adapter.py @@ -0,0 +1,56 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceProductProductAdapter(Component): + _name = "woocommerce.product.product.adapter" + _inherit = [ + "woocommerce.product.product.adapter", + "woocommerce.product.wpml.mixin.adapter", + ] + + # TODO AQUI: do we need this 2 next methods + # def create(self, data): + # if data.get("translation_of"): + # sku = data.pop("sku") + # res = super().create(data) + # if data.get("translation_of"): + # if res and isinstance(res, dict): + # external_id = res.get("id") + # parent_id = res.get("parent_id") + # if external_id: + # url_l = "products/%s/variations/%s" % (parent_id, external_id) + # self._exec("put", url_l, data={"sku": sku}) + # return res + + # def write(self, external_id, data): # pylint: disable=W8106 + # old_sku = None + # if data.get("sku"): + # old_sku = data.pop("sku") + # res = super().write(external_id, data) + # if old_sku and res.get("data").get("sku") != old_sku: + # data["sku"] = old_sku + # res = super().write(external_id, data) + # return res + + # TODO: REVIEW: can we return this in better way? + def _get_search_fields(self): + res = list( + set(self.wpml_get_search_fields()) | set(super()._get_search_fields()) + ) + return res + + def _domain_to_normalized_dict(self, real_domain): + return self.wpml_domain_to_normalized_dict(real_domain) + + # on Product variations the query return all variations with sku + def _extract_domain_clauses(self, domain, search_fields): + real_domain, common_domain = super()._extract_domain_clauses( + domain, search_fields + ) + for clause in domain: + if "lang" in clause[0]: + common_domain.append(clause) + return real_domain, common_domain diff --git a/connector_woocommerce_wpml/models/product_product/binder.py b/connector_woocommerce_wpml/models/product_product/binder.py new file mode 100644 index 000000000..c42c2b8fc --- /dev/null +++ b/connector_woocommerce_wpml/models/product_product/binder.py @@ -0,0 +1,55 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceProductProductBinder(Component): + _name = "woocommerce.product.product.binder" + _inherit = [ + "woocommerce.product.product.binder", + "woocommerce.product.wpml.mixin.binder", + ] + # _inherit = "woocommerce.product.product.binder" + + @property + def external_alt_id(self): + return super().external_alt_id + ["lang"] + + def get_binding_domain(self, record): + return self.wpml_get_binding_domain(record) + + def _additional_external_binding_fields(self, external_data, relation): + return self.wpml_additional_external_binding_fields(external_data, relation) + + def wpml_additional_external_binding_fields(self, external_data, relation): + return { + **super().wpml_additional_external_binding_fields(external_data, relation), + "woocommerce_master_lang": relation._context["first_lang"], + } + + # def unwrap_binding(self, binding): + # return self.wpml_unwrap_binding(binding) + + # TODO: code commented. It's not necessary? delete! + # def to_external(self, binding, wrap=True, binding_extra_vals=None): + # return super().to_external( + # binding, wrap=wrap, binding_extra_vals={"lang": binding.woocommerce_lang} + # ) + + # We need this because we can't filter sku and lang + def _get_external_record_alt(self, relation, id_values): + res = super()._get_external_record_alt(relation, id_values) + if res: + relation_wp_lang = self.env["res.lang"]._get_wpml_code_from_iso_code( + relation.env.context.get("lang") + ) + if res.get("lang") != relation_wp_lang: + if res.get("translations") and res["translations"].get( + relation_wp_lang + ): + adapter = self.component(usage="backend.adapter") + res = adapter.read(res["translations"][relation_wp_lang]) + else: + return None + return res diff --git a/connector_woocommerce_wpml/models/product_product/binding.py b/connector_woocommerce_wpml/models/product_product/binding.py new file mode 100644 index 000000000..96bebae72 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_product/binding.py @@ -0,0 +1,75 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# Copyright 2025 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from ..binding.binding import WoocommerceWPMLBindingMixin + + +class WooCommerceProductProduct(models.Model): + _name = "woocommerce.product.product" + _inherit = [ + "woocommerce.product.product", + "woocommerce.wpml.binding.mixin", + ] + _order = ( + "backend_id, product_tmpl_id, odoo_id, woocommerce_master_lang desc, " + " woocommerce_lang, woocommerce_idparent," + "woocommerce_idproduct" + ) + + # TODO: add this to the mixin + woocommerce_master_lang = fields.Boolean( + string="WooCommerce Master Language", + readonly=True, + required=True, + default=False, + ) + + _sql_constraints = WoocommerceWPMLBindingMixin._sql_constraints + + @api.constrains("woocommerce_master_lang", "backend_id", "odoo_id") + def _check_woocommerce_master_lang(self): + for rec in self: + master_bindings = rec.odoo_id.woocommerce_bind_ids.filtered( + lambda x, rec=rec: x.backend_id == rec.backend_id + and x.woocommerce_master_lang + ) + if len(master_bindings) != 1: + raise ValidationError( + _( + "It should always be one and exactly one binding with" + " master language enabled" + ) + ) + + # TODO: This function should be an overwrite of the original one, + # it should be refactored to avoid code duplication + # doing a hook to set a context variable with lang + # TODO: The optimization needs to be done at the language level, + # just as we do in the upper module connector_woocommerce + # def resync_export(self): + # super().resync_export() + # if not self.env.context.get("resync_product_template", False): + # for rec in self: + # rec.product_tmpl_id.woocommerce_bind_ids.filtered( + # lambda x: x.backend_id == rec.backend_id + # and x.woocommerce_lang == rec.woocommerce_lang + # ).with_context(resync_product_product=True).resync_export() + + # def unlink(self): + # to_remove, to_remove_variants = self.env[self._name], + # self.env["woocommerce.product.product"] + # for rec in self: + # to_remove |= rec.odoo_id.woocommerce_bind_ids.filtered( + # lambda x: x.backend_id == rec.backend_id #and x != rec + # ) + # to_remove_variants |= rec.odoo_id.with_context(active_test=False) + # .product_variant_ids.woocommerce_bind_ids.filtered( + # lambda x: x.backend_id == rec.backend_id + # ) + # to_remove_variants.with_context().unlink() + # return super(WooCommerceProductTemplate, to_remove).unlink() diff --git a/connector_woocommerce_wpml/models/product_product/export_mapper.py b/connector_woocommerce_wpml/models/product_product/export_mapper.py new file mode 100644 index 000000000..0ffbd4c8b --- /dev/null +++ b/connector_woocommerce_wpml/models/product_product/export_mapper.py @@ -0,0 +1,139 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# Copyright NuoBiT Solutions - Eric Antones +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import changed_by, mapping +from odoo.addons.connector_extension.common import tools + + +class WooCommerceProductProductExportMapper(Component): + _inherit = "woocommerce.product.product.export.mapper" + + # TODO: Make this only_create??? + @changed_by("lang") + @mapping + def lang(self, record): + # TODO: unify this code. Probably do a function in res lang + odoo_lang = record._context.get("lang") + if not odoo_lang: + raise ValidationError(_("Language must be always set")) + wc_lang = self.env["res.lang"]._get_wpml_code_from_iso_code(odoo_lang) + return {"lang": wc_lang} + + # TODO: Make this only_create??? + @mapping + def translation_of(self, record): + binding = self.options["binding"] if self.options else None + if not binding: + if not record._context.get("lang"): + raise ValidationError(_("Language must be always set")) + if "first_lang" not in record._context: + raise ValidationError(_("First lang must be always set on context")) + if not isinstance(record._context["first_lang"], bool): + raise ValidationError(_("First lang must be a boolean")) + first_lang = record._context["first_lang"] + if first_lang: + odoo_lang = record._context["lang"] + if odoo_lang != self.backend_record.language_id.code: + raise ValidationError( + _( + "Unexpected!! The first language on creation should be " + "always the same defined in the backend." + ) + ) + else: + other_bindings = record.woocommerce_bind_ids.filtered( + lambda x: x.backend_id == self.backend_record + ) + if not other_bindings: + raise ValidationError( + _( + "Unexpected. No other bindings found when it should because" + " this is not the first language!!" + ) + ) + master_binding = other_bindings.filtered( + lambda x: x.woocommerce_master_lang + ) + if not master_binding: + raise ValidationError( + _( + "Unexpected. No Master language found! On creation " + "of additional languages it shoould " + "always already exists the master language should be " + "always the same defined in the backend." + ) + ) + + return {"translation_of": master_binding.woocommerce_idproduct} + + @changed_by("default_code") + @mapping + def sku(self, record): + binding = self.options["binding"] if self.options else None + if binding: + if binding.woocommerce_master_lang: + return super().sku(record) + else: + master_binding = record.woocommerce_bind_ids.filtered( + lambda x: x.backend_id == self.backend_record + and x.woocommerce_master_lang + ) + if master_binding: + if len(master_binding) > 1: + raise ValidationError( + _( + "It should always be one and exactly one binding " + "with master language emabled" + ) + ) + else: + if not record._context.get("lang"): + raise ValidationError(_("Language must be always set")) + if "first_lang" not in record._context: + raise ValidationError(_("First lang must be always set on context")) + if not isinstance(record._context["first_lang"], bool): + raise ValidationError(_("First lang must be a boolean")) + first_lang = record._context["first_lang"] + if first_lang: + odoo_lang = record._context["lang"] + if odoo_lang != self.backend_record.language_id.code: + raise ValidationError( + _( + "Unexpected!! The first language on creation should " + "be always the same defined in the backend." + ) + ) + return super().sku(record) + + def _get_product_description(self, record): + res = False + odoo_lang = record._context.get("lang") + if not odoo_lang: + raise ValidationError(_("Language must be always set")) + if odoo_lang == self.backend_record.language_id.code: + res = super()._get_product_description(record) + else: + # We don't need check backend_record lang + # because record already has lang on context + description = record.variant_public_description + if description: + res = tools.color_rgb2hex(description) + return res + + # @mapping + # def attributes(self, record): + # binder = self.binder_for("woocommerce.product.attribute") + # attr_list = [] + # for value in record.product_template_attribute_value_ids: + # values = binder.get_external_dict_ids(value.attribute_id) + # attr_list.append( + # { + # "id": values["id"], + # "option": value.name, + # } + # ) + # return {"attributes": attr_list} diff --git a/connector_woocommerce_wpml/models/product_product/exporter.py b/connector_woocommerce_wpml/models/product_product/exporter.py new file mode 100644 index 000000000..fa391ceb3 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_product/exporter.py @@ -0,0 +1,15 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceProductProductExporter(Component): + _name = "woocommerce.product.product.record.direct.exporter" + _inherit = [ + "woocommerce.product.product.record.direct.exporter", + "woocommerce.product.wpml.mixin.record.direct.exporter", + ] + + def run(self, relation, always=True, internal_fields=None): + return self.wpml_run(relation, always=always, internal_fields=internal_fields) diff --git a/connector_woocommerce_wpml/models/product_public_category/__init__.py b/connector_woocommerce_wpml/models/product_public_category/__init__.py new file mode 100644 index 000000000..b6ea9e5ec --- /dev/null +++ b/connector_woocommerce_wpml/models/product_public_category/__init__.py @@ -0,0 +1,5 @@ +from . import adapter +from . import binder +from . import binding +from . import export_mapper +from . import exporter diff --git a/connector_woocommerce_wpml/models/product_public_category/adapter.py b/connector_woocommerce_wpml/models/product_public_category/adapter.py new file mode 100644 index 000000000..1db1ef6a0 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_public_category/adapter.py @@ -0,0 +1,69 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import Component + + +class WooCommerceProductPublicCategoryAdapter(Component): + _name = "woocommerce.product.public.category.adapter" + _inherit = [ + "woocommerce.product.public.category.adapter", + "woocommerce.product.wpml.mixin.adapter", + ] + + def _manage_error_codes( + self, op, res_data, res, resource, *args, raise_on_error=True, **kwargs + ): + if res.status_code == 500: + if res_data.get("code") == "duplicate_term_slug": + error_message = _( + "Error: '%(message)s'. " + "WPML plugin allows set the same slug for different " + "languages on FrontEnd but this can't be done via API. " + "Probably we need a solution in plugin code, it can't " + "be solved in Odoo without a workaround modifying raw data. " + "Review the slug of the category '%(name)s' in lang [%(lang)s]" + " and try again." + "" + ) % { + "message": res_data["message"], + "name": kwargs["data"].get("name"), + "lang": kwargs["data"].get("lang"), + } + if raise_on_error: + raise ValidationError(error_message) + else: + return error_message + + return super()._manage_error_codes( + op, res_data, res, resource, *args, raise_on_error=raise_on_error, **kwargs + ) + + def _get_search_fields(self): + return self.wpml_get_search_fields() + + # def _get_search_fields(self): + # res_new = [] + # res = self.wpml_get_search_fields() + # # Workaround for a WooCommerce API bug: the API sometimes fails to filter by + # # language. This is another bug in the WPML API. Because of this, we cannot + # # rely on server-side filtering. There is no other option but to fetch all + # # records and filter them locally (inefficient, but reliable). If the WPML + # # WooCommerce API is fixed in the future, we can keep the language as a + # # search field and perform server-side filtering instead of removing it here. + # # With the version 1.0.3 of the WooCommerce plugin this should not be + # necessary + # # https://github.com/nuobit/woocommerce-wpml-api-rest-extension + # for f in res: + # if f != "lang": + # res_new.append(f) + # return res_new + + def _domain_to_normalized_dict(self, real_domain): + return self.wpml_domain_to_normalized_dict(real_domain) + + def _extract_domain_clauses(self, domain, search_fields): + return self.wpml_extract_domain_clauses(domain, search_fields) diff --git a/connector_woocommerce_wpml/models/product_public_category/binder.py b/connector_woocommerce_wpml/models/product_public_category/binder.py new file mode 100644 index 000000000..c5db1882c --- /dev/null +++ b/connector_woocommerce_wpml/models/product_public_category/binder.py @@ -0,0 +1,31 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import AbstractComponent + + +class WooCommerceProductPublicCategoryBinder(AbstractComponent): + _name = "woocommerce.product.public.category.binder" + _inherit = [ + "woocommerce.product.public.category.binder", + "woocommerce.product.wpml.mixin.binder", + ] + + @property + def external_alt_id(self): + return super().external_alt_id + ["lang"] + + def get_binding_domain(self, record): + return self.wpml_get_binding_domain(record) + + def _additional_external_binding_fields(self, external_data, relation): + return self.wpml_additional_external_binding_fields(external_data, relation) + + def wpml_additional_external_binding_fields(self, external_data, relation): + return { + **super().wpml_additional_external_binding_fields(external_data, relation), + "woocommerce_master_lang": relation._context["first_lang"], + } + + # def unwrap_binding(self, binding): + # return self.wpml_unwrap_binding(binding) diff --git a/connector_woocommerce_wpml/models/product_public_category/binding.py b/connector_woocommerce_wpml/models/product_public_category/binding.py new file mode 100644 index 000000000..49dfa2f59 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_public_category/binding.py @@ -0,0 +1,42 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# Copyright 2025 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from ..binding.binding import WoocommerceWPMLBindingMixin + + +class WooCommerceProductPublicCategory(models.Model): + _name = "woocommerce.product.public.category" + _inherit = [ + "woocommerce.product.public.category", + "woocommerce.wpml.binding.mixin", + ] + + # TODO: add this to the mixin + woocommerce_master_lang = fields.Boolean( + string="WooCommerce Master Language", + readonly=True, + required=True, + default=False, + ) + + _sql_constraints = WoocommerceWPMLBindingMixin._sql_constraints + + @api.constrains("woocommerce_master_lang", "backend_id", "odoo_id") + def _check_woocommerce_master_lang(self): + for rec in self: + master_bindings = rec.odoo_id.woocommerce_bind_ids.filtered( + lambda x, rec=rec: x.backend_id == rec.backend_id + and x.woocommerce_master_lang + ) + if len(master_bindings) != 1: + raise ValidationError( + _( + "It should always be one and exactly one binding with" + " master language enabled" + ) + ) diff --git a/connector_woocommerce_wpml/models/product_public_category/export_mapper.py b/connector_woocommerce_wpml/models/product_public_category/export_mapper.py new file mode 100644 index 000000000..a3105f04f --- /dev/null +++ b/connector_woocommerce_wpml/models/product_public_category/export_mapper.py @@ -0,0 +1,107 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import changed_by, mapping + + +class WooCommerceProductPublicCategoryExportMapper(Component): + _inherit = "woocommerce.product.public.category.export.mapper" + + # TODO: Make this only_create??? + @changed_by("lang") + @mapping + def lang(self, record): + # TODO: unify this code. Probably do a function in res lang + odoo_lang = record._context.get("lang") + if not odoo_lang: + raise ValidationError(_("Language must be always set")) + wc_lang = self.env["res.lang"]._get_wpml_code_from_iso_code(odoo_lang) + return {"lang": wc_lang} + + # @only_create + # @mapping + # def translation_of(self, record): + # lang_code = record._context.get("lang") + # if lang_code: + # other_binding_backend = record.woocommerce_bind_ids.filtered( + # lambda x: x.backend_id == self.backend_record + # and x.woocommerce_lang + # != self.env["res.lang"]._get_wpml_code_from_iso_code( + # record._context.get("lang") + # ) + # ) + # translation_of = None + # for obb in other_binding_backend: + # translation_of = obb.woocommerce_idpubliccategory + # return {"translation_of": translation_of} + + # TODO: Make this only_create??? + @mapping + def translation_of(self, record): + binding = self.options["binding"] if self.options else None + if not binding: + if not record._context.get("lang"): + raise ValidationError(_("Language must be always set")) + if "first_lang" not in record._context: + raise ValidationError(_("First lang must be always set on context")) + if not isinstance(record._context["first_lang"], bool): + raise ValidationError(_("First lang must be a boolean")) + first_lang = record._context["first_lang"] + if first_lang: + odoo_lang = record._context["lang"] + if odoo_lang != self.backend_record.language_id.code: + raise ValidationError( + _( + "Unexpected!! The first language on creation should be " + "always the same defined in the backend." + ) + ) + else: + other_bindings = record.woocommerce_bind_ids.filtered( + lambda x: x.backend_id == self.backend_record + ) + if not other_bindings: + raise ValidationError( + _( + "Unexpected. No other bindings found when it should because" + " this is not the first language!!" + ) + ) + master_binding = other_bindings.filtered( + lambda x: x.woocommerce_master_lang + ) + if not master_binding: + raise ValidationError( + _( + "Unexpected. No Master language found! On creation " + "of additional languages it shoould " + "always already exists the master language should be " + "always the same defined in the backend." + ) + ) + + return {"translation_of": master_binding.woocommerce_idpubliccategory} + + # TODO: Try to don't repeat this code + @changed_by("name") + @mapping + def name(self, record): + dict_name = super().name(record) + if "name" in dict_name: + if dict_name["name"] != record.name: + dict_name["name"] = record.name + return dict_name + + @mapping + def description(self, record): + dict_description = super().description(record) + if dict_description["description"] != record.description: + dict_description["description"] = record.description or None + return dict_description + + def _get_slug_name(self, record): + return record.slug_name diff --git a/connector_woocommerce_wpml/models/product_public_category/exporter.py b/connector_woocommerce_wpml/models/product_public_category/exporter.py new file mode 100644 index 000000000..404d95652 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_public_category/exporter.py @@ -0,0 +1,15 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceProductPublicCategoryExporter(Component): + _name = "woocommerce.product.public.category.record.direct.exporter" + _inherit = [ + "woocommerce.product.public.category.record.direct.exporter", + "woocommerce.product.wpml.mixin.record.direct.exporter", + ] + + def run(self, relation, always=True, internal_fields=None): + return self.wpml_run(relation, always=always, internal_fields=internal_fields) diff --git a/connector_woocommerce_wpml/models/product_template/__init__.py b/connector_woocommerce_wpml/models/product_template/__init__.py new file mode 100644 index 000000000..b6ea9e5ec --- /dev/null +++ b/connector_woocommerce_wpml/models/product_template/__init__.py @@ -0,0 +1,5 @@ +from . import adapter +from . import binder +from . import binding +from . import export_mapper +from . import exporter diff --git a/connector_woocommerce_wpml/models/product_template/adapter.py b/connector_woocommerce_wpml/models/product_template/adapter.py new file mode 100644 index 000000000..57f3c7bfb --- /dev/null +++ b/connector_woocommerce_wpml/models/product_template/adapter.py @@ -0,0 +1,63 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceProductTemplateAdapter(Component): + _name = "woocommerce.product.template.adapter" + _inherit = [ + "woocommerce.product.template.adapter", + "woocommerce.product.wpml.mixin.adapter", + ] + + # TODO: do we really need this next 2 methods??? + # def create(self, data): + # if data.get("type") == "simple" and data.get("translation_of"): + # sku = data.pop("sku") + # res = super().create(data) + # if data.get("type") == "simple" and data.get("translation_of"): + # if res and isinstance(res, dict): + # external_id = res.get("id") + # if external_id: + # sku = self._normalize_simple_sku(sku) + # url = "products/%i" % external_id + # self._exec("put", url, data={"sku": sku}) + # return res + + # def write(self, external_id, data): # pylint: disable=W8106 + # old_sku = None + # if data.get("type") == "simple": + # if data.get("translation_of"): + # data.pop("sku") + # else: + # old_sku = data.pop("sku") + # if isinstance(old_sku, list): + # old_sku = old_sku[0] + # res = super().write(external_id, data) + # if old_sku and res["data"].get("sku") != old_sku: + # data["sku"] = old_sku + # # This conversion is to "revert" first conversion done on prepare_data + # if isinstance(data["regular_price"], str): + # data["regular_price"] = float(data["regular_price"]) + # if isinstance(data["sale_price"], str): + # data["sale_price"] = float(data["sale_price"]) + # res = super().write(external_id, data) + # return res + + # TODO: REVIEW: can we return this in better way? + def _get_search_fields(self): + return list( + set(self.wpml_get_search_fields()) | set(super()._get_search_fields()) + ) + + def _modify_res_on_search_read(self, parent_ids, domain_dict): + res = super()._modify_res_on_search_read(parent_ids, domain_dict) + res[0]["lang"] = domain_dict.get("lang") + return res + + def _domain_to_normalized_dict(self, real_domain): + return self.wpml_domain_to_normalized_dict(real_domain) + + def _extract_domain_clauses(self, domain, search_fields): + return self.wpml_extract_domain_clauses(domain, search_fields) diff --git a/connector_woocommerce_wpml/models/product_template/binder.py b/connector_woocommerce_wpml/models/product_template/binder.py new file mode 100644 index 000000000..6e2b49d19 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_template/binder.py @@ -0,0 +1,48 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceProductTemplateBinder(Component): + _name = "woocommerce.product.template.binder" + _inherit = [ + "woocommerce.product.template.binder", + "woocommerce.product.wpml.mixin.binder", + ] + + @property + def external_alt_id(self): + return super().external_alt_id + ["lang"] + + def get_binding_domain(self, record): + return self.wpml_get_binding_domain(record) + + def _additional_external_binding_fields(self, external_data, relation): + return self.wpml_additional_external_binding_fields(external_data, relation) + + def wpml_additional_external_binding_fields(self, external_data, relation): + return { + **super().wpml_additional_external_binding_fields(external_data, relation), + "woocommerce_master_lang": relation._context["first_lang"], + } + + # def unwrap_binding(self, binding): + # return self.wpml_unwrap_binding(binding) + + # We need this because we can't filter sku and lang + def _get_external_record_alt(self, relation, id_values): + res = super()._get_external_record_alt(relation, id_values) + if res: + relation_wp_lang = self.env["res.lang"]._get_wpml_code_from_iso_code( + relation.env.context.get("lang") + ) + if res.get("lang") != relation_wp_lang: + if res.get("translations") and res["translations"].get( + relation_wp_lang + ): + adapter = self.component(usage="backend.adapter") + res = adapter.read(res["translations"][relation_wp_lang]) + else: + return None + return res diff --git a/connector_woocommerce_wpml/models/product_template/binding.py b/connector_woocommerce_wpml/models/product_template/binding.py new file mode 100644 index 000000000..0a4a23177 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_template/binding.py @@ -0,0 +1,90 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# Copyright 2025 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from ..binding.binding import WoocommerceWPMLBindingMixin + + +class WooCommerceProductTemplate(models.Model): + _name = "woocommerce.product.template" + _inherit = [ + "woocommerce.product.template", + "woocommerce.wpml.binding.mixin", + ] + _order = ( + "backend_id, odoo_id, woocommerce_master_lang desc, " + " woocommerce_lang, woocommerce_idproduct" + ) + + woocommerce_master_lang = fields.Boolean( + string="WooCommerce Master Language", + readonly=True, + required=True, + default=False, + ) + + _sql_constraints = WoocommerceWPMLBindingMixin._sql_constraints + + # TODO: duplicated in product.product + @api.constrains("woocommerce_master_lang", "backend_id", "odoo_id") + def _check_woocommerce_master_lang(self): + for rec in self: + master_bindings = rec.odoo_id.woocommerce_bind_ids.filtered( + lambda x, rec=rec: x.backend_id == rec.backend_id + and x.woocommerce_master_lang + ) + if len(master_bindings) != 1: + raise ValidationError( + _( + "It should always be one and exactly one binding with " + "master language enabled" + ) + ) + + # @api.constrains("woocommerce_master_lang") + # def _check_woocommerce_master_lang(self): + # for rec in self: + # if not rec.woocommerce_master_lang: + # master_binding = rec.odoo_id.woocommerce_bind_ids.filtered( + # lambda x: x.backend_id == self.backend_id + # and x.woocommerce_master_lang + # ) + # if not master_binding: + # raise ValidationError( + # _( + # "WooCommerce master Language binding does not exists. + # At least should exist 1" + # ) + # ) + + # TODO: This function should be an overwrite of the original one, + # it should be refactored to avoid code duplication + # doing a hook to set a context variable with lang + # TODO: The optimization needs to be done at the language level, + # just as we do in the upper module connector_woocommerce + # def resync_export(self): + # super().resync_export() + # if not self.env.context.get("resync_product_product", False): + # for rec in self: + # rec.product_variant_ids.woocommerce_bind_ids.filtered( + # lambda x: x.backend_id == self.backend_id + # and x.woocommerce_lang == rec.woocommerce_lang + # ).with_context(resync_product_template=True).resync_export() + + # def unlink(self): + # to_remove, to_remove_variants = self.env[self._name], + # self.env["woocommerce.product.product"] + # for rec in self: + # to_remove |= rec.odoo_id.woocommerce_bind_ids.filtered( + # lambda x: x.backend_id == rec.backend_id #and x != rec + # ) + # to_remove_variants |= rec.odoo_id.with_context(active_test=False) + # .product_variant_ids.woocommerce_bind_ids.filtered( + # lambda x: x.backend_id == rec.backend_id + # ) + # to_remove_variants.with_context().unlink() + # return super(WooCommerceProductTemplate, to_remove).unlink() diff --git a/connector_woocommerce_wpml/models/product_template/export_mapper.py b/connector_woocommerce_wpml/models/product_template/export_mapper.py new file mode 100644 index 000000000..7f404afda --- /dev/null +++ b/connector_woocommerce_wpml/models/product_template/export_mapper.py @@ -0,0 +1,131 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# Copyright 2025 NuoBiT Solutions - Eric Antones +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import changed_by, mapping +from odoo.addons.connector_extension.common import tools + + +class WooCommerceProductTemplateExportMapper(Component): + _inherit = "woocommerce.product.template.export.mapper" + + # TODO: Make this only_create??? + @changed_by("lang") + @mapping + def lang(self, record): + # TODO: unify this code. Probably do a function in res lang + odoo_lang = record._context.get("lang") + if not odoo_lang: + raise ValidationError(_("Language must be always set")) + wc_lang = self.env["res.lang"]._get_wpml_code_from_iso_code(odoo_lang) + return {"lang": wc_lang} + + # TODO: Make this only_create??? + @mapping + def translation_of(self, record): + binding = self.options["binding"] + if not binding: + if not record._context.get("lang"): + raise ValidationError(_("Language must be always set")) + if "first_lang" not in record._context: + raise ValidationError(_("First lang must be always set on context")) + if not isinstance(record._context["first_lang"], bool): + raise ValidationError(_("First lang must be a boolean")) + first_lang = record._context["first_lang"] + if first_lang: + odoo_lang = record._context["lang"] + if odoo_lang != self.backend_record.language_id.code: + raise ValidationError( + _( + "Unexpected!! The first language on creation should be " + "always the same defined in the backend." + ) + ) + else: + other_bindings = record.woocommerce_bind_ids.filtered( + lambda x: x.backend_id == self.backend_record + ) + if not other_bindings: + raise ValidationError( + _( + "Unexpected. No other bindings found when it should because" + " this is not the first language!!" + ) + ) + master_binding = other_bindings.filtered( + lambda x: x.woocommerce_master_lang + ) + if not master_binding: + raise ValidationError( + _( + "Unexpected. No master language found! On creation of " + "additional languages it should " + "always already exists the master language should be " + "always the same defined in the backend." + ) + ) + + return {"translation_of": master_binding.woocommerce_idproduct} + + # TODO: this is exactly the same in product_product, unify, + # via inheritance or mixim, whatever + @changed_by("default_code") + @mapping + def sku(self, record): + binding = self.options["binding"] if self.options else None + if binding: + if binding.woocommerce_master_lang: + return super().sku(record) + else: + master_binding = record.woocommerce_bind_ids.filtered( + lambda x: x.backend_id == self.backend_record + and x.woocommerce_master_lang + ) + if master_binding: + if len(master_binding) > 1: + raise ValidationError( + _( + "It should always be one and exactly one binding " + "with master language emabled" + ) + ) + else: + if not record._context.get("lang"): + raise ValidationError(_("Language must be always set")) + if "first_lang" not in record._context: + raise ValidationError(_("First lang must be always set on context")) + if not isinstance(record._context["first_lang"], bool): + raise ValidationError(_("First lang must be a boolean")) + first_lang = record._context["first_lang"] + if first_lang: + odoo_lang = record._context["lang"] + if odoo_lang != self.backend_record.language_id.code: + raise ValidationError( + _( + "Unexpected!! The first language on creation should " + "be always the same defined in the backend." + ) + ) + return super().sku(record) + + def _get_product_description(self, record): + # We don't need check backend_record lang + # because record already has lang on context + return tools.color_rgb2hex(record.public_description) + + def _get_short_description(self, record): + return record.public_short_description + + def _get_product_variant_description(self, record): + # We don't need check backend_record lang + # because record already has lang on context + return tools.color_rgb2hex(record.product_variant_id.variant_public_description) + + def _get_value_ids(self, attribute_line): + return attribute_line.product_template_value_ids.mapped("name") + + def _get_slug_name(self, record): + return record.slug_name diff --git a/connector_woocommerce_wpml/models/product_template/exporter.py b/connector_woocommerce_wpml/models/product_template/exporter.py new file mode 100644 index 000000000..d34fc6a4b --- /dev/null +++ b/connector_woocommerce_wpml/models/product_template/exporter.py @@ -0,0 +1,15 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceProductTemplateExporter(Component): + _name = "woocommerce.product.template.record.direct.exporter" + _inherit = [ + "woocommerce.product.template.record.direct.exporter", + "woocommerce.product.wpml.mixin.record.direct.exporter", + ] + + def run(self, relation, always=True, internal_fields=None): + return self.wpml_run(relation, always=always, internal_fields=internal_fields) diff --git a/connector_woocommerce_wpml/models/product_wpml_mixin/__init__.py b/connector_woocommerce_wpml/models/product_wpml_mixin/__init__.py new file mode 100644 index 000000000..e51edd774 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_wpml_mixin/__init__.py @@ -0,0 +1,3 @@ +from . import adapter +from . import binder +from . import exporter diff --git a/connector_woocommerce_wpml/models/product_wpml_mixin/adapter.py b/connector_woocommerce_wpml/models/product_wpml_mixin/adapter.py new file mode 100644 index 000000000..b6974edb9 --- /dev/null +++ b/connector_woocommerce_wpml/models/product_wpml_mixin/adapter.py @@ -0,0 +1,32 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import AbstractComponent + + +# TODO: we shoudn't need to use this if we do component aproach. +class WooCommerceProductWPMLMixinAdapter(AbstractComponent): + _name = "woocommerce.product.wpml.mixin.adapter" + _inherit = "connector.extension.woocommerce.adapter.crud" + + def wpml_get_search_fields(self): + res = super()._get_search_fields() + res.append("lang") + return res + + def wpml_domain_to_normalized_dict(self, real_domain): + domain = super()._domain_to_normalized_dict(real_domain) + if not domain.get("lang"): + domain["lang"] = "all" + return domain + + def wpml_extract_domain_clauses(self, domain, search_fields): + real_domain, common_domain = super()._extract_domain_clauses( + domain, search_fields + ) + # TODO: does the following code needed??? + # lang_clause_exists = any(clause[0] == "lang" for clause in common_domain) + # for clause in domain: + # if "lang" in clause[0] and not lang_clause_exists: + # common_domain.append(clause) + return real_domain, common_domain diff --git a/connector_woocommerce_wpml/models/product_wpml_mixin/binder.py b/connector_woocommerce_wpml/models/product_wpml_mixin/binder.py new file mode 100644 index 000000000..7e20729aa --- /dev/null +++ b/connector_woocommerce_wpml/models/product_wpml_mixin/binder.py @@ -0,0 +1,32 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import AbstractComponent + + +class WooCommerceProductWPMLMixinBinder(AbstractComponent): + _name = "woocommerce.product.wpml.mixin.binder" + _inherit = "connector.extension.binder" + + def wpml_get_binding_domain(self, record): + domain = super().get_binding_domain(record) + wp_wpml_code = self.env["res.lang"]._get_wpml_code_from_iso_code( + record._context.get("lang") + ) + if wp_wpml_code: + domain += [ + ( + "woocommerce_lang", + "=", + wp_wpml_code, + ) + ] + return domain + + def wpml_additional_external_binding_fields(self, external_data, relation): + # TODO: this additional fields probably should be + # included in binding as m2o to res lang on upper binder + return { + **super()._additional_external_binding_fields(external_data, relation), + "woocommerce_lang": external_data["lang"], + } diff --git a/connector_woocommerce_wpml/models/product_wpml_mixin/exporter.py b/connector_woocommerce_wpml/models/product_wpml_mixin/exporter.py new file mode 100644 index 000000000..722b0658f --- /dev/null +++ b/connector_woocommerce_wpml/models/product_wpml_mixin/exporter.py @@ -0,0 +1,38 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import _ +from odoo.exceptions import ValidationError + +from odoo.addons.component.core import AbstractComponent + + +class WooCommerceProductWPMLMixinExporter(AbstractComponent): + _name = "woocommerce.product.wpml.mixin.record.direct.exporter" + _inherit = "connector.extension.record.direct.exporter" + + def wpml_run(self, relation, always=True, internal_fields=None): + res = [] + langs_to_export = self.backend_record.lang_ids.mapped("code") + if not langs_to_export: + raise ValidationError( + _( + "You need to define at least one language to export " + "in the WooCommerce WPML Backend (%s)." + ) + % self.backend_record.name + ) + langs_first_default = sorted( + langs_to_export, + key=lambda lang: (lang != self.backend_record.language_id.code, lang), + ) + first_lang = True + for lang in langs_first_default: + result = super().run( + relation.with_context(lang=lang, first_lang=first_lang), + always=always, + internal_fields=internal_fields, + ) + res.append(result) + if first_lang: + first_lang = False + return res diff --git a/connector_woocommerce_wpml/models/sale_order/__init__.py b/connector_woocommerce_wpml/models/sale_order/__init__.py new file mode 100644 index 000000000..f502287fe --- /dev/null +++ b/connector_woocommerce_wpml/models/sale_order/__init__.py @@ -0,0 +1 @@ +from . import adapter diff --git a/connector_woocommerce_wpml/models/sale_order/adapter.py b/connector_woocommerce_wpml/models/sale_order/adapter.py new file mode 100644 index 000000000..ad23779a6 --- /dev/null +++ b/connector_woocommerce_wpml/models/sale_order/adapter.py @@ -0,0 +1,20 @@ +# Copyright NuoBiT Solutions - Kilian Niubo +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class WooCommerceSaleOrderAdapter(Component): + _name = "woocommerce.sale.order.adapter" + _inherit = "woocommerce.sale.order.adapter" + + def _reorg_order_data(self, values): + res = super()._reorg_order_data(values) + for value in values: + if "meta_data" in value: + for meta_data in value["meta_data"]: + if meta_data["key"] == "wpml_language": + for product in value["products"]: + product["lang"] = meta_data["value"] + continue + return res diff --git a/connector_woocommerce_wpml/pyproject.toml b/connector_woocommerce_wpml/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/connector_woocommerce_wpml/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/connector_woocommerce_wpml/readme/CONFIGURE.md b/connector_woocommerce_wpml/readme/CONFIGURE.md new file mode 100644 index 000000000..1b0246dee --- /dev/null +++ b/connector_woocommerce_wpml/readme/CONFIGURE.md @@ -0,0 +1,17 @@ +## Required WooCommerce Plugin + +This module requires the **WooCommerce WPML API REST Extension** plugin +to function properly. + +Plugin URL: + + +This plugin is necessary to work around several bugs in the WPML REST +API related to: + +- Language parameter handling +- Retrieving language-specific product data +- Setting and updating content per language + +Without this extension, the connector will not be able to properly +synchronize multilingual content between Odoo and WooCommerce. diff --git a/connector_woocommerce_wpml/readme/CONTRIBUTORS.md b/connector_woocommerce_wpml/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..5d535bfca --- /dev/null +++ b/connector_woocommerce_wpml/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [NuoBiT](https://www.nuobit.com): + - Kilian Niubo + - Deniz Gallo diff --git a/connector_woocommerce_wpml/readme/DESCRIPTION.md b/connector_woocommerce_wpml/readme/DESCRIPTION.md new file mode 100644 index 000000000..4f6674cda --- /dev/null +++ b/connector_woocommerce_wpml/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +- This module works with plugin WordPress Multi Language. +- diff --git a/connector_woocommerce_wpml/readme/ROADMAP.md b/connector_woocommerce_wpml/readme/ROADMAP.md new file mode 100644 index 000000000..9b963766e --- /dev/null +++ b/connector_woocommerce_wpml/readme/ROADMAP.md @@ -0,0 +1,6 @@ +For a better maintenance, we can try to use a mixin component to define +the common methods and properties. + +> - binding: woocommerce_lang field and sql_constrains +> woocommerce_internal_uniq overwriting the common for all +> - export_mapper: Include the translation_of and lang? diff --git a/connector_woocommerce_wpml/static/description/icon.png b/connector_woocommerce_wpml/static/description/icon.png new file mode 100644 index 000000000..1cd641e79 Binary files /dev/null and b/connector_woocommerce_wpml/static/description/icon.png differ diff --git a/connector_woocommerce_wpml/static/description/index.html b/connector_woocommerce_wpml/static/description/index.html new file mode 100644 index 000000000..1d653553a --- /dev/null +++ b/connector_woocommerce_wpml/static/description/index.html @@ -0,0 +1,459 @@ + + + + + +Connector WooCommerce WMPL + + + +
+

Connector WooCommerce WMPL

+ + +

Beta License: AGPL-3 NuoBiT/odoo-addons

+ +

Table of contents

+ +
+

Configuration

+
+

Required WooCommerce Plugin

+

This module requires the WooCommerce WPML API REST Extension plugin +to function properly.

+

Plugin URL: +https://github.com/nuobit/woocommerce-wpml-api-rest-extension

+

This plugin is necessary to work around several bugs in the WPML REST +API related to:

+
    +
  • Language parameter handling
  • +
  • Retrieving language-specific product data
  • +
  • Setting and updating content per language
  • +
+

Without this extension, the connector will not be able to properly +synchronize multilingual content between Odoo and WooCommerce.

+
+
+
+

Known issues / Roadmap

+

For a better maintenance, we can try to use a mixin component to define +the common methods and properties.

+
+
    +
  • binding: woocommerce_lang field and sql_constrains +woocommerce_internal_uniq overwriting the common for all
  • +
  • export_mapper: Include the translation_of and lang?
  • +
+
+
+
+

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

+
    +
  • NuoBiT Solutions SL
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the NuoBiT/odoo-addons project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/connector_woocommerce_wpml/views/product_attribute_value.xml b/connector_woocommerce_wpml/views/product_attribute_value.xml new file mode 100644 index 000000000..aab9a6679 --- /dev/null +++ b/connector_woocommerce_wpml/views/product_attribute_value.xml @@ -0,0 +1,38 @@ + + + + + woocommerce.product.attribute.value.view.form + woocommerce.product.attribute.value + + + + + + + + + + + + woocommerce.product.attribute.value.view.list + woocommerce.product.attribute.value + + + + + + + + + + + diff --git a/connector_woocommerce_wpml/views/product_product.xml b/connector_woocommerce_wpml/views/product_product.xml new file mode 100644 index 000000000..b23d7b3b2 --- /dev/null +++ b/connector_woocommerce_wpml/views/product_product.xml @@ -0,0 +1,38 @@ + + + + + woocommerce.product.product.view.form + woocommerce.product.product + + + + + + + + + + + + woocommerce.product.product.view.list + woocommerce.product.product + + + + + + + + + + + diff --git a/connector_woocommerce_wpml/views/product_public_category.xml b/connector_woocommerce_wpml/views/product_public_category.xml new file mode 100644 index 000000000..7212d6575 --- /dev/null +++ b/connector_woocommerce_wpml/views/product_public_category.xml @@ -0,0 +1,38 @@ + + + + + woocommerce.product.public.category.view.form + woocommerce.product.public.category + + + + + + + + + + + + woocommerce.product.public.category.view.list + woocommerce.product.public.category + + + + + + + + + + + diff --git a/connector_woocommerce_wpml/views/product_template.xml b/connector_woocommerce_wpml/views/product_template.xml new file mode 100644 index 000000000..7f99ff767 --- /dev/null +++ b/connector_woocommerce_wpml/views/product_template.xml @@ -0,0 +1,38 @@ + + + + + woocommerce.product.template.view.form + woocommerce.product.template + + + + + + + + + + + + woocommerce.product.template.view.list + woocommerce.product.template + + + + + + + + + + + diff --git a/connector_woocommerce_wpml/views/woocommerce_backend_view.xml b/connector_woocommerce_wpml/views/woocommerce_backend_view.xml new file mode 100644 index 000000000..dd9f0df79 --- /dev/null +++ b/connector_woocommerce_wpml/views/woocommerce_backend_view.xml @@ -0,0 +1,23 @@ + + + + + woocommerce.backend.form + woocommerce.backend + + + + + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..7b4c1eb2f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +woocommerce diff --git a/setup/connector_woocommerce_wpml/odoo/addons/connector_woocommerce_wpml b/setup/connector_woocommerce_wpml/odoo/addons/connector_woocommerce_wpml new file mode 120000 index 000000000..e1b06d23f --- /dev/null +++ b/setup/connector_woocommerce_wpml/odoo/addons/connector_woocommerce_wpml @@ -0,0 +1 @@ +../../../../connector_woocommerce_wpml \ No newline at end of file diff --git a/setup/connector_woocommerce_wpml/setup.py b/setup/connector_woocommerce_wpml/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/connector_woocommerce_wpml/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..2dc13f76a --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,10 @@ +odoo-addon-website_sale_stock_variant@git+https://github.com/nuobit/odoo-addons.git@refs/pull/825/head#subdirectory=website_sale_stock_variant +odoo-addon-website_sale_extra_fields@git+https://github.com/nuobit/odoo-addons.git@refs/pull/826/head#subdirectory=website_sale_extra_fields +odoo-addon-connector_wordpress@git+https://github.com/nuobit/odoo-addons.git@refs/pull/876/head#subdirectory=connector_wordpress +odoo-addon-connector_extension_woocommerce@git+https://github.com/nuobit/odoo-addons.git@refs/pull/875/head#subdirectory=connector_extension_woocommerce +odoo-addon-tools_mimetypes_extension@git+https://github.com/nuobit/odoo-addons.git@refs/pull/878/head#subdirectory=tools_mimetypes_extension +odoo-addon-website_sale_variant@git+https://github.com/nuobit/odoo-addons.git@refs/pull/824/head#subdirectory=website_sale_variant +odoo-addon-website_sale_product_document@git+https://github.com/nuobit/odoo-addons.git@refs/pull/827/head#subdirectory=website_sale_product_document +odoo-addon-connector_extension_wordpress@git+https://github.com/nuobit/odoo-addons.git@refs/pull/877/head#subdirectory=connector_extension_wordpress +odoo-addon-connector_woocommerce@git+https://github.com/nuobit/odoo-addons.git@refs/pull/874/head#subdirectory=connector_woocommerce +odoo-addon-connector_wordpress_wpml@git+https://github.com/nuobit/odoo-addons.git@refs/pull/880/head#subdirectory=connector_wordpress_wpml