From 6e04b9d61d3a403b5f1875f28b666676010f0bb5 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Wed, 15 Dec 2021 10:36:44 -0800 Subject: [PATCH 1/3] WIP from 12 --- connector_opencart/__init__.py | 2 + connector_opencart/__manifest__.py | 28 ++ connector_opencart/components/__init__.py | 6 + connector_opencart/components/api/__init__.py | 1 + connector_opencart/components/api/opencart.py | 171 +++++++ .../components/backend_adapter.py | 67 +++ connector_opencart/components/binder.py | 25 + connector_opencart/components/exporter.py | 313 ++++++++++++ connector_opencart/components/importer.py | 332 +++++++++++++ connector_opencart/components/mapper.py | 16 + .../data/connector_opencart_data.xml | 40 ++ connector_opencart/models/__init__.py | 5 + .../models/delivery/__init__.py | 1 + connector_opencart/models/delivery/common.py | 22 + .../models/opencart/__init__.py | 5 + connector_opencart/models/opencart/backend.py | 188 ++++++++ .../models/opencart/backend_importer.py | 19 + connector_opencart/models/opencart/binding.py | 48 ++ connector_opencart/models/opencart/store.py | 81 ++++ .../models/opencart/store_importer.py | 27 ++ connector_opencart/models/product/__init__.py | 2 + connector_opencart/models/product/common.py | 88 ++++ connector_opencart/models/product/importer.py | 95 ++++ .../models/sale_order/__init__.py | 2 + .../models/sale_order/common.py | 120 +++++ .../models/sale_order/importer.py | 450 ++++++++++++++++++ .../models/stock_picking/__init__.py | 2 + .../models/stock_picking/common.py | 93 ++++ .../models/stock_picking/exporter.py | 36 ++ .../security/ir.model.access.csv | 15 + connector_opencart/views/delivery_views.xml | 19 + .../views/opencart_backend_views.xml | 176 +++++++ .../views/opencart_product_views.xml | 56 +++ connector_opencart/views/product_views.xml | 33 ++ connector_opencart/views/sale_order_views.xml | 53 +++ 35 files changed, 2637 insertions(+) create mode 100644 connector_opencart/__init__.py create mode 100644 connector_opencart/__manifest__.py create mode 100644 connector_opencart/components/__init__.py create mode 100644 connector_opencart/components/api/__init__.py create mode 100644 connector_opencart/components/api/opencart.py create mode 100644 connector_opencart/components/backend_adapter.py create mode 100644 connector_opencart/components/binder.py create mode 100644 connector_opencart/components/exporter.py create mode 100644 connector_opencart/components/importer.py create mode 100644 connector_opencart/components/mapper.py create mode 100644 connector_opencart/data/connector_opencart_data.xml create mode 100644 connector_opencart/models/__init__.py create mode 100644 connector_opencart/models/delivery/__init__.py create mode 100644 connector_opencart/models/delivery/common.py create mode 100644 connector_opencart/models/opencart/__init__.py create mode 100644 connector_opencart/models/opencart/backend.py create mode 100644 connector_opencart/models/opencart/backend_importer.py create mode 100644 connector_opencart/models/opencart/binding.py create mode 100644 connector_opencart/models/opencart/store.py create mode 100644 connector_opencart/models/opencart/store_importer.py create mode 100644 connector_opencart/models/product/__init__.py create mode 100644 connector_opencart/models/product/common.py create mode 100644 connector_opencart/models/product/importer.py create mode 100644 connector_opencart/models/sale_order/__init__.py create mode 100644 connector_opencart/models/sale_order/common.py create mode 100644 connector_opencart/models/sale_order/importer.py create mode 100644 connector_opencart/models/stock_picking/__init__.py create mode 100644 connector_opencart/models/stock_picking/common.py create mode 100644 connector_opencart/models/stock_picking/exporter.py create mode 100644 connector_opencart/security/ir.model.access.csv create mode 100644 connector_opencart/views/delivery_views.xml create mode 100644 connector_opencart/views/opencart_backend_views.xml create mode 100644 connector_opencart/views/opencart_product_views.xml create mode 100644 connector_opencart/views/product_views.xml create mode 100644 connector_opencart/views/sale_order_views.xml diff --git a/connector_opencart/__init__.py b/connector_opencart/__init__.py new file mode 100644 index 00000000..f24d3e24 --- /dev/null +++ b/connector_opencart/__init__.py @@ -0,0 +1,2 @@ +from . import components +from . import models diff --git a/connector_opencart/__manifest__.py b/connector_opencart/__manifest__.py new file mode 100644 index 00000000..324e8593 --- /dev/null +++ b/connector_opencart/__manifest__.py @@ -0,0 +1,28 @@ +# © 2019-2021 Hibou Corp. + +{ + 'name': 'Opencart Connector', + 'version': '15.0.1.0.0', + 'category': 'Connector', + 'depends': [ + 'account', + 'product', + 'delivery', + 'sale_stock', + 'connector_ecommerce', + 'base_technical_user', + ], + 'author': 'Hibou Corp.', + 'license': 'AGPL-3', + 'website': 'https://hibou.io', + 'data': [ + 'data/connector_opencart_data.xml', + 'security/ir.model.access.csv', + 'views/delivery_views.xml', + 'views/opencart_backend_views.xml', + 'views/opencart_product_views.xml', + 'views/product_views.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/connector_opencart/components/__init__.py b/connector_opencart/components/__init__.py new file mode 100644 index 00000000..ad96af21 --- /dev/null +++ b/connector_opencart/components/__init__.py @@ -0,0 +1,6 @@ +from . import api +from . import backend_adapter +from . import binder +from . import importer +from . import exporter +from . import mapper diff --git a/connector_opencart/components/api/__init__.py b/connector_opencart/components/api/__init__.py new file mode 100644 index 00000000..cb2d1f1d --- /dev/null +++ b/connector_opencart/components/api/__init__.py @@ -0,0 +1 @@ +from . import opencart diff --git a/connector_opencart/components/api/opencart.py b/connector_opencart/components/api/opencart.py new file mode 100644 index 00000000..dafe8327 --- /dev/null +++ b/connector_opencart/components/api/opencart.py @@ -0,0 +1,171 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import requests +from urllib.parse import urlencode +from json import loads, dumps +from json.decoder import JSONDecodeError + +import logging +_logger = logging.getLogger(__name__) + + +class Opencart: + + def __init__(self, base_url, restadmin_token): + self.base_url = str(base_url) + '/api/rest_admin/' + self.restadmin_token = restadmin_token + self.session = requests.Session() + self.session.headers['X-Oc-Restadmin-Id'] = self.restadmin_token + + @property + def orders(self): + return Orders(connection=self) + + @property + def stores(self): + return Stores(connection=self) + + @property + def products(self): + return Products(connection=self) + + def get_headers(self, url, method): + headers = {} + if method in ('POST', 'PUT', ): + headers['Content-Type'] = 'application/json' + return headers + + def send_request(self, method, url, params=None, body=None): + encoded_url = url + if params: + encoded_url += '?%s' % urlencode(params) + headers = self.get_headers(encoded_url, method) + _logger.debug('send_request method: %s url: %s headers: %s params: %s body: %s' % ( + method, + url, + headers, + params, + body + )) + if method == 'GET': + result_text = self.session.get(url, params=params, headers=headers).text + elif method == 'PUT' or method == 'POST': + result_text = self.session.put(url, data=body, headers=headers).text + _logger.debug('raw_text: ' + str(result_text)) + try: + return loads(result_text) + except JSONDecodeError: + return {} + + +class Resource: + """ + A base class for all Resources to extend + """ + + def __init__(self, connection): + self.connection = connection + + @property + def url(self): + return self.connection.base_url + self.path + + +class Orders(Resource): + """ + Retrieves Order details + """ + + path = 'orders' + + def all(self, id_larger_than=None, modified_from=None): + url = self.url + if id_larger_than: + url += '/id_larger_than/%s' % id_larger_than + if modified_from: + url += '/modified_from/%s' % modified_from + return self.connection.send_request(method='GET', url=url) + + def get(self, id): + url = self.url + ('/%s' % id) + return self.connection.send_request(method='GET', url=url) + + def ship(self, id, tracking, tracking_comment=None): + def url(stem): + return self.connection.base_url + ('%s/%s' % (stem, id)) + res = self.connection.send_request(method='PUT', url=url('trackingnumber'), body=self.get_tracking_payload(tracking)) + if tracking_comment: + res = self.connection.send_request(method='PUT', url=url('orderhistory'), body=self.get_orderhistory_payload( + 3, # "Shipped" + True, # Notify! + tracking_comment, + )) + return res + + def cancel(self, id): + url = self.connection.base_url + ('order_status/%s' % id) + return self.connection.send_request(method='POST', url=url, body=self.get_status_payload('Canceled')) + + def get_status_payload(self, status): + """ + { + "status": "Canceled" + } + """ + payload = { + "status": status, + } + return dumps(payload) + + def get_tracking_payload(self, tracking): + """ + { + "tracking": "5559994444" + } + """ + payload = { + "tracking": tracking, + } + return dumps(payload) + + def get_orderhistory_payload(self, status_id, notify, comment): + """ + { + "order_status_id": "5", + "notify": "1", + "comment": "demo comment" + } + """ + payload = { + 'order_status_id': str(status_id), + 'notify': '1' if notify else '0', + 'comment': str(comment) + } + return dumps(payload) + + +class Stores(Resource): + """ + Retrieves Store details + """ + + path = 'stores' + + def all(self): + return self.connection.send_request(method='GET', url=self.url) + + def get(self, id): + url = self.url + ('/%s' % id) + return self.connection.send_request(method='GET', url=url) + + +class Products(Resource): + """ + Retrieves Product details + """ + path = 'products' + + def get(self, id): + url = self.url + ('/%s' % id) + return self.connection.send_request(method='GET', url=url) diff --git a/connector_opencart/components/backend_adapter.py b/connector_opencart/components/backend_adapter.py new file mode 100644 index 00000000..cbbcd231 --- /dev/null +++ b/connector_opencart/components/backend_adapter.py @@ -0,0 +1,67 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent +from odoo.addons.queue_job.exception import RetryableJobError +from odoo.addons.connector.exception import NetworkRetryableError +from .api.opencart import Opencart +from logging import getLogger +from lxml import etree + +_logger = getLogger(__name__) + + +class BaseOpencartConnectorComponent(AbstractComponent): + """ Base Opencart Connector Component + + All components of this connector should inherit from it. + """ + _name = 'base.opencart.connector' + _inherit = 'base.connector' + _collection = 'opencart.backend' + + +class OpencartAdapter(AbstractComponent): + + _name = 'opencart.adapter' + _inherit = ['base.backend.adapter', 'base.opencart.connector'] + + _opencart_model = None + + 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 + + @property + def api_instance(self): + try: + opencart_api = getattr(self.work, 'opencart_api') + except AttributeError: + raise AttributeError( + 'You must provide a opencart_api attribute with a ' + 'Opencart instance to be able to use the ' + 'Backend Adapter.' + ) + return opencart_api diff --git a/connector_opencart/components/binder.py b/connector_opencart/components/binder.py new file mode 100644 index 00000000..b2525fea --- /dev/null +++ b/connector_opencart/components/binder.py @@ -0,0 +1,25 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class OpencartModelBinder(Component): + """ Bind records and give odoo/opencart ids correspondence + + Binding models are models called ``opencart.{normal_model}``, + like ``opencart.sale.order`` or ``opencart.product.product``. + They are ``_inherits`` of the normal models and contains + the Opencart ID, the ID of the Opencart Backend and the additional + fields belonging to the Opencart instance. + """ + _name = 'opencart.binder' + _inherit = ['base.binder', 'base.opencart.connector'] + _apply_on = [ + 'opencart.store', + 'opencart.sale.order', + 'opencart.sale.order.line', + 'opencart.stock.picking', + 'opencart.product.template', + 'opencart.product.template.attribute.value', + ] diff --git a/connector_opencart/components/exporter.py b/connector_opencart/components/exporter.py new file mode 100644 index 00000000..21920d7d --- /dev/null +++ b/connector_opencart/components/exporter.py @@ -0,0 +1,313 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from contextlib import contextmanager +from datetime import datetime + +import psycopg2 + +import odoo +from odoo import _ +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.exception import (IDMissingInBackend, + RetryableJobError) + +_logger = logging.getLogger(__name__) + + + + +class OpencartBaseExporter(AbstractComponent): + """ Base exporter for Opencart """ + + _name = 'opencart.base.exporter' + _inherit = ['base.exporter', 'base.opencart.connector'] + _usage = 'record.exporter' + + def __init__(self, working_context): + super(OpencartBaseExporter, self).__init__(working_context) + self.binding = None + self.external_id = None + + 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) + + 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']: + self.env.cr.commit() + + 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 to Opencart """ + pass + + +class OpencartExporter(AbstractComponent): + """ A common flow for the exports to Opencart """ + + _name = 'opencart.exporter' + _inherit = 'opencart.base.exporter' + + def __init__(self, working_context): + super(OpencartExporter, 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 "opencart_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 Opencart 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='opencart_bind_ids', + binding_extra_vals=None): + """ + Export a dependency. The exporter class is a subclass of + ``OpencartExporter``. 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: opencart_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 + # 'opencart.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 + # opencart.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']: + self.env.cr.commit() # noqa + else: + # If opencart_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 Opencart 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` """ + return map_record.values(fields=fields, **kwargs) + + def _update(self, data): + """ Update an Opencart 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.') + self.external_id = self._create(record) + return _('Record exported with ID %s on Opencart.') % self.external_id diff --git a/connector_opencart/components/importer.py b/connector_opencart/components/importer.py new file mode 100644 index 00000000..6a1ef5db --- /dev/null +++ b/connector_opencart/components/importer.py @@ -0,0 +1,332 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +""" + +Importers for Opencart. + +An import can be skipped if the last sync date is more recent than +the last update in Opencart. + +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, Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.exception import NothingToDoJob + + +_logger = logging.getLogger(__name__) + + +class OpencartImporter(AbstractComponent): + """ Base importer for Opencart """ + + _name = 'opencart.importer' + _inherit = ['base.importer', 'base.opencart.connector'] + _usage = 'record.importer' + + def __init__(self, work_context): + super(OpencartImporter, self).__init__(work_context) + self.external_id = None + self.opencart_record = None + + def _get_opencart_data(self): + """ Return the raw Opencart 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 Opencart + 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.opencart_record + if not self.opencart_record.get('date_updated'): + return # no update date on Opencart, 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) + opencart_date = from_string(self.opencart_record['date_updated']) + # if the last synchronization date is greater than the last + # update in opencart, we skip the import. + # Important: at the beginning of the exporters flows, we have to + # check if the opencart_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 Opencart + return opencart_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:`OpencartImporter`. 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 Opencart 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) + record = binder.to_internal(external_id) + if always or not record: + 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 + ) + return True + if binding_model == 'opencart.product.template' and record.backend_id.so_require_product_setup: + # Though this is not the "right" place to do this, + # we need to return True if there is a checkpoint for a product. + if record.backend_id.find_checkpoint(record): + return True + return False + + 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.opencart_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 opencart %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 opencart %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 Opencart + """ + 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.opencart_record = self._get_opencart_data() + except IDMissingInBackend: + return _('Record does no longer exist in Opencart') + + 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 = 'opencart.batch.importer' + _inherit = ['base.importer', 'base.opencart.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 = 'opencart.direct.batch.importer' + _inherit = 'opencart.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 = 'opencart.delayed.batch.importer' + _inherit = 'opencart.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) + + +# class SimpleRecordImporter(Component): +# """ Import one Opencart Website """ +# +# _name = 'opencart.simple.record.importer' +# _inherit = 'opencart.importer' +# _apply_on = [ +# 'opencart.res.partner.category', +# ] + + +# class TranslationImporter(Component): +# """ Import translations for a record. +# +# Usually called from importers, in ``_after_import``. +# For instance from the products and products' categories importers. +# """ +# +# _name = 'opencart.translation.importer' +# _inherit = 'opencart.importer' +# _usage = 'translation.importer' +# +# def _get_opencart_data(self, storeview_id=None): +# """ Return the raw Opencart data for ``self.external_id`` """ +# return self.backend_adapter.read(self.external_id, storeview_id) +# +# def run(self, external_id, binding, mapper=None): +# self.external_id = external_id +# storeviews = self.env['opencart.storeview'].search( +# [('backend_id', '=', self.backend_record.id)] +# ) +# default_lang = self.backend_record.default_lang_id +# lang_storeviews = [sv for sv in storeviews +# if sv.lang_id and sv.lang_id != default_lang] +# if not lang_storeviews: +# return +# +# # find the translatable fields of the model +# fields = self.model.fields_get() +# translatable_fields = [field for field, attrs in fields.items() +# if attrs.get('translate')] +# +# if mapper is None: +# mapper = self.mapper +# else: +# mapper = self.component_by_name(mapper) +# +# for storeview in lang_storeviews: +# lang_record = self._get_opencart_data(storeview.external_id) +# map_record = mapper.map_record(lang_record) +# record = map_record.values() +# +# data = dict((field, value) for field, value in record.items() +# if field in translatable_fields) +# +# binding.with_context(connector_no_export=True, +# lang=storeview.lang_id.code).write(data) diff --git a/connector_opencart/components/mapper.py b/connector_opencart/components/mapper.py new file mode 100644 index 00000000..f64b1483 --- /dev/null +++ b/connector_opencart/components/mapper.py @@ -0,0 +1,16 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class OpencartImportMapper(AbstractComponent): + _name = 'opencart.import.mapper' + _inherit = ['base.opencart.connector', 'base.import.mapper'] + _usage = 'import.mapper' + + +class OpencartExportMapper(AbstractComponent): + _name = 'opencart.export.mapper' + _inherit = ['base.opencart.connector', 'base.export.mapper'] + _usage = 'export.mapper' diff --git a/connector_opencart/data/connector_opencart_data.xml b/connector_opencart/data/connector_opencart_data.xml new file mode 100644 index 00000000..bc703882 --- /dev/null +++ b/connector_opencart/data/connector_opencart_data.xml @@ -0,0 +1,40 @@ + + + + + + Opencart - Import Sales Orders + + code + + 1 + hours + -1 + + + model._scheduler_import_sale_orders() + + + + Total Amount differs from Opencart + The amount computed in Odoo doesn't match with the amount in Opencart. + +Cause: +The taxes are probably different between Odoo and Opencart. A fiscal position could have changed the final price. + +Resolution: +Check your taxes and fiscal positions configuration and correct them if necessary. + 30 + sale.order + sale + failed = sale.opencart_bind_ids and abs(sale.amount_total - sale.opencart_bind_ids[0].total_amount) >= 0.01 + + + + + + + Opencart Order Comment Reviewer + + + diff --git a/connector_opencart/models/__init__.py b/connector_opencart/models/__init__.py new file mode 100644 index 00000000..a7f55577 --- /dev/null +++ b/connector_opencart/models/__init__.py @@ -0,0 +1,5 @@ +from . import delivery +from . import opencart +from . import product +from . import sale_order +from . import stock_picking diff --git a/connector_opencart/models/delivery/__init__.py b/connector_opencart/models/delivery/__init__.py new file mode 100644 index 00000000..e4193cf0 --- /dev/null +++ b/connector_opencart/models/delivery/__init__.py @@ -0,0 +1 @@ +from . import common diff --git a/connector_opencart/models/delivery/common.py b/connector_opencart/models/delivery/common.py new file mode 100644 index 00000000..e584ba29 --- /dev/null +++ b/connector_opencart/models/delivery/common.py @@ -0,0 +1,22 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models, fields, api + + +class DeliveryCarrier(models.Model): + """ Adds Opencart specific fields to ``delivery.carrier`` + + ``opencart_code`` + + Code of the carrier delivery method in Opencart. + Example: ``USPS`` + + + """ + _inherit = "delivery.carrier" + + opencart_code = fields.Char( + string='Opencart Method Code', + required=False, + ) diff --git a/connector_opencart/models/opencart/__init__.py b/connector_opencart/models/opencart/__init__.py new file mode 100644 index 00000000..289a138e --- /dev/null +++ b/connector_opencart/models/opencart/__init__.py @@ -0,0 +1,5 @@ +from . import backend +from . import backend_importer +from . import binding +from . import store +from . import store_importer diff --git a/connector_opencart/models/opencart/backend.py b/connector_opencart/models/opencart/backend.py new file mode 100644 index 00000000..7c3b4f77 --- /dev/null +++ b/connector_opencart/models/opencart/backend.py @@ -0,0 +1,188 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from logging import getLogger +from contextlib import contextmanager +from datetime import timedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.addons.connector.models.checkpoint import add_checkpoint +from ...components.api.opencart import Opencart + +_logger = getLogger(__name__) + + +class OpencartBackend(models.Model): + _name = 'opencart.backend' + _description = 'Opencart Backend' + _inherit = 'connector.backend' + + name = fields.Char(string='Name') + base_url = fields.Char( + string='Base URL', + required=True, + help='Url of your site, e.g. http://your-site.com', + ) + restadmin_token = fields.Char( + string='RestAdmin Token', + required=True, + help='configured in Extensions->Modules->RestAdminAPI', + ) + + warehouse_id = fields.Many2one( + comodel_name='stock.warehouse', + string='Warehouse', + required=True, + help='Warehouse to use for stock.', + ) + company_id = fields.Many2one( + comodel_name='res.company', + related='warehouse_id.company_id', + string='Company', + readonly=True, + ) + fiscal_position_id = fields.Many2one( + comodel_name='account.fiscal.position', + string='Fiscal Position', + help='Fiscal position to use on orders.', + ) + analytic_account_id = fields.Many2one( + comodel_name='account.analytic.account', + string='Analytic account', + help='If specified, this analytic account will be used to fill the ' + 'field on the sale order created by the connector.' + ) + team_id = fields.Many2one(comodel_name='crm.team', string='Sales Team') + sale_prefix = fields.Char( + string='Sale Prefix', + help="A prefix put before the name of imported sales orders.\n" + "For instance, if the prefix is 'OC-', the sales " + "order 36071 in Opencart, will be named 'OC-36071' " + "in Odoo.", + ) + coupon_product_id = fields.Many2one(comodel_name='product.product', string='Coupon Product', + help='Product to represent coupon discounts.') + + # New Product fields. + product_categ_id = fields.Many2one(comodel_name='product.category', string='Product Category', + help='Default product category for newly created products.') + + # renamed, and not used when searching orders anymore + import_orders_after_id = fields.Integer( + string='Highest Order ID', + ) + # Note that Opencart may not return timestamps in UTC + import_orders_after_date = fields.Datetime( + string='Import Orders Modified After', + ) + server_offset_hours = fields.Float( + string='Opencart Server Timezone Offset', + help='E.g. US Pacific is -8.0, the important thing is to either not change this during DST or to adjust the import_orders_after_date field at the same time.', + ) + + so_require_product_setup = fields.Boolean(string='SO Require Product Setup', + help='Prevents SO from being confirmed (failed queue job), if one or more products has an open checkpoint.') + + scheduler_order_import_running = fields.Boolean(string='Auctomatic Sale Order Import is Running', + compute='_compute_scheduler_order_import_running', + compute_sudo=True) + scheduler_order_import = fields.Boolean(string='Automatic Sale Order Import', + help='Individual stores should also be enabled for import.') + + def _compute_scheduler_order_import_running(self): + sched_action = self.env.ref('connector_opencart.ir_cron_import_sale_orders', raise_if_not_found=False) + for backend in self: + backend.scheduler_order_import_running = bool(sched_action.active) + + @contextmanager + @api.multi + def work_on(self, model_name, **kwargs): + self.ensure_one() + opencart_api = Opencart(self.base_url, self.restadmin_token) + _super = super(OpencartBackend, self) + with _super.work_on(model_name, opencart_api=opencart_api, **kwargs) as work: + yield work + + @api.multi + def add_checkpoint(self, record): + self.ensure_one() + record.ensure_one() + return add_checkpoint(self.env, record._name, record.id, + self._name, self.id) + + @api.multi + def find_checkpoint(self, record): + self.ensure_one() + record.ensure_one() + checkpoint_model = self.env['connector.checkpoint'] + model_model = self.env['ir.model'] + model = model_model.search([('model', '=', record._name)], limit=1) + return checkpoint_model.search([ + ('backend_id', '=', '%s,%s' % (self._name, self.id)), + ('model_id', '=', model.id), + ('record_id', '=', record.id), + ('state', '=', 'need_review'), + ], limit=1) + + @api.multi + def synchronize_metadata(self): + try: + for backend in self: + self.env['opencart.store'].import_batch(backend) + return True + except Exception as e: + _logger.error(e) + raise UserError(_("Check your configuration, we can't get the data. " + "Here is the error:\n%s") % (e, )) + + @api.model + def _scheduler_import_sale_orders(self): + # potential hook for customization (e.g. pad from date or provide its own) + backends = self.search([ + ('base_url', '!=', False), + ('restadmin_token', '!=', False), + ('import_orders_after_date', '!=', False), + ('scheduler_order_import', '=', True), + ]) + return backends.import_sale_orders() + + @api.multi + def import_sale_orders(self): + self._import_sale_orders_after_date() + return True + + @api.multi + def _import_after_id(self, model_name, after_id_field): + for backend in self: + after_id = backend[after_id_field] + self.env[model_name].with_delay().import_batch( + backend, + filters={'after_id': after_id} + ) + + @api.multi + def _import_sale_orders_after_date(self): + for backend in self: + date = backend.date_to_opencart(backend.import_orders_after_date) + date = str(date).replace(' ', 'T') + self.env['opencart.sale.order'].with_delay().import_batch( + backend, + filters={'modified_from': date} + ) + + def date_to_opencart(self, date): + # date provided should be UTC and will be converted to Opencart's dates + return self._date_plus_hours(date, self.server_offset_hours or 0) + + def date_to_odoo(self, date): + # date provided should be in Opencart's TZ, converted to UTC + return self._date_plus_hours(date, -(self.server_offset_hours or 0)) + + def _date_plus_hours(self, date, hours): + if not hours: + return date + if isinstance(date, str): + date = fields.Datetime.from_string(date) + return date + timedelta(hours=hours) diff --git a/connector_opencart/models/opencart/backend_importer.py b/connector_opencart/models/opencart/backend_importer.py new file mode 100644 index 00000000..9cf1b2e4 --- /dev/null +++ b/connector_opencart/models/opencart/backend_importer.py @@ -0,0 +1,19 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class MetadataBatchImporter(Component): + """ Import the records directly, without delaying the jobs. + + Import the Opencart Stores + + They are imported directly because this is a rare and fast operation, + and we don't really bother if it blocks the UI during this time. + + """ + + _name = 'opencart.metadata.batch.importer' + _inherit = 'opencart.direct.batch.importer' + _apply_on = ['opencart.store'] diff --git a/connector_opencart/models/opencart/binding.py b/connector_opencart/models/opencart/binding.py new file mode 100644 index 00000000..4273bfec --- /dev/null +++ b/connector_opencart/models/opencart/binding.py @@ -0,0 +1,48 @@ +# © 2019 Hibou Corp. +# 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 OpencartBinding(models.AbstractModel): + """ Abstract Model for the Bindings. + + All of the models used as bindings between Opencart and Odoo + (``opencart.sale.order``) should ``_inherit`` from it. + """ + _name = 'opencart.binding' + _inherit = 'external.binding' + _description = 'Opencart Binding (abstract)' + + backend_id = fields.Many2one( + comodel_name='opencart.backend', + string='Opencart Backend', + required=True, + ondelete='restrict', + ) + external_id = fields.Char(string='ID in Opencart') + + _sql_constraints = [ + ('opencart_uniq', 'unique(backend_id, external_id)', 'A binding already exists for this Opencart ID.'), + ] + + @job(default_channel='root.opencart') + @related_action(action='related_action_opencart_link') + @api.model + def import_batch(self, backend, filters=None): + """ Prepare the import of records modified on Opencart """ + 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.opencart') + @related_action(action='related_action_opencart_link') + @api.model + def import_record(self, backend, external_id, force=False): + """ Import a Opencart record """ + with backend.work_on(self._name) as work: + importer = work.component(usage='record.importer') + return importer.run(external_id, force=force) diff --git a/connector_opencart/models/opencart/store.py b/connector_opencart/models/opencart/store.py new file mode 100644 index 00000000..33b97625 --- /dev/null +++ b/connector_opencart/models/opencart/store.py @@ -0,0 +1,81 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + + +class OpencartStore(models.Model): + _name = 'opencart.store' + _inherit = ['opencart.binding'] + _description = 'Opencart Store' + _parent_name = 'backend_id' + + name = fields.Char() + backend_id = fields.Many2one('opencart.backend', + string='Opencart Backend', + ondelete='cascade', + readonly=True) + + warehouse_id = fields.Many2one( + comodel_name='stock.warehouse', + string='Warehouse', + help='Warehouse to use for stock. (overridden from backend)', + ) + company_id = fields.Many2one( + comodel_name='res.company', + related='warehouse_id.company_id', + string='Company', + readonly=True, + ) + fiscal_position_id = fields.Many2one( + comodel_name='account.fiscal.position', + string='Fiscal Position', + help='Fiscal position to use on orders. (overridden from backend)', + ) + analytic_account_id = fields.Many2one( + comodel_name='account.analytic.account', + string='Analytic account', + help='If specified, this analytic account will be used to fill the ' + 'field on the sale order created by the connector. (overridden from backend)' + ) + team_id = fields.Many2one(comodel_name='crm.team', string='Sales Team', + help='(overridden from backend)') + sale_prefix = fields.Char( + string='Sale Prefix', + help="A prefix put before the name of imported sales orders.\n" + "For instance, if the prefix is 'OC-', the sales " + "order 36071 in Opencart, will be named 'OC-36071' " + "in Odoo. (overridden from backend)", + ) + coupon_product_id = fields.Many2one(comodel_name='product.product', string='Coupon Product', + help='Product to represent coupon discounts.') + enable_order_import = fields.Boolean(string='Enable Sale Order Import', default=True, + help='If not enabled, then stores will be skipped during order imiport.') + + +class OpencartStoreAdapter(Component): + _name = 'opencart.store.adapter' + _inherit = 'opencart.adapter' + _apply_on = 'opencart.store' + + def search(self, filters=None): + api_instance = self.api_instance + stores_response = api_instance.stores.all() + if 'error' in stores_response and stores_response['error']: + raise ValidationError(str(stores_response)) + + if 'data' not in stores_response or not isinstance(stores_response['data'], list): + return [] + + stores = stores_response['data'] + return list(map(lambda s: s['store_id'], stores)) + + def read(self, id): + api_instance = self.api_instance + record = api_instance.stores.get(id) + if 'data' in record and record['data']: + return record['data'] + raise RetryableJobError('Store "' + str(id) + '" did not return an store response. ' + str(record)) diff --git a/connector_opencart/models/opencart/store_importer.py b/connector_opencart/models/opencart/store_importer.py new file mode 100644 index 00000000..79fcf834 --- /dev/null +++ b/connector_opencart/models/opencart/store_importer.py @@ -0,0 +1,27 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping + + +class OpencartStoreImportMapper(Component): + _name = 'opencart.store.mapper' + _inherit = 'opencart.import.mapper' + _apply_on = 'opencart.store' + + direct = [ + ('config_name', 'name'), + ] + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + +class OpencartStoreImporter(Component): + """ Import one Opencart Store """ + + _name = 'opencart.store.importer' + _inherit = 'opencart.importer' + _apply_on = 'opencart.store' diff --git a/connector_opencart/models/product/__init__.py b/connector_opencart/models/product/__init__.py new file mode 100644 index 00000000..79ab5dc6 --- /dev/null +++ b/connector_opencart/models/product/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import importer diff --git a/connector_opencart/models/product/common.py b/connector_opencart/models/product/common.py new file mode 100644 index 00000000..7b66edb5 --- /dev/null +++ b/connector_opencart/models/product/common.py @@ -0,0 +1,88 @@ +from odoo import api, fields, models +from odoo.addons.queue_job.exception import NothingToDoJob, RetryableJobError +from odoo.addons.component.core import Component + + +class OpencartProductTemplate(models.Model): + _name = 'opencart.product.template' + _inherit = 'opencart.binding' + _inherits = {'product.template': 'odoo_id'} + _description = 'Opencart Product' + + odoo_id = fields.Many2one('product.template', + string='Product', + required=True, + ondelete='cascade') # cascade so that you can delete an Odoo product that was created by connector + opencart_attribute_value_ids = fields.One2many('opencart.product.template.attribute.value', + 'opencart_product_tmpl_id', + string='Opencart Product Attribute Values') + + def opencart_sale_get_combination(self, options, reentry=False): + if not options: + return self.odoo_id.product_variant_id + selected_attribute_values = self.env['product.template.attribute.value'] + for option in options: + product_option_value_id = str(option['product_option_value_id']) + opencart_attribute_value = self.opencart_attribute_value_ids.filtered(lambda v: v.external_id == product_option_value_id) + if not opencart_attribute_value: + if reentry: + # we have already triggered an import. + raise Exception('Order Product has option (%s) "%s" that does not exist on the product.' % (product_option_value_id, option.get('name', ''))) + # need to re-import product. + try: + self.import_record(self.backend_id, self.external_id, force=True) + return self.opencart_sale_get_combination(options, reentry=True) + except NothingToDoJob: + if reentry: + raise RetryableJobError('Product imported, but selected option is not available.') + if not opencart_attribute_value.odoo_id: + raise RetryableJobError('Order Product (%s) has option (%s) "%s" that is not mapped to an Odoo Attribute Value.' % (self, opencart_attribute_value.external_id, opencart_attribute_value.opencart_name)) + selected_attribute_values += opencart_attribute_value.odoo_id + # Now that we know what options are selected, we can load a variant with those options + product = self.odoo_id._create_product_variant(selected_attribute_values, log_warning=True) + if not product: + raise Exception('No product can be created for selected attribute values, check logs. ' + str(selected_attribute_values)) + return product + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + opencart_sku = fields.Char('Opencart SKU') + opencart_bind_ids = fields.One2many('opencart.product.template', 'odoo_id', string='Opencart Bindings') + + +class OpencartProductTemplateAdapter(Component): + _name = 'opencart.product.template.adapter' + _inherit = 'opencart.adapter' + _apply_on = 'opencart.product.template' + + def read(self, id): + api_instance = self.api_instance + record = api_instance.products.get(id) + if 'data' in record and record['data']: + return record['data'] + raise RetryableJobError('Product "' + str(id) + '" did not return an product response. ' + str(record)) + + +# Product Attribute Value, cannot "inherits" the odoo_id as then it cannot be empty +class OpencartProductTemplateAttributeValue(models.Model): + _name = 'opencart.product.template.attribute.value' + _inherit = 'opencart.binding' + _description = 'Opencart Product Attribute Value' + + odoo_id = fields.Many2one('product.template.attribute.value', + string='Product Attribute Value', + required=False, + ondelete='cascade') + opencart_name = fields.Char(string='Opencart Name', help='For matching purposes.') + opencart_product_tmpl_id = fields.Many2one('opencart.product.template', + string='Opencart Product', + required=True, + ondelete='cascade') + product_tmpl_id = fields.Many2one(related='opencart_product_tmpl_id.odoo_id') + + # The regular constraint won't work here because multiple templates can/will have the same attribute id in opencart + _sql_constraints = [ + ('opencart_uniq', 'unique(backend_id, external_id, opencart_product_tmpl_id)', 'A binding already exists for this Opencart ID+Product Template.'), + ] diff --git a/connector_opencart/models/product/importer.py b/connector_opencart/models/product/importer.py new file mode 100644 index 00000000..6618cf11 --- /dev/null +++ b/connector_opencart/models/product/importer.py @@ -0,0 +1,95 @@ +from html import unescape +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping, only_create + + +class ProductImportMapper(Component): + _name = 'opencart.product.template.import.mapper' + _inherit = 'opencart.import.mapper' + _apply_on = ['opencart.product.template'] + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + @mapping + def name(self, record): + name = record.get('product_description', [{}])[0].get('name', record.get('id')) + return {'name': unescape(name)} + + @only_create + @mapping + def product_type(self, record): + # why this check if @only_create? + # well because we would turn the binding create into a very real product.template.write + existing_product = self.existing_product(record) + if existing_product and existing_product.get('odoo_id'): + return {'type': self.env['product.template'].browse(existing_product['odoo_id']).type} + return {'type': 'product' if record.get('shipping') else 'service'} + + @mapping + def opencart_sku(self, record): + sku = str(record.get('model') or record.get('sku') or '').strip() + return {'opencart_sku': sku} + + @only_create + @mapping + def existing_product(self, record): + product_template = self.env['product.template'] + template = product_template.browse() + + if record.get('model'): + model = str(record.get('model') or '').strip() + # Try to match our own field + template = product_template.search([('opencart_sku', '=', model)], limit=1) + if not template: + # Try to match the default_code + template = product_template.search([('default_code', '=', model)], limit=1) + if not template and record.get('sku'): + sku = str(record.get('sku') or '').strip() + template = product_template.search([('opencart_sku', '=', sku)], limit=1) + if not template: + template = product_template.search([('default_code', '=', sku)], limit=1) + if not template and record.get('name'): + name = record.get('product_description', [{}])[0].get('name') + if name: + template = product_template.search([('name', '=', unescape(name))], limit=1) + return {'odoo_id': template.id} + + +class ProductImporter(Component): + _name = 'opencart.product.template.importer' + _inherit = 'opencart.importer' + _apply_on = ['opencart.product.template'] + + def _create(self, data): + binding = super(ProductImporter, self)._create(data) + self.backend_record.add_checkpoint(binding) + return binding + + def _after_import(self, binding): + self._sync_options(binding) + + def _sync_options(self, binding): + existing_option_values = binding.opencart_attribute_value_ids + mapped_option_values = binding.opencart_attribute_value_ids.browse() + record = self.opencart_record + backend = self.backend_record + for option in record.get('options', []): + for record_option_value in option.get('option_value', []): + option_value = existing_option_values.filtered(lambda v: v.external_id == str(record_option_value['product_option_value_id'])) + name = unescape(record_option_value.get('name', '')) + if not option_value: + option_value = existing_option_values.create({ + 'backend_id': backend.id, + 'external_id': record_option_value['product_option_value_id'], + 'opencart_name': name, + 'opencart_product_tmpl_id': binding.id, + }) + # Keep options consistent with Opencart by renaming them + if option_value.opencart_name != name: + option_value.opencart_name = name + mapped_option_values += option_value + + to_unlink = existing_option_values - mapped_option_values + to_unlink.unlink() diff --git a/connector_opencart/models/sale_order/__init__.py b/connector_opencart/models/sale_order/__init__.py new file mode 100644 index 00000000..79ab5dc6 --- /dev/null +++ b/connector_opencart/models/sale_order/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import importer diff --git a/connector_opencart/models/sale_order/common.py b/connector_opencart/models/sale_order/common.py new file mode 100644 index 00000000..3e91b8f6 --- /dev/null +++ b/connector_opencart/models/sale_order/common.py @@ -0,0 +1,120 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import odoo.addons.decimal_precision as dp + +from odoo import models, fields, api +from odoo.exceptions import ValidationError +from odoo.addons.queue_job.job import job +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + + +class OpencartSaleOrder(models.Model): + _name = 'opencart.sale.order' + _inherit = 'opencart.binding' + _description = 'Opencart Sale Order' + _inherits = {'sale.order': 'odoo_id'} + + odoo_id = fields.Many2one(comodel_name='sale.order', + string='Sale Order', + required=True, + ondelete='cascade') + opencart_order_line_ids = fields.One2many( + comodel_name='opencart.sale.order.line', + inverse_name='opencart_order_id', + string='Walmart Order Lines' + ) + store_id = fields.Many2one('opencart.store', string='Store') + + total_amount = fields.Float( + string='Total amount', + digits=dp.get_precision('Account') + ) + + @job(default_channel='root.opencart') + @api.model + def import_batch(self, backend, filters=None): + """ Prepare the import of Sales Orders from Opencart """ + return super(OpencartSaleOrder, self).import_batch(backend, filters=filters) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + opencart_bind_ids = fields.One2many( + comodel_name='opencart.sale.order', + inverse_name='odoo_id', + string="Opencart Bindings", + ) + + +class OpencartSaleOrderLine(models.Model): + _name = 'opencart.sale.order.line' + _inherit = 'opencart.binding' + _description = 'Opencart Sale Order Line' + _inherits = {'sale.order.line': 'odoo_id'} + + opencart_order_id = fields.Many2one(comodel_name='opencart.sale.order', + string='Opencart 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='opencart_order_id.backend_id', + string='Opencart Backend', + readonly=True, + store=True, + required=False) + + @api.model + def create(self, vals): + opencart_order_id = vals['opencart_order_id'] + binding = self.env['opencart.sale.order'].browse(opencart_order_id) + vals['order_id'] = binding.odoo_id.id + binding = super(OpencartSaleOrderLine, self).create(vals) + return binding + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + opencart_bind_ids = fields.One2many( + comodel_name='opencart.sale.order.line', + inverse_name='odoo_id', + string="Opencart Bindings", + ) + + +class SaleOrderAdapter(Component): + _name = 'opencart.sale.order.adapter' + _inherit = 'opencart.adapter' + _apply_on = 'opencart.sale.order' + + def search(self, filters=None): + api_instance = self.api_instance + api_filters = {} + if 'after_id' in filters: + api_filters['id_larger_than'] = filters['after_id'] + if 'modified_from' in filters: + api_filters['modified_from'] = filters['modified_from'] + orders_response = api_instance.orders.all(**api_filters) + if 'error' in orders_response and orders_response['error']: + raise ValidationError(str(orders_response)) + + if 'data' not in orders_response or not isinstance(orders_response['data'], list): + return [] + + orders = orders_response['data'] + # Note that `store_id is None` is checked as it may not be in the output. + return map(lambda o: (o['order_id'], o.get('store_id', None), o.get('date_modified') or o.get('date_added')), orders) + + def read(self, id): + api_instance = self.api_instance + record = api_instance.orders.get(id) + if 'data' in record and record['data']: + return record['data'] + raise RetryableJobError('Order "' + str(id) + '" did not return an order response. ' + str(record)) diff --git a/connector_opencart/models/sale_order/importer.py b/connector_opencart/models/sale_order/importer.py new file mode 100644 index 00000000..a4e834ec --- /dev/null +++ b/connector_opencart/models/sale_order/importer.py @@ -0,0 +1,450 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from copy import copy +from html import unescape +import logging + +from odoo import fields, _ +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping +from odoo.exceptions import ValidationError +from odoo.addons.queue_job.exception import RetryableJobError + + +_logger = logging.getLogger(__name__) + + +class SaleOrderBatchImporter(Component): + _name = 'opencart.sale.order.batch.importer' + _inherit = 'opencart.delayed.batch.importer' + _apply_on = 'opencart.sale.order' + + def _import_record(self, external_id, store_id, job_options=None, **kwargs): + if not job_options: + job_options = { + 'max_retries': 0, + 'priority': 5, + } + # It is very likely that we already have this order because we may have just uploaded a tracking number + # We want to avoid creating queue jobs for orders already imported. + order_binder = self.binder_for('opencart.sale.order') + order = order_binder.to_internal(external_id) + if order: + _logger.warning('Order (%s) already imported.' % (order.name, )) + return + if store_id is not None: + store_binder = self.binder_for('opencart.store') + store = store_binder.to_internal(store_id).sudo() + if not store.enable_order_import: + _logger.warning('Store (%s) is not enabled for Sale Order import (%s).' % (store.name, external_id)) + return + user = store.warehouse_id.company_id.user_tech_id + if user and user != self.env.user: + # Note that this is a component, which has an env through it's 'collection' + # however, when importing the 'model' is actually what runs the delayed job + env = self.env(user=user) + self.collection.env = env + self.model.env = env + return super(SaleOrderBatchImporter, self)._import_record( + external_id, job_options=job_options) + + def run(self, filters=None): + """ Run the synchronization """ + if filters is None: + filters = {} + external_ids = list(self.backend_adapter.search(filters)) + for ids in external_ids: + _logger.debug('run._import_record for %s' % (ids, )) + self._import_record(ids[0], ids[1]) + if external_ids: + last_id = list(sorted(external_ids, key=lambda i: i[0]))[-1][0] + last_date = list(sorted(external_ids, key=lambda i: i[2]))[-1][2] + self.backend_record.write({ + 'import_orders_after_id': last_id, + 'import_orders_after_date': self.backend_record.date_to_odoo(last_date), + }) + + +class SaleOrderImportMapper(Component): + _name = 'opencart.sale.order.mapper' + _inherit = 'opencart.import.mapper' + _apply_on = 'opencart.sale.order' + + direct = [('order_id', 'external_id'), + ('store_id', 'store_id'), + ('comment', 'note'), + ] + + children = [('products', 'opencart_order_line_ids', 'opencart.sale.order.line'), + ] + + def _add_coupon_lines(self, map_record, values): + # Data from API + # 'coupons': [{'amount': '7.68', 'code': '1111'}], + record = map_record.source + + coupons = record.get('coupons') + if not coupons: + return values + + coupon_product = self.options.store.coupon_product_id or self.backend_record.coupon_product_id + if not coupon_product: + coupon_product = self.env.ref('connector_ecommerce.product_product_discount', raise_if_not_found=False) + + if not coupon_product: + raise ValueError('Coupon %s on order requires coupon product in configuration.' % (coupons, )) + for coupon in coupons: + line_builder = self.component(usage='order.line.builder') + line_builder.price_unit = -float(coupon.get('amount', 0.0)) + line_builder.product = coupon_product + # `order.line.builder` does not allow naming. + line_values = line_builder.get_line() + code = coupon.get('code') + if code: + line_values['name'] = '%s Code: %s' % (coupon_product.name, code) + values['order_line'].append((0, 0, line_values)) + return values + + def _add_shipping_line(self, map_record, values): + record = map_record.source + + line_builder = self.component(usage='order.line.builder.shipping') + line_builder.price_unit = record.get('shipping_exclude_tax', 0.0) + + if values.get('carrier_id'): + carrier = self.env['delivery.carrier'].browse(values['carrier_id']) + line_builder.product = carrier.product_id + line = (0, 0, line_builder.get_line()) + values['order_line'].append(line) + + return values + + def finalize(self, map_record, values): + values.setdefault('order_line', []) + self._add_coupon_lines(map_record, values) + self._add_shipping_line(map_record, values) + values.update({ + 'partner_id': self.options.partner_id, + 'partner_invoice_id': self.options.partner_invoice_id, + 'partner_shipping_id': self.options.partner_shipping_id, + }) + onchange = self.component( + usage='ecommerce.onchange.manager.sale.order' + ) + # will I need more?! + return onchange.play(values, values['opencart_order_line_ids']) + + @mapping + def name(self, record): + name = str(record['order_id']) + prefix = self.options.store.sale_prefix or self.backend_record.sale_prefix + if prefix: + name = prefix + name + return {'name': name} + + @mapping + def date_order(self, record): + date_added = record.get('date_added') + if date_added: + date_added = self.backend_record.date_to_odoo(date_added) + return {'date_order': date_added or fields.Datetime.now()} + + @mapping + def fiscal_position_id(self, record): + fiscal_position = self.options.store.fiscal_position_id or self.backend_record.fiscal_position_id + if fiscal_position: + return {'fiscal_position_id': fiscal_position.id} + + @mapping + def team_id(self, record): + team = self.options.store.team_id or self.backend_record.team_id + if team: + return {'team_id': team.id} + + @mapping + def payment_mode_id(self, record): + record_method = record['payment_method'] + method = self.env['account.payment.mode'].search( + [('name', '=', record_method)], + limit=1, + ) + if not method: + raise ValueError('Payment Mode named "%s", cannot be found.' % (record_method, )) + return {'payment_mode_id': method.id} + + @mapping + def project_id(self, record): + analytic_account = self.options.store.analytic_account_id or self.backend_record.analytic_account_id + if analytic_account: + return {'project_id': analytic_account.id} + + @mapping + def warehouse_id(self, record): + warehouse = self.options.store.warehouse_id or self.backend_record.warehouse_id + if warehouse: + return {'warehouse_id': warehouse.id} + + @mapping + def shipping_code(self, record): + method = record.get('shipping_code') or record.get('shipping_method') + if not method: + return {'carrier_id': False} + + carrier_domain = [('opencart_code', '=', method.strip())] + company = self.options.store.company_id or self.backend_record.company_id + if company: + carrier_domain += [ + '|', ('company_id', '=', company.id), ('company_id', '=', False) + ] + carrier = self.env['delivery.carrier'].search(carrier_domain, limit=1) + if not carrier: + raise ValueError('Delivery Carrier for method Code "%s", cannot be found.' % (method, )) + return {'carrier_id': carrier.id} + + @mapping + def company_id(self, record): + company = self.options.store.company_id or self.backend_record.company_id + if not company: + raise ValidationError('Company not found in Opencart Backend or Store') + return {'company_id': company.id} + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + @mapping + def total_amount(self, record): + total_amount = record['total'] + return {'total_amount': total_amount} + + +class SaleOrderImporter(Component): + _name = 'opencart.sale.order.importer' + _inherit = 'opencart.importer' + _apply_on = 'opencart.sale.order' + + def _must_skip(self): + if self.binder.to_internal(self.external_id): + return _('Already imported') + + def _before_import(self): + # Check if status is ok, etc. on self.opencart_record + pass + + def _create_partner(self, values): + return self.env['res.partner'].create(values) + + def _partner_matches(self, partner, values): + for key, value in values.items(): + if key in ('active', 'parent_id', 'type'): + continue + + if key == 'state_id': + if value != partner.state_id.id: + return False + elif key == 'country_id': + if value != partner.country_id.id: + return False + elif bool(value) and isinstance(value, str): + if value.lower() != str(getattr(partner, key)).lower(): + return False + elif bool(value) and value != getattr(partner, key): + return False + return True + + def _make_partner_name(self, firstname, lastname): + name = (str(firstname or '').strip() + ' ' + str(lastname or '').strip()).strip() + if not name: + return 'Undefined' + return name + + def _get_partner_values(self, info_string='shipping_'): + record = self.opencart_record + + # find or make partner with these details. + email = record.get('email') + if not email: + raise ValueError('Order does not have email in : ' + str(record)) + + phone = record.get('telephone', False) + + info = {} + for k, v in record.items(): + # Strip the info_string so that the remainder of the code depends on it. + if k.find(info_string) == 0: + info[k[len(info_string):]] = v + + + name = self._make_partner_name(info.get('firstname', ''), info.get('lastname', '')) + street = info.get('address_1', '') + street2 = info.get('address_2', '') + city = info.get('city', '') + state_code = info.get('zone_code', '') + zip_ = info.get('postcode', '') + country_code = info.get('iso_code_2', '') + country = self.env['res.country'].search([('code', '=', country_code)], limit=1) + state = self.env['res.country.state'].search([ + ('country_id', '=', country.id), + ('code', '=', state_code) + ], limit=1) + + return { + 'email': email.strip(), + 'name': name.strip(), + 'phone': phone.strip(), + 'street': street.strip(), + 'street2': street2.strip(), + 'zip': zip_.strip(), + 'city': city.strip(), + 'state_id': state.id, + 'country_id': country.id, + } + + def _import_addresses(self): + partner_values = self._get_partner_values() + # If they only buy services, then the shipping details will be empty + if partner_values.get('name', 'Undefined') == 'Undefined': + partner_values = self._get_partner_values(info_string='payment_') + + partners = self.env['res.partner'].search([ + ('email', '=ilike', partner_values['email']), + '|', ('active', '=', False), ('active', '=', True), + ], order='active DESC, id ASC') + + partner = None + for possible in partners: + if self._partner_matches(possible, partner_values): + partner = possible + break + if not partner and partners: + partner = partners[0] + + if not partner: + # create partner. + partner = self._create_partner(copy(partner_values)) + + if not self._partner_matches(partner, partner_values): + partner_values['parent_id'] = partner.id + shipping_values = copy(partner_values) + shipping_values['type'] = 'delivery' + shipping_partner = self._create_partner(shipping_values) + else: + shipping_partner = partner + + invoice_values = self._get_partner_values(info_string='payment_') + invoice_values['type'] = 'invoice' + + if (not self._partner_matches(partner, invoice_values) + and not self._partner_matches(shipping_partner, invoice_values)): + # Try to find existing invoice address.... + for possible in partners: + if self._partner_matches(possible, invoice_values): + invoice_partner = possible + break + else: + invoice_values['parent_id'] = partner.id + invoice_partner = self._create_partner(copy(invoice_values)) + elif self._partner_matches(partner, invoice_values): + invoice_partner = partner + elif self._partner_matches(shipping_partner, invoice_values): + invoice_partner = shipping_partner + + self.partner = partner + self.shipping_partner = shipping_partner + self.invoice_partner = invoice_partner + + def _check_special_fields(self): + assert self.partner, ( + "self.partner should have been defined " + "in SaleOrderImporter._import_addresses") + assert self.shipping_partner, ( + "self.shipping_partner should have been defined " + "in SaleOrderImporter._import_addresses") + assert self.invoice_partner, ( + "self.invoice_partner should have been defined " + "in SaleOrderImporter._import_addresses") + + def _get_store(self, record): + store_binder = self.binder_for('opencart.store') + return store_binder.to_internal(record['store_id']) + + def _create_data(self, map_record, **kwargs): + # non dependencies + # our current handling of partners doesn't require anything special for the store + self._check_special_fields() + store = self._get_store(map_record.source) + return super(SaleOrderImporter, self)._create_data( + map_record, + partner_id=self.partner.id, + partner_invoice_id=self.invoice_partner.id, + partner_shipping_id=self.shipping_partner.id, + store=store, + **kwargs + ) + + def _order_comment_review(self, binding): + review_group = self.env.ref('connector_opencart.group_order_comment_review', raise_if_not_found=False) + if review_group and binding.note: + activity_type = self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False) + activity_type_id = activity_type.id if activity_type else False + for user in review_group.users: + self.env['mail.activity'].create({ + 'activity_type_id': activity_type_id, + 'summary': 'Order Comment Review', + 'note': '

' + binding.note + '

', # field is HTML, note is expected to be escaped + 'user_id': user.id, + 'res_id': binding.odoo_id.id, + 'res_model_id': self.env.ref('sale.model_sale_order').id, + }) + + def _create(self, data): + binding = super(SaleOrderImporter, self)._create(data) + # Without this, it won't map taxes with the fiscal position. + if binding.fiscal_position_id: + binding.odoo_id._compute_tax_id() + + self._order_comment_review(binding) + + return binding + + def _import_dependencies(self): + record = self.opencart_record + self._import_addresses() + products_need_setup = [] + for product in record.get('products', []): + if 'product_id' in product and product['product_id']: + needs_product_setup = self._import_dependency(product['product_id'], 'opencart.product.template') + if needs_product_setup: + products_need_setup.append(product['product_id']) + + if products_need_setup and self.backend_record.so_require_product_setup: + # There are products that were either just imported, or + raise RetryableJobError('Products need setup. OpenCart Product IDs:' + str(products_need_setup), seconds=3600) + + +class SaleOrderLineImportMapper(Component): + + _name = 'opencart.sale.order.line.mapper' + _inherit = 'opencart.import.mapper' + _apply_on = 'opencart.sale.order.line' + + direct = [('quantity', 'product_uom_qty'), + ('price', 'price_unit'), + ('order_product_id', 'external_id'), + ] + + @mapping + def name(self, record): + return {'name': unescape(record['name'])} + + @mapping + def product_id(self, record): + product_id = record['product_id'] + binder = self.binder_for('opencart.product.template') + # do not unwrap, because it would be a product.template, but I need a specific variant + # connector bindings are found with `active_test=False` but that also means computed fields + # like `product.template.product_variant_id` could find different products because of archived variants + opencart_product_template = binder.to_internal(product_id, unwrap=False).with_context(active_test=True) + product = opencart_product_template.opencart_sale_get_combination(record.get('option')) + return {'product_id': product.id, 'product_uom': product.uom_id.id} diff --git a/connector_opencart/models/stock_picking/__init__.py b/connector_opencart/models/stock_picking/__init__.py new file mode 100644 index 00000000..2db3f18c --- /dev/null +++ b/connector_opencart/models/stock_picking/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import exporter diff --git a/connector_opencart/models/stock_picking/common.py b/connector_opencart/models/stock_picking/common.py new file mode 100644 index 00000000..71dd204c --- /dev/null +++ b/connector_opencart/models/stock_picking/common.py @@ -0,0 +1,93 @@ +# © 2019 Hibou Corp. +# 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 +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + + +class OpencartStockPicking(models.Model): + _name = 'opencart.stock.picking' + _inherit = 'opencart.binding' + _inherits = {'stock.picking': 'odoo_id'} + _description = 'Opencart Delivery Order' + + odoo_id = fields.Many2one(comodel_name='stock.picking', + string='Stock Picking', + required=True, + ondelete='cascade') + opencart_order_id = fields.Many2one(comodel_name='opencart.sale.order', + string='Opencart Sale Order', + ondelete='set null') + + @job(default_channel='root.opencart') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_picking_done(self): + """ Export a complete or partial delivery order. """ + self.ensure_one() + with self.backend_id.work_on(self._name) as work: + exporter = work.component(usage='record.exporter') + return exporter.run(self) + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + opencart_bind_ids = fields.One2many( + comodel_name='opencart.stock.picking', + inverse_name='odoo_id', + string="Opencart Bindings", + ) + + +class StockPickingAdapter(Component): + _name = 'opencart.stock.picking.adapter' + _inherit = 'opencart.adapter' + _apply_on = 'opencart.stock.picking' + + def create(self, id, tracking): + api_instance = self.api_instance + tracking_comment = _('Order shipped with tracking number: %s') % (tracking, ) + result = api_instance.orders.ship(id, tracking, tracking_comment) + if 'success' in result: + return result['success'] + raise RetryableJobError('Shipping Order %s did not return an order response. (tracking: %s) %s' % ( + str(id), str(tracking), str(result))) + + +class OpencartBindingStockPickingListener(Component): + _name = 'opencart.binding.stock.picking.listener' + _inherit = 'base.event.listener' + _apply_on = ['opencart.stock.picking'] + + def on_record_create(self, record, fields=None): + record.with_delay().export_picking_done() + + +class OpencartStockPickingListener(Component): + _name = 'opencart.stock.picking.listener' + _inherit = 'base.event.listener' + _apply_on = ['stock.picking'] + + def on_picking_dropship_done(self, record, picking_method): + return self.on_picking_out_done(record, picking_method) + + def on_picking_out_done(self, record, picking_method): + """ + Create a ``opencart.stock.picking`` record. This record will then + be exported to Opencart. + + :param picking_method: picking_method, can be 'complete' or 'partial' + :type picking_method: str + """ + sale = record.sale_id + if not sale: + return + for opencart_sale in sale.opencart_bind_ids: + self.env['opencart.stock.picking'].create({ + 'backend_id': opencart_sale.backend_id.id, + 'odoo_id': record.id, + 'opencart_order_id': opencart_sale.id, + }) diff --git a/connector_opencart/models/stock_picking/exporter.py b/connector_opencart/models/stock_picking/exporter.py new file mode 100644 index 00000000..3951fad0 --- /dev/null +++ b/connector_opencart/models/stock_picking/exporter.py @@ -0,0 +1,36 @@ +# © 2019 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import NothingToDoJob + + +class OpencartPickingExporter(Component): + _name = 'opencart.stock.picking.exporter' + _inherit = 'opencart.exporter' + _apply_on = ['opencart.stock.picking'] + + def _get_id(self, binding): + sale_binder = self.binder_for('opencart.sale.order') + opencart_sale_id = sale_binder.to_external(binding.opencart_order_id) + return opencart_sale_id + + def _get_tracking(self, binding): + return binding.carrier_tracking_ref or '' + + def run(self, binding): + """ + Export the picking to Opencart + :param binding: opencart.stock.picking + :return: + """ + if binding.external_id: + return 'Already exported' + + tracking = self._get_tracking(binding) + if not tracking: + raise NothingToDoJob('Cancelled: the delivery order does not contain tracking.') + id = self._get_id(binding) + _ = self.backend_adapter.create(id, tracking) + # Cannot bind because shipments do not have ID's in Opencart + #self.binder.bind(external_id, binding) diff --git a/connector_opencart/security/ir.model.access.csv b/connector_opencart/security/ir.model.access.csv new file mode 100644 index 00000000..ccbfcb99 --- /dev/null +++ b/connector_opencart/security/ir.model.access.csv @@ -0,0 +1,15 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_opencart_backend","opencart_backend connector manager","model_opencart_backend","connector.group_connector_manager",1,1,1,1 +"access_opencart_store","opencart_store connector manager","model_opencart_store","connector.group_connector_manager",1,1,1,1 +"access_opencart_binding","opencart_binding connector manager","model_opencart_binding","connector.group_connector_manager",1,1,1,1 +"access_opencart_sale_order","opencart_sale_order connector manager","model_opencart_sale_order","connector.group_connector_manager",1,1,1,1 +"access_opencart_sale_order_line","opencart_sale_order_line connector manager","model_opencart_sale_order_line","connector.group_connector_manager",1,1,1,1 +"access_opencart_product_template","opencart_product_template connector manager","model_opencart_product_template","connector.group_connector_manager",1,1,1,1 +"access_opencart_product_template_attribute_value","opencart_product_template_attribute_value connector manager","model_opencart_product_template_attribute_value","connector.group_connector_manager",1,1,1,1 +"access_opencart_stock_picking","opencart_stock_picking connector manager","model_opencart_stock_picking","connector.group_connector_manager",1,1,1,1 +"access_opencart_sale_order_sale_salesman","opencart_sale_order","model_opencart_sale_order","sales_team.group_sale_salesman",1,0,0,0 +"access_opencart_sale_order_sale_manager","opencart_sale_order","model_opencart_sale_order","sales_team.group_sale_manager",1,1,1,1 +"access_opencart_sale_order_stock_user","opencart_sale_order warehouse user","model_opencart_sale_order","stock.group_stock_user",1,0,0,0 +"access_opencart_backend_user","opencart_backend user","model_opencart_backend","sales_team.group_sale_salesman",1,0,0,0 +"access_opencart_stock_picking_user","opencart_stock_picking user","model_opencart_stock_picking","stock.group_stock_user",1,1,1,0 +"access_opencart_product_template_user","opencart_product_template user","model_opencart_product_template","base.group_user",1,0,0,0 diff --git a/connector_opencart/views/delivery_views.xml b/connector_opencart/views/delivery_views.xml new file mode 100644 index 00000000..40d5bd26 --- /dev/null +++ b/connector_opencart/views/delivery_views.xml @@ -0,0 +1,19 @@ + + + + + opencart.delivery.carrier.form + delivery.carrier + + + + + + + + + + + + + diff --git a/connector_opencart/views/opencart_backend_views.xml b/connector_opencart/views/opencart_backend_views.xml new file mode 100644 index 00000000..be7adaad --- /dev/null +++ b/connector_opencart/views/opencart_backend_views.xml @@ -0,0 +1,176 @@ + + + + + opencart.backend.form + opencart.backend + +
+
+
+ +