1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/sale_amazon/models/amazon_account.py
2024-12-10 09:04:09 +07:00

1368 lines
65 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from datetime import timedelta
import dateutil.parser
import psycopg2
from markupsafe import Markup
from werkzeug import urls
from odoo import _, api, exceptions, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY as CONCURRENCY_ERRORS
from odoo.addons.sale_amazon import const, utils as amazon_utils
from odoo.addons.sale_amazon.controllers.onboarding import compute_oauth_signature
_logger = logging.getLogger(__name__)
class AmazonAccount(models.Model):
_name = 'amazon.account'
_description = "Amazon Account"
_check_company_auto = True
name = fields.Char(string="Name", help="The user-defined name of the account.", required=True)
offer_ids = fields.One2many(
string="Offers", comodel_name='amazon.offer', inverse_name='account_id', auto_join=True
)
# Credentials fields.
seller_key = fields.Char()
refresh_token = fields.Char(
string="LWA Refresh Token",
help="The long-lived token that can be exchanged for a new access token.",
)
# The API credentials fields below are not stored because they are all short-lived. Their values
# are kept in memory for the duration of the request and they are re-used as long as they are
# not expired. If that happens, they are refreshed through an API call.
access_token = fields.Char(
string="LWA Access Token",
help="The short-lived token used to query Amazon API on behalf of a seller.",
store=False,
)
access_token_expiry = fields.Datetime(
string="The moment at which the token becomes invalid.", default='1970-01-01', store=False
)
aws_access_key = fields.Char(
string="AWS Access Key",
help="The short-lived key used to identify the assumed ARN role on AWS.",
store=False,
)
aws_secret_key = fields.Char(
string="AWS Secret Key",
help="The short-lived key used to verify the access to the assumed ARN role on AWS.",
store=False,
)
aws_session_token = fields.Char(
string="AWS Session Token",
help="The short-lived token used to query the SP-API with the assumed ARN role on AWS.",
store=False,
)
aws_credentials_expiry = fields.Datetime(
string="The moment at which the AWS credentials become invalid.",
default='1970-01-01',
store=False,
)
restricted_data_token = fields.Char(
string="Restricted Data Token",
help="The short-lived token used instead of the LWA Access Token to access restricted data",
store=False,
)
restricted_data_token_expiry = fields.Datetime(
string="The moment at which the Restricted Data Token becomes invalid.",
default='1970-01-01',
store=False,
)
# Marketplace fields.
base_marketplace_id = fields.Many2one(
string="Home Marketplace",
help="The home marketplace of this account; used for authentication only.",
comodel_name='amazon.marketplace',
required=True,
)
available_marketplace_ids = fields.Many2many(
string="Available Marketplaces",
help="The marketplaces this account has access to.",
comodel_name='amazon.marketplace',
relation='amazon_account_marketplace_rel',
copy=False,
)
active_marketplace_ids = fields.Many2many(
string="Sync Marketplaces",
help="The marketplaces this account sells on.",
comodel_name='amazon.marketplace',
relation='amazon_account_active_marketplace_rel',
domain="[('id', 'in', available_marketplace_ids)]",
copy=False,
)
# Follow-up fields.
user_id = fields.Many2one(
string="Salesperson",
comodel_name='res.users',
default=lambda self: self.env.user,
check_company=True,
)
team_id = fields.Many2one(
string="Sales Team",
help="The Sales Team assigned to Amazon orders for reporting",
comodel_name='crm.team',
check_company=True,
)
company_id = fields.Many2one(
string="Company",
comodel_name='res.company',
default=lambda self: self.env.company,
required=True,
readonly=True,
)
location_id = fields.Many2one(
string="Stock Location",
help="The location of the stock managed by Amazon under the Amazon Fulfillment program.",
comodel_name='stock.location',
domain="[('usage', '=', 'internal')]",
check_company=True,
)
active = fields.Boolean(
string="Active",
help="If made inactive, this account will no longer be synchronized with Amazon.",
default=True,
required=True,
)
synchronize_inventory = fields.Boolean(
string="Synchronize FBM Inventory",
help="Whether the available quantities of FBM products linked to this account are"
" synchronized with Amazon.",
default=True,
)
last_orders_sync = fields.Datetime(
help="The last synchronization date for orders placed on this account. Orders whose status "
"has not changed since this date will not be created nor updated in Odoo.",
default=fields.Datetime.now,
required=True,
)
# Display fields.
order_count = fields.Integer(compute='_compute_order_count')
offer_count = fields.Integer(compute='_compute_offer_count')
is_follow_up_displayed = fields.Boolean(compute='_compute_is_follow_up_displayed')
#=== COMPUTE METHODS ===#
def _compute_order_count(self):
for account in self:
account.order_count = self.env['sale.order.line'].search_count([('amazon_offer_id.account_id', '=', account.id)])
def _compute_offer_count(self):
offers_data = self.env['amazon.offer']._read_group(
[('account_id', 'in', self.ids)], ['account_id'], ['__count']
)
accounts_data = {account.id: count for account, count in offers_data}
for account in self:
account.offer_count = accounts_data.get(account.id, 0)
@api.depends('company_id') # Trick to compute the field on new records
def _compute_is_follow_up_displayed(self):
""" Return True is the page Order Follow-up should be displayed in the view form. """
for account in self:
account.is_follow_up_displayed = account._origin.id or self.user_has_groups(
'base.group_multi_company,base.group_no_one'
)
#=== ONCHANGE METHODS ===#
@api.onchange('last_orders_sync')
def _onchange_last_orders_sync(self):
""" Display a warning about the possible consequences of modifying the last orders sync. """
self.ensure_one()
if self._origin.id:
return {
'warning': {
'title': _("Warning"),
'message': _("If the date is set in the past, orders placed on this Amazon "
"Account before the first synchronization of the module might be "
"synchronized with Odoo.\n"
"If the date is set in the future, orders placed on this Amazon "
"Account between the previous and the new date will not be "
"synchronized with Odoo.")
}
}
#=== CONSTRAINT METHODS ===#
@api.constrains('active_marketplace_ids')
def _check_actives_subset_of_availables(self):
for account in self:
if account.active_marketplace_ids.filtered(
lambda m: m.id not in account.available_marketplace_ids.ids):
raise exceptions.ValidationError(_("Only available marketplaces can be selected"))
#=== CRUD METHODS ===#
@api.model_create_multi
def create(self, vals_list):
amazon_accounts_rg = self._read_group([], ['team_id', 'location_id'])
amazon_teams_ids = [team.id for team, __ in amazon_accounts_rg]
amazon_locations_ids = [location.id for __, location in amazon_accounts_rg]
for vals in vals_list:
# Find or create the location of the Amazon warehouse to be associated with this account
location = self.env['stock.location'].search([
*self.env['stock.location']._check_company_domain(vals.get('company_id')),
('id', 'in', amazon_locations_ids),
], limit=1)
if not location:
parent_location_data = self.env['stock.warehouse'].search_read(
[*self.env['stock.warehouse']._check_company_domain(vals.get('company_id'))],
['view_location_id'],
limit=1,
)
location = self.env['stock.location'].create({
'name': 'Amazon',
'usage': 'internal',
'location_id': parent_location_data[0]['view_location_id'][0],
'company_id': vals.get('company_id'),
})
vals.update({'location_id': location.id})
# Find or create the sales team to be associated with this account
team = self.env['crm.team'].search([
*self.env['crm.team']._check_company_domain(vals.get('company_id')),
('id', 'in', amazon_teams_ids),
], limit=1)
if not team:
team = self.env['crm.team'].create({
'name': 'Amazon',
'company_id': vals.get('company_id'),
})
vals.update({'team_id': team.id})
return super().create(vals_list)
#=== ACTION METHODS ===#
def action_redirect_to_oauth_url(self):
""" Build the OAuth redirect URL and redirect the user to it.
See step 1 of https://developer-docs.amazon.com/sp-api/docs/website-authorization-workflow.
Note: self.ensure_one()
:return: An `ir.actions.act_url` action to redirect the user to the OAuth URL.
:rtype: dict
"""
self.ensure_one()
base_seller_central_url = self.base_marketplace_id.seller_central_url
oauth_url = urls.url_join(base_seller_central_url, '/apps/authorize/consent')
base_database_url = self.get_base_url()
metadata = {
'account_id': self.id,
'return_url': urls.url_join(base_database_url, 'amazon/return'),
'signature': compute_oauth_signature(self.id),
} # The metadata included in the redirect URL after authorizing the app on Amazon.
oauth_url_params = {
'application_id': const.APP_ID,
'state': json.dumps(metadata),
}
return {
'type': 'ir.actions.act_url',
'url': f'{oauth_url}?{urls.url_encode(oauth_url_params)}',
'target': 'self',
}
def action_reset_refresh_token(self):
""" Reset the refresh token of the account.
Note: self.ensure_one()
:return: None
"""
self.ensure_one()
self.refresh_token = None
def action_update_available_marketplaces(self):
""" Update available marketplaces and assign new ones to the account.
:return: A rainbow-man action to inform the user about the successful update.
:rtype: dict
"""
for account in self:
available_marketplaces = account._get_available_marketplaces()
new_marketplaces = available_marketplaces - account.available_marketplace_ids
account.write({'available_marketplace_ids': [(6, 0, available_marketplaces.ids)]})
# Remove active marketplace that are no longer available
account.active_marketplace_ids &= account.available_marketplace_ids
account.active_marketplace_ids += new_marketplaces
return {
'effect': {
'type': 'rainbow_man',
'message': _("Successfully updated the marketplaces available to this account!"),
}
}
def action_view_offers(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Offers'),
'res_model': 'amazon.offer',
'view_mode': 'tree',
'domain': [('account_id', '=', self.id)],
'context': {'default_account_id': self.id},
}
def action_view_orders(self):
self.ensure_one()
order_lines = self.env['sale.order.line'].search(
[('amazon_offer_id', '!=', False), ('amazon_offer_id.account_id', '=', self.id)]
)
return {
'type': 'ir.actions.act_window',
'name': _('Orders'),
'res_model': 'sale.order',
'view_mode': 'tree,form',
'domain': [('id', 'in', order_lines.order_id.ids)],
'context': {'create': False},
}
def action_sync_orders(self):
self._sync_orders()
def action_sync_pickings(self):
self.env['stock.picking']._sync_pickings(tuple(self.ids))
def action_sync_inventory(self):
self._sync_inventory()
def action_sync_feeds_status(self):
self._sync_feeds()
def action_recover_order(self):
self.ensure_one()
return {
'name': _("Recover Order"),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'amazon.recover.order.wizard',
'target': 'new',
}
#=== BUSINESS METHODS ===#
def _get_available_marketplaces(self):
""" Fetch the API refs of the available marketplaces and return the corresponding recordset.
Note: self.ensure_one()
:return: The available marketplaces for the Amazon account.
:rtype: recordset of `amazon.marketplace`
:raise UserError: If the rate limit is reached.
"""
self.ensure_one()
amazon_utils.ensure_account_is_set_up(self, require_marketplaces=False)
try:
response_content = amazon_utils.make_sp_api_request(
self, 'getMarketplaceParticipations'
)
except amazon_utils.AmazonRateLimitError:
_logger.info(
"Rate limit reached while updating available marketplaces for Amazon account with "
"id %s.", self.id
)
raise UserError(_(
"You reached the maximum number of requests for this operation; please try again "
"later."
))
else:
available_marketplace_api_refs = [
marketplace['marketplace']['id'] for marketplace in response_content['payload']
]
return self.env['amazon.marketplace'].search(
[('api_ref', 'in', available_marketplace_api_refs)]
)
def _sync_orders(self, auto_commit=True):
""" Synchronize the accounts' sales orders that were recently updated on Amazon.
If called on an empty recordset, the orders of all active accounts are synchronized instead.
Note: This method is called by the `ir_cron_sync_amazon_orders` cron.
:param bool auto_commit: Whether the database cursor should be committed as soon as an order
is successfully synchronized.
:return: None
"""
accounts = self or self.search([])
for account in accounts:
account = account[0] # Avoid pre-fetching after each cache invalidation.
amazon_utils.ensure_account_is_set_up(account)
# The last synchronization date of the account is used as the lower limit on the orders'
# last status update date. The upper limit is determined by the API and returned with
# the request response, then saved on the account if the synchronization goes through.
last_updated_after = account.last_orders_sync # Lower limit for pulling orders.
status_update_upper_limit = None # Upper limit of synchronized orders.
# Pull all recently updated orders and save the progress during synchronization.
payload = {
'LastUpdatedAfter': last_updated_after.isoformat(sep='T'),
'MarketplaceIds': ','.join(account.active_marketplace_ids.mapped('api_ref')),
}
try:
# Orders are pulled in batches of up to 100 orders. If more can be synchronized, the
# request results are paginated and the next page holds another batch.
has_next_page = True
while has_next_page:
# Pull the next batch of orders data.
orders_batch_data, has_next_page = amazon_utils.pull_batch_data(
account, 'getOrders', payload
)
orders_data = orders_batch_data['Orders']
status_update_upper_limit = dateutil.parser.parse(
orders_batch_data['LastUpdatedBefore']
)
# Process the batch one order data at a time.
for order_data in orders_data:
try:
if auto_commit:
with self.env.cr.savepoint():
account._process_order_data(order_data)
else: # Avoid the savepoint in testing
account._process_order_data(order_data)
except amazon_utils.AmazonRateLimitError:
raise # Don't treat a rate limit error as a business error.
except Exception as error:
amazon_order_ref = order_data['AmazonOrderId']
if isinstance(error, psycopg2.OperationalError) \
and error.pgcode in CONCURRENCY_ERRORS:
_logger.info(
"A concurrency error occurred while processing the order data "
"with amazon_order_ref %s for Amazon account with id %s. "
"Discarding the error to trigger the retry mechanism.",
amazon_order_ref, account.id
)
# Let the error bubble up so that either the request can be retried
# up to 5 times or the cron job rollbacks the cursor and reschedules
# itself later, depending on which of the two called this method.
raise
else:
_logger.warning(
"A business error occurred while processing the order data "
"with amazon_order_ref %s for Amazon account with id %s. "
"Skipping the order data and moving to the next order.",
amazon_order_ref, account.id,
exc_info=True
)
# Dismiss business errors to allow the synchronization to skip the
# problematic orders and require synchronizing them manually.
self.env.cr.rollback()
account._handle_sync_failure(
flow='order_sync', amazon_order_ref=amazon_order_ref
)
continue # Skip these order data and resume with the next ones.
# The synchronization of this order went through, use its last status update
# as a backup and set it to be the last synchronization date of the account.
last_order_update = dateutil.parser.parse(order_data['LastUpdateDate'])
account.last_orders_sync = last_order_update.replace(tzinfo=None)
if auto_commit:
with amazon_utils.preserve_credentials(account):
self.env.cr.commit() # Commit to mitigate an eventual cron kill.
except amazon_utils.AmazonRateLimitError as error:
_logger.info(
"Rate limit reached while synchronizing sales orders for Amazon account with "
"id %s. Operation: %s", account.id, error.operation
)
continue # The remaining orders will be pulled later when the cron runs again.
# There are no more orders to pull and the synchronization went through. Set the API
# upper limit on order status update to be the last synchronization date of the account.
account.last_orders_sync = status_update_upper_limit.replace(tzinfo=None)
def _sync_order_by_reference(self, amazon_order_ref):
""" Synchronize an order based on its Amazon order reference.
Note: `self.ensure_one()`
:param str amazon_order_ref: The amazon reference of the order to re-synchronize.
:return: The synchronized Amazon order act window.
:rtype: dict
:raise UserError: If the order reference is incorrect or the order is not for an active
marketplace.
:raise ValidationError: If the order is in a status that prevents its synchronization.
"""
self.ensure_one()
amazon_utils.ensure_account_is_set_up(self)
order_data = amazon_utils.make_sp_api_request(
self, 'getOrder', path_parameter=amazon_order_ref
)['payload']
if not order_data: # Order not found by Amazon
raise UserError(_("The provided reference does not match any Amazon order."))
if order_data['MarketplaceId'] not in self.active_marketplace_ids.mapped('api_ref'):
raise UserError(_("The order was not found on this account's marketplaces."))
order = self._process_order_data(order_data)
if not order:
amazon_status = order_data['OrderStatus']
fulfillment_channel = order_data['FulfillmentChannel']
raise ValidationError(_(
"The Amazon order with reference %(ref)s was not recovered because its status"
" (%(status)s) is not eligible for synchronization for its fulfillment channel"
" (%(channel)s).",
ref=amazon_order_ref,
status=amazon_status,
channel=fulfillment_channel,
))
return {
'name': order.display_name,
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': order.id,
}
def _process_order_data(self, order_data):
""" Process the provided order data and return the matching sales order, if any.
If no matching sales order is found, a new one is created if it is in a 'synchronizable'
status: 'Shipped' or 'Unshipped', if it is respectively an FBA or an FBA order. If the
matching sales order already exists and the Amazon order was canceled, the sales order is
also canceled. If the matching sales order already exists and the order data confirm that a
FBM order got shipped, we update the shipping status when it's needed.
Note: self.ensure_one()
:param dict order_data: The order data to process.
:return: The matching Amazon order, if any, as a `sale.order` record.
:rtype: recordset of `sale.order`
"""
self.ensure_one()
# Search for the sales order based on its Amazon order reference.
amazon_order_ref = order_data['AmazonOrderId']
order = self.env['sale.order'].search(
[('amazon_order_ref', '=', amazon_order_ref)], limit=1
)
amazon_status = order_data['OrderStatus']
fulfillment_channel = order_data['FulfillmentChannel']
if not order: # No sales order was found with the given Amazon order reference.
if amazon_status in const.STATUS_TO_SYNCHRONIZE[fulfillment_channel]:
# Create the sales order and generate stock moves depending on the Amazon channel.
order = self._create_order_from_data(order_data)
if order.amazon_channel == 'fba':
self._generate_stock_moves(order)
elif order.amazon_channel == 'fbm':
order.with_context(mail_notrack=True).action_lock()
_logger.info(
"Created a new sales order with amazon_order_ref %(ref)s for Amazon account"
" with id %(id)s.", {'ref': amazon_order_ref, 'id': self.id}
)
else:
_logger.info(
"Ignored Amazon order with reference %(ref)s and status %(status)s for Amazon"
" account with id %(account_id)s.",
{'ref': amazon_order_ref, 'status': amazon_status, 'account_id': self.id},
)
else: # The sales order already exists.
unsynced_pickings = order.picking_ids.filtered(
lambda picking: picking.amazon_sync_status != 'done' and picking.state != 'cancel'
) # Consider any "unsynced" status so that we synchronize updates made from Amazon.
if amazon_status == 'Canceled' and order.state != 'cancel':
order._action_cancel()
_logger.info(
"Canceled sales order with amazon_order_ref %(ref)s for Amazon account with id"
" %(id)s.", {'ref': amazon_order_ref, 'id': self.id}
)
elif amazon_status == 'Shipped' and fulfillment_channel == 'MFN' and unsynced_pickings:
# The processing of the feed of a batch of pickings can fail on Amazon side in a way
# that we cannot tell which picking is faulty. In that case, all pickings of the
# batch are flagged as in error. The order status update allows correcting the
# status of non-faulty pickings while leaving the faulty one in error.
unsynced_pickings.amazon_sync_status = 'done'
_logger.info(
"Forced the picking synchronization status to 'done' for sales order with"
" Amazon order reference %(ref)s and Amazon account with id %(id)s.",
{'ref': amazon_order_ref, 'id': self.id},
)
else:
_logger.info(
"Ignored already synchronized sales order with amazon_order_ref %(ref)s for"
" Amazon account with id %(id)s.", {'ref': amazon_order_ref, 'id': self.id}
)
return order
def _create_order_from_data(self, order_data):
""" Create a new sales order based on the provided order data.
Note: self.ensure_one()
:param dict order_data: The order data to create a sales order from.
:return: The newly created sales order.
:rtype: record of `sale.order`
"""
self.ensure_one()
order_vals = self._prepare_order_values(order_data)
return self.env['sale.order'].with_context(
mail_create_nosubscribe=True
).with_company(self.company_id).create(order_vals)
def _prepare_order_values(self, order_data):
# Prepare the order line values.
shipping_code = order_data.get('ShipServiceLevel')
shipping_product = self._find_matching_product(
shipping_code, 'shipping_product', 'Shipping', 'service'
)
currency = self.env['res.currency'].with_context(active_test=False).search(
[('name', '=', order_data['OrderTotal']['CurrencyCode'])], limit=1
)
amazon_order_ref = order_data['AmazonOrderId']
contact_partner, delivery_partner = self._find_or_create_partners_from_data(order_data)
fiscal_position = self.env['account.fiscal.position'].with_company(
self.company_id
)._get_fiscal_position(contact_partner, delivery_partner)
order_lines_values = self._prepare_order_lines_values(
order_data, currency, fiscal_position, shipping_product
)
fulfillment_channel = order_data['FulfillmentChannel']
purchase_date = dateutil.parser.parse(order_data['PurchaseDate']).replace(tzinfo=None)
order_vals = {
'origin': f"Amazon Order {amazon_order_ref}",
'state': 'sale',
# The order is first created unlocked and later locked to trigger the creation of a
# stock picking if fulfilled by merchant.
'locked': fulfillment_channel == 'AFN',
'date_order': purchase_date,
'partner_id': contact_partner.id,
'pricelist_id': self._find_or_create_pricelist(currency).id,
'order_line': [(0, 0, order_line_values) for order_line_values in order_lines_values],
'invoice_status': 'no',
'partner_shipping_id': delivery_partner.id,
'require_signature': False,
'require_payment': False,
'fiscal_position_id': fiscal_position.id,
'company_id': self.company_id.id,
'user_id': self.user_id.id,
'team_id': self.team_id.id,
'amazon_order_ref': amazon_order_ref,
'amazon_channel': 'fba' if fulfillment_channel == 'AFN' else 'fbm',
}
if fulfillment_channel == 'AFN' and self.location_id.warehouse_id:
order_vals['warehouse_id'] = self.location_id.warehouse_id.id
return order_vals
def _find_or_create_partners_from_data(self, order_data):
""" Find or create the contact and delivery partners based on the provided order data.
Note: self.ensure_one()
:param dict order_data: The order data to find or create the partners from.
:return: The contact and delivery partners, as `res.partner` records. When the contact
partner acts as delivery partner, the records are the same.
:rtype: tuple[record of `res.partner`, record of `res.partner`]
"""
self.ensure_one()
amazon_order_ref = order_data['AmazonOrderId']
anonymized_email = order_data['BuyerInfo'].get('BuyerEmail', '')
buyer_name = order_data['BuyerInfo'].get('BuyerName', '')
fulfillment_channel = order_data['FulfillmentChannel']
shipping_address_info = order_data.get('ShippingAddress', {})
shipping_address_name = shipping_address_info.get('Name', '')
street = shipping_address_info.get('AddressLine1', '')
address_line2 = shipping_address_info.get('AddressLine2', '')
address_line3 = shipping_address_info.get('AddressLine3', '')
street2 = "%s %s" % (address_line2, address_line3) if address_line2 or address_line3 \
else None
zip_code = shipping_address_info.get('PostalCode', '')
city = shipping_address_info.get('City', '')
country_code = shipping_address_info.get('CountryCode', '')
state_code = shipping_address_info.get('StateOrRegion', '')
phone = shipping_address_info.get('Phone', '')
is_company = shipping_address_info.get('AddressType') == 'Commercial'
country = self.env['res.country'].search([('code', '=', country_code)], limit=1)
state = self.env['res.country.state'].search([
('country_id', '=', country.id),
'|', ('code', '=ilike', state_code), ('name', '=ilike', state_code),
], limit=1)
partner_vals = {
'street': street,
'street2': street2,
'zip': zip_code,
'city': city,
'country_id': country.id,
'state_id': state.id,
'phone': phone,
'customer_rank': 1,
'company_id': self.company_id.id,
'amazon_email': anonymized_email,
}
# The contact partner is searched based on all the personal information and only if the
# amazon email is provided. A match thus only occurs if the customer had already made a
# previous order and if the personal information provided by the API did not change in the
# meantime. If there is no match, a new contact partner is created. This behavior is
# preferred over updating the personal information with new values because it allows using
# the correct contact details when invoicing the customer for an earlier order, should there
# be a change in the personal information.
contact = self.env['res.partner'].search([
*self.env['res.partner']._check_company_domain(self.company_id),
('type', '=', 'contact'),
('name', '=', buyer_name),
('amazon_email', '=', anonymized_email),
], limit=1) if anonymized_email else None # Don't match random partners.
if not contact:
contact_name = buyer_name or f"Amazon Customer # {amazon_order_ref}"
contact = self.env['res.partner'].with_context(tracking_disable=True).create({
'name': contact_name,
'is_company': is_company,
**partner_vals,
})
if not contact.state_id and state_code and fulfillment_channel == 'MFN':
contact._amazon_create_activity_set_state(self.user_id.id, state_code)
# The contact partner acts as delivery partner if the address is strictly equal to that of
# the contact partner. If not, a delivery partner is created.
delivery = contact if (
contact.name == shipping_address_name
and contact.street == street
and (not contact.street2 or contact.street2 == street2)
and contact.zip == zip_code
and contact.city == city
and contact.country_id.id == country.id
and contact.state_id.id == state.id
) else None
if not delivery:
delivery = self.env['res.partner'].search([
*self.env['res.partner']._check_company_domain(self.company_id),
('parent_id', '=', contact.id),
('type', '=', 'delivery'),
('name', '=', shipping_address_name),
('street', '=', street),
'|', ('street2', '=', False), ('street2', '=', street2),
('zip', '=', zip_code),
('city', '=', city),
('country_id', '=', country.id),
('state_id', '=', state.id),
], limit=1)
if not delivery:
delivery = self.env['res.partner'].with_context(tracking_disable=True).create({
'name': shipping_address_name,
'type': 'delivery',
'parent_id': contact.id,
**partner_vals,
})
if not delivery.state_id and state_code and fulfillment_channel == 'MFN':
delivery._amazon_create_activity_set_state(self.user_id.id, state_code)
return contact, delivery
def _prepare_order_lines_values(self, order_data, currency, fiscal_pos, shipping_product):
""" Prepare the values for the order lines to create based on Amazon data.
Note: self.ensure_one()
:param dict order_data: The order data related to the item data.
:param record currency: The currency of the sales order, as a `res.currency` record.
:param record fiscal_pos: The fiscal position of the sales order, as an
`account.fiscal.position` record.
:param record shipping_product: The shipping product matching the shipping code, as a
`product.product` record.
:return: The order lines values.
:rtype: dict
"""
def pull_items_data(amazon_order_ref_):
""" Pull all item data for the order to synchronize.
:param str amazon_order_ref_: The Amazon reference of the order to synchronize.
:return: The items data.
:rtype: list
"""
items_data_ = []
# Order items are pulled in batches. If more order items than those returned can be
# synchronized, the request results are paginated and the next page holds another batch.
has_next_page_ = True
while has_next_page_:
# Pull the next batch of order items.
items_batch_data_, has_next_page_ = amazon_utils.pull_batch_data(
self, 'getOrderItems', {}, path_parameter=amazon_order_ref_
)
items_data_ += items_batch_data_['OrderItems']
return items_data_
self.ensure_one()
amazon_order_ref = order_data['AmazonOrderId']
marketplace_api_ref = order_data['MarketplaceId']
items_data = pull_items_data(amazon_order_ref)
order_lines_values = []
for item_data in items_data:
# Prepare the values for the product line.
sku = item_data['SellerSKU']
marketplace = self.active_marketplace_ids.filtered(
lambda m: m.api_ref == marketplace_api_ref
)
offer = self._find_or_create_offer(sku, marketplace)
product_taxes = offer.product_id.taxes_id.filtered_domain(
[*self.env['account.tax']._check_company_domain(self.company_id)]
)
main_condition = item_data.get('ConditionId')
sub_condition = item_data.get('ConditionSubtypeId')
if not main_condition or main_condition.lower() == 'new':
description = "[%s] %s" % (sku, item_data['Title'])
else:
item_title = item_data['Title']
description = _(
"[%s] %s\nCondition: %s - %s", sku, item_title, main_condition, sub_condition
)
sales_price = float(item_data.get('ItemPrice', {}).get('Amount', 0.0))
tax_amount = float(item_data.get('ItemTax', {}).get('Amount', 0.0))
original_subtotal = sales_price - tax_amount \
if marketplace.tax_included else sales_price
taxes = fiscal_pos.map_tax(product_taxes) if fiscal_pos else product_taxes
subtotal = self._recompute_subtotal(
original_subtotal, tax_amount, taxes, currency, fiscal_pos
)
promo_discount = float(item_data.get('PromotionDiscount', {}).get('Amount', '0'))
promo_disc_tax = float(item_data.get('PromotionDiscountTax', {}).get('Amount', '0'))
original_promo_discount_subtotal = promo_discount - promo_disc_tax \
if marketplace.tax_included else promo_discount
promo_discount_subtotal = self._recompute_subtotal(
original_promo_discount_subtotal, promo_disc_tax, taxes, currency, fiscal_pos
)
amazon_item_ref = item_data['OrderItemId']
order_lines_values.append(self._convert_to_order_line_values(
item_data=item_data,
product_id=offer.product_id.id,
description=description,
subtotal=subtotal,
tax_ids=taxes.ids,
quantity=item_data['QuantityOrdered'],
discount=promo_discount_subtotal,
amazon_item_ref=amazon_item_ref,
amazon_offer_id=offer.id,
))
# Prepare the values for the gift wrap line.
if item_data.get('IsGift', 'false') == 'true':
item_gift_info = item_data.get('BuyerInfo', {})
gift_wrap_code = item_gift_info.get('GiftWrapLevel')
gift_wrap_price = float(item_gift_info.get('GiftWrapPrice', {}).get('Amount', '0'))
if gift_wrap_code and gift_wrap_price != 0:
gift_wrap_product = self._find_matching_product(
gift_wrap_code, 'default_product', 'Amazon Sales', 'consu'
)
gift_wrap_product_taxes = gift_wrap_product.taxes_id.filtered_domain(
[*self.env['account.tax']._check_company_domain(self.company_id)]
)
gift_wrap_taxes = fiscal_pos.map_tax(gift_wrap_product_taxes) \
if fiscal_pos else gift_wrap_product_taxes
gift_wrap_tax_amount = float(
item_gift_info.get('GiftWrapTax', {}).get('Amount', '0')
)
original_gift_wrap_subtotal = gift_wrap_price - gift_wrap_tax_amount \
if marketplace.tax_included else gift_wrap_price
gift_wrap_subtotal = self._recompute_subtotal(
original_gift_wrap_subtotal,
gift_wrap_tax_amount,
gift_wrap_taxes,
currency,
fiscal_pos,
)
order_lines_values.append(self._convert_to_order_line_values(
item_data=item_data,
product_id=gift_wrap_product.id,
description=_(
"[%s] Gift Wrapping Charges for %s",
gift_wrap_code, offer.product_id.name
),
subtotal=gift_wrap_subtotal,
tax_ids=gift_wrap_taxes.ids,
))
gift_message = item_gift_info.get('GiftMessageText')
if gift_message:
order_lines_values.append(self._convert_to_order_line_values(
item_data=item_data,
description=_("Gift message:\n%s", gift_message),
display_type='line_note',
))
# Prepare the values for the delivery charges.
shipping_code = order_data.get('ShipServiceLevel')
shipping_price = float(item_data.get('ShippingPrice', {}).get('Amount', '0'))
if shipping_code and shipping_price != 0:
shipping_product_taxes = shipping_product.taxes_id.filtered_domain(
[*self.env['account.tax']._check_company_domain(self.company_id)]
)
shipping_taxes = fiscal_pos.map_tax(shipping_product_taxes) if fiscal_pos \
else shipping_product_taxes
shipping_tax_amount = float(item_data.get('ShippingTax', {}).get('Amount', '0'))
origin_ship_subtotal = shipping_price - shipping_tax_amount \
if marketplace.tax_included else shipping_price
shipping_subtotal = self._recompute_subtotal(
origin_ship_subtotal, shipping_tax_amount, shipping_taxes, currency, fiscal_pos
)
ship_discount = float(item_data.get('ShippingDiscount', {}).get('Amount', '0'))
ship_disc_tax = float(item_data.get('ShippingDiscountTax', {}).get('Amount', '0'))
origin_ship_disc_subtotal = ship_discount - ship_disc_tax \
if marketplace.tax_included else ship_discount
ship_discount_subtotal = self._recompute_subtotal(
origin_ship_disc_subtotal, ship_disc_tax, shipping_taxes, currency, fiscal_pos
)
order_lines_values.append(self._convert_to_order_line_values(
item_data=item_data,
product_id=shipping_product.id,
description=_(
"[%s] Delivery Charges for %s", shipping_code, offer.product_id.name
),
subtotal=shipping_subtotal,
tax_ids=shipping_taxes.ids,
discount=ship_discount_subtotal,
))
return order_lines_values
def _convert_to_order_line_values(self, **kwargs):
""" Convert and complete a dict of values to comply with fields of `sale.order.line`.
:param dict kwargs: The values to convert and complete.
:return: The completed values.
:rtype: dict
"""
subtotal = kwargs.get('subtotal', 0)
quantity = kwargs.get('quantity', 1)
return {
'name': kwargs.get('description', ''),
'product_id': kwargs.get('product_id'),
'price_unit': subtotal / quantity if quantity else 0,
'tax_id': [(6, 0, kwargs.get('tax_ids', []))],
'product_uom_qty': quantity,
'discount': (kwargs.get('discount', 0) / subtotal) * 100 if subtotal else 0,
'display_type': kwargs.get('display_type', False),
'amazon_item_ref': kwargs.get('amazon_item_ref'),
'amazon_offer_id': kwargs.get('amazon_offer_id'),
}
def _find_or_create_offer(self, sku, marketplace):
""" Find or create the amazon offer based on the SKU and marketplace.
Note: self.ensure_one()
:param str sku: The SKU of the product.
:param recordset marketplace: The marketplace of the offer, as an `amazon.marketplace`
record.
:return: The amazon offer.
:rtype: record or `amazon.offer`
"""
self.ensure_one()
offer = self.offer_ids.filtered(lambda o: o.sku == sku)
if not offer:
offer = self.env['amazon.offer'].with_context(tracking_disable=True).create({
'account_id': self.id,
'marketplace_id': marketplace.id,
'product_id': self._find_matching_product(
sku, 'default_product', 'Amazon Sales', 'consu'
).id,
'sku': sku,
})
# If the offer has been linked with the default product, search if another product has now
# been assigned the current SKU as internal reference and update the offer if so.
# This trades off a bit of performance in exchange for a more expected behavior for the
# matching of products if one was assigned the right SKU after that the offer was created.
elif 'sale_amazon.default_product' in offer.product_id._get_external_ids().get(
offer.product_id.id, []
):
product = self._find_matching_product(sku, '', '', '', fallback=False)
if product:
offer.product_id = product.id
return offer
def _find_or_create_pricelist(self, currency):
""" Find or create the pricelist based on the currency.
Note: self.ensure_one()
:param recordset currency: The currency of the pricelist, as a `res.currency` record.
:return: The pricelist.
:rtype: record or `product.pricelist`
"""
self.ensure_one()
pricelist = self.env['product.pricelist'].with_context(active_test=False).search([
*self.env['product.pricelist']._check_company_domain(self.company_id),
('currency_id', '=', currency.id),
], limit=1)
if not pricelist:
pricelist = self.env['product.pricelist'].with_context(tracking_disable=True).create({
'name': 'Amazon Pricelist %s' % currency.name,
'active': False,
'currency_id': currency.id,
'company_id': self.company_id.id,
})
return pricelist
def _find_matching_product(
self, internal_reference, default_xmlid, default_name, default_type, fallback=True
):
""" Find the matching product for a given internal reference.
If no product is found for the given internal reference, we fall back on the default
product. If the default product was deleted, we restore it.
:param str internal_reference: The internal reference of the product to be searched.
:param str default_xmlid: The xmlid of the default product to use as fallback.
:param str default_name: The name of the default product to use as fallback.
:param str default_type: The product type of the default product to use as fallback.
:param bool fallback: Whether we should fall back to the default product when no product
matching the provided internal reference is found.
:return: The matching product.
:rtype: record of `product.product`
"""
self.ensure_one()
product = self.env['product.product'].search([
*self.env['product.product']._check_company_domain(self.company_id),
('default_code', '=', internal_reference),
], limit=1)
if not product and fallback: # Fallback to the default product
product = self.env.ref('sale_amazon.%s' % default_xmlid, raise_if_not_found=False)
if not product and fallback: # Restore the default product if it was deleted
product = self.env['product.product']._restore_data_product(
default_name, default_type, default_xmlid
)
return product
def _recompute_subtotal(self, subtotal, tax_amount, taxes, currency, _fiscal_pos=None):
""" Recompute the subtotal from the tax amount and the taxes.
As it is not always possible to find the right tax record for a tax rate computed from the
tax amount because of rounding errors or because of multiple taxes for a given rate, the
taxes on the product (or those given by the fiscal position) are used instead.
To achieve this, the subtotal is recomputed from the taxes for the total to match that of
the order in SellerCentral. If the taxes used are not identical to that used by Amazon, the
recomputed subtotal will differ from the original subtotal.
:param float subtotal: The original subtotal to use for the computation of the base total.
:param float tax_amount: The original tax amount to use for the computation of the base
total.
:param recordset taxes: The final taxes to use for the computation of the new subtotal, as
an `account.tax` recordset.
:param recordset currency: The currency used by the rounding methods, as a `res.currency`
record.
:param recordset _fiscal_pos: The fiscal position only used in overrides of this method, as
an `account.fiscal.position` recordset.
:return: The new subtotal.
:rtype: float
"""
total = subtotal + tax_amount
taxes_res = taxes.with_context(force_price_include=True).compute_all(
total, currency=currency
)
subtotal = taxes_res['total_excluded']
for tax_res in taxes_res['taxes']:
tax = self.env['account.tax'].browse(tax_res['id'])
if tax.price_include:
subtotal += tax_res['amount']
return subtotal
def _generate_stock_moves(self, order):
""" Generate a stock move for each product of the provided sales order.
:param recordset order: The sales order to generate the stock moves for, as a `sale.order`
record.
:return: The generated stock moves.
:rtype: recordset of `stock.move`
"""
customers_location = self.env.ref('stock.stock_location_customers')
for order_line in order.order_line.filtered(
lambda l: l.product_id.type != 'service' and not l.display_type
):
stock_move = self.env['stock.move'].create({
'name': _('Amazon move: %s', order.name),
'company_id': self.company_id.id,
'product_id': order_line.product_id.id,
'product_uom_qty': order_line.product_uom_qty,
'product_uom': order_line.product_uom.id,
'location_id': self.location_id.id,
'location_dest_id': customers_location.id,
'state': 'confirmed',
'sale_line_id': order_line.id,
})
stock_move._set_quantity_done(order_line.product_uom_qty)
stock_move.picked = True # To also change move lines created in `_set_quantity_done`
stock_move._action_done()
def _sync_inventory(self):
""" Synchronize the inventory availability of products sold on Amazon.
If called on an empty recordset, the products of all active accounts with inventory
synchronization are synchronized instead.
Note: This method is called by the `ir_cron_sync_amazon_inventory` cron.
:return: None
"""
self = self or self.search([])
accounts = self.filtered('synchronize_inventory')
if not accounts:
return
# Cache `free_qty` of all products to avoid recomputing it for each offer.
accounts.offer_ids.product_id.filtered(lambda p: p.type == 'product')._compute_quantities()
for account in accounts:
amazon_utils.ensure_account_is_set_up(account)
offers = account.offer_ids.filtered(lambda o: o.product_id.type == 'product')
offers._update_inventory_availability(account)
# As Amazon needs some time to process the feed, we trigger the cron to check the status of
# the feed after 10 minutes.
next_call = fields.Datetime.now() + timedelta(minutes=10)
self.env.ref('sale_amazon.ir_cron_sync_amazon_feeds')._trigger(at=next_call)
def _sync_feeds(self):
""" Synchronize the status of the accounts' feeds that were sent to Amazon.
If called on an empty recordset, the feeds of all active account are synchronized instead.
We assume that the combined set of feeds (of all accounts) to be handled will always be too
small for the cron to be killed before it finishes synchronizing all feeds.
Note: This method is called by the `ir_cron_sync_amazon_feeds` cron.
:return: None
"""
self = self or self.search([])
# Select accounts with offers or pickings requiring synchronization.
accounts_with_offers = self.filtered(
lambda a: any(o.amazon_sync_status == 'processing' for o in a.offer_ids)
)
pickings_by_account = self.env['stock.picking']._get_pickings_by_account(
'processing', tuple(self.ids)
)
# Syn feeds status.
for account in accounts_with_offers:
amazon_utils.ensure_account_is_set_up(account)
offers = account.offer_ids.filtered(lambda o: o.amazon_sync_status == 'processing')
account._pull_feeds_status(offers, 'inventory_sync')
for account, pickings in pickings_by_account.items():
amazon_utils.ensure_account_is_set_up(account)
account._pull_feeds_status(pickings, 'picking_sync')
# Re-schedule the cron if not all the offers and pickings reached a final status.
any_processing_feed = any(
offer.amazon_sync_status == 'processing' for offer in accounts_with_offers.offer_ids
) or any(
account_pickings.filtered(lambda p: p.amazon_sync_status == 'processing')
for account_pickings in pickings_by_account.values()
)
if any_processing_feed:
next_call = fields.Datetime.now() + timedelta(minutes=10)
self.env.ref('sale_amazon.ir_cron_sync_amazon_feeds')._trigger(at=next_call)
def _pull_feeds_status(self, records, flow):
""" Pull the status of the feeds corresponding to the provided recordset.
Note: `self.ensure_one()`
:param recordset records: The records whose feed status should be pulled. Only
`amazon.offer` and `stock.picking` are supported.
:param str flow: The feed name that must be fetched. Supported feeds are 'inventory_sync'
and 'picking_sync'.
:return: None
"""
self.ensure_one()
if flow == 'inventory_sync':
record_model = self.env['amazon.offer']
elif flow == 'picking_sync':
record_model = self.env['stock.picking']
else:
return
records_by_feed = {}
for record in records:
records_by_feed.setdefault(record.amazon_feed_ref, record_model)
records_by_feed[record.amazon_feed_ref] += record
errors_by_record = {}
for feed_ref, feed_records in records_by_feed.items():
# Pull the status and result document reference for the current feed.
feed_data = amazon_utils.make_sp_api_request(self, 'getFeed', path_parameter=feed_ref)
feed_status = feed_data['processingStatus']
result_document_ref = feed_data.get('resultFeedDocumentId')
# Update the records according to their feed status.
if feed_status == 'DONE': # The feed was fully processed.
try:
document = amazon_utils.get_feed_document(self, result_document_ref)
except amazon_utils.AmazonRateLimitError:
raise # Don't treat a rate limit error as a business error.
except ValidationError:
_logger.exception(
"A business error occurred while processing feed %(feed_ref)s for Amazon"
" account with id %(account_id)s. Skipping the feed and moving to the next"
" one.", {'feed_ref': feed_ref, 'account_id': self.id}
)
else:
if document.find('ProcessingSummary/MessagesWithError').text == '0':
feed_records.amazon_sync_status = 'done'
_logger.info(
"Synchronized feed %(feed_ref)s for Amazon account with id"
" %(account_id)s.", {'feed_ref': feed_ref, 'account_id': self.id}
)
continue
# Iterate over the processing results and flag failed records as in 'error'.
consider_unprocessed_records_as_failed = False
for result_message in document.iter('Result'):
result_code = result_message.find('ResultCode').text
if result_code != 'Error':
continue
if flow == 'inventory_sync':
sku = result_message.find('AdditionalInfo/SKU').text
failed_offer = feed_records.filtered(lambda o: o.sku == sku)
# Using a set to combine duplicates created by Amazon with every retry.
errors_by_record.setdefault(failed_offer, set())
errors_by_record[failed_offer].add(
result_message.find('ResultDescription').text
)
elif flow == 'picking_sync':
order_info = result_message.find('AdditionalInfo/AmazonOrderID')
order_id = order_info is not None and order_info.text
if order_id: # We can identify failed pickings.
error_desc = result_message.find('ResultDescription').text
failed_pickings = feed_records.filtered(
lambda p: p.sale_id.amazon_order_ref == order_id
)
for failed_picking in failed_pickings:
errors_by_record.setdefault(failed_picking, set())
errors_by_record[failed_picking].add(error_desc)
else: # Amazon doesn't specify which order (and thus picking) failed.
consider_unprocessed_records_as_failed = True
feed_records.filtered(
lambda p: p in errors_by_record
).amazon_sync_status = 'error'
unprocessed_records = feed_records.filtered(
lambda p: p.amazon_sync_status == 'processing'
) # The sync order might have run before, avoid changing back done records.
if consider_unprocessed_records_as_failed:
for record in unprocessed_records:
errors_by_record.setdefault(record, set()).add(None)
unprocessed_records.amazon_sync_status = 'error'
else: # All errors were identified, the remaining records succeeded.
unprocessed_records.amazon_sync_status = 'done'
_logger.info(
"Found errors while synchronizing feed %(feed_ref)s for Amazon account with"
" id %(account_id)s.", {'feed_ref': feed_ref, 'account_id': self.id}
)
elif feed_status in ['IN_QUEUE', 'IN_PROGRESS']: # The feed has not yet been processed.
_logger.info(
"Ignoring in progress feed %(feed_ref)s for Amazon account with id"
" %(account_id)s.", {'feed_ref': feed_ref, 'account_id': self.id}
)
elif feed_status == 'CANCELLED': # The feed has been canceled before being processed.
feed_records.amazon_sync_status = 'pending'
if flow == 'inventory_sync':
_logger.info(
"Re-scheduling a synchronization of inventory for offers of canceled"
" feed %(feed_ref)s for Amazon account with id %(account_id)s.",
{'feed_ref': feed_ref, 'account_id': self.id},
)
elif flow == 'picking_sync':
_logger.info(
"Re-scheduling a synchronization for pickings of canceled feed %(feed_ref)s"
" for Amazon account with id %(account_id)s.",
{'feed_ref': feed_ref, 'account_id': self.id},
)
elif feed_status == 'FATAL': # The feed failed with no further information.
for record in feed_records:
errors_by_record.setdefault(record, set()).add(None)
feed_records.amazon_sync_status = 'error'
if errors_by_record:
error_messages = []
for r, errors in errors_by_record.items():
for error in errors:
if error and flow == 'picking_sync':
r.message_post(body=Markup("%s<br/>%s") % (
_("The synchronization with Amazon failed. Amazon gave us this "
"information about the problem:"),
error,
))
if flow == 'inventory_sync':
error_messages.append({'sku': r.sku, 'message': error})
elif flow == 'picking_sync':
error_messages.append(
{'order_ref': r.sale_id.amazon_order_ref, 'message': error}
)
self._handle_sync_failure(flow=flow, error_messages=error_messages)
def _handle_sync_failure(self, flow, amazon_order_ref=False, error_messages=False):
""" Send a mail to the responsible persons to report a synchronization failure.
:param str flow: The flow for which the failure mail is requested. Supported flows are:
`order_sync`, `inventory_sync`, and `picking_sync`.
:param str amazon_order_ref: The amazon reference of the order that failed to synchronize.
Required for the `order_sync` flow.
:param list[dict] error_messages: A list containing the referenced Amazon orders and their
linked errors in the format [{'order_ref': 'error'}].
Required for the `picking_sync` flow.
:return: None
"""
if flow == 'order_sync':
_logger.exception(
"Failed to synchronize order with amazon reference %(ref)s for amazon.account with "
"id %(account_id)s (seller id %(seller_id)s).",
{'ref': amazon_order_ref, 'account_id': self.id, 'seller_id': self.seller_key},
)
mail_template_id = 'sale_amazon.order_sync_failure'
elif flow == 'inventory_sync':
_logger.exception(
"Failed to synchronize the inventory for offers in amazon.account with id "
"%(account_id)s (seller id %(seller_id)s).",
{'account_id': self.id, 'seller_id': self.seller_key}
)
mail_template_id = 'sale_amazon.inventory_sync_failure'
else: # flow == 'picking_sync':
_logger.exception(
"Failed to synchronize pickings for amazon.account with id %(account_id)s "
"(seller id %(seller_id)s).", {'account_id': self.id, 'seller_id': self.seller_key}
)
mail_template_id = 'sale_amazon.picking_sync_failure'
mail_template = self.env.ref(mail_template_id, raise_if_not_found=False)
if not mail_template:
_logger.warning("The mail template with xmlid %s has been deleted.", mail_template_id)
else:
responsible_emails = {user.email for user in filter(
None, (self.user_id, self.env.ref('base.user_admin', raise_if_not_found=False))
)}
mail_template.with_context(**{
'email_to': ','.join(responsible_emails),
'amazon_order_ref': amazon_order_ref,
'error_messages': error_messages,
'amazon_account': self.name,
}).send_mail(self.env.user.id)
_logger.info(
"Sent synchronization failure notification email to %s",
', '.join(responsible_emails)
)