diff --git a/connector_woocommerce/LICENSE/LICENSE b/connector_woocommerce/LICENSE/LICENSE new file mode 100644 index 0000000..1aba4ee --- /dev/null +++ b/connector_woocommerce/LICENSE/LICENSE @@ -0,0 +1,19 @@ + + Tech-Receptives Solutions Pvt. Ltd. + Copyright (C) 2009-TODAY Tech-Receptives(). + + Serpent Consulting Services Pvt. Ltd. + Copyright (C) 2018-TODAY Serpent Consulting Services Pvt. Ltd. (). + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . diff --git a/connector_woocommerce/README.rst b/connector_woocommerce/README.rst new file mode 100644 index 0000000..74674f4 --- /dev/null +++ b/connector_woocommerce/README.rst @@ -0,0 +1,62 @@ +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: https://www.gnu.org/licenses/agpl + :alt: License: AGPL-3 + +===================== +WooCommerce Connector +===================== + +Connector between WooCommerce and Odoo + +Usage +===== + +#. Go to ... + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/207/11.0 + + +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 smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Tech-Receptives Solutions +* Serpent Consulting Services Pvt. Ltd. + +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. + + + +.. 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 diff --git a/connector_woocommerce/__init__.py b/connector_woocommerce/__init__.py new file mode 100644 index 0000000..9deca92 --- /dev/null +++ b/connector_woocommerce/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import components +from . import models +from . import wizard diff --git a/connector_woocommerce/__manifest__.py b/connector_woocommerce/__manifest__.py new file mode 100644 index 0000000..4794eb8 --- /dev/null +++ b/connector_woocommerce/__manifest__.py @@ -0,0 +1,39 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +{ + 'name': 'WooCommerce Connector', + 'version': '11.0.1.0.1', + 'category': 'Ecommerce', + 'author': """Tech Receptives, + Serpent Consulting Services Pvt. Ltd., + Odoo Community Association (OCA)""", + 'contributors': """Tech Receptives, + Serpent Consulting Services Pvt. Ltd.""", + 'license': 'AGPL-3', + 'maintainer': 'Odoo Community Association (OCA)', + 'website': 'https://github.com/OCA/connector-woocommerce', + 'summary': """Imports the Product's Categories, Products, Customers and + Sale orders from WooCommerce, Meanwhile Exports from Odoo + To WooCommerce.""", + 'depends': ['sale_stock', 'connector', 'connector_ecommerce'], + 'installable': True, + 'auto_install': False, + 'data': [ + "security/ir.model.access.csv", + "views/backend_view.xml", + "views/product_view.xml", + "views/res_partner_views.xml", + "views/sale_views.xml", + "wizard/woo_export_view.xml", + "wizard/woo_validation_view.xml", + "wizard/backend_instance.xml", + ], + 'external_dependencies': { + 'python': ['woocommerce'], + }, + 'application': True, + "sequence": 3, +} diff --git a/connector_woocommerce/components/__init__.py b/connector_woocommerce/components/__init__.py new file mode 100644 index 0000000..a418f58 --- /dev/null +++ b/connector_woocommerce/components/__init__.py @@ -0,0 +1,8 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import core +from . import backend_adapter +from . import binder +from . import importer +from . import exporter +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..03ffb23 --- /dev/null +++ b/connector_woocommerce/components/backend_adapter.py @@ -0,0 +1,209 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import socket +import logging +import xmlrpc.client +from datetime import datetime +from odoo.addons.component.core import AbstractComponent +from odoo.addons.queue_job.exception import RetryableJobError +from odoo.addons.connector.exception import NetworkRetryableError +from odoo.addons.queue_job.exception import FailedJobError + +_logger = logging.getLogger(__name__) + +try: + from woocommerce import API +except ImportError: + _logger.debug("Cannot import 'woocommerce'") + +WOO_DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S' + + +class WooLocation(object): + + def __init__(self, location, consumer_key, consumre_secret, version): + self._location = location + self.consumer_key = consumer_key + self.consumer_secret = consumre_secret + self.version = version + + @property + def location(self): + location = self._location + return location + + +class WooAPI(object): + + def __init__(self, location): + """ + :param location: Woo location + :type location: :class:`WooLocation` + """ + self._location = location + self._api = None + + @property + def api(self): + return self._api + + def __enter__(self): + # we do nothing, api is lazy + return self + + def __exit__(self, type, value, traceback): + if self._api is not None: + return + + def call(self, method=None, resource=None, arguments=None): + try: + location = self._location._location + cons_key = self._location.consumer_key + sec_key = self._location.consumer_secret + version = self._location.version or 'v3' + # WooCommerce API Connection + wcapi = API( + url=location, # Your store URL + consumer_key=cons_key, # Your consumer key + consumer_secret=sec_key, # Your consumer secret + wp_api=True, + version=version, # WooCommerce WP REST API version + query_string_auth=True # Force Basic Authentication as query + # string true and using under HTTPS + ) + if wcapi: + if isinstance(arguments, list): + while arguments and arguments[-1] is None: + arguments.pop() + start = datetime.now() + try: + wooapi = getattr(wcapi, method) + res = wooapi(resource) if method not in ['put', 'post'] \ + else wooapi(resource, arguments) + vals = res.json() + if not res.ok: + raise FailedJobError(vals) + result = vals + except Exception as e: + _logger.error("api.call(%s, %s, %s, %s) failed", method, + resource, arguments, e) + raise + else: + _logger.debug("api.call(%s, %s, %s) returned %s in %s\ + seconds", method, resource, 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 = 'woo.crud.adapter' + _inherit = ['base.backend.adapter', 'base.woo.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, resource, arguments): + try: + woo_api = getattr(self.work, 'woo_api') + except AttributeError: + raise AttributeError( + 'You must provide a woo_api attribute with a ' + 'WooAPI instance to be able to use the ' + 'Backend Adapter.' + ) + return woo_api.call(method, resource, arguments) + + +class GenericAdapter(AbstractComponent): + + _name = 'woo.adapter' + _inherit = 'woo.crud.adapter' + + _woo_model = None + _admin_path = 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('get', '%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('post', self._woo_model, data) + + def write(self, id, data): + """ Update records on the external system """ + return self._call('put', self._woo_model + "/" + str(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..0ef5904 --- /dev/null +++ b/connector_woocommerce/components/binder.py @@ -0,0 +1,27 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +from odoo.addons.component.core import Component + + +class WooModelBinder(Component): + """ Bind records and give odoo/woo ids correspondence + + 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 = 'woo.binder' + _inherit = ['base.binder', 'base.woo.connector'] + _apply_on = [ + 'woo.res.partner', + 'woo.product.category', + 'woo.product.product', + 'woo.sale.order', + 'woo.sale.order.line', + 'woo.shipping.zone', + ] diff --git a/connector_woocommerce/components/core.py b/connector_woocommerce/components/core.py new file mode 100644 index 0000000..2673374 --- /dev/null +++ b/connector_woocommerce/components/core.py @@ -0,0 +1,16 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class BaseWooConnectorComponent(AbstractComponent): + """ Base Woo Connector Component + + All components of this connector should inherit from it. + """ + + _name = 'base.woo.connector' + _inherit = 'base.connector' + _collection = 'woo.backend' diff --git a/connector_woocommerce/components/exporter.py b/connector_woocommerce/components/exporter.py new file mode 100644 index 0000000..75d03b9 --- /dev/null +++ b/connector_woocommerce/components/exporter.py @@ -0,0 +1,355 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import psycopg2 +from contextlib import contextmanager +import odoo +from odoo import _ +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.exception import (IDMissingInBackend, + RetryableJobError) + +_logger = logging.getLogger(__name__) + +""" + +Exporters for Woo. + +In addition to its export job, an exporter has to: + +* check in Woo if the record has been updated more recently than the + last sync date and if yes, delay an import +* call the ``bind`` method of the binder to update the last sync date + +""" + + +class WooBaseExporter(AbstractComponent): + """ Base exporter for Woo """ + + _name = 'woo.base.exporter' + _inherit = ['base.exporter', 'base.woo.connector'] + _usage = 'record.exporter' + + def __init__(self, working_context): + super(WooBaseExporter, self).__init__(working_context) + self.binding = None + self.external_id = None + + def _delay_import(self): + """ Schedule an import of the record. + + Adapt in the sub-classes when the model is not imported + using ``import_record``. + """ + return + + def _should_import(self): + """ Before the export, compare the update date + in Woo and the last sync date in Odoo, + if the former is more recent, schedule an import + to not miss changes done in Woo. + """ + assert self.binding + if not self.external_id: + return False + sync = self.binding.sync_date + if not sync: + return True + result = self.backend_adapter.is_woo_record(self.external_id) + return result + + def run(self, binding, *args, **kwargs): + """ Run the synchronization + + :param binding: binding record to export + """ + self.binding = binding + self.external_id = self.binder.to_external(self.binding) + try: + should_import = self._should_import() + except IDMissingInBackend: + self.external_id = None + should_import = False + if should_import: + self._delay_import() + + result = self._run(*args, **kwargs) + + self.binder.bind(self.external_id, self.binding) + # Commit so we keep the external ID when there are several + # exports (due to dependencies) and one of them fails. + # The commit will also release the lock acquired on the binding + # record + if not odoo.tools.config['test_enable']: + binding = binding + #self.env.cr.commit() # noqa + + self._after_export() + return result + + def _run(self): + """ Flow of the synchronization, implemented in inherited classes""" + raise NotImplementedError + + def _after_export(self): + """ Can do several actions after exporting a record on woo """ + pass + + +class WooExporter(AbstractComponent): + """ A common flow for the exports to Woo """ + + _name = 'woo.exporter' + _inherit = 'woo.base.exporter' + + def __init__(self, working_context): + super(WooExporter, self).__init__(working_context) + self.binding = None + + def _lock(self): + """ Lock the binding record. + + Lock the binding record so we are sure that only one export + job is running for this record if concurrent jobs have to export the + same record. + + When concurrent jobs try to export the same record, the first one + will lock and proceed, the others will fail to lock and will be + retried later. + + This behavior works also when the export becomes multilevel + with :meth:`_export_dependencies`. Each level will set its own lock + on the binding record it has to export. + + """ + sql = ("SELECT id FROM %s WHERE ID = %%s FOR UPDATE NOWAIT" % + self.model._table) + try: + self.env.cr.execute(sql, (self.binding.id, ), + log_exceptions=False) + except psycopg2.OperationalError: + _logger.info('A concurrent job is already exporting the same ' + 'record (%s with id %s). Job delayed later.', + self.model._name, self.binding.id) + raise RetryableJobError( + 'A concurrent job is already exporting the same record ' + '(%s with id %s). The job will be retried later.' % + (self.model._name, self.binding.id)) + + def _has_to_skip(self): + """ Return True if the export can be skipped """ + return False + + @contextmanager + def _retry_unique_violation(self): + """ Context manager: catch Unique constraint error and retry the + job later. + + When we execute several jobs workers concurrently, it happens + that 2 jobs are creating the same record at the same time (binding + record created by :meth:`_export_dependency`), resulting in: + + IntegrityError: duplicate key value violates unique + constraint "woo_product_product_odoo_uniq" + DETAIL: Key (backend_id, odoo_id)=(1, 4851) already exists. + + In that case, we'll retry the import just later. + + .. warning:: The unique constraint must be created on the + binding record to prevent 2 bindings to be created + for the same Woo record. + + """ + try: + yield + except psycopg2.IntegrityError as err: + if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION: + raise RetryableJobError( + 'A database error caused the failure of the job:\n' + '%s\n\n' + 'Likely due to 2 concurrent jobs wanting to create ' + 'the same record. The job will be retried later.' % err) + else: + raise + + def _export_dependency(self, relation, binding_model, + component_usage='record.exporter', + binding_field='woo_bind_ids', + binding_extra_vals=None): + """ + Export a dependency. The exporter class is a subclass of + ``WooExporter``. If a more precise class need to be defined, + it can be passed to the ``exporter_class`` keyword argument. + + .. warning:: a commit is done at the end of the export of each + dependency. The reason for that is that we pushed a record + on the backend and we absolutely have to keep its ID. + + So you *must* take care not to modify the Odoo + database during an export, excepted when writing + back the external ID or eventually to store + external data that we have to keep on this side. + + You should call this method only at the beginning + of the exporter synchronization, + in :meth:`~._export_dependencies`. + + :param relation: record to export if not already exported + :type relation: :py:class:`odoo.models.BaseModel` + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param component_usage: 'usage' to look for to find the Component to + for the export, by default 'record.exporter' + :type exporter: str | unicode + :param binding_field: name of the one2many field on a normal + record that points to the binding record + (default: woo_bind_ids). + It is used only when the relation is not + a binding but is a normal record. + :type binding_field: str | unicode + :binding_extra_vals: In case we want to create a new binding + pass extra values for this binding + :type binding_extra_vals: dict + """ + if not relation: + return + rel_binder = self.binder_for(binding_model) + # wrap is typically True if the relation is for instance a + # 'product.product' record but the binding model is + # 'woo.product.product' + wrap = relation._name != binding_model + if wrap and hasattr(relation, binding_field): + domain = [('odoo_id', '=', relation.id), + ('backend_id', '=', self.backend_record.id)] + binding = self.env[binding_model].search(domain) + if binding: + assert len(binding) == 1, ( + 'only 1 binding for a backend is ' + 'supported in _export_dependency') + # we are working with a unwrapped record (e.g. + # product.category) and the binding does not exist yet. + # Example: I created a product.product and its binding + # woo.product.product and we are exporting it, but we need to + # create the binding for the product.category on which it + # depends. + else: + bind_values = {'backend_id': self.backend_record.id, + 'odoo_id': relation.id} + if binding_extra_vals: + bind_values.update(binding_extra_vals) + # If 2 jobs create it at the same time, retry + # one later. A unique constraint (backend_id, + # odoo_id) should exist on the binding model + with self._retry_unique_violation(): + binding = (self.env[binding_model] + .with_context(connector_no_export=True) + .sudo() + .create(bind_values)) + # Eager commit to avoid having 2 jobs + # exporting at the same time. The constraint + # will pop if an other job already created + # the same binding. It will be caught and + # raise a RetryableJobError. + if not odoo.tools.config['test_enable']: + binding = binding + #self.env.cr.commit() # noqa + else: + # If woo_bind_ids does not exist we are typically in a + # "direct" binding (the binding record is the same record). + # If wrap is True, relation is already a binding record. + binding = relation + if not rel_binder.to_external(binding): + exporter = self.component(usage=component_usage, + model_name=binding_model) + exporter.run(binding) + + def _export_dependencies(self): + """ Export the dependencies for the record""" + return + + def _map_data(self): + """ Returns an instance of + :py:class:`~odoo.addons.connector.components.mapper.MapRecord` + + """ + return self.mapper.map_record(self.binding) + + def _validate_create_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.create`` if some fields + are missing or invalid + + Raise `InvalidDataError` + """ + return + + def _validate_update_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.update`` if some fields + are missing or invalid + + Raise `InvalidDataError` + """ + return + + def _create_data(self, map_record, fields=None, **kwargs): + """ Get the data to pass to :py:meth:`_create` """ + return map_record.values(for_create=True, fields=fields, **kwargs) + + def _create(self, data): + """ Create the Woo record """ + # special check on data before export + self._validate_create_data(data) + return self.backend_adapter.create(data) + + def _update_data(self, map_record, fields=None, **kwargs): + """ Get the data to pass to :py:meth:`_update` """ + res = map_record.values(fields=fields, **kwargs) + return res + + def _update(self, data): + """ Update an Woo record """ + assert self.external_id + # special check on data before export + self._validate_update_data(data) + self.backend_adapter.write(self.external_id, data) + + def _run(self, fields=None): + """ Flow of the synchronization, implemented in inherited classes""" + assert self.binding + if not self.external_id: + fields = None # should be created with all the fields + + if self._has_to_skip(): + return + + # export the missing linked resources + self._export_dependencies() + + # prevent other jobs to export the same record + # will be released on commit (or rollback) + self._lock() + + map_record = self._map_data() + if self.external_id: + record = self._update_data(map_record, fields=fields) + if not record: + return _('Nothing to export.') + self._update(record) + else: + record = self._create_data(map_record, fields=fields) + if not record: + return _('Nothing to export.') + bind = self._create(record) + # Need of more test cases. + if bind.get('id'): + self.external_id = bind.get('id') + else: + self.external_id = bind.get(list(bind.keys())[0]).get('id') + assert self.external_id, "External Id is not Found" + return _('Record exported with ID %s on Woo.') % self.external_id diff --git a/connector_woocommerce/components/importer.py b/connector_woocommerce/components/importer.py new file mode 100644 index 0000000..bff134c --- /dev/null +++ b/connector_woocommerce/components/importer.py @@ -0,0 +1,267 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +""" + +Importers for Woo. + +An import can be skipped if the last sync date is more recent than +the last update in Woo. + +They should call the ``bind`` method if the binder even if the records +are already bound, to update the last sync date. + +""" + +import logging +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 Woo """ + + _name = 'woo.importer' + _inherit = ['base.importer', 'base.woo.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 Woo 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 Woo + data""" + + def _is_uptodate(self, binding): + """Return True if the import should be skipped because + it is already up-to-date in Odoo""" + assert self.woo_record + if not self.woo_record.get('updated_at'): + return # no update date on Woo, 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) + woo_date = from_string(self.woo_record['updated_at']) + # 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 Woo + 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_component: component to use for import + By default: 'importer' + :type importer_component: Component + :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 Woo 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 importer is None: + 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:`~odoo.addons.connector.components.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 Odoo 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 Odoo 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, filters=None): + """ Run the synchronization + + :param external_id: identifier of the record on Woo + """ + 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 Woo') + + 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 = 'woo.batch.importer' + _inherit = ['base.importer', 'base.woo.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, external_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 = 'woo.direct.batch.importer' + _inherit = 'woo.batch.importer' + + def _import_record(self, external_id): + """ Import the record directly """ + self.model.import_record(self.backend_record, external_id) + + +class DelayedBatchImporter(AbstractComponent): + """ Delay import of the records """ + + _name = 'woo.delayed.batch.importer' + _inherit = 'woo.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..6fd9ddf --- /dev/null +++ b/connector_woocommerce/components/mapper.py @@ -0,0 +1,30 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +from odoo.addons.component.core import AbstractComponent + + +class WooImportMapper(AbstractComponent): + _name = 'woo.import.mapper' + _inherit = ['base.woo.connector', 'base.import.mapper'] + _usage = 'import.mapper' + + +class WooExportMapper(AbstractComponent): + _name = 'woo.export.mapper' + _inherit = ['base.woo.connector', 'base.export.mapper'] + _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 + Odoo""" + + 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..92bb10c --- /dev/null +++ b/connector_woocommerce/models/__init__.py @@ -0,0 +1,12 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import woo_binding +from . import woo_backend +from . import customer +from . import product +from . import product_category +from . import queue_job +from . import sale +from . import shipping_methods +from . import shipping_zone + diff --git a/connector_woocommerce/models/customer/__init__.py b/connector_woocommerce/models/customer/__init__.py new file mode 100644 index 0000000..515c143 --- /dev/null +++ b/connector_woocommerce/models/customer/__init__.py @@ -0,0 +1,6 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common +from . import exporter +from . import importer +from . import listener diff --git a/connector_woocommerce/models/customer/common.py b/connector_woocommerce/models/customer/common.py new file mode 100644 index 0000000..0fb19f5 --- /dev/null +++ b/connector_woocommerce/models/customer/common.py @@ -0,0 +1,147 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import logging + +import xmlrpc.client +from odoo import models, fields, api +from odoo.addons.component.core import Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.job import job, related_action + +_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='woo.backend', + string='Woo Backend', + store=True, + readonly=False, + ) + + @job(default_channel='root.woo') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_record(self): + """ Export a Customer. """ + for rec in self: + rec.ensure_one() + with rec.backend_id.work_on(rec._name) as work: + exporter = work.component(usage='res.partner.exporter') + return exporter.run(self) + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + woo_bind_ids = fields.One2many( + comodel_name='woo.res.partner', + inverse_name='odoo_id', + string="Woo Bindings", + ) + # These fields are required for export + sync_data = fields.Boolean("Synch with Woo?") + woo_backend_id = fields.Many2one( + 'woo.backend', + string="WooCommerce Store" + ) + + +class CustomerAdapter(Component): + _name = 'woo.partner.adapter' + _inherit = 'woo.adapter' + _apply_on = 'woo.res.partner' + + _woo_model = 'customers' + + def _call(self, method, resource, arguments): + try: + return super(CustomerAdapter, self)._call( + method, + resource, + arguments + ) + except xmlrpc.client.Fault as err: + # this is the error in the WooCommerce API + # when the customer does not exist + if err.faultCode == 102: + raise IDMissingInBackend + else: + raise + + def search(self, method=None, filters=None, + from_date=None, to_date=None): + """ Search records according to some criteria and return a + list of ids + + :rtype: list + """ + WOO_DATETIME_FORMAT = '%Y/%m/%d %H:%M:%S' + dt_fmt = WOO_DATETIME_FORMAT + if from_date is not None: + # updated_at include the created records + filters.setdefault('updated_at', {}) + filters['updated_at']['from'] = from_date.strftime(dt_fmt) + if to_date is not None: + filters.setdefault('updated_at', {}) + filters['updated_at']['to'] = to_date.strftime(dt_fmt) + # the search method is on ol_customer instead of customer + res = self._call(method, 'customers', [filters] if filters else [{}]) + + # Set customer ids and return it(Due to new WooCommerce REST API) + customer_ids = list() + for customer in res.get('customers'): + customer_ids.append(customer.get('id')) + return customer_ids + + 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('get', '%s/' % self._woo_model + str(id), []) + + def create(self, data): + """ Create a record on the external system """ + data = { + "customer": data + } + return self._call('post', self._woo_model, data) + + def write(self, id, data): + """ Update records on the external system """ + data = { + "customer": data + } + return self._call('put', self._woo_model + "/" + str(id), data) + + def is_woo_record(self, woo_id, filters=None): + """ + This method is to verify the existing record on WooCommerce. + @param: woo_id : External id (int) + @param: filters : Filters to check (json) + @return: result : Response of Woocom (Boolean) + """ + return self._call('get', self._woo_model + '/' + str(woo_id), filters) diff --git a/connector_woocommerce/models/customer/exporter.py b/connector_woocommerce/models/customer/exporter.py new file mode 100644 index 0000000..be5e811 --- /dev/null +++ b/connector_woocommerce/models/customer/exporter.py @@ -0,0 +1,122 @@ +# Copyright 2013-2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import changed_by, mapping, \ + only_create +from odoo.addons.connector.exception import InvalidDataError + + +class CustomerExporter(Component): + _name = 'woo.res.partner.exporter' + _inherit = ['woo.exporter', 'woo.base.exporter'] + _apply_on = ['woo.res.partner'] + _usage = 'res.partner.exporter' + + def _after_export(self): + "After Import" + self.binding.odoo_id.sudo().write({ + 'sync_data': True, + 'woo_backend_id': self.backend_record.id + }) + + def _validate_create_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.create`` or + ``Model.update`` if some fields are missing + + Raise `InvalidDataError` + """ + if not data.get('email'): + raise InvalidDataError( + "The partner does not have an email " + "but it is mandatory for Woo" + ) + if not data.get("shipping_address"): + address = data.get("billing_address") + address.pop('email') + address.pop('phone') + data.update(shipping_address=address) + return + + def _get_data(self, binding, fields): + result = {} + return result + + +class CustomerExportMapper(Component): + _name = 'woo.res.partner.export.mapper' + _inherit = 'woo.export.mapper' + _apply_on = ['woo.res.partner'] + + @changed_by('name') + @mapping + def name(self, record): + name = record.name.split(" ") + data = { + "first_name": name[0], + "last_name": " ".join(name[1:]) + } + return data + + @changed_by('email') + @mapping + def email(self, record): + data = { + "email": record.email, + } + return data + + @only_create + @changed_by('email') + @mapping + def username(self, record): + data = dict() + if not record.external_id: + data.update(username=record.email, password=record.email) + return data + + @mapping + def billing(self, record): + data = {} + name = record.name.split(" ") + data.update({ + "first_name": name[0], + "last_name": " ".join(name[1:]), + "company": record.company_name, + "address_1": record.street, + "address_2": record.street2, + "city": record.city, + "postcode": record.zip, + "email": record.email, + "phone": record.phone, + "state": record.state_id and record.state_id.code or False, + "country": record.country_id and record.country_id.code or False + }) + return {'billing_address': data} + + @mapping + def shipping(self, record): + data = {} + partner_obj = self.env["res.partner"] + ship_id = partner_obj.search([ + ('parent_id', '=', record.odoo_id.id), + ('type', '=', 'delivery')], + limit=1, + order='write_date desc') + if ship_id: + name = ship_id.name.split(" ") + data.update({ + "first_name": name[0], + "last_name": " ".join(name[1:]), + "company": ship_id.company_name, + "address_1": ship_id.street, + "address_2": ship_id.street2, + "city": ship_id.city, + "postcode": ship_id.zip, + "state": ship_id.state_id and ship_id.state_id.code or False, + "country": ship_id.country_id and ship_id.country_id.code or + False + }) + return {'shipping_address': data} diff --git a/connector_woocommerce/models/customer/importer.py b/connector_woocommerce/models/customer/importer.py new file mode 100644 index 0000000..1e6d9f7 --- /dev/null +++ b/connector_woocommerce/models/customer/importer.py @@ -0,0 +1,182 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +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 = 'woo.partner.batch.importer' + _inherit = 'woo.delayed.batch.importer' + _apply_on = 'woo.res.partner' + + def _import_record(self, woo_id): + """ Delay a job for the import """ + super(CustomerBatchImporter, self)._import_record( + woo_id) + + def run(self, filters=None): + """ Run the synchronization """ + from_date = filters.pop('from_date', None) + to_date = filters.pop('to_date', None) + # Get external ids with specific filters + record_ids = self.backend_adapter.search( + method='get', + filters=filters, + from_date=from_date, + to_date=to_date, + ) + customer_ref = self.env['woo.res.partner'] + record = [] + # Get external ids from odoo for comparison + customer_rec = customer_ref.search([('external_id', '!=', '')]) + for ext_id in customer_rec: + record.append(int(ext_id.external_id)) + # Get difference ids + diff = list(set(record) - set(record_ids)) + for del_woo_rec in diff: + woo_customer_id = customer_ref.search( + [('external_id', '=', del_woo_rec)]) + cust_id = woo_customer_id.odoo_id + odoo_customer_id = self.env['res.partner'].search( + [('id', '=', cust_id.id)]) + # Delete reference from odoo + odoo_customer_id.write({ + 'woo_bind_ids': [(3, odoo_customer_id.woo_bind_ids[0].id)], + 'sync_data': False, + 'woo_backend_id': None + }) + + _logger.info('search for woo partners %s returned %s', + filters, record_ids) + # Importing data + for record_id in record_ids: + self._import_record(record_id) + + +class CustomerImporter(Component): + _name = 'woo.partner.importer' + _inherit = 'woo.importer' + _apply_on = 'woo.res.partner' + + def _import_dependencies(self): + """ Import the dependencies for the record""" + return + + def _create(self, data): + odoo_binding = super(CustomerImporter, self)._create(data) + # Adding Creation Checkpoint + self.backend_record.add_checkpoint(odoo_binding) + return odoo_binding + + def _update(self, binding, data): + """ Update an Odoo record """ + super(CustomerImporter, self)._update(binding, data) + # Adding updation checkpoint + # self.backend_record.add_checkpoint(binding) + return + + def _before_import(self): + """ Hook called before the import""" + return + + def _after_import(self, binding): + """ Hook called at the end of the import """ + return + + +class CustomerImportMapper(Component): + _name = 'woo.partner.import.mapper' + _inherit = 'woo.import.mapper' + _apply_on = 'woo.res.partner' + + @mapping + def name(self, record): + if record['customer']: + rec = record['customer'] + return {'name': rec['first_name'] + " " + rec['last_name']} + + @mapping + def email(self, record): + if record['customer']: + rec = record['customer'] + return {'email': rec['email'] or None} + + @mapping + def city(self, record): + if record['customer']: + rec = record['customer']['billing_address'] + return {'city': rec['city'] or None} + + @mapping + def zip(self, record): + if record['customer']: + rec = record['customer']['billing_address'] + return {'zip': rec['postcode'] or None} + + @mapping + def address(self, record): + if record['customer']: + rec = record['customer']['billing_address'] + return {'street': rec['address_1'] or None} + + @mapping + def address_2(self, record): + if record['customer']: + rec = record['customer']['billing_address'] + return {'street2': rec['address_2'] or None} + + @mapping + def country(self, record): + if record['customer']: + rec = record['customer']['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['customer']: + rec = record['customer']['billing_address'] + if rec['state'] and rec['country']: + state_id = self.env['res.country.state'].search( + [('code', '=', rec['state'])], limit=1) + if not state_id: + country_id = self.env['res.country'].search( + [('code', '=', rec['country'])], limit=1) + 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} + + # Required for export + @mapping + def sync_data(self, record): + if record.get('customer'): + return {'sync_data': True} + + @mapping + def woo_backend_id(self, record): + return {'woo_backend_id': self.backend_record.id} diff --git a/connector_woocommerce/models/customer/listener.py b/connector_woocommerce/models/customer/listener.py new file mode 100644 index 0000000..23d8f3b --- /dev/null +++ b/connector_woocommerce/models/customer/listener.py @@ -0,0 +1,38 @@ +# Copyright 2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class WooCustomerBindingExportListener(Component): + _name = 'woo.res.partner.binding.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.res.partner'] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + record.with_delay().export_record() + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + record.with_delay().export_record() + + def on_record_unlink(self, record): + with record.backend_id.work_on(record._name) as work: + external_id = work.component(usage='binder').to_external(record) + if external_id: + record.with_delay().export_delete_record(record.backend_id, + external_id) + + +class WooCustomerExportListener(Component): + _name = 'woo.res.partner.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.res.partner'] + + # XXX must check record.env!!! + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + for binding in record.woo_bind_ids: + binding.with_delay().export_record() diff --git a/connector_woocommerce/models/product/__init__.py b/connector_woocommerce/models/product/__init__.py new file mode 100644 index 0000000..515c143 --- /dev/null +++ b/connector_woocommerce/models/product/__init__.py @@ -0,0 +1,6 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common +from . import exporter +from . import importer +from . import listener diff --git a/connector_woocommerce/models/product/common.py b/connector_woocommerce/models/product/common.py new file mode 100644 index 0000000..766dd76 --- /dev/null +++ b/connector_woocommerce/models/product/common.py @@ -0,0 +1,150 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import logging + +import xmlrpc.client +from odoo import models, fields, api +from odoo.addons.component.core import Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.job import job, related_action + +_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='woo.backend', + string='Woo Backend', + store=True, + readonly=False, + required=True, + ) + slug = fields.Char('Slung Name') + credated_at = fields.Date('created_at') + weight = fields.Float('weight') + + @job(default_channel='root.woo') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_record(self): + """ Export Products. """ + for rec in self: + rec.ensure_one() + with rec.backend_id.work_on(rec._name) as work: + exporter = work.component(usage='product.product.exporter') + return exporter.run(self) + + +class ProductProduct(models.Model): + _inherit = 'product.product' + + woo_categ_ids = fields.Many2many( + comodel_name='product.category', + string='Woo product category', + ) + in_stock = fields.Boolean('In Stock') + woo_bind_ids = fields.One2many( + comodel_name='woo.product.product', + inverse_name='odoo_id', + string="Woo Bindings", + ) + # These fields are required for export + sync_data = fields.Boolean("Synch with Woo?") + woo_backend_id = fields.Many2one( + 'woo.backend', + string="WooCommerce Store" + ) + + +class ProductProductAdapter(Component): + _name = 'woo.product.product.adapter' + _inherit = 'woo.adapter' + _apply_on = 'woo.product.product' + + _woo_model = 'products' + _woo_base_model = 'products' + + def _call(self, method, resource, arguments): + try: + return super(ProductProductAdapter, self)._call( + method, + resource, + arguments + ) + except xmlrpc.client.Fault as err: + # this is the error in the WooCommerce API + # when the customer does not exist + if err.faultCode == 102: + raise IDMissingInBackend + else: + raise + + def search(self, method=None, filters=None, from_date=None, to_date=None): + """ Search records according to some criteria and return a + list of ids + + :rtype: list + """ + WOO_DATETIME_FORMAT = '%Y/%m/%d %H:%M:%S' + dt_fmt = WOO_DATETIME_FORMAT + if from_date is not None: + # updated_at include the created records + filters.setdefault('updated_at', {}) + filters['updated_at']['from'] = from_date.strftime(dt_fmt) + if to_date is not None: + filters.setdefault('updated_at', {}) + filters['updated_at']['to'] = to_date.strftime(dt_fmt) + res = self._call(method, 'products', [filters] if filters else [{}]) + # Set product ids and return it(Due to new WooCommerce REST API) + product_ids = list() + for product in res.get('products'): + product_ids.append(product.get('id')) + return product_ids + + def get_images(self, id, method='get'): + return self._call( + method, + 'products/' + str(id), + [] + ) + + def create(self, data): + """ Create a record on the external system """ + data = { + "product": data + } + return self._call('post', self._woo_base_model, data) + + def write(self, id, data): + """ Update records on the external system """ + data = { + "product": data + } + return self._call('put', self._woo_base_model + "/" + str(id), data) + + def is_woo_record(self, woo_id, filters=None): + """ + This method is verify the existing record on WooCommerce. + @param: woo_id : External id (int) + @param: filters : Filters to check (json) + @return: result : Response of Woocom (Boolean) + """ + self._call( + 'get', + self._woo_base_model + '/' + str(woo_id), + filters + ) + return True diff --git a/connector_woocommerce/models/product/exporter.py b/connector_woocommerce/models/product/exporter.py new file mode 100644 index 0000000..fe6b9cd --- /dev/null +++ b/connector_woocommerce/models/product/exporter.py @@ -0,0 +1,85 @@ +# Copyright 2013-2017 Camptocamp SA +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping, changed_by +from odoo.addons.connector.exception import InvalidDataError + + +class ProductProductExporter(Component): + _name = 'woo.product.product.exporter' + _inherit = ['woo.exporter', 'woo.base.exporter'] + _apply_on = ['woo.product.product'] + _usage = 'product.product.exporter' + + def _after_export(self): + """After Export""" + self.binding.odoo_id.sudo().write({ + 'sync_data': True, + 'woo_backend_id': self.backend_record.id + }) + return + + def _validate_create_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.create`` or + ``Model.update`` if some fields are missing + + Raise `InvalidDataError` + """ + + if not data.get('email'): + raise InvalidDataError("The partner does not have an email " + "but it is mandatory for Woo") + return + + def _get_data(self, binding, fields): + result = {} + return result + + def _export_dependencies(self): + """ Export the dependencies for the record""" + record = self.binding.odoo_id + if record.categ_id: + self._export_dependency( + record.categ_id, + 'woo.product.category', + component_usage='product.category.exporter' + ) + return + + +class ProductProductExportMapper(Component): + _name = 'woo.product.product.export.mapper' + _inherit = 'woo.export.mapper' + _apply_on = ['woo.product.product'] + + @changed_by('name') + @mapping + def name(self, record): + data = { + "name": record.name, + "title": record.name + } + return data + + @changed_by('category_id') + @mapping + def sku(self, record): + if record.default_code: + return {'sku': record.default_code} + + @changed_by('default_code') + @mapping + def categories(self, record): + if record.categ_id: + binder = self.binder_for("woo.product.category") + category_id = binder.to_external(record.categ_id, wrap=True) + return {"categories": [category_id]} + + @changed_by('list_price') + @mapping + def sale_price(self, record): + return {'regular_price': record.list_price} diff --git a/connector_woocommerce/models/product/importer.py b/connector_woocommerce/models/product/importer.py new file mode 100644 index 0000000..92e2f90 --- /dev/null +++ b/connector_woocommerce/models/product/importer.py @@ -0,0 +1,256 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import base64 +import logging + +import urllib.error +import urllib.request +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 Partners. + + For every partner in the list, a delayed job is created. + """ + _name = 'woo.product.product.batch.importer' + _inherit = 'woo.delayed.batch.importer' + _apply_on = ['woo.product.product'] + + def _import_record(self, woo_id): + """ Delay a job for the import """ + super(ProductBatchImporter, self)._import_record( + woo_id) + + 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( + method='get', + filters=filters, + from_date=from_date, + to_date=to_date, + ) + product_ref = self.env['woo.product.product'] + record = [] + # Get external ids from odoo for comparison + product_rec = product_ref.search([('external_id', '!=', '')]) + for ext_id in product_rec: + record.append(int(ext_id.external_id)) + # Get difference ids + diff = list(set(record) - set(record_ids)) + for del_woo_rec in diff: + woo_product_id = product_ref.search( + [('external_id', '=', del_woo_rec)]) + product_id = woo_product_id.odoo_id + odoo_product_id = self.env['product.product'].search( + [('id', '=', product_id.id)]) + # Delete reference from odoo + odoo_product_id.write({ + 'woo_bind_ids': [(3, odoo_product_id.woo_bind_ids[0].id)], + 'sync_data': False, + 'woo_backend_id': None + }) + + _logger.info('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 = 'woo.product.product.importer' + _inherit = 'woo.importer' + _apply_on = ['woo.product.product'] + + def _create(self, data): + odoo_binding = super(ProductProductImporter, self)._create(data) + # Adding Creation Checkpoint + self.backend_record.add_checkpoint(odoo_binding) + return odoo_binding + + def _update(self, binding, data): + """ Update an Odoo record """ + super(ProductProductImporter, self)._update(binding, data) + # Adding updation checkpoint + return + + def _before_import(self): + """ Hook called before the import""" + return + + 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.external_id, binding.id) + return + + +class ProductImageImporter(Component): + """ Import images for a record. + + Usually called from importers, in ``_after_import``. + For instance from the products importer. + """ + _name = 'woo.product.image.importer' + _inherit = 'woo.importer' + _apply_on = ['woo.product.product'] + _usage = 'product.image.importer' + + def _get_images(self): + return self.backend_adapter.get_images(self.woo_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 = str(image_data.get('src')).replace("\\", '') + 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 run(self, woo_id, binding_id): + self.woo_id = woo_id + images = self._get_images() + images = images['product'] + images = images['images'] + binary = None + while not binary and images: + binary = self._get_binary_image(images.pop()) + if not binary: + return + model = self.model.with_context(connector_no_export=True) + binding = model.browse(binding_id) + binding.write({'image': base64.b64encode(binary)}) + + +class ProductProductImportMapper(Component): + _name = 'woo.product.product.import.mapper' + _inherit = 'woo.import.mapper' + _apply_on = ['woo.product.product'] + + direct = [ + ('description', 'description'), + ('weight', 'weight'), + ] + + @mapping + def is_active(self, record): + """Check if the product is active in Woo + and set active flag in Odoo + status == 1 in Woo means active""" + if record['product']: + rec = record['product'] + return {'active': rec['visible']} + + @mapping + def in_stock(self, record): + if record['product']: + rec = record['product'] + return {'in_stock': rec['in_stock']} + + @mapping + def name(self, record): + if record['product']: + rec = record['product'] + return {'name': rec['title']} + + @mapping + def website_published(self, record): + if record['product']: + rec = record['product'] + if rec['status'] == 'publish': + return {'website_published': True} + else: + return {'website_published': False} + + @mapping + def type(self, record): + if record['product']: + rec = record['product'] + if rec['type'] == 'simple': + return {'type': 'product'} + + @mapping + def categories(self, record): + if record['product']: + rec = record['product'] + woo_categories = rec['categories'] + binder = self.binder_for('woo.product.category') + category_ids = [] + main_categ_id = None + for woo_category_id in woo_categories: + cat_id = binder.to_internal(woo_category_id, unwrap=True) + if cat_id is None: + raise MappingError("The product category with " + "woo id %s is not imported." % + woo_category_id) + category_ids.append(cat_id.id) + if category_ids: + main_categ_id = category_ids.pop(0) + result = {'woo_categ_ids': [(6, 0, category_ids)]} + if main_categ_id: # Odoo assign 'All Products' if not specified + result['categ_id'] = main_categ_id + return result + + @mapping + def price(self, record): + """ The price is imported at the creation of + the product, then it is only modified and exported + from Odoo """ + if record['product']: + rec = record['product'] + return {'list_price': rec and rec['price'] or 0.0} + + @mapping + def sale_price(self, record): + """ The price is imported at the creation of + the product, then it is only modified and exported + from Odoo """ + if record['product']: + rec = record['product'] + return {'standard_price': rec and rec['sale_price'] or 0.0} + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + # Required for export + @mapping + def sync_data(self, record): + if record.get('product'): + return {'sync_data': True} + + @mapping + def woo_backend_id(self, record): + return {'woo_backend_id': self.backend_record.id} diff --git a/connector_woocommerce/models/product/listener.py b/connector_woocommerce/models/product/listener.py new file mode 100644 index 0000000..7b850a3 --- /dev/null +++ b/connector_woocommerce/models/product/listener.py @@ -0,0 +1,38 @@ +# Copyright 2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class WooProductProductBindingExportListener(Component): + _name = 'woo.product.product.binding.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.product.product'] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + record.with_delay().export_record() + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + record.with_delay().export_record() + + def on_record_unlink(self, record): + with record.backend_id.work_on(record._name) as work: + external_id = work.component(usage='binder').to_external(record) + if external_id: + record.with_delay().export_delete_record(record.backend_id, + external_id) + + +class WooProductProductExportListener(Component): + _name = 'woo.product.product.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.product.product'] + + # XXX must check record.env!!! + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + for binding in record.woo_bind_ids: + binding.with_delay().export_record() diff --git a/connector_woocommerce/models/product_category/__init__.py b/connector_woocommerce/models/product_category/__init__.py new file mode 100644 index 0000000..515c143 --- /dev/null +++ b/connector_woocommerce/models/product_category/__init__.py @@ -0,0 +1,6 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common +from . import exporter +from . import importer +from . import listener diff --git a/connector_woocommerce/models/product_category/common.py b/connector_woocommerce/models/product_category/common.py new file mode 100644 index 0000000..08c5b0f --- /dev/null +++ b/connector_woocommerce/models/product_category/common.py @@ -0,0 +1,144 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import logging + +import xmlrpc.client +from odoo import api, fields, models +from odoo.addons.component.core import Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.job import job, related_action + +_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( + 'product.category', + string='category', + required=True, + ondelete='cascade' + ) + backend_id = fields.Many2one( + comodel_name='woo.backend', + string='Woo Backend', + store=True, + readonly=False, + ) + slug = fields.Char('Slung Name') + woo_parent_id = fields.Many2one( + comodel_name='woo.product.category', + string='Woo Parent Category', + ondelete='cascade', ) + description = fields.Char('Description') + count = fields.Integer('count') + woo_child_ids = fields.One2many( + comodel_name='woo.product.category', + inverse_name='woo_parent_id', + string='Woo Child Categories', + ) + + @job(default_channel='root.woo') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_record(self): + """ Export Product Category. """ + for rec in self: + rec.ensure_one() + with rec.backend_id.work_on(rec._name) as work: + exporter = work.component(usage='product.category.exporter') + return exporter.run(self) + + +class ProductCategory(models.Model): + _inherit = 'product.category' + + woo_bind_ids = fields.One2many( + comodel_name='woo.product.category', + inverse_name='odoo_id', + string="Woo Bindings", + ) + woo_image = fields.Binary("WooCommerce Image") + # These fields are required for export + sync_data = fields.Boolean("Synch with Woo?") + woo_backend_id = fields.Many2one( + 'woo.backend', + string="WooCommerce Store" + ) + + +class CategoryAdapter(Component): + _name = 'woo.product.category.adapter' + _inherit = 'woo.adapter' + _apply_on = 'woo.product.category' + + _woo_model = 'products/categories' + + def _call(self, method, resource, arguments): + try: + return super(CategoryAdapter, self)._call(method, resource, + arguments) + except xmlrpc.client.Fault as err: + # this is the error in the WooCommerce API + # when the product Category does not exist + if err.faultCode == 102: + raise IDMissingInBackend + else: + raise + + def search(self, method, filters=None, from_date=None, to_date=None): + """ Search records according to some criteria and return a + list of ids + + :rtype: list + """ + if filters is None: + filters = {} + WOO_DATETIME_FORMAT = '%Y/%m/%d %H:%M:%S' + dt_fmt = WOO_DATETIME_FORMAT + if from_date is not None: + filters.setdefault('updated_at', {}) + filters['updated_at']['from'] = from_date.strftime(dt_fmt) + if to_date is not None: + filters.setdefault('updated_at', {}) + filters['updated_at']['to'] = to_date.strftime(dt_fmt) + res = self._call(method, 'products/categories', + [filters] if filters else [{}]) + # Set product category ids and return + # it(Due to new WooCommerce REST API) + cat_ids = list() + for category in res.get('product_categories'): + cat_ids.append(category.get('id')) + return cat_ids + + def create(self, data): + """ Create a record on the external system """ + data = { + "product_category": data + } + return self._call('post', self._woo_model, data) + + def write(self, id, data): + """ Update records on the external system """ + data = { + "product_category": data + } + return self._call('put', self._woo_model + "/" + str(id), data) + + def is_woo_record(self, woo_id, filters=None): + """ + This method is verify the existing record on WooCommerce. + @param: woo_id : External id (int) + @param: filters : Filters to check (json) + @return: result : Response of Woocom (Boolean) + """ + return self._call('get', self._woo_model + '/' + str(woo_id), filters) diff --git a/connector_woocommerce/models/product_category/exporter.py b/connector_woocommerce/models/product_category/exporter.py new file mode 100644 index 0000000..9d0a88b --- /dev/null +++ b/connector_woocommerce/models/product_category/exporter.py @@ -0,0 +1,67 @@ +# Copyright 2013-2017 Camptocamp SA +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping, changed_by + + +class ProductCategoryExporter(Component): + _name = 'woo.product.category.exporter' + _inherit = ['woo.exporter', 'woo.base.exporter'] + _apply_on = ['woo.product.category'] + _usage = 'product.category.exporter' + + def _after_export(self): + """After Export""" + self.binding.odoo_id.sudo().write({ + 'sync_data': True, + 'woo_backend_id': self.backend_record.id + }) + return + + def _validate_create_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.create`` or + ``Model.update`` if some fields are missing + + Raise `InvalidDataError` + """ + return + + def _get_data(self, binding, fields): + result = {} + return result + + def _export_dependencies(self): + """ Export the dependencies for the record""" + record = self.binding.odoo_id + if record.parent_id: + self._export_dependency( + self.binding.odoo_id.parent_id, + 'woo.product.category', + component_usage='product.category.exporter' + ) + return + + +class ProductCategoryExportMapper(Component): + _name = 'woo.product.category.export.mapper' + _inherit = 'woo.export.mapper' + _apply_on = ['woo.product.category'] + + @changed_by('name') + @mapping + def name(self, record): + return {"name": record.name} + + @changed_by('parent_id') + @mapping + def parent(self, record): + binder = self.binder_for("woo.product.category") + category_id = False + if record.parent_id: + # Get id of product.category model + category_id = binder.to_external(record.parent_id, wrap=True) + return {'parent': category_id} diff --git a/connector_woocommerce/models/product_category/importer.py b/connector_woocommerce/models/product_category/importer.py new file mode 100644 index 0000000..605920d --- /dev/null +++ b/connector_woocommerce/models/product_category/importer.py @@ -0,0 +1,169 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import base64 +import logging + +import urllib.error +import urllib.request +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 Product Categories. + + For every partner in the list, a delayed job is created. + """ + + _name = 'woo.product.category.batch.importer' + _inherit = 'woo.delayed.batch.importer' + _apply_on = 'woo.product.category' + + def _import_record(self, woo_id): + """ Delay a job for the import """ + super(CategoryBatchImporter, self)._import_record( + woo_id) + + def run(self, filters=None): + """ Run the synchronization """ + from_date = filters.pop('from_date', None) + to_date = filters.pop('to_date', None) + # backend_adapter = self.component(usage='backend.adapter') + record_ids = self.backend_adapter.search( + method='get', + filters=filters, + from_date=from_date, + to_date=to_date, + ) + category_ref = self.env['woo.product.category'] + record = [] + # Get external ids from odoo for comparison + cat_rec = category_ref.search([('external_id', '!=', '')]) + for ext_id in cat_rec: + record.append(int(ext_id.external_id)) + # Get difference ids + diff = list(set(record) - set(record_ids)) + for del_woo_rec in diff: + woo_cat_id = category_ref.search( + [('external_id', '=', del_woo_rec)]) + cat_id = woo_cat_id.odoo_id + odoo_cat_id = self.env['product.category'].search( + [('id', '=', cat_id.id)]) + # Delete reference from odoo + odoo_cat_id.write({ + 'woo_bind_ids': [(3, odoo_cat_id.woo_bind_ids[0].id)], + 'sync_data': False, + 'woo_backend_id': None + }) + + _logger.info('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 = 'woo.product.category.importer' + _inherit = 'woo.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 + record = record['product_category'] + if record['parent']: + self._import_dependency(record.get('parent'), self.model) + return + + def _create(self, data): + odoo_binding = super(ProductCategoryImporter, self)._create(data) + # Adding Creation Checkpoint + self.backend_record.add_checkpoint(odoo_binding) + return odoo_binding + + def _update(self, binding, data): + """ Update an Odoo record """ + super(ProductCategoryImporter, self)._update(binding, data) + # Adding updation checkpoint + # self.backend_record.add_checkpoint(binding) + return + + def _before_import(self): + """ Hook called before the import""" + return + + def _after_import(self, binding): + """ Hook called at the end of the import """ + return + + +class ProductCategoryImportMapper(Component): + _name = 'woo.product.category.import.mapper' + _inherit = 'woo.import.mapper' + _apply_on = 'woo.product.category' + + @mapping + def name(self, record): + if record['product_category']: + rec = record['product_category'] + return {'name': rec['name']} + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + @mapping + def parent_id(self, record): + if record['product_category']: + rec = record['product_category'] + if not rec['parent']: + return + binder = self.binder_for() + # Get id of product.category model + category_id = binder.to_internal(rec['parent'], unwrap=True) + # Get id of woo.product.category model + woo_cat_id = binder.to_internal(rec['parent']) + if category_id is None: + raise MappingError("The product category with " + "woo id %s is not imported." % + rec['parent']) + return {'parent_id': category_id.id, + 'woo_parent_id': woo_cat_id.id} + + @mapping + def woo_image(self, record): + image = record.get('image') + if image: + src = image.replace("\\", '') + try: + request = urllib.request.Request(src) + 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 {'woo_image': base64.b64encode(binary.read())} + + # Required for export + @mapping + def sync_data(self, record): + if record.get('product_category'): + return {'sync_data': True} + + @mapping + def woo_backend_id(self, record): + return {'woo_backend_id': self.backend_record.id} diff --git a/connector_woocommerce/models/product_category/listener.py b/connector_woocommerce/models/product_category/listener.py new file mode 100644 index 0000000..811d3dc --- /dev/null +++ b/connector_woocommerce/models/product_category/listener.py @@ -0,0 +1,38 @@ +# Copyright 2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class WooProductCategoryBindingExportListener(Component): + _name = 'woo.product.category.binding.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.product.category'] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + record.with_delay().export_record() + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + record.with_delay().export_record() + + def on_record_unlink(self, record): + with record.backend_id.work_on(record._name) as work: + external_id = work.component(usage='binder').to_external(record) + if external_id: + record.with_delay().export_delete_record(record.backend_id, + external_id) + + +class WooProductCategoryExportListener(Component): + _name = 'woo.product.category.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.product.category'] + + # XXX must check record.env!!! + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + for binding in record.woo_bind_ids: + binding.with_delay().export_record() diff --git a/connector_woocommerce/models/queue_job/__init__.py b/connector_woocommerce/models/queue_job/__init__.py new file mode 100644 index 0000000..c81be2c --- /dev/null +++ b/connector_woocommerce/models/queue_job/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common diff --git a/connector_woocommerce/models/queue_job/common.py b/connector_woocommerce/models/queue_job/common.py new file mode 100644 index 0000000..431fca4 --- /dev/null +++ b/connector_woocommerce/models/queue_job/common.py @@ -0,0 +1,36 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +from odoo import _, api, exceptions, models + + +class QueueJob(models.Model): + _inherit = 'queue.job' + + @api.multi + def related_action_woo_link(self, backend_id_pos=0, external_id_pos=1): + """ Open a WooCommerce URL on the admin page to view/edit the record + related to the job. + """ + self.ensure_one() + model_name = self.model_name + backend = self.args[backend_id_pos] + external_id = self.args[external_id_pos] + with backend.work_on(model_name) as work: + adapter = work.component(usage='backend.adapter') + try: + url = adapter.admin_url(external_id) + except ValueError: + raise exceptions.UserError( + _('No admin URL configured on the backend or ' + 'no admin path is defined for this record.') + ) + + action = { + 'type': 'ir.actions.act_url', + 'target': 'new', + 'url': url, + } + return action diff --git a/connector_woocommerce/models/sale/__init__.py b/connector_woocommerce/models/sale/__init__.py new file mode 100644 index 0000000..515c143 --- /dev/null +++ b/connector_woocommerce/models/sale/__init__.py @@ -0,0 +1,6 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common +from . import exporter +from . import importer +from . import listener diff --git a/connector_woocommerce/models/sale/common.py b/connector_woocommerce/models/sale/common.py new file mode 100644 index 0000000..a2a85a7 --- /dev/null +++ b/connector_woocommerce/models/sale/common.py @@ -0,0 +1,214 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import logging + +import xmlrpc.client +from odoo import models, fields, api +from odoo.addons.component.core import Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.job import job, related_action + +_logger = logging.getLogger(__name__) + + +class WooSaleOrderStatus(models.Model): + _name = 'woo.sale.order.status' + _description = 'WooCommerce Sale Order Status' + + name = fields.Char('Name') + desc = fields.Text('Description') + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + status_id = fields.Many2one('woo.sale.order.status', + 'WooCommerce Order Status') + woo_bind_ids = fields.One2many( + comodel_name='woo.sale.order', + inverse_name='odoo_id', + string="Woo Bindings", + ) + # These fields are required for export + sync_data = fields.Boolean("Synch with Woo?") + woo_backend_id = fields.Many2one( + 'woo.backend', + string="WooCommerce Store" + ) + + +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('woo.sale.order.status', + '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='woo.backend', + string='Woo Backend', + store=True, + readonly=False, + required=True, + ) + + @job(default_channel='root.woo') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_record(self): + """ Export Sale orders. """ + for rec in self: + rec.ensure_one() + with rec.backend_id.work_on(rec._name) as work: + exporter = work.component(usage='sale.order.exporter') + return exporter.run(self) + + +class WooSaleOrderLine(models.Model): + _name = 'woo.sale.order.line' + _inherits = {'sale.order.line': 'odoo_id'} + + 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, vals): + woo_order_id = vals['woo_order_id'] + binding = self.env['woo.sale.order'].browse(woo_order_id) + vals['order_id'] = binding.odoo_id.id + binding = super(WooSaleOrderLine, self).create(vals) + 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 = 'woo.sale.order.adapter' + _inherit = 'woo.adapter' + _apply_on = 'woo.sale.order' + + _woo_model = 'orders' + + def _call(self, method, resource, arguments): + try: + return super(SaleOrderAdapter, self)._call( + method, + resource, + arguments + ) + except xmlrpc.client.Fault as err: + # this is the error in the Woo API + # when the customer does not exist + if err.faultCode == 102: + raise IDMissingInBackend + else: + raise + + def search(self, method=None, filters=None, + from_date=None, to_date=None): + """ Search records according to some criteria and return a + list of ids + + :rtype: list + """ + WOO_DATETIME_FORMAT = '%Y/%m/%d %H:%M:%S' + dt_fmt = WOO_DATETIME_FORMAT + if from_date is not None: + # updated_at include the created records + filters.setdefault('updated_at', {}) + filters['updated_at']['from'] = from_date.strftime(dt_fmt) + if to_date is not None: + filters.setdefault('updated_at', {}) + filters['updated_at']['to'] = to_date.strftime(dt_fmt) + + res = self._call(method, 'orders/', [filters] if filters else [{}]) + # Set sale order ids and return it(Due to new Wordpress version) + order_ids = list() + for order in res.get('orders'): + order_ids.append(order.get('id')) + return order_ids + + 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('get', '%s/' % self._woo_model + str(id), []) + + def create(self, data): + """ Create a record on the external system """ + data = { + "order": data + } + return self._call('post', self._woo_model, data) + + def write(self, id, data): + """ Update records on the external system """ + data = { + "order": data + } + return self._call('put', self._woo_model + "/" + str(id), data) + + def is_woo_record(self, woo_id, filters=None): + """ + This method is to verify the existing record on WooCommerce. + @param: woo_id : External id (int) + @param: filters : Filters to check (json) + @return: result : Response of Woocom (Boolean) + """ + self._call( + 'get', + self._woo_model + '/' + str(woo_id), + filters + ) + return True diff --git a/connector_woocommerce/models/sale/exporter.py b/connector_woocommerce/models/sale/exporter.py new file mode 100644 index 0000000..8fdbc89 --- /dev/null +++ b/connector_woocommerce/models/sale/exporter.py @@ -0,0 +1,144 @@ +# Copyright 2013-2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import changed_by, mapping, \ + only_create + + +class SaleOrderExporter(Component): + _name = 'woo.sale.order.exporter' + _inherit = ['woo.exporter', 'woo.base.exporter'] + _apply_on = ['woo.sale.order'] + _usage = 'sale.order.exporter' + + def _after_export(self): + """ After Export""" + self.binding.odoo_id.sudo().write({ + 'sync_data': True, + 'woo_backend_id': self.backend_record.id + }) + return + + def _validate_create_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.create`` or + ``Model.update`` if some fields are missing + + Raise `InvalidDataError` + """ + return + + def _get_data(self, binding, fields): + result = {} + return result + + def _export_dependencies(self): + """ Export the dependencies for the record""" + record = self.binding.odoo_id + # Export Customer + if record.partner_id: + self._export_dependency( + record.partner_id, + 'woo.res.partner', + component_usage='res.partner.exporter' + ) + # Export Products + if record.order_line: + for line in record.order_line: + self._export_dependency( + line.product_id, + 'woo.product.product', + component_usage='product.product.exporter' + ) + return + + +class SaleOrderExportMapper(Component): + _name = 'woo.sale.order.export.mapper' + _inherit = 'woo.export.mapper' + _apply_on = ['woo.sale.order'] + + @changed_by('partner_id') + @mapping + def customer(self, record): + if record.partner_id: + binder = self.binder_for("woo.res.partner") + customer_id = binder.to_external(record.partner_id, wrap=True) + return {"customer_id": customer_id} + + @mapping + def billing(self, record): + ivoice = record.partner_invoice_id + data = {} + name = ivoice.name.split(" ") + data.update({ + "first_name": name[0], + "last_name": " ".join(name[1:]), + "company": ivoice.company_name, + "address_1": ivoice.street, + "address_2": ivoice.street2, + "city": ivoice.city, + "postcode": ivoice.zip, + "email": ivoice.email, + "phone": ivoice.phone, + "state": ivoice.state_id and ivoice.state_id.code or False, + "country": ivoice.country_id and ivoice.country_id.code or False + }) + return {'billing_address': data} + + @mapping + def shipping(self, record): + shipping = record.partner_shipping_id + data = {} + name = shipping.name.split(" ") + data.update({ + "first_name": name[0], + "last_name": " ".join(name[1:]), + "company": shipping.company_name, + "address_1": shipping.street, + "address_2": shipping.street2, + "city": shipping.city, + "postcode": shipping.zip, + "state": shipping.state_id and shipping.state_id.code or False, + "country": shipping.country_id and shipping.country_id.code or + False + }) + return {'shipping_address': data} + + @changed_by('state') + @mapping + def status(self, record): + if record.state == 'draft': + return {'status': 'pending'} + elif record.state == 'done': + return {'status': 'completed'} + elif record.state == 'sale': + return {'status': 'processing'} + elif record.state == 'cancel': + return {'status': 'cancelled'} + + @only_create + @mapping + def orderline_create(self, record): + items = [] + if record.order_line: + for line in record.order_line: + binder = self.binder_for("woo.product.product") + product_id = binder.to_external( + line.product_id, + wrap=True + ) + items.append({ + "product_id": product_id, + # SKU can be used instead of product_id, while mapping. + "quantity": line.product_uom_qty, + "total": line.price_unit + }) + return {"line_items": items} + + @mapping + def orderline_update(self, record): + """ Eneter your Logic here to Update order lines """ + return diff --git a/connector_woocommerce/models/sale/importer.py b/connector_woocommerce/models/sale/importer.py new file mode 100644 index 0000000..14dfa16 --- /dev/null +++ b/connector_woocommerce/models/sale/importer.py @@ -0,0 +1,262 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import logging + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping + +_logger = logging.getLogger(__name__) + + +class SaleOrderLineImportMapper(Component): + _name = 'woo.sale.order.line.mapper' + _inherit = 'woo.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_id = binder.to_internal(record['product_id'], unwrap=True) + assert product_id is not None,\ + ("product_id %s should have been imported in " + "SaleOrderImporter._import_dependencies" % record['product_id']) + return {'product_id': product_id.id} + + +class SaleOrderBatchImporter(Component): + """ Import the WooCommerce Partners. + + For every partner in the list, a delayed job is created. + """ + _name = 'woo.sale.order.batch.importer' + _inherit = 'woo.delayed.batch.importer' + _apply_on = 'woo.sale.order' + + def _import_record(self, woo_id): + """ Delay a job for the import """ + super(SaleOrderBatchImporter, self)._import_record( + woo_id) + + def update_existing_order(self, woo_sale_order, record_id): + """ Enter Your logic for Existing Sale Order """ + return True + + 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( + method='get', + filters=filters, + from_date=from_date, + to_date=to_date, + ) + saleOrder_ref = self.env['woo.sale.order'] + order_ids = [] + record = [] + # Get external ids from odoo for comparison + saleOrder_rec = saleOrder_ref.search([('external_id', '!=', '')]) + for ext_id in saleOrder_rec: + record.append(int(ext_id.external_id)) + # Get difference ids + diff = list(set(record) - set(record_ids)) + for del_woo_rec in diff: + woo_saleOrder_id = saleOrder_ref.search( + [('external_id', '=', del_woo_rec)]) + saleOrder_id = woo_saleOrder_id.odoo_id + odoo_saleOrder_id = self.env['sale.order'].search( + [('id', '=', saleOrder_id.id)]) + # Delete reference from odoo + odoo_saleOrder_id.write({ + 'woo_bind_ids': [(3, odoo_saleOrder_id.woo_bind_ids[0].id)], + 'sync_data': False, + 'woo_backend_id': None + }) + + for record_id in record_ids: + woo_sale_order = saleOrder_ref.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 = 'woo.sale.order.importer' + _inherit = 'woo.importer' + _apply_on = 'woo.sale.order' + + def _import_customer(self): + record = self.woo_record + record = record['order'] + 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_customer() + 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['order']['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 _create(self, data): + odoo_binding = super(SaleOrderImporter, self)._create(data) + # Adding Creation Checkpoint + self.backend_record.add_checkpoint(odoo_binding) + return odoo_binding + + def _update(self, binding, data): + """ Update an Odoo record """ + super(SaleOrderImporter, self)._update(binding, data) + return + + def _before_import(self): + """ Hook called before the import""" + return + + def _after_import(self, binding): + """ Hook called at the end of the import """ + # Calling partner onchange of SO. + binding.odoo_id.onchange_partner_id() + return + + def _get_woo_data(self): + """ Return the raw WooCommerce data for ``self.woo_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 = 'woo.sale.order.mapper' + _inherit = 'woo.import.mapper' + _apply_on = 'woo.sale.order' + + children = [('items', 'woo_order_line_ids', 'woo.sale.order.line'), ] + + @mapping + def status(self, record): + if record['order']: + rec = record['order'] + if rec['status'] == 'pending': + rec['status'] = 'draft' + elif rec['status'] in ['processing', 'refunded', 'on-hold']: + rec['status'] = 'sale' + elif rec['status'] == 'completed': + rec['status'] = 'done' + elif rec['status'] in ['cancelled', 'failed']: + rec['status'] = 'cancel' + if rec['status']: + status_id = self.env['woo.sale.order.status'].sudo().search( + [('name', '=', rec['status'])]) + if status_id: + return {'status_id': status_id[0].id, + 'state': rec['status'], + } + else: + status_id = self.env['woo.sale.order.status'].sudo(). \ + create({'name': rec['status']}) + return {'status_id': status_id.id, + 'state': rec['status'], + } + else: + return {'status_id': False, + 'state': rec['status'], + } + + @mapping + def customer_id(self, record): + if record['order']: + rec = record['order'] + binder = self.binder_for('woo.res.partner') + if rec['customer_id']: + partner_id = binder.to_internal(rec['customer_id'], + unwrap=True) or False + assert partner_id, ("Please Check Customer Role \ + in WooCommerce") + result = {'partner_id': partner_id.id} + else: + customer = rec['customer']['billing_address'] + country_id = False + state_id = False + if customer['country']: + country_id = self.env['res.country'].search( + [('code', '=', customer['country'])], limit=1) + if country_id: + country_id = country_id.id + if customer['state']: + state_id = self.env['res.country.state'].search( + [('code', '=', customer['state'])], limit=1) + 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) + result = {'partner_id': partner_id.id} + return result + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + # Required for export + @mapping + def sync_data(self, record): + if record.get('order'): + return {'sync_data': True} + + @mapping + def woo_backend_id(self, record): + return {'woo_backend_id': self.backend_record.id} diff --git a/connector_woocommerce/models/sale/listener.py b/connector_woocommerce/models/sale/listener.py new file mode 100644 index 0000000..267ad63 --- /dev/null +++ b/connector_woocommerce/models/sale/listener.py @@ -0,0 +1,38 @@ +# Copyright 2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class WooSaleOrderBindingExportListener(Component): + _name = 'woo.sale.order.binding.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.sale.order'] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + record.with_delay().export_record() + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + record.with_delay().export_record() + + def on_record_unlink(self, record): + with record.backend_id.work_on(record._name) as work: + external_id = work.component(usage='binder').to_external(record) + if external_id: + record.with_delay().export_delete_record(record.backend_id, + external_id) + + +class WooSaleOrderExportListener(Component): + _name = 'woo.sale.order.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.sale.order'] + + # XXX must check record.env!!! + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + for binding in record.woo_bind_ids: + binding.with_delay().export_record() diff --git a/connector_woocommerce/models/shipping_methods/__init__.py b/connector_woocommerce/models/shipping_methods/__init__.py new file mode 100644 index 0000000..515c143 --- /dev/null +++ b/connector_woocommerce/models/shipping_methods/__init__.py @@ -0,0 +1,6 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common +from . import exporter +from . import importer +from . import listener diff --git a/connector_woocommerce/models/shipping_methods/common.py b/connector_woocommerce/models/shipping_methods/common.py new file mode 100644 index 0000000..8a08dd2 --- /dev/null +++ b/connector_woocommerce/models/shipping_methods/common.py @@ -0,0 +1,148 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import logging + +import xmlrpc.client +from odoo import models, fields, api +from odoo.addons.component.core import Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.job import job, related_action + +_logger = logging.getLogger(__name__) + + +class WooDeliveryCarrier(models.Model): + _name = 'woo.delivery.carrier' + _inherit = 'woo.binding' + _inherits = {'delivery.carrier': 'odoo_id'} + _description = 'woo delivery carrier' + + _rec_name = 'name' + + odoo_id = fields.Many2one(comodel_name='delivery.carrier', + string='Delivery', + required=True, + ondelete='cascade') + backend_id = fields.Many2one( + comodel_name='woo.backend', + string='Woo Backend', + store=True, + readonly=False, + ) + + @job(default_channel='root.woo') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_record(self): + """ Export a DeliveryCarrier. """ + for rec in self: + rec.ensure_one() + with rec.backend_id.work_on(rec._name) as work: + exporter = work.component(usage='delivery.carrier.exporter') + return exporter.run(self) + + +class DeliveryCarrier(models.Model): + _inherit = 'delivery.carrier' + + woo_bind_ids = fields.One2many( + comodel_name='woo.delivery.carrier', + inverse_name='odoo_id', + string="Woo Bindings", + ) + # These fields are required for export + sync_data = fields.Boolean("Synch with Woo?") + woo_backend_id = fields.Many2one( + 'woo.backend', + string="WooCommerce Store" + ) + + +class DeliveryCarrierAdapter(Component): + _name = 'woo.delivery.carier.adapter' + _inherit = 'woo.adapter' + _apply_on = 'woo.delivery.carrier' + + _woo_model = 'shipping/zones/' + + def _call(self, method, resource, arguments): + try: + return super(DeliveryCarrierAdapter, self)._call( + method, + resource, + arguments + ) + except xmlrpc.client.Fault as err: + # this is the error in the WooCommerce API + # when the Delivery Carrier does not exist + if err.faultCode == 102: + raise IDMissingInBackend + else: + raise + + def search(self, method=None, filters=None, + from_date=None, to_date=None): + """ Search records according to some criteria and return a + list of ids + + :rtype: list + """ + WOO_DATETIME_FORMAT = '%Y/%m/%d %H:%M:%S' + dt_fmt = WOO_DATETIME_FORMAT + if from_date is not None: + # updated_at include the created records + filters.setdefault('updated_at', {}) + filters['updated_at']['from'] = from_date.strftime(dt_fmt) + if to_date is not None: + filters.setdefault('updated_at', {}) + filters['updated_at']['to'] = to_date.strftime(dt_fmt) + res = self._call(method, 'shipping/zones/5/methods', + [filters] if filters else [{}]) + + # Set delivery carrier ids and return + # it(Due to new WooCommerce REST API) + deliveryCarrier_ids = list() + for delivery in res.get('shipping/zones/5/methods'): + deliveryCarrier_ids.append(delivery.get('id')) + return deliveryCarrier_ids + + 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('get', '%s/' % self._woo_model + str(id), []) + + def create(self, data): + """ Create a record on the external system """ + data = { + "deliverycarier": data + } + return self._call('post', self._woo_model, data) + + def write(self, id, data): + """ Update records on the external system """ + data = { + "deliverycarier": data + } + return self._call('put', self._woo_model + "/" + str(id), data) + + def is_woo_record(self, woo_id, filters=None): + """ + This method is to verify the existing record on WooCommerce. + @param: woo_id : External id (int) + @param: filters : Filters to check (json) + @return: result : Response of Woocom (Boolean) + """ + return self._call('get', self._woo_model + '/' + str(woo_id), filters) diff --git a/connector_woocommerce/models/shipping_methods/exporter.py b/connector_woocommerce/models/shipping_methods/exporter.py new file mode 100644 index 0000000..3c46d3b --- /dev/null +++ b/connector_woocommerce/models/shipping_methods/exporter.py @@ -0,0 +1,47 @@ +# Copyright 2013-2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import changed_by, mapping + + +class DeliveryCarrierExporter(Component): + _name = 'woo.delivery.carrier.exporter' + _inherit = ['woo.exporter', 'woo.base.exporter'] + _apply_on = ['woo.delivery.carrier'] + _usage = 'delivery.carrier.exporter' + + def _after_export(self): + "After Import" + self.binding.odoo_id.sudo().write({ + 'sync_data': True, + 'woo_backend_id': self.backend_record.id + }) + + def _validate_create_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.create`` or + ``Model.update`` if some fields are missing + + Raise `InvalidDataError` + """ + return + + def _get_data(self, binding, fields): + result = {} + return result + + +class DeliveryCarrierExporterMapper(Component): + _name = 'woo.delivery.carrier.exporter.mapper' + _inherit = 'woo.export.mapper' + _apply_on = ['woo.delivery.carrier'] + + @changed_by('name') + @mapping + def name(self, record): + data = { + "name": record.method_title, + } + return data diff --git a/connector_woocommerce/models/shipping_methods/importer.py b/connector_woocommerce/models/shipping_methods/importer.py new file mode 100644 index 0000000..888af98 --- /dev/null +++ b/connector_woocommerce/models/shipping_methods/importer.py @@ -0,0 +1,104 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import logging + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping + +_logger = logging.getLogger(__name__) + + +class DeliveryCarrierBatchImporter(Component): + """ Import the WooCommerce Delivery Carrier. + + For every Delivery Carrier in the list, a delayed job is created. + """ + _name = 'woo.delivery.carrier.batch.importer' + _inherit = 'woo.delayed.batch.importer' + _apply_on = 'woo.delivery.carrier' + + def _import_record(self, woo_id): + """ Delay a job for the import """ + super(DeliveryCarrierBatchImporter, self)._import_record( + woo_id) + + def run(self, filters=None): + """ Run the synchronization """ + from_date = filters.pop('from_date', None) + to_date = filters.pop('to_date', None) + # Get external ids with specific filters + record_ids = self.backend_adapter.search(method='get', filters=filters, + from_date=from_date, + to_date=to_date, ) + deliveryCarrier_ref = self.env['woo.delivery.carrier'] + record = [] + # Get external ids from odoo for comparison + deliveryCarrier_rec = deliveryCarrier_ref.search( + [('external_id', '!=', '')]) + for ext_id in deliveryCarrier_rec: + record.append(int(ext_id.external_id)) + # Get difference ids + diff = list(set(record) - set(record_ids)) + for del_woo_rec in diff: + woo_DeliveryCarrier_id = deliveryCarrier_ref.search( + [('external_id', '=', del_woo_rec)]) + cust_id = woo_DeliveryCarrier_id.odoo_id + odoo_DeliveryCarrier_id = self.env['delivery.carrier'].search( + [('id', '=', cust_id.id)]) + # Delete reference from odoo + odoo_DeliveryCarrier_id.write({ + 'woo_bind_ids': [ + (3, odoo_DeliveryCarrier_id.woo_bind_ids[0].id)], + 'sync_data': False, + 'woo_backend_id': None + }) + + _logger.info('search for woo DeliveryCarrier %s returned %s', + filters, record_ids) + # Importing data + for record_id in record_ids: + self._import_record(record_id) + + +class DeliveryCarrierImporter(Component): + _name = 'woo.delivery.carrier.importer' + _inherit = 'woo.importer' + _apply_on = 'woo.delivery.carrier' + + def _import_dependencies(self): + """ Import the dependencies for the record""" + return + + def _create(self, data): + odoo_binding = super(DeliveryCarrierImporter, self)._create(data) + # Adding Creation Checkpoint + self.backend_record.add_checkpoint(odoo_binding) + return odoo_binding + + def _update(self, binding, data): + """ Update an Odoo record """ + super(DeliveryCarrierImporter, self)._update(binding, data) + return + + def _before_import(self): + """ Hook called before the import""" + return + + def _after_import(self, binding): + """ Hook called at the end of the import """ + return + + +class DeliveryCarrierImportMapper(Component): + _name = 'woo.delivery.carrier.import.mapper' + _inherit = 'woo.import.mapper' + _apply_on = 'woo.delivery.carrier' + + @mapping + def name(self, record): + if record['deliverycarier']: + rec = record['deliverycarier'] + return {'name': rec['method_title']} diff --git a/connector_woocommerce/models/shipping_methods/listener.py b/connector_woocommerce/models/shipping_methods/listener.py new file mode 100644 index 0000000..8f09921 --- /dev/null +++ b/connector_woocommerce/models/shipping_methods/listener.py @@ -0,0 +1,38 @@ +# Copyright 2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class WooDeliveryCarrierBindingExportListener(Component): + _name = 'woo.delivery.carrier.binding.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.delivery.carrier'] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + record.with_delay().export_record() + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + record.with_delay().export_record() + + def on_record_unlink(self, record): + with record.backend_id.work_on(record._name) as work: + external_id = work.component(usage='binder').to_external(record) + if external_id: + record.with_delay().export_delete_record(record.backend_id, + external_id) + + +class WooDeliveryCarrierExportListener(Component): + _name = 'woo.delivery.carrier.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.delivery.carrier'] + + # XXX must check record.env!!! + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + for binding in record.woo_bind_ids: + binding.with_delay().export_record() diff --git a/connector_woocommerce/models/shipping_zone/__init__.py b/connector_woocommerce/models/shipping_zone/__init__.py new file mode 100644 index 0000000..515c143 --- /dev/null +++ b/connector_woocommerce/models/shipping_zone/__init__.py @@ -0,0 +1,6 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common +from . import exporter +from . import importer +from . import listener diff --git a/connector_woocommerce/models/shipping_zone/common.py b/connector_woocommerce/models/shipping_zone/common.py new file mode 100644 index 0000000..39649c3 --- /dev/null +++ b/connector_woocommerce/models/shipping_zone/common.py @@ -0,0 +1,130 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import logging + +import xmlrpc.client +from odoo import api, fields, models +from odoo.addons.component.core import Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.job import job, related_action + +_logger = logging.getLogger(__name__) + + +class WooShippingZone(models.Model): + _name = 'woo.shipping.zone' + _inherit = 'woo.binding' + _inherits = {'res.country': 'odoo_id'} + _description = 'Woo Shipping Zone' + + _rec_name = 'name' + + odoo_id = fields.Many2one( + 'res.country', + string='country', + required=True, + ondelete='cascade' + ) + backend_id = fields.Many2one( + comodel_name='woo.backend', + string='Woo Backend', + store=True, + readonly=False, + ) + + @job(default_channel='root.woo') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_record(self): + """ Export Shipping Zones. """ + for rec in self: + rec.ensure_one() + with rec.backend_id.work_on(rec._name) as work: + exporter = work.component(usage='shipping.zone.exporter') + return exporter.run(self) + + +class ShippingZone(models.Model): + _inherit = 'res.country' + + woo_bind_ids = fields.One2many( + comodel_name='woo.shipping.zone', + inverse_name='odoo_id', + string="Woo Bindings", + ) + # These fields are required for export + sync_data = fields.Boolean("Synch with Woo?") + woo_backend_id = fields.Many2one( + 'woo.backend', + string="WooCommerce Store" + ) + + +class ShippingZoneAdapter(Component): + _name = 'woo.shipping.zone.adapter' + _inherit = 'woo.adapter' + _apply_on = 'woo.shipping.zone' + + _woo_model = 'shipping/zones' + + def _call(self, method, resource, arguments): + try: + return super(ShippingZoneAdapter, self)._call(method, resource, + arguments) + except xmlrpc.client.Fault as err: + # this is the error in the WooCommerce API + # when the Shipping Zone does not exist + if err.faultCode == 102: + raise IDMissingInBackend + else: + raise + + def search(self, method, filters=None, from_date=None, to_date=None): + """ Search records according to some criteria and return a + list of ids + + :rtype: list + """ + if filters is None: + filters = {} + WOO_DATETIME_FORMAT = '%Y/%m/%d %H:%M:%S' + dt_fmt = WOO_DATETIME_FORMAT + if from_date is not None: + filters.setdefault('updated_at', {}) + filters['updated_at']['from'] = from_date.strftime(dt_fmt) + if to_date is not None: + filters.setdefault('updated_at', {}) + filters['updated_at']['to'] = to_date.strftime(dt_fmt) + res = self._call(method, 'shipping/zones', + [filters] if filters else [{}]) + # Set shipping zone ids and return it(Due to new WooCommerce REST API) + zone_ids = list() + for zone in res: # name + zone_ids.append(zone.get('id')) + return zone_ids + + def create(self, data): + """ Create a record on the external system """ + data = { + "shipping_zone": data + } + return self._call('post', self._woo_model, data) + + def write(self, id, data): + """ Update records on the external system """ + data = { + "shipping_zone": data + } + return self._call('put', self._woo_model + "/" + str(id), data) + + def is_woo_record(self, woo_id, filters=None): + """ + This method is verify the existing record on WooCommerce. + @param: woo_id : External id (int) + @param: filters : Filters to check (json) + @return: result : Response of Woocom (Boolean) + """ + return self._call('get', self._woo_model + '/' + str(woo_id), filters) diff --git a/connector_woocommerce/models/shipping_zone/exporter.py b/connector_woocommerce/models/shipping_zone/exporter.py new file mode 100644 index 0000000..05a6c67 --- /dev/null +++ b/connector_woocommerce/models/shipping_zone/exporter.py @@ -0,0 +1,45 @@ +# Copyright 2013-2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping, changed_by + + +class ShippingZoneExporter(Component): + _name = 'woo.shipping.zone.exporter' + _inherit = ['woo.exporter', 'woo.base.exporter'] + _apply_on = ['woo.shipping.zone'] + _usage = 'shipping.zone.exporter' + + def _after_export(self): + """After Export""" + self.binding.odoo_id.sudo().write({ + 'sync_data': True, + 'woo_backend_id': self.backend_record.id + }) + return + + def _validate_create_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.create`` or + ``Model.update`` if some fields are missing + + Raise `InvalidDataError` + """ + return + + def _get_data(self, binding, fields): + result = {} + return result + + +class ShippingZoneExportMapper(Component): + _name = 'woo.shipping.zone.export.mapper' + _inherit = 'woo.export.mapper' + _apply_on = ['woo.shipping.zone'] + + @changed_by('name') + @mapping + def name(self, record): + return {"name": record.name} diff --git a/connector_woocommerce/models/shipping_zone/importer.py b/connector_woocommerce/models/shipping_zone/importer.py new file mode 100644 index 0000000..5d20ef2 --- /dev/null +++ b/connector_woocommerce/models/shipping_zone/importer.py @@ -0,0 +1,116 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import logging + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping + +_logger = logging.getLogger(__name__) + + +class ShippingZoneBatchImporter(Component): + """ Import the WooCommerce Product Categories. + + For every partner in the list, a delayed job is created. + """ + + _name = 'woo.shipping.zone.batch.importer' + _inherit = 'woo.delayed.batch.importer' + _apply_on = 'woo.shipping.zone' + + def _import_record(self, woo_id): + """ Delay a job for the import """ + super(ShippingZoneBatchImporter, self)._import_record( + woo_id) + + 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( + method='get', + filters=filters, + from_date=from_date, + to_date=to_date, + ) + ShippingZone_ref = self.env['woo.shipping.zone'] + record = [] + # Get external ids from odoo for comparison + zone_rec = ShippingZone_ref.search([('external_id', '!=', '')]) + for ext_id in zone_rec: + record.append(int(ext_id.external_id)) + # Get difference ids + diff = list(set(record) - set(record_ids)) + for del_woo_rec in diff: + woo_zone_id = ShippingZone_ref.search( + [('external_id', '=', del_woo_rec)]) + zone_id = woo_zone_id.odoo_id + odoo_zone_id = self.env['res.country'].search( + [('id', '=', zone_id.id)]) + # Delete reference from odoo + odoo_zone_id.write({ + 'woo_bind_ids': [(3, odoo_zone_id.woo_bind_ids[0].id)], + 'sync_data': False, + 'woo_backend_id': None + }) + + _logger.info('search for woo shipping zone %s returned %s', + filters, record_ids) + for record_id in record_ids: + self._import_record(record_id) + + +class ShippingZoneImporter(Component): + _name = 'woo.shipping.zone.importer' + _inherit = 'woo.importer' + _apply_on = ['woo.shipping.zone'] + + def _create(self, data): + odoo_binding = super(ShippingZoneImporter, self)._create(data) + # Adding Creation Checkpoint + self.backend_record.add_checkpoint(odoo_binding) + return odoo_binding + + def _update(self, binding, data): + """ Update an Odoo record """ + super(ShippingZoneImporter, self)._update(binding, data) + # Adding updation checkpoint + # self.backend_record.add_checkpoint(binding) + return + + def _before_import(self): + """ Hook called before the import""" + return + + def _after_import(self, binding): + """ Hook called at the end of the import """ + return + + +class ShippingZoneImportMapper(Component): + _name = 'woo.shipping.zone.import.mapper' + _inherit = 'woo.import.mapper' + _apply_on = 'woo.shipping.zone' + + @mapping + def name(self, record): + if record['name']: + rec = record['name'] + return {'name': rec['name']} + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + # Required for export + @mapping + def sync_data(self, record): + if record.get('res.country'): + return {'sync_data': True} + + @mapping + def woo_backend_id(self, record): + return {'woo_backend_id': self.backend_record.id} diff --git a/connector_woocommerce/models/shipping_zone/listener.py b/connector_woocommerce/models/shipping_zone/listener.py new file mode 100644 index 0000000..8e568a4 --- /dev/null +++ b/connector_woocommerce/models/shipping_zone/listener.py @@ -0,0 +1,38 @@ +# Copyright 2017 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.addons.component.core import Component +from odoo.addons.component_event import skip_if + + +class WooShippingZoneBindingExportListener(Component): + _name = 'woo.shipping.zone.binding.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.shipping.zone'] + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_create(self, record, fields=None): + record.with_delay().export_record() + + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + record.with_delay().export_record() + + def on_record_unlink(self, record): + with record.backend_id.work_on(record._name) as work: + external_id = work.component(usage='binder').to_external(record) + if external_id: + record.with_delay().export_delete_record(record.backend_id, + external_id) + + +class WooShippingZoneExportListener(Component): + _name = 'woo.shipping.zone.export.listener' + _inherit = 'base.connector.listener' + _apply_on = ['woo.shipping.zone'] + + # XXX must check record.env!!! + @skip_if(lambda self, record, **kwargs: self.no_connector_export(record)) + def on_record_write(self, record, fields=None): + for binding in record.woo_bind_ids: + binding.with_delay().export_record() diff --git a/connector_woocommerce/models/woo_backend/__init__.py b/connector_woocommerce/models/woo_backend/__init__.py new file mode 100644 index 0000000..c81be2c --- /dev/null +++ b/connector_woocommerce/models/woo_backend/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import common diff --git a/connector_woocommerce/models/woo_backend/common.py b/connector_woocommerce/models/woo_backend/common.py new file mode 100644 index 0000000..b18bea2 --- /dev/null +++ b/connector_woocommerce/models/woo_backend/common.py @@ -0,0 +1,359 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +import logging +from contextlib import contextmanager +from datetime import datetime + +from odoo import models, api, fields, _ +from odoo.addons.connector.models import checkpoint +from odoo.exceptions import Warning + +from ...components.backend_adapter import WooLocation, WooAPI + +_logger = logging.getLogger(__name__) + +try: + from woocommerce import API +except ImportError: + _logger.debug("Cannot import 'woocommerce'") + + +class WooBackend(models.Model): + _name = 'woo.backend' + _inherit = 'connector.backend' + _description = 'WooCommerce Backend Configuration' + + name = fields.Char(string='name') + location = fields.Char("Url") + consumer_key = fields.Char("Consumer key") + consumer_secret = fields.Char("Consumer Secret") + version = fields.Selection([ + ('v2', 'V2'), + ('v3', 'V3') + ], + string='Version') + verify_ssl = fields.Boolean("Verify SSL") + 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) + woo_location = WooLocation( + self.location, + self.consumer_key, + self.consumer_secret, + self.version or 'v3' + ) + with WooAPI(woo_location) as woo_api: + _super = super(WooBackend, self) + # from the components we'll be able to do: self.work.woo_api + with _super.work_on( + model_name, woo_api=woo_api, **kwargs) as work: + yield work + + @api.multi + def add_checkpoint(self, record): + self.ensure_one() + record.ensure_one() + return checkpoint.add_checkpoint(self.env, record._name, record.id, + self._name, self.id) + + @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 + version = self.version or 'v3' + msg = str() + try: + wcapi = API( + url=location, # Your store URL + consumer_key=cons_key, # Your consumer key + consumer_secret=sec_key, # Your consumer secret + version=version, # WooCommerce WP REST API version + query_string_auth=True # Force Basic Authentication as query + # string true and using under HTTPS + ) + r = wcapi.get("products") + if r.status_code == 404: + msg = "(Enter Valid url)" + val = r.json() + except Exception as e: + raise Warning(_( + "Sorry, Could not reach WooCommerce site! %s %s") % (msg, e)) + msg = '' + if 'errors' in r.json(): + msg = val['errors'][0]['message'] + '\n' + val['errors'][0]['code'] + raise Warning(_(msg)) + else: + raise Warning(_('Test Success')) + return True + + @api.multi + def import_shippingzone(self): + import_start_time = datetime.now() + backend = self + from_date = None + self.env['woo.shipping.zone'].with_delay(priority=1).import_batch( + backend, + filters={'from_date': from_date, + 'to_date': import_start_time}, + ) + return True + + @api.multi + def import_category(self): + import_start_time = datetime.now() + backend = self + from_date = None + self.env['woo.product.category'].with_delay(priority=1).import_batch( + backend, + filters={'from_date': from_date, + 'to_date': import_start_time}, + ) + return True + + @api.multi + def import_product(self): + import_start_time = datetime.now() + backend = self + from_date = None + self.env['woo.product.product'].with_delay(priority=2).import_batch( + backend, + filters={'from_date': from_date, + 'to_date': import_start_time}, + ) + return True + + @api.multi + def import_customer(self): + import_start_time = datetime.now() + backend = self + from_date = None + self.env['woo.res.partner'].with_delay(priority=3).import_batch( + backend, + filters={'from_date': from_date, + 'to_date': import_start_time} + ) + return True + + @api.multi + def import_order(self): + import_start_time = datetime.now() + backend = self + from_date = None + self.env['woo.sale.order'].with_delay(priority=4).import_batch( + backend, + filters={'from_date': from_date, + 'to_date': import_start_time} + ) + return True + + @api.multi + def import_categories(self): + """ Import Product categories from WooCommerce site """ + for backend in self: + backend.import_category() + return True + + @api.multi + def import_products(self): + """ Import Products from WooCommerce site """ + for backend in self: + backend.import_product() + return True + + @api.multi + def import_customers(self): + """ Import Customers from WooCommerce site """ + for backend in self: + backend.import_customer() + return True + + @api.multi + def import_orders(self): + """ Import Orders from WooCommerce site """ + for backend in self: + backend.import_order() + return True + + @api.multi + def export_data(self, model, domain=None): + """ + This method create/updates the records with Odoo record + on WooCoomerce store. + """ + # Set active_field based on model for passing context purpose + if model == 'res.country': + active_field = 'res_country' + elif model == 'sale.order': + active_field = 'order_ids' + elif model == 'res.partner': + active_field = 'partner_ids' + elif model == 'product.product': + active_field = 'product_ids' + elif model == 'product.category': + active_field = 'product_cate_ids' + + self.ensure_one() + if model == 'res.country': + woo_obj = self.env["woo.shipping.zone"] + else: + woo_obj = self.env["woo.%s" % model] + + target_obj = self.env[model] + import_ids = target_obj.search(domain) + if not import_ids: + raise Warning(_("Sorry, There is no record to Export!")) + # Creating Jobs + for import_id in import_ids: + is_woo_data = woo_obj.search([ + ('odoo_id', '=', import_id.id)], limit=1) + if is_woo_data: + result = self.env['wizard.woo.export'].before_woo_validate( + active_field=active_field, active_model=model, + is_woo_data=is_woo_data, active_id=import_id) + if not result: + context = { + 'is_woo_data': is_woo_data.id, + 'active_field': active_field, + 'active_model': model, + 'odoo_id': is_woo_data.odoo_id.id, + 'external_id': is_woo_data.external_id, + 'backend_id': import_id.woo_backend_id.id, + } + self.env['wizard.woo.validation'].with_context( + context).woo_validate() + is_woo_data.with_delay().export_record() + else: + # Build environment to export + import_id = woo_obj.create({ + 'backend_id': self.id, + 'odoo_id': import_id.id, + }) + # Do export + import_id.with_delay().export_record() + return True + + @api.multi + def export_category(self): + """ + This Method create/update the product category records + on WooCommerce with Odoo data. + """ + # Add filters if any here. + domain = [] + context = self.env.context + # Set domain based on context (Export/Update record condition) + if context.get('export_product_category') and not context.get( + 'update_product_category'): + domain = [('sync_data', '!=', True)] + elif not context.get('export_product_category') and context.get( + 'update_product_category'): + domain = [('sync_data', '=', True)] + self.with_context(context).export_data("product.category", domain) + + @api.multi + def export_product(self): + """ + This Method create/update the product records + on WooCommerce with Odoo data. + """ + # Add filters if any here. + domain = [] + context = self.env.context + # Set domain based on context (Export/Update record condition) + if context.get('export_product') and not context.get( + 'update_product'): + domain = [('sync_data', '!=', True), ('active', '=', True)] + elif not context.get('export_product') and context.get( + 'update_product'): + domain = [('sync_data', '=', True), ('active', '=', True)] + self.with_context(context).export_data("product.product", domain) + + @api.multi + def export_customer(self): + """ + This Method create/update the customer records + on WooCommerce with Odoo data. + """ + # Add filters if any here. + domain = [] + context = self.env.context + # Set domain based on context (Export/Update record condition) + if context.get('export_customer') and not context.get( + 'update_customer'): + domain = [('sync_data', '!=', True), ('customer', '=', True), + ('active', '=', True)] + elif not context.get('export_customer')and context.get( + 'update_customer'): + domain = [('sync_data', '=', True), ('customer', '=', True), + ('active', '=', True)] + self.with_context(context).export_data("res.partner", domain) + + @api.multi + def export_saleorder(self): + """ + This Method create/update the customer records + on WooCommerce with Odoo data. + """ + # Add filters if any here. + domain = [] + context = self.env.context + # Set domain based on context (Export/Update record condition) + if context.get('export_sale_order') and not context.get( + 'update_sale_order'): + domain = [('sync_data', '!=', True)] + elif not context.get('export_sale_order') and context.get( + 'update_sale_order'): + domain = [('sync_data', '=', True)] + self.with_context(context).export_data("sale.order", domain) + + @api.multi + def export_shippingzone(self): + """ + This Method create/update the customer records + on WooCommerce with Odoo data. + """ + # Add filters if any here. + domain = [] + context = self.env.context + # Set domain based on context (Export/Update record condition) + if context.get('export_shippingzone') and not context.get( + 'update_shippingzone'): + domain = [('sync_data', '!=', True)] + elif not context.get('export_shippingzone') and context.get( + 'update_shippingzone'): + domain = [('sync_data', '=', True)] + self.with_context(context).export_data("res.country", domain) diff --git a/connector_woocommerce/models/woo_binding/__init__.py b/connector_woocommerce/models/woo_binding/__init__.py new file mode 100644 index 0000000..4415620 --- /dev/null +++ b/connector_woocommerce/models/woo_binding/__init__.py @@ -0,0 +1,6 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +from . import common diff --git a/connector_woocommerce/models/woo_binding/common.py b/connector_woocommerce/models/woo_binding/common.py new file mode 100644 index 0000000..de36c6a --- /dev/null +++ b/connector_woocommerce/models/woo_binding/common.py @@ -0,0 +1,69 @@ +# © 2009 Tech-Receptives Solutions Pvt. Ltd. +# © 2018 Serpent Consulting Services Pvt. Ltd. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +# See LICENSE file for full copyright and licensing details. + +from odoo import api, models, fields +from odoo.addons.queue_job.job import job, related_action + + +class WooBinding(models.AbstractModel): + """ Abstract Model for the Bindings. + + All the models used as bindings between Woo and Odoo + (``woo.res.partner``, ``woo.product.product``, ...) should + ``_inherit`` it. + """ + _name = 'woo.binding' + _inherit = 'external.binding' + _description = 'Woo Binding (abstract)' + + # odoo_id = odoo-side id must be declared in concrete model + backend_id = fields.Many2one( + comodel_name='woo.backend', + string='Woo Backend', + required=True, + ondelete='restrict', + ) + # fields.Char because 0 is a valid Woo 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.woo') + @api.model + def import_batch(self, backend, filters=None): + """ Prepare the import of records modified on Woo """ + with backend.work_on(self._name) as work: + importer = work.component(usage='batch.importer') + return importer.run(filters=filters) + + @job(default_channel='root.woo') + @related_action(action='related_action_woo_link') + @api.model + def import_record(self, backend, external_id, force=False): + """ Import a Woo 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.woo') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_record(self): + """ Export a Odoo Record. """ + self.ensure_one() + with self.backend_id.work_on(self._name) as work: + exporter = work.component(usage='record.exporter') + return exporter.run(self) + + @job(default_channel='root.woo') + @related_action(action='related_action_magento_link') + 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/security/ir.model.access.csv b/connector_woocommerce/security/ir.model.access.csv new file mode 100644 index 0000000..57746bb --- /dev/null +++ b/connector_woocommerce/security/ir.model.access.csv @@ -0,0 +1,10 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_woo_sale_order_status,access_woo_sale_order_status,model_woo_sale_order_status,connector.group_connector_manager,1,1,1,0 +access_woo_sale_order_line,access_woo_sale_order_line,model_woo_sale_order_line,connector.group_connector_manager,1,1,1,0 +access_woo_backend,access_woo_backend,model_woo_backend,connector.group_connector_manager,1,1,1,1 +access_woo_sale_oder,access_woo_sale_order,model_woo_sale_order,connector.group_connector_manager,1,1,1,0 +access_woo_res_partner,access_woo_res_partner,model_woo_res_partner,connector.group_connector_manager,1,1,1,0 +access_woo_product_product,access_woo_product_product,model_woo_product_product,connector.group_connector_manager,1,1,1,0 +access_woo_product_category,access_woo_product_category,model_woo_product_category,connector.group_connector_manager,1,1,1,0 +access_woo_delivery_carrier,access_woo_delivery_carrier,model_woo_delivery_carrier,base.group_user,1,0,0,0 +access_woo_shipping_zone,access_woo_shipping_zone,model_woo_shipping_zone,base.group_user,1,0,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/img/checkpoint.png b/connector_woocommerce/static/description/img/checkpoint.png new file mode 100644 index 0000000..105925f Binary files /dev/null and b/connector_woocommerce/static/description/img/checkpoint.png differ diff --git a/connector_woocommerce/static/description/img/config.png b/connector_woocommerce/static/description/img/config.png new file mode 100644 index 0000000..9d85e35 Binary files /dev/null and b/connector_woocommerce/static/description/img/config.png differ diff --git a/connector_woocommerce/static/description/img/ex_config.png b/connector_woocommerce/static/description/img/ex_config.png new file mode 100644 index 0000000..838f7ca Binary files /dev/null and b/connector_woocommerce/static/description/img/ex_config.png differ diff --git a/connector_woocommerce/static/description/img/ex_customer.png b/connector_woocommerce/static/description/img/ex_customer.png new file mode 100644 index 0000000..75e9a44 Binary files /dev/null and b/connector_woocommerce/static/description/img/ex_customer.png differ diff --git a/connector_woocommerce/static/description/img/ex_product.png b/connector_woocommerce/static/description/img/ex_product.png new file mode 100644 index 0000000..1c58681 Binary files /dev/null and b/connector_woocommerce/static/description/img/ex_product.png differ diff --git a/connector_woocommerce/static/description/img/ex_sale.png b/connector_woocommerce/static/description/img/ex_sale.png new file mode 100644 index 0000000..456e377 Binary files /dev/null and b/connector_woocommerce/static/description/img/ex_sale.png differ diff --git a/connector_woocommerce/static/description/img/ex_sale_2.png b/connector_woocommerce/static/description/img/ex_sale_2.png new file mode 100644 index 0000000..0b0f17f Binary files /dev/null and b/connector_woocommerce/static/description/img/ex_sale_2.png differ diff --git a/connector_woocommerce/static/description/img/ex_sale_3.png b/connector_woocommerce/static/description/img/ex_sale_3.png new file mode 100644 index 0000000..d72d95d Binary files /dev/null and b/connector_woocommerce/static/description/img/ex_sale_3.png differ diff --git a/connector_woocommerce/static/description/img/export.png b/connector_woocommerce/static/description/img/export.png new file mode 100644 index 0000000..d025de0 Binary files /dev/null and b/connector_woocommerce/static/description/img/export.png differ diff --git a/connector_woocommerce/static/description/img/import.png b/connector_woocommerce/static/description/img/import.png new file mode 100644 index 0000000..62ddec7 Binary files /dev/null and b/connector_woocommerce/static/description/img/import.png differ diff --git a/connector_woocommerce/static/description/img/job.png b/connector_woocommerce/static/description/img/job.png new file mode 100644 index 0000000..d8987a5 Binary files /dev/null and b/connector_woocommerce/static/description/img/job.png differ diff --git a/connector_woocommerce/static/description/img/test.png b/connector_woocommerce/static/description/img/test.png new file mode 100644 index 0000000..078b942 Binary files /dev/null and b/connector_woocommerce/static/description/img/test.png differ diff --git a/connector_woocommerce/static/description/img/url.png b/connector_woocommerce/static/description/img/url.png new file mode 100644 index 0000000..990b54f Binary files /dev/null and b/connector_woocommerce/static/description/img/url.png differ diff --git a/connector_woocommerce/static/description/img/woo_setting.png b/connector_woocommerce/static/description/img/woo_setting.png new file mode 100644 index 0000000..e18af10 Binary files /dev/null and b/connector_woocommerce/static/description/img/woo_setting.png differ diff --git a/connector_woocommerce/static/description/img/woo_setting2.png b/connector_woocommerce/static/description/img/woo_setting2.png new file mode 100644 index 0000000..e6b2ce2 Binary files /dev/null and b/connector_woocommerce/static/description/img/woo_setting2.png differ diff --git a/connector_woocommerce/static/description/img/wookeys.png b/connector_woocommerce/static/description/img/wookeys.png new file mode 100644 index 0000000..d9efeed Binary files /dev/null and b/connector_woocommerce/static/description/img/wookeys.png differ diff --git a/connector_woocommerce/static/description/index.html b/connector_woocommerce/static/description/index.html new file mode 100644 index 0000000..0b03f12 --- /dev/null +++ b/connector_woocommerce/static/description/index.html @@ -0,0 +1,118 @@ +
+
+

Connect Woocommerce with Odoo.

+
+
+
+
+

Get the URL, Consumer key and Consumer Secret Key of Woocommerce.

+
+ +
+
+ +
+
+
+
+
+

Configure the URL, Consumer key and Consumer Secret Key of Woocommerce.

+
+ +
+
+
+
+
+

Update the WooCommerce Permalinks Settings as Below.

+
+ +
+
+ +
+
+
+
+
+

Check for the connection.

+
+ +
+
+
+
+
+

Import the Product's Categories, Products, Customers and Sale Orders of Woocommerce.

+
+ +
+
+
+
+
+

Export all Product's Categories, Products, Customers and Sale Orders from Odoo to WooCommerce.

+
+ +
+
+
+
+
+

Set the WooCommerce Backend at Product's Categories, Products, Customers and Sale Orders for specific export.

+
+ +
+
+
+
+
+

Export Specific Product's Categories, Products, Customers and Sale Orders from Odoo to WooCommerce through wizard.

+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+

Validate the Export Record.

+
+ +
+
+
+
+
+

Export the Records.

+
+ +
+
+
+
+
+

Trace the jobs.

+
+ +
+
+
+
+
+

Review the Checkpoints.

+
+ +
+
+
+
+
diff --git a/connector_woocommerce/views/backend_view.xml b/connector_woocommerce/views/backend_view.xml new file mode 100644 index 0000000..e63306e --- /dev/null +++ b/connector_woocommerce/views/backend_view.xml @@ -0,0 +1,138 @@ + + + + woo.backend.tree + woo.backend + tree + + + + + + + + + + woo.backend.form + woo.backend + form + + +
+
+
+ +