diff --git a/connector_prestashop_catalog_manager/README.rst b/connector_prestashop_catalog_manager/README.rst new file mode 100644 index 000000000..8582f7c74 --- /dev/null +++ b/connector_prestashop_catalog_manager/README.rst @@ -0,0 +1,74 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +============================================= +Catalog Manager for Odoo PrestaShop Connector +============================================= + +This module is an extension for *connector_prestashop*. With it, you will be +able to manage your catalog directly from Odoo: + +* Create/modify attributtes and values in Odoo and push then in PrestaShop. +* Create/modify products and push them in PrestaShop. +* Create/modify products variants and push them in PrestaShop (combinations). +* Create/modify category and push them in PrestaShop. +* Create/modify image and push then in PrestaShop. + +Usage +===== + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/108/8.0 + + +Known issues / Roadmap +====================== + +* Tests. + +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 smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* `PrestaShop logo `_. +* `Odoo logo `_. +* `Cable `_. + +Contributors +------------ + +* Sébastien Beau +* Benoît Guillot +* Mikel Arregi +* Sergio Teruel +* Pedro M. Baeza +* Simone Orsi +* Guillaume Masson +* Marc Poch + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/connector_prestashop_catalog_manager/__init__.py b/connector_prestashop_catalog_manager/__init__.py new file mode 100644 index 000000000..7588e52c8 --- /dev/null +++ b/connector_prestashop_catalog_manager/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models +from . import wizards diff --git a/connector_prestashop_catalog_manager/__manifest__.py b/connector_prestashop_catalog_manager/__manifest__.py new file mode 100644 index 000000000..d87c7a59e --- /dev/null +++ b/connector_prestashop_catalog_manager/__manifest__.py @@ -0,0 +1,37 @@ +# Copyright 2011-2013 Camptocamp +# Copyright 2011-2013 Akretion +# Copyright 2015 AvanzOSC +# Copyright 2015-2016 Tecnativa +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Prestashop-Odoo Catalog Manager", + "version": "14.0.1.0.0", + "license": "AGPL-3", + "depends": [ + "connector_prestashop", + "product_categ_image", + "product_multi_image", + "product_brand", + ], + "author": "Akretion," + "AvanzOSC," + "Tecnativa," + "Camptocamp SA," + "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/connector-prestashop", + "category": "Connector", + "data": [ + "views/product_attribute_view.xml", + "views/product_view.xml", + "wizards/export_category_view.xml", + "wizards/export_multiple_products_view.xml", + "wizards/sync_products_view.xml", + "wizards/active_deactive_products_view.xml", + "wizards/export_brand_view.xml", + "views/product_image_view.xml", + "views/product_category_view.xml", + "security/ir.model.access.csv", + ], + "installable": True, +} diff --git a/connector_prestashop_catalog_manager/models/__init__.py b/connector_prestashop_catalog_manager/models/__init__.py new file mode 100644 index 000000000..c04cebf35 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/__init__.py @@ -0,0 +1,10 @@ +# © 2016 Sergio Teruel +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from . import binding +from . import product_brand +from . import product_category +from . import product_product +from . import product_template +from . import product_image +from . import ir_translation diff --git a/connector_prestashop_catalog_manager/models/binding/__init__.py b/connector_prestashop_catalog_manager/models/binding/__init__.py new file mode 100644 index 000000000..c7244de94 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/binding/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2021 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common diff --git a/connector_prestashop_catalog_manager/models/binding/common.py b/connector_prestashop_catalog_manager/models/binding/common.py new file mode 100644 index 000000000..355207005 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/binding/common.py @@ -0,0 +1,16 @@ +# Copyright 2021 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class PrestashopBinding(models.AbstractModel): + _inherit = "prestashop.binding" + + @api.model + def create(self, vals): + ctx = self.env.context.copy() + ctx["catalog_manager_ignore_translation"] = True + res = super(PrestashopBinding, self.with_context(ctx)).create(vals) + + return res diff --git a/connector_prestashop_catalog_manager/models/ir_translation.py b/connector_prestashop_catalog_manager/models/ir_translation.py new file mode 100644 index 000000000..5230ab174 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/ir_translation.py @@ -0,0 +1,46 @@ +# Copyright 2019 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class IrTranslation(models.Model): + _inherit = "ir.translation" + + def write_on_source_model(self): + """Force a write on source model to make catalog_manager + export the translation + """ + for translation in self: + if translation.type == "model": + # get model and ir_field + model, fieldname = translation.name.split(",") + model_obj = translation.env[model] + instance = model_obj.browse(translation.res_id) + instance_vals = instance.read([fieldname])[0] + untranslated_content = instance_vals[fieldname] + instance.with_context(catalog_manager_force_translation=True).write( + {fieldname: untranslated_content} + ) + return True + + def write(self, vals): + res = False + for translation in self: + if translation.env.context.get("catalog_manager_force_translation", False): + continue + res = super().write(vals) + + self.write_on_source_model() + return res + + @api.model + def create(self, vals): + res = super().create(vals) + + if not self.env.context.get("catalog_manager_ignore_translation", False): + # It is called from a binding creation so, + # Once the binding will be created, it will export everything. + # this way we avoid job duplicities exporting the same instance + res.write_on_source_model() + return res diff --git a/connector_prestashop_catalog_manager/models/product_brand/__init__.py b/connector_prestashop_catalog_manager/models/product_brand/__init__.py new file mode 100644 index 000000000..c8f1c4733 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_brand/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2020 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common +from . import exporter +from . import deleter diff --git a/connector_prestashop_catalog_manager/models/product_brand/common.py b/connector_prestashop_catalog_manager/models/product_brand/common.py new file mode 100644 index 000000000..95550895e --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_brand/common.py @@ -0,0 +1,49 @@ +# Copyright 2020 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class PrestashopProductBrandListener(Component): + _name = "prestashop.product.brand.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = "prestashop.product.brand" + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + """Called when a record is created""" + record.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + @skip_if(lambda self, record, **kwargs: self.need_to_export(record, **kwargs)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + record.with_delay().export_record(fields=fields) + + +class ProductBrandListener(Component): + _name = "product.brand.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = "product.brand" + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + for binding in record.prestashop_bind_ids: + if not self.need_to_export(binding, fields): + binding.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_unlink(self, record, fields=None): + """Called when a record is deleted""" + for binding in record.prestashop_bind_ids: + work = self.work.work_on(collection=binding.backend_id) + binder = work.component( + usage="binder", model_name="prestashop.product.brand" + ) + prestashop_id = binder.to_external(binding) + if prestashop_id: + self.env["prestashop.product.brand"].with_delay().export_delete_record( + binding.backend_id, prestashop_id + ) diff --git a/connector_prestashop_catalog_manager/models/product_brand/deleter.py b/connector_prestashop_catalog_manager/models/product_brand/deleter.py new file mode 100644 index 000000000..52fade7d1 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_brand/deleter.py @@ -0,0 +1,12 @@ +# Copyright 2020 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class ProductBrandDeleter(Component): + _name = "prestashop.product.brand.deleter" + _inherit = "prestashop.deleter" + _apply_on = [ + "prestashop.product.brand", + ] diff --git a/connector_prestashop_catalog_manager/models/product_brand/exporter.py b/connector_prestashop_catalog_manager/models/product_brand/exporter.py new file mode 100644 index 000000000..c0bb2e4bf --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_brand/exporter.py @@ -0,0 +1,25 @@ +# Copyright 2020 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping + + +class ProductBrandExporter(Component): + _name = "prestashop.product.brand.exporter" + _inherit = "prestashop.exporter" + _apply_on = "prestashop.product.brand" + + +class ProductBrandExportMapper(Component): + _name = "prestashop.product.brand.export.mapper" + _inherit = "translation.prestashop.export.mapper" + _apply_on = "prestashop.product.brand" + + direct = [ + ("name", "name"), + ] + + @mapping + def active(self, record): + return {"active": 1} diff --git a/connector_prestashop_catalog_manager/models/product_category/__init__.py b/connector_prestashop_catalog_manager/models/product_category/__init__.py new file mode 100644 index 000000000..2c2fc9544 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_category/__init__.py @@ -0,0 +1,3 @@ +from . import common +from . import exporter +from . import deleter diff --git a/connector_prestashop_catalog_manager/models/product_category/common.py b/connector_prestashop_catalog_manager/models/product_category/common.py new file mode 100644 index 000000000..cfeacef54 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_category/common.py @@ -0,0 +1,141 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models +from odoo.tools import config + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if +from odoo.addons.connector_prestashop.components.backend_adapter import ( + PrestaShopWebServiceImage, +) + + +class ProductCategory(models.Model): + _inherit = "product.category" + + prestashop_image_bind_ids = fields.One2many( + comodel_name="prestashop.categ.image", + inverse_name="odoo_id", + copy=False, + string="PrestaShop Image Bindings", + ) + + +class PrestashopCategImage(models.Model): + _name = "prestashop.categ.image" + _inherit = "prestashop.binding.odoo" + _inherits = {"product.category": "odoo_id"} + _description = "Prestashop Category Image" + + odoo_id = fields.Many2one( + comodel_name="product.category", + string="Product", + required=True, + ondelete="cascade", + ) + + +class PrestashopCategImageModelBinder(Component): + _name = "prestashop.categ.image.binder" + _inherit = "prestashop.binder" + _apply_on = "prestashop.categ.image" + + +class CategImageAdapter(Component): + _name = "prestashop.categ.image.adapter" + _inherit = "prestashop.crud.adapter" + _apply_on = "prestashop.categ.image" + _prestashop_image_model = "categories" + + def connect(self): + debug = False + if config["log_level"] == "debug": + debug = True + return PrestaShopWebServiceImage( + self.prestashop.api_url, self.prestashop.webservice_key, debug=debug + ) + + def read(self, category_id, image_id, options=None): + # pylint: disable=method-required-super + api = self.connect() + return api.get_image( + self._prestashop_image_model, category_id, image_id, options=options + ) + + def create(self, attributes=None): + # pylint: disable=method-required-super + api = self.connect() + image_binary = attributes["image"] + img_filename = attributes["name"] + image_url = "images/{}/{}".format( + self._prestashop_image_model, + str(attributes["categ_id"]), + ) + return api.add(image_url, files=[("image", img_filename, image_binary)]) + + def write(self, id_, attributes=None): + # pylint: disable=method-required-super + api = self.connect() + image_binary = attributes["image"] + img_filename = attributes["name"] + delete_url = "images/%s" % (self._prestashop_image_model) + api.delete(delete_url, str(attributes["categ_id"])) + image_url = "images/{}/{}".format( + self._prestashop_image_model, + str(attributes["categ_id"]), + ) + return api.add(image_url, files=[("image", img_filename, image_binary)]) + + +class PrestashopProductCategoryListener(Component): + _name = "prestashop.product.category.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = "prestashop.product.category" + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + """Called when a record is created""" + record.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + @skip_if(lambda self, record, **kwargs: self.need_to_export(record, **kwargs)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + record.with_delay().export_record(fields=fields) + + +class ProductCategoryListener(Component): + _name = "product.category.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = "product.category" + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + for binding in record.prestashop_bind_ids: + if not self.need_to_export(binding, fields): + binding.with_delay().export_record(fields=fields) + if "image" in fields: + if record.prestashop_image_bind_ids: + for image in record.prestashop_image_bind_ids: + image.with_delay().export_record(fields=fields) + else: + for presta_categ in record.prestashop_bind_ids: + image = self.env["prestashop.categ.image"].create( + {"backend_id": presta_categ.backend_id.id, "odoo_id": record.id} + ) + image.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_unlink(self, record, fields=None): + """Called when a record is deleted""" + for binding in record.prestashop_bind_ids: + work = self.work.work_on(collection=binding.backend_id) + binder = work.component( + usage="binder", model_name="prestashop.product.category" + ) + prestashop_id = binder.to_external(binding) + if prestashop_id: + self.env[ + "prestashop.product.category" + ].with_delay().export_delete_record(binding.backend_id, prestashop_id) diff --git a/connector_prestashop_catalog_manager/models/product_category/deleter.py b/connector_prestashop_catalog_manager/models/product_category/deleter.py new file mode 100644 index 000000000..4d8211ee0 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_category/deleter.py @@ -0,0 +1,13 @@ +# Copyright 2018 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# + +from odoo.addons.component.core import Component + + +class ProductCategoryDeleter(Component): + _name = "prestashop.product.category.deleter" + _inherit = "prestashop.deleter" + _apply_on = [ + "prestashop.product.category", + ] diff --git a/connector_prestashop_catalog_manager/models/product_category/exporter.py b/connector_prestashop_catalog_manager/models/product_category/exporter.py new file mode 100644 index 000000000..08c22a9fa --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_category/exporter.py @@ -0,0 +1,99 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import changed_by, mapping + +from ..product_template.exporter import get_slug + + +class ProductCategoryExporter(Component): + _name = "prestashop.product.category.exporter" + _inherit = "prestashop.exporter" + _apply_on = "prestashop.product.category" + + def _export_dependencies(self): + """Export the dependencies for the category""" + category_binder = self.binder_for("prestashop.product.category") + categories_obj = self.env["prestashop.product.category"] + for category in self.binding: + self.export_parent_category( + category.odoo_id.parent_id, category_binder, categories_obj + ) + + def export_parent_category(self, category, binder, ps_categ_obj): + if not category: + return + ext_id = binder.to_external(category.id, wrap=True) + if ext_id: + return ext_id + res = { + "backend_id": self.backend_record.id, + "odoo_id": category.id, + "link_rewrite": get_slug(category.name), + } + category_ext = ps_categ_obj.with_context(connector_no_export=True).create(res) + parent_cat_id = category_ext.export_record() + return parent_cat_id + + +class ProductCategoryExportMapper(Component): + _name = "prestashop.product.category.export.mapper" + _inherit = "translation.prestashop.export.mapper" + _apply_on = "prestashop.product.category" + + direct = [ + ("default_shop_id", "id_shop_default"), + ("active", "active"), + ("position", "position"), + ] + # handled by base mapping `translatable_fields` + _translatable_fields = [ + ("name", "name"), + ("link_rewrite", "link_rewrite"), + ("description", "description"), + ("meta_description", "meta_description"), + ("meta_keywords", "meta_keywords"), + ("meta_title", "meta_title"), + ] + + @changed_by("parent_id") + @mapping + def parent_id(self, record): + if not record["parent_id"]: + return {"id_parent": 2} + category_binder = self.binder_for("prestashop.product.category") + ext_categ_id = category_binder.to_external(record.parent_id.id, wrap=True) + return {"id_parent": ext_categ_id} + + +class CategImageExporter(Component): + _name = "prestashop.product.category.image.exporter" + _inherit = "prestashop.exporter" + _apply_on = "prestashop.categ.image" + + def _create(self, data): + """Create the Prestashop record""" + if self.backend_adapter.create(data): + return 1 + + def _update(self, data): + return 1 + + +class CategImageExportMapper(Component): + _name = "prestashop.product.category.image.mapper" + _inherit = "prestashop.export.mapper" + _apply_on = "prestashop.categ.image" + + @changed_by("image") + @mapping + def image(self, record): + name = record.name.lower() + ".jpg" + return {"image": record["image"], "name": name} + + @changed_by("odoo_id") + @mapping + def odoo_id(self, record): + binder = self.binder_for("prestashop.product.category") + ext_categ_id = binder.to_external(record.odoo_id.id, wrap=True) + return {"categ_id": ext_categ_id} diff --git a/connector_prestashop_catalog_manager/models/product_image/__init__.py b/connector_prestashop_catalog_manager/models/product_image/__init__.py new file mode 100644 index 000000000..2c2fc9544 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_image/__init__.py @@ -0,0 +1,3 @@ +from . import common +from . import exporter +from . import deleter diff --git a/connector_prestashop_catalog_manager/models/product_image/common.py b/connector_prestashop_catalog_manager/models/product_image/common.py new file mode 100644 index 000000000..b5744d443 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_image/common.py @@ -0,0 +1,51 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class ProductImage(models.Model): + _inherit = "base_multi_image.image" + + front_image = fields.Boolean(string="Front image") + + +class PrestashopProductImageListener(Component): + _name = "prestashop.product.image.event.listener" + _inherit = "base.connector.listener" + _apply_on = "base_multi_image.image" + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + for binding in record.prestashop_bind_ids: + binding.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_unlink(self, record, fields=None): + """Called when a record is deleted""" + for binding in record.prestashop_bind_ids: + product = self.env[record.owner_model].browse(record.owner_id) + if product.exists(): + template = product.prestashop_bind_ids.filtered( + lambda x: x.backend_id == binding.backend_id + ) + if not template: + return + + work = self.work.work_on(collection=binding.backend_id) + binder = work.component( + usage="binder", model_name="prestashop.product.image" + ) + prestashop_id = binder.to_external(binding) + attributes = { + "id_product": template.prestashop_id, + } + if prestashop_id: + self.env[ + "prestashop.product.image" + ].with_delay().export_delete_record( + binding.backend_id, prestashop_id, attributes + ) diff --git a/connector_prestashop_catalog_manager/models/product_image/deleter.py b/connector_prestashop_catalog_manager/models/product_image/deleter.py new file mode 100644 index 000000000..0449a19e0 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_image/deleter.py @@ -0,0 +1,10 @@ +# Copyright 2018 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class ProductImageDeleter(Component): + _name = "prestashop.product.image.deleter" + _inherit = "prestashop.deleter" + _apply_on = "prestashop.product.image" diff --git a/connector_prestashop_catalog_manager/models/product_image/exporter.py b/connector_prestashop_catalog_manager/models/product_image/exporter.py new file mode 100644 index 000000000..c0a5cb55a --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_image/exporter.py @@ -0,0 +1,162 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import os +import os.path + +from odoo.tools.translate import _ + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping +from odoo.addons.connector_prestashop.components.backend_adapter import ( + PrestaShopWebServiceImage, +) + + +class ProductImageExporter(Component): + _name = "prestashop.product.image.exporter" + _inherit = "prestashop.exporter" + _apply_on = "prestashop.product.image" + + def _run(self, fields=None): + """Flow of the synchronization, implemented in inherited classes""" + assert self.binding_id + assert self.binding + + if self._has_to_skip(): + return + + # export the missing linked resources + self._export_dependencies() + map_record = self.mapper.map_record(self.binding) + + if self.prestashop_id: + record = list(map_record.values()) + if not record: + return _("Nothing to export.") + # special check on data before export + self._validate_data(record) + exported_vals = self._update(record) + else: + record = map_record.values(for_create=True) + if not record: + return _("Nothing to export.") + # special check on data before export + self._validate_data(record) + exported_vals = self._create(record) + self._after_export() + if ( + exported_vals + and exported_vals.get("prestashop") + and exported_vals["prestashop"].get("image") + ): + self.prestashop_id = int(exported_vals["prestashop"]["image"].get("id")) + + self._link_image_to_url() + message = _("Record exported with ID %s on Prestashop.") + return message % self.prestashop_id + + def _link_image_to_url(self): + """Change image storage to a url linked to product prestashop image""" + api = PrestaShopWebServiceImage( + api_url=self.backend_record.location, + api_key=self.backend_record.webservice_key, + ) + full_public_url = api.get_image_public_url( + { + "id_image": str(self.prestashop_id), + "type": "image/jpeg", + } + ) + if self.binding.url != full_public_url: + self.binding.with_context(connector_no_export=True).write( + { + "url": full_public_url, + "file_db_store": False, + "storage": "url", + } + ) + + +class ProductImageExportMapper(Component): + _name = "prestashop.product.image.mapper" + _inherit = "prestashop.export.mapper" + _apply_on = "prestashop.product.image" + + direct = [ + ("name", "name"), + ] + + def _get_file_name(self, record): + """ + Get file name with extension from depending storage. + :param record: browse record + :return: string: file name.extension. + """ + file_name = record.odoo_id.filename + if not file_name: + storage = record.odoo_id.storage + if storage == "url": + file_name = os.path.splitext(os.path.basename(record.odoo_id.url)) + elif storage == "db": + if not record.odoo_id.filename: + file_name = "{}_{}.jpg".format( + record.odoo_id.owner_model, + record.odoo_id.owner_id, + ) + file_name = os.path.splitext( + os.path.basename(record.odoo_id.filename or file_name) + ) + elif storage == "file": + file_name = os.path.splitext(os.path.basename(record.odoo_id.path)) + elif storage == "filestore": + mimetype = record.odoo_id.attachment_id.mimetype + if "/" in mimetype: + ext = mimetype.split("/")[-1] + else: + ext = mimetype + if ext == "jpeg": + ext = "jpg" + file_name = [record.odoo_id.attachment_id.res_name, ext] + return file_name + + @mapping + def source_image(self, record): + content = getattr( + record.odoo_id, "_get_image_from_%s" % record.odoo_id.storage + )() + return {"content": content} + + @mapping + def product_id(self, record): + if record.odoo_id.owner_model == "product.product": + product_tmpl = ( + record.env["product.product"] + .browse(record.odoo_id.owner_id) + .product_tmpl_id + ) + else: + product_tmpl = record.env["product.template"].browse( + record.odoo_id.owner_id + ) + binder = self.binder_for("prestashop.product.template") + ps_product_id = binder.to_external(product_tmpl, wrap=True) + return {"id_product": ps_product_id} + + @mapping + def extension(self, record): + return {"extension": self._get_file_name(record)[1]} + + @mapping + def legend(self, record): + return {"legend": record.name} + + @mapping + def filename(self, record): + file_name = record.filename + if not file_name: + name_tuple = self._get_file_name(record) + if name_tuple[1].startswith("."): + file_name = "".join(name_tuple) + else: + file_name = ".".join(name_tuple) + return {"filename": file_name} diff --git a/connector_prestashop_catalog_manager/models/product_product/__init__.py b/connector_prestashop_catalog_manager/models/product_product/__init__.py new file mode 100644 index 000000000..bc9719d7f --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_product/__init__.py @@ -0,0 +1,3 @@ +from . import exporter +from . import common +from . import deleter diff --git a/connector_prestashop_catalog_manager/models/product_product/common.py b/connector_prestashop_catalog_manager/models/product_product/common.py new file mode 100644 index 000000000..cb86f20a1 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_product/common.py @@ -0,0 +1,160 @@ +# © 2016 Sergio Teruel +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from odoo import fields, models + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if +from odoo.addons.connector_prestashop.models.product_template.common import ( + PrestashopProductQuantityListener, +) + + +class PrestashopProductCombination(models.Model): + _inherit = "prestashop.product.combination" + minimal_quantity = fields.Integer( + string="Minimal Quantity", + default=1, + help="Minimal Sale quantity", + ) + + +class PrestashopProductProductListener(Component): + _name = "prestashop.product.product.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = "prestashop.product.combination" + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + """Called when a record is created""" + record.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + @skip_if(lambda self, record, **kwargs: self.need_to_export(record, **kwargs)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + inventory_fields = PrestashopProductQuantityListener._get_inventory_fields(self) + fields = list(set(fields).difference(set(inventory_fields))) + if fields: + record.with_delay().export_record(fields=fields) + + +class ProductProductListener(Component): + _name = "product.product.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = "product.product" + + EXCLUDE_FIELDS = ["list_price"] + + def prestashop_product_combination_unlink(self, record): + # binding is deactivate when deactive a product variant + for binding in record.prestashop_combinations_bind_ids: + work = self.work.work_on(collection=binding.backend_id) + binder = work.component( + usage="binder", model_name="prestashop.product.combination" + ) + prestashop_id = binder.to_external(binding) + binding.with_delay().export_delete_record(binding.backend_id, prestashop_id) + record.prestashop_combinations_bind_ids.unlink() + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + for field in self.EXCLUDE_FIELDS: + if field in fields: + fields.remove(field) + if "active" in fields and not record["active"]: + self.prestashop_product_combination_unlink(record) + return + if fields: + priority = 20 + if "default_on" in fields and record["default_on"]: + # PS has to uncheck actual default combination first + priority = 99 + for binding in record.prestashop_combinations_bind_ids: + if not self.need_to_export(binding, fields): + binding.with_delay(priority=priority).export_record(fields=fields) + + def on_product_price_changed(self, record): + fields = ["standard_price", "impact_price", "lst_price", "list_price"] + for binding in record.prestashop_combinations_bind_ids: + if not self.need_to_export(binding, fields): + binding.with_delay(priority=20).export_record(fields=fields) + + +class PrestashopAttributeListener(Component): + _name = "prestashop.attribute.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = [ + "prestashop.product.combination.option", + "prestashop.product.combination.option.value", + ] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + """Called when a record is created""" + record.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + @skip_if(lambda self, record, **kwargs: self.need_to_export(record, **kwargs)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + record.with_delay().export_record(fields=fields) + + +class AttributeListener(Component): + _name = "attribute.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = [ + "product.attribute", + ] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + for binding in record.prestashop_bind_ids: + if not self.need_to_export(binding, fields): + binding.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_unlink(self, record, fields=None): + """Called when a record is deleted""" + for binding in record.prestashop_bind_ids: + work = self.work.work_on(collection=binding.backend_id) + binder = work.component( + usage="binder", model_name="prestashop.product.combination.option" + ) + prestashop_id = binder.to_external(binding) + if prestashop_id: + self.env[ + "prestashop.product.combination.option" + ].with_delay().export_delete_record(binding.backend_id, prestashop_id) + + +class AttributeValueListener(Component): + _name = "attribute.value.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = [ + "product.attribute.value", + ] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + for binding in record.prestashop_bind_ids: + if not self.need_to_export(binding, fields): + binding.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_unlink(self, record, fields=None): + """Called when a record is deleted""" + for binding in record.prestashop_bind_ids: + work = self.work.work_on(collection=binding.backend_id) + binder = work.component( + usage="binder", model_name="prestashop.product.combination.option.value" + ) + prestashop_id = binder.to_external(binding) + if prestashop_id: + self.env[ + "prestashop.product.combination.option.value" + ].with_delay().export_delete_record(binding.backend_id, prestashop_id) diff --git a/connector_prestashop_catalog_manager/models/product_product/deleter.py b/connector_prestashop_catalog_manager/models/product_product/deleter.py new file mode 100644 index 000000000..148a29d6c --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_product/deleter.py @@ -0,0 +1,19 @@ +# Copyright 2018 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class ProductCombinationDeleter(Component): + _name = "prestashop.product.combination.deleter" + _inherit = "prestashop.deleter" + _apply_on = "prestashop.product.combination" + + +class ProductCombinationOptionDeleter(Component): + _name = "prestashop.product.combination.option.deleter" + _inherit = "prestashop.deleter" + _apply_on = [ + "prestashop.product.combination.option", + "prestashop.product.combination.option.value", + ] diff --git a/connector_prestashop_catalog_manager/models/product_product/exporter.py b/connector_prestashop_catalog_manager/models/product_product/exporter.py new file mode 100644 index 000000000..1d6ce4acd --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_product/exporter.py @@ -0,0 +1,293 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import changed_by, mapping + +_logger = logging.getLogger(__name__) + + +class ProductCombinationExporter(Component): + _name = "prestashop.product.combination.exporter" + _inherit = "translation.prestashop.exporter" + _apply_on = "prestashop.product.combination" + + def _create(self, record): + """ + :param record: browse record to create in prestashop + :return integer: Prestashop record id + """ + res = super()._create(record) + return res["prestashop"]["combination"]["id"] + + def _export_images(self): + if self.binding.image_ids: + image_binder = self.binder_for("prestashop.product.image") + for image_line in self.binding.image_ids: + image_ext_id = image_binder.to_external(image_line.id, wrap=True) + if not image_ext_id: + image_ext = ( + self.env["prestashop.product.image"] + .with_context(connector_no_export=True) + .create( + { + "backend_id": self.backend_record.id, + "odoo_id": image_line.id, + } + ) + .id + ) + image_content = getattr( + image_line, "_get_image_from_%s" % image_line.storage + )() + image_ext.export_record(image_content) + + def _export_dependencies(self): + """Export the dependencies for the product""" + # TODO add export of category + attribute_binder = self.binder_for("prestashop.product.combination.option") + option_binder = self.binder_for("prestashop.product.combination.option.value") + Option = self.env["prestashop.product.combination.option"] + OptionValue = self.env["prestashop.product.combination.option.value"] + for value in self.binding.product_template_attribute_value_ids: + prestashop_option_id = attribute_binder.to_external( + value.attribute_id.id, wrap=True + ) + if not prestashop_option_id: + option_binding = Option.search( + [ + ("backend_id", "=", self.backend_record.id), + ("odoo_id", "=", value.attribute_id.id), + ] + ) + if not option_binding: + option_binding = Option.with_context( + connector_no_export=True + ).create( + { + "backend_id": self.backend_record.id, + "odoo_id": value.attribute_id.id, + } + ) + option_binding.export_record() + prestashop_value_id = option_binder.to_external( + value.product_attribute_value_id.id, wrap=True + ) + if not prestashop_value_id: + value_binding = OptionValue.search( + [ + ("backend_id", "=", self.backend_record.id), + ("odoo_id", "=", value.id), + ] + ) + if not value_binding: + option_binding = Option.search( + [ + ("backend_id", "=", self.backend_record.id), + ("odoo_id", "=", value.attribute_id.id), + ] + ) + value_binding = OptionValue.with_context( + connector_no_export=True + ).create( + { + "backend_id": self.backend_record.id, + "odoo_id": value.product_attribute_value_id.id, + "id_attribute_group": option_binding.id, + } + ) + value_binding.export_record() + # self._export_images() + + def update_quantities(self): + self.binding.odoo_id.with_context(self.env.context).update_prestashop_qty() + + def _after_export(self): + self.update_quantities() + + +class ProductCombinationExportMapper(Component): + _name = "prestashop.product.combination.export.mapper" + _inherit = "translation.prestashop.export.mapper" + _apply_on = "prestashop.product.combination" + + direct = [ + ("default_code", "reference"), + ("active", "active"), + ("barcode", "ean13"), + ("minimal_quantity", "minimal_quantity"), + ("weight", "weight"), + ] + + def _get_factor_tax(self, tax): + factor_tax = tax.price_include and (1 + tax.amount / 100) or 1.0 + return factor_tax + + @mapping + def combination_default(self, record): + return {"default_on": int(record["default_on"])} + + def get_main_template_id(self, record): + template_binder = self.binder_for("prestashop.product.template") + return template_binder.to_external(record.main_template_id.id) + + @mapping + def main_template_id(self, record): + return {"id_product": self.get_main_template_id(record)} + + @changed_by("impact_price") + @mapping + def _unit_price_impact(self, record): + pricelist = record.backend_id.pricelist_id + if pricelist: + tmpl_prices = pricelist.get_products_price( + [record.odoo_id.product_tmpl_id], [1.0], [None] + ) + tmpl_price = tmpl_prices.get(record.odoo_id.product_tmpl_id.id) + product_prices = pricelist.get_products_price( + [record.odoo_id], [1.0], [None] + ) + product_price = product_prices.get(record.odoo_id.id) + extra_to_export = product_price - tmpl_price + else: + extra_to_export = record.impact_price + tax = record.taxes_id[:1] + if tax.price_include and tax.amount_type == "percent": + # 6 is the rounding precision used by PrestaShop for the + # tax excluded price. we can get back a 2 digits tax included + # price from the 6 digits rounded value + return {"price": round(extra_to_export / self._get_factor_tax(tax), 6)} + else: + return {"price": extra_to_export} + + @changed_by("standard_price") + @mapping + def cost_price(self, record): + wholesale_price = float("{:.2f}".format(record.standard_price)) + return {"wholesale_price": wholesale_price} + + def _get_product_option_value(self, record): + option_value = [] + option_binder = self.binder_for("prestashop.product.combination.option.value") + for value in record.product_template_attribute_value_ids: + value_ext_id = option_binder.to_external( + value.product_attribute_value_id.id, wrap=True + ) + if value_ext_id: + option_value.append({"id": value_ext_id}) + return option_value + + def _get_combination_image(self, record): + images = [] + image_binder = self.binder_for("prestashop.product.image") + for image in record.image_ids: + image_ext_id = image_binder.to_external(image.id, wrap=True) + if image_ext_id: + images.append({"id": image_ext_id}) + return images + + @changed_by("product_template_attribute_value_ids", "image_ids") + @mapping + def associations(self, record): + return { + "associations": { + "product_option_values": { + "product_option_value": self._get_product_option_value(record) + }, + "images": {"image": self._get_combination_image(record)}, + } + } + + @mapping + def low_stock_alert(self, record): + low_stock_alert = False + if record.product_tmpl_id.prestashop_bind_ids: + for presta_prod_tmpl in record.product_tmpl_id.prestashop_bind_ids: + if presta_prod_tmpl.low_stock_alert: + low_stock_alert = True + break + return {"low_stock_alert": "1" if low_stock_alert else "0"} + + +class ProductCombinationOptionExporter(Component): + _name = "prestashop.product.combination.option.exporter" + _inherit = "prestashop.exporter" + _apply_on = "prestashop.product.combination.option" + + def _create(self, record): + res = super()._create(record) + return res["prestashop"]["product_option"]["id"] + + +class ProductCombinationOptionExportMapper(Component): + _name = "prestashop.product.combination.option.export.mapper" + _inherit = "translation.prestashop.export.mapper" + _apply_on = "prestashop.product.combination.option" + + direct = [ + ("prestashop_position", "position"), + ("display_type", "group_type"), + ] + + _translatable_fields = [ + ("name", "name"), + ("name", "public_name"), + ] + + +class ProductCombinationOptionValueExporter(Component): + _name = "prestashop.product.combination.option.value.exporter" + _inherit = "prestashop.exporter" + _apply_on = "prestashop.product.combination.option.value" + + def _create(self, record): + res = super()._create(record) + return res["prestashop"]["product_option_value"]["id"] + + def _export_dependencies(self): + """Export the dependencies for the record""" + attribute_id = self.binding.attribute_id.id + # export product attribute + attr_model = "prestashop.product.combination.option" + binder = self.binder_for(attr_model) + if not binder.to_external(attribute_id, wrap=True): + with self.backend_id.work_on(attr_model) as work: + exporter = work.component(usage="record.exporter") + exporter.run(attribute_id) + return + + +class ProductCombinationOptionValueExportMapper(Component): + _name = "prestashop.product.combination.option.value.export.mapper" + _inherit = "translation.prestashop.export.mapper" + _apply_on = "prestashop.product.combination.option.value" + + direct = [ + ("name", "value"), + ("prestashop_position", "position"), + ] + # handled by base mapping `translatable_fields` + _translatable_fields = [ + ("name", "name"), + ] + + @mapping + def prestashop_product_attribute_id(self, record): + attribute_binder = self.binder_for( + "prestashop.product.combination.option.value" + ) + return { + "id_feature": attribute_binder.to_external( + record.attribute_id.id, wrap=True + ) + } + + @mapping + def prestashop_product_group_attribute_id(self, record): + attribute_binder = self.binder_for("prestashop.product.combination.option") + return { + "id_attribute_group": attribute_binder.to_external( + record.attribute_id.id, wrap=True + ), + } diff --git a/connector_prestashop_catalog_manager/models/product_template/__init__.py b/connector_prestashop_catalog_manager/models/product_template/__init__.py new file mode 100644 index 000000000..2c2fc9544 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_template/__init__.py @@ -0,0 +1,3 @@ +from . import common +from . import exporter +from . import deleter diff --git a/connector_prestashop_catalog_manager/models/product_template/common.py b/connector_prestashop_catalog_manager/models/product_template/common.py new file mode 100644 index 000000000..e5b8017c5 --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_template/common.py @@ -0,0 +1,125 @@ +# © 2016 Sergio Teruel +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from odoo import fields, models + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class PrestashopProductTemplate(models.Model): + _inherit = "prestashop.product.template" + + meta_title = fields.Char(string="Meta Title", translate=True) + meta_description = fields.Char(string="Meta Description", translate=True) + meta_keywords = fields.Char(string="Meta Keywords", translate=True) + tags = fields.Char(string="Tags", translate=True) + online_only = fields.Boolean(string="Online Only") + additional_shipping_cost = fields.Float( + string="Additional Shipping Price", + digits="Product Price", + help="Additionnal Shipping Price for the product on Prestashop", + ) + available_now = fields.Char(string="Available Now", translate=True) + available_later = fields.Char(string="Available Later", translate=True) + available_date = fields.Date(string="Available Date") + minimal_quantity = fields.Integer( + string="Minimal Quantity", + help="Minimal Sale quantity", + default=1, + ) + state = fields.Boolean(string="State", default=True) + visibility = fields.Char(translate=True) + low_stock_threshold = fields.Integer( + string="Low Stock Threshold", + help="Low Stock Threshold", + default=0, + ) + low_stock_alert = fields.Integer( + string="Low Stock Alert", + help="Low Stock Alert", + default=0, + ) + + +class PrestashopProductTemplateListener(Component): + _name = "prestashop.product.template.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = "prestashop.product.template" + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + """Called when a record is created""" + record.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + @skip_if(lambda self, record, **kwargs: self.need_to_export(record, **kwargs)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + record.with_delay().export_record(fields=fields) + if "minimal_quantity" in fields: + record.product_variant_ids.mapped( + "prestashop_combinations_bind_ids" + ).filtered(lambda cb: cb.backend_id == record.backend_id).write( + {"minimal_quantity": record.minimal_quantity} + ) + + +class ProductTemplateListener(Component): + _name = "product.template.event.listener" + _inherit = "prestashop.connector.listener" + _apply_on = "product.template" + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + """Called when a record is written""" + for binding in record.prestashop_bind_ids: + # when assigning a product.attribute to a product.template, + # write is called 2 times. + # To avoid duplicates entries on Prestashop, ignore the empty write + if fields and not self.need_to_export(binding, fields): + binding.with_delay().export_record(fields=fields) + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_unlink(self, record, fields=None): + """Called when a record is deleted""" + for binding in record.prestashop_bind_ids: + work = self.work.work_on(collection=binding.backend_id) + binder = work.component( + usage="binder", model_name="prestashop.product.template" + ) + prestashop_id = binder.to_external(binding) + if prestashop_id: + self.env[ + "prestashop.product.template" + ].with_delay().export_delete_record(binding.backend_id, prestashop_id) + + +class TemplateAdapter(Component): + _inherit = "prestashop.product.template.adapter" + + def write(self, id_, attributes=None): + # Prestashop wants all product data: + prestashop_data = self.client.get(self._prestashop_model, id_) + + # Remove read-only fields: + prestashop_data["product"].pop("manufacturer_name", False) + prestashop_data["product"].pop("quantity", False) + + # Remove position_in_category to avoid these PrestaShop issues: + # https://github.com/PrestaShop/PrestaShop/issues/14903 + # https://github.com/PrestaShop/PrestaShop/issues/15380 + prestashop_data["product"].pop("position_in_category", False) + + full_attributes = prestashop_data["product"].copy() + fa_assoc = full_attributes["associations"] + for field in attributes: + if field != "associations": + full_attributes[field] = attributes[field] + continue + for association, value in attributes["associations"].items(): + fa_assoc[association] = value + + res = super().write(id_, full_attributes) + + return res diff --git a/connector_prestashop_catalog_manager/models/product_template/deleter.py b/connector_prestashop_catalog_manager/models/product_template/deleter.py new file mode 100644 index 000000000..f5201652b --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_template/deleter.py @@ -0,0 +1,13 @@ +# Copyright 2018 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# + +from odoo.addons.component.core import Component + + +class ProductCombinationOptionDeleter(Component): + _name = "prestashop.product.template.deleter" + _inherit = "prestashop.deleter" + _apply_on = [ + "prestashop.product.template", + ] diff --git a/connector_prestashop_catalog_manager/models/product_template/exporter.py b/connector_prestashop_catalog_manager/models/product_template/exporter.py new file mode 100644 index 000000000..117891afe --- /dev/null +++ b/connector_prestashop_catalog_manager/models/product_template/exporter.py @@ -0,0 +1,334 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import re +import unicodedata +from datetime import timedelta + +from odoo import fields +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import changed_by, m2o_to_external, mapping + +try: + import slugify as slugify_lib +except ImportError: + slugify_lib = None + + +def get_slug(name): + if slugify_lib: + try: + return slugify_lib.slugify(name) + except TypeError: + pass + uni = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[\W_]", " ", uni).strip().lower() + slug = re.sub(r"[-\s]+", "-", slug) + return slug + + +class ProductTemplateExporter(Component): + _name = "prestashop.product.template.exporter" + _inherit = "translation.prestashop.exporter" + _apply_on = "prestashop.product.template" + + def _create(self, record): + res = super()._create(record) + self.write_binging_vals(self.binding, record) + return res["prestashop"]["product"]["id"] + + def _update(self, data): + """Update an Prestashop record""" + assert self.prestashop_id + self.check_images() + self.backend_adapter.write(self.prestashop_id, data) + + def write_binging_vals(self, erp_record, ps_record): + keys_to_update = [ + ("description_short_html", "description_short"), + ("description_html", "description"), + ] + trans = self.component(usage="record.importer") + splitted_record = trans._split_per_language(ps_record) + for lang_code, prestashop_record in list(splitted_record.items()): + vals = {} + for key in keys_to_update: + vals[key[0]] = prestashop_record[key[1]] + erp_record.with_context(connector_no_export=True, lang=lang_code).write( + vals + ) + + def export_categories(self, category): + if not category: + return + category_binder = self.binder_for("prestashop.product.category") + ext_id = category_binder.to_external(category, wrap=True) + if ext_id: + return ext_id + + ps_categ_obj = self.env["prestashop.product.category"] + position_cat_id = ps_categ_obj.search([], order="position desc", limit=1) + obj_position = position_cat_id.position + 1 + res = { + "backend_id": self.backend_record.id, + "odoo_id": category.id, + "link_rewrite": get_slug(category.name), + "position": obj_position, + } + binding = ps_categ_obj.with_context(connector_no_export=True).create(res) + binding.export_record() + + def _parent_length(self, categ): + if not categ.parent_id: + return 1 + else: + return 1 + self._parent_length(categ.parent_id) + + def export_brand(self, brand): + if not brand: + return + brand_binder = self.binder_for("prestashop.product.brand") + ext_id = brand_binder.to_external(brand, wrap=True) + if ext_id: + return ext_id + + ps_brand_obj = self.env["prestashop.product.brand"] + res = { + "backend_id": self.backend_record.id, + "odoo_id": brand.id, + "link_rewrite": get_slug(brand.name), + } + binding = ps_brand_obj.with_context(connector_no_export=True).create(res) + binding.export_record() + + def _export_dependencies(self): + """Export the dependencies for the product""" + super()._export_dependencies() + attribute_binder = self.binder_for("prestashop.product.combination.option") + option_binder = self.binder_for("prestashop.product.combination.option.value") + + for category in self.binding.categ_ids: + self.export_categories(category) + + self.export_brand(self.binding.product_brand_id) + + for line in self.binding.attribute_line_ids: + attribute_ext_id = attribute_binder.to_external( + line.attribute_id, wrap=True + ) + if not attribute_ext_id: + self._export_dependency( + line.attribute_id, "prestashop.product.combination.option" + ) + for value in line.value_ids: + value_ext_id = option_binder.to_external(value, wrap=True) + if not value_ext_id: + self._export_dependency( + value, "prestashop.product.combination.option.value" + ) + + def export_variants(self): + combination_obj = self.env["prestashop.product.combination"] + for product in self.binding.product_variant_ids: + if not product.product_template_attribute_value_ids: + continue + combination_ext = combination_obj.search( + [ + ("backend_id", "=", self.backend_record.id), + ("odoo_id", "=", product.id), + ] + ) + if not combination_ext: + combination_ext = combination_obj.with_context( + connector_no_export=True + ).create( + { + "backend_id": self.backend_record.id, + "odoo_id": product.id, + "main_template_id": self.binding_id, + } + ) + # If a template has been modified then always update PrestaShop + # combinations + combination_ext.with_delay( + priority=50, eta=timedelta(seconds=20) + ).export_record() + + def _not_in_variant_images(self, image): + images = [] + if len(self.binding.product_variant_ids) > 1: + for product in self.binding.product_variant_ids: + images.extend(product.image_ids.ids) + return image.id not in images + + def check_images(self): + if self.binding.image_ids: + image_binder = self.binder_for("prestashop.product.image") + for image in self.binding.image_ids: + image_ext_id = image_binder.to_external(image, wrap=True) + # `image_ext_id` is ZERO as long as the image is not exported. + # Here we delay the export so, + # if we don't check this we create 2 records to be sync'ed + # and this leads to: + # ValueError: + # Expected singleton: prestashop.product.image(x, y) + if image_ext_id is None: + image_ext = ( + self.env["prestashop.product.image"] + .with_context(connector_no_export=True) + .create( + { + "backend_id": self.backend_record.id, + "odoo_id": image.id, + } + ) + ) + image_ext.with_delay(priority=5).export_record() + + def update_quantities(self): + if len(self.binding.product_variant_ids) == 1: + product = self.binding.odoo_id.product_variant_ids[0] + product.update_prestashop_quantities() + + def _after_export(self): + self.check_images() + self.export_variants() + self.update_quantities() + if not self.binding.date_add: + self.binding.with_context( + connector_no_export=True + ).date_add = fields.Datetime.now() + + +class ProductTemplateExportMapper(Component): + _name = "prestashop.product.template.export.mapper" + _inherit = "translation.prestashop.export.mapper" + _apply_on = "prestashop.product.template" + + direct = [ + ("available_for_order", "available_for_order"), + ("show_price", "show_price"), + ("online_only", "online_only"), + ("weight", "weight"), + ("standard_price", "wholesale_price"), + (m2o_to_external("default_shop_id"), "id_shop_default"), + ("always_available", "active"), + ("barcode", "barcode"), + ("additional_shipping_cost", "additional_shipping_cost"), + ("minimal_quantity", "minimal_quantity"), + ("on_sale", "on_sale"), + ("date_add", "date_add"), + ("barcode", "ean13"), + ( + m2o_to_external( + "prestashop_default_category_id", binding="prestashop.product.category" + ), + "id_category_default", + ), + ("state", "state"), + ("low_stock_threshold", "low_stock_threshold"), + ("default_code", "reference"), + ( + m2o_to_external("product_brand_id", binding="prestashop.product.brand"), + "id_manufacturer", + ), + ("visibility", "visibility"), + ] + # handled by base mapping `translatable_fields` + _translatable_fields = [ + ("name", "name"), + ("link_rewrite", "link_rewrite"), + ("meta_title", "meta_title"), + ("meta_description", "meta_description"), + ("meta_keywords", "meta_keywords"), + ("tags", "tags"), + ("available_now", "available_now"), + ("available_later", "available_later"), + ("description_short_html", "description_short"), + ("description_html", "description"), + ] + + def _get_factor_tax(self, tax): + return (1 + tax.amount / 100) if tax.price_include else 1.0 + + @changed_by("taxes_id", "list_price") + @mapping + def list_price(self, record): + tax = record.taxes_id + pricelist = record.backend_id.pricelist_id + if pricelist: + prices = pricelist.get_products_price([record.odoo_id], [1.0], [None]) + price_to_export = prices.get(record.odoo_id.id) + else: + price_to_export = record.list_price + if tax.price_include and tax.amount_type == "percent": + # 6 is the rounding precision used by PrestaShop for the + # tax excluded price. we can get back a 2 digits tax included + # price from the 6 digits rounded value + return {"price": str(round(price_to_export / self._get_factor_tax(tax), 6))} + else: + return {"price": str(price_to_export)} + + def _get_product_category(self, record): + ext_categ_ids = [] + binder = self.binder_for("prestashop.product.category") + for category in record.categ_ids: + ext_categ_ids.append({"id": binder.to_external(category, wrap=True)}) + return ext_categ_ids + + def _get_product_image(self, record): + ext_image_ids = [] + binder = self.binder_for("prestashop.product.image") + for image in record.image_ids: + ext_image_ids.append({"id": binder.to_external(image, wrap=True)}) + return ext_image_ids + + @changed_by( + "attribute_line_ids", + "categ_ids", + "categ_id", + "image_ids", + ) + @mapping + def associations(self, record): + return { + "associations": { + "categories": {"category_id": self._get_product_category(record)}, + "images": {"image": self._get_product_image(record)}, + } + } + + @changed_by("taxes_id") + @mapping + def tax_ids(self, record): + if not record.taxes_id: + return + binder = self.binder_for("prestashop.account.tax.group") + ext_id = binder.to_external(record.taxes_id[:1].tax_group_id, wrap=True) + return {"id_tax_rules_group": ext_id} + + @changed_by("available_date") + @mapping + def available_date(self, record): + if record.available_date: + return {"available_date": record.available_date.strftime("%Y-%m-%d")} + return {} + + @mapping + def date_add(self, record): + # When export a record the date_add in PS is null. + return {"date_add": record.create_date.strftime(DEFAULT_SERVER_DATETIME_FORMAT)} + + @mapping + def default_image(self, record): + default_image = record.image_ids.filtered("front_image")[:1] + if default_image: + binder = self.binder_for("prestashop.product.image") + ps_image_id = binder.to_external(default_image, wrap=True) + if ps_image_id: + return {"id_default_image": ps_image_id} + + @mapping + def low_stock_alert(self, record): + return {"low_stock_alert": "1" if record.low_stock_alert else "0"} diff --git a/connector_prestashop_catalog_manager/readme/CREDITS.rst b/connector_prestashop_catalog_manager/readme/CREDITS.rst new file mode 100644 index 000000000..f24fcbf9d --- /dev/null +++ b/connector_prestashop_catalog_manager/readme/CREDITS.rst @@ -0,0 +1 @@ +The migration of this module from 10.0 to 14.0 was financially supported by Camptocamp. diff --git a/connector_prestashop_catalog_manager/readme/USAGE.rst b/connector_prestashop_catalog_manager/readme/USAGE.rst new file mode 100644 index 000000000..d631bf845 --- /dev/null +++ b/connector_prestashop_catalog_manager/readme/USAGE.rst @@ -0,0 +1,3 @@ +Inventory > Configuration + > Settings > Search Location > Enable + > Location > WH/Stock > Sync with Prestashop diff --git a/connector_prestashop_catalog_manager/security/ir.model.access.csv b/connector_prestashop_catalog_manager/security/ir.model.access.csv new file mode 100644 index 000000000..cb1d83273 --- /dev/null +++ b/connector_prestashop_catalog_manager/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_prestashop_product_category_image_user,User access on prestashop.categ.image,model_prestashop_categ_image,base.group_user,1,0,0,0 +access_prestashop_product_category_image_full,Full access on prestashop.categ.image,model_prestashop_categ_image,connector.group_connector_manager,1,1,1,1 +access_prestashop_export_multiple_products,Export access on product template,model_export_multiple_products,connector.group_connector_manager,1,1,1,1 +access_sync_products,access_sync_products,model_sync_products,connector.group_connector_manager,1,1,1,1 +access_active_deactive_products,access_active_deactive_products,model_active_deactive_products,connector.group_connector_manager,1,1,1,1 +access_wiz_prestashop_export_category,access_wiz_prestashop_export_category,model_wiz_prestashop_export_category,connector.group_connector_manager,1,1,1,1 +access_wiz_prestashop_export_product_brand,access_wiz_prestashop_export_product_brand,model_wiz_prestashop_export_product_brand,connector.group_connector_manager,1,1,1,1 diff --git a/connector_prestashop_catalog_manager/static/description/icon.png b/connector_prestashop_catalog_manager/static/description/icon.png new file mode 100644 index 000000000..2fc6eee5a Binary files /dev/null and b/connector_prestashop_catalog_manager/static/description/icon.png differ diff --git a/connector_prestashop_catalog_manager/static/description/icon.svg b/connector_prestashop_catalog_manager/static/description/icon.svg new file mode 100644 index 000000000..29aa74d8c --- /dev/null +++ b/connector_prestashop_catalog_manager/static/description/icon.svg @@ -0,0 +1,238 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/connector_prestashop_catalog_manager/tests/__init__.py b/connector_prestashop_catalog_manager/tests/__init__.py new file mode 100644 index 000000000..bddcb98ff --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/__init__.py @@ -0,0 +1,6 @@ +from . import test_export_product_attribute +from . import test_export_product_category +from . import test_export_product_image +from . import test_export_product_product +from . import test_export_product_template +from . import test_export_product_brand diff --git a/connector_prestashop_catalog_manager/tests/common.py b/connector_prestashop_catalog_manager/tests/common.py new file mode 100644 index 000000000..46aebd6b4 --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/common.py @@ -0,0 +1,30 @@ +# © 2018 PlanetaTIC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from os.path import dirname, join +from unittest import mock + +from odoo.addons.connector_prestashop.tests.common import PrestashopTransactionCase + + +class CatalogManagerTransactionCase(PrestashopTransactionCase): + def setUp(self): + super().setUp() + self.sync_metadata() + self.base_mapping() + self.shop_group = self.env["prestashop.shop.group"].search([]) + self.shop = self.env["prestashop.shop"].search([]) + + mock_delay_record = mock.MagicMock() + self.instance_delay_record = mock_delay_record.return_value + self.patch_delay_record = mock.patch( + "odoo.addons.queue_job.models.base.DelayableRecordset", + new=mock_delay_record, + ) + self.patch_delay_record.start() + + self.cassette_library_dir = join(dirname(__file__), "fixtures/cassettes") + + def tearDown(self): + super().tearDown() + self.patch_delay_record.stop() diff --git a/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_attribute.yaml b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_attribute.yaml new file mode 100644 index 000000000..63f824e6f --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_attribute.yaml @@ -0,0 +1,50 @@ +interactions: + - request: + body: + !!python/unicode New + attribute4New + attributeselect + headers: + Accept: ["*/*"] + Accept-Encoding: ["gzip, deflate"] + Connection: [keep-alive] + Content-Length: ["273"] + Content-Type: [text/xml] + User-Agent: [python-requests/2.11.1] + method: POST + uri: http://localhost:8080/api/product_options + response: + body: + { + string: + !!python/unicode "\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\n\n\n", + } + headers: + access-time: ["1544009921"] + connection: [keep-alive] + content-length: ["652"] + content-sha1: [4290e789a9ce1823ce4ac91b063ca96ff36f04ea] + content-type: [text/xml;charset=utf-8] + date: ["Wed, 05 Dec 2018 11:38:41 GMT"] + execution-time: ["0.02"] + psws-version: [1.6.1.22] + server: [nginx/1.10.3 (Ubuntu)] + set-cookie: + [ + "PrestaShop-ac85e42aef73418ac8f31f6687398719=785d98e9bd28108f77b731d15480adab26832755ccb4f95be30db352aeea3ac5%3Ay9dT%2FUvu1KNqFoEFFplK6NS2kY3JMur8CJSWpxVtPnDlJXtgJWHexZZTRsadIcbiwDNh4vUR3%2B5XjBpOfoNRUDLN4zMfjjUQnwJD8t04vsM%3D; + expires=Tue, 25-Dec-2018 11:38:41 GMT; Max-Age=1728000; path=/; HttpOnly", + ] + transfer-encoding: [chunked] + vary: [Accept-Encoding] + x-powered-by: [PrestaShop Webservice] + status: {code: 201, message: Created} +version: 1 diff --git a/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_attribute_value.yaml b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_attribute_value.yaml new file mode 100644 index 000000000..7d91119b7 --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_attribute_value.yaml @@ -0,0 +1,46 @@ +interactions: + - request: + body: + !!python/unicode 11New valueNew + value + headers: + Accept: ["*/*"] + Accept-Encoding: ["gzip, deflate"] + Connection: [keep-alive] + Content-Length: ["271"] + Content-Type: [text/xml] + User-Agent: [python-requests/2.11.1] + method: POST + uri: http://localhost:8080/api/product_option_values + response: + body: + { + string: + !!python/unicode "\n\n\n\t\n\t\n\t\n\t\n\t\n\n\n", + } + headers: + access-time: ["1544009922"] + connection: [keep-alive] + content-length: ["462"] + content-sha1: [680b0fdf6f597b19907a853063d1118004e618e6] + content-type: [text/xml;charset=utf-8] + date: ["Wed, 05 Dec 2018 11:38:42 GMT"] + execution-time: ["0.015"] + psws-version: [1.6.1.22] + server: [nginx/1.10.3 (Ubuntu)] + set-cookie: + [ + "PrestaShop-ac85e42aef73418ac8f31f6687398719=a8a4d05d822236e10fc6b55aa29d11faa3716e1e99c5fb6f90388ddcc869bf44%3Ay9dT%2FUvu1KNqFoEFFplK6MdWNmB2dGM8e7QF71k0oQ%2FPRFEuTpUHkyA1D38AfRaBcdttFxJhxpUDel%2FmoANR4Bx32suWjACFgxh1quGCHB4%3D; + expires=Tue, 25-Dec-2018 11:38:42 GMT; Max-Age=1728000; path=/; HttpOnly", + ] + transfer-encoding: [chunked] + vary: [Accept-Encoding] + x-powered-by: [PrestaShop Webservice] + status: {code: 201, message: Created} +version: 1 diff --git a/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_brand.yaml b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_brand.yaml new file mode 100644 index 000000000..5484a4b83 --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_brand.yaml @@ -0,0 +1,54 @@ +interactions: + - request: + body: + !!python/unicode 1New + brand + headers: + Accept: ["*/*"] + Accept-Encoding: ["gzip, deflate"] + Connection: [keep-alive] + Content-Length: ["134"] + Content-Type: [text/xml] + User-Agent: [python-requests/2.11.1] + method: POST + uri: http://localhost:8080/api/manufacturers + response: + body: + { + string: + !!python/unicode "\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\n\n\n", + } + headers: + access-time: ["1581009413"] + connection: [keep-alive] + content-length: ["802"] + content-sha1: [4c9faea22f246917d392a77fa137b7adf312b696] + content-type: [text/xml;charset=utf-8] + date: ["Thu, 06 Feb 2020 17:16:53 GMT"] + execution-time: ["0.016"] + psws-version: [1.6.1.22] + server: [nginx/1.10.3 (Ubuntu)] + set-cookie: + [ + "PrestaShop-6afe1be0fe41e536d378c33ccb8576a7=70fe1813ab881a88b93cae0c0b17932ce2c31a2a23a428085e404b798b0b57f5%3AERwbwgjnbPhegaRWNyAjEBSV37qybRlOe7gRV3My9ws7tUTjvDrto87GEN6i4LKN6ERP2fCILkNsEMee7E3G5LxiZ0xlfI3ztXDxbrmmZQM%3D; + expires=Wed, 26-Feb-2020 17:16:53 GMT; Max-Age=1728000; path=/; + domain=localhost:8080; HttpOnly", + ] + transfer-encoding: [chunked] + vary: [Accept-Encoding] + x-powered-by: [PrestaShop Webservice] + status: {code: 201, message: Created} +version: 1 diff --git a/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_category.yaml b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_category.yaml new file mode 100644 index 000000000..29183ec08 --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_category.yaml @@ -0,0 +1,69 @@ +interactions: + - request: + body: + !!python/unicode New + category meta descriptionnew-categoryNew + category meta titleNew + category keywordsNew + category2111<p>New category + description</p> + headers: + Accept: ["*/*"] + Accept-Encoding: ["gzip, deflate"] + Connection: [keep-alive] + Content-Length: ["654"] + Content-Type: [text/xml] + User-Agent: [python-requests/2.11.1] + method: POST + uri: http://localhost:8080/api/categories + response: + body: + { + string: + !!python/unicode "\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\tNew + category + description

]]>
\n\t\n\t\n\t\n\n\n\n\n
\n
\n", + } + headers: + access-time: ["1544010311"] + connection: [keep-alive] + content-length: ["1614"] + content-sha1: [73b504e1f3e7d44f8511f02cc8b1e8764b3380da] + content-type: [text/xml;charset=utf-8] + date: ["Wed, 05 Dec 2018 11:45:11 GMT"] + execution-time: ["0.044"] + psws-version: [1.6.1.22] + server: [nginx/1.10.3 (Ubuntu)] + set-cookie: + [ + "PrestaShop-ac85e42aef73418ac8f31f6687398719=8a23211feb6747fd92d7117800b697fe81b83282d61b7482c14fd1156239b1ef%3Ay9dT%2FUvu1KNqFoEFFplK6GUHezpItVFXBKxry0vskUErKLdJXddpHQxmwbDxzWLGiJLOUqe2aMh%2Bsa40YPqEWMWyRAoyMLZ3CDMF1J8HvEQ%3D; + expires=Tue, 25-Dec-2018 11:45:11 GMT; Max-Age=1728000; path=/; HttpOnly", + ] + transfer-encoding: [chunked] + vary: [Accept-Encoding] + x-powered-by: [PrestaShop Webservice] + status: {code: 201, message: Created} +version: 1 diff --git a/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_image.yaml b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_image.yaml new file mode 100644 index 000000000..8c8b5f16f --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_image.yaml @@ -0,0 +1,225 @@ +interactions: + - request: + body: !!binary | + LS0tLS0tLS0tLS0tVGhJc19Jc190SGVfYm91TmRhUllfJA0KQ29udGVudC1EaXNwb3NpdGlvbjog + Zm9ybS1kYXRhOyAgICAgICAgICAgICAgICAgICAgIG5hbWU9ImltYWdlIjsgZmlsZW5hbWU9Imlj + b24uLnBuZyINCkNvbnRlbnQtVHlwZTogaW1hZ2UvcG5nDQoNColQTkcNChoKAAAADUlIRFIAAACA + AAAAgAgGAAAAwz5hywAAAARzQklUCAgICHwIZIgAAAAJcEhZcwAADdcAAA3XAUIom3gAAAAZdEVY + dFNvZnR3YXJlAHd3dy5pbmtzY2FwZS5vcmeb7jwaAAAdbklEQVR4nO2deZgU1dW431PV3bOzM4AI + QcUtJERFRRR1QBgIJMbvi5C4gLjkMyZGAdklphP2AcElMWKiIhp/CcR8iRuyyIwQ9YtKXAgoRB0W + GZR9hmG6p7vrnt8fszAzDNBL9Sxh3uepB7q6zrlnuk7duvfce88VWkgKT147L8sOSG/L0m+i0hu0 + J9BJoYNAByACHAEOg+5F+FiQT1R1c6ph3cg1U4obwk5piEJOFZbkzuljYQ0VyFXoB3jjVOUovGsJ + qyPqPH/rqmmfuGlnTVocIEGW5s7PRswojN6GyNeTUYbAmwZ5yoTKnr+1wB90WXcL8bDk23lnWY5O + BUYBvoYoU6EIZK4Jlf3OLUdocYAYWTpobndEZiLcAHgaxwrdhTBp9MopzyeqqcUBomRxn8Xe9HbF + P1HRGUBWY9tTyRsOzo8TaSO0OEAUPDN0fm8x5nmgV2PbUg9lItw9auXkp+MRbnGAk/DskLmjVeW3 + QHpj23JClGcR667RqyYeiUWsxQGOw7IRfl95cfqTit7c2LZEjcq7EU9k+G0rpu2NVqTFAephae78 + DDB/BoY2ti1x8JmxZciYFZM+i+biFgeow9M5i9p4fOE1ivZpbFsS4AvbY/e/6dUJ2092odUQ1jQX + lvVbmGb7Qi8285sPcLoTcdY8f82sTie7sMUBKsnP8XuCmeHlwJWNbYtL9IzYnhcXf9d/wsZriwNU + sjMlfRbC8Ma2w2UuTStPe+REF7S0AYBnh8wdriov4f7vcUTQNxVrPZZuxlhbHStS6rMpJmR3MBad + FXOeqFyg0B/o7XL5AIjoLaNWTlla73fJKLA58fSwvM52RP8FtHdLpwqvC/pUwBf8650v+cuilXtm + 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 + headers: + Accept: ["*/*"] + Accept-Encoding: ["gzip, deflate"] + Connection: [keep-alive] + Content-Length: ["7856"] + Content-Type: [multipart/form-data; boundary=----------ThIs_Is_tHe_bouNdaRY_$] + User-Agent: [python-requests/2.11.1] + method: POST + uri: http://localhost:8080/api/images/products/1 + response: + body: {string: !!python/unicode ' + + + + + + + + + + + + + + + + + + + + + + '} + headers: + access-time: ["1544010333"] + connection: [keep-alive] + content-length: ["6609"] + content-sha1: [3a308e3ae28e34ab2eef6c8bedeacd54ba5250f3] + content-type: [text/xml;charset=utf-8] + date: ["Wed, 05 Dec 2018 11:45:33 GMT"] + execution-time: ["0.104"] + psws-version: [1.6.1.22] + server: [nginx/1.10.3 (Ubuntu)] + set-cookie: + [ + "PrestaShop-ac85e42aef73418ac8f31f6687398719=955af223f6180571e5e5e318e1a81079ca7d43406aa0e6cdc920b78d05165d99%3Ay9dT%2FUvu1KNqFoEFFplK6KgmFvX9MCUWzxfVdGR2sctbyzMZQ%2FuHKRHmpCgQ6rfMv8tpY%2FD%2BCaCDSw627GwpbrPaMx1unqas4i0VMPWUblE%3D; + expires=Tue, 25-Dec-2018 11:45:33 GMT; Max-Age=1728000; path=/; HttpOnly", + ] + transfer-encoding: [chunked] + vary: [Accept-Encoding] + x-powered-by: [PrestaShop Webservice] + status: {code: 200, message: OK} + - request: + body: null + headers: + Accept: ["*/*"] + Accept-Encoding: ["gzip, deflate"] + Connection: [keep-alive] + Content-Length: ["0"] + User-Agent: [python-requests/2.11.1] + method: DELETE + uri: http://localhost:8080/api/images/products/1/68 + response: + body: {string: !!python/unicode ""} + headers: + access-time: ["1544010333"] + connection: [keep-alive] + content-length: ["0"] + date: ["Wed, 05 Dec 2018 11:45:33 GMT"] + execution-time: ["0.01"] + psws-version: [1.6.1.22] + server: [nginx/1.10.3 (Ubuntu)] + set-cookie: + [ + "PrestaShop-ac85e42aef73418ac8f31f6687398719=955af223f6180571e5e5e318e1a81079ca7d43406aa0e6cdc920b78d05165d99%3Ay9dT%2FUvu1KNqFoEFFplK6KgmFvX9MCUWzxfVdGR2sctbyzMZQ%2FuHKRHmpCgQ6rfMv8tpY%2FD%2BCaCDSw627GwpbrPaMx1unqas4i0VMPWUblE%3D; + expires=Tue, 25-Dec-2018 11:45:33 GMT; Max-Age=1728000; path=/; HttpOnly", + ] + transfer-encoding: [chunked] + vary: [Accept-Encoding] + x-powered-by: [PrestaShop Webservice] + status: {code: 200, message: OK} +version: 1 diff --git a/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_product.yaml b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_product.yaml new file mode 100644 index 000000000..42bfa2ddc --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_product.yaml @@ -0,0 +1,77 @@ +interactions: + - request: + body: + !!python/unicode 4138411788010150demo_3_OS0.10110.020.023 + headers: + Accept: ["*/*"] + Accept-Encoding: ["gzip, deflate"] + Connection: [keep-alive] + Content-Length: ["532"] + Content-Type: [text/xml] + User-Agent: [python-requests/2.11.1] + method: POST + uri: http://localhost:8080/api/combinations + response: + body: + { + string: + !!python/unicode "\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\n\n\n", + } + headers: + access-time: ["1544010356"] + connection: [keep-alive] + content-length: ["1200"] + content-sha1: [7a46cf797b3653f5c063bf16e0a898a08fc411cb] + content-type: [text/xml;charset=utf-8] + date: ["Wed, 05 Dec 2018 11:45:56 GMT"] + execution-time: ["0.026"] + psws-version: [1.6.1.22] + server: [nginx/1.10.3 (Ubuntu)] + set-cookie: + [ + "PrestaShop-ac85e42aef73418ac8f31f6687398719=a6ba7c94454759752a139fc6fdae9b327c99c9b1c42f846b56f69caf29139e71%3Ay9dT%2FUvu1KNqFoEFFplK6Otra9jGVczMTxQ0zVLeniD4jwMTT5sp%2F2lnJQCK1O3fW7QmPITjvETXHqyribPUUOy6ImdWABG%2F43xhrzi%2BccQ%3D; + expires=Tue, 25-Dec-2018 11:45:56 GMT; Max-Age=1728000; path=/; HttpOnly", + ] + transfer-encoding: [chunked] + vary: [Accept-Encoding] + x-powered-by: [PrestaShop Webservice] + status: {code: 201, message: Created} + - request: + body: null + headers: + Accept: ["*/*"] + Accept-Encoding: ["gzip, deflate"] + Connection: [keep-alive] + Content-Length: ["0"] + User-Agent: [python-requests/2.11.1] + method: DELETE + uri: http://localhost:8080/api/combinations/60 + response: + body: {string: !!python/unicode ""} + headers: + access-time: ["1544010356"] + connection: [keep-alive] + content-length: ["0"] + date: ["Wed, 05 Dec 2018 11:45:56 GMT"] + execution-time: ["0.018"] + psws-version: [1.6.1.22] + server: [nginx/1.10.3 (Ubuntu)] + set-cookie: + [ + "PrestaShop-ac85e42aef73418ac8f31f6687398719=a6ba7c94454759752a139fc6fdae9b327c99c9b1c42f846b56f69caf29139e71%3Ay9dT%2FUvu1KNqFoEFFplK6Otra9jGVczMTxQ0zVLeniD4jwMTT5sp%2F2lnJQCK1O3fW7QmPITjvETXHqyribPUUOy6ImdWABG%2F43xhrzi%2BccQ%3D; + expires=Tue, 25-Dec-2018 11:45:56 GMT; Max-Age=1728000; path=/; HttpOnly", + ] + transfer-encoding: [chunked] + vary: [Accept-Encoding] + x-powered-by: [PrestaShop Webservice] + status: {code: 200, message: OK} +version: 1 diff --git a/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_template.yaml b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_template.yaml new file mode 100644 index 000000000..eb9b33fcb --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/fixtures/cassettes/test_export_product_template.yaml @@ -0,0 +1,110 @@ +interactions: + - request: + body: + !!python/unicode 2020-02-06 + 16:57:248411788010150New + product meta + title2new-product<p>New product + description</p>1110.020.084117880101505New product meta + keywords2016-08-2911NEW_PRODUCTNew productNew + product meta + description01.00.11111New product + tags2345New product available + now<p>New + product description + short</p>New product available + later + headers: + Accept: ["*/*"] + Accept-Encoding: ["gzip, deflate"] + Connection: [keep-alive] + Content-Length: ["1845"] + Content-Type: [text/xml] + User-Agent: [python-requests/2.11.1] + method: POST + uri: http://localhost:8080/api/products + response: + body: + { + string: + !!python/unicode "\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\tNew product + description

]]>
\n\tNew product + description + short

]]>
\n\t\n\t\n\n\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\t\n\n\n\n\n\n\n\n\t\n\t\n\t\n\t\n\n\n\n\n
\n
\n", + } + headers: + access-time: ["1581008244"] + connection: [keep-alive] + content-length: ["5218"] + content-sha1: [c70fd4a619a4a4bf71229b57e465b3aace21f256] + content-type: [text/xml;charset=utf-8] + date: ["Thu, 06 Feb 2020 16:57:24 GMT"] + execution-time: ["0.051"] + psws-version: [1.6.1.22] + server: [nginx/1.10.3 (Ubuntu)] + set-cookie: + [ + "PrestaShop-6afe1be0fe41e536d378c33ccb8576a7=99ef58a58fe325ebc994812ea04fec000e7d9e4532b86e166abaa438d78e6711%3AERwbwgjnbPhegaRWNyAjEDGs9Ou1AWI7j6aOPwsre8LNHD2QLBTUTdzjB3g7IWQeicISDrhEhOu4WTKS9MpUFEgN1N3HM7BH145oU1%2FGeuo%3D; + expires=Wed, 26-Feb-2020 16:57:24 GMT; Max-Age=1728000; path=/; + domain=localhost:8080; HttpOnly", + ] + transfer-encoding: [chunked] + vary: [Accept-Encoding] + x-powered-by: [PrestaShop Webservice] + status: {code: 201, message: Created} +version: 1 diff --git a/connector_prestashop_catalog_manager/tests/test_export_product_attribute.py b/connector_prestashop_catalog_manager/tests/test_export_product_attribute.py new file mode 100644 index 000000000..5578f05a0 --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/test_export_product_attribute.py @@ -0,0 +1,190 @@ +# © 2018 PlanetaTIC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.connector_prestashop.tests.common import ( + assert_no_job_delayed, + recorder, +) + +from .common import CatalogManagerTransactionCase + + +class TestExportProductAttribute(CatalogManagerTransactionCase): + def setUp(self): + super().setUp() + + # create and bind attribute + attribute_size = self.env["product.attribute"].create( + { + "name": "Size", + } + ) + self.create_binding_no_export( + "prestashop.product.combination.option", attribute_size.id, 1 + ) + + # create attribute and value + self.attribute = self.env["product.attribute"].create( + { + "name": "New attribute", + } + ) + self.value = self.env["product.attribute.value"].create( + { + "attribute_id": attribute_size.id, + "name": "New value", + } + ) + + def _bind_attribute(self): + return self.create_binding_no_export( + "prestashop.product.combination.option", self.attribute.id, 4 + ).with_context(connector_no_export=False) + + def _bind_value(self): + return self.create_binding_no_export( + "prestashop.product.combination.option.value", self.value.id, 25 + ).with_context(connector_no_export=False) + + @assert_no_job_delayed + def test_export_product_attribute_onbind(self): + # create attribute binding + self.env["prestashop.product.combination.option"].create( + { + "backend_id": self.backend_record.id, + "odoo_id": self.attribute.id, + } + ) + # check export delayed + self.assertEqual(1, self.instance_delay_record.export_record.call_count) + + @assert_no_job_delayed + def test_export_product_attribute_value_onbind(self): + # bind attribute + self._bind_attribute() + # create value binding + self.env["prestashop.product.combination.option.value"].create( + { + "backend_id": self.backend_record.id, + "odoo_id": self.value.id, + } + ) + # check export delayed + self.assertEqual(1, self.instance_delay_record.export_record.call_count) + + @assert_no_job_delayed + def test_export_product_attribute_onwrite(self): + # bind attribute + self._bind_attribute() + # check no export delayed + self.assertEqual(0, self.instance_delay_record.export_record.call_count) + # write in value + self.attribute.name = "New attribute updated" + # check export delayed + self.assertEqual(1, self.instance_delay_record.export_record.call_count) + # write in binding + # binding.display_type = "radio" --> This triggered below 2 events + # attribute.event.listener.on_record_write calling export_record + # prestashop.attribute.event.listener.on_record_write calling export_record + self.attribute.display_type = "radio" + # check export delayed again + self.assertEqual(2, self.instance_delay_record.export_record.call_count) + + @assert_no_job_delayed + def test_export_product_attribute_value_onwrite(self): + # bind attribute and value + self._bind_attribute() + binding = self._bind_value() + # check no export delayed + self.assertEqual(0, self.instance_delay_record.export_record.call_count) + # write in value + self.value.name = "New value updated" + # check export delayed + self.assertEqual(1, self.instance_delay_record.export_record.call_count) + # write in binding + binding.prestashop_position = 2 + # check export delayed again + self.assertEqual(2, self.instance_delay_record.export_record.call_count) + + @assert_no_job_delayed + def test_export_product_attribute_job(self): + # create attribute binding + binding = self.env["prestashop.product.combination.option"].create( + { + "backend_id": self.backend_record.id, + "odoo_id": self.attribute.id, + "group_type": "select", + "prestashop_position": 4, + } + ) + # export attribute + with recorder.use_cassette( + "test_export_product_attribute", + cassette_library_dir=self.cassette_library_dir, + ) as cassette: + binding.export_record() + + # check request + self.assertEqual(1, len(cassette.requests)) + request = cassette.requests[0] + self.assertEqual("POST", request.method) + self.assertEqual("/api/product_options", self.parse_path(request.uri)) + self.assertDictEqual({}, self.parse_qs(request.uri)) + body = self.xmltodict(request.body) + ps_option = body["prestashop"]["product_options"] + # check basic fields + for field, value in list( + { + "group_type": "select", + "position": "4", + }.items() + ): + self.assertEqual(value, ps_option[field]) + # check translatable fields + for field, value in list( + { + "name": "New attribute", + "public_name": "New attribute", + }.items() + ): + self.assertEqual(value, ps_option[field]["language"]["value"]) + + @assert_no_job_delayed + def test_export_product_attribute_value_job(self): + # create value binding + binding = self.env["prestashop.product.combination.option.value"].create( + { + "backend_id": self.backend_record.id, + "odoo_id": self.value.id, + } + ) + # export value + with recorder.use_cassette( + "test_export_product_attribute_value", + cassette_library_dir=self.cassette_library_dir, + ) as cassette: + binding.export_record() + + # check request + self.assertEqual(1, len(cassette.requests)) + request = cassette.requests[0] + self.assertEqual("POST", request.method) + self.assertEqual("/api/product_option_values", self.parse_path(request.uri)) + self.assertDictEqual({}, self.parse_qs(request.uri)) + body = self.xmltodict(request.body) + ps_option = body["prestashop"]["product_option_value"] + # check basic fields + for field, value in list( + { + "id_attribute_group": "1", + "value": "New value", + }.items() + ): + self.assertEqual(value, ps_option[field]) + # check translatable fields + for field, value in list( + { + "name": "New value", + }.items() + ): + self.assertEqual(value, ps_option[field]["language"]["value"]) diff --git a/connector_prestashop_catalog_manager/tests/test_export_product_brand.py b/connector_prestashop_catalog_manager/tests/test_export_product_brand.py new file mode 100644 index 000000000..973b36023 --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/test_export_product_brand.py @@ -0,0 +1,93 @@ +# © 2018 PlanetaTIC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.connector_prestashop.tests.common import ( + assert_no_job_delayed, + recorder, +) + +from .common import CatalogManagerTransactionCase + + +class TestExportProductBrand(CatalogManagerTransactionCase): + def setUp(self): + super().setUp() + + # create and bind parent + parent = self.env["product.brand"].create({"name": "Home"}) + self.create_binding_no_export("prestashop.product.brand", parent.id, 2) + + # Create a product brand to export: + self.brand = self.env["product.brand"].create( + { + "name": "New brand", + } + ) + + def _bind_brand(self): + return self.create_binding_no_export( + "prestashop.product.brand", self.brand.id, 12 + ).with_context(connector_no_export=False) + + @assert_no_job_delayed + def test_export_product_brand_wizard(self): + # export from wizard + wizard = ( + self.env["wiz.prestashop.export.product.brand"] + .with_context(active_ids=[self.brand.id]) + .create({}) + ) + wizard.export_product_brands() + + # check binding created + bindings = self.env["prestashop.product.brand"].search( + [("odoo_id", "=", self.brand.id)] + ) + self.assertEqual(1, len(bindings)) + # check export delayed + self.instance_delay_record.export_record.assert_called_once_with( + fields=["backend_id", "odoo_id"] + ) + + @assert_no_job_delayed + def test_export_product_brand_onwrite(self): + self._bind_brand() + + # check no export delayed + self.assertEqual(0, self.instance_delay_record.export_record.call_count) + + # write in brand + self.brand.name = "New brand updated" + # check export delayed + self.assertEqual(1, self.instance_delay_record.export_record.call_count) + + @assert_no_job_delayed + def test_export_product_brand_job(self): + # create binding + binding = self.env["prestashop.product.brand"].create( + { + "backend_id": self.backend_record.id, + "odoo_id": self.brand.id, + } + ) + # export brand + with recorder.use_cassette( + "test_export_product_brand", cassette_library_dir=self.cassette_library_dir + ) as cassette: + binding.export_record() + + # check request + self.assertEqual(1, len(cassette.requests)) + request = cassette.requests[0] + self.assertEqual("POST", request.method) + self.assertEqual("/api/manufacturers", self.parse_path(request.uri)) + self.assertDictEqual({}, self.parse_qs(request.uri)) + body = self.xmltodict(request.body) + ps_brand = body["prestashop"]["manufacturers"] + # check name + for field, value in list( + { + "name": "New brand", + }.items() + ): + self.assertEqual(value, ps_brand[field]) diff --git a/connector_prestashop_catalog_manager/tests/test_export_product_category.py b/connector_prestashop_catalog_manager/tests/test_export_product_category.py new file mode 100644 index 000000000..1509f5285 --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/test_export_product_category.py @@ -0,0 +1,124 @@ +# © 2018 PlanetaTIC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.connector_prestashop.tests.common import ( + assert_no_job_delayed, + recorder, +) + +from ..models.product_template.exporter import get_slug +from .common import CatalogManagerTransactionCase + + +class TestExportProductCategory(CatalogManagerTransactionCase): + def setUp(self): + super().setUp() + + # create and bind parent + parent = self.env["product.category"].create({"name": "Home"}) + self.create_binding_no_export("prestashop.product.category", parent.id, 2) + + # Create a product category to export: + self.category = self.env["product.category"].create( + { + "name": "New category", + "parent_id": parent.id, + } + ) + + def _bind_category(self): + return self.create_binding_no_export( + "prestashop.product.category", self.category.id, 12 + ).with_context(connector_no_export=False) + + @assert_no_job_delayed + def test_export_product_category_wizard(self): + # export from wizard + wizard = ( + self.env["wiz.prestashop.export.category"] + .with_context(active_ids=[self.category.id]) + .create({}) + ) + wizard.export_categories() + + # check binding created + bindings = self.env["prestashop.product.category"].search( + [("odoo_id", "=", self.category.id)] + ) + self.assertEqual(1, len(bindings)) + # check export delayed + # sequence of fields is from ./wizards/export_category.py + # > def export_categories + self.instance_delay_record.export_record.assert_called_once_with( + fields=["backend_id", "default_shop_id", "link_rewrite", "odoo_id"] + ) + + @assert_no_job_delayed + def test_export_product_category_onwrite(self): + # bind category + binding = self._bind_category() + # check no export delayed + self.assertEqual(0, self.instance_delay_record.export_record.call_count) + + # write in category + self.category.name = "New category updated" + # check export delayed + self.assertEqual(1, self.instance_delay_record.export_record.call_count) + # write in binding + binding.description = "New category description updated" + # check export delayed again + self.assertEqual(2, self.instance_delay_record.export_record.call_count) + + @assert_no_job_delayed + def test_export_product_category_job(self): + # create binding + binding = self.env["prestashop.product.category"].create( + { + "backend_id": self.backend_record.id, + "odoo_id": self.category.id, + "default_shop_id": self.shop.id, + "description": "New category description", + "link_rewrite": get_slug(self.category.name), + "meta_description": "New category meta description", + "meta_keywords": "New category keywords", + "meta_title": "New category meta title", + "position": 1, + } + ) + # export category + with recorder.use_cassette( + "test_export_product_category", + cassette_library_dir=self.cassette_library_dir, + ) as cassette: + binding.export_record() + + # check request + self.assertEqual(1, len(cassette.requests)) + request = cassette.requests[0] + self.assertEqual("POST", request.method) + self.assertEqual("/api/categories", self.parse_path(request.uri)) + self.assertDictEqual({}, self.parse_qs(request.uri)) + body = self.xmltodict(request.body) + ps_category = body["prestashop"]["category"] + # check basic fields + for field, value in list( + { + "active": "1", + "id_parent": "2", + "id_shop_default": "1", + "position": "1", + }.items() + ): + self.assertEqual(value, ps_category[field]) + # check translatable fields + for field, value in list( + { + "description": "

New category description

", + "link_rewrite": "new-category", + "meta_description": "New category meta description", + "meta_keywords": "New category keywords", + "meta_title": "New category meta title", + "name": "New category", + }.items() + ): + self.assertEqual(value, ps_category[field]["language"]["value"]) diff --git a/connector_prestashop_catalog_manager/tests/test_export_product_image.py b/connector_prestashop_catalog_manager/tests/test_export_product_image.py new file mode 100644 index 000000000..54a6249a4 --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/test_export_product_image.py @@ -0,0 +1,147 @@ +# © 2018 PlanetaTIC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.modules.module import get_resource_path + +from odoo.addons.connector_prestashop.tests.common import ( + assert_no_job_delayed, + recorder, +) + +from .common import CatalogManagerTransactionCase + + +class TestExportProductImage(CatalogManagerTransactionCase): + def setUp(self): + super().setUp() + + # create and bind template + template = self.env["product.template"].create( + { + "name": "Faded Short Sleeves T-shirt", + } + ) + self.create_binding_no_export( + "prestashop.product.template", + template.id, + 1, + **{ + "default_shop_id": self.shop.id, + "link_rewrite": "faded-short-sleaves-t-shirt", + } + ) + + # create image and binding + self.image = self.env["base_multi_image.image"].create( + { + "owner_id": template.id, + "owner_model": "product.template", + "storage": "file", + "path": get_resource_path( + "connector_prestashop", "static", "description", "icon.png" + ), + } + ) + self.binding = self.create_binding_no_export( + "prestashop.product.image", self.image.id, None + ) + + @assert_no_job_delayed + def test_export_product_image_onwrite(self): + # write in image + self.image.write( + { + "path": get_resource_path( + "connector_prestashop_catalog_manager", + "static", + "description", + "icon.png", + ), + } + ) + # check export delayed + self.instance_delay_record.export_record.assert_called_once_with( + fields=[ + "path", + ] + ) + + @assert_no_job_delayed + def test_export_product_image_ondelete(self): + # bind image + self.binding.prestashop_id = 24 + + # delete image + self.image.unlink() + # check export delete delayed + self.instance_delay_record.export_delete_record.assert_called_once_with( + self.backend_record, 24, {"id_product": 1} + ) + + @assert_no_job_delayed + def test_export_product_image_jobs(self): + with recorder.use_cassette( + "test_export_product_image", cassette_library_dir=self.cassette_library_dir + ) as cassette: + + # create image in PS + self.binding.export_record() + + # check POST request + request = cassette.requests[0] + self.assertEqual("POST", request.method) + self.assertEqual("/api/images/products/1", self.parse_path(request.uri)) + self.assertDictEqual({}, self.parse_qs(request.uri)) + + # VCR.py does not support urllib v1 request in + # OCA/server-tools/base_multi_image/models/image.py: + # to get image from URL so... + + # ...update test is avoided + # update image in PS + # prestashop_id = self.binding.prestashop_id + # self.binding.export_record() + # + # # check DELETE requests + # request = cassette.requests[1] + # self.assertEqual('DELETE', request.method) + # self.assertEqual( + # '/api/images/products/1/%s' % prestashop_id, + # self.parse_path(request.uri)) + # self.assertDictEqual({}, self.parse_qs(request.uri)) + # + # # check POST request + # request = cassette.requests[2] + # self.assertEqual('POST', request.method) + # self.assertEqual('/api/images/products/1', + # self.parse_path(request.uri)) + # self.assertDictEqual({}, self.parse_qs(request.uri)) + + # ...and delete test is hacked + self.image.write( + { + "storage": "file", + "path": get_resource_path( + "connector_prestashop", "static", "description", "icon.png" + ), + } + ) + + # delete image in PS + attributes = { + "id_product": 1, + } + self.env["prestashop.product.image"].export_delete_record( + self.backend_record, + self.binding.prestashop_id, + attributes, + ) + + # check DELETE requests + request = cassette.requests[1] + self.assertEqual("DELETE", request.method) + self.assertEqual( + "/api/images/products/1/%s" % self.binding.prestashop_id, + self.parse_path(request.uri), + ) + self.assertDictEqual({}, self.parse_qs(request.uri)) diff --git a/connector_prestashop_catalog_manager/tests/test_export_product_product.py b/connector_prestashop_catalog_manager/tests/test_export_product_product.py new file mode 100644 index 000000000..87a58ee6a --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/test_export_product_product.py @@ -0,0 +1,228 @@ +# © 2018 PlanetaTIC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from unittest import mock + +from odoo.addons.connector_prestashop.tests.common import ( + assert_no_job_delayed, + recorder, +) + +from .common import CatalogManagerTransactionCase + + +class TestExportProductProduct(CatalogManagerTransactionCase): + def setUp(self): + super().setUp() + + # create and bind color attribute + color_attribute = self.env["product.attribute"].create( + { + "name": "Color", + } + ) + self.create_binding_no_export( + "prestashop.product.combination.option", color_attribute.id, 3 + ) + + # create and bind color value + color_value = self.env["product.attribute.value"].create( + { + "attribute_id": color_attribute.id, + "name": "Orange", + } + ) + self.create_binding_no_export( + "prestashop.product.combination.option.value", color_value.id, 13 + ) + + # create and bind size attribute + size_attribute = self.env["product.attribute"].create( + { + "name": "Size", + } + ) + self.create_binding_no_export( + "prestashop.product.combination.option", size_attribute.id, 1 + ) + + # create and bind size value + size_value = self.env["product.attribute.value"].create( + { + "attribute_id": size_attribute.id, + "name": "One size", + } + ) + self.create_binding_no_export( + "prestashop.product.combination.option.value", size_value.id, 4 + ) + + # create and bind template + template = self.env["product.template"].create( + { + "name": "Printed Dress", + } + ) + self.main_template_id = self.create_binding_no_export( + "prestashop.product.template", + template.id, + 3, + **{ + "default_shop_id": self.shop.id, + "link_rewrite": "printed-dress", + "attribute_line_ids": [ + ( + 0, + 0, + { + "attribute_id": color_attribute.id, + "value_ids": [(6, 0, [color_value.id])], + }, + ), + ( + 0, + 0, + { + "attribute_id": size_attribute.id, + "value_ids": [(6, 0, [size_value.id])], + }, + ), + ], + } + ) + + # update product + self.product = template.product_variant_ids[0] + self.product.write( + { + "barcode": "8411788010150", + "default_code": "demo_3_OS", + "default_on": False, + "impact_price": 20.0, + "product_tmpl_id": template.id, + "standard_price": 10.0, + "weight": 0.1, + } + ) + + def _bind_product(self): + return self.create_binding_no_export( + "prestashop.product.combination", + self.product.id, + None, + **{ + "main_template_id": self.main_template_id.id, + "minimal_quantity": 2, + } + ).with_context(connector_no_export=False) + + def test_export_product_product_oncreate(self): + # create binding + self.env["prestashop.product.combination"].create( + { + "backend_id": self.backend_record.id, + "odoo_id": self.product.id, + "main_template_id": self.main_template_id.id, + } + ) + # check export delayed + # The sequence of fields should follow above create + self.instance_delay_record.export_record.assert_called_once_with( + fields=["backend_id", "odoo_id", "main_template_id"] + ) + + def test_export_product_product_onwrite(self): + # reset mock: + self.patch_delay_record.stop() + mock_delay_record = mock.MagicMock() + self.instance_delay_record = mock_delay_record.return_value + self.patch_delay_record = mock.patch( + "odoo.addons.queue_job.models.base.DelayableRecordset", + new=mock_delay_record, + ) + self.patch_delay_record.start() + + # bind product + binding = self._bind_product() + # check no export delayed + self.assertEqual(0, self.instance_delay_record.export_record.call_count) + # write in product + self.product.default_code = "demo_3_OS" + # check export delayed + self.assertEqual(1, self.instance_delay_record.export_record.call_count) + # write in binding + binding.minimal_quantity = 2 + # check export delayed + self.assertEqual(2, self.instance_delay_record.export_record.call_count) + + @assert_no_job_delayed + def test_export_product_product_ondelete(self): + # bind product + binding = self._bind_product() + binding.prestashop_id = 46 + backend_id = binding.backend_id + # delete product + self.product.unlink() + # check export delete delayed + self.instance_delay_record.export_delete_record.assert_any_call(backend_id, 46) + self.instance_delay_record.export_delete_record.assert_called_with( + backend_id, 3 + ) + assert self.instance_delay_record.export_delete_record.call_count == 2 + + @assert_no_job_delayed + def test_export_product_product_jobs(self): + # bind product + binding = self._bind_product() + + with recorder.use_cassette( + "test_export_product_product", + cassette_library_dir=self.cassette_library_dir, + ) as cassette: + + # create combination in PS + binding.export_record() + + # check POST request + request = cassette.requests[0] + self.assertEqual("POST", request.method) + self.assertEqual("/api/combinations", self.parse_path(request.uri)) + self.assertDictEqual({}, self.parse_qs(request.uri)) + body = self.xmltodict(request.body) + ps_product = body["prestashop"]["combination"] + # check basic fields + for field, value in list( + { + "active": "1", + "default_on": "0", + "ean13": "8411788010150", + "id_product": "3", + "minimal_quantity": "2", + "price": "20.0", + "reference": "demo_3_OS", + "weight": "0.1", + "wholesale_price": "10.0", + }.items() + ): + self.assertEqual(value, ps_product[field]) + # check option values + ps_product_option_values = ps_product["associations"][ + "product_option_values" + ]["product_option_value"] + self.assertIn({"id": "4"}, ps_product_option_values) + self.assertIn({"id": "13"}, ps_product_option_values) + + # delete combination in PS + self.env["prestashop.product.combination"].export_delete_record( + self.backend_record, + binding.prestashop_id, + ) + + # check DELETE requests + request = cassette.requests[1] + self.assertEqual("DELETE", request.method) + self.assertEqual( + "/api/combinations/{}".format(binding.prestashop_id), + self.parse_path(request.uri), + ) + self.assertDictEqual({}, self.parse_qs(request.uri)) diff --git a/connector_prestashop_catalog_manager/tests/test_export_product_template.py b/connector_prestashop_catalog_manager/tests/test_export_product_template.py new file mode 100644 index 000000000..e638318db --- /dev/null +++ b/connector_prestashop_catalog_manager/tests/test_export_product_template.py @@ -0,0 +1,266 @@ +# © 2018 PlanetaTIC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.connector_prestashop.tests.common import ( + assert_no_job_delayed, + recorder, +) + +from ..models.product_template.exporter import get_slug +from .common import CatalogManagerTransactionCase + + +class TestExportProduct(CatalogManagerTransactionCase): + def setUp(self): + super().setUp() + + # create and bind category + category_home = self.env["product.category"].create( + { + "name": "Home", + } + ) + self.create_binding_no_export( + "prestashop.product.category", category_home.id, 2 + ) + category_women = self.env["product.category"].create( + { + "name": "Women", + "parent_id": category_home.id, + } + ) + self.create_binding_no_export( + "prestashop.product.category", category_women.id, 3 + ) + category_tops = self.env["product.category"].create( + { + "name": "Tops", + "parent_id": category_women.id, + } + ) + self.create_binding_no_export( + "prestashop.product.category", category_tops.id, 4 + ) + category_tshirts = self.env["product.category"].create( + { + "name": "T-shirts", + "parent_id": category_tops.id, + } + ) + self.create_binding_no_export( + "prestashop.product.category", category_tshirts.id, 5 + ) + + acme_brand = self.env["product.brand"].create( + { + "name": "ACME", + } + ) + self.create_binding_no_export("prestashop.product.brand", acme_brand.id, 1) + + # create template + self.template = self.env["product.template"].create( + { + "barcode": "8411788010150", + "categ_ids": [ + ( + 6, + False, + [ + category_home.id, + category_women.id, + category_tops.id, + category_tshirts.id, + ], + ) + ], + "default_code": "NEW_PRODUCT", + "list_price": 20.0, + "name": "New product", + "prestashop_default_category_id": category_tshirts.id, + "standard_price": 10.0, + "weight": 0.1, + "product_brand_id": acme_brand.id, + } + ) + + def _bind_template(self): + return self.create_binding_no_export( + "prestashop.product.template", + self.template.id, + 8, + **{ + "default_shop_id": self.shop.id, + "link_rewrite": "new-product", + } + ).with_context(connector_no_export=False) + + @assert_no_job_delayed + def test_export_product_template_wizard_export(self): + # export from wizard + wizard = ( + self.env["export.multiple.products"] + .with_context(active_ids=[self.template.id]) + .create({}) + ) + wizard.export_products() + + # check binding created + binding_model = "prestashop.product.template" + bindings = self.env[binding_model].search([("odoo_id", "=", self.template.id)]) + self.assertEqual(1, len(bindings)) + # check export delayed + # sequence of fields is from ./wizards/export_multiple_products.py + # > def create_prestashop_template + self.instance_delay_record.export_record.assert_called_once_with( + fields=["backend_id", "default_shop_id", "link_rewrite", "odoo_id"] + ) + + @assert_no_job_delayed + def test_export_product_template_wizard_active(self): + # bind template + self._bind_template() + # check no export delayed + self.assertEqual(0, self.instance_delay_record.export_record.call_count) + # deactivate from wizard + wizard = ( + self.env["active.deactive.products"] + .with_context(active_ids=[self.template.id]) + .create({}) + ) + wizard.deactive_products() + # check export delayed + self.assertEqual(1, self.instance_delay_record.export_record.call_count) + # deactivate again + wizard.deactive_products() + # check no export delayed + self.assertEqual(1, self.instance_delay_record.export_record.call_count) + # force deactivate + wizard.force_status = True + wizard.deactive_products() + # check export delayed + self.assertEqual(2, self.instance_delay_record.export_record.call_count) + # activate from wizard + wizard.force_status = False + wizard.active_products() + # check export delayed + self.assertEqual(3, self.instance_delay_record.export_record.call_count) + # activate again + wizard.active_products() + # check no export delayed + self.assertEqual(3, self.instance_delay_record.export_record.call_count) + # force activate + wizard.force_status = True + wizard.active_products() + # check export delayed + self.assertEqual(4, self.instance_delay_record.export_record.call_count) + + @assert_no_job_delayed + def test_export_product_template_wizard_resync(self): + # bind template + self._bind_template() + # resync from wizard + wizard = ( + self.env["sync.products"] + .with_context(active_ids=[self.template.id], connector_delay=True) + .create({}) + ) + wizard.sync_products() + # check import done + self.instance_delay_record.import_record.assert_called_once_with( + self.backend_record, 8 + ) + + @assert_no_job_delayed + def test_export_product_template_onwrite(self): + # bind template + binding = self._bind_template() + # check no export delayed + self.assertEqual(0, self.instance_delay_record.export_record.call_count) + # write in template + self.template.name = "New product updated" + # check export delayed + self.assertEqual(1, self.instance_delay_record.export_record.call_count) + # write in binding + binding.meta_title = "New product meta title updated" + # check export delayed + self.assertEqual(2, self.instance_delay_record.export_record.call_count) + + @assert_no_job_delayed + def test_export_product_template_job(self): + # create binding + binding = self.env["prestashop.product.template"].create( + { + "backend_id": self.backend_record.id, + "odoo_id": self.template.id, + "additional_shipping_cost": 1.0, + "always_available": True, + "available_date": "2016-08-29", + "available_later": "New product available later", + "available_now": "New product available now", + "default_shop_id": self.shop.id, + "description_html": "New product description", + "description_short_html": "New product description short", + "link_rewrite": get_slug(self.template.name), + "meta_title": "New product meta title", + "meta_description": "New product meta description", + "meta_keywords": "New product meta keywords", + "minimal_quantity": 2, + "on_sale": True, + "online_only": True, + "tags": "New product tags", + } + ) + # export template + with recorder.use_cassette( + "test_export_product_template", + cassette_library_dir=self.cassette_library_dir, + ) as cassette: + binding.export_record() + + # check request + self.assertEqual(1, len(cassette.requests)) + request = cassette.requests[0] + self.assertEqual("POST", request.method) + self.assertEqual("/api/products", self.parse_path(request.uri)) + self.assertDictEqual({}, self.parse_qs(request.uri)) + body = self.xmltodict(request.body) + ps_product = body["prestashop"]["product"] + # check basic fields + for field, value in list( + { + "active": "1", + "additional_shipping_cost": "1.0", + "available_date": "2016-08-29", + "available_for_order": "1", + "barcode": "8411788010150", + "id_category_default": "5", + "id_shop_default": "1", + "minimal_quantity": "2", + "on_sale": "1", + "online_only": "1", + "price": "20.0", + "reference": "NEW_PRODUCT", + "show_price": "1", + "weight": "0.1", + "wholesale_price": "10.0", + "id_manufacturer": "1", + }.items() + ): + self.assertEqual(value, ps_product[field]) + # check translatable fields + for field, value in list( + { + "available_later": "New product available later", + "available_now": "New product available now", + "description": "

New product description

", + "description_short": "

New product description short" "

", + "link_rewrite": "new-product", + "meta_description": "New product meta description", + "meta_keywords": "New product meta keywords", + "meta_title": "New product meta title", + "name": "New product", + "tags": "New product tags", + }.items() + ): + self.assertEqual(value, ps_product[field]["language"]["value"]) diff --git a/connector_prestashop_catalog_manager/views/product_attribute_view.xml b/connector_prestashop_catalog_manager/views/product_attribute_view.xml new file mode 100644 index 000000000..bde69d5c4 --- /dev/null +++ b/connector_prestashop_catalog_manager/views/product_attribute_view.xml @@ -0,0 +1,41 @@ + + + + + product.attribute.form + product.attribute + +
+ + + + +
+
+
+ + + prestashop.product.combination.option + +
+ + + + + +
+
+
+ + + prestashop.product.combination.option + + + + + + + + + +
diff --git a/connector_prestashop_catalog_manager/views/product_category_view.xml b/connector_prestashop_catalog_manager/views/product_category_view.xml new file mode 100644 index 000000000..0468370ac --- /dev/null +++ b/connector_prestashop_catalog_manager/views/product_category_view.xml @@ -0,0 +1,18 @@ + + + + + connector_prestashop.product.category.tree + prestashop.product.category + + + + 1 + + + + + diff --git a/connector_prestashop_catalog_manager/views/product_image_view.xml b/connector_prestashop_catalog_manager/views/product_image_view.xml new file mode 100644 index 000000000..61624ece4 --- /dev/null +++ b/connector_prestashop_catalog_manager/views/product_image_view.xml @@ -0,0 +1,13 @@ + + + + connector_prestashop.product.image.form + base_multi_image.image + + + + + + + + diff --git a/connector_prestashop_catalog_manager/views/product_view.xml b/connector_prestashop_catalog_manager/views/product_view.xml new file mode 100644 index 000000000..ca8d5d131 --- /dev/null +++ b/connector_prestashop_catalog_manager/views/product_view.xml @@ -0,0 +1,46 @@ + + + + connector_prestashop.product.template.form + + prestashop.product.template + + form + + + + + + + + + + + + + + + + + + + + + connector_prestashop.product.template.tree + + prestashop.product.template + + + + 1 + + + + + diff --git a/connector_prestashop_catalog_manager/wizards/__init__.py b/connector_prestashop_catalog_manager/wizards/__init__.py new file mode 100644 index 000000000..720ac0d4e --- /dev/null +++ b/connector_prestashop_catalog_manager/wizards/__init__.py @@ -0,0 +1,7 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import export_multiple_products +from . import sync_products +from . import active_deactive_products +from . import export_category +from . import export_brand diff --git a/connector_prestashop_catalog_manager/wizards/active_deactive_products.py b/connector_prestashop_catalog_manager/wizards/active_deactive_products.py new file mode 100644 index 000000000..e97898688 --- /dev/null +++ b/connector_prestashop_catalog_manager/wizards/active_deactive_products.py @@ -0,0 +1,29 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class SyncProducts(models.TransientModel): + _name = "active.deactive.products" + _description = "Activate/Deactivate Products" + + force_status = fields.Boolean( + string="Force Status", + help="Check this option to force active product in prestashop", + ) + + def _change_status(self, status): + self.ensure_one() + product_obj = self.env["product.template"] + for product in product_obj.browse(self.env.context["active_ids"]): + for bind in product.prestashop_bind_ids: + if bind.always_available != status or self.force_status: + bind.always_available = status + + def active_products(self): + for product in self: + product._change_status(True) + + def deactive_products(self): + for product in self: + product._change_status(False) diff --git a/connector_prestashop_catalog_manager/wizards/active_deactive_products_view.xml b/connector_prestashop_catalog_manager/wizards/active_deactive_products_view.xml new file mode 100644 index 000000000..4da7b2838 --- /dev/null +++ b/connector_prestashop_catalog_manager/wizards/active_deactive_products_view.xml @@ -0,0 +1,72 @@ + + + + active.product.form + active.deactive.products + +
+ +
+

Active selected products

+
+
+ + + +
+
+
+
+
+ + Active Products + active.deactive.products + + form + form + new + + + + + deactive.product.form + active.deactive.products + +
+ +
+

Dective selected products

+
+
+
+
+
+
+
+ + Deactive Products + active.deactive.products + + form + form + new + + + +
diff --git a/connector_prestashop_catalog_manager/wizards/export_brand.py b/connector_prestashop_catalog_manager/wizards/export_brand.py new file mode 100644 index 000000000..4557672ee --- /dev/null +++ b/connector_prestashop_catalog_manager/wizards/export_brand.py @@ -0,0 +1,45 @@ +# Copyright 2020 PlanetaTIC - Marc Poch +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PrestashopExportProductBrand(models.TransientModel): + _name = "wiz.prestashop.export.product.brand" + _description = "Prestashop Export Product Brand" + + def _default_backend(self): + return self.env["prestashop.backend"].search([], limit=1).id + + def _default_shop(self): + return self.env["prestashop.shop"].search([], limit=1).id + + backend_id = fields.Many2one( + comodel_name="prestashop.backend", + default=_default_backend, + string="Backend", + ) + shop_id = fields.Many2one( + comodel_name="prestashop.shop", + default=_default_shop, + string="Shop", + ) + + def export_product_brands(self): + self.ensure_one() + brand_obj = self.env["product.brand"] + ps_product_brand_obj = self.env["prestashop.product.brand"] + for prod_brand in brand_obj.browse(self.env.context["active_ids"]): + ps_product_brand = ps_product_brand_obj.search( + [ + ("odoo_id", "=", prod_brand.id), + ("backend_id", "=", self.backend_id.id), + ] + ) + if not ps_product_brand: + ps_product_brand_obj.create( + { + "backend_id": self.backend_id.id, + "odoo_id": prod_brand.id, + } + ) diff --git a/connector_prestashop_catalog_manager/wizards/export_brand_view.xml b/connector_prestashop_catalog_manager/wizards/export_brand_view.xml new file mode 100644 index 000000000..94cf2aca3 --- /dev/null +++ b/connector_prestashop_catalog_manager/wizards/export_brand_view.xml @@ -0,0 +1,38 @@ + + + + + + wiz.prestashop.export.product.brand.form + wiz.prestashop.export.product.brand + +
+ + + + +
+
+
+
+
+ + Export To PrestaShop + wiz.prestashop.export.product.brand + + form + form + new + + + +
diff --git a/connector_prestashop_catalog_manager/wizards/export_category.py b/connector_prestashop_catalog_manager/wizards/export_category.py new file mode 100644 index 000000000..bde658155 --- /dev/null +++ b/connector_prestashop_catalog_manager/wizards/export_category.py @@ -0,0 +1,49 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +from ..models.product_template.exporter import get_slug + + +class PrestashopExportCategory(models.TransientModel): + _name = "wiz.prestashop.export.category" + _description = "Prestashop Export Category" + + def _default_backend(self): + return self.env["prestashop.backend"].search([], limit=1).id + + def _default_shop(self): + return self.env["prestashop.shop"].search([], limit=1).id + + backend_id = fields.Many2one( + comodel_name="prestashop.backend", + default=_default_backend, + string="Backend", + ) + shop_id = fields.Many2one( + comodel_name="prestashop.shop", + default=_default_shop, + string="Shop", + ) + + def export_categories(self): + self.ensure_one() + category_obj = self.env["product.category"] + ps_category_obj = self.env["prestashop.product.category"] + for category in category_obj.browse(self.env.context["active_ids"]): + ps_category = ps_category_obj.search( + [ + ("odoo_id", "=", category.id), + ("backend_id", "=", self.backend_id.id), + ("default_shop_id", "=", self.shop_id.id), + ] + ) + if not ps_category: + ps_category_obj.create( + { + "backend_id": self.backend_id.id, + "default_shop_id": self.shop_id.id, + "link_rewrite": get_slug(category.name), + "odoo_id": category.id, + } + ) diff --git a/connector_prestashop_catalog_manager/wizards/export_category_view.xml b/connector_prestashop_catalog_manager/wizards/export_category_view.xml new file mode 100644 index 000000000..d45d45375 --- /dev/null +++ b/connector_prestashop_catalog_manager/wizards/export_category_view.xml @@ -0,0 +1,35 @@ + + + + + wiz.prestashop.export.category.form + wiz.prestashop.export.category + +
+ + + + + +