diff --git a/addons/account_payment/views/account_portal_templates.xml b/addons/account_payment/views/account_portal_templates.xml
index e9d121e15e0..bdc9c9da488 100644
--- a/addons/account_payment/views/account_portal_templates.xml
+++ b/addons/account_payment/views/account_portal_templates.xml
@@ -48,7 +48,7 @@
-
+
diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py
index f8f01119e7d..c9ee67f2c7b 100644
--- a/addons/base_address_city/models/res_partner.py
+++ b/addons/base_address_city/models/res_partner.py
@@ -31,13 +31,13 @@ class Partner(models.Model):
replacement_xml = """
-
-
1 else None
router = request.httprequest.app.get_db_router(request.db).bind('')
@@ -174,6 +174,10 @@ def is_multilang_url(local_url, langs=None):
return (func.routing.get('website', False) and
func.routing.get('multilang', func.routing['type'] == 'http'))
except werkzeug.exceptions.NotFound:
+ # Consider /static/ files as non-multilang
+ static_index = path.find('/static/', 1)
+ if static_index != -1 and static_index == path.find('/', 1):
+ return False
return True
except Exception as e:
return False
diff --git a/addons/l10n_pl/data/account_tax_data.xml b/addons/l10n_pl/data/account_tax_data.xml
index f29294b36aa..973d05ff7b6 100644
--- a/addons/l10n_pl/data/account_tax_data.xml
+++ b/addons/l10n_pl/data/account_tax_data.xml
@@ -256,7 +256,7 @@
sale
-
+
diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py
index f758456c77e..a2ba820656f 100644
--- a/addons/mail/models/mail_mail.py
+++ b/addons/mail/models/mail_mail.py
@@ -5,6 +5,7 @@ import base64
import datetime
import logging
import psycopg2
+import smtplib
import threading
import re
@@ -394,11 +395,12 @@ class MailMail(models.Model):
mail.id, mail.message_id)
# mail status will stay on ongoing since transaction will be rollback
raise
- except psycopg2.Error:
- # If an error with the database occurs, chances are that the cursor is unusable.
- # This will lead to an `psycopg2.InternalError` being raised when trying to write
- # `state`, shadowing the original exception and forbid a retry on concurrent
- # update. Let's bubble it.
+ except (psycopg2.Error, smtplib.SMTPServerDisconnected):
+ # If an error with the database or SMTP session occurs, chances are that the cursor
+ # or SMTP session are unusable, causing further errors when trying to save the state.
+ _logger.exception(
+ 'Exception while processing mail with ID %r and Msg-Id %r.',
+ mail.id, mail.message_id)
raise
except Exception as e:
failure_reason = tools.ustr(e)
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
index e228f7c4062..e2ebb16c69e 100644
--- a/addons/mail/models/mail_message.py
+++ b/addons/mail/models/mail_message.py
@@ -154,8 +154,8 @@ class Message(models.Model):
@api.multi
def _search_has_error(self, operator, operand):
if operator == '=' and operand:
- return ['&', ('notification_ids.email_status', 'in', ('bounce', 'exception')), ('author_id', '=', self.env.user.partner_id.id)]
- return ['!', '&', ('notification_ids.email_status', 'in', ('bounce', 'exception')), ('author_id', '=', self.env.user.partner_id.id)] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False"
+ return [('notification_ids.email_status', 'in', ('bounce', 'exception'))]
+ return ['!', ('notification_ids.email_status', 'in', ('bounce', 'exception'))] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False"
@api.depends('starred_partner_ids')
def _get_starred(self):
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
index 79cfb230a6b..8e35a8fda18 100644
--- a/addons/mail/models/mail_thread.py
+++ b/addons/mail/models/mail_thread.py
@@ -237,7 +237,7 @@ class MailThread(models.AbstractModel):
@api.model
def _search_message_has_error(self, operator, operand):
- return [('message_ids.has_error', operator, operand)]
+ return ['&', ('message_ids.has_error', operator, operand), ('message_ids.author_id', '=', self.env.user.partner_id.id)]
@api.multi
def _compute_message_attachment_count(self):
diff --git a/addons/mass_mailing/models/mass_mailing_stats.py b/addons/mass_mailing/models/mass_mailing_stats.py
index 662b6adbaaf..f0d8a763438 100644
--- a/addons/mass_mailing/models/mass_mailing_stats.py
+++ b/addons/mass_mailing/models/mass_mailing_stats.py
@@ -66,14 +66,14 @@ class MailMailStats(models.Model):
stat.state = 'ignored'
elif stat.exception:
stat.state = 'exception'
- elif stat.sent:
- stat.state = 'sent'
elif stat.opened or stat.clicked:
stat.state = 'opened'
elif stat.replied:
stat.state = 'replied'
elif stat.bounced:
stat.state = 'bounced'
+ elif stat.sent:
+ stat.state = 'sent'
else:
stat.state = 'outgoing'
diff --git a/addons/mass_mailing/wizard/mail_compose_message.py b/addons/mass_mailing/wizard/mail_compose_message.py
index d0804977301..3f88e62552b 100644
--- a/addons/mass_mailing/wizard/mail_compose_message.py
+++ b/addons/mass_mailing/wizard/mail_compose_message.py
@@ -64,7 +64,8 @@ class MailComposeMessage(models.TransientModel):
if mail_values.get('email_to'):
mail_to = tools.email_normalize(mail_values['email_to'])
else:
- mail_to = tools.email_normalize(partners_email.get(res_id))
+ partner_id = (mail_values.get('recipient_ids') or [(False, '')])[0][1]
+ mail_to = tools.email_normalize(partners_email.get(partner_id))
if (opt_out_list and mail_to in opt_out_list) or (seen_list and mail_to in seen_list) \
or (not mail_to or not email_re.findall(mail_to)):
# prevent sending to blocked addresses that were included by mistake
diff --git a/addons/point_of_sale/static/src/js/chrome.js b/addons/point_of_sale/static/src/js/chrome.js
index 026682d5135..23ea37d9810 100644
--- a/addons/point_of_sale/static/src/js/chrome.js
+++ b/addons/point_of_sale/static/src/js/chrome.js
@@ -721,9 +721,6 @@ var Chrome = PosBaseWidget.extend(AbstractAction.prototype, {
if(err.message === 'XmlHttpRequestError '){
title = 'Network Failure (XmlHttpRequestError)';
body = 'The Point of Sale could not be loaded due to a network problem.\n Please check your internet connection.';
- }else if(err.message === 'TLSError'){
- title = 'Https connection to IoT Box failed';
- body = 'Make sure you are using IoT Box v18.12 or higher.\n\n Navigate to ' + err.url + ' to accept the certificate of your IoT Box.';
}else if(err.code === 200){
title = err.data.message;
body = err.data.debug;
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index 4cf483dfcde..eb4c30701fa 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -137,11 +137,16 @@ exports.PosModel = Backbone.Model.extend({
done.resolve();
},
function(statusText, url){
- if (statusText == 'error' && window.location.protocol == 'https:') {
- var error = {message: 'TLSError', url: url};
- self.chrome.loading_error(error);
- } else {
- done.resolve();
+ var show_loading_error = (self.gui.current_screen === null);
+ done.resolve();
+ if (show_loading_error && statusText == 'error' && window.location.protocol == 'https:') {
+ self.gui.show_popup('alert', {
+ title: _t('HTTPS connection to IoT Box failed'),
+ body: _.str.sprintf(
+ _t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'),
+ url
+ ),
+ });
}
});
return done;
diff --git a/addons/product/models/product.py b/addons/product/models/product.py
index 1c79a5d8f39..1a48f8fa526 100644
--- a/addons/product/models/product.py
+++ b/addons/product/models/product.py
@@ -15,6 +15,7 @@ from odoo.tools import float_compare
_logger = logging.getLogger(__name__)
+
class ProductCategory(models.Model):
_name = "product.category"
_description = "Product Category"
@@ -521,7 +522,10 @@ class ProductProduct(models.Model):
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
res = self.env['product.supplierinfo']
- for seller in self._prepare_sellers(params):
+ sellers = self._prepare_sellers(params)
+ if self.env.context.get('force_company'):
+ sellers = sellers.filtered(lambda s: not s.company_id or s.company_id.id == self.env.context['force_company'])
+ for seller in sellers:
# Set quantity in UoM of seller
quantity_uom_seller = quantity
if quantity_uom_seller and uom_id and uom_id != seller.product_uom:
diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml
index a75e6d5ba10..a354b3c5174 100644
--- a/addons/product/views/product_views.xml
+++ b/addons/product/views/product_views.xml
@@ -297,8 +297,9 @@
-
+
+
diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py
index 9f63039afd7..6c7b1b9293d 100644
--- a/addons/purchase/models/account_invoice.py
+++ b/addons/purchase/models/account_invoice.py
@@ -25,6 +25,10 @@ class AccountInvoice(models.Model):
return {}
self.purchase_id = self.vendor_bill_purchase_id.purchase_order_id
self.vendor_bill_id = self.vendor_bill_purchase_id.vendor_bill_id
+ vendor_ref = self.vendor_bill_purchase_id.purchase_order_id.partner_ref
+ if vendor_ref:
+ self.reference = ", ".join([self.reference, vendor_ref]) if (
+ self.reference and vendor_ref not in self.reference) else vendor_ref
self.vendor_bill_purchase_id = False
return {}
diff --git a/addons/purchase_requisition/models/product.py b/addons/purchase_requisition/models/product.py
index ef94ad283b7..48380f1c3dc 100644
--- a/addons/purchase_requisition/models/product.py
+++ b/addons/purchase_requisition/models/product.py
@@ -16,10 +16,11 @@ class ProductProduct(models.Model):
_inherit = 'product.product'
def _prepare_sellers(self, params):
+ sellers = super(ProductProduct, self)._prepare_sellers(params)
if params and params.get('order_id'):
- return self.seller_ids.filtered(lambda s: not s.purchase_requisition_id or s.purchase_requisition_id == params['order_id'].requisition_id)
+ return sellers.filtered(lambda s: not s.purchase_requisition_id or s.purchase_requisition_id == params['order_id'].requisition_id)
else:
- return self.seller_ids
+ return sellers
class ProductTemplate(models.Model):
diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py
index 5059aa2cf24..797ce794067 100644
--- a/addons/purchase_stock/models/stock_rule.py
+++ b/addons/purchase_stock/models/stock_rule.py
@@ -174,7 +174,7 @@ class StockRule(models.Model):
def _update_purchase_order_line(self, product_id, product_qty, product_uom, company_id, values, line):
partner = values['supplier'].name
procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id)
- seller = product_id._select_seller(
+ seller = product_id.with_context(force_company=company_id.id)._select_seller(
partner_id=partner,
quantity=line.product_qty + procurement_uom_po_qty,
date=line.order_id.date_order and line.order_id.date_order.date(),
@@ -197,7 +197,7 @@ class StockRule(models.Model):
procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id)
# _select_seller is used if the supplier have different price depending
# the quantities ordered.
- seller = product_id._select_seller(
+ seller = product_id.with_context(force_company=company_id.id)._select_seller(
partner_id=partner,
quantity=procurement_uom_po_qty,
date=po.date_order and po.date_order.date(),
diff --git a/addons/sale/models/res_company.py b/addons/sale/models/res_company.py
index 6d6cfccc87c..439061adafa 100644
--- a/addons/sale/models/res_company.py
+++ b/addons/sale/models/res_company.py
@@ -61,6 +61,7 @@ class ResCompany(models.Model):
'product_uom_qty': 10,
'price_unit': 123,
'order_id': sample_sales_order.id,
+ 'company_id': sample_sales_order.company_id.id,
})
return sample_sales_order
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 875b48646c2..79456e26d28 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -1031,20 +1031,23 @@ class SaleOrderLine(models.Model):
res[field] = line._fields[field].convert_to_write(line[field], line)
return res
- @api.model
- def create(self, values):
- if values.get('display_type', self.default_get(['display_type'])['display_type']):
- values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0)
+ @api.model_create_multi
+ def create(self, vals_list):
+ for values in vals_list:
+ if values.get('display_type', self.default_get(['display_type'])['display_type']):
+ values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0)
- values.update(self._prepare_add_missing_fields(values))
- line = super(SaleOrderLine, self).create(values)
- if line.product_id and line.order_id.state == 'sale':
- msg = _("Extra line with %s ") % (line.product_id.display_name,)
- line.order_id.message_post(body=msg)
- # create an analytic account if at least an expense product
- if line.product_id.expense_policy not in [False, 'no'] and not self.order_id.analytic_account_id:
- self.order_id._create_analytic_account()
- return line
+ values.update(self._prepare_add_missing_fields(values))
+
+ lines = super().create(vals_list)
+ for line in lines:
+ if line.product_id and line.order_id.state == 'sale':
+ msg = _("Extra line with %s ") % (line.product_id.display_name,)
+ line.order_id.message_post(body=msg)
+ # create an analytic account if at least an expense product
+ if line.product_id.expense_policy not in [False, 'no'] and not line.order_id.analytic_account_id:
+ line.order_id._create_analytic_account()
+ return lines
_sql_constraints = [
('accountable_required_fields',
diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py
index d3111d2564f..4710ff88851 100644
--- a/addons/stock/models/stock_inventory.py
+++ b/addons/stock/models/stock_inventory.py
@@ -3,7 +3,7 @@
from odoo import api, fields, models, _
from odoo.addons import decimal_precision as dp
-from odoo.exceptions import UserError
+from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_utils, float_compare
@@ -151,13 +151,13 @@ class Inventory(models.Model):
if self.filter == 'none' and self.product_id and self.location_id and self.lot_id:
return
if self.filter not in ('product', 'product_owner') and self.product_id:
- raise UserError(_('The selected product doesn\'t belong to that owner..'))
+ raise ValidationError(_('The selected product doesn\'t belong to that owner..'))
if self.filter != 'lot' and self.lot_id:
- raise UserError(_('The selected lot number doesn\'t exist.'))
+ raise ValidationError(_('The selected lot number doesn\'t exist.'))
if self.filter not in ('owner', 'product_owner') and self.partner_id:
- raise UserError(_('The selected owner doesn\'t have the proprietary of that product.'))
+ raise ValidationError(_('The selected owner doesn\'t have the proprietary of that product.'))
if self.filter != 'pack' and self.package_id:
- raise UserError(_('The selected inventory options are not coherent, the package doesn\'t exist.'))
+ raise ValidationError(_('The selected inventory options are not coherent, the package doesn\'t exist.'))
def action_reset_product_qty(self):
self.mapped('line_ids').write({'product_qty': 0})
@@ -425,7 +425,7 @@ class InventoryLine(models.Model):
"""
for line in self:
if line.product_id.type != 'product':
- raise UserError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type))
+ raise ValidationError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type))
def _get_move_values(self, qty, location_id, location_dest_id, out):
self.ensure_one()
diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py
index 03c1890c51a..66a7aa90b8c 100644
--- a/addons/stock/models/stock_quant.py
+++ b/addons/stock/models/stock_quant.py
@@ -365,7 +365,7 @@ class QuantPackage(models.Model):
'res.partner', 'Owner', compute='_compute_package_info', search='_search_owner',
index=True, readonly=True)
- @api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id')
+ @api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id', 'quant_ids.quantity', 'quant_ids.reserved_quantity')
def _compute_package_info(self):
for package in self:
values = {'location_id': False, 'company_id': self.env.user.company_id.id, 'owner_id': False}
diff --git a/addons/stock/wizard/stock_immediate_transfer.py b/addons/stock/wizard/stock_immediate_transfer.py
index 66a652ac795..9b46a91c585 100644
--- a/addons/stock/wizard/stock_immediate_transfer.py
+++ b/addons/stock/wizard/stock_immediate_transfer.py
@@ -22,7 +22,7 @@ class StockImmediateTransfer(models.TransientModel):
picking.action_assign()
if picking.state != 'assigned':
raise UserError(_("Could not reserve all requested products. Please use the \'Mark as Todo\' button to handle the reservation manually."))
- for move in picking.move_lines:
+ for move in picking.move_lines.filtered(lambda m: m.state not in ['done', 'cancel']):
for move_line in move.move_line_ids:
move_line.qty_done = move_line.product_uom_qty
if picking._check_backorder():
diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py
index 35742c6de79..a708f57e256 100644
--- a/addons/stock_account/models/stock.py
+++ b/addons/stock_account/models/stock.py
@@ -304,7 +304,7 @@ class StockMove(models.Model):
if qty_to_take_on_candidates == 0:
move.write({
'value': -tmp_value if not quantity else move.value or -tmp_value, # outgoing move are valued negatively
- 'price_unit': -tmp_value / move.product_qty,
+ 'price_unit': -tmp_value / (move.product_qty or quantity),
})
elif qty_to_take_on_candidates > 0:
last_fifo_price = new_standard_price or move.product_id.standard_price
diff --git a/addons/survey/static/src/js/survey.js b/addons/survey/static/src/js/survey.js
index 727bef148fd..b4b47cb3d2f 100644
--- a/addons/survey/static/src/js/survey.js
+++ b/addons/survey/static/src/js/survey.js
@@ -98,7 +98,7 @@ if(!the_form.length) {
}
// checkboxes and radios
- the_form.find("input[name^=" + key + "][type!='text']").each(function(){
+ the_form.find("input[name=" + key + "][type!='text']").each(function(){
$(this).val(value);
});
});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
index e8a93e7c5f3..7f73cb8269b 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
@@ -905,7 +905,7 @@ var VideoWidget = MediaWidget.extend({
options = options || {};
// Video url patterns(youtube, instagram, vimeo, dailymotion, youku, ...)
- var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;
+ var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube(-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((?:\w|-){11})(?:\S+)?$/;
var ytMatch = url.match(ytRegExp);
var insRegExp = /(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)/;
@@ -932,8 +932,8 @@ var VideoWidget = MediaWidget.extend({
var autoplay = options.autoplay ? '?autoplay=1' : '?autoplay=0';
- if (ytMatch && ytMatch[1].length === 11) {
- $video.attr('src', '//www.youtube.com/embed/' + ytMatch[1] + autoplay);
+ if (ytMatch && ytMatch[2].length === 11) {
+ $video.attr('src', '//www.youtube' + (ytMatch[1] || '') + '.com/embed/' + ytMatch[2] + autoplay);
} else if (insMatch && insMatch[2].length) {
$video.attr('src', '//www.instagram.com/p/' + insMatch[2] + '/embed/');
videoType = 'ins';
diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py
index 0a36e8fa8ee..ea33aa114b2 100644
--- a/addons/website/models/ir_qweb.py
+++ b/addons/website/models/ir_qweb.py
@@ -4,6 +4,7 @@ import re
from collections import OrderedDict
from odoo import models
+from odoo.addons.http_routing.models.ir_http import url_for
from odoo.http import request
from odoo.addons.base.models.assetsbundle import AssetsBundle
from odoo.tools import html_escape as escape
@@ -50,10 +51,16 @@ class QWeb(models.AbstractModel):
if not website and options.get('website_id'):
website = self.env['website'].browse(options['website_id'])
- if not website or not website.cdn_activated:
+ if not website:
return atts
name = self.URL_ATTRS.get(tagName)
+ if request and name and name in atts:
+ atts[name] = url_for(atts[name])
+
+ if not website.cdn_activated:
+ return atts
+
if name and name in atts:
atts = OrderedDict(atts)
atts[name] = website.get_cdn_url(atts[name])
diff --git a/addons/website/tests/test_qweb.py b/addons/website/tests/test_qweb.py
index 5e078d89dd6..8cba2d150c0 100644
--- a/addons/website/tests/test_qweb.py
+++ b/addons/website/tests/test_qweb.py
@@ -2,7 +2,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
+import werkzeug
+import odoo
from odoo import tools
from odoo.modules.module import get_module_resource
from odoo.tests.common import TransactionCase
@@ -63,3 +65,123 @@ class TestQweb(TransactionCase):
"css": attachments[1].url,
"user_id": demo.id,
}).encode('utf8'))
+
+class MockObject(object):
+ _log_call = []
+ def __init__(self, *args, **kwargs):
+ self.__dict__ = kwargs
+ def __call__(self, *args, **kwargs):
+ self._log_call.append((args, kwargs))
+ return self
+ def __getitem__(self, index):
+ return self
+
+def werkzeugRaiseNotFound(*args, **kwargs):
+ raise werkzeug.exceptions.NotFound()
+
+class MockRequest(object):
+ """ Class with context manager mocking odoo.http.request for tests """
+ def __init__(self, env, website=None, context=None, multilang=True, routing=True):
+ app = MockObject(routing={
+ 'type': 'http',
+ 'website': True,
+ 'multilang': multilang,
+ })
+ app.get_db_router = app.bind = app.match = app
+ if not routing:
+ app.match = werkzeugRaiseNotFound
+ self.request = MockObject(
+ env=env, context=context or {}, db=None, debug=False,
+ website=website, httprequest=MockObject(
+ path='/hello/',
+ app=app
+ )
+ )
+ odoo.http._request_stack.push(self.request)
+ def __enter__(self):
+ return self.request
+ def __exit__(self, exc_type, exc_value, traceback):
+ odoo.http._request_stack.pop()
+
+class TestQwebProcessAtt(TransactionCase):
+ def setUp(self):
+ super(TestQwebProcessAtt, self).setUp()
+ self.website = self.env['website'].browse(1)
+ self.website.language_ids = self.env.ref('base.lang_en') + self.env.ref('base.lang_fr')
+ self.website.default_lang_id = self.env.ref('base.lang_en')
+ self.website.cdn_activated = True
+ self.website.cdn_url = "http://test.cdn"
+ self.website.cdn_filters = "\n".join(["^(/[a-z]{2}_[A-Z]{2})?/a$", "^/b$"])
+
+ def _test_att(self, url, expect, tag='a', attribute='href'):
+ self.assertEqual(
+ self.env['ir.qweb']._post_processing_att(tag, {attribute: url}, {}),
+ expect
+ )
+
+ def test_process_att_no_request(self):
+ # no request so no URL rewriting
+ self._test_att('/', {'href': '/'})
+ self._test_att('/en_US/', {'href': '/en_US/'})
+ self._test_att('/fr_FR/', {'href': '/fr_FR/'})
+ # no URL rewritting for CDN
+ self._test_att('/a', {'href': '/a'})
+
+ def test_process_att_no_website(self):
+ with MockRequest(self.env) as request:
+ # no website so URL rewriting
+ self._test_att('/', {'href': '/'})
+ self._test_att('/en_US/', {'href': '/en_US/'})
+ self._test_att('/fr_FR/', {'href': '/fr_FR/'})
+ # no URL rewritting for CDN
+ self._test_att('/a', {'href': '/a'})
+
+ def test_process_att_monolang_route(self):
+ with MockRequest(self.env, website=self.website, multilang=False) as request:
+ # lang not changed in URL but CDN enabled
+ self._test_att('/a', {'href': 'http://test.cdn/a'})
+ self._test_att('/en_US/a', {'href': 'http://test.cdn/en_US/a'})
+ self._test_att('/b', {'href': 'http://test.cdn/b'})
+ self._test_att('/en_US/b', {'href': '/en_US/b'})
+
+ def test_process_att_no_request_lang(self):
+ with MockRequest(self.env, self.website) as request:
+ self._test_att('/', {'href': '/'})
+ self._test_att('/en_US/', {'href': '/'})
+ self._test_att('/fr_FR/', {'href': '/fr_FR/'})
+
+ def test_process_att_with_request_lang(self):
+ with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}) as request:
+ self._test_att('/', {'href': '/fr_FR/'})
+ self._test_att('/en_US/', {'href': '/'})
+ self._test_att('/fr_FR/', {'href': '/fr_FR/'})
+
+ def test_process_att_matching_cdn_and_lang(self):
+ with MockRequest(self.env, self.website) as request:
+ # lang prefix is added before CDN
+ self._test_att('/a', {'href': 'http://test.cdn/a'})
+ self._test_att('/en_US/a', {'href': 'http://test.cdn/a'})
+ self._test_att('/fr_FR/a', {'href': 'http://test.cdn/fr_FR/a'})
+ self._test_att('/b', {'href': 'http://test.cdn/b'})
+ self._test_att('/en_US/b', {'href': 'http://test.cdn/b'})
+ self._test_att('/fr_FR/b', {'href': '/fr_FR/b'})
+
+ def test_process_att_no_route(self):
+ with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}, routing=False) as request:
+ # default on multilang=True if route is not /{module}/static/
+ self._test_att('/web/static/hi', {'href': '/web/static/hi'})
+ self._test_att('/my-page', {'href': '/fr_FR/my-page'})
+
+ def test_process_att_url_crap(self):
+ with MockRequest(self.env, self.website) as request:
+ # #{fragment} is stripped from URL when testing route
+ self._test_att('/x#y?z', {'href': '/x#y?z'})
+ self.assertEqual(
+ request.httprequest.app._log_call[-1],
+ (('/x',), {'method': 'POST', 'query_args': None})
+ )
+ self._test_att('/x?y#z', {'href': '/x?y#z'})
+ self.assertEqual(
+ request.httprequest.app._log_call[-1],
+ (('/x',), {'method': 'POST', 'query_args': 'y'})
+ )
diff --git a/doc/cla/corporate/business-solutions-for-africa.md b/doc/cla/corporate/business-solutions-for-africa.md
new file mode 100644
index 00000000000..7619f7e2471
--- /dev/null
+++ b/doc/cla/corporate/business-solutions-for-africa.md
@@ -0,0 +1,15 @@
+The Democratic Republic of Congo, April 3rd 2019
+
+Business Solutions For Africa agrees to the terms of the Odoo Corporate Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Vincent Luba vincent@biz-4-africa.com https://github.com/BIZ4Africa
+
+List of contributors:
+
+Vincent Luba vincent@biz-4-africa.com https://github.com/BIZ4Africa
diff --git a/doc/cla/corporate/xoe-corp.md b/doc/cla/corporate/xoe-corp.md
index ef449e25d72..cb0e7d5eb42 100644
--- a/doc/cla/corporate/xoe-corp.md
+++ b/doc/cla/corporate/xoe-corp.md
@@ -13,3 +13,4 @@ David Arnold dar@xoe.solutions https://github.com/blaggacao
List of contributors:
David Arnold dar@devco.co https://github.com/blaggacao
+Deiber Rincon dri@xoe.solutions https://github.com/deiber
diff --git a/doc/cla/individual/andriisem.md b/doc/cla/individual/andriisem.md
new file mode 100644
index 00000000000..da69e5db958
--- /dev/null
+++ b/doc/cla/individual/andriisem.md
@@ -0,0 +1,9 @@
+Ukraine, 2019-04-08
+
+I hereby agree to the terms of the Odoo Individual Contributor License Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this declaration.
+
+Signed,
+
+Andrii Semko semko.andrey.i@gmail.com https://github.com/andriisem
\ No newline at end of file
diff --git a/doc/cla/individual/bletourmy.md b/doc/cla/individual/bletourmy.md
new file mode 100644
index 00000000000..1d51fcaf71d
--- /dev/null
+++ b/doc/cla/individual/bletourmy.md
@@ -0,0 +1,11 @@
+Switzerland, 2019-03-25
+
+I hereby agree to the terms of the Odoo Individual Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Bernard Letourmy bernard.letourmy@gmail.com https://github.com/bletourmy
diff --git a/doc/cla/individual/matthewdwatkins.md b/doc/cla/individual/matthewdwatkins.md
new file mode 100644
index 00000000000..75f773aa5a4
--- /dev/null
+++ b/doc/cla/individual/matthewdwatkins.md
@@ -0,0 +1,11 @@
+USA, 2019-04-04
+
+I hereby agree to the terms of the Odoo Individual Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Matthew Watkins mwatkins.bulk@gmail.com https://github.com/matthewdwatkins
diff --git a/doc/cla/individual/mmahgoub.md b/doc/cla/individual/mmahgoub.md
new file mode 100644
index 00000000000..e74845bff23
--- /dev/null
+++ b/doc/cla/individual/mmahgoub.md
@@ -0,0 +1,11 @@
+Sudan, 2019-03-15
+
+I hereby agree to the terms of the Odoo Individual Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Mohammed Mahgoub mmahgoub@gmail.com https://github.com/mmahgoub
diff --git a/doc/howtos/themes.rst b/doc/howtos/themes.rst
index f4f18dff759..89695326b7d 100644
--- a/doc/howtos/themes.rst
+++ b/doc/howtos/themes.rst
@@ -277,7 +277,11 @@ Installing your theme
To install your theme, you just place your theme folder inside addons in your Odoo installation.
-After that, navigate to the Settings page, look for your theme and click on the install button.
+After that, navigate to the Odoo **Website** module, go to
+:menuselection:`Configuration --> Settings`.
+
+Under **Website** section click the **Choose a theme** button, then hover over
+your theme and click **Use this theme**.
Structure of an Odoo page
=========================
diff --git a/odoo/addons/base/models/ir_mail_server.py b/odoo/addons/base/models/ir_mail_server.py
index b7597ebaa77..6773e1f61cc 100644
--- a/odoo/addons/base/models/ir_mail_server.py
+++ b/odoo/addons/base/models/ir_mail_server.py
@@ -483,6 +483,8 @@ class IrMailServer(models.Model):
# do not quit() a pre-established smtp_session
if not smtp_session:
smtp.quit()
+ except smtplib.SMTPServerDisconnected:
+ raise
except Exception as e:
params = (ustr(smtp_server), e.__class__.__name__, ustr(e))
msg = _("Mail delivery failed via SMTP server '%s'.\n%s: %s") % params
diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py
index eb03dfcafcd..b7a923dfa49 100644
--- a/odoo/addons/base/models/ir_model.py
+++ b/odoo/addons/base/models/ir_model.py
@@ -185,6 +185,11 @@ class IrModel(models.Model):
self._cr.execute('DROP VIEW "%s"' % table)
elif kind == 'r':
self._cr.execute('DROP TABLE "%s" CASCADE' % table)
+ # discard all translations for this model
+ self._cr.execute("""
+ DELETE FROM ir_translation
+ WHERE type IN ('model', 'model_terms') AND name LIKE %s
+ """, [model.model + ',%'])
else:
_logger.warning('The model %s could not be dropped because it did not exist in the registry.', model.model)
return True
@@ -574,6 +579,12 @@ class IrModelFields(models.Model):
tables_to_drop.add(rel_name)
if field.state == 'manual' and is_model:
model._pop_field(field.name)
+ if field.translate:
+ # discard all translations for this field
+ self._cr.execute("""
+ DELETE FROM ir_translation
+ WHERE type IN ('model', 'model_terms') AND name=%s
+ """, ['%s,%s' % (field.model, field.name)])
if tables_to_drop:
# drop the relation tables that are not used by other fields
diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py
index 55100b39e17..59bc52426c5 100644
--- a/odoo/addons/base/models/ir_module.py
+++ b/odoo/addons/base/models/ir_module.py
@@ -19,6 +19,7 @@ from docutils.core import publish_string
from docutils.transforms import Transform, writer_aux
from docutils.writers.html4css1 import Writer
import lxml.html
+import psycopg2
import odoo
from odoo import api, fields, models, modules, tools, _
@@ -543,6 +544,14 @@ class Module(models.Model):
@api.multi
def _button_immediate_function(self, function):
+ try:
+ # This is done because the installation/uninstallation/upgrade can modify a currently
+ # running cron job and prevent it from finishing, and since the ir_cron table is locked
+ # during execution, the lock won't be released until timeout.
+ self._cr.execute("SELECT * FROM ir_cron FOR UPDATE NOWAIT")
+ except psycopg2.OperationalError:
+ raise UserError(_("The server is busy right now, module operations are not possible at"
+ " this time, please try again later."))
function(self)
self._cr.commit()
diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py
index 2d9744abada..bf560adf467 100644
--- a/odoo/addons/base/models/ir_translation.py
+++ b/odoo/addons/base/models/ir_translation.py
@@ -669,6 +669,47 @@ class IrTranslation(models.Model):
})
self._modified_model(field.model_name)
+ @api.model
+ def _upsert_translations(self, vals_list):
+ """ Insert or update translations of type 'model' or 'model_terms'.
+
+ This method is used for creations of translations where the given
+ ``vals_list`` is trusted to be the right values and potential
+ conflicts should be updated to the new given value.
+ """
+ rows_by_type = defaultdict(list)
+ for vals in vals_list:
+ rows_by_type[vals['type']].append((
+ vals['name'], vals['lang'], vals['res_id'], vals['src'], vals['type'],
+ vals.get('module'), vals['value'], vals.get('state'), vals.get('comments'),
+ ))
+
+ if rows_by_type['model']:
+ query = """
+ INSERT INTO ir_translation (name, lang, res_id, src, type,
+ module, value, state, comments)
+ VALUES {}
+ ON CONFLICT (type, lang, name, res_id) WHERE type='model'
+ DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
+ (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
+ EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
+ WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
+ """.format(", ".join(["%s"] * len(rows_by_type['model'])))
+ self.env.cr.execute(query, rows_by_type['model'])
+
+ if rows_by_type['model_terms']:
+ query = """
+ INSERT INTO ir_translation (name, lang, res_id, src, type,
+ module, value, state, comments)
+ VALUES {}
+ ON CONFLICT (type, name, lang, res_id, md5(src))
+ DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
+ (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
+ EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
+ WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
+ """.format(", ".join(["%s"] * len(rows_by_type['model_terms'])))
+ self.env.cr.execute(query, rows_by_type['model_terms'])
+
@api.model
def translate_fields(self, model, id, field=None):
""" Open a view for translating the field(s) of the record (model, id). """
diff --git a/odoo/fields.py b/odoo/fields.py
index 16a13bedbe4..0f729ba924b 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -945,7 +945,11 @@ class Field(MetaField('DummyField', (object,), {})):
"""
indexname = '%s_%s_index' % (model._table, self.name)
if self.index:
- sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name])
+ try:
+ with model._cr.savepoint():
+ sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name])
+ except psycopg2.OperationalError:
+ _schema.error("Unable to add index for %s", self)
else:
sql.drop_index(model._cr, indexname, model._table)
diff --git a/odoo/models.py b/odoo/models.py
index 6a09ce54453..b5f661e7c83 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -4308,11 +4308,12 @@ Fields:
if vals['lang'] == old.env.lang and field.translate is True:
# force a source if the new_val was not changed by copy override
if new_val == old[name]:
- vals['source'] = old_wo_lang[name]
+ new_wo_lang[name] = old_wo_lang[name]
+ vals['src'] = old_wo_lang[name]
# the value should be the new value (given by copy())
vals['value'] = new_val
vals_list.append(vals)
- Translation.create(vals_list)
+ Translation._upsert_translations(vals_list)
@api.multi
@api.returns('self', lambda value: value.id)