mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
Merge branch 'mig/15.0/connector_opencart' into '15.0'
mig/15.0/connector_opencart into 15.0 See merge request hibou-io/hibou-odoo/suite!1229
This commit is contained in:
2
connector_opencart/__init__.py
Normal file
2
connector_opencart/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import components
|
||||
from . import models
|
||||
28
connector_opencart/__manifest__.py
Normal file
28
connector_opencart/__manifest__.py
Normal file
@@ -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,
|
||||
}
|
||||
6
connector_opencart/components/__init__.py
Normal file
6
connector_opencart/components/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from . import api
|
||||
from . import backend_adapter
|
||||
from . import binder
|
||||
from . import importer
|
||||
from . import exporter
|
||||
from . import mapper
|
||||
1
connector_opencart/components/api/__init__.py
Normal file
1
connector_opencart/components/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import opencart
|
||||
170
connector_opencart/components/api/opencart.py
Normal file
170
connector_opencart/components/api/opencart.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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)
|
||||
66
connector_opencart/components/backend_adapter.py
Normal file
66
connector_opencart/components/backend_adapter.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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
|
||||
24
connector_opencart/components/binder.py
Normal file
24
connector_opencart/components/binder.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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',
|
||||
]
|
||||
312
connector_opencart/components/exporter.py
Normal file
312
connector_opencart/components/exporter.py
Normal file
@@ -0,0 +1,312 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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
|
||||
333
connector_opencart/components/importer.py
Normal file
333
connector_opencart/components/importer.py
Normal file
@@ -0,0 +1,333 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
"""
|
||||
|
||||
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
|
||||
)
|
||||
# Or the binding may not have its external_id set if you raise an exception.
|
||||
self.env['base'].flush()
|
||||
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)
|
||||
15
connector_opencart/components/mapper.py
Normal file
15
connector_opencart/components/mapper.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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'
|
||||
47
connector_opencart/data/connector_opencart_data.xml
Normal file
47
connector_opencart/data/connector_opencart_data.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record model="ir.cron" id="ir_cron_import_sale_orders" forcecreate="True">
|
||||
<field name="name">Opencart - Import Sales Orders</field>
|
||||
<field name="active" eval="False"/>
|
||||
<field name="state">code</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field ref="connector_opencart.model_opencart_backend" name="model_id"/>
|
||||
<field name="code">model._scheduler_import_sale_orders()</field>
|
||||
</record>
|
||||
|
||||
<record id="excep_wrong_total_amount" model="exception.rule">
|
||||
<field name="name">Total Amount differs from Opencart</field>
|
||||
<field name="description">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.</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="code">failed = sale.opencart_bind_ids and abs(sale.amount_total - sale.opencart_bind_ids[0].total_amount) >= 0.01</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
|
||||
<record id="group_order_comment_review" model="res.groups">
|
||||
<field name="name">Opencart Order Comment Reviewer</field>
|
||||
</record>
|
||||
|
||||
<record id="checkpoint" model="mail.activity.type">
|
||||
<field name="name">Opencart Checkpoint</field>
|
||||
<field name="summary">Opencart checkpoint.</field>
|
||||
<!-- TODO maybe add something like this in connector -->
|
||||
<!-- <field name="category">checkpoint</field>-->
|
||||
<field name="chaining_type">trigger</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
5
connector_opencart/models/__init__.py
Normal file
5
connector_opencart/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from . import delivery
|
||||
from . import opencart
|
||||
from . import product
|
||||
from . import sale_order
|
||||
from . import stock_picking
|
||||
1
connector_opencart/models/delivery/__init__.py
Normal file
1
connector_opencart/models/delivery/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import common
|
||||
21
connector_opencart/models/delivery/common.py
Normal file
21
connector_opencart/models/delivery/common.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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,
|
||||
)
|
||||
5
connector_opencart/models/opencart/__init__.py
Normal file
5
connector_opencart/models/opencart/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from . import backend
|
||||
from . import backend_importer
|
||||
from . import binding
|
||||
from . import store
|
||||
from . import store_importer
|
||||
181
connector_opencart/models/opencart/backend.py
Normal file
181
connector_opencart/models/opencart/backend.py
Normal file
@@ -0,0 +1,181 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
|
||||
from logging import getLogger
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
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
|
||||
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
|
||||
|
||||
def add_checkpoint(self, record):
|
||||
self.ensure_one()
|
||||
record.ensure_one()
|
||||
user = self.env.user
|
||||
if 'user_id' in record and record.user_id:
|
||||
user = record.user_id
|
||||
if 'odoo_id' in record:
|
||||
return record.odoo_id.activity_schedule(
|
||||
act_type_xmlid='connector_opencart.checkpoint',
|
||||
user_id=user.id)
|
||||
return record.activity_schedule(
|
||||
act_type_xmlid='connector_opencart.checkpoint',
|
||||
user_id=user.id)
|
||||
|
||||
def find_checkpoint(self, record):
|
||||
self.ensure_one()
|
||||
record.ensure_one()
|
||||
if 'odoo_id' in record:
|
||||
return record.odoo_id.activity_search(act_type_xmlids='connector_opencart.checkpoint')
|
||||
return record.activity_search(act_type_xmlids='connector_opencart.checkpoint')
|
||||
|
||||
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()
|
||||
|
||||
def import_sale_orders(self):
|
||||
self._import_sale_orders_after_date()
|
||||
return True
|
||||
|
||||
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}
|
||||
)
|
||||
|
||||
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)
|
||||
18
connector_opencart/models/opencart/backend_importer.py
Normal file
18
connector_opencart/models/opencart/backend_importer.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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']
|
||||
42
connector_opencart/models/opencart/binding.py
Normal file
42
connector_opencart/models/opencart/binding.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
from odoo import api, models, fields
|
||||
|
||||
|
||||
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.'),
|
||||
]
|
||||
|
||||
@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)
|
||||
|
||||
@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)
|
||||
80
connector_opencart/models/opencart/store.py
Normal file
80
connector_opencart/models/opencart/store.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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))
|
||||
26
connector_opencart/models/opencart/store_importer.py
Normal file
26
connector_opencart/models/opencart/store_importer.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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'
|
||||
2
connector_opencart/models/product/__init__.py
Normal file
2
connector_opencart/models/product/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import common
|
||||
from . import importer
|
||||
88
connector_opencart/models/product/common.py
Normal file
88
connector_opencart/models/product/common.py
Normal file
@@ -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', '<Empty>')))
|
||||
# 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.'),
|
||||
]
|
||||
95
connector_opencart/models/product/importer.py
Normal file
95
connector_opencart/models/product/importer.py
Normal file
@@ -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()
|
||||
2
connector_opencart/models/sale_order/__init__.py
Normal file
2
connector_opencart/models/sale_order/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import common
|
||||
from . import importer
|
||||
117
connector_opencart/models/sale_order/common.py
Normal file
117
connector_opencart/models/sale_order/common.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
import odoo.addons.decimal_precision as dp
|
||||
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import ValidationError
|
||||
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')
|
||||
)
|
||||
|
||||
@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))
|
||||
449
connector_opencart/models/sale_order/importer.py
Normal file
449
connector_opencart/models/sale_order/importer.py
Normal file
@@ -0,0 +1,449 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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': '<p>' + binding.note + '</p>', # 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}
|
||||
2
connector_opencart/models/stock_picking/__init__.py
Normal file
2
connector_opencart/models/stock_picking/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import common
|
||||
from . import exporter
|
||||
88
connector_opencart/models/stock_picking/common.py
Normal file
88
connector_opencart/models/stock_picking/common.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
from odoo import api, models, fields, _
|
||||
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')
|
||||
|
||||
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,
|
||||
})
|
||||
35
connector_opencart/models/stock_picking/exporter.py
Normal file
35
connector_opencart/models/stock_picking/exporter.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# © 2019-2021 Hibou Corp.
|
||||
|
||||
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)
|
||||
15
connector_opencart/security/ir.model.access.csv
Normal file
15
connector_opencart/security/ir.model.access.csv
Normal file
@@ -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
|
||||
|
19
connector_opencart/views/delivery_views.xml
Normal file
19
connector_opencart/views/delivery_views.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_opencart_delivery_carrier_form" model="ir.ui.view">
|
||||
<field name="name">opencart.delivery.carrier.form</field>
|
||||
<field name="model">delivery.carrier</field>
|
||||
<field name="inherit_id" ref="delivery.view_delivery_carrier_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Opencart" name="opencart">
|
||||
<group name="opencart_info">
|
||||
<field name="opencart_code"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
174
connector_opencart/views/opencart_backend_views.xml
Normal file
174
connector_opencart/views/opencart_backend_views.xml
Normal file
@@ -0,0 +1,174 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_opencart_backend_form" model="ir.ui.view">
|
||||
<field name="name">opencart.backend.form</field>
|
||||
<field name="model">opencart.backend</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Opencart Backend">
|
||||
<header>
|
||||
<button name="synchronize_metadata"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
string="Synchronize Metadata"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1>
|
||||
<field name="name" class="oe_inline" />
|
||||
</h1>
|
||||
<group name="opencart" string="Opencart Configuration">
|
||||
<notebook name="api">
|
||||
<page string="API" name="api">
|
||||
<group colspan="4" col="4">
|
||||
<field name="base_url"/>
|
||||
<field name="restadmin_token" password="1"/>
|
||||
<field name="server_offset_hours"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</group>
|
||||
<group name="main_configuration" string="Main Configuration">
|
||||
<group name="order_configuration" string="Order Defaults">
|
||||
<field name="warehouse_id"/>
|
||||
<field name="analytic_account_id"/>
|
||||
<field name="fiscal_position_id"/>
|
||||
<field name="team_id"/>
|
||||
<field name="sale_prefix"/>
|
||||
<field name="scheduler_order_import_running" invisible="1" />
|
||||
<field name="scheduler_order_import"/>
|
||||
<p attrs="{'invisible': [('scheduler_order_import_running', '=', True)]}" class="text-danger" colspan="2">
|
||||
The automatic scheduler is not currently enabled.
|
||||
</p>
|
||||
<p attrs="{'invisible': [('scheduler_order_import_running', '=', False)]}" class="text-success" colspan="2">
|
||||
The automatic scheduler is enabled.
|
||||
</p>
|
||||
</group>
|
||||
<group name="product_configuration" string="Product Defaults">
|
||||
<field name="product_categ_id"/>
|
||||
<field name="coupon_product_id"/>
|
||||
<field name="so_require_product_setup"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook name="import_config">
|
||||
<page name="import" string="Imports">
|
||||
<p class="oe_grey oe_inline">
|
||||
By clicking on the buttons,
|
||||
you will initiate the synchronizations
|
||||
with Opencart.
|
||||
Note that the import or exports
|
||||
won't be done directly,
|
||||
they will create 'Jobs'
|
||||
executed as soon as possible.
|
||||
</p>
|
||||
<p class="oe_grey oe_inline">
|
||||
Once imported,
|
||||
some types of records,
|
||||
like the products or categories,
|
||||
need a manual review.
|
||||
You will find the list
|
||||
of the new records to review
|
||||
in the menu 'Connectors > Checkpoint'.
|
||||
</p>
|
||||
<group name="import_since">
|
||||
<field name="import_orders_after_date"/>
|
||||
<field name="import_orders_after_id"/>
|
||||
<button name="import_sale_orders"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
string="Import in background"/>
|
||||
</group>
|
||||
|
||||
</page>
|
||||
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_opencart_backend_tree" model="ir.ui.view">
|
||||
<field name="name">opencart.backend.tree</field>
|
||||
<field name="model">opencart.backend</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Opencart Backend">
|
||||
<field name="name"/>
|
||||
<field name="import_orders_after_id"/>
|
||||
<field name="import_orders_after_date"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_opencart_backend" model="ir.actions.act_window">
|
||||
<field name="name">Opencart Backends</field>
|
||||
<field name="res_model">opencart.backend</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" ref="view_opencart_backend_tree"/>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_opencart_root"
|
||||
parent="connector.menu_connector_root"
|
||||
name="Opencart"
|
||||
sequence="10"
|
||||
groups="connector.group_connector_manager"/>
|
||||
|
||||
<menuitem id="menu_opencart_backend"
|
||||
name="Backends"
|
||||
parent="menu_opencart_root"
|
||||
sequence="10"
|
||||
action="action_opencart_backend"/>
|
||||
|
||||
<!-- Store -->
|
||||
<record id="view_opencart_store_form" model="ir.ui.view">
|
||||
<field name="name">opencart.store.form</field>
|
||||
<field name="model">opencart.store</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Opencart Store">
|
||||
<header>
|
||||
</header>
|
||||
<sheet>
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1>
|
||||
<field name="name" class="oe_inline" />
|
||||
</h1>
|
||||
<group name="main_configuration" string="Override Configuration">
|
||||
<group name="order_configuration" string="Order Defaults">
|
||||
<field name="enable_order_import" />
|
||||
<field name="warehouse_id"/>
|
||||
<field name="analytic_account_id"/>
|
||||
<field name="fiscal_position_id"/>
|
||||
<field name="team_id"/>
|
||||
<field name="sale_prefix"/>
|
||||
<field name="coupon_product_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_opencart_store_tree" model="ir.ui.view">
|
||||
<field name="name">opencart.store.tree</field>
|
||||
<field name="model">opencart.store</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Opencart Store">
|
||||
<field name="name"/>
|
||||
<field name="backend_id"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_opencart_store" model="ir.actions.act_window">
|
||||
<field name="name">Opencart Stores</field>
|
||||
<field name="res_model">opencart.store</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" ref="view_opencart_store_tree"/>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_opencart_store"
|
||||
name="Stores"
|
||||
parent="menu_opencart_root"
|
||||
sequence="20"
|
||||
action="action_opencart_store"/>
|
||||
|
||||
</odoo>
|
||||
70
connector_opencart/views/opencart_product_views.xml
Normal file
70
connector_opencart/views/opencart_product_views.xml
Normal file
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_opencart_product_template_form" model="ir.ui.view">
|
||||
<field name="name">opencart.product.template.form</field>
|
||||
<field name="model">opencart.product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Opencart Product">
|
||||
<header/>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="odoo_id"/>
|
||||
<field name="opencart_attribute_value_ids" options="{'no_create': True}">
|
||||
<tree editable="top" decoration-warning="not odoo_id">
|
||||
<field name="external_id" readonly="1"/>
|
||||
<field name="opencart_name" readonly="1"/>
|
||||
<field name="opencart_product_tmpl_id" invisible="1"/>
|
||||
<field name="product_tmpl_id" invisible="1"/>
|
||||
<field name="odoo_id" domain="[('product_tmpl_id', '=', product_tmpl_id)]"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_opencart_product_template_tree" model="ir.ui.view">
|
||||
<field name="name">opencart.product.template.tree</field>
|
||||
<field name="model">opencart.product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Opencart Products">
|
||||
<field name="backend_id"/>
|
||||
<field name="external_id" string="Opencart ID"/>
|
||||
<field name="odoo_id" string="Product Template"/>
|
||||
<field name="create_date"/>
|
||||
<field name="write_date"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_opencart_product_template_search" model="ir.ui.view">
|
||||
<field name="name">opencart.product.template.search</field>
|
||||
<field name="model">opencart.product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Purchase Order">
|
||||
<field name="odoo_id" string="Odoo Product"/>
|
||||
<field name="external_id" string="External ID"/>
|
||||
<separator/>
|
||||
<filter string="My Activities" name="activities_my" domain="[('activity_ids.user_id', '=', uid)]"/>
|
||||
<separator/>
|
||||
<filter string="Has Activities" name="activities_all" domain="[('activity_ids', '!=', False)]" />
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_opencart_product_template" model="ir.actions.act_window">
|
||||
<field name="name">Opencart Products</field>
|
||||
<field name="res_model">opencart.product.template</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" ref="view_opencart_product_template_tree"/>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_opencart_product"
|
||||
name="Products"
|
||||
parent="menu_opencart_root"
|
||||
sequence="50"
|
||||
action="action_opencart_product_template"/>
|
||||
|
||||
</odoo>
|
||||
33
connector_opencart/views/product_views.xml
Normal file
33
connector_opencart/views/product_views.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="product_template_only_form_view_inherit" model="ir.ui.view">
|
||||
<field name="name">product.template.product.form.inherit</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="product.product_template_only_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='barcode']" position="after">
|
||||
<field name="opencart_sku"/>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='inventory']" position="after">
|
||||
<page name="opencart" string="OpenCart">
|
||||
<field name="opencart_bind_ids"/>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="product_normal_form_view_inherit" model="ir.ui.view">
|
||||
<field name="name">product.product.form.inherit</field>
|
||||
<field name="model">product.product</field>
|
||||
<field name="inherit_id" ref="product.product_normal_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='inventory']" position="after">
|
||||
<page name="opencart" string="OpenCart">
|
||||
<field name="opencart_bind_ids" />
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
53
connector_opencart/views/sale_order_views.xml
Normal file
53
connector_opencart/views/sale_order_views.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
<record id="view_sale_order_opencart_form" model="ir.ui.view">
|
||||
<field name="name">sale.order.opencart.form</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="connector_ecommerce.view_order_connector_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<page name="connector" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</page>
|
||||
<page name="connector" position="inside">
|
||||
<group string="Opencart Bindings">
|
||||
<field name="opencart_bind_ids" nolabel="1"/>
|
||||
</group>
|
||||
</page>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_opencart_sale_order_form" model="ir.ui.view">
|
||||
<field name="name">opencart.sale.order.form</field>
|
||||
<field name="model">opencart.sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Opencart Sales Orders"
|
||||
create="false" delete="false">
|
||||
<group>
|
||||
<field name="backend_id"/>
|
||||
<field name="external_id"/>
|
||||
<field name="customer_order_id"/>
|
||||
<field name="total_amount"/>
|
||||
<field name="total_amount_tax"/>
|
||||
<field name="shipping_method_code"/>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_opencart_sale_order_tree" model="ir.ui.view">
|
||||
<field name="name">opencart.sale.order.tree</field>
|
||||
<field name="model">opencart.sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Opencart Sales Orders"
|
||||
create="false" delete="false">
|
||||
<field name="backend_id"/>
|
||||
<field name="external_id"/>
|
||||
<field name="customer_order_id"/>
|
||||
<field name="total_amount"/>
|
||||
<field name="total_amount_tax"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
-->
|
||||
</odoo>
|
||||
2
external/hibou-oca/bank-payment
vendored
2
external/hibou-oca/bank-payment
vendored
Submodule external/hibou-oca/bank-payment updated: f23701fecc...1639a372f3
Reference in New Issue
Block a user