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