diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 060e6e193..6c85f8d30 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,7 @@
exclude: |
(?x)
# NOT INSTALLABLE ADDONS
+ ^connector_prestashop_catalog_manager/|
# END NOT INSTALLABLE ADDONS
# Files and folders generated by bots, to avoid loops
^setup/|/static/description/index\.html$|
diff --git a/connector_prestashop_catalog_manager/README.rst b/connector_prestashop_catalog_manager/README.rst
new file mode 100644
index 000000000..ba1c61318
--- /dev/null
+++ b/connector_prestashop_catalog_manager/README.rst
@@ -0,0 +1,72 @@
+.. 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
+
+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..15143a453
--- /dev/null
+++ b/connector_prestashop_catalog_manager/__manifest__.py
@@ -0,0 +1,35 @@
+# 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": "16.0.1.0.0",
+ "license": "AGPL-3",
+ "depends": [
+ "connector_prestashop",
+ "product_categ_image",
+ "product_multi_image",
+ ],
+ "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",
+ "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..1fccf4fc5
--- /dev/null
+++ b/connector_prestashop_catalog_manager/models/__init__.py
@@ -0,0 +1,7 @@
+# © 2016 Sergio Teruel
+# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html
+
+from . import product_category
+from . import product_product
+from . import product_template
+from . import product_image
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..c43456fb8
--- /dev/null
+++ b/connector_prestashop_catalog_manager/models/product_category/deleter.py
@@ -0,0 +1,12 @@
+# 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..2c0b27e5b
--- /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()
+
+
+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..f9bbd6a89
--- /dev/null
+++ b/connector_prestashop_catalog_manager/models/product_image/exporter.py
@@ -0,0 +1,111 @@
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+
+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.load_from != full_public_url:
+ self.binding.with_context(connector_no_export=True).write(
+ {
+ "load_from": full_public_url,
+ }
+ )
+
+
+class ProductImageExportMapper(Component):
+ _name = "prestashop.product.image.mapper"
+ _inherit = "prestashop.export.mapper"
+ _apply_on = "prestashop.product.image"
+
+ direct = [
+ ("name", "name"),
+ ]
+
+ @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 legend(self, record):
+ return {"legend": record.name}
+
+ @mapping
+ def load_from(self, record):
+ return {"load_from": record.load_from}
+
+ @mapping
+ def image_1920(self, record):
+ return {"image_1920": record.image_1920}
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..ab0eb8f27
--- /dev/null
+++ b/connector_prestashop_catalog_manager/models/product_product/common.py
@@ -0,0 +1,159 @@
+# © 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(
+ 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..0a16f59b8
--- /dev/null
+++ b/connector_prestashop_catalog_manager/models/product_product/exporter.py
@@ -0,0 +1,291 @@
+# 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.load_from
+ )()
+ 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
+ )
+ tmpl_price = tmpl_prices.get(record.odoo_id.product_tmpl_id.id)
+ product_prices = pricelist._get_products_price(record.odoo_id, 1.0)
+ 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(f"{record.standard_price:.2f}")
+ 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..36b9bccce
--- /dev/null
+++ b/connector_prestashop_catalog_manager/models/product_template/common.py
@@ -0,0 +1,122 @@
+# © 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(translate=True)
+ meta_description = fields.Char(translate=True)
+ meta_keywords = fields.Char(translate=True)
+ tags = fields.Char(translate=True)
+ online_only = fields.Boolean()
+ 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(translate=True)
+ available_later = fields.Char(translate=True)
+ available_date = fields.Date()
+ minimal_quantity = fields.Integer(
+ help="Minimal Sale quantity",
+ default=1,
+ )
+ state = fields.Boolean(default=True)
+ visibility = fields.Char(translate=True)
+ low_stock_threshold = fields.Integer(
+ help="Low Stock Threshold",
+ default=0,
+ )
+ low_stock_alert = fields.Integer(
+ 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..04490516a
--- /dev/null
+++ b/connector_prestashop_catalog_manager/models/product_template/exporter.py
@@ -0,0 +1,320 @@
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+import logging
+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
+
+_logger = logging.getLogger(__name__)
+
+
+def get_slug(name):
+ if slugify_lib:
+ try:
+ return slugify_lib.slugify(name)
+ except TypeError as e:
+ _logger.info("get_slug TypeError: %s", e)
+ 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_dependencies(self):
+ """Export the dependencies for the product"""
+ res = 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)
+
+ 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"
+ )
+ return res
+
+ 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"),
+ (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"),
+ ("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)
+ 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)}
+
+ @changed_by("standard_price")
+ @mapping
+ def cost_price(self, record):
+ wholesale_price = float(f"{record.standard_price:.2f}")
+ return {"wholesale_price": wholesale_price}
+
+ 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/security/ir.model.access.csv b/connector_prestashop_catalog_manager/security/ir.model.access.csv
new file mode 100644
index 000000000..bc98bca0d
--- /dev/null
+++ b/connector_prestashop_catalog_manager/security/ir.model.access.csv
@@ -0,0 +1,7 @@
+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
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 @@
+
+
+
+
diff --git a/connector_prestashop_catalog_manager/tests/__init__.py b/connector_prestashop_catalog_manager/tests/__init__.py
new file mode 100644
index 000000000..130e754a4
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/__init__.py
@@ -0,0 +1,5 @@
+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
diff --git a/connector_prestashop_catalog_manager/tests/common.py b/connector_prestashop_catalog_manager/tests/common.py
new file mode 100644
index 000000000..6de40552f
--- /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")
+
+ def tearDown(self):
+ super().tearDown()
+ self.patch_delay_record.stop()
diff --git a/connector_prestashop_catalog_manager/tests/fixtures/test_export_product_attribute.yaml b/connector_prestashop_catalog_manager/tests/fixtures/test_export_product_attribute.yaml
new file mode 100644
index 000000000..63f824e6f
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/fixtures/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/test_export_product_attribute_value.yaml b/connector_prestashop_catalog_manager/tests/fixtures/test_export_product_attribute_value.yaml
new file mode 100644
index 000000000..7d91119b7
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/fixtures/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/test_export_product_category.yaml b/connector_prestashop_catalog_manager/tests/fixtures/test_export_product_category.yaml
new file mode 100644
index 000000000..29183ec08
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/fixtures/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/test_export_product_image.yaml b/connector_prestashop_catalog_manager/tests/fixtures/test_export_product_image.yaml
new file mode 100644
index 000000000..8c8b5f16f
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/fixtures/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/test_export_product_product.yaml b/connector_prestashop_catalog_manager/tests/fixtures/test_export_product_product.yaml
new file mode 100644
index 000000000..42bfa2ddc
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/fixtures/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/test_export_product_template.yaml b/connector_prestashop_catalog_manager/tests/fixtures/test_export_product_template.yaml
new file mode 100644
index 000000000..eb9b33fcb
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/fixtures/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..6726aa9ce
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/test_export_product_attribute.py
@@ -0,0 +1,192 @@
+# © 2018 PlanetaTIC
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
+from odoo.tests import tagged
+
+from odoo.addons.connector_prestashop.tests.common import (
+ assert_no_job_delayed,
+ recorder,
+)
+
+from .common import CatalogManagerTransactionCase
+
+
+@tagged("post_install", "-at_install")
+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_01_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_02_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_03_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_04_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_05_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_06_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_category.py b/connector_prestashop_catalog_manager/tests/test_export_product_category.py
new file mode 100644
index 000000000..dcb8de17a
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/test_export_product_category.py
@@ -0,0 +1,126 @@
+# © 2018 PlanetaTIC
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
+from odoo.tests import tagged
+
+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
+
+
+@tagged("post_install", "-at_install")
+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_01_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_02_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_03_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..94683c4e9
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/test_export_product_image.py
@@ -0,0 +1,137 @@
+# © 2018 PlanetaTIC
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
+from odoo.tests import tagged
+
+from odoo.addons.connector_prestashop.tests.common import (
+ assert_no_job_delayed,
+ recorder,
+)
+
+from .common import CatalogManagerTransactionCase
+
+
+@tagged("post_install", "-at_install")
+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",
+ }
+ )
+ self.transparent_image = ( # 1x1 Transparent GIF
+ b"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
+ )
+ self.grey_image = ( # 1x1 Grey GIF
+ b"R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw =="
+ )
+ # create image and binding
+ self.image = self.env["base_multi_image.image"].create(
+ {
+ "owner_id": template.id,
+ "owner_model": "product.template",
+ "image_1920": self.transparent_image,
+ }
+ )
+ self.image._onchange_load_from()
+ self.binding = self.create_binding_no_export(
+ "prestashop.product.image", self.image.id, None
+ )
+
+ @assert_no_job_delayed
+ def test_01_export_product_image_onwrite(self):
+ # write in image
+ self.image.write(
+ {
+ "image_1920": self.grey_image,
+ }
+ )
+ # check export delayed
+ self.instance_delay_record.export_record.assert_called_once_with(
+ fields=[
+ "load_from",
+ ]
+ )
+
+ @assert_no_job_delayed
+ def test_02_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_03_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({"image_1920": self.grey_image})
+
+ # 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..553a7483d
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/test_export_product_product.py
@@ -0,0 +1,231 @@
+# © 2018 PlanetaTIC
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
+
+from unittest import mock
+
+from odoo.tests import tagged
+
+from odoo.addons.connector_prestashop.tests.common import (
+ assert_no_job_delayed,
+ recorder,
+)
+
+from .common import CatalogManagerTransactionCase
+
+
+@tagged("post_install", "-at_install")
+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_01_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_02_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_03_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_04_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(
+ f"/api/combinations/{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..198f97aa5
--- /dev/null
+++ b/connector_prestashop_catalog_manager/tests/test_export_product_template.py
@@ -0,0 +1,261 @@
+# © 2018 PlanetaTIC
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
+
+from odoo.tests import tagged
+
+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
+
+
+@tagged("post_install", "-at_install")
+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
+ )
+
+ # 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,
+ }
+ )
+
+ 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_01_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_02_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_03_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_04_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_05_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..cdd4f03ca
--- /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..62f69711a
--- /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..e1443ff79
--- /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..d45436ab9
--- /dev/null
+++ b/connector_prestashop_catalog_manager/wizards/__init__.py
@@ -0,0 +1,6 @@
+# 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
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..5a6458537
--- /dev/null
+++ b/connector_prestashop_catalog_manager/wizards/active_deactive_products.py
@@ -0,0 +1,28 @@
+# 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(
+ 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 Products
+ active.deactive.products
+
+ form
+ form
+ new
+
+
+
+
+ deactive.product.form
+ active.deactive.products
+
+
+
+
+
+ Deactive Products
+ active.deactive.products
+
+ 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
+
+
+
+
+
+
+ Export To PrestaShop
+ wiz.prestashop.export.category
+ form
+ new
+
+
+
+
diff --git a/connector_prestashop_catalog_manager/wizards/export_multiple_products.py b/connector_prestashop_catalog_manager/wizards/export_multiple_products.py
new file mode 100644
index 000000000..b0eda5702
--- /dev/null
+++ b/connector_prestashop_catalog_manager/wizards/export_multiple_products.py
@@ -0,0 +1,161 @@
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+import logging
+import re
+import unicodedata
+from functools import reduce
+
+from odoo import _, fields, models
+from odoo.exceptions import ValidationError
+
+_logger = logging.getLogger(__name__)
+
+
+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 as e:
+ _logger.info("get_slug TypeError: %s", e)
+ 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 ExportMultipleProducts(models.TransientModel):
+ _name = "export.multiple.products"
+ _description = "Export Multiple Products"
+
+ 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="Default Shop",
+ )
+
+ def _parent_length(self, categ):
+ if not categ.parent_id:
+ return 1
+ else:
+ return 1 + self._parent_length(categ.parent_id)
+
+ def _set_main_category(self, product):
+ if product.categ_ids and product.categ_id.parent_id:
+ max_parent = {"length": 0}
+ for categ in product.categ_ids:
+ parent_length = self._parent_length(categ.parent_id)
+ if parent_length > max_parent["length"]:
+ max_parent = {"categ_id": categ.id, "length": parent_length}
+ categ_length = self._parent_length(product.categ_id.parent_id)
+ if categ_length < parent_length:
+ if product.categ_id.id not in product.categ_ids.ids:
+ product.write(
+ {
+ "categ_ids": [(4, product.categ_id.id)],
+ }
+ )
+ product.write(
+ {
+ "categ_id": max_parent["categ_id"],
+ "categ_ids": [(3, max_parent["categ_id"])],
+ }
+ )
+ else:
+ product.write(
+ {
+ "categ_id": max_parent["categ_id"],
+ "categ_ids": [(3, max_parent["categ_id"])],
+ }
+ )
+
+ def set_category(self):
+ product_obj = self.env["product.template"]
+ for product in product_obj.browse(self.env.context["active_ids"]):
+ self._set_main_category(product)
+
+ def _check_images(self, product):
+ for variant in product.product_variant_ids:
+ for image in variant.image_ids:
+ if image.owner_id != product.id:
+ image.product_id = product
+
+ def _check_category(self, product):
+ if not (product.categ_ids):
+ return False
+ return True
+
+ def _check_variants(self, product):
+ if len(product.product_variant_ids) == 1:
+ return True
+ if len(product.product_variant_ids) > 1 and not product.attribute_line_ids:
+ check_count = reduce(
+ lambda x, y: x * y,
+ [len(x.value_ids) for x in product.attribute_line_ids],
+ )
+ if check_count < len(product.product_variant_ids):
+ return False
+ return True
+
+ def export_variant_stock(self):
+ template_obj = self.env["product.template"]
+ products = template_obj.browse(self.env.context["active_ids"])
+ products.update_prestashop_quantities()
+
+ def create_prestashop_template(self, product):
+ presta_tmpl_obj = self.env["prestashop.product.template"]
+ return presta_tmpl_obj.create(
+ {
+ "backend_id": self.backend_id.id,
+ "default_shop_id": self.shop_id.id,
+ "link_rewrite": get_slug(product.name),
+ "odoo_id": product.id,
+ }
+ )
+
+ def export_products(self):
+ self.ensure_one()
+ product_obj = self.env["product.template"]
+ presta_tmpl_obj = self.env["prestashop.product.template"]
+ for product in product_obj.browse(self.env.context["active_ids"]):
+ presta_tmpl = presta_tmpl_obj.search(
+ [
+ ("odoo_id", "=", product.id),
+ ("backend_id", "=", self.backend_id.id),
+ ("default_shop_id", "=", self.shop_id.id),
+ ]
+ )
+ if not presta_tmpl:
+ self._check_images(product)
+ cat = self._check_category(product)
+ var = self._check_variants(product)
+ if not (var and cat):
+ raise ValidationError(
+ _(
+ """Product "%s" cannot be exported to Prestashop \
+because is not assigned to any Prestashop category or \
+has not any Product Variant."""
+ )
+ % product.name
+ )
+ self.create_prestashop_template(product)
+ else:
+ for tmpl in presta_tmpl:
+ if " " in tmpl.link_rewrite:
+ tmpl.link_rewrite = get_slug(tmpl.link_rewrite)
diff --git a/connector_prestashop_catalog_manager/wizards/export_multiple_products_view.xml b/connector_prestashop_catalog_manager/wizards/export_multiple_products_view.xml
new file mode 100644
index 000000000..59bbe91f6
--- /dev/null
+++ b/connector_prestashop_catalog_manager/wizards/export_multiple_products_view.xml
@@ -0,0 +1,63 @@
+
+
+
+ export.multiple.products.form
+ export.multiple.products
+
+
+
+
+
+ Export Products to Prestashop
+ export.multiple.products
+
+ list,form
+ form
+ new
+
+
+
+
+ export.variant.stock.form
+ export.multiple.products
+
+
+
+
+
+ Export Products Stock
+ export.multiple.products
+
+ form
+ form
+ new
+
+
+
+
diff --git a/connector_prestashop_catalog_manager/wizards/sync_products.py b/connector_prestashop_catalog_manager/wizards/sync_products.py
new file mode 100644
index 000000000..932c3e8a4
--- /dev/null
+++ b/connector_prestashop_catalog_manager/wizards/sync_products.py
@@ -0,0 +1,29 @@
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+import logging
+
+from odoo import models
+
+_logger = logging.getLogger(__name__)
+
+
+class SyncProducts(models.TransientModel):
+ _name = "sync.products"
+ _description = "Synchronize Products"
+
+ def _bind_resync(self, product_ids):
+ products = self.env["product.template"].browse(product_ids)
+ for product in products:
+ try:
+ for bind in product.prestashop_bind_ids:
+ bind.resync()
+ except Exception as e:
+ _logger.info("id %s, attributes %s\n", str(product.id), e)
+
+ def sync_products(self):
+ for product in self:
+ product._bind_resync(product.env.context["active_ids"])
+
+ def sync_all_products(self):
+ for product in self:
+ product._bind_resync([])
diff --git a/connector_prestashop_catalog_manager/wizards/sync_products_view.xml b/connector_prestashop_catalog_manager/wizards/sync_products_view.xml
new file mode 100644
index 000000000..c24e3f7af
--- /dev/null
+++ b/connector_prestashop_catalog_manager/wizards/sync_products_view.xml
@@ -0,0 +1,34 @@
+
+
+
+ sync.products.form
+ sync.products
+
+
+
+
+
+ Sync Products
+ sync.products
+
+ form
+ form
+ new
+
+
+