WIP from 12

This commit is contained in:
Jared Kipe
2021-12-15 10:36:44 -08:00
parent 01d68c1267
commit 6e04b9d61d
35 changed files with 2637 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
from . import components
from . import models

View 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,
}

View File

@@ -0,0 +1,6 @@
from . import api
from . import backend_adapter
from . import binder
from . import importer
from . import exporter
from . import mapper

View File

@@ -0,0 +1 @@
from . import opencart

View File

@@ -0,0 +1,171 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import requests
from urllib.parse import urlencode
from json import loads, dumps
from json.decoder import JSONDecodeError
import logging
_logger = logging.getLogger(__name__)
class Opencart:
def __init__(self, base_url, restadmin_token):
self.base_url = str(base_url) + '/api/rest_admin/'
self.restadmin_token = restadmin_token
self.session = requests.Session()
self.session.headers['X-Oc-Restadmin-Id'] = self.restadmin_token
@property
def orders(self):
return Orders(connection=self)
@property
def stores(self):
return Stores(connection=self)
@property
def products(self):
return Products(connection=self)
def get_headers(self, url, method):
headers = {}
if method in ('POST', 'PUT', ):
headers['Content-Type'] = 'application/json'
return headers
def send_request(self, method, url, params=None, body=None):
encoded_url = url
if params:
encoded_url += '?%s' % urlencode(params)
headers = self.get_headers(encoded_url, method)
_logger.debug('send_request method: %s url: %s headers: %s params: %s body: %s' % (
method,
url,
headers,
params,
body
))
if method == 'GET':
result_text = self.session.get(url, params=params, headers=headers).text
elif method == 'PUT' or method == 'POST':
result_text = self.session.put(url, data=body, headers=headers).text
_logger.debug('raw_text: ' + str(result_text))
try:
return loads(result_text)
except JSONDecodeError:
return {}
class Resource:
"""
A base class for all Resources to extend
"""
def __init__(self, connection):
self.connection = connection
@property
def url(self):
return self.connection.base_url + self.path
class Orders(Resource):
"""
Retrieves Order details
"""
path = 'orders'
def all(self, id_larger_than=None, modified_from=None):
url = self.url
if id_larger_than:
url += '/id_larger_than/%s' % id_larger_than
if modified_from:
url += '/modified_from/%s' % modified_from
return self.connection.send_request(method='GET', url=url)
def get(self, id):
url = self.url + ('/%s' % id)
return self.connection.send_request(method='GET', url=url)
def ship(self, id, tracking, tracking_comment=None):
def url(stem):
return self.connection.base_url + ('%s/%s' % (stem, id))
res = self.connection.send_request(method='PUT', url=url('trackingnumber'), body=self.get_tracking_payload(tracking))
if tracking_comment:
res = self.connection.send_request(method='PUT', url=url('orderhistory'), body=self.get_orderhistory_payload(
3, # "Shipped"
True, # Notify!
tracking_comment,
))
return res
def cancel(self, id):
url = self.connection.base_url + ('order_status/%s' % id)
return self.connection.send_request(method='POST', url=url, body=self.get_status_payload('Canceled'))
def get_status_payload(self, status):
"""
{
"status": "Canceled"
}
"""
payload = {
"status": status,
}
return dumps(payload)
def get_tracking_payload(self, tracking):
"""
{
"tracking": "5559994444"
}
"""
payload = {
"tracking": tracking,
}
return dumps(payload)
def get_orderhistory_payload(self, status_id, notify, comment):
"""
{
"order_status_id": "5",
"notify": "1",
"comment": "demo comment"
}
"""
payload = {
'order_status_id': str(status_id),
'notify': '1' if notify else '0',
'comment': str(comment)
}
return dumps(payload)
class Stores(Resource):
"""
Retrieves Store details
"""
path = 'stores'
def all(self):
return self.connection.send_request(method='GET', url=self.url)
def get(self, id):
url = self.url + ('/%s' % id)
return self.connection.send_request(method='GET', url=url)
class Products(Resource):
"""
Retrieves Product details
"""
path = 'products'
def get(self, id):
url = self.url + ('/%s' % id)
return self.connection.send_request(method='GET', url=url)

View File

@@ -0,0 +1,67 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.addons.component.core import AbstractComponent
from odoo.addons.queue_job.exception import RetryableJobError
from odoo.addons.connector.exception import NetworkRetryableError
from .api.opencart import Opencart
from logging import getLogger
from lxml import etree
_logger = getLogger(__name__)
class BaseOpencartConnectorComponent(AbstractComponent):
""" Base Opencart Connector Component
All components of this connector should inherit from it.
"""
_name = 'base.opencart.connector'
_inherit = 'base.connector'
_collection = 'opencart.backend'
class OpencartAdapter(AbstractComponent):
_name = 'opencart.adapter'
_inherit = ['base.backend.adapter', 'base.opencart.connector']
_opencart_model = None
def search(self, filters=None):
""" Search records according to some criterias
and returns a list of ids """
raise NotImplementedError
def read(self, id, attributes=None):
""" Returns the information of a record """
raise NotImplementedError
def search_read(self, filters=None):
""" Search records according to some criterias
and returns their information"""
raise NotImplementedError
def create(self, data):
""" Create a record on the external system """
raise NotImplementedError
def write(self, id, data):
""" Update records on the external system """
raise NotImplementedError
def delete(self, id):
""" Delete a record on the external system """
raise NotImplementedError
@property
def api_instance(self):
try:
opencart_api = getattr(self.work, 'opencart_api')
except AttributeError:
raise AttributeError(
'You must provide a opencart_api attribute with a '
'Opencart instance to be able to use the '
'Backend Adapter.'
)
return opencart_api

View File

@@ -0,0 +1,25 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.addons.component.core import Component
class OpencartModelBinder(Component):
""" Bind records and give odoo/opencart ids correspondence
Binding models are models called ``opencart.{normal_model}``,
like ``opencart.sale.order`` or ``opencart.product.product``.
They are ``_inherits`` of the normal models and contains
the Opencart ID, the ID of the Opencart Backend and the additional
fields belonging to the Opencart instance.
"""
_name = 'opencart.binder'
_inherit = ['base.binder', 'base.opencart.connector']
_apply_on = [
'opencart.store',
'opencart.sale.order',
'opencart.sale.order.line',
'opencart.stock.picking',
'opencart.product.template',
'opencart.product.template.attribute.value',
]

View File

@@ -0,0 +1,313 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from contextlib import contextmanager
from datetime import datetime
import psycopg2
import odoo
from odoo import _
from odoo.addons.component.core import AbstractComponent
from odoo.addons.connector.exception import (IDMissingInBackend,
RetryableJobError)
_logger = logging.getLogger(__name__)
class OpencartBaseExporter(AbstractComponent):
""" Base exporter for Opencart """
_name = 'opencart.base.exporter'
_inherit = ['base.exporter', 'base.opencart.connector']
_usage = 'record.exporter'
def __init__(self, working_context):
super(OpencartBaseExporter, self).__init__(working_context)
self.binding = None
self.external_id = None
def run(self, binding, *args, **kwargs):
""" Run the synchronization
:param binding: binding record to export
"""
self.binding = binding
self.external_id = self.binder.to_external(self.binding)
result = self._run(*args, **kwargs)
self.binder.bind(self.external_id, self.binding)
# Commit so we keep the external ID when there are several
# exports (due to dependencies) and one of them fails.
# The commit will also release the lock acquired on the binding
# record
if not odoo.tools.config['test_enable']:
self.env.cr.commit()
self._after_export()
return result
def _run(self):
""" Flow of the synchronization, implemented in inherited classes"""
raise NotImplementedError
def _after_export(self):
""" Can do several actions after exporting a record to Opencart """
pass
class OpencartExporter(AbstractComponent):
""" A common flow for the exports to Opencart """
_name = 'opencart.exporter'
_inherit = 'opencart.base.exporter'
def __init__(self, working_context):
super(OpencartExporter, self).__init__(working_context)
self.binding = None
def _lock(self):
""" Lock the binding record.
Lock the binding record so we are sure that only one export
job is running for this record if concurrent jobs have to export the
same record.
When concurrent jobs try to export the same record, the first one
will lock and proceed, the others will fail to lock and will be
retried later.
This behavior works also when the export becomes multilevel
with :meth:`_export_dependencies`. Each level will set its own lock
on the binding record it has to export.
"""
sql = ("SELECT id FROM %s WHERE ID = %%s FOR UPDATE NOWAIT" %
self.model._table)
try:
self.env.cr.execute(sql, (self.binding.id, ),
log_exceptions=False)
except psycopg2.OperationalError:
_logger.info('A concurrent job is already exporting the same '
'record (%s with id %s). Job delayed later.',
self.model._name, self.binding.id)
raise RetryableJobError(
'A concurrent job is already exporting the same record '
'(%s with id %s). The job will be retried later.' %
(self.model._name, self.binding.id))
def _has_to_skip(self):
""" Return True if the export can be skipped """
return False
@contextmanager
def _retry_unique_violation(self):
""" Context manager: catch Unique constraint error and retry the
job later.
When we execute several jobs workers concurrently, it happens
that 2 jobs are creating the same record at the same time (binding
record created by :meth:`_export_dependency`), resulting in:
IntegrityError: duplicate key value violates unique
constraint "opencart_product_product_odoo_uniq"
DETAIL: Key (backend_id, odoo_id)=(1, 4851) already exists.
In that case, we'll retry the import just later.
.. warning:: The unique constraint must be created on the
binding record to prevent 2 bindings to be created
for the same Opencart record.
"""
try:
yield
except psycopg2.IntegrityError as err:
if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION:
raise RetryableJobError(
'A database error caused the failure of the job:\n'
'%s\n\n'
'Likely due to 2 concurrent jobs wanting to create '
'the same record. The job will be retried later.' % err)
else:
raise
def _export_dependency(self, relation, binding_model,
component_usage='record.exporter',
binding_field='opencart_bind_ids',
binding_extra_vals=None):
"""
Export a dependency. The exporter class is a subclass of
``OpencartExporter``. If a more precise class need to be defined,
it can be passed to the ``exporter_class`` keyword argument.
.. warning:: a commit is done at the end of the export of each
dependency. The reason for that is that we pushed a record
on the backend and we absolutely have to keep its ID.
So you *must* take care not to modify the Odoo
database during an export, excepted when writing
back the external ID or eventually to store
external data that we have to keep on this side.
You should call this method only at the beginning
of the exporter synchronization,
in :meth:`~._export_dependencies`.
:param relation: record to export if not already exported
:type relation: :py:class:`odoo.models.BaseModel`
:param binding_model: name of the binding model for the relation
:type binding_model: str | unicode
:param component_usage: 'usage' to look for to find the Component to
for the export, by default 'record.exporter'
:type exporter: str | unicode
:param binding_field: name of the one2many field on a normal
record that points to the binding record
(default: opencart_bind_ids).
It is used only when the relation is not
a binding but is a normal record.
:type binding_field: str | unicode
:binding_extra_vals: In case we want to create a new binding
pass extra values for this binding
:type binding_extra_vals: dict
"""
if not relation:
return
rel_binder = self.binder_for(binding_model)
# wrap is typically True if the relation is for instance a
# 'product.product' record but the binding model is
# 'opencart.product.product'
wrap = relation._name != binding_model
if wrap and hasattr(relation, binding_field):
domain = [('odoo_id', '=', relation.id),
('backend_id', '=', self.backend_record.id)]
binding = self.env[binding_model].search(domain)
if binding:
assert len(binding) == 1, (
'only 1 binding for a backend is '
'supported in _export_dependency')
# we are working with a unwrapped record (e.g.
# product.category) and the binding does not exist yet.
# Example: I created a product.product and its binding
# opencart.product.product and we are exporting it, but we need to
# create the binding for the product.category on which it
# depends.
else:
bind_values = {'backend_id': self.backend_record.id,
'odoo_id': relation.id}
if binding_extra_vals:
bind_values.update(binding_extra_vals)
# If 2 jobs create it at the same time, retry
# one later. A unique constraint (backend_id,
# odoo_id) should exist on the binding model
with self._retry_unique_violation():
binding = (self.env[binding_model]
.with_context(connector_no_export=True)
.sudo()
.create(bind_values))
# Eager commit to avoid having 2 jobs
# exporting at the same time. The constraint
# will pop if an other job already created
# the same binding. It will be caught and
# raise a RetryableJobError.
if not odoo.tools.config['test_enable']:
self.env.cr.commit() # noqa
else:
# If opencart_bind_ids does not exist we are typically in a
# "direct" binding (the binding record is the same record).
# If wrap is True, relation is already a binding record.
binding = relation
if not rel_binder.to_external(binding):
exporter = self.component(usage=component_usage,
model_name=binding_model)
exporter.run(binding)
def _export_dependencies(self):
""" Export the dependencies for the record"""
return
def _map_data(self):
""" Returns an instance of
:py:class:`~odoo.addons.connector.components.mapper.MapRecord`
"""
return self.mapper.map_record(self.binding)
def _validate_create_data(self, data):
""" Check if the values to import are correct
Pro-actively check before the ``Model.create`` if some fields
are missing or invalid
Raise `InvalidDataError`
"""
return
def _validate_update_data(self, data):
""" Check if the values to import are correct
Pro-actively check before the ``Model.update`` if some fields
are missing or invalid
Raise `InvalidDataError`
"""
return
def _create_data(self, map_record, fields=None, **kwargs):
""" Get the data to pass to :py:meth:`_create` """
return map_record.values(for_create=True, fields=fields, **kwargs)
def _create(self, data):
""" Create the Opencart record """
# special check on data before export
self._validate_create_data(data)
return self.backend_adapter.create(data)
def _update_data(self, map_record, fields=None, **kwargs):
""" Get the data to pass to :py:meth:`_update` """
return map_record.values(fields=fields, **kwargs)
def _update(self, data):
""" Update an Opencart record """
assert self.external_id
# special check on data before export
self._validate_update_data(data)
self.backend_adapter.write(self.external_id, data)
def _run(self, fields=None):
""" Flow of the synchronization, implemented in inherited classes"""
assert self.binding
if not self.external_id:
fields = None # should be created with all the fields
if self._has_to_skip():
return
# export the missing linked resources
self._export_dependencies()
# prevent other jobs to export the same record
# will be released on commit (or rollback)
self._lock()
map_record = self._map_data()
if self.external_id:
record = self._update_data(map_record, fields=fields)
if not record:
return _('Nothing to export.')
self._update(record)
else:
record = self._create_data(map_record, fields=fields)
if not record:
return _('Nothing to export.')
self.external_id = self._create(record)
return _('Record exported with ID %s on Opencart.') % self.external_id

View File

@@ -0,0 +1,332 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
"""
Importers for Opencart.
An import can be skipped if the last sync date is more recent than
the last update in Opencart.
They should call the ``bind`` method if the binder even if the records
are already bound, to update the last sync date.
"""
import logging
from odoo import fields, _
from odoo.addons.component.core import AbstractComponent, Component
from odoo.addons.connector.exception import IDMissingInBackend
from odoo.addons.queue_job.exception import NothingToDoJob
_logger = logging.getLogger(__name__)
class OpencartImporter(AbstractComponent):
""" Base importer for Opencart """
_name = 'opencart.importer'
_inherit = ['base.importer', 'base.opencart.connector']
_usage = 'record.importer'
def __init__(self, work_context):
super(OpencartImporter, self).__init__(work_context)
self.external_id = None
self.opencart_record = None
def _get_opencart_data(self):
""" Return the raw Opencart data for ``self.external_id`` """
return self.backend_adapter.read(self.external_id)
def _before_import(self):
""" Hook called before the import, when we have the Opencart
data"""
def _is_uptodate(self, binding):
"""Return True if the import should be skipped because
it is already up-to-date in Odoo"""
assert self.opencart_record
if not self.opencart_record.get('date_updated'):
return # no update date on Opencart, always import it.
if not binding:
return # it does not exist so it should not be skipped
sync = binding.sync_date
if not sync:
return
from_string = fields.Datetime.from_string
sync_date = from_string(sync)
opencart_date = from_string(self.opencart_record['date_updated'])
# if the last synchronization date is greater than the last
# update in opencart, we skip the import.
# Important: at the beginning of the exporters flows, we have to
# check if the opencart_date is more recent than the sync_date
# and if so, schedule a new import. If we don't do that, we'll
# miss changes done in Opencart
return opencart_date < sync_date
def _import_dependency(self, external_id, binding_model,
importer=None, always=False):
""" Import a dependency.
The importer class is a class or subclass of
:class:`OpencartImporter`. A specific class can be defined.
:param external_id: id of the related binding to import
:param binding_model: name of the binding model for the relation
:type binding_model: str | unicode
:param importer_component: component to use for import
By default: 'importer'
:type importer_component: Component
:param always: if True, the record is updated even if it already
exists, note that it is still skipped if it has
not been modified on Opencart since the last
update. When False, it will import it only when
it does not yet exist.
:type always: boolean
"""
if not external_id:
return
binder = self.binder_for(binding_model)
record = binder.to_internal(external_id)
if always or not record:
if importer is None:
importer = self.component(usage='record.importer',
model_name=binding_model)
try:
importer.run(external_id)
except NothingToDoJob:
_logger.info(
'Dependency import of %s(%s) has been ignored.',
binding_model._name, external_id
)
return True
if binding_model == 'opencart.product.template' and record.backend_id.so_require_product_setup:
# Though this is not the "right" place to do this,
# we need to return True if there is a checkpoint for a product.
if record.backend_id.find_checkpoint(record):
return True
return False
def _import_dependencies(self):
""" Import the dependencies for the record
Import of dependencies can be done manually or by calling
:meth:`_import_dependency` for each dependency.
"""
return
def _map_data(self):
""" Returns an instance of
:py:class:`~odoo.addons.connector.components.mapper.MapRecord`
"""
return self.mapper.map_record(self.opencart_record)
def _validate_data(self, data):
""" Check if the values to import are correct
Pro-actively check before the ``_create`` or
``_update`` if some fields are missing or invalid.
Raise `InvalidDataError`
"""
return
def _must_skip(self):
""" Hook called right after we read the data from the backend.
If the method returns a message giving a reason for the
skipping, the import will be interrupted and the message
recorded in the job (if the import is called directly by the
job, not by dependencies).
If it returns None, the import will continue normally.
:returns: None | str | unicode
"""
return
def _get_binding(self):
return self.binder.to_internal(self.external_id)
def _create_data(self, map_record, **kwargs):
return map_record.values(for_create=True, **kwargs)
def _create(self, data):
""" Create the OpenERP record """
# special check on data before import
self._validate_data(data)
model = self.model.with_context(connector_no_export=True)
binding = model.create(data)
_logger.debug('%d created from opencart %s', binding, self.external_id)
return binding
def _update_data(self, map_record, **kwargs):
return map_record.values(**kwargs)
def _update(self, binding, data):
""" Update an OpenERP record """
# special check on data before import
self._validate_data(data)
binding.with_context(connector_no_export=True).write(data)
_logger.debug('%d updated from opencart %s', binding, self.external_id)
return
def _after_import(self, binding):
""" Hook called at the end of the import """
return
def run(self, external_id, force=False):
""" Run the synchronization
:param external_id: identifier of the record on Opencart
"""
self.external_id = external_id
lock_name = 'import({}, {}, {}, {})'.format(
self.backend_record._name,
self.backend_record.id,
self.work.model_name,
external_id,
)
try:
self.opencart_record = self._get_opencart_data()
except IDMissingInBackend:
return _('Record does no longer exist in Opencart')
skip = self._must_skip()
if skip:
return skip
binding = self._get_binding()
if not force and self._is_uptodate(binding):
return _('Already up-to-date.')
# Keep a lock on this import until the transaction is committed
# The lock is kept since we have detected that the informations
# will be updated into Odoo
self.advisory_lock_or_retry(lock_name)
self._before_import()
# import the missing linked resources
self._import_dependencies()
map_record = self._map_data()
if binding:
record = self._update_data(map_record)
self._update(binding, record)
else:
record = self._create_data(map_record)
binding = self._create(record)
self.binder.bind(self.external_id, binding)
self._after_import(binding)
class BatchImporter(AbstractComponent):
""" The role of a BatchImporter is to search for a list of
items to import, then it can either import them directly or delay
the import of each item separately.
"""
_name = 'opencart.batch.importer'
_inherit = ['base.importer', 'base.opencart.connector']
_usage = 'batch.importer'
def run(self, filters=None):
""" Run the synchronization """
record_ids = self.backend_adapter.search(filters)
for record_id in record_ids:
self._import_record(record_id)
def _import_record(self, external_id):
""" Import a record directly or delay the import of the record.
Method to implement in sub-classes.
"""
raise NotImplementedError
class DirectBatchImporter(AbstractComponent):
""" Import the records directly, without delaying the jobs. """
_name = 'opencart.direct.batch.importer'
_inherit = 'opencart.batch.importer'
def _import_record(self, external_id):
""" Import the record directly """
self.model.import_record(self.backend_record, external_id)
class DelayedBatchImporter(AbstractComponent):
""" Delay import of the records """
_name = 'opencart.delayed.batch.importer'
_inherit = 'opencart.batch.importer'
def _import_record(self, external_id, job_options=None, **kwargs):
""" Delay the import of the records"""
delayable = self.model.with_delay(**job_options or {})
delayable.import_record(self.backend_record, external_id, **kwargs)
# class SimpleRecordImporter(Component):
# """ Import one Opencart Website """
#
# _name = 'opencart.simple.record.importer'
# _inherit = 'opencart.importer'
# _apply_on = [
# 'opencart.res.partner.category',
# ]
# class TranslationImporter(Component):
# """ Import translations for a record.
#
# Usually called from importers, in ``_after_import``.
# For instance from the products and products' categories importers.
# """
#
# _name = 'opencart.translation.importer'
# _inherit = 'opencart.importer'
# _usage = 'translation.importer'
#
# def _get_opencart_data(self, storeview_id=None):
# """ Return the raw Opencart data for ``self.external_id`` """
# return self.backend_adapter.read(self.external_id, storeview_id)
#
# def run(self, external_id, binding, mapper=None):
# self.external_id = external_id
# storeviews = self.env['opencart.storeview'].search(
# [('backend_id', '=', self.backend_record.id)]
# )
# default_lang = self.backend_record.default_lang_id
# lang_storeviews = [sv for sv in storeviews
# if sv.lang_id and sv.lang_id != default_lang]
# if not lang_storeviews:
# return
#
# # find the translatable fields of the model
# fields = self.model.fields_get()
# translatable_fields = [field for field, attrs in fields.items()
# if attrs.get('translate')]
#
# if mapper is None:
# mapper = self.mapper
# else:
# mapper = self.component_by_name(mapper)
#
# for storeview in lang_storeviews:
# lang_record = self._get_opencart_data(storeview.external_id)
# map_record = mapper.map_record(lang_record)
# record = map_record.values()
#
# data = dict((field, value) for field, value in record.items()
# if field in translatable_fields)
#
# binding.with_context(connector_no_export=True,
# lang=storeview.lang_id.code).write(data)

View File

@@ -0,0 +1,16 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.addons.component.core import AbstractComponent
class OpencartImportMapper(AbstractComponent):
_name = 'opencart.import.mapper'
_inherit = ['base.opencart.connector', 'base.import.mapper']
_usage = 'import.mapper'
class OpencartExportMapper(AbstractComponent):
_name = 'opencart.export.mapper'
_inherit = ['base.opencart.connector', 'base.export.mapper']
_usage = 'export.mapper'

View File

@@ -0,0 +1,40 @@
<?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="rule_group">sale</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>
</odoo>

View File

@@ -0,0 +1,5 @@
from . import delivery
from . import opencart
from . import product
from . import sale_order
from . import stock_picking

View File

@@ -0,0 +1 @@
from . import common

View File

@@ -0,0 +1,22 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api
class DeliveryCarrier(models.Model):
""" Adds Opencart specific fields to ``delivery.carrier``
``opencart_code``
Code of the carrier delivery method in Opencart.
Example: ``USPS``
"""
_inherit = "delivery.carrier"
opencart_code = fields.Char(
string='Opencart Method Code',
required=False,
)

View File

@@ -0,0 +1,5 @@
from . import backend
from . import backend_importer
from . import binding
from . import store
from . import store_importer

View File

@@ -0,0 +1,188 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from logging import getLogger
from contextlib import contextmanager
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.addons.connector.models.checkpoint import add_checkpoint
from ...components.api.opencart import Opencart
_logger = getLogger(__name__)
class OpencartBackend(models.Model):
_name = 'opencart.backend'
_description = 'Opencart Backend'
_inherit = 'connector.backend'
name = fields.Char(string='Name')
base_url = fields.Char(
string='Base URL',
required=True,
help='Url of your site, e.g. http://your-site.com',
)
restadmin_token = fields.Char(
string='RestAdmin Token',
required=True,
help='configured in Extensions->Modules->RestAdminAPI',
)
warehouse_id = fields.Many2one(
comodel_name='stock.warehouse',
string='Warehouse',
required=True,
help='Warehouse to use for stock.',
)
company_id = fields.Many2one(
comodel_name='res.company',
related='warehouse_id.company_id',
string='Company',
readonly=True,
)
fiscal_position_id = fields.Many2one(
comodel_name='account.fiscal.position',
string='Fiscal Position',
help='Fiscal position to use on orders.',
)
analytic_account_id = fields.Many2one(
comodel_name='account.analytic.account',
string='Analytic account',
help='If specified, this analytic account will be used to fill the '
'field on the sale order created by the connector.'
)
team_id = fields.Many2one(comodel_name='crm.team', string='Sales Team')
sale_prefix = fields.Char(
string='Sale Prefix',
help="A prefix put before the name of imported sales orders.\n"
"For instance, if the prefix is 'OC-', the sales "
"order 36071 in Opencart, will be named 'OC-36071' "
"in Odoo.",
)
coupon_product_id = fields.Many2one(comodel_name='product.product', string='Coupon Product',
help='Product to represent coupon discounts.')
# New Product fields.
product_categ_id = fields.Many2one(comodel_name='product.category', string='Product Category',
help='Default product category for newly created products.')
# renamed, and not used when searching orders anymore
import_orders_after_id = fields.Integer(
string='Highest Order ID',
)
# Note that Opencart may not return timestamps in UTC
import_orders_after_date = fields.Datetime(
string='Import Orders Modified After',
)
server_offset_hours = fields.Float(
string='Opencart Server Timezone Offset',
help='E.g. US Pacific is -8.0, the important thing is to either not change this during DST or to adjust the import_orders_after_date field at the same time.',
)
so_require_product_setup = fields.Boolean(string='SO Require Product Setup',
help='Prevents SO from being confirmed (failed queue job), if one or more products has an open checkpoint.')
scheduler_order_import_running = fields.Boolean(string='Auctomatic Sale Order Import is Running',
compute='_compute_scheduler_order_import_running',
compute_sudo=True)
scheduler_order_import = fields.Boolean(string='Automatic Sale Order Import',
help='Individual stores should also be enabled for import.')
def _compute_scheduler_order_import_running(self):
sched_action = self.env.ref('connector_opencart.ir_cron_import_sale_orders', raise_if_not_found=False)
for backend in self:
backend.scheduler_order_import_running = bool(sched_action.active)
@contextmanager
@api.multi
def work_on(self, model_name, **kwargs):
self.ensure_one()
opencart_api = Opencart(self.base_url, self.restadmin_token)
_super = super(OpencartBackend, self)
with _super.work_on(model_name, opencart_api=opencart_api, **kwargs) as work:
yield work
@api.multi
def add_checkpoint(self, record):
self.ensure_one()
record.ensure_one()
return add_checkpoint(self.env, record._name, record.id,
self._name, self.id)
@api.multi
def find_checkpoint(self, record):
self.ensure_one()
record.ensure_one()
checkpoint_model = self.env['connector.checkpoint']
model_model = self.env['ir.model']
model = model_model.search([('model', '=', record._name)], limit=1)
return checkpoint_model.search([
('backend_id', '=', '%s,%s' % (self._name, self.id)),
('model_id', '=', model.id),
('record_id', '=', record.id),
('state', '=', 'need_review'),
], limit=1)
@api.multi
def synchronize_metadata(self):
try:
for backend in self:
self.env['opencart.store'].import_batch(backend)
return True
except Exception as e:
_logger.error(e)
raise UserError(_("Check your configuration, we can't get the data. "
"Here is the error:\n%s") % (e, ))
@api.model
def _scheduler_import_sale_orders(self):
# potential hook for customization (e.g. pad from date or provide its own)
backends = self.search([
('base_url', '!=', False),
('restadmin_token', '!=', False),
('import_orders_after_date', '!=', False),
('scheduler_order_import', '=', True),
])
return backends.import_sale_orders()
@api.multi
def import_sale_orders(self):
self._import_sale_orders_after_date()
return True
@api.multi
def _import_after_id(self, model_name, after_id_field):
for backend in self:
after_id = backend[after_id_field]
self.env[model_name].with_delay().import_batch(
backend,
filters={'after_id': after_id}
)
@api.multi
def _import_sale_orders_after_date(self):
for backend in self:
date = backend.date_to_opencart(backend.import_orders_after_date)
date = str(date).replace(' ', 'T')
self.env['opencart.sale.order'].with_delay().import_batch(
backend,
filters={'modified_from': date}
)
def date_to_opencart(self, date):
# date provided should be UTC and will be converted to Opencart's dates
return self._date_plus_hours(date, self.server_offset_hours or 0)
def date_to_odoo(self, date):
# date provided should be in Opencart's TZ, converted to UTC
return self._date_plus_hours(date, -(self.server_offset_hours or 0))
def _date_plus_hours(self, date, hours):
if not hours:
return date
if isinstance(date, str):
date = fields.Datetime.from_string(date)
return date + timedelta(hours=hours)

View File

@@ -0,0 +1,19 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.addons.component.core import Component
class MetadataBatchImporter(Component):
""" Import the records directly, without delaying the jobs.
Import the Opencart Stores
They are imported directly because this is a rare and fast operation,
and we don't really bother if it blocks the UI during this time.
"""
_name = 'opencart.metadata.batch.importer'
_inherit = 'opencart.direct.batch.importer'
_apply_on = ['opencart.store']

View File

@@ -0,0 +1,48 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models, fields
from odoo.addons.queue_job.job import job, related_action
class OpencartBinding(models.AbstractModel):
""" Abstract Model for the Bindings.
All of the models used as bindings between Opencart and Odoo
(``opencart.sale.order``) should ``_inherit`` from it.
"""
_name = 'opencart.binding'
_inherit = 'external.binding'
_description = 'Opencart Binding (abstract)'
backend_id = fields.Many2one(
comodel_name='opencart.backend',
string='Opencart Backend',
required=True,
ondelete='restrict',
)
external_id = fields.Char(string='ID in Opencart')
_sql_constraints = [
('opencart_uniq', 'unique(backend_id, external_id)', 'A binding already exists for this Opencart ID.'),
]
@job(default_channel='root.opencart')
@related_action(action='related_action_opencart_link')
@api.model
def import_batch(self, backend, filters=None):
""" Prepare the import of records modified on Opencart """
if filters is None:
filters = {}
with backend.work_on(self._name) as work:
importer = work.component(usage='batch.importer')
return importer.run(filters=filters)
@job(default_channel='root.opencart')
@related_action(action='related_action_opencart_link')
@api.model
def import_record(self, backend, external_id, force=False):
""" Import a Opencart record """
with backend.work_on(self._name) as work:
importer = work.component(usage='record.importer')
return importer.run(external_id, force=force)

View File

@@ -0,0 +1,81 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
from odoo.exceptions import ValidationError
from odoo.addons.component.core import Component
from odoo.addons.queue_job.exception import RetryableJobError
class OpencartStore(models.Model):
_name = 'opencart.store'
_inherit = ['opencart.binding']
_description = 'Opencart Store'
_parent_name = 'backend_id'
name = fields.Char()
backend_id = fields.Many2one('opencart.backend',
string='Opencart Backend',
ondelete='cascade',
readonly=True)
warehouse_id = fields.Many2one(
comodel_name='stock.warehouse',
string='Warehouse',
help='Warehouse to use for stock. (overridden from backend)',
)
company_id = fields.Many2one(
comodel_name='res.company',
related='warehouse_id.company_id',
string='Company',
readonly=True,
)
fiscal_position_id = fields.Many2one(
comodel_name='account.fiscal.position',
string='Fiscal Position',
help='Fiscal position to use on orders. (overridden from backend)',
)
analytic_account_id = fields.Many2one(
comodel_name='account.analytic.account',
string='Analytic account',
help='If specified, this analytic account will be used to fill the '
'field on the sale order created by the connector. (overridden from backend)'
)
team_id = fields.Many2one(comodel_name='crm.team', string='Sales Team',
help='(overridden from backend)')
sale_prefix = fields.Char(
string='Sale Prefix',
help="A prefix put before the name of imported sales orders.\n"
"For instance, if the prefix is 'OC-', the sales "
"order 36071 in Opencart, will be named 'OC-36071' "
"in Odoo. (overridden from backend)",
)
coupon_product_id = fields.Many2one(comodel_name='product.product', string='Coupon Product',
help='Product to represent coupon discounts.')
enable_order_import = fields.Boolean(string='Enable Sale Order Import', default=True,
help='If not enabled, then stores will be skipped during order imiport.')
class OpencartStoreAdapter(Component):
_name = 'opencart.store.adapter'
_inherit = 'opencart.adapter'
_apply_on = 'opencart.store'
def search(self, filters=None):
api_instance = self.api_instance
stores_response = api_instance.stores.all()
if 'error' in stores_response and stores_response['error']:
raise ValidationError(str(stores_response))
if 'data' not in stores_response or not isinstance(stores_response['data'], list):
return []
stores = stores_response['data']
return list(map(lambda s: s['store_id'], stores))
def read(self, id):
api_instance = self.api_instance
record = api_instance.stores.get(id)
if 'data' in record and record['data']:
return record['data']
raise RetryableJobError('Store "' + str(id) + '" did not return an store response. ' + str(record))

View File

@@ -0,0 +1,27 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.addons.component.core import Component
from odoo.addons.connector.components.mapper import mapping
class OpencartStoreImportMapper(Component):
_name = 'opencart.store.mapper'
_inherit = 'opencart.import.mapper'
_apply_on = 'opencart.store'
direct = [
('config_name', 'name'),
]
@mapping
def backend_id(self, record):
return {'backend_id': self.backend_record.id}
class OpencartStoreImporter(Component):
""" Import one Opencart Store """
_name = 'opencart.store.importer'
_inherit = 'opencart.importer'
_apply_on = 'opencart.store'

View File

@@ -0,0 +1,2 @@
from . import common
from . import importer

View 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.'),
]

View 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()

View File

@@ -0,0 +1,2 @@
from . import common
from . import importer

View File

@@ -0,0 +1,120 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import odoo.addons.decimal_precision as dp
from odoo import models, fields, api
from odoo.exceptions import ValidationError
from odoo.addons.queue_job.job import job
from odoo.addons.component.core import Component
from odoo.addons.queue_job.exception import RetryableJobError
class OpencartSaleOrder(models.Model):
_name = 'opencart.sale.order'
_inherit = 'opencart.binding'
_description = 'Opencart Sale Order'
_inherits = {'sale.order': 'odoo_id'}
odoo_id = fields.Many2one(comodel_name='sale.order',
string='Sale Order',
required=True,
ondelete='cascade')
opencart_order_line_ids = fields.One2many(
comodel_name='opencart.sale.order.line',
inverse_name='opencart_order_id',
string='Walmart Order Lines'
)
store_id = fields.Many2one('opencart.store', string='Store')
total_amount = fields.Float(
string='Total amount',
digits=dp.get_precision('Account')
)
@job(default_channel='root.opencart')
@api.model
def import_batch(self, backend, filters=None):
""" Prepare the import of Sales Orders from Opencart """
return super(OpencartSaleOrder, self).import_batch(backend, filters=filters)
class SaleOrder(models.Model):
_inherit = 'sale.order'
opencart_bind_ids = fields.One2many(
comodel_name='opencart.sale.order',
inverse_name='odoo_id',
string="Opencart Bindings",
)
class OpencartSaleOrderLine(models.Model):
_name = 'opencart.sale.order.line'
_inherit = 'opencart.binding'
_description = 'Opencart Sale Order Line'
_inherits = {'sale.order.line': 'odoo_id'}
opencart_order_id = fields.Many2one(comodel_name='opencart.sale.order',
string='Opencart Sale Order',
required=True,
ondelete='cascade',
index=True)
odoo_id = fields.Many2one(comodel_name='sale.order.line',
string='Sale Order Line',
required=True,
ondelete='cascade')
backend_id = fields.Many2one(related='opencart_order_id.backend_id',
string='Opencart Backend',
readonly=True,
store=True,
required=False)
@api.model
def create(self, vals):
opencart_order_id = vals['opencart_order_id']
binding = self.env['opencart.sale.order'].browse(opencart_order_id)
vals['order_id'] = binding.odoo_id.id
binding = super(OpencartSaleOrderLine, self).create(vals)
return binding
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
opencart_bind_ids = fields.One2many(
comodel_name='opencart.sale.order.line',
inverse_name='odoo_id',
string="Opencart Bindings",
)
class SaleOrderAdapter(Component):
_name = 'opencart.sale.order.adapter'
_inherit = 'opencart.adapter'
_apply_on = 'opencart.sale.order'
def search(self, filters=None):
api_instance = self.api_instance
api_filters = {}
if 'after_id' in filters:
api_filters['id_larger_than'] = filters['after_id']
if 'modified_from' in filters:
api_filters['modified_from'] = filters['modified_from']
orders_response = api_instance.orders.all(**api_filters)
if 'error' in orders_response and orders_response['error']:
raise ValidationError(str(orders_response))
if 'data' not in orders_response or not isinstance(orders_response['data'], list):
return []
orders = orders_response['data']
# Note that `store_id is None` is checked as it may not be in the output.
return map(lambda o: (o['order_id'], o.get('store_id', None), o.get('date_modified') or o.get('date_added')), orders)
def read(self, id):
api_instance = self.api_instance
record = api_instance.orders.get(id)
if 'data' in record and record['data']:
return record['data']
raise RetryableJobError('Order "' + str(id) + '" did not return an order response. ' + str(record))

View File

@@ -0,0 +1,450 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from copy import copy
from html import unescape
import logging
from odoo import fields, _
from odoo.addons.component.core import Component
from odoo.addons.connector.components.mapper import mapping
from odoo.exceptions import ValidationError
from odoo.addons.queue_job.exception import RetryableJobError
_logger = logging.getLogger(__name__)
class SaleOrderBatchImporter(Component):
_name = 'opencart.sale.order.batch.importer'
_inherit = 'opencart.delayed.batch.importer'
_apply_on = 'opencart.sale.order'
def _import_record(self, external_id, store_id, job_options=None, **kwargs):
if not job_options:
job_options = {
'max_retries': 0,
'priority': 5,
}
# It is very likely that we already have this order because we may have just uploaded a tracking number
# We want to avoid creating queue jobs for orders already imported.
order_binder = self.binder_for('opencart.sale.order')
order = order_binder.to_internal(external_id)
if order:
_logger.warning('Order (%s) already imported.' % (order.name, ))
return
if store_id is not None:
store_binder = self.binder_for('opencart.store')
store = store_binder.to_internal(store_id).sudo()
if not store.enable_order_import:
_logger.warning('Store (%s) is not enabled for Sale Order import (%s).' % (store.name, external_id))
return
user = store.warehouse_id.company_id.user_tech_id
if user and user != self.env.user:
# Note that this is a component, which has an env through it's 'collection'
# however, when importing the 'model' is actually what runs the delayed job
env = self.env(user=user)
self.collection.env = env
self.model.env = env
return super(SaleOrderBatchImporter, self)._import_record(
external_id, job_options=job_options)
def run(self, filters=None):
""" Run the synchronization """
if filters is None:
filters = {}
external_ids = list(self.backend_adapter.search(filters))
for ids in external_ids:
_logger.debug('run._import_record for %s' % (ids, ))
self._import_record(ids[0], ids[1])
if external_ids:
last_id = list(sorted(external_ids, key=lambda i: i[0]))[-1][0]
last_date = list(sorted(external_ids, key=lambda i: i[2]))[-1][2]
self.backend_record.write({
'import_orders_after_id': last_id,
'import_orders_after_date': self.backend_record.date_to_odoo(last_date),
})
class SaleOrderImportMapper(Component):
_name = 'opencart.sale.order.mapper'
_inherit = 'opencart.import.mapper'
_apply_on = 'opencart.sale.order'
direct = [('order_id', 'external_id'),
('store_id', 'store_id'),
('comment', 'note'),
]
children = [('products', 'opencart_order_line_ids', 'opencart.sale.order.line'),
]
def _add_coupon_lines(self, map_record, values):
# Data from API
# 'coupons': [{'amount': '7.68', 'code': '1111'}],
record = map_record.source
coupons = record.get('coupons')
if not coupons:
return values
coupon_product = self.options.store.coupon_product_id or self.backend_record.coupon_product_id
if not coupon_product:
coupon_product = self.env.ref('connector_ecommerce.product_product_discount', raise_if_not_found=False)
if not coupon_product:
raise ValueError('Coupon %s on order requires coupon product in configuration.' % (coupons, ))
for coupon in coupons:
line_builder = self.component(usage='order.line.builder')
line_builder.price_unit = -float(coupon.get('amount', 0.0))
line_builder.product = coupon_product
# `order.line.builder` does not allow naming.
line_values = line_builder.get_line()
code = coupon.get('code')
if code:
line_values['name'] = '%s Code: %s' % (coupon_product.name, code)
values['order_line'].append((0, 0, line_values))
return values
def _add_shipping_line(self, map_record, values):
record = map_record.source
line_builder = self.component(usage='order.line.builder.shipping')
line_builder.price_unit = record.get('shipping_exclude_tax', 0.0)
if values.get('carrier_id'):
carrier = self.env['delivery.carrier'].browse(values['carrier_id'])
line_builder.product = carrier.product_id
line = (0, 0, line_builder.get_line())
values['order_line'].append(line)
return values
def finalize(self, map_record, values):
values.setdefault('order_line', [])
self._add_coupon_lines(map_record, values)
self._add_shipping_line(map_record, values)
values.update({
'partner_id': self.options.partner_id,
'partner_invoice_id': self.options.partner_invoice_id,
'partner_shipping_id': self.options.partner_shipping_id,
})
onchange = self.component(
usage='ecommerce.onchange.manager.sale.order'
)
# will I need more?!
return onchange.play(values, values['opencart_order_line_ids'])
@mapping
def name(self, record):
name = str(record['order_id'])
prefix = self.options.store.sale_prefix or self.backend_record.sale_prefix
if prefix:
name = prefix + name
return {'name': name}
@mapping
def date_order(self, record):
date_added = record.get('date_added')
if date_added:
date_added = self.backend_record.date_to_odoo(date_added)
return {'date_order': date_added or fields.Datetime.now()}
@mapping
def fiscal_position_id(self, record):
fiscal_position = self.options.store.fiscal_position_id or self.backend_record.fiscal_position_id
if fiscal_position:
return {'fiscal_position_id': fiscal_position.id}
@mapping
def team_id(self, record):
team = self.options.store.team_id or self.backend_record.team_id
if team:
return {'team_id': team.id}
@mapping
def payment_mode_id(self, record):
record_method = record['payment_method']
method = self.env['account.payment.mode'].search(
[('name', '=', record_method)],
limit=1,
)
if not method:
raise ValueError('Payment Mode named "%s", cannot be found.' % (record_method, ))
return {'payment_mode_id': method.id}
@mapping
def project_id(self, record):
analytic_account = self.options.store.analytic_account_id or self.backend_record.analytic_account_id
if analytic_account:
return {'project_id': analytic_account.id}
@mapping
def warehouse_id(self, record):
warehouse = self.options.store.warehouse_id or self.backend_record.warehouse_id
if warehouse:
return {'warehouse_id': warehouse.id}
@mapping
def shipping_code(self, record):
method = record.get('shipping_code') or record.get('shipping_method')
if not method:
return {'carrier_id': False}
carrier_domain = [('opencart_code', '=', method.strip())]
company = self.options.store.company_id or self.backend_record.company_id
if company:
carrier_domain += [
'|', ('company_id', '=', company.id), ('company_id', '=', False)
]
carrier = self.env['delivery.carrier'].search(carrier_domain, limit=1)
if not carrier:
raise ValueError('Delivery Carrier for method Code "%s", cannot be found.' % (method, ))
return {'carrier_id': carrier.id}
@mapping
def company_id(self, record):
company = self.options.store.company_id or self.backend_record.company_id
if not company:
raise ValidationError('Company not found in Opencart Backend or Store')
return {'company_id': company.id}
@mapping
def backend_id(self, record):
return {'backend_id': self.backend_record.id}
@mapping
def total_amount(self, record):
total_amount = record['total']
return {'total_amount': total_amount}
class SaleOrderImporter(Component):
_name = 'opencart.sale.order.importer'
_inherit = 'opencart.importer'
_apply_on = 'opencart.sale.order'
def _must_skip(self):
if self.binder.to_internal(self.external_id):
return _('Already imported')
def _before_import(self):
# Check if status is ok, etc. on self.opencart_record
pass
def _create_partner(self, values):
return self.env['res.partner'].create(values)
def _partner_matches(self, partner, values):
for key, value in values.items():
if key in ('active', 'parent_id', 'type'):
continue
if key == 'state_id':
if value != partner.state_id.id:
return False
elif key == 'country_id':
if value != partner.country_id.id:
return False
elif bool(value) and isinstance(value, str):
if value.lower() != str(getattr(partner, key)).lower():
return False
elif bool(value) and value != getattr(partner, key):
return False
return True
def _make_partner_name(self, firstname, lastname):
name = (str(firstname or '').strip() + ' ' + str(lastname or '').strip()).strip()
if not name:
return 'Undefined'
return name
def _get_partner_values(self, info_string='shipping_'):
record = self.opencart_record
# find or make partner with these details.
email = record.get('email')
if not email:
raise ValueError('Order does not have email in : ' + str(record))
phone = record.get('telephone', False)
info = {}
for k, v in record.items():
# Strip the info_string so that the remainder of the code depends on it.
if k.find(info_string) == 0:
info[k[len(info_string):]] = v
name = self._make_partner_name(info.get('firstname', ''), info.get('lastname', ''))
street = info.get('address_1', '')
street2 = info.get('address_2', '')
city = info.get('city', '')
state_code = info.get('zone_code', '')
zip_ = info.get('postcode', '')
country_code = info.get('iso_code_2', '')
country = self.env['res.country'].search([('code', '=', country_code)], limit=1)
state = self.env['res.country.state'].search([
('country_id', '=', country.id),
('code', '=', state_code)
], limit=1)
return {
'email': email.strip(),
'name': name.strip(),
'phone': phone.strip(),
'street': street.strip(),
'street2': street2.strip(),
'zip': zip_.strip(),
'city': city.strip(),
'state_id': state.id,
'country_id': country.id,
}
def _import_addresses(self):
partner_values = self._get_partner_values()
# If they only buy services, then the shipping details will be empty
if partner_values.get('name', 'Undefined') == 'Undefined':
partner_values = self._get_partner_values(info_string='payment_')
partners = self.env['res.partner'].search([
('email', '=ilike', partner_values['email']),
'|', ('active', '=', False), ('active', '=', True),
], order='active DESC, id ASC')
partner = None
for possible in partners:
if self._partner_matches(possible, partner_values):
partner = possible
break
if not partner and partners:
partner = partners[0]
if not partner:
# create partner.
partner = self._create_partner(copy(partner_values))
if not self._partner_matches(partner, partner_values):
partner_values['parent_id'] = partner.id
shipping_values = copy(partner_values)
shipping_values['type'] = 'delivery'
shipping_partner = self._create_partner(shipping_values)
else:
shipping_partner = partner
invoice_values = self._get_partner_values(info_string='payment_')
invoice_values['type'] = 'invoice'
if (not self._partner_matches(partner, invoice_values)
and not self._partner_matches(shipping_partner, invoice_values)):
# Try to find existing invoice address....
for possible in partners:
if self._partner_matches(possible, invoice_values):
invoice_partner = possible
break
else:
invoice_values['parent_id'] = partner.id
invoice_partner = self._create_partner(copy(invoice_values))
elif self._partner_matches(partner, invoice_values):
invoice_partner = partner
elif self._partner_matches(shipping_partner, invoice_values):
invoice_partner = shipping_partner
self.partner = partner
self.shipping_partner = shipping_partner
self.invoice_partner = invoice_partner
def _check_special_fields(self):
assert self.partner, (
"self.partner should have been defined "
"in SaleOrderImporter._import_addresses")
assert self.shipping_partner, (
"self.shipping_partner should have been defined "
"in SaleOrderImporter._import_addresses")
assert self.invoice_partner, (
"self.invoice_partner should have been defined "
"in SaleOrderImporter._import_addresses")
def _get_store(self, record):
store_binder = self.binder_for('opencart.store')
return store_binder.to_internal(record['store_id'])
def _create_data(self, map_record, **kwargs):
# non dependencies
# our current handling of partners doesn't require anything special for the store
self._check_special_fields()
store = self._get_store(map_record.source)
return super(SaleOrderImporter, self)._create_data(
map_record,
partner_id=self.partner.id,
partner_invoice_id=self.invoice_partner.id,
partner_shipping_id=self.shipping_partner.id,
store=store,
**kwargs
)
def _order_comment_review(self, binding):
review_group = self.env.ref('connector_opencart.group_order_comment_review', raise_if_not_found=False)
if review_group and binding.note:
activity_type = self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False)
activity_type_id = activity_type.id if activity_type else False
for user in review_group.users:
self.env['mail.activity'].create({
'activity_type_id': activity_type_id,
'summary': 'Order Comment Review',
'note': '<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}

View File

@@ -0,0 +1,2 @@
from . import common
from . import exporter

View File

@@ -0,0 +1,93 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models, fields, _
from odoo.addons.queue_job.job import job, related_action
from odoo.addons.component.core import Component
from odoo.addons.queue_job.exception import RetryableJobError
class OpencartStockPicking(models.Model):
_name = 'opencart.stock.picking'
_inherit = 'opencart.binding'
_inherits = {'stock.picking': 'odoo_id'}
_description = 'Opencart Delivery Order'
odoo_id = fields.Many2one(comodel_name='stock.picking',
string='Stock Picking',
required=True,
ondelete='cascade')
opencart_order_id = fields.Many2one(comodel_name='opencart.sale.order',
string='Opencart Sale Order',
ondelete='set null')
@job(default_channel='root.opencart')
@related_action(action='related_action_unwrap_binding')
@api.multi
def export_picking_done(self):
""" Export a complete or partial delivery order. """
self.ensure_one()
with self.backend_id.work_on(self._name) as work:
exporter = work.component(usage='record.exporter')
return exporter.run(self)
class StockPicking(models.Model):
_inherit = 'stock.picking'
opencart_bind_ids = fields.One2many(
comodel_name='opencart.stock.picking',
inverse_name='odoo_id',
string="Opencart Bindings",
)
class StockPickingAdapter(Component):
_name = 'opencart.stock.picking.adapter'
_inherit = 'opencart.adapter'
_apply_on = 'opencart.stock.picking'
def create(self, id, tracking):
api_instance = self.api_instance
tracking_comment = _('Order shipped with tracking number: %s') % (tracking, )
result = api_instance.orders.ship(id, tracking, tracking_comment)
if 'success' in result:
return result['success']
raise RetryableJobError('Shipping Order %s did not return an order response. (tracking: %s) %s' % (
str(id), str(tracking), str(result)))
class OpencartBindingStockPickingListener(Component):
_name = 'opencart.binding.stock.picking.listener'
_inherit = 'base.event.listener'
_apply_on = ['opencart.stock.picking']
def on_record_create(self, record, fields=None):
record.with_delay().export_picking_done()
class OpencartStockPickingListener(Component):
_name = 'opencart.stock.picking.listener'
_inherit = 'base.event.listener'
_apply_on = ['stock.picking']
def on_picking_dropship_done(self, record, picking_method):
return self.on_picking_out_done(record, picking_method)
def on_picking_out_done(self, record, picking_method):
"""
Create a ``opencart.stock.picking`` record. This record will then
be exported to Opencart.
:param picking_method: picking_method, can be 'complete' or 'partial'
:type picking_method: str
"""
sale = record.sale_id
if not sale:
return
for opencart_sale in sale.opencart_bind_ids:
self.env['opencart.stock.picking'].create({
'backend_id': opencart_sale.backend_id.id,
'odoo_id': record.id,
'opencart_order_id': opencart_sale.id,
})

View File

@@ -0,0 +1,36 @@
# © 2019 Hibou Corp.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.addons.component.core import Component
from odoo.addons.queue_job.exception import NothingToDoJob
class OpencartPickingExporter(Component):
_name = 'opencart.stock.picking.exporter'
_inherit = 'opencart.exporter'
_apply_on = ['opencart.stock.picking']
def _get_id(self, binding):
sale_binder = self.binder_for('opencart.sale.order')
opencart_sale_id = sale_binder.to_external(binding.opencart_order_id)
return opencart_sale_id
def _get_tracking(self, binding):
return binding.carrier_tracking_ref or ''
def run(self, binding):
"""
Export the picking to Opencart
:param binding: opencart.stock.picking
:return:
"""
if binding.external_id:
return 'Already exported'
tracking = self._get_tracking(binding)
if not tracking:
raise NothingToDoJob('Cancelled: the delivery order does not contain tracking.')
id = self._get_id(binding)
_ = self.backend_adapter.create(id, tracking)
# Cannot bind because shipments do not have ID's in Opencart
#self.binder.bind(external_id, binding)

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_opencart_backend opencart_backend connector manager model_opencart_backend connector.group_connector_manager 1 1 1 1
3 access_opencart_store opencart_store connector manager model_opencart_store connector.group_connector_manager 1 1 1 1
4 access_opencart_binding opencart_binding connector manager model_opencart_binding connector.group_connector_manager 1 1 1 1
5 access_opencart_sale_order opencart_sale_order connector manager model_opencart_sale_order connector.group_connector_manager 1 1 1 1
6 access_opencart_sale_order_line opencart_sale_order_line connector manager model_opencart_sale_order_line connector.group_connector_manager 1 1 1 1
7 access_opencart_product_template opencart_product_template connector manager model_opencart_product_template connector.group_connector_manager 1 1 1 1
8 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
9 access_opencart_stock_picking opencart_stock_picking connector manager model_opencart_stock_picking connector.group_connector_manager 1 1 1 1
10 access_opencart_sale_order_sale_salesman opencart_sale_order model_opencart_sale_order sales_team.group_sale_salesman 1 0 0 0
11 access_opencart_sale_order_sale_manager opencart_sale_order model_opencart_sale_order sales_team.group_sale_manager 1 1 1 1
12 access_opencart_sale_order_stock_user opencart_sale_order warehouse user model_opencart_sale_order stock.group_stock_user 1 0 0 0
13 access_opencart_backend_user opencart_backend user model_opencart_backend sales_team.group_sale_salesman 1 0 0 0
14 access_opencart_stock_picking_user opencart_stock_picking user model_opencart_stock_picking stock.group_stock_user 1 1 1 0
15 access_opencart_product_template_user opencart_product_template user model_opencart_product_template base.group_user 1 0 0 0

View 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>

View File

@@ -0,0 +1,176 @@
<?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_type">form</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_type">form</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>

View File

@@ -0,0 +1,56 @@
<?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="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_type">form</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>

View 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>

View 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>