diff --git a/connector_woocommerce/README.rst b/connector_woocommerce/README.rst new file mode 100644 index 0000000..264a5b5 --- /dev/null +++ b/connector_woocommerce/README.rst @@ -0,0 +1,92 @@ +===================== +WooCommerce Connector +===================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fconnector--woocommerce-lightgray.png?logo=github + :target: https://github.com/OCA/connector-woocommerce/tree/12.0/connector_woocommerce + :alt: OCA/connector-woocommerce +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/connector-woocommerce-12-0/connector-woocommerce-12-0-connector_woocommerce + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/207/12.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Connector between WooCommerce and Odoo + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +#. Go to ... + +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Tech Receptives +* FactorLibre + +Contributors +~~~~~~~~~~~~ + +* Tech-Receptives Solutions Pvt. Ltd. +* Hugo Santos + +* `Obertix `_: + + * Cubells + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +.. |maintainer-cubells| image:: https://github.com/cubells.png?size=40px + :target: https://github.com/cubells + :alt: cubells + +Current `maintainer `__: + +|maintainer-cubells| + +This module is part of the `OCA/connector-woocommerce `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/connector_woocommerce/__init__.py b/connector_woocommerce/__init__.py new file mode 100644 index 0000000..def4400 --- /dev/null +++ b/connector_woocommerce/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import components +from . import models diff --git a/connector_woocommerce/__manifest__.py b/connector_woocommerce/__manifest__.py new file mode 100644 index 0000000..a12fa9e --- /dev/null +++ b/connector_woocommerce/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "WooCommerce Connector", + "version": "12.0.1.0.0", + "category": "Connector", + "author": "Tech Receptives,FactorLibre,Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "http://www.openerp.com", + "maintainers": ["cubells"], + "depends": [ + "connector", + "product_multi_category", + "sale_stock", + ], + "installable": True, + "data": [ + "security/ir.model.access.csv", + "views/backend_views.xml", + ], + "external_dependencies": { + "python": ["woocommerce"], + }, + "application": True, +} diff --git a/connector_woocommerce/components/__init__.py b/connector_woocommerce/components/__init__.py new file mode 100644 index 0000000..092d0f6 --- /dev/null +++ b/connector_woocommerce/components/__init__.py @@ -0,0 +1,5 @@ +from . import core +from . import backend_adapter +from . import binder +from . import importer +from . import mapper diff --git a/connector_woocommerce/components/backend_adapter.py b/connector_woocommerce/components/backend_adapter.py new file mode 100644 index 0000000..3d94361 --- /dev/null +++ b/connector_woocommerce/components/backend_adapter.py @@ -0,0 +1,237 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import socket +import logging +import xmlrpc.client +from odoo.addons.component.core import AbstractComponent +from odoo.addons.queue_job.exception import FailedJobError +from odoo.addons.connector.exception import (NetworkRetryableError, + RetryableJobError) +from datetime import datetime +_logger = logging.getLogger(__name__) + +try: + from woocommerce import API +except ImportError: + _logger.debug("cannot import 'woocommerce'") + +recorder = {} + +WOO_DATETIME_FORMAT = "%Y/%m/%d %H:%M:%S" + + +def call_to_key(method, arguments): + """ Used to "freeze" the method and arguments of a call to WooCommerce + so they can be hashable; they will be stored in a dict. + + Used in both the recorder and the tests. + """ + def freeze(arg): + if isinstance(arg, dict): + items = dict((key, freeze(value)) for key, value + in arg.items()) + return frozenset(iter(items.items())) + elif isinstance(arg, list): + return tuple([freeze(item) for item in arg]) + else: + return arg + + new_args = [] + for arg in arguments: + new_args.append(freeze(arg)) + return (method, tuple(new_args)) + + +def record(method, arguments, result): + """ Utility function which can be used to record test data + during synchronisations. Call it from WooCRUDAdapter._call + + Then ``output_recorder`` can be used to write the data recorded + to a file. + """ + recorder[call_to_key(method, arguments)] = result + + +def output_recorder(filename): + import pprint + with open(filename, "w") as f: + pprint.pprint(recorder, f) + _logger.debug("recorder written to file %s", filename) + + +class WooLocation(object): + + def __init__(self, location, consumer_key, consumer_secret): + self._location = location + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret + + @property + def location(self): + location = self._location + return location + + +class WooAPI(object): + + def __init__(self, location): + """ + :param location: Woocommerce Location + :type location: :class:`WooLocation` + """ + self._location = location + self._api = None + + @property + def api(self): + if not self._api: + api = API( + url=self._location.location, + consumer_key=self._location.consumer_key, + consumer_secret=self._location.consumer_secret, + wp_api=True, + version="wc/v2" + ) + self._api = api + return self._api + + def call(self, method, arguments): + try: + if isinstance(arguments, list): + while arguments and arguments[-1] is None: + arguments.pop() + start = datetime.now() + try: + response = self.api.get(method) + response_json = response.json() + if not response.ok: + if response_json.get("code") and \ + response_json.get("message"): + raise FailedJobError( + "%s error: %s - %s" % (response.status_code, + response_json["code"], + response_json["message"])) + else: + return response.raise_for_status() + result = response_json + except: + _logger.error("api.call(%s, %s) failed", method, arguments) + raise + else: + _logger.debug("api.call(%s, %s) returned %s in %s seconds", + method, arguments, result, + (datetime.now() - start).seconds) + return result + except (socket.gaierror, socket.error, socket.timeout) as err: + raise NetworkRetryableError( + "A network error caused the failure of the job: " + "%s" % err) + except xmlrpc.client.ProtocolError as err: + if err.errcode in [502, # Bad gateway + 503, # Service unavailable + 504]: # Gateway timeout + raise RetryableJobError( + "A protocol error caused the failure of the job:\n" + "URL: %s\n" + "HTTP/HTTPS headers: %s\n" + "Error code: %d\n" + "Error message: %s\n" % + (err.url, err.headers, err.errcode, err.errmsg)) + else: + raise + + +class WooCRUDAdapter(AbstractComponent): + """ External Records Adapter for woo """ + + _name = "woocommerce.crud.adapter" + _inherit = ["base.backend.adapter", "base.woocommerce.connector"] + _usage = "backend.adapter" + + def search(self, filters=None): + """ Search records according to some criterias + and returns a list of ids """ + raise NotImplementedError + + def read(self, id, attributes=None): + """ Returns the information of a record """ + raise NotImplementedError + + def search_read(self, filters=None): + """ Search records according to some criterias + and returns their information""" + raise NotImplementedError + + def create(self, data): + """ Create a record on the external system """ + raise NotImplementedError + + def write(self, id, data): + """ Update records on the external system """ + raise NotImplementedError + + def delete(self, id): + """ Delete a record on the external system """ + raise NotImplementedError + + def _call(self, method, arguments): + try: + wc_api = getattr(self.work, "wc_api") + except AttributeError: + raise AttributeError( + "You must provide a wc_api attribute with a " + "WooAPI instance to be able to use the " + "Backend Adapter." + ) + return wc_api.call(method, arguments) + + +class GenericAdapter(AbstractComponent): + + _name = "woocommerce.adapter" + _inherit = "woocommerce.crud.adapter" + + _woo_model = None + + def search(self, filters=None): + """ Search records according to some criterias + and returns a list of ids + + :rtype: list + """ + return self._call("%s.search" % self._woo_model, + [filters] if filters else [{}]) + + def read(self, id, attributes=None): + """ Returns the information of a record + + :rtype: dict + """ + arguments = [] + if attributes: + # Avoid to pass Null values in attributes. Workaround for + # is not installed, calling info() with None in attributes + # would return a wrong result (almost empty list of + # attributes). The right correction is to install the + # compatibility patch on WooCommerce. + arguments.append(attributes) + return self._call("%s/" % self._woo_model + str(id), []) + + def search_read(self, filters=None): + """ Search records according to some criterias + and returns their information""" + return self._call("%s.list" % self._woo_model, [filters]) + + def create(self, data): + """ Create a record on the external system """ + return self._call("%s.create" % self._woo_model, [data]) + + def write(self, id, data): + """ Update records on the external system """ + return self._call("%s.update" % self._woo_model, + [int(id), data]) + + def delete(self, id): + """ Delete a record on the external system """ + return self._call("%s.delete" % self._woo_model, [int(id)]) diff --git a/connector_woocommerce/components/binder.py b/connector_woocommerce/components/binder.py new file mode 100644 index 0000000..ef53a85 --- /dev/null +++ b/connector_woocommerce/components/binder.py @@ -0,0 +1,27 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class WooModelBinder(Component): + """ + Bindings are done directly on the binding model.woo.product.category + + Binding models are models called ``woo.{normal_model}``, + like ``woo.res.partner`` or ``woo.product.product``. + They are ``_inherits`` of the normal models and contains + the Woo ID, the ID of the Woo Backend and the additional + fields belonging to the Woo instance. + """ + + _name = "woocommerce.binder" + _inherit = ["base.binder", "base.woocommerce.connector"] + _apply_on = [ + "woo.res.partner", + "woo.product.category", + "woo.product.product", + "woo.sale.order", + "woo.sale.order.line", + ] diff --git a/connector_woocommerce/components/core.py b/connector_woocommerce/components/core.py new file mode 100644 index 0000000..9b03efa --- /dev/null +++ b/connector_woocommerce/components/core.py @@ -0,0 +1,15 @@ +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class BaseWoocommerceConnectorComponent(AbstractComponent): + """ Base Woocommerce Connector Component + + All components of this connector should inherit from it. + + """ + _name = "base.woocommerce.connector" + _inherit = "base.connector" + _collection = "wc.backend" diff --git a/connector_woocommerce/components/importer.py b/connector_woocommerce/components/importer.py new file mode 100644 index 0000000..28c2f4c --- /dev/null +++ b/connector_woocommerce/components/importer.py @@ -0,0 +1,259 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +from datetime import datetime +from odoo import fields, _ +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.exception import NothingToDoJob + +_logger = logging.getLogger(__name__) + + +class WooImporter(AbstractComponent): + """ Base importer for WooCommerce """ + + _name = "woocommerce.importer" + _inherit = ["base.importer", "base.woocommerce.connector"] + _usage = "record.importer" + + def __init__(self, work_context): + super(WooImporter, self).__init__(work_context) + self.external_id = None + self.woo_record = None + + def _get_woo_data(self): + """ Return the raw WooCommerce data for ``self.external_id`` """ + return self.backend_adapter.read(self.external_id) + + def _before_import(self): + """ Hook called before the import, when we have the WooCommerce + data""" + + def _is_uptodate(self, binding): + """Return True if the import should be skipped because + it is already up-to-date in OpenERP""" + WOO_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" + dt_fmt = WOO_DATETIME_FORMAT + assert self.woo_record + if not self.woo_record: + return # no update date on WooCommerce, always import it. + if not binding: + return # it does not exist so it should not be skipped + sync = binding.sync_date + if not sync: + return + from_string = fields.Datetime.from_string + sync_date = from_string(sync) + self.woo_record["updated_at"] = {} + self.woo_record["updated_at"] = {"to": datetime.now().strftime(dt_fmt)} + woo_date = from_string(self.woo_record["updated_at"]["to"]) + # if the last synchronization date is greater than the last + # update in woo, we skip the import. + # Important: at the beginning of the exporters flows, we have to + # check if the woo_date is more recent than the sync_date + # and if so, schedule a new import. If we don"t do that, we"ll + # miss changes done in WooCommerce + return woo_date < sync_date + + def _import_dependency(self, external_id, binding_model, + importer=None, always=False): + """ Import a dependency. + + The importer class is a class or subclass of + :class:`WooImporter`. A specific class can be defined. + + :param external_id: id of the related binding to import + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param importer_cls: :class:`openerp.addons.connector.\ + connector.ConnectorUnit` + class or parent class to use for the export. + By default: WooImporter + :type importer_cls: :class:`openerp.addons.connector.\ + connector.MetaConnectorUnit` + :param always: if True, the record is updated even if it already + exists, note that it is still skipped if it has + not been modified on WooCommerce since the last + update. When False, it will import it only when + it does not yet exist. + :type always: boolean + """ + if not external_id: + return + binder = self.binder_for(binding_model) + if always or not binder.to_internal(external_id): + if not importer: + importer = self.component(usage="record.importer", + model_name=binding_model) + try: + importer.run(external_id) + except NothingToDoJob: + _logger.info( + "Dependency import of %s(%s) has been ignored.", + binding_model._name, external_id + ) + + def _import_dependencies(self): + """ Import the dependencies for the record + + Import of dependencies can be done manually or by calling + :meth:`_import_dependency` for each dependency. + """ + return + + def _map_data(self): + """ Returns an instance of + :py:class:`~openerp.addons.connector.unit.mapper.MapRecord` + + """ + return self.mapper.map_record(self.woo_record) + + def _validate_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``_create`` or + ``_update`` if some fields are missing or invalid. + + Raise `InvalidDataError` + """ + return + + def _must_skip(self): + """ Hook called right after we read the data from the backend. + + If the method returns a message giving a reason for the + skipping, the import will be interrupted and the message + recorded in the job (if the import is called directly by the + job, not by dependencies). + + If it returns None, the import will continue normally. + + :returns: None | str | unicode + """ + return + + def _get_binding(self): + return self.binder.to_internal(self.external_id) + + def _create_data(self, map_record, **kwargs): + return map_record.values(for_create=True, **kwargs) + + def _create(self, data): + """ Create the OpenERP record """ + # special check on data before import + self._validate_data(data) + model = self.model.with_context(connector_no_export=True) + binding = model.create(data) + _logger.debug("%d created from woo %s", binding, self.external_id) + return binding + + def _update_data(self, map_record, **kwargs): + return map_record.values(**kwargs) + + def _update(self, binding, data): + """ Update an OpenERP record """ + # special check on data before import + self._validate_data(data) + binding.with_context(connector_no_export=True).write(data) + _logger.debug("%d updated from woo %s", binding, self.external_id) + return + + def _after_import(self, binding): + """ Hook called at the end of the import """ + return + + def run(self, external_id, force=False): + """ Run the synchronization + + :param external_id: identifier of the record on WooCommerce + """ + self.external_id = external_id + lock_name = "import({}, {}, {}, {})".format( + self.backend_record._name, + self.backend_record.id, + self.work.model_name, + external_id, + ) + + try: + self.woo_record = self._get_woo_data() + except IDMissingInBackend: + return _("Record does no longer exist in WooCommerce") + + skip = self._must_skip() + if skip: + return skip + + binding = self._get_binding() + if not force and self._is_uptodate(binding): + return _("Already up-to-date.") + + # Keep a lock on this import until the transaction is committed + # The lock is kept since we have detected that the informations + # will be updated into Odoo + self.advisory_lock_or_retry(lock_name) + self._before_import() + + # import the missing linked resources + self._import_dependencies() + + map_record = self._map_data() + + if binding: + record = self._update_data(map_record) + self._update(binding, record) + else: + record = self._create_data(map_record) + binding = self._create(record) + self.binder.bind(self.external_id, binding) + + self._after_import(binding) + + +class BatchImporter(AbstractComponent): + """ The role of a BatchImporter is to search for a list of + items to import, then it can either import them directly or delay + the import of each item separately. + """ + + _name = "woocommerce.batch.importer" + _inherit = ["base.importer", "base.woocommerce.connector"] + _usage = "batch.importer" + + def run(self, filters=None): + """ Run the synchronization """ + record_ids = self.backend_adapter.search(filters) + for record_id in record_ids: + self._import_record(record_id) + + def _import_record(self, record_id): + """ Import a record directly or delay the import of the record. + + Method to implement in sub-classes. + """ + raise NotImplementedError + + +class DirectBatchImporter(AbstractComponent): + """ Import the records directly, without delaying the jobs. """ + _name = "woocommerce.direct.batch.importer" + _inherit = "woocommerce.batch.importer" + + def _import_record(self, record_id): + """ Import the record directly """ + self.model.import_record(self.backend_record, record_id) + + +class DelayedBatchImporter(AbstractComponent): + """ Delay import of the records """ + + _name = "woocommerce.delayed.batch.importer" + _inherit = "woocommerce.batch.importer" + + def _import_record(self, external_id, job_options=None, **kwargs): + """ Delay the import of the records""" + delayable = self.model.with_delay(**job_options or {}) + delayable.import_record(self.backend_record, external_id, **kwargs) diff --git a/connector_woocommerce/components/mapper.py b/connector_woocommerce/components/mapper.py new file mode 100644 index 0000000..09d9940 --- /dev/null +++ b/connector_woocommerce/components/mapper.py @@ -0,0 +1,28 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo.addons.component.core import AbstractComponent + + +class WooImportMapper(AbstractComponent): + _name = "woocommerce.import.mapper" + _inherit = ["base.import.mapper", "base.woocommerce.connector"] + _usage = "import.mapper" + + +class WooExportMapper(AbstractComponent): + _name = "woocommerce.export.mapper" + _inherit = ["base.export.mapper", "base.woocommerce.connector"] + _usage = "export.mapper" + + +def normalize_datetime(field): + """Change a invalid date which comes from Woo, if + no real date is set to null for correct import to + OpenERP""" + + def modifier(self, record, to_attr): + if record[field] == "0000-00-00 00:00:00": + return None + return record[field] + return modifier diff --git a/connector_woocommerce/models/__init__.py b/connector_woocommerce/models/__init__.py new file mode 100644 index 0000000..f59d363 --- /dev/null +++ b/connector_woocommerce/models/__init__.py @@ -0,0 +1,6 @@ +from . import woocommerce_binding +from . import woocommerce_backend +from . import partner +from . import product_category +from . import product +from . import sale_order diff --git a/connector_woocommerce/models/partner/__init__.py b/connector_woocommerce/models/partner/__init__.py new file mode 100644 index 0000000..79ab5dc --- /dev/null +++ b/connector_woocommerce/models/partner/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import importer diff --git a/connector_woocommerce/models/partner/common.py b/connector_woocommerce/models/partner/common.py new file mode 100644 index 0000000..a6422fb --- /dev/null +++ b/connector_woocommerce/models/partner/common.py @@ -0,0 +1,60 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +from odoo import models, fields +from odoo.addons.component.core import Component + +from ...components.backend_adapter import WOO_DATETIME_FORMAT + +_logger = logging.getLogger(__name__) + + +class WooResPartner(models.Model): + _name = "woo.res.partner" + _inherit = "woo.binding" + _inherits = {"res.partner": "odoo_id"} + _description = "woo res partner" + + _rec_name = "name" + + odoo_id = fields.Many2one( + comodel_name="res.partner", + string="Partner", + required=True, + ondelete="cascade", + ) + backend_id = fields.Many2one( + comodel_name="wc.backend", + string="Woo Backend", + store=True, + ) + + +class CustomerAdapter(Component): + _name = "woocommerce.partner.adapter" + _inherit = "woocommerce.adapter" + _apply_on = "woo.res.partner" + + _woo_model = "customers" + + def search(self, filters=None, from_date=None, to_date=None): + """ Search records according to some criteria and return a + list of ids + + :rtype: list + """ + if not filters: + filters = {} + dt_fmt = WOO_DATETIME_FORMAT + if not from_date: + # updated_at include the created records + filters.setdefault("updated_at", {}) + filters["updated_at"]["from"] = from_date.strftime(dt_fmt) + if not to_date: + filters.setdefault("updated_at", {}) + filters["updated_at"]["to"] = to_date.strftime(dt_fmt) + # the search method is on ol_customer instead of customer + customers = self._call("customers", [filters] if filters else [{}]) + return [customer["id"] for customer in customers] diff --git a/connector_woocommerce/models/partner/importer.py b/connector_woocommerce/models/partner/importer.py new file mode 100644 index 0000000..7850320 --- /dev/null +++ b/connector_woocommerce/models/partner/importer.py @@ -0,0 +1,112 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# 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 mapping + +_logger = logging.getLogger(__name__) + + +class CustomerBatchImporter(Component): + """ Import the WooCommerce Partners. + + For every partner in the list, a delayed job is created. + """ + _name = "woocommerce.partner.batch.importer" + _inherit = "woocommerce.delayed.batch.importer" + _apply_on = "woo.res.partner" + + def run(self, filters=None): + """ Run the synchronization """ + from_date = filters.pop("from_date", None) + to_date = filters.pop("to_date", None) + record_ids = self.backend_adapter.search( + filters, + from_date=from_date, + to_date=to_date, + ) + _logger.info("search for woo partners %s returned %s", + filters, record_ids) + for record_id in record_ids: + self._import_record(record_id) + + +class CustomerImporter(Component): + _name = "woocommerce.partner.importer" + _inherit = "woocommerce.importer" + _apply_on = "woo.res.partner" + + +class CustomerImportMapper(Component): + _name = "woocommerce.partner.import.mapper" + _inherit = "woocommerce.import.mapper" + _apply_on = "woo.res.partner" + + direct = [ + ("email", "email"), + ] + + @mapping + def name(self, record): + return {"name": record["first_name"] + " " + record["last_name"]} + + @mapping + def city(self, record): + if record.get("billing_address"): + rec = record["billing_address"] + return {"city": rec["city"] or None} + + @mapping + def zip(self, record): + if record.get("billing_address"): + rec = record["customer"]["billing_address"] + return {"zip": rec["postcode"] or None} + + @mapping + def address(self, record): + if record.get("billing_address"): + rec = record["billing_address"] + return {"street": rec["address_1"] or None} + + @mapping + def address_2(self, record): + if record.get("billing_address"): + rec = record["billing_address"] + return {"street2": rec["address_2"] or None} + + @mapping + def country(self, record): + if record.get("billing_address"): + rec = record["billing_address"] + if rec["country"]: + country_id = self.env["res.country"].search( + [("code", "=", rec["country"])]) + country_id = country_id.id + else: + country_id = False + return {"country_id": country_id} + + @mapping + def state(self, record): + if record.get("billing_address"): + rec = record["billing_address"] + if rec["state"] and rec["country"]: + state_id = self.env["res.country.state"].search( + [("code", "=", rec["state"])]) + if not state_id: + country_id = self.env["res.country"].search( + [("code", "=", rec["country"])]) + state_id = self.env["res.country.state"].create( + {"name": rec["state"], + "code": rec["state"], + "country_id": country_id.id}) + state_id = state_id.id or False + else: + state_id = False + return {"state_id": state_id} + + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} diff --git a/connector_woocommerce/models/product/__init__.py b/connector_woocommerce/models/product/__init__.py new file mode 100644 index 0000000..79ab5dc --- /dev/null +++ b/connector_woocommerce/models/product/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import importer diff --git a/connector_woocommerce/models/product/common.py b/connector_woocommerce/models/product/common.py new file mode 100644 index 0000000..68e0286 --- /dev/null +++ b/connector_woocommerce/models/product/common.py @@ -0,0 +1,67 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import models, fields +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class WooProductProduct(models.Model): + _name = "woo.product.product" + _inherit = "woo.binding" + _inherits = {"product.product": "odoo_id"} + _description = "woo product product" + + _rec_name = "name" + + odoo_id = fields.Many2one( + comodel_name="product.product", + string="product", + required=True, + ondelete="cascade", + ) + backend_id = fields.Many2one( + comodel_name="wc.backend", + string="Woo Backend", + store=True, + required=True, + ) + + slug = fields.Char( + string="Slug Name", + ) + created_at = fields.Date() + weight = fields.Float() + + +class ProductProductAdapter(Component): + _name = "woocommerce.product.product.adapter" + _inherit = "woocommerce.adapter" + _apply_on = "woo.product.product" + + _woo_model = "products" + + def search(self, filters=None, from_date=None, to_date=None): + """ Search records according to some criteria and return a + list of ids + + :rtype: list + """ + if not filters: + filters = {} + WOO_DATETIME_FORMAT = "%Y/%m/%d %H:%M:%S" + dt_fmt = WOO_DATETIME_FORMAT + if not from_date: + # updated_at include the created records + filters.setdefault("updated_at", {}) + filters["updated_at"]["from"] = from_date.strftime(dt_fmt) + if not to_date: + filters.setdefault("updated_at", {}) + filters["updated_at"]["to"] = to_date.strftime(dt_fmt) + products = self._call("products", + [filters] if filters else [{}]) + return [product["id"] for product in products] diff --git a/connector_woocommerce/models/product/importer.py b/connector_woocommerce/models/product/importer.py new file mode 100644 index 0000000..d8d4f78 --- /dev/null +++ b/connector_woocommerce/models/product/importer.py @@ -0,0 +1,172 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import urllib.request +import urllib.error +import urllib.parse +import base64 + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping +from odoo.addons.connector.exception import MappingError + +_logger = logging.getLogger(__name__) + + +class ProductBatchImporter(Component): + """ Import the WooCommerce Products. + + For every product in the list, a delayed job is created. + """ + _name = 'woocommerce.product.product.batch.importer' + _inherit = 'woocommerce.delayed.batch.importer' + _apply_on = ['woo.product.product'] + + def run(self, filters=None): + """ Run the synchronization """ + from_date = filters.pop('from_date', None) + to_date = filters.pop('to_date', None) + record_ids = self.backend_adapter.search( + filters, + from_date=from_date, + to_date=to_date, + ) + _logger.debug('search for woo Products %s returned %s', + filters, record_ids) + for record_id in record_ids: + self._import_record(record_id) + + +class ProductProductImporter(Component): + _name = 'woocommerce.product.product.importer' + _inherit = 'woocommerce.importer' + _apply_on = ['woo.product.product'] + + def _import_dependencies(self): + """ Import the dependencies for the record""" + record = self.woo_record + for woo_category in record['categories']: + self._import_dependency(woo_category['id'], + 'woo.product.category') + + def _after_import(self, binding): + """ Hook called at the end of the import """ + image_importer = self.component(usage='product.image.importer') + image_importer.run(self.woo_record, binding) + return + + +class ProductImageImporter(Component): + + """ Import images for a record. + + Usually called from importers, in ``_after_import``. + For instance from the products importer. + """ + _name = 'woocommerce.product.image.importer' + _inherit = 'woocommerce.importer' + _apply_on = ['woo.product.product'] + _usage = 'product.image.importer' + + def _get_images(self, storeview_id=None): + return self.backend_adapter.get_images(self.external_id) + + def _sort_images(self, images): + """ Returns a list of images sorted by their priority. + An image with the 'image' type is the the primary one. + The other images are sorted by their position. + + The returned list is reversed, the items at the end + of the list have the higher priority. + """ + if not images: + return {} + # place the images where the type is 'image' first then + # sort them by the reverse priority (last item of the list has + # the the higher priority) + + def _get_binary_image(self, image_data): + url = image_data['src'] + try: + request = urllib.request.Request(url) + binary = urllib.request.urlopen(request) + except urllib.error.HTTPError as err: + if err.code == 404: + # the image is just missing, we skip it + return + else: + # we don't know why we couldn't download the image + # so we propagate the error, the import will fail + # and we have to check why it couldn't be accessed + raise + else: + return binary.read() + + def _write_image_data(self, binding, binary, image_data): + binding = binding.with_context(connector_no_export=True) + binding.write({'image': base64.b64encode(binary)}) + + def run(self, woo_record, binding): + images = woo_record['images'] + binary = None + while not binary and images: + image_data = images.pop() + binary = self._get_binary_image(image_data) + if not binary: + return + self._write_image_data(binding, binary, image_data) + + +class ProductProductImportMapper(Component): + _name = 'woocommerce.product.product.import.mapper' + _inherit = 'woocommerce.import.mapper' + _apply_on = 'woo.product.product' + + direct = [ + ('name', 'name'), + ('description', 'description'), + ('weight', 'weight'), + ('price', 'list_price') + ] + + @mapping + def is_active(self, record): + """Check if the product is active in Woo + and set active flag in OpenERP + status == 1 in Woo means active""" + return {'active': record.get('catalog_visibility') == 'visible'} + + @mapping + def type(self, record): + if record['type'] == 'simple': + return {'type': 'product'} + + @mapping + def categories(self, record): + woo_categories = record['categories'] + binder = self.binder_for('woo.product.category') + + category_ids = [] + main_categ_id = None + + for woo_category in woo_categories: + cat = binder.to_internal(woo_category['id'], unwrap=True) + if not cat: + raise MappingError("The product category with " + "woo id %s is not imported." % + woo_category['id']) + category_ids.append(cat.id) + + if category_ids: + main_categ_id = category_ids.pop(0) + + result = {'categ_ids': [(6, 0, category_ids)]} + if main_categ_id: # OpenERP assign 'All Products' if not specified + result['categ_id'] = main_categ_id + return result + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} diff --git a/connector_woocommerce/models/product_category/__init__.py b/connector_woocommerce/models/product_category/__init__.py new file mode 100644 index 0000000..05c4a41 --- /dev/null +++ b/connector_woocommerce/models/product_category/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import importer \ No newline at end of file diff --git a/connector_woocommerce/models/product_category/common.py b/connector_woocommerce/models/product_category/common.py new file mode 100644 index 0000000..869d19b --- /dev/null +++ b/connector_woocommerce/models/product_category/common.py @@ -0,0 +1,68 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +from odoo import models, fields +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class WooProductCategory(models.Model): + _name = "woo.product.category" + _inherit = "woo.binding" + _inherits = {"product.category": "odoo_id"} + _description = "woo product category" + + _rec_name = "name" + + odoo_id = fields.Many2one( + comodel_name="product.category", + string="category", + required=True, + ondelete="cascade", + ) + backend_id = fields.Many2one( + comodel_name="wc.backend", + string="Woo Backend", + store=True, + ) + slug = fields.Char( + string="Slug Name", + ) + woo_parent_id = fields.Many2one( + comodel_name="woo.product.category", + string="Woo Parent Category", + ondelete="cascade", + ) + description = fields.Char() + count = fields.Integer() + + +class CategoryAdapter(Component): + _name = "woocommerce.product.category.adapter" + _inherit = "woocommerce.adapter" + _apply_on = "woo.product.category" + + _woo_model = "products/categories" + + def search(self, filters=None, from_date=None, to_date=None): + """ Search records according to some criteria and return a + list of ids + + :rtype: list + """ + if not filters: + filters = {} + WOO_DATETIME_FORMAT = "%Y/%m/%d %H:%M:%S" + dt_fmt = WOO_DATETIME_FORMAT + if not from_date: + filters.setdefault("updated_at", {}) + filters["updated_at"]["from"] = from_date.strftime(dt_fmt) + if not to_date: + filters.setdefault("updated_at", {}) + filters["updated_at"]["to"] = to_date.strftime(dt_fmt) + categories = self._call("products/categories", + [filters] if filters else [{}]) + return [category["id"] for category in categories] diff --git a/connector_woocommerce/models/product_category/importer.py b/connector_woocommerce/models/product_category/importer.py new file mode 100644 index 0000000..cf69bdb --- /dev/null +++ b/connector_woocommerce/models/product_category/importer.py @@ -0,0 +1,81 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# 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 mapping +from odoo.addons.connector.exception import MappingError + +_logger = logging.getLogger(__name__) + + +class CategoryBatchImporter(Component): + """ Import the WooCommerce Partners. + + For every partner in the list, a delayed job is created. + """ + _name = 'woocommerce.product.category.batch.importer' + _inherit = 'woocommerce.delayed.batch.importer' + _apply_on = ['woo.product.category'] + + def _import_record(self, external_id, job_options=None): + """ Delay a job for the import """ + super(CategoryBatchImporter, self)._import_record( + external_id, job_options=job_options) + + def run(self, filters=None): + """ Run the synchronization """ + from_date = filters.pop('from_date', None) + to_date = filters.pop('to_date', None) + record_ids = self.backend_adapter.search( + filters, + from_date=from_date, + to_date=to_date, + ) + _logger.debug('search for woo Product Category %s returned %s', + filters, record_ids) + for record_id in record_ids: + self._import_record(record_id) + + +class ProductCategoryImporter(Component): + _name = 'woocommerce.product.category.importer' + _inherit = 'woocommerce.importer' + _apply_on = ['woo.product.category'] + + def _import_dependencies(self): + """ Import the dependencies for the record""" + record = self.woo_record + # import parent category + # the root category has a 0 parent_id + if record.get('parent'): + self._import_dependency(record.get('parent'), self.model) + + +class ProductCategoryImportMapper(Component): + _name = 'woocommerce.product.category.import.mapper' + _inherit = 'woocommerce.import.mapper' + _apply_on = 'woo.product.category' + + direct = [ + ('name', 'name') + ] + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + @mapping + def parent_id(self, record): + if not record.get('parent'): + return + binder = self.binder_for() + parent_binding = binder.to_internal(record['parent']) + + if not parent_binding: + raise MappingError("The product category with " + "woocommerce id %s is not imported." % + record['parent_id']) + + parent = parent_binding.odoo_id + return {'parent_id': parent.id, 'woo_parent_id': parent_binding.id} diff --git a/connector_woocommerce/models/sale_order/__init__.py b/connector_woocommerce/models/sale_order/__init__.py new file mode 100644 index 0000000..79ab5dc --- /dev/null +++ b/connector_woocommerce/models/sale_order/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import importer diff --git a/connector_woocommerce/models/sale_order/common.py b/connector_woocommerce/models/sale_order/common.py new file mode 100644 index 0000000..45ac0b7 --- /dev/null +++ b/connector_woocommerce/models/sale_order/common.py @@ -0,0 +1,125 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import models, fields, api +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class WooSaleOrderStatus(models.Model): + _name = "woo.sale.order.status" + _description = "WooCommerce Sale Order Status" + + name = fields.Char() + desc = fields.Text( + string="Description", + ) + + +class WooSaleOrder(models.Model): + _name = "woo.sale.order" + _inherit = "woo.binding" + _inherits = {"sale.order": "odoo_id"} + _description = "Woo Sale Order" + + _rec_name = "name" + + status_id = fields.Many2one( + comodel_name="woo.sale.order.status", + string="WooCommerce Order Status", + ) + odoo_id = fields.Many2one( + comodel_name="sale.order", + string="Sale Order", + required=True, + ondelete="cascade", + ) + woo_order_line_ids = fields.One2many( + comodel_name="woo.sale.order.line", + inverse_name="woo_order_id", + string="Woo Order Lines" + ) + backend_id = fields.Many2one( + comodel_name="wc.backend", + string="Woo Backend", + store=True, + required=True, + ) + + +class WooSaleOrderLine(models.Model): + _name = "woo.sale.order.line" + _inherits = {"sale.order.line": "odoo_id"} + _description = "Woo Sale Order Line" + + woo_order_id = fields.Many2one( + comodel_name="woo.sale.order", + string="Woo Sale Order", + required=True, + ondelete="cascade", + index=True, + ) + odoo_id = fields.Many2one( + comodel_name="sale.order.line", + string="Sale Order Line", + required=True, + ondelete="cascade", + ) + backend_id = fields.Many2one( + related="woo_order_id.backend_id", + string="Woo Backend", + readonly=True, + store=True, + required=False, + ) + + @api.model + def create(self, values): + woo_order_id = values["woo_order_id"] + binding = self.env["woo.sale.order"].browse(woo_order_id) + values["order_id"] = binding.odoo_id.id + binding = super(WooSaleOrderLine, self).create(values) + return binding + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + woo_bind_ids = fields.One2many( + comodel_name="woo.sale.order.line", + inverse_name="odoo_id", + string="WooCommerce Bindings", + ) + + +class SaleOrderAdapter(Component): + _name = "woocommerce.sale.order.adapater" + _inherit = "woocommerce.adapter" + _apply_on = "woo.sale.order" + + _woo_model = "orders" + + def search(self, filters=None, from_date=None, to_date=None): + """ Search records according to some criteria and return a + list of ids + + :rtype: list + """ + if not filters: + filters = {} + WOO_DATETIME_FORMAT = "%Y/%m/%d %H:%M:%S" + dt_fmt = WOO_DATETIME_FORMAT + if not from_date: + # updated_at include the created records + filters.setdefault("updated_at", {}) + filters["updated_at"]["from"] = from_date.strftime(dt_fmt) + if not to_date: + filters.setdefault("updated_at", {}) + filters["updated_at"]["to"] = to_date.strftime(dt_fmt) + orders = self._call("orders", + [filters] if filters else [{}]) + return [order["id"] for order in orders] diff --git a/connector_woocommerce/models/sale_order/importer.py b/connector_woocommerce/models/sale_order/importer.py new file mode 100644 index 0000000..02edbc7 --- /dev/null +++ b/connector_woocommerce/models/sale_order/importer.py @@ -0,0 +1,197 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# 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 mapping + +_logger = logging.getLogger(__name__) + + +class SaleOrderBatchImporter(Component): + """ Import the WooCommerce Orders. + + For every order in the list, a delayed job is created. + """ + _name = "woocommerce.sale.order.batch.importer" + _inherit = "woocommerce.delayed.batch.importer" + _apply_on = ["woo.sale.order"] + + def _import_record(self, external_id, job_options=None, **kwargs): + job_options = { + "max_retries": 0, + "priority": 5, + } + return super(SaleOrderBatchImporter, self)._import_record( + external_id, job_options=job_options) + + def run(self, filters=None): + """ Run the synchronization """ + from_date = filters.pop("from_date", None) + to_date = filters.pop("to_date", None) + record_ids = self.backend_adapter.search( + filters, + from_date=from_date, + to_date=to_date, + ) + order_ids = [] + for record_id in record_ids: + woo_sale_order = self.env["woo.sale.order"].search( + [("external_id", "=", record_id)]) + if woo_sale_order: + self.update_existing_order(woo_sale_order[0], record_id) + else: + order_ids.append(record_id) + _logger.info("search for woo partners %s returned %s", + filters, record_ids) + for record_id in order_ids: + self._import_record(record_id) + + +class SaleOrderImporter(Component): + _name = "woocommerce.sale.order.importer" + _inherit = "woocommerce.importer" + _apply_on = ["woo.sale.order"] + + def _import_addresses(self): + record = self.woo_record + self._import_dependency(record["customer_id"], + "woo.res.partner") + + def _import_dependencies(self): + """ Import the dependencies for the record""" + record = self.woo_record + + self._import_addresses() + record = record["items"] + for line in record: + _logger.debug("line: %s", line) + if "product_id" in line: + self._import_dependency(line["product_id"], + "woo.product.product") + + def _clean_woo_items(self, resource): + """ + Method that clean the sale order line given by WooCommerce before + importing it + + This method has to stay here because it allow to customize the + behavior of the sale order. + + """ + child_items = {} # key is the parent item id + top_items = [] + + # Group the childs with their parent + for item in resource["line_items"]: + if item.get("parent_item_id"): + child_items.setdefault(item["parent_item_id"], []).append(item) + else: + top_items.append(item) + + all_items = [] + for top_item in top_items: + all_items.append(top_item) + resource["items"] = all_items + return resource + + def _get_woo_data(self): + """ Return the raw WooCommerce data for ``self.external_id`` """ + record = super(SaleOrderImporter, self)._get_woo_data() + # sometimes we need to clean woo items (ex : configurable + # product in a sale) + record = self._clean_woo_items(record) + return record + + +class SaleOrderImportMapper(Component): + _name = "woocommerce.sale.order.mapper" + _inherit = "woocommerce.import.mapper" + _apply_on = "woo.sale.order" + + direct = [ + ("number", "name"), + ] + + children = [("items", "woo_order_line_ids", "woo.sale.order.line")] + + @mapping + def status(self, record): + if record["status"]: + status_id = self.env["woo.sale.order.status"].search( + [("name", "=", record["status"])]) + if status_id: + return {"status_id": status_id[0].id} + else: + status_id = self.env["woo.sale.order.status"].create({ + "name": record["status"] + }) + return {"status_id": status_id.id} + else: + return {"status_id": False} + + @mapping + def customer_id(self, record): + binder = self.binder_for("woo.res.partner") + if record["customer_id"]: + partner = binder.to_internal(record["customer_id"], + unwrap=True) or False + assert partner, ("Please Check Customer Role \ + in WooCommerce") + result = {"partner_id": partner.id} + else: + customer = record["customer"]["billing_address"] + country_id = False + state_id = False + if customer["country"]: + country_id = self.env["res.country"].search( + [("code", "=", customer["country"])]) + if country_id: + country_id = country_id.id + if customer["state"]: + state_id = self.env["res.country.state"].search( + [("code", "=", customer["state"])]) + if state_id: + state_id = state_id.id + name = customer["first_name"] + " " + customer["last_name"] + partner_dict = { + "name": name, + "city": customer["city"], + "phone": customer["phone"], + "zip": customer["postcode"], + "state_id": state_id, + "country_id": country_id, + } + partner_id = self.env["res.partner"].create(partner_dict) + partner_dict.update({ + "backend_id": self.backend_record.id, + "openerp_id": partner_id.id, + }) + result = {"partner_id": partner_id.id} + return result + + @mapping + def backend_id(self, record): + return {"backend_id": self.backend_record.id} + + +class SaleOrderLineImportMapper(Component): + _name = "woocommerce.sale.order.line.mapper" + _inherit = "woocommerce.import.mapper" + _apply_on = "woo.sale.order.line" + + direct = [ + ("quantity", "product_uom_qty"), + ("name", "name"), + ("price", "price_unit"), + ] + + @mapping + def product_id(self, record): + binder = self.binder_for("woo.product.product") + product = binder.to_internal(record["product_id"], unwrap=True) + assert product is not None, ( + "product_id %s should have been imported in " + "SaleOrderImporter._import_dependencies" % record["product_id"]) + return {"product_id": product.id} diff --git a/connector_woocommerce/models/woocommerce_backend/__init__.py b/connector_woocommerce/models/woocommerce_backend/__init__.py new file mode 100644 index 0000000..e4193cf --- /dev/null +++ b/connector_woocommerce/models/woocommerce_backend/__init__.py @@ -0,0 +1 @@ +from . import common diff --git a/connector_woocommerce/models/woocommerce_backend/common.py b/connector_woocommerce/models/woocommerce_backend/common.py new file mode 100644 index 0000000..4b2bbb4 --- /dev/null +++ b/connector_woocommerce/models/woocommerce_backend/common.py @@ -0,0 +1,212 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from contextlib import contextmanager + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +from ...components.backend_adapter import WooLocation, WooAPI + +_logger = logging.getLogger(__name__) + +try: + from woocommerce import API +except ImportError: + _logger.debug("Cannot import 'woocommerce'") + +IMPORT_DELTA_BUFFER = 30 # seconds + + +class WooBackend(models.Model): + _name = "wc.backend" + _inherit = "connector.backend" + _description = "WooCommerce Backend Configuration" + + @api.model + def select_versions(self): + """ Available versions in the backend. + + Can be inherited to add custom versions. Using this method + to add a version from an ``_inherit`` does not constrain + to redefine the ``version`` field in the ``_inherit`` model. + """ + return [("v2", "V2")] + + name = fields.Char( + required=True, + ) + location = fields.Char( + string="Url", + required=True, + ) + consumer_key = fields.Char() + consumer_secret = fields.Char() + version = fields.Selection( + selection="select_versions", + required=True, + ) + verify_ssl = fields.Boolean( + string="Verify SSL", + ) + warehouse_id = fields.Many2one( + comodel_name="stock.warehouse", + string="Warehouse", + required=True, + help="Warehouse used to compute the stock quantities.", + ) + company_id = fields.Many2one( + comodel_name="res.company", + related="warehouse_id.company_id", + string="Company", + readonly=True, + ) + default_lang_id = fields.Many2one( + comodel_name="res.lang", + string="Default Language", + help="If a default language is selected, the records " + "will be imported in the translation of this language.\n" + "Note that a similar configuration exists " + "for each storeview.", + ) + + @contextmanager + @api.multi + def work_on(self, model_name, **kwargs): + self.ensure_one() + # lang = self.default_lang_id + # if lang.code != self.env.context.get("lang"): + # self = self.with_context(lang=lang.code) + woocommerce_location = WooLocation( + self.location, + self.consumer_key, + self.consumer_secret + ) + # TODO: Check Auth Basic + # if self.use_auth_basic: + # magento_location.use_auth_basic = True + # magento_location.auth_basic_username = self.auth_basic_username + # magento_location.auth_basic_password = self.auth_basic_password + wc_api = WooAPI(woocommerce_location) + _super = super(WooBackend, self) + with _super.work_on(model_name, wc_api=wc_api, **kwargs) as work: + yield work + + @api.multi + def get_product_ids(self, data): + product_ids = [x["id"] for x in data["products"]] + product_ids = sorted(product_ids) + return product_ids + + @api.multi + def get_product_category_ids(self, data): + product_category_ids = [x["id"] for x in data["product_categories"]] + product_category_ids = sorted(product_category_ids) + return product_category_ids + + @api.multi + def get_customer_ids(self, data): + customer_ids = [x["id"] for x in data["customers"]] + customer_ids = sorted(customer_ids) + return customer_ids + + @api.multi + def get_order_ids(self, data): + order_ids = self.check_existing_order(data) + return order_ids + + @api.multi + def update_existing_order(self, woo_sale_order, data): + """ Enter Your logic for Existing Sale Order """ + return True + + @api.multi + def check_existing_order(self, data): + order_ids = [] + for val in data["orders"]: + woo_sale_order = self.env["woo.sale.order"].search( + [("external_id", "=", val["id"])]) + if woo_sale_order: + self.update_existing_order(woo_sale_order[0], val) + continue + order_ids.append(val["id"]) + return order_ids + + @api.multi + def test_connection(self): + location = self.location + cons_key = self.consumer_key + sec_key = self.consumer_secret + + wcapi = API(url=location, consumer_key=cons_key, + consumer_secret=sec_key, + wp_api=True, + version="wc/v2") + r = wcapi.get("products") + if r.status_code == 404: + raise UserError(_("Enter Valid url")) + val = r.json() + if "errors" in r.json(): + msg = val["errors"][0]["message"] + "\n" + val["errors"][0]["code"] + raise UserError(_(msg)) + else: + raise UserError(_("Test Success")) + + @api.multi + def import_categories(self): + for backend in self: + self.env["woo.product.category"].with_delay().import_batch(backend) + return True + + @api.multi + def import_products(self): + for backend in self: + self.env["woo.product.product"].with_delay().import_batch(backend) + return True + + @api.multi + def import_customers(self): + for backend in self: + self.env["woo.res.partner"].with_delay().import_batch(backend) + return True + + @api.multi + def import_orders(self): + for backend in self: + self.env["woo.sale.order"].with_delay().import_batch(backend) + return True + + # @api.multi + # def import_order(self): + # session = ConnectorSession(self.env.cr, self.env.uid, + # context=self.env.context) + # import_start_time = datetime.now() + # backend_id = self.id + # from_date = None + # sale_order_import_batch.delay( + # session, "woo.sale.order", backend_id, + # {"from_date": from_date, + # "to_date": import_start_time}, priority=4) + # return True + + # @api.multi + # def import_categories(self): + # """ Import Product categories """ + # for backend in self: + # backend.import_category() + # return True + + # @api.multi + # def import_products(self): + # """ Import categories from all websites """ + # for backend in self: + # backend.import_product() + # return True + + # @api.multi + # def import_orders(self): + # """ Import Orders from all websites """ + # for backend in self: + # backend.import_order() + # return True diff --git a/connector_woocommerce/models/woocommerce_binding/__init__.py b/connector_woocommerce/models/woocommerce_binding/__init__.py new file mode 100644 index 0000000..e4193cf --- /dev/null +++ b/connector_woocommerce/models/woocommerce_binding/__init__.py @@ -0,0 +1 @@ +from . import common diff --git a/connector_woocommerce/models/woocommerce_binding/common.py b/connector_woocommerce/models/woocommerce_binding/common.py new file mode 100644 index 0000000..3f2d1c1 --- /dev/null +++ b/connector_woocommerce/models/woocommerce_binding/common.py @@ -0,0 +1,69 @@ +# Copyright 2009 Tech-Receptives Solutions Pvt. Ltd. +# Copyright 2018 FactorLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models, fields +from odoo.addons.queue_job.job import job, related_action + + +class WooBinding(models.AbstractModel): + + """ Abstract Model for the Bindigs. + + All the models used as bindings between WooCommerce and OpenERP + (``woo.res.partner``, ``woo.product.product``, ...) should + ``_inherit`` it. + """ + _name = "woo.binding" + _inherit = "external.binding" + _description = "Woo Binding (abstract)" + + # openerp_id = openerp-side id must be declared in concrete model + backend_id = fields.Many2one( + comodel_name="wc.backend", + string="Woo Backend", + required=True, + ondelete="restrict", + ) + # fields.Char because 0 is a valid WooCommerce ID + external_id = fields.Char(string="ID on Woo") + + _sql_constraints = [ + ("woo_uniq", "unique(backend_id, external_id)", + "A binding already exists with the same Woo ID."), + ] + + @job(default_channel="root.woocommerce") + @api.model + def import_batch(self, backend, filters=None): + """ Prepare the import of records modified on Woocommerce""" + if not filters: + filters = {} + with backend.work_on(self._name) as work: + importer = work.component(usage="batch.importer") + return importer.run(filters=filters) + + @job(default_channel="root.woocommerce") + @api.model + def import_record(self, backend, external_id, force=False): + """ Import a Woocommerce record """ + with backend.work_on(self._name) as work: + importer = work.component(usage="record.importer") + return importer.run(external_id, force=force) + + @job(default_channel="root.woocommerce") + @related_action(action="related_action_unwrap_binding") + @api.multi + def export_record(self, fields=None): + """ Export a record on Woocommerce """ + self.ensure_one() + with self.backend_id.work_on(self._name) as work: + exporter = work.component(usage="record.exporter") + return exporter.run(self, fields) + + @job(default_channel="root.woocommerce") + def export_delete_record(self, backend, external_id): + """ Delete a record on Woocommerce """ + with backend.work_on(self._name) as work: + deleter = work.component(usage="record.exporter.deleter") + return deleter.run(external_id) diff --git a/connector_woocommerce/readme/CONTRIBUTORS.rst b/connector_woocommerce/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..2e36beb --- /dev/null +++ b/connector_woocommerce/readme/CONTRIBUTORS.rst @@ -0,0 +1,6 @@ +* Tech-Receptives Solutions Pvt. Ltd. +* Hugo Santos + +* `Obertix `_: + + * Cubells diff --git a/connector_woocommerce/readme/DESCRIPTION.rst b/connector_woocommerce/readme/DESCRIPTION.rst new file mode 100644 index 0000000..c035633 --- /dev/null +++ b/connector_woocommerce/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Connector between WooCommerce and Odoo diff --git a/connector_woocommerce/readme/USAGE.rst b/connector_woocommerce/readme/USAGE.rst new file mode 100644 index 0000000..46f09cb --- /dev/null +++ b/connector_woocommerce/readme/USAGE.rst @@ -0,0 +1 @@ +#. Go to ... diff --git a/connector_woocommerce/security/ir.model.access.csv b/connector_woocommerce/security/ir.model.access.csv new file mode 100644 index 0000000..e298eaa --- /dev/null +++ b/connector_woocommerce/security/ir.model.access.csv @@ -0,0 +1,22 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_wc_backend","wc_backend connector manager","model_wc_backend","connector.group_connector_manager",1,1,1,1 +"access_wc_backend_user","wc_backend user","model_wc_backend","sales_team.group_sale_salesman",1,0,0,0 +"access_wc_backend_sale_manager","wc_backend manager","model_wc_backend","sales_team.group_sale_manager",1,0,0,0 +"access_woo_res_partner","woo_res_partner connector manager","model_woo_res_partner","connector.group_connector_manager",1,1,1,1 +"access_res_partner_group_user","woo_res_partner group_user","model_woo_res_partner","base.group_user",1,0,0,0 +"access_woo_product_category","woo_product_category connector manager","model_woo_product_category","connector.group_connector_manager",1,1,1,1 +"access_product_category_sale_manager","woo_product.category salemanager","model_woo_product_category","sales_team.group_sale_manager",1,1,1,1 +"access_product_category_user","woo_product.category.user","model_woo_product_category","base.group_user",1,0,0,0 +"access_woo_product_product","woo_product_product connector manager","model_woo_product_product","connector.group_connector_manager",1,1,1,1 +"access_woo_product_product_user","woo_product_product user","model_woo_product_product","sales_team.group_sale_salesman",1,0,0,0 +"access_woo_product_product_sale_manager","woo_product_product sale manager","model_woo_product_product","sales_team.group_sale_manager",1,1,1,1 +"access_woo_sale_order","woo_sale_order connector manager","model_woo_sale_order","connector.group_connector_manager",1,1,1,1 +"access_woo_sale_order_sale_salesman","woo_sale_order","model_woo_sale_order","sales_team.group_sale_salesman",1,1,1,1 +"access_woo_sale_order_sale_manager","woo_sale_order","model_woo_sale_order","sales_team.group_sale_manager",1,1,1,1 +"access_woo_sale_order_line","woo_sale_order_line connector manager","model_woo_sale_order_line","connector.group_connector_manager",1,1,1,1 +"access_woo_sale_order_line_sale_salesman","woo_sale_order_line","model_woo_sale_order_line","sales_team.group_sale_salesman",1,1,1,1 +"access_woo_sale_order_line_sale_manager","woo_sale_order_line","model_woo_sale_order_line","sales_team.group_sale_manager",1,1,1,1 +"access_woo_sale_order_status","woo_sale_order_status connector manager","model_woo_sale_order_status","connector.group_connector_manager",1,1,1,1 +"access_woo_sale_order_status_sale_salesman","woo_sale_order_status","model_woo_sale_order_status","sales_team.group_sale_salesman",1,1,1,1 +"access_woo_sale_order_status_sale_manager","woo_sale_order_status","model_woo_sale_order_status","sales_team.group_sale_manager",1,1,1,1 +"access_woo_sale_order_stock_user","woo_sale_order warehouse user","model_woo_sale_order","stock.group_stock_user",1,1,0,0 diff --git a/connector_woocommerce/static/description/icon.png b/connector_woocommerce/static/description/icon.png new file mode 100644 index 0000000..0d5b58d Binary files /dev/null and b/connector_woocommerce/static/description/icon.png differ diff --git a/connector_woocommerce/static/description/index.html b/connector_woocommerce/static/description/index.html new file mode 100644 index 0000000..6ae8af4 --- /dev/null +++ b/connector_woocommerce/static/description/index.html @@ -0,0 +1,417 @@ + + + + + + +WooCommerce Connector + + + +
+

WooCommerce Connector

+ + +

Beta License: AGPL-3 OCA/connector-woocommerce Translate me on Weblate Try me on Runbot

+

Connector between WooCommerce and Odoo

+

Table of contents

+ +
+

Usage

+
    +
  1. Go to …
  2. +
+
+
+

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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Tech Receptives
  • +
  • FactorLibre
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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.

+

Current maintainer:

+

cubells

+

This module is part of the OCA/connector-woocommerce project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/connector_woocommerce/views/backend_views.xml b/connector_woocommerce/views/backend_views.xml new file mode 100644 index 0000000..78ab4fe --- /dev/null +++ b/connector_woocommerce/views/backend_views.xml @@ -0,0 +1,94 @@ + + + + + wc.backend.tree + wc.backend + + + + + + + + + wc.backend.form + wc.backend + +
+
+
+ +