summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/account/report/account_aged_partner_balance.py14
-rw-r--r--addons/base_gengo/wizard/base_gengo_translations.py4
-rw-r--r--addons/bus/static/src/js/bus.js3
-rw-r--r--addons/crm/security/crm_security.xml10
-rw-r--r--addons/hr_expense/models/hr_expense.py2
-rw-r--r--addons/hr_expense/views/hr_expense_views.xml2
-rw-r--r--addons/hr_holidays/models/hr.py9
-rw-r--r--addons/hr_holidays/models/hr_holidays.py1
-rw-r--r--addons/hr_payroll_account/models/hr_payroll_account.py1
-rw-r--r--addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml2
-rw-r--r--addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py6
-rw-r--r--addons/mail/models/ir_model.py2
-rw-r--r--addons/mail/models/mail_channel.py12
-rw-r--r--addons/mail/models/mail_message.py2
-rw-r--r--addons/mail/static/src/js/thread.js4
-rw-r--r--addons/mail/static/src/js/utils.js4
-rw-r--r--addons/mail/static/tests/mail_utils_tests.js34
-rw-r--r--addons/mail/views/mail_templates.xml1
-rw-r--r--addons/mrp/models/mrp_bom.py5
-rw-r--r--addons/mrp_repair/views/mrp_repair_views.xml4
-rw-r--r--addons/payment/controllers/portal.py99
-rw-r--r--addons/payment/static/src/js/payment_transaction_portal.js26
-rw-r--r--addons/payment/views/payment_portal_templates.xml40
-rw-r--r--addons/payment_stripe/static/src/js/stripe.js11
-rw-r--r--addons/point_of_sale/models/pos_order.py10
-rw-r--r--addons/point_of_sale/static/src/js/models.js1
-rw-r--r--addons/point_of_sale/static/src/js/screens.js12
-rw-r--r--addons/pos_mercury/views/pos_mercury_transaction_templates.xml8
-rw-r--r--addons/project/controllers/portal.py16
-rw-r--r--addons/project/models/project.py6
-rw-r--r--addons/purchase/models/account_invoice.py6
-rw-r--r--addons/purchase/models/purchase.py11
-rw-r--r--addons/purchase/tests/test_create_picking.py50
-rw-r--r--addons/purchase/tests/test_stockvaluation.py73
-rw-r--r--addons/sale/models/analytic.py4
-rw-r--r--addons/sale_mrp/tests/test_sale_mrp_flow.py4
-rw-r--r--addons/sale_stock/models/sale_order.py12
-rw-r--r--addons/sale_stock/tests/test_sale_stock.py122
-rw-r--r--addons/sale_timesheet/models/product.py2
-rw-r--r--addons/sales_team/models/crm_team.py2
-rw-r--r--addons/stock/models/product.py10
-rw-r--r--addons/stock/models/stock_move.py4
-rw-r--r--addons/stock/models/stock_picking.py17
-rw-r--r--addons/stock/models/stock_production_lot.py14
-rw-r--r--addons/stock/tests/test_move.py188
-rw-r--r--addons/stock/wizard/stock_immediate_transfer.py7
-rw-r--r--addons/stock_account/models/account_invoice.py6
-rw-r--r--addons/stock_account/models/product.py4
-rw-r--r--addons/web/controllers/main.py2
-rw-r--r--addons/web/static/src/js/chrome/web_client.js11
-rw-r--r--addons/web/static/src/js/core/domain.js2
-rw-r--r--addons/web/static/src/js/core/utils.js17
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js4
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js73
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js1
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js9
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js211
-rw-r--r--addons/web/static/tests/views/kanban_tests.js56
-rw-r--r--addons/web/static/tests/views/list_tests.js58
-rw-r--r--addons/web_editor/static/src/js/widgets/ace.js4
-rw-r--r--addons/web_editor/static/src/js/widgets/widgets.js17
-rw-r--r--addons/website/models/website.py6
-rw-r--r--addons/website/views/website_templates.xml9
-rw-r--r--addons/website_event/models/event.py4
-rw-r--r--addons/website_forum/controllers/main.py4
-rw-r--r--addons/website_quote/models/sale_order.py7
-rw-r--r--addons/website_sale/views/templates.xml11
-rw-r--r--doc/_extensions/autojsdoc/parser/tests/test_typespec.py23
-rw-r--r--doc/_extensions/autojsdoc/parser/types.py3
-rw-r--r--doc/cla/individual/osarrat.md11
-rw-r--r--doc/cla/individual/subodhdahal.md11
-rw-r--r--doc/cla/individual/willmakenoise.md10
-rw-r--r--doc/reference/cmdline.rst4
-rw-r--r--doc/setup/deploy.rst19
-rw-r--r--odoo/addons/base/models/res_partner.py2
-rw-r--r--odoo/addons/base/views/ir_model_views.xml4
-rw-r--r--odoo/addons/test_impex/tests/test_export.py49
-rw-r--r--odoo/models.py96
-rw-r--r--odoo/service/wsgi_server.py2
79 files changed, 1274 insertions, 353 deletions
diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py
index fd2fef466fb..d280b6cb850 100644
--- a/addons/account/report/account_aged_partner_balance.py
+++ b/addons/account/report/account_aged_partner_balance.py
@@ -27,7 +27,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
res = []
total = []
cr = self.env.cr
- user_company = self.env.user.company_id.id
+ company_ids = self.env.context.get('company_ids', (self.env.user.company_id.id,))
move_state = ['draft', 'posted']
if target_move == 'posted':
move_state = ['posted']
@@ -41,7 +41,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
if reconciled_after_date:
reconciliation_clause = '(l.reconciled IS FALSE OR l.id IN %s)'
arg_list += (tuple(reconciled_after_date),)
- arg_list += (date_from, user_company)
+ arg_list += (date_from, tuple(company_ids))
query = '''
SELECT DISTINCT l.partner_id, UPPER(res_partner.name)
FROM account_move_line AS l left join res_partner on l.partner_id = res_partner.id, account_account, account_move am
@@ -51,7 +51,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
AND (account_account.internal_type IN %s)
AND ''' + reconciliation_clause + '''
AND (l.date <= %s)
- AND l.company_id = %s
+ AND l.company_id IN %s
ORDER BY UPPER(res_partner.name)'''
cr.execute(query, arg_list)
@@ -76,8 +76,8 @@ class ReportAgedPartnerBalance(models.AbstractModel):
AND (COALESCE(l.date_maturity,l.date) > %s)\
AND ((l.partner_id IN %s) OR (l.partner_id IS NULL))
AND (l.date <= %s)
- AND l.company_id = %s'''
- cr.execute(query, (tuple(move_state), tuple(account_type), date_from, tuple(partner_ids), date_from, user_company))
+ AND l.company_id IN %s'''
+ cr.execute(query, (tuple(move_state), tuple(account_type), date_from, tuple(partner_ids), date_from, tuple(company_ids)))
aml_ids = cr.fetchall()
aml_ids = aml_ids and [x[0] for x in aml_ids] or []
for line in self.env['account.move.line'].browse(aml_ids):
@@ -117,7 +117,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
else:
dates_query += ' <= %s)'
args_list += (periods[str(i)]['stop'],)
- args_list += (date_from, user_company)
+ args_list += (date_from, tuple(company_ids))
query = '''SELECT l.id
FROM account_move_line AS l, account_account, account_move am
@@ -127,7 +127,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
AND ((l.partner_id IN %s) OR (l.partner_id IS NULL))
AND ''' + dates_query + '''
AND (l.date <= %s)
- AND l.company_id = %s'''
+ AND l.company_id IN %s'''
cr.execute(query, args_list)
partners_amount = {}
aml_ids = cr.fetchall()
diff --git a/addons/base_gengo/wizard/base_gengo_translations.py b/addons/base_gengo/wizard/base_gengo_translations.py
index 0a585934af8..43d2e81ca33 100644
--- a/addons/base_gengo/wizard/base_gengo_translations.py
+++ b/addons/base_gengo/wizard/base_gengo_translations.py
@@ -62,9 +62,9 @@ class BaseGengoTranslations(models.TransientModel):
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
- 'res_model': 'res.company',
- 'res_id': self.env.user.company_id.id,
+ 'res_model': 'res.config.settings',
'target': 'current',
+ 'context': {'module' : 'general_settings'},
}
@api.model
diff --git a/addons/bus/static/src/js/bus.js b/addons/bus/static/src/js/bus.js
index 25a8b86903f..73b69013389 100644
--- a/addons/bus/static/src/js/bus.js
+++ b/addons/bus/static/src/js/bus.js
@@ -76,7 +76,8 @@ bus.Bus = Widget.extend({
this.last_partners_presence_check = now;
}
var data = {channels: self.channels, last: self.last, options: options};
- session.rpc('/longpolling/poll', data, {shadow : true}).then(function(result) {
+ // The backend has a maximum cycle time of 50 seconds so give +10 seconds
+ session.rpc('/longpolling/poll', data, {shadow : true, timeout: 60000}).then(function(result) {
self.on_notification(result);
if(!self.stop){
self.poll();
diff --git a/addons/crm/security/crm_security.xml b/addons/crm/security/crm_security.xml
index 235d6854f93..83f3197a5b1 100644
--- a/addons/crm/security/crm_security.xml
+++ b/addons/crm/security/crm_security.xml
@@ -34,16 +34,6 @@
<field name="domain_force">['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]</field>
</record>
- <record id="crm_rule_lead_assigned_activities" model="ir.rule">
- <field name="name">Leads with assigned activities</field>
- <field ref="model_crm_lead" name="model_id"/>
- <field name="domain_force">[('activity_ids.user_id','=',user.id)]</field>
- <field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
- <field name="perm_read" eval="True"/>
- <field name="perm_write" eval="False"/>
- <field name="perm_create" eval="False"/>
- <field name="perm_unlink" eval="False"/>
- </record>
<record id="crm_rule_all_lead" model="ir.rule">
<field name="name">All Leads</field>
<field ref="model_crm_lead" name="model_id"/>
diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py
index 5380365b501..2ed20b6a7c5 100644
--- a/addons/hr_expense/models/hr_expense.py
+++ b/addons/hr_expense/models/hr_expense.py
@@ -524,7 +524,7 @@ class HrExpenseSheet(models.Model):
@api.onchange('employee_id')
def _onchange_employee_id(self):
- self.address_id = self.employee_id.address_home_id
+ self.address_id = self.employee_id.sudo().address_home_id
self.department_id = self.employee_id.department_id
@api.depends('expense_line_ids.total_amount_company')
diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml
index 28a852af5df..c323a71a637 100644
--- a/addons/hr_expense/views/hr_expense_views.xml
+++ b/addons/hr_expense/views/hr_expense_views.xml
@@ -61,7 +61,7 @@
<field name="reference"/>
<field name="date"/>
<field name="account_id" domain="[('internal_type', '=', 'other')]" groups="account.group_account_user"/>
- <field name="employee_id"/>
+ <field name="employee_id" groups="hr_expense.group_hr_expense_user"/>
<field name="sheet_id" invisible="1"/>
<field name="currency_id" groups="base.group_multi_currency"/>
<field name="analytic_account_id" groups="analytic.group_analytic_accounting"/>
diff --git a/addons/hr_holidays/models/hr.py b/addons/hr_holidays/models/hr.py
index e74e60f314d..bf8fbddd5ea 100644
--- a/addons/hr_holidays/models/hr.py
+++ b/addons/hr_holidays/models/hr.py
@@ -218,7 +218,12 @@ class Employee(models.Model):
def write(self, values):
res = super(Employee, self).write(values)
- if 'parent_id' in values:
+ if 'parent_id' in values or 'department_id' in values:
holidays = self.env['hr.holidays'].search([('state', 'in', ['draft', 'confirm']), ('employee_id', 'in', self.ids)])
- holidays.write({'manager_id': values['parent_id']})
+ hr_vals = {}
+ if values.get('parent_id') is not None:
+ hr_vals['manager_id'] = values['parent_id']
+ if values.get('department_id') is not None:
+ hr_vals['department_id'] = values['department_id']
+ holidays.write(hr_vals)
return res
diff --git a/addons/hr_holidays/models/hr_holidays.py b/addons/hr_holidays/models/hr_holidays.py
index 51664bc1db6..e8672298603 100644
--- a/addons/hr_holidays/models/hr_holidays.py
+++ b/addons/hr_holidays/models/hr_holidays.py
@@ -217,6 +217,7 @@ class Holidays(models.Model):
@api.onchange('employee_id')
def _onchange_employee_id(self):
self.manager_id = self.employee_id and self.employee_id.parent_id
+ self.department_id = self.employee_id and self.employee_id.department_id
@api.multi
@api.depends('number_of_days_temp', 'type')
diff --git a/addons/hr_payroll_account/models/hr_payroll_account.py b/addons/hr_payroll_account/models/hr_payroll_account.py
index e76173d45a4..da168912095 100644
--- a/addons/hr_payroll_account/models/hr_payroll_account.py
+++ b/addons/hr_payroll_account/models/hr_payroll_account.py
@@ -158,7 +158,6 @@ class HrContract(models.Model):
class HrPayslipRun(models.Model):
_inherit = 'hr.payslip.run'
- _description = 'Payslip Run'
journal_id = fields.Many2one('account.journal', 'Salary Journal', states={'draft': [('readonly', False)]}, readonly=True,
required=True, default=lambda self: self.env['account.journal'].search([('type', '=', 'general')], limit=1))
diff --git a/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml b/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml
index 14ad3eaefa0..0f6bb86479f 100644
--- a/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml
+++ b/addons/l10n_be_hr_payroll/data/l10n_be_hr_payroll_data.xml
@@ -545,7 +545,6 @@ result = -contract.car_atn
ref('hr_salary_rule_representation_fees'),
ref('hr_payroll_rules_suppl_net'),
ref('hr_payroll_rules_retained_net'),
- ref('hr_salary_rule_commission_on_target'),
ref('hr_salary_rule_pp_reduction_employees'),
])]" name="rule_ids"/>
@@ -571,7 +570,6 @@ result = -contract.car_atn
ref('hr_salary_rule_representation_fees'),
ref('hr_payroll_rules_suppl_net'),
ref('hr_payroll_rules_retained_net'),
- ref('hr_salary_rule_commission_on_target'),
ref('hr_salary_rule_pp_reduction_workers')
])]" name="rule_ids"/>
<field name="company_id" ref="base.main_company"/>
diff --git a/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py b/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py
index c80ccc983a5..6174699920c 100644
--- a/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py
+++ b/addons/l10n_be_hr_payroll/models/l10n_be_hr_payroll.py
@@ -116,8 +116,8 @@ class HrContract(models.Model):
@api.depends('commission_on_target')
def _compute_warrants_cost(self):
for contract in self:
- contract.warrants_cost = contract.commission_on_target * 1.326 / 1.05 * 12.0
- contract.warrant_value_employee = contract.warrants_cost * 0.54
+ contract.warrants_cost = contract.commission_on_target * 1.05 * 12.0
+ contract.warrant_value_employee = contract.commission_on_target * (1.00 - 0.535) * 12.0
@api.depends('wage', 'fuel_card', 'representation_fees', 'transport_employer_cost',
'internet', 'mobile', 'mobile_plus')
@@ -230,7 +230,7 @@ class HrContract(models.Model):
- 12.0 * contract.internet \
- 12.0 * (contract.mobile + contract.mobile_plus) \
- 12.0 * contract.transport_employer_cost \
- - (1.326 / 1.05 * 12.0) * contract.commission_on_target \
+ - contract.warrants_cost \
- 220.0 * contract.meal_voucher_paid_by_employer
gross = remaining_for_gross / (13.0 + 13.0 * 0.3507 + 0.92)
return gross
diff --git a/addons/mail/models/ir_model.py b/addons/mail/models/ir_model.py
index 3a312cfe843..04529610f0e 100644
--- a/addons/mail/models/ir_model.py
+++ b/addons/mail/models/ir_model.py
@@ -74,6 +74,6 @@ class IrModelField(models.Model):
def _instanciate_attrs(self, field_data):
attrs = super(IrModelField, self)._instanciate_attrs(field_data)
- if field_data.get('track_visibility'):
+ if attrs and field_data.get('track_visibility'):
attrs['track_visibility'] = field_data['track_visibility']
return attrs
diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py
index f4e79cbf8eb..e24a3193f97 100644
--- a/addons/mail/models/mail_channel.py
+++ b/addons/mail/models/mail_channel.py
@@ -211,7 +211,7 @@ class Channel(models.Model):
# http://blogs.technet.com/b/exchange/archive/2006/10/06/3395024.aspx
headers['X-Auto-Response-Suppress'] = 'OOF'
if self.alias_domain and self.alias_name:
- headers['List-Id'] = '%s.%s' % (self.alias_name, self.alias_domain)
+ headers['List-Id'] = '<%s.%s>' % (self.alias_name, self.alias_domain)
headers['List-Post'] = '<mailto:%s@%s>' % (self.alias_name, self.alias_domain)
# Avoid users thinking it was a personal message
# X-Forge-To: will replace To: after SMTP envelope is determined by ir.mail.server
@@ -505,7 +505,15 @@ class Channel(models.Model):
partners_to_add = partners - channel.channel_partner_ids
channel.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': partner_id}) for partner_id in partners_to_add.ids]})
for partner in partners_to_add:
- notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (self.id, self.name,)
+ if partner.id != self.env.user.partner_id.id:
+ notification = _('<div class="o_mail_notification">%(author)s invited %(new_partner)s to <a href="#" class="o_channel_redirect" data-oe-id="%(channel_id)s">#%(channel_name)s</a></div>') % {
+ 'author': self.env.user.display_name,
+ 'new_partner': partner.display_name,
+ 'channel_id': channel.id,
+ 'channel_name': channel.name,
+ }
+ else:
+ notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (channel.id, channel.name,)
self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id)
# broadcast the channel header to the added partner
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py
index 45d07d5b97c..62f0f524ebf 100644
--- a/addons/mail/models/mail_message.py
+++ b/addons/mail/models/mail_message.py
@@ -11,7 +11,7 @@ from odoo.exceptions import UserError, AccessError
from odoo.osv import expression
_logger = logging.getLogger(__name__)
-_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/]{3,}=*)([\'"])', re.I)
+_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])', re.I)
class Message(models.Model):
diff --git a/addons/mail/static/src/js/thread.js b/addons/mail/static/src/js/thread.js
index 68a01df2345..f63c7073af9 100644
--- a/addons/mail/static/src/js/thread.js
+++ b/addons/mail/static/src/js/thread.js
@@ -210,6 +210,10 @@ var Thread = Widget.extend({
}
},
on_click_redirect: function (event) {
+ // ignore inherited branding
+ if ($(event.target).data('oe-field') !== undefined) {
+ return;
+ }
var id = $(event.target).data('oe-id');
if (id) {
event.preventDefault();
diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js
index 49d1705eccd..22e9a51b9dd 100644
--- a/addons/mail/static/src/js/utils.js
+++ b/addons/mail/static/src/js/utils.js
@@ -60,7 +60,7 @@ function _parse_and_transform(nodes, transform_function) {
// Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
// Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match.
-var url_regexp = /\b(?:https?:\/\/|(www\.))[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;
+var url_regexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_\+.~#?&//=]*)/gi;
function linkify(text, attrs) {
attrs = attrs || {};
if (attrs.target === undefined) {
@@ -70,7 +70,7 @@ function linkify(text, attrs) {
return key + '="' + _.escape(value) + '"';
}).join(' ');
return text.replace(url_regexp, function (url) {
- var href = (!/^(f|ht)tps?:\/\//i.test(url)) ? "http://" + url : url;
+ var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url;
return '<a ' + attrs + ' href="' + href + '">' + url + '</a>';
});
}
diff --git a/addons/mail/static/tests/mail_utils_tests.js b/addons/mail/static/tests/mail_utils_tests.js
new file mode 100644
index 00000000000..e8ec776a8d5
--- /dev/null
+++ b/addons/mail/static/tests/mail_utils_tests.js
@@ -0,0 +1,34 @@
+odoo.define('mail.mail_utils_tests', function (require) {
+"use strict";
+
+var utils = require('mail.utils');
+
+QUnit.module('mail', {}, function () {
+
+QUnit.module('Mail utils');
+
+QUnit.test('add_link utility function', function (assert) {
+ assert.expect(7);
+
+ var testInputs = {
+ 'http://admin:password@example.com:8/%2020': true,
+ 'https://admin:password@example.com/test': true,
+ 'www.example.com:8/test': true,
+ 'https://127.0.0.5:8069': true,
+ 'www.127.0.0.5': false,
+ 'should.notmatch': false,
+ 'fhttps://test.example.com/test': false,
+ };
+
+ _.each(testInputs, function (willLinkify, content) {
+ var output = utils.parse_and_transform(content, utils.add_link);
+ if (willLinkify) {
+ assert.strictEqual(output.indexOf('<a '), 0, "There should be a link");
+ } else {
+ assert.strictEqual(output.indexOf('<a '), -1, "There should be no link");
+ }
+ });
+});
+
+});
+});
diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml
index 31bbcf1ee47..a63f6d0edc4 100644
--- a/addons/mail/views/mail_templates.xml
+++ b/addons/mail/views/mail_templates.xml
@@ -44,6 +44,7 @@
<template id="qunit_suite" name="mail_tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/mail/static/tests/chatter_tests.js"></script>
+ <script type="text/javascript" src="/mail/static/tests/mail_utils_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/systray_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/chat_window_test.js"></script>
</xpath>
diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py
index 4d9a0e5bcbe..8e5ce0ad6c8 100644
--- a/addons/mrp/models/mrp_bom.py
+++ b/addons/mrp/models/mrp_bom.py
@@ -81,6 +81,11 @@ class MrpBom(models.Model):
if self.product_tmpl_id:
self.product_uom_id = self.product_tmpl_id.uom_id.id
+ @api.onchange('routing_id')
+ def onchange_routing_id(self):
+ for line in self.bom_line_ids:
+ line.operation_id = False
+
@api.multi
def name_get(self):
return [(bom.id, '%s%s' % (bom.code and '%s: ' % bom.code or '', bom.product_tmpl_id.display_name)) for bom in self]
diff --git a/addons/mrp_repair/views/mrp_repair_views.xml b/addons/mrp_repair/views/mrp_repair_views.xml
index 753e8287096..db899a5b06a 100644
--- a/addons/mrp_repair/views/mrp_repair_views.xml
+++ b/addons/mrp_repair/views/mrp_repair_views.xml
@@ -50,7 +50,7 @@
</h1>
<group>
<group>
- <field name="tracking" invisible="1"/>
+ <field name="tracking" invisible="1" attrs="{'readonly': 1}"/>
<field name="product_id" domain="[('type', 'in', ['product', 'consu'])]"/>
<label for="product_qty"/>
<div class="o_row">
@@ -116,7 +116,7 @@
<field name="product_uom_qty" string="Quantity"/>
<field name="product_uom" string="Unit of Measure" groups="product.group_uom"/>
<field name="price_unit"/>
- <field name="tax_id" widget="many2many_tags"/>
+ <field name="tax_id" widget="many2many_tags" domain="[('type_tax_use','=','sale')]"/>
<field name="price_subtotal" widget="monetary"/>
</tree>
</field>
diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py
index 4047ef7d30a..444d936429b 100644
--- a/addons/payment/controllers/portal.py
+++ b/addons/payment/controllers/portal.py
@@ -31,49 +31,110 @@ class WebsitePayment(http.Controller):
currency_id = currency_id and int(currency_id) or user.company_id.currency_id.id
currency = env['res.currency'].browse(currency_id)
- # Try default one then fallback on first
- acquirer_id = acquirer_id and int(acquirer_id) or \
- env['ir.default'].get('payment.transaction', 'acquirer_id', company_id=user.company_id.id) or \
- env['payment.acquirer'].search([('website_published', '=', True), ('company_id', '=', user.company_id.id)])[0].id
-
- acquirer = env['payment.acquirer'].with_context(submit_class='btn btn-primary pull-right',
- submit_txt=_('Pay Now')).browse(acquirer_id)
+ acquirers = None
+ if acquirer_id:
+ acquirers = env['payment.acquirer'].browse(int(acquirer_id))
+ if not acquirers:
+ acquirers = env['payment.acquirer'].search([('website_published', '=', True), ('company_id', '=', user.company_id.id)])
# auto-increment reference with a number suffix if the reference already exists
reference = request.env['payment.transaction'].get_next_reference(reference)
partner_id = user.partner_id.id if not user._is_public() else False
- payment_form = acquirer.sudo().render(reference, float(amount), currency.id, values={'return_url': '/website_payment/confirm', 'partner_id': partner_id})
values = {
'reference': reference,
- 'acquirer': acquirer,
'currency': currency,
'amount': float(amount),
- 'payment_form': payment_form,
+ 'return_url': '/website_payment/confirm',
+ 'partner_id': partner_id,
+ 'bootstrap_formatting': True,
+ 'error_msg': kw.get('error_msg')
}
- # TODO: remove return when payment is implemented with refactored payments, i.e. use of
- # payment.payment_tokens_list in the template.
- # return request.render('payment.pay', values)
- return request.render('website.404')
- @http.route(['/website_payment/transaction'], type='json', auth="public", website=True)
- def transaction(self, reference, amount, currency_id, acquirer_id):
+ values['s2s_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 's2s']
+ values['form_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 'form']
+ values['pms'] = request.env['payment.token'].search([('acquirer_id', 'in', [acq.id for acq in values['s2s_acquirers']])])
+
+ return request.render('payment.pay', values)
+
+ def _get_existing_transaction(self, reference, amount, partner_id, currency_id, acquirer_id, tx_id):
+ PaymentTransaction = request.env['payment.transaction']
+ tx = None
+ if tx_id:
+ tx = PaymentTransaction.sudo().browse(tx_id)
+ if not tx.exists() or tx.reference != reference or tx.acquirer_id.id != acquirer_id:
+ tx = None
+
+ if not tx:
+ tx = PaymentTransaction.sudo().search([('reference', '=', reference), ('acquirer_id', '=', acquirer_id)])
+
+ if tx and (tx.state != 'draft' or tx.partner_id.id != partner_id or tx.amount != amount or tx.currency_id.id != currency_id):
+ tx = None
+
+ return tx
+
+ @http.route(['/website_payment/transaction/<string:reference>/<string:amount>/<string:currency_id>',], type='json', auth='public')
+ def transaction(self, acquirer_id, reference, amount, currency_id, **kwargs):
partner_id = request.env.user.partner_id.id if not request.env.user._is_public() else False
+ acquirer = request.env['payment.acquirer'].browse(acquirer_id)
+
+ tx = self._get_existing_transaction(reference, float(amount), partner_id,
+ int(currency_id), int(acquirer_id), request.session.get('website_payment_tx_id'))
+
+ if not tx:
+ values = {
+ 'acquirer_id': int(acquirer_id),
+ 'reference': reference,
+ 'amount': float(amount),
+ 'currency_id': currency_id,
+ 'partner_id': partner_id,
+ }
+
+ tx = request.env['payment.transaction'].sudo().create(values)
+ request.session['website_payment_tx_id'] = tx.id
+
+ render_values = {
+ 'return_url': '/website_payment/confirm?tx_id=%d' % tx.id,
+ 'partner_id': partner_id,
+ }
+
+ return acquirer.sudo().render(reference, float(amount), int(currency_id), values=render_values)
+
+ @http.route(['/website_payment/token/<string:reference>/<float:amount>/<int:currency_id>'], type='http', auth='public', website=True)
+ def payment_token(self, pm_id, reference, amount, currency_id, return_url=None, **kwargs):
+ token = request.env['payment.token'].browse(int(pm_id))
+
+ if not token:
+ return request.redirect('/website_payment/pay?error_msg=%s' % _('Cannot setup the payment.'))
+
+ partner_id = request.env.user.partner_id.id if not request.env.user._is_public() else False
+
values = {
- 'acquirer_id': int(acquirer_id),
+ 'acquirer_id': token.acquirer_id.id,
'reference': reference,
'amount': float(amount),
'currency_id': int(currency_id),
'partner_id': partner_id,
+ 'payment_token_id': pm_id
}
tx = request.env['payment.transaction'].sudo().create(values)
request.session['website_payment_tx_id'] = tx.id
- return tx.id
+
+ try:
+ res = tx.s2s_do_transaction()
+ except Exception as e:
+ return request.redirect('/website_payment/pay?error_msg=%s' % _('Payment transaction failed.'))
+
+ valid_state = 'authorized' if tx.acquirer_id.capture_manually else 'done'
+ if not res or tx.state != valid_state:
+ return request.redirect('/website_payment/pay?error_msg=%s' % _('Payment transaction failed.'))
+
+ return request.redirect(return_url if return_url else '/website_payment/confirm?tx_id=%d' % tx.id)
@http.route(['/website_payment/confirm'], type='http', auth='public', website=True)
def confirm(self, **kw):
- tx_id = request.session.pop('website_payment_tx_id', False)
+ tx_id = int(kw.get('tx_id', 0)) or request.session.pop('website_payment_tx_id', 0)
if tx_id:
tx = request.env['payment.transaction'].browse(tx_id)
if tx.state == 'done':
diff --git a/addons/payment/static/src/js/payment_transaction_portal.js b/addons/payment/static/src/js/payment_transaction_portal.js
index 26c0fb35d1e..094149b6274 100644
--- a/addons/payment/static/src/js/payment_transaction_portal.js
+++ b/addons/payment/static/src/js/payment_transaction_portal.js
@@ -1,24 +1,2 @@
-odoo.define('payment.website_payment', function (require) {
-"use strict";
-
-require('web.dom_ready');
-var ajax = require('web.ajax');
-
- if (!$('.o_website_payment').length) {
- return $.Deferred().reject("DOM doesn't contain '.o_website_payment'");
- }
-
- // When clicking on payment button: create the tx using json then continue to the acquirer
- var $payment = $(".o_website_payment_form");
- $payment.on("click", 'button[type="submit"],button[name="submit"]', function (ev) {
- ev.preventDefault();
- ev.stopPropagation();
- $(ev.currentTarget).attr('disabled', true);
- $(ev.currentTarget).prepend('<i class="fa fa-refresh fa-spin"></i> ');
- var $form = $(ev.currentTarget).parents('form');
- var data =$("div[class~='o_website_payment_new_payment']").data();
- ajax.jsonRpc('/website_payment/transaction/', 'call', data).then(function (result) {
- $form.submit();
- });
- });
-});
+// This file is kept to avoid breaking assets compilation on instance that didn't run "-u payment".
+// TBE TODO: Remove me in master \ No newline at end of file
diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml
index d2fa3c1c4cd..166cd429718 100644
--- a/addons/payment/views/payment_portal_templates.xml
+++ b/addons/payment/views/payment_portal_templates.xml
@@ -46,32 +46,22 @@
<div class="wrap">
<div class="container o_website_payment">
<h1>Payment</h1>
- <div class="row">
- <div class="col-md-6">
- <div class="oe_structure"></div>
- <div>
- <div class="form-horizontal row o_website_payment_new_payment" t-att-data-acquirer_id="acquirer.id" t-att-data-currency_id="currency.id" t-att-data-reference="reference" t-att-data-amount="amount">
- <div class="form-group">
- <label for="form_reference" class="col-sm-3 control-label">Reference</label>
- <div class="col-sm-9">
- <span name="form_reference" class="form-control" t-esc="reference"/>
- </div>
- </div>
- <div class="form-group">
- <label for="form_amount" class="col-sm-3 control-label">Amount</label>
- <div class="col-sm-9">
- <span name="form_amount" class="form-control" t-esc="amount" t-options="{'widget': 'monetary', 'display_currency': currency}"/>
- </div>
- </div>
- <div class="col-sm-9 col-sm-offset-3 text-muted mt16" t-raw="acquirer.pre_msg"/>
- <div class="col-sm-12 o_website_payment_form" t-raw="payment_form"/>
- <div class="col-sm-6 col-sm-offset-6 text-muted mt16">
- <span t-field="acquirer.image_small" t-att-title="acquirer.name" t-options='{"widget": "image", "style":"max-width: 60px; display: inline-block"}'/>
- <span>Processed by <t t-esc="acquirer.name"/>.</span>
- </div>
- </div>
+ <div t-if="error_msg" class="alert alert-danger col-md-7">
+ <t t-esc="error_msg"/>
+ </div>
+ <div class="row mt32 mb32">
+ <div class="col-md-7">
+ <p><b>Reference:</b> <t t-esc="reference"/></p>
+ <p><b>Amount:</b> <t t-esc="amount" t-options="{'widget': 'monetary', 'display_currency': currency}"/></p>
+ <t t-call="payment.payment_tokens_list" t-if="reference and amount and currency">
+ <t t-set="mode" t-value="'payment'"/>
+ <t t-set="prepare_tx_url" t-value="'/website_payment/transaction/' + reference + '/' + str(amount) + '/' + str(currency.id)"/>
+ <t t-set="form_action" t-value="'/website_payment/token/' + reference + '/' + str(amount) + '/' + str(currency.id)"/>
+ </t>
+ <div t-if="not (s2s_acquirers or form_acquirers)" class="alert alert-danger">
+ <p>No payment acquirer found.</p>
+ <p>Please configure a payment acquirer.</p>
</div>
- <div class="oe_structure"/>
</div>
</div>
</div>
diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js
index a9395ea7bd6..7ecf25284f6 100644
--- a/addons/payment_stripe/static/src/js/stripe.js
+++ b/addons/payment_stripe/static/src/js/stripe.js
@@ -108,11 +108,14 @@ odoo.define('payment_stripe.stripe', function(require) {
if ($('.o_website_payment').length !== 0) {
- var create_tx = ajax.jsonRpc('/website_payment/transaction', 'call', {
- reference: $("input[name='invoice_num']").val(),
- amount: amount, // exact amount, not stripe cents
- currency_id: currency_id,
+ var invoice_num = $("input[name='invoice_num']").val();
+ var url = _.str.sprintf("/website_payment/transaction/%s/%f/%s",
+ invoice_num, amount, currency_id);
+
+ var create_tx = ajax.jsonRpc(url, 'call', {
acquirer_id: acquirer_id
+ }).then(function (data) {
+ try { provider_form[0].innerHTML = data; } catch (e) {}
});
}
else if ($('.o_website_quote').length !== 0) {
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index 19412db6cf6..af94179c442 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -227,10 +227,14 @@ class PosOrder(models.Model):
return InvoiceLine.sudo().create(inv_line)
def _create_account_move_line(self, session=None, move=None):
- def _flatten_tax_and_children(taxes):
+ def _flatten_tax_and_children(taxes, group_done=None):
children = self.env['account.tax']
- for tax in taxes:
- children |= _flatten_tax_and_children(tax.children_tax_ids)
+ if group_done is None:
+ group_done = set()
+ for tax in taxes.filtered(lambda t: t.amount_type == 'group'):
+ if tax.id not in group_done:
+ group_done.add(tax.id)
+ children |= _flatten_tax_and_children(tax.children_tax_ids, group_done)
return taxes + children
# Tricky, via the workflow, we only have one id in the ids variable
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index 99fdc1ec7db..e6b00499e62 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -729,6 +729,7 @@ exports.PosModel = Backbone.Model.extend({
deferred.resolve();
};
+ img.crossOrigin = 'use-credentials';
img.src = url;
return deferred;
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
index 4e53d259248..dadd9cffbb8 100644
--- a/addons/point_of_sale/static/src/js/screens.js
+++ b/addons/point_of_sale/static/src/js/screens.js
@@ -1170,8 +1170,9 @@ var ClientListScreenWidget = ScreenWidget.extend({
var order = this.pos.get_order();
if( this.has_client_changed() ){
var default_fiscal_position_id = _.findWhere(this.pos.fiscal_positions, {'id': this.pos.config.default_fiscal_position_id[0]});
- if ( this.new_client ) {
- order.fiscal_position = _.findWhere(this.pos.fiscal_positions, {'id': this.new_client.property_account_position_id[0]});
+ if ( this.new_client && this.new_client.property_account_position_id ) {
+ var client_fiscal_position_id = _.findWhere(this.pos.fiscal_positions, {'id': this.new_client.property_account_position_id[0]});
+ order.fiscal_position = client_fiscal_position_id || default_fiscal_position_id;
order.set_pricelist(_.findWhere(this.pos.pricelists, {'id': this.new_client.property_product_pricelist[0]}) || this.pos.default_pricelist);
} else {
order.fiscal_position = default_fiscal_position_id;
@@ -1277,9 +1278,14 @@ var ClientListScreenWidget = ScreenWidget.extend({
.then(function(partner_id){
self.saved_client_details(partner_id);
},function(type,err){
+ var error_body = _t('Your Internet connection is probably down.');
+ if (err.data) {
+ var except = err.data;
+ error_body = except.arguments && except.arguments[0] || except.message || error_body;
+ }
self.gui.show_popup('error',{
'title': _t('Error: Could not Save Changes'),
- 'body': _t('Your Internet connection is probably down.'),
+ 'body': error_body,
});
});
},
diff --git a/addons/pos_mercury/views/pos_mercury_transaction_templates.xml b/addons/pos_mercury/views/pos_mercury_transaction_templates.xml
index 0ac0f8edb75..3573672acb4 100644
--- a/addons/pos_mercury/views/pos_mercury_transaction_templates.xml
+++ b/addons/pos_mercury/views/pos_mercury_transaction_templates.xml
@@ -6,11 +6,11 @@
<TranType><t t-esc='transaction_type'/></TranType>
<TranCode><t t-esc='transaction_code'/></TranCode>
<InvoiceNo><t t-esc='invoice_no'/></InvoiceNo>
- <Frequency>OneTime</Frequency>
+ <Frequency t-translation="off">OneTime</Frequency>
</template>
<template id='mercury_transaction'>
- <TStream>
+ <TStream t-translation="off">
<Transaction>
<t t-call='pos_mercury.mercury_common'/>
<OperatorID><t t-esc='operator_id'/></OperatorID>
@@ -31,7 +31,7 @@
</template>
<template id='mercury_voidsale'>
- <TStream>
+ <TStream t-translation="off">
<Transaction>
<t t-call='pos_mercury.mercury_common'/>
<RefNo><t t-esc='ref_no'/></RefNo>
@@ -52,7 +52,7 @@
</template>
<template id='mercury_return'>
- <TStream>
+ <TStream t-translation="off">
<Transaction>
<t t-call='pos_mercury.mercury_common'/>
<OperatorID><t t-esc='operator_id'/></OperatorID>
diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py
index 621113a385b..e847875f2d1 100644
--- a/addons/project/controllers/portal.py
+++ b/addons/project/controllers/portal.py
@@ -14,10 +14,12 @@ class CustomerPortal(CustomerPortal):
def _prepare_portal_layout_values(self):
values = super(CustomerPortal, self)._prepare_portal_layout_values()
- values.update({
- 'project_count': request.env['project.project'].search_count([('privacy_visibility', '=', 'portal')]),
- 'task_count': request.env['project.task'].search_count([('project_id.privacy_visibility', '=', 'portal')])
- })
+ Project = request.env['project.project']
+ Task = request.env['project.task']
+ # portal users can't view projects they don't follow
+ projects = Project.sudo().search([('privacy_visibility', '=', 'portal')])
+ values['project_count'] = Project.search_count([('id', 'in', projects.ids)])
+ values['task_count'] = Task.search_count([('project_id', 'in', projects.ids)])
return values
@http.route(['/my/projects', '/my/projects/page/<int:page>'], type='http', auth="user", website=True)
@@ -77,8 +79,6 @@ class CustomerPortal(CustomerPortal):
@http.route(['/my/tasks', '/my/tasks/page/<int:page>'], type='http', auth="user", website=True)
def portal_my_tasks(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', **kw):
values = self._prepare_portal_layout_values()
- domain = [('project_id.privacy_visibility', '=', 'portal')]
-
searchbar_sortings = {
'date': {'label': _('Newest'), 'order': 'create_date desc'},
'name': {'label': _('Title'), 'order': 'name'},
@@ -96,7 +96,9 @@ class CustomerPortal(CustomerPortal):
'all': {'input': 'all', 'label': _('Search in All')},
}
# extends filterby criteria with project (criteria name is the project id)
- projects = request.env['project.project'].search([('privacy_visibility', '=', 'portal')])
+ # Note: portal users can't view projects they don't follow
+ projects = request.env['project.project'].sudo().search([('privacy_visibility', '=', 'portal')])
+ domain = [('project_id', 'in', projects.ids)]
for proj in projects:
searchbar_filters.update({
str(proj.id): {'label': proj.name, 'domain': [('project_id', '=', proj.id)]}
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index e4b7f259588..b240fdefcc7 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -593,14 +593,12 @@ class Task(models.Model):
@api.onchange('project_id')
def _onchange_project(self):
- default_partner_id = self.env.context.get('default_partner_id')
- default_partner = self.env['res.partner'].browse(default_partner_id) if default_partner_id else self.env['res.partner']
if self.project_id:
- self.partner_id = self.project_id.partner_id or default_partner
+ if self.project_id.partner_id:
+ self.partner_id = self.project_id.partner_id
if self.project_id not in self.stage_id.project_ids:
self.stage_id = self.stage_find(self.project_id.id, [('fold', '=', False)])
else:
- self.partner_id = default_partner
self.stage_id = False
@api.onchange('user_id')
diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py
index ef23444c32c..4847ddf11ee 100644
--- a/addons/purchase/models/account_invoice.py
+++ b/addons/purchase/models/account_invoice.py
@@ -192,13 +192,13 @@ class AccountInvoice(models.Model):
if child.type_tax_use != 'none':
tax_ids.append((4, child.id, None))
price_before = line.get('price', 0.0)
- line.update({'price': company_currency.round(valuation_price_unit * line['quantity'])})
+ line.update({'price': inv.currency_id.round(valuation_price_unit * line['quantity'])})
diff_res.append({
'type': 'src',
'name': i_line.name[:64],
- 'price_unit': company_currency.round(price_unit - valuation_price_unit),
+ 'price_unit': inv.currency_id.round(price_unit - valuation_price_unit),
'quantity': line['quantity'],
- 'price': company_currency.round(price_before - line.get('price', 0.0)),
+ 'price': inv.currency_id.round(price_before - line.get('price', 0.0)),
'account_id': acc,
'product_id': line['product_id'],
'uom_id': line['uom_id'],
diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py
index 1aee90fc83c..a63a2d679e4 100644
--- a/addons/purchase/models/purchase.py
+++ b/addons/purchase/models/purchase.py
@@ -695,7 +695,7 @@ class PurchaseOrderLine(models.Model):
qty = 0.0
price_unit = self._get_stock_move_price_unit()
for move in self.move_ids.filtered(lambda x: x.state != 'cancel' and not x.location_dest_id.usage == "supplier"):
- qty += move.product_qty
+ qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
template = {
'name': self.name or '',
'product_id': self.product_id.id,
@@ -719,7 +719,14 @@ class PurchaseOrderLine(models.Model):
}
diff_quantity = self.product_qty - qty
if float_compare(diff_quantity, 0.0, precision_rounding=self.product_uom.rounding) > 0:
- template['product_uom_qty'] = diff_quantity
+ quant_uom = self.product_id.uom_id
+ get_param = self.env['ir.config_parameter'].sudo().get_param
+ if self.product_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1':
+ product_qty = self.product_uom._compute_quantity(diff_quantity, quant_uom, rounding_method='HALF-UP')
+ template['product_uom'] = quant_uom.id
+ template['product_uom_qty'] = product_qty
+ else:
+ template['product_uom_qty'] = diff_quantity
res.append(template)
return res
diff --git a/addons/purchase/tests/test_create_picking.py b/addons/purchase/tests/test_create_picking.py
index b29e85b3825..9cdfe0fb4fd 100644
--- a/addons/purchase/tests/test_create_picking.py
+++ b/addons/purchase/tests/test_create_picking.py
@@ -174,3 +174,53 @@ class TestCreatePicking(common.TestProductCommon):
customer_move._action_assign()
self.assertEqual(customer_move.state, 'assigned', 'Reservation should work with the new quantity provided by the PO.')
+
+ def test_03_uom(self):
+ """ Buy a dozen of products stocked in units. Check that the quantities on the purchase order
+ lines as well as the received quantities are handled in dozen while the moves themselves
+ are handled in units. Edit the ordered quantities, check that the quantites are correctly
+ updated on the moves. Edit the ir.config_parameter to propagate the uom of the purchase order
+ lines to the moves and edit a last time the ordered quantities. Receive, check the quantities.
+ """
+ uom_unit = self.env.ref('product.product_uom_unit')
+ uom_dozen = self.env.ref('product.product_uom_dozen')
+
+ self.assertEqual(self.product_id_1.uom_po_id.id, uom_unit.id)
+
+ # buy a dozen
+ po = self.env['purchase.order'].create(self.po_vals)
+
+ po.order_line.product_qty = 1
+ po.order_line.product_uom = uom_dozen.id
+ po.button_confirm()
+
+ # the move should be 12 units
+ # note: move.product_qty = computed field, always in the uom of the quant
+ # move.product_uom_qty = stored field representing the initial demand in move.product_uom
+ move1 = po.picking_ids.move_lines[0]
+ self.assertEqual(move1.product_uom_qty, 12)
+ self.assertEqual(move1.product_uom.id, uom_unit.id)
+ self.assertEqual(move1.product_qty, 12)
+
+ # edit the so line, sell 2 dozen, the move should now be 24 units
+ po.order_line.product_qty = 2
+ self.assertEqual(move1.product_uom_qty, 24)
+ self.assertEqual(move1.product_uom.id, uom_unit.id)
+ self.assertEqual(move1.product_qty, 24)
+
+ # force the propagation of the uom, sell 3 dozen
+ self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1')
+ po.order_line.product_qty = 3
+ move2 = po.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id)
+ self.assertEqual(move2.product_uom_qty, 1)
+ self.assertEqual(move2.product_uom.id, uom_dozen.id)
+ self.assertEqual(move2.product_qty, 12)
+
+ # deliver everything
+ move1.quantity_done = 24
+ move2.quantity_done = 1
+ po.picking_ids.button_validate()
+
+ # check the delivered quantity
+ self.assertEqual(po.order_line.qty_received, 3.0)
+
diff --git a/addons/purchase/tests/test_stockvaluation.py b/addons/purchase/tests/test_stockvaluation.py
index 0421bde3f71..83f44229d21 100644
--- a/addons/purchase/tests/test_stockvaluation.py
+++ b/addons/purchase/tests/test_stockvaluation.py
@@ -198,3 +198,76 @@ class TestStockValuation(TransactionCase):
self.assertEquals(move1.price_unit, price_unit_usd_new_rate)
self.assertAlmostEqual(self.product1.stock_value, price_unit_usd_new_rate * 10, delta=0.1)
+
+ def test_extra_move_fifo_1(self):
+ """ Check that the extra move when over processing a receipt is correctly merged back in
+ the original move.
+ """
+ self.product1.product_tmpl_id.cost_method = 'fifo'
+ po1 = self.env['purchase.order'].create({
+ 'partner_id': self.partner_id.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': self.product1.name,
+ 'product_id': self.product1.id,
+ 'product_qty': 10.0,
+ 'product_uom': self.product1.uom_po_id.id,
+ 'price_unit': 100.0,
+ 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ }),
+ ],
+ })
+ po1.button_confirm()
+
+ picking1 = po1.picking_ids[0]
+ move1 = picking1.move_lines[0]
+ move1.quantity_done = 15
+ res_dict = picking1.button_validate()
+ self.assertEqual(res_dict['res_model'], 'stock.overprocessed.transfer')
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ wizard.action_confirm()
+
+ # there should be only one move
+ self.assertEqual(len(picking1.move_lines), 1)
+ self.assertEqual(move1.price_unit, 100)
+ self.assertEqual(move1.product_qty, 15)
+ self.assertEqual(self.product1.stock_value, 1500)
+
+ def test_backorder_fifo_1(self):
+ """ Check that the backordered move when under processing a receipt correctly keep the
+ price unit of the original move.
+ """
+ self.product1.product_tmpl_id.cost_method = 'fifo'
+ po1 = self.env['purchase.order'].create({
+ 'partner_id': self.partner_id.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': self.product1.name,
+ 'product_id': self.product1.id,
+ 'product_qty': 10.0,
+ 'product_uom': self.product1.uom_po_id.id,
+ 'price_unit': 100.0,
+ 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ }),
+ ],
+ })
+ po1.button_confirm()
+
+ picking1 = po1.picking_ids[0]
+ move1 = picking1.move_lines[0]
+ move1.quantity_done = 5
+ res_dict = picking1.button_validate()
+ self.assertEqual(res_dict['res_model'], 'stock.backorder.confirmation')
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ wizard.process()
+
+ self.assertEqual(len(picking1.move_lines), 1)
+ self.assertEqual(move1.price_unit, 100)
+ self.assertEqual(move1.product_qty, 5)
+
+ picking2 = po1.picking_ids.filtered(lambda p: p.backorder_id)
+ move2 = picking2.move_lines[0]
+ self.assertEqual(len(picking2.move_lines), 1)
+ self.assertEqual(move2.price_unit, 100)
+ self.assertEqual(move2.product_qty, 5)
+
diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py
index 73b9f95d93a..6d8d381c6f6 100644
--- a/addons/sale/models/analytic.py
+++ b/addons/sale/models/analytic.py
@@ -41,7 +41,9 @@ class AccountAnalyticLine(models.Model):
@api.multi
def _sale_postprocess(self, values, additional_so_lines=None):
if 'so_line' not in values: # allow to force a False value for so_line
- self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no').with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
+ # only take the AAL from expense or vendor bill, meaning having a negative amount
+ self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no' and aal.amount <= 0).with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
+ self.filtered(lambda aal: aal.amount <= 0).with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
if any(field_name in values for field_name in self._sale_get_fields_delivered_qty()):
if not self._context.get('sale_analytic_norecompute'):
diff --git a/addons/sale_mrp/tests/test_sale_mrp_flow.py b/addons/sale_mrp/tests/test_sale_mrp_flow.py
index f6268534ef7..1152b7fffa3 100644
--- a/addons/sale_mrp/tests/test_sale_mrp_flow.py
+++ b/addons/sale_mrp/tests/test_sale_mrp_flow.py
@@ -166,8 +166,8 @@ class TestSaleMrpFlow(common.TransactionCase):
mnf_product_a = self.env['mrp.production'].search([('product_id', '=', product_a.id)])
self.assertTrue(mnf_product_a, 'Manufacturing order not created.')
- self.assertEqual(mnf_product_a.product_qty, 10, 'Wrong product quantity in manufacturing order.')
- self.assertEqual(mnf_product_a.product_uom_id, self.uom_dozen, 'Wrong unit of measure in manufacturing order.')
+ self.assertEqual(mnf_product_a.product_qty, 120, 'Wrong product quantity in manufacturing order.')
+ self.assertEqual(mnf_product_a.product_uom_id, self.uom_unit, 'Wrong unit of measure in manufacturing order.')
self.assertEqual(mnf_product_a.state, 'confirmed', 'Manufacturing order should be confirmed.')
# ------------------------------------------------------------------------------------------
diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py
index 99b8d299fa8..67daba354bd 100644
--- a/addons/sale_stock/models/sale_order.py
+++ b/addons/sale_stock/models/sale_order.py
@@ -238,7 +238,7 @@ class SaleOrderLine(models.Model):
continue
qty = 0.0
for move in line.move_ids.filtered(lambda r: r.state != 'cancel'):
- qty += move.product_qty
+ qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0:
continue
@@ -263,8 +263,16 @@ class SaleOrderLine(models.Model):
values = line._prepare_procurement_values(group_id=group_id)
product_qty = line.product_uom_qty - qty
+
+ procurement_uom = line.product_uom
+ quant_uom = line.product_id.uom_id
+ get_param = self.env['ir.config_parameter'].sudo().get_param
+ if procurement_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1':
+ product_qty = line.product_uom._compute_quantity(product_qty, quant_uom, rounding_method='HALF-UP')
+ procurement_uom = quant_uom
+
try:
- self.env['procurement.group'].run(line.product_id, product_qty, line.product_uom, line.order_id.partner_shipping_id.property_stock_customer, line.name, line.order_id.name, values)
+ self.env['procurement.group'].run(line.product_id, product_qty, procurement_uom, line.order_id.partner_shipping_id.property_stock_customer, line.name, line.order_id.name, values)
except UserError as error:
errors.append(error.name)
if errors:
diff --git a/addons/sale_stock/tests/test_sale_stock.py b/addons/sale_stock/tests/test_sale_stock.py
index 5a6e43377fe..c99f211ce50 100644
--- a/addons/sale_stock/tests/test_sale_stock.py
+++ b/addons/sale_stock/tests/test_sale_stock.py
@@ -244,8 +244,8 @@ class TestSaleStock(TestSale):
the new move lines.
"""
# sell two products
- item1 = self.products['prod_order']
- item2 = self.products['prod_del']
+ item1 = self.products['prod_order'] # consumable
+ item2 = self.products['prod_del'] # stockable
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
@@ -257,6 +257,66 @@ class TestSaleStock(TestSale):
self.so.action_confirm()
# deliver them
+ # One of the move is for a consumable product, thus is assigned. The second one is for a
+ # stockable product, thus is unavailable. Hitting `button_validate` will first ask to
+ # process all the reserved quantities and, if the user chose to process, a second wizard
+ # will ask to create a backorder for the unavailable product.
+ self.assertEquals(len(self.so.picking_ids), 1)
+ res_dict = self.so.picking_ids[0].button_validate()
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ self.assertEqual(wizard._name, 'stock.immediate.transfer')
+ res_dict = wizard.process()
+ wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ self.assertEqual(wizard._name, 'stock.backorder.confirmation')
+ wizard.process()
+
+ # Now, the original picking is done and there is a new one (the backorder).
+ self.assertEquals(len(self.so.picking_ids), 2)
+ for picking in self.so.picking_ids:
+ move = picking.move_lines
+ if picking.backorder_id:
+ self.assertEqual(move.product_id.id, item2.id)
+ self.assertEqual(move.state, 'confirmed')
+ else:
+ self.assertEqual(picking.move_lines.product_id.id, item1.id)
+ self.assertEqual(move.state, 'done')
+
+ # update the two original sale order lines
+ self.so.write({
+ 'order_line': [
+ (1, self.so.order_line[0].id, {'product_uom_qty': 2}),
+ (1, self.so.order_line[1].id, {'product_uom_qty': 2}),
+ ]
+ })
+ # a single picking should be created for the new delivery
+ self.assertEquals(len(self.so.picking_ids), 2)
+ backorder = self.so.picking_ids.filtered(lambda p: p.backorder_id)
+ self.assertEqual(len(backorder.move_lines), 2)
+ for backorder_move in backorder.move_lines:
+ if backorder_move.product_id.id == item1.id:
+ self.assertEqual(backorder_move.product_qty, 1)
+ elif backorder_move.product_id.id == item2.id:
+ self.assertEqual(backorder_move.product_qty, 2)
+
+ def test_05_create_picking_update_saleorderline(self):
+ """ Same test than test_04 but only with enough products in stock so that the reservation
+ is successful.
+ """
+ # sell two products
+ item1 = self.products['prod_order'] # consumable
+ item2 = self.products['prod_del'] # stockable
+
+ self.env['stock.quant']._update_available_quantity(item2, self.env.ref('stock.stock_location_stock'), 2)
+ self.so = self.env['sale.order'].create({
+ 'partner_id': self.partner.id,
+ 'order_line': [
+ (0, 0, {'name': item1.name, 'product_id': item1.id, 'product_uom_qty': 1, 'product_uom': item1.uom_id.id, 'price_unit': item1.list_price}),
+ (0, 0, {'name': item2.name, 'product_id': item2.id, 'product_uom_qty': 1, 'product_uom': item2.uom_id.id, 'price_unit': item2.list_price}),
+ ],
+ })
+ self.so.action_confirm()
+
+ # deliver them
self.assertEquals(len(self.so.picking_ids), 1)
self.so.picking_ids[0].force_assign()
res_dict = self.so.picking_ids[0].button_validate()
@@ -303,3 +363,61 @@ class TestSaleStock(TestSale):
self.assertEqual(len(so1.picking_ids), 2)
picking2 = so1.picking_ids.filtered(lambda p: p.state != 'cancel')
self.assertEqual(picking2.partner_id.id, partner2)
+
+ def test_06_uom(self):
+ """ Sell a dozen of products stocked in units. Check that the quantities on the sale order
+ lines as well as the delivered quantities are handled in dozen while the moves themselves
+ are handled in units. Edit the ordered quantities, check that the quantites are correctly
+ updated on the moves. Edit the ir.config_parameter to propagate the uom of the sale order
+ lines to the moves and edit a last time the ordered quantities. Deliver, check the
+ quantities.
+ """
+ uom_unit = self.env.ref('product.product_uom_unit')
+ uom_dozen = self.env.ref('product.product_uom_dozen')
+ item1 = self.products['prod_order']
+
+ self.assertEqual(item1.uom_id.id, uom_unit.id)
+
+ # sell a dozen
+ so1 = self.env['sale.order'].create({
+ 'partner_id': self.partner.id,
+ 'order_line': [(0, 0, {
+ 'name': item1.name,
+ 'product_id': item1.id,
+ 'product_uom_qty': 1,
+ 'product_uom': uom_dozen.id,
+ 'price_unit': item1.list_price,
+ })],
+ })
+ so1.action_confirm()
+
+ # the move should be 12 units
+ # note: move.product_qty = computed field, always in the uom of the quant
+ # move.product_uom_qty = stored field representing the initial demand in move.product_uom
+ move1 = so1.picking_ids.move_lines[0]
+ self.assertEqual(move1.product_uom_qty, 12)
+ self.assertEqual(move1.product_uom.id, uom_unit.id)
+ self.assertEqual(move1.product_qty, 12)
+
+ # edit the so line, sell 2 dozen, the move should now be 24 units
+ so1.order_line.product_uom_qty = 2
+ self.assertEqual(move1.product_uom_qty, 24)
+ self.assertEqual(move1.product_uom.id, uom_unit.id)
+ self.assertEqual(move1.product_qty, 24)
+
+ # force the propagation of the uom, sell 3 dozen
+ self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1')
+ so1.order_line.product_uom_qty = 3
+ move2 = so1.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id)
+ self.assertEqual(move2.product_uom_qty, 1)
+ self.assertEqual(move2.product_uom.id, uom_dozen.id)
+ self.assertEqual(move2.product_qty, 12)
+
+ # deliver everything
+ move1.quantity_done = 24
+ move2.quantity_done = 1
+ so1.picking_ids.button_validate()
+
+ # check the delivered quantity
+ self.assertEqual(so1.order_line.qty_delivered, 3.0)
+
diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py
index aeddf1bd2ce..88b98d97065 100644
--- a/addons/sale_timesheet/models/product.py
+++ b/addons/sale_timesheet/models/product.py
@@ -39,7 +39,7 @@ class ProductTemplate(models.Model):
def _inverse_service_policy(self):
for product in self:
policy = product.service_policy
- if not policy:
+ if not policy and not product.invoice_policy =='delivery':
product.invoice_policy = 'order'
product.service_type = 'manual'
elif policy == 'ordered_timesheet':
diff --git a/addons/sales_team/models/crm_team.py b/addons/sales_team/models/crm_team.py
index cf95b66a7e2..4542116c47c 100644
--- a/addons/sales_team/models/crm_team.py
+++ b/addons/sales_team/models/crm_team.py
@@ -232,7 +232,7 @@ class CrmTeam(models.Model):
values[index][y_field] = data_item.get('y_value')
elif self.dashboard_graph_group == 'week':
- weeks_in_start_year = int(date(start_date.year, 12, 31).isocalendar()[1])
+ weeks_in_start_year = int(date(start_date.year, 12, 28).isocalendar()[1]) # This date is always in the last week of ISO years
for week in range(0, (end_date.isocalendar()[1] - start_date.isocalendar()[1]) % weeks_in_start_year + 1):
short_name = get_week_name(start_date + relativedelta(days=7 * week), locale)
values.append({x_field: short_name, y_field: 0})
diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py
index d4db5ff5451..e164edfa331 100644
--- a/addons/stock/models/product.py
+++ b/addons/stock/models/product.py
@@ -365,8 +365,14 @@ class Product(models.Model):
def write(self, values):
res = super(Product, self).write(values)
- if 'active' in values and not values['active'] and self.mapped('orderpoint_ids').filtered(lambda r: r.active):
- raise UserError(_('You still have some active reordering rules on this product. Please archive or delete them first.'))
+ if 'active' in values and not values['active']:
+ products = self.mapped('orderpoint_ids').filtered(lambda r: r.active).mapped('product_id')
+ if products:
+ msg = _('You still have some active reordering rules on this product. Please archive or delete them first.')
+ msg += '\n\n'
+ for product in products:
+ msg += '- %s \n' % product.display_name
+ raise UserError(msg)
return res
class ProductTemplate(models.Model):
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index db19f9d1f95..a30ede5d783 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -952,7 +952,8 @@ class StockMove(models.Model):
def _prepare_extra_move_vals(self, qty):
vals = {
'product_uom_qty': qty,
- 'picking_id': self.picking_id.id
+ 'picking_id': self.picking_id.id,
+ 'price_unit': self.price_unit,
}
return vals
@@ -1080,6 +1081,7 @@ class StockMove(models.Model):
'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')],
'move_orig_ids': [(4, x.id) for x in self.move_orig_ids],
'origin_returned_move_id': self.origin_returned_move_id.id,
+ 'price_unit': self.price_unit,
}
return vals
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index 446b55a2a0d..267308b3da7 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -8,7 +8,7 @@ import time
from itertools import groupby
from odoo import api, fields, models, _
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
-from odoo.tools.float_utils import float_compare, float_round
+from odoo.tools.float_utils import float_compare, float_is_zero, float_round
from odoo.exceptions import UserError
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
from operator import itemgetter
@@ -443,7 +443,7 @@ class Picking(models.Model):
for picking in self:
if self._context.get('planned_picking') and picking.state == 'draft':
picking.show_validate = False
- elif picking.state not in ('draft', 'confirmed', 'assigned') or not picking.is_locked:
+ elif picking.state not in ('draft', 'waiting', 'confirmed', 'assigned') or not picking.is_locked:
picking.show_validate = False
else:
picking.show_validate = True
@@ -594,8 +594,7 @@ class Picking(models.Model):
@return: True
"""
# TDE FIXME: remove decorator when migration the remaining
- # TDE FIXME: draft -> automatically done, if waiting ?? CLEAR ME
- todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'partially_available', 'assigned', 'confirmed'])
+ todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'waiting', 'partially_available', 'assigned', 'confirmed'])
# Check if there are ops not linked to moves yet
for pick in self:
# # Explode manually added packages
@@ -684,10 +683,10 @@ class Picking(models.Model):
# If no lots when needed, raise error
picking_type = self.picking_type_id
- no_quantities_done = all(line.qty_done == 0.0 for line in self.move_line_ids)
- no_initial_demand = all(move.product_uom_qty == 0.0 for move in self.move_lines)
- if no_initial_demand and no_quantities_done:
- raise UserError(_('You cannot validate a transfer if you have not processed any quantity.'))
+ no_quantities_done = all(float_is_zero(move_line.qty_done, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
+ no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
+ if no_reserved_quantities and no_quantities_done:
+ raise UserError(_('You cannot validate a transfer if you have not processed any quantity. You should rather cancel the transfer.'))
if picking_type.use_create_lots or picking_type.use_existing_lots:
lines_to_check = self.move_line_ids
@@ -832,7 +831,7 @@ class Picking(models.Model):
def _put_in_pack(self):
package = False
- for pick in self:
+ for pick in self.filtered(lambda p: p.state not in ('done', 'cancel')):
operations = pick.move_line_ids.filtered(lambda o: o.qty_done > 0 and not o.result_package_id)
operation_ids = self.env['stock.move.line']
if operations:
diff --git a/addons/stock/models/stock_production_lot.py b/addons/stock/models/stock_production_lot.py
index 06cc2917502..4fc1092839d 100644
--- a/addons/stock/models/stock_production_lot.py
+++ b/addons/stock/models/stock_production_lot.py
@@ -42,17 +42,3 @@ class ProductionLot(models.Model):
# We only care for the quants in internal or transit locations.
quants = self.quant_ids.filtered(lambda q: q.location_id.usage in ['internal', 'transit'])
self.product_qty = sum(quants.mapped('quantity'))
-
- @api.multi
- def action_traceability(self):
- move_ids = self.mapped('quant_ids').mapped('history_ids').ids
- if not move_ids:
- return False
- return {
- 'domain': [('id', 'in', move_ids)],
- 'name': _('Traceability'),
- 'view_mode': 'tree,form',
- 'view_type': 'form',
- 'context': {'tree_view_ref': 'stock.view_move_tree'},
- 'res_model': 'stock.move',
- 'type': 'ir.actions.act_window'}
diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py
index dfa9fc1f8f7..609bf0aa334 100644
--- a/addons/stock/tests/test_move.py
+++ b/addons/stock/tests/test_move.py
@@ -2356,19 +2356,18 @@ class StockMove(TransactionCase):
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product3, self.stock_location, lot_id=lot2, package_id=package1), 1.0)
def test_immediate_validate_1(self):
- """ Create a picking and simulates validate button effect.
- Move line quantity should be set to their reservation quantity automatically
+ """ In a picking with a single available move, clicking on validate without filling any
+ quantities should open a wizard asking to process all the reservation (so, the whole move).
"""
- owner = self.env['res.partner'].create({'name': 'Jean'})
+ partner = self.env['res.partner'].create({'name': 'Jean'})
picking = self.env['stock.picking'].create({
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
- 'partner_id': owner.id,
+ 'partner_id': partner.id,
'picking_type_id': self.env.ref('stock.picking_type_in').id,
})
- # move from shelf1
self.env['stock.move'].create({
- 'name': 'test_edit_moveline_1',
+ 'name': 'test_immediate_validate_1',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'picking_id': picking.id,
@@ -2379,29 +2378,28 @@ class StockMove(TransactionCase):
picking.action_confirm()
picking.action_assign()
res_dict = picking.button_validate()
+ self.assertEqual(res_dict.get('res_model'), 'stock.immediate.transfer')
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
wizard.process()
-
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 10.0)
def test_immediate_validate_2(self):
- """ Create a picking and simulates validate button effect.
- Move line quantity should be set to their reservation quantity automatically.
- If the Initial demand quantity is greater than the available quantity. It should only
- set the quantity done to the available(reserved) quantity and create a backorder with the
- remaining quantity.
+ """ In a picking with a single partially available move, clicking on validate without
+ filling any quantities should open a wizard asking to process all the reservation (so, only
+ a part of the initial demand). Validating this wizard should open another one asking for
+ the creation of a backorder. If the backorder is created, it should contain the quantities
+ not processed.
"""
- owner = self.env['res.partner'].create({'name': 'Jean'})
+ partner = self.env['res.partner'].create({'name': 'Jean'})
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 5.0)
picking = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
- 'partner_id': owner.id,
+ 'partner_id': partner.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
- # move from shelf1
self.env['stock.move'].create({
- 'name': 'test_edit_moveline_1',
+ 'name': 'test_immediate_validate_2',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'picking_id': picking.id,
@@ -2411,67 +2409,96 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
+ # Only 5 products are reserved on the move of 10, click on `button_validate`.
res_dict = picking.button_validate()
+ self.assertEqual(res_dict.get('res_model'), 'stock.immediate.transfer')
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
res_dict_for_back_order = wizard.process()
+ self.assertEqual(res_dict_for_back_order.get('res_model'), 'stock.backorder.confirmation')
backorder_wizard = self.env[(res_dict_for_back_order.get('res_model'))].browse(res_dict_for_back_order.get('res_id'))
+ # Chose to create a backorder.
backorder_wizard.process()
- self.assertEqual(len(picking.move_lines.move_line_ids), 1)
+ # Only 5 products should be processed on the initial move.
+ self.assertEqual(picking.move_lines.state, 'done')
self.assertEqual(picking.move_lines.quantity_done, 5.0)
- self.assertEqual(picking.move_lines.move_line_ids.qty_done, 5.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 0.0)
+ # The backoder should contain a move for the other 5 produts.
backorder = self.env['stock.picking'].search([('backorder_id', '=', picking.id)])
self.assertEqual(len(backorder), 1.0)
self.assertEqual(backorder.move_lines.product_uom_qty, 5.0)
def test_immediate_validate_3(self):
- """ Create a picking and simulates validate button effect.
- In case of a force assign it should ignore the reserved quantity and set
- the quantity done to the initial demand. It should also create the move line.
- """
- owner = self.env['res.partner'].create({'name': 'Jean'})
+ """ In a picking with two moves, one partially available and one unavailable, clicking
+ on validate without filling any quantities should open a wizard asking to process all the
+ reservation (so, only a part of one of the moves). Validating this wizard should open
+ another one asking for the creation of a backorder. If the backorder is created, it should
+ contain the quantities not processed.
+ """
+ product5 = self.env['product.product'].create({
+ 'name': 'Product 5',
+ 'type': 'product',
+ 'categ_id': self.env.ref('product.product_category_all').id,
+ })
+
+ self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1)
+
picking = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
- 'location_dest_id': self.customer_location.id,
- 'partner_id': owner.id,
- 'picking_type_id': self.env.ref('stock.picking_type_out').id,
+ 'location_dest_id': self.pack_location.id,
+ 'picking_type_id': self.env.ref('stock.picking_type_internal').id,
})
- # move from shelf1
- self.env['stock.move'].create({
- 'name': 'test_edit_moveline_1',
+ product1_move = self.env['stock.move'].create({
+ 'name': 'product1_move',
'location_id': self.stock_location.id,
- 'location_dest_id': self.customer_location.id,
+ 'location_dest_id': self.pack_location.id,
'picking_id': picking.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
- 'product_uom_qty': 10.0,
+ 'product_uom_qty': 100,
+ })
+ product5_move = self.env['stock.move'].create({
+ 'name': 'product3_move',
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.pack_location.id,
+ 'picking_id': picking.id,
+ 'product_id': product5.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100,
})
picking.action_confirm()
- picking.force_assign()
- res_dict = picking.button_validate()
- wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
+ picking.action_assign()
+
+ # product1_move should be partially available (1/100), product5_move should be totally
+ # unavailable (0/100)
+ self.assertEqual(product1_move.state, 'partially_available')
+ self.assertEqual(product5_move.state, 'confirmed')
+
+ action = picking.button_validate()
+ self.assertEqual(action.get('res_model'), 'stock.immediate.transfer')
+ wizard = self.env[(action.get('res_model'))].browse(action.get('res_id'))
+ action = wizard.process()
+ self.assertTrue(isinstance(action, dict), 'Should open backorder wizard')
+ self.assertEqual(action.get('res_model'), 'stock.backorder.confirmation')
+ wizard = self.env[(action.get('res_model'))].browse(action.get('res_id'))
wizard.process()
+ backorder = self.env['stock.picking'].search([('backorder_id', '=', picking.id)])
+ self.assertEqual(len(backorder), 1.0)
- self.assertEqual(picking.move_lines.quantity_done, 10.0)
- # Check move_lines data
- self.assertEqual(len(picking.move_lines.move_line_ids), 1)
- self.assertEqual(picking.move_lines.move_line_ids.qty_done, 10.0)
- self.assertEqual(picking.move_lines.move_line_ids.product_qty, 0.0)
- # Check quants data
- self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
- self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location, allow_negative=True), -10.0)
- self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1.0)
+ # The backorder should contain 99 product1 and 100 product5.
+ for backorder_move in backorder.move_lines:
+ if backorder_move.product_id.id == self.product1.id:
+ self.assertEqual(backorder_move.product_qty, 99)
+ elif backorder_move.product_id.id == product5.id:
+ self.assertEqual(backorder_move.product_qty, 100)
def test_immediate_validate_4(self):
- """ Create a picking and simulates validate button effect.
- If the product is tracked by lot. The user should set the lot
- and the quantity done himself before validate. Otherwise it should
- return a UserError.
+ """ In a picking with a single available tracked by lot move, clicking on validate without
+ filling any quantities should open an UserError.
"""
- owner = self.env['res.partner'].create({'name': 'Jean'})
+ partner = self.env['res.partner'].create({'name': 'Jean'})
lot1 = self.env['stock.production.lot'].create({
'name': 'lot1',
'product_id': self.product3.id,
@@ -2480,12 +2507,12 @@ class StockMove(TransactionCase):
picking = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
- 'partner_id': owner.id,
+ 'partner_id': partner.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
# move from shelf1
self.env['stock.move'].create({
- 'name': 'test_edit_moveline_1',
+ 'name': 'test_immediate_validate_4',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'picking_id': picking.id,
@@ -2495,10 +2522,11 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
+ # No quantites/lot filled, it should raise.
with self.assertRaises(UserError):
picking.button_validate()
picking.move_lines.move_line_ids[0].qty_done = 5.0
- # We do not need to catch wizard since everything is already defined and the action_done will be processed immediately
+ # All the information are present (lots and quantities), the wizard won't be opened.
picking.button_validate()
self.assertEqual(picking.move_lines.quantity_done, 5.0)
@@ -2535,9 +2563,9 @@ class StockMove(TransactionCase):
return picking
def test_immediate_validate_5(self):
- """ Create a picking and simulates validate button effect.
- Test that tracked products can be received without specifying a serial
- number when the picking type is configured that way.
+ """ In a receipt with a single tracked by serial numbers move, clicking on validate without
+ filling any quantities nor lot should open an UserError except if the picking type is
+ configured to allow otherwise.
"""
picking_type_id = self.env.ref('stock.picking_type_in')
product_id = self.product2
@@ -2555,23 +2583,17 @@ class StockMove(TransactionCase):
self.assertEqual(picking.state, 'done')
def test_immediate_validate_6(self):
- """ Create a picking and simulates validate button effect.
- This tests three cases:
- - if a user has processed no quantities and at least one product is tracked,
- he should specify lots
- - if a user has processed some quantities then it's ok because a backorder
- will be created for the ones not filled in
- - if a user overprocesses a move he will be asked to confirm if this is ok
+ """ In a receipt picking with two moves, one tracked and one untracked, clicking on
+ validate without filling any quantities should displays an UserError as long as no quantity
+ done and lot_name is set on the tracked move. Now if the user validates the picking, the
+ wizard telling the user all reserved quantities will be processed will NOT be opened. This
+ wizard is only opene if no quantities were filled. So validating the picking at this state
+ will open another wizard asking for the creation of a backorder. Now, if the user processed
+ on the second move more than the reservation, a wizard will ask him to confirm.
"""
picking_type = self.env.ref('stock.picking_type_in')
picking_type.use_create_lots = True
picking_type.use_existing_lots = False
- lot1 = self.env['stock.production.lot'].create({
- 'name': 'lot1',
- 'product_id': self.product3.id,
- })
- self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1)
- self.env['stock.quant']._update_available_quantity(self.product3, self.stock_location, 1, lot_id=lot1)
picking = self.env['stock.picking'].create({
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
@@ -2597,16 +2619,15 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
+
with self.assertRaises(UserError):
picking.button_validate()
-
product3_move.move_line_ids[0].qty_done = 1
- # should still complain about lots because no lot_{name,id} was set
with self.assertRaises(UserError):
picking.button_validate()
-
product3_move.move_line_ids[0].lot_name = '271828'
action = picking.button_validate() # should open backorder wizard
+
self.assertTrue(isinstance(action, dict), 'Should open backorder wizard')
self.assertEqual(action.get('res_model'), 'stock.backorder.confirmation')
@@ -2615,6 +2636,33 @@ class StockMove(TransactionCase):
self.assertTrue(isinstance(action, dict), 'Should open overprocessing wizard')
self.assertEqual(action.get('res_model'), 'stock.overprocessed.transfer')
+ def test_immediate_validate_7(self):
+ """ In a picking with a single unavailable move, clicking on validate without filling any
+ quantities should display an UserError telling the user he cannot process a picking without
+ any processed quantity.
+ """
+ partner = self.env['res.partner'].create({'name': 'Jean'})
+ picking = self.env['stock.picking'].create({
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'partner_id': partner.id,
+ 'picking_type_id': self.env.ref('stock.picking_type_out').id,
+ })
+ self.env['stock.move'].create({
+ 'name': 'test_immediate_validate_2',
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'picking_id': picking.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 10.0,
+ })
+ picking.action_confirm()
+ picking.action_assign()
+ # No products are reserved on the move of 10, click on `button_validate`.
+ with self.assertRaises(UserError):
+ picking.button_validate()
+
def test_set_quantity_done_1(self):
move1 = self.env['stock.move'].create({
'name': 'test_set_quantity_done_1',
diff --git a/addons/stock/wizard/stock_immediate_transfer.py b/addons/stock/wizard/stock_immediate_transfer.py
index e137f5ad85d..66a652ac795 100644
--- a/addons/stock/wizard/stock_immediate_transfer.py
+++ b/addons/stock/wizard/stock_immediate_transfer.py
@@ -23,11 +23,8 @@ class StockImmediateTransfer(models.TransientModel):
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:
- if move.move_line_ids:
- for move_line in move.move_line_ids:
- move_line.qty_done = move_line.product_uom_qty
- else:
- move.quantity_done = move.product_uom_qty
+ for move_line in move.move_line_ids:
+ move_line.qty_done = move_line.product_uom_qty
if picking._check_backorder():
pick_to_backorder |= picking
continue
diff --git a/addons/stock_account/models/account_invoice.py b/addons/stock_account/models/account_invoice.py
index 9547c0625e7..8614fde2f6f 100644
--- a/addons/stock_account/models/account_invoice.py
+++ b/addons/stock_account/models/account_invoice.py
@@ -30,13 +30,13 @@ class AccountInvoice(models.Model):
company_currency = inv.company_id.currency_id
price_unit = i_line._get_anglo_saxon_price_unit()
if inv.currency_id != company_currency:
- currency_id = inv.currency_id.id
+ currency = inv.currency_id
amount_currency = i_line._get_price(company_currency, price_unit)
else:
- currency_id = False
+ currency = False
amount_currency = False
- return self.env['product.product']._anglo_saxon_sale_move_lines(i_line.name, i_line.product_id, i_line.uom_id, i_line.quantity, price_unit, currency=currency_id, amount_currency=amount_currency, fiscal_position=inv.fiscal_position_id, account_analytic=i_line.account_analytic_id, analytic_tags=i_line.analytic_tag_ids)
+ return self.env['product.product']._anglo_saxon_sale_move_lines(i_line.name, i_line.product_id, i_line.uom_id, i_line.quantity, price_unit, currency=currency, amount_currency=amount_currency, fiscal_position=inv.fiscal_position_id, account_analytic=i_line.account_analytic_id, analytic_tags=i_line.analytic_tag_ids)
class AccountInvoiceLine(models.Model):
diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py
index 93bc4c980ae..098e976fc7e 100644
--- a/addons/stock_account/models/product.py
+++ b/addons/stock_account/models/product.py
@@ -234,7 +234,7 @@ class ProductProduct(models.Model):
'product_id': product.id,
'uom_id': uom.id,
'account_analytic_id': account_analytic and account_analytic.id,
- 'analytic_tag_ids': analytic_tags and analytic_tag.ids and [(6, 0, analytic_tag.ids)] or False,
+ 'analytic_tag_ids': analytic_tags and analytic_tags.ids and [(6, 0, analytic_tags.ids)] or False,
},
{
@@ -259,7 +259,7 @@ class ProductProduct(models.Model):
price = self.standard_price
if not uom or self.uom_id.id == uom.id:
return price
- return self.uom_id._compute_price(self.uom_id.id, price, to_uom_id=uom.id)
+ return self.uom_id._compute_price(price, uom)
class ProductCategory(models.Model):
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index b0cf820005b..913edc89932 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -1382,7 +1382,7 @@ class ExportFormat(object):
model, fields, ids, domain, import_compat = \
operator.itemgetter('model', 'fields', 'ids', 'domain', 'import_compat')(params)
- Model = request.env[model].with_context(**params.get('context', {}))
+ Model = request.env[model].with_context(import_compat=import_compat, **params.get('context', {}))
records = Model.browse(ids) or Model.search(domain, offset=0, limit=False, order=False)
if not Model._is_an_ordinary_table():
diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js
index e725aaee8f6..4b32ffecfb3 100644
--- a/addons/web/static/src/js/chrome/web_client.js
+++ b/addons/web/static/src/js/chrome/web_client.js
@@ -59,7 +59,7 @@ return AbstractWebClient.extend({
update_logo: function(reload) {
var company = session.company_id;
var img = session.url('/web/binary/company_logo' + '?db=' + session.db + (company ? '&company=' + company : ''));
- this.$('.o_sub_menu_logo img').attr('src', '').attr('src', img + (reload ? "#" + Date.now() : ''));
+ this.$('.o_sub_menu_logo img').attr('src', '').attr('src', img + (reload ? "&t=" + Date.now() : ''));
this.$('.oe_logo_edit').toggleClass('oe_logo_edit_admin', session.is_superuser);
},
logo_edit: function(ev) {
@@ -83,13 +83,8 @@ return AbstractWebClient.extend({
action_buttons: true,
headless: true,
};
- self.action_manager.do_action(result).then(function () {
- var form = self.action_manager.dialog_widget.views.form.controller;
- form.on("on_button_cancel", self.action_manager, self.action_manager.dialog_stop);
- form.on('record_saved', self, function() {
- self.action_manager.dialog_stop();
- self.update_logo();
- });
+ self.action_manager.do_action(result, {
+ on_close: self.update_logo.bind(self, true),
});
});
});
diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js
index 75aaf0105ed..8ce570b8605 100644
--- a/addons/web/static/src/js/core/domain.js
+++ b/addons/web/static/src/js/core/domain.js
@@ -240,7 +240,7 @@ var Domain = collections.Tree.extend({
*/
stringToArray: function (domain, evalContext) {
if (!_.isString(domain)) return _.clone(domain);
- return pyeval.eval("domain", domain || "[]", evalContext);
+ return pyeval.eval("domain", domain ? domain.replace(/%%/g, '%') : "[]", evalContext);
},
/**
* Makes implicit "&" operators explicit in the given JS prefix-array
diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js
index 1bdd9e70a69..251cd2b508c 100644
--- a/addons/web/static/src/js/core/utils.js
+++ b/addons/web/static/src/js/core/utils.js
@@ -392,6 +392,23 @@ var utils = {
array[i2] = elem1;
array[i1] = elem2;
},
+
+ /**
+ * @param {string} value
+ * @param {boolean} allow_mailto
+ * @returns boolean
+ */
+ is_email: function (value, allow_mailto) {
+ // http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
+ var re;
+ if (allow_mailto) {
+ re = /^(mailto:)?(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
+ } else {
+ re = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
+ }
+ return re.test(value);
+ },
+
/**
* @param {any} str
* @param {any} elseValues
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
index 432d810bbbb..6f07ac1c2f8 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -332,7 +332,9 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
}
self.model.discardChanges(recordID);
if (self.model.isNew(recordID)) {
- self._abandonRecord(recordID);
+ if (self.model.canBeAbandoned(recordID)) {
+ self._abandonRecord(recordID);
+ }
return;
}
return self._confirmSave(recordID);
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index b5b668a43a1..6eef41f149d 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -478,6 +478,30 @@ var BasicModel = AbstractModel.extend({
return _t("New");
},
/**
+ * Returns true if a record can be abandoned from a list datapoint.
+ *
+ * A record cannot be abandonned if it has been registered as "added"
+ * in the parent's savepoint, otherwise it can be abandonned.
+ *
+ * This is useful when discarding changes on this record, as it means that
+ * we must keep the record even if some fields are invalids (e.g. required
+ * field is empty).
+ *
+ * @param {string} id id for a local resource
+ * @returns {boolean}
+ */
+ canBeAbandoned: function (id) {
+ var data = this.localData[id];
+ var parent = this.localData[data.parentID];
+ var abandonable = true;
+ if (parent) {
+ abandonable = !_.some(parent._savePoint, function (entry) {
+ return entry.operation === 'ADD' && entry.id === id;
+ });
+ }
+ return abandonable;
+ },
+ /**
* Returns true if a record is dirty. A record is considered dirty if it has
* some unsaved changes, marked by the _isDirty property on the record or
* one of its subrecords.
@@ -1428,25 +1452,23 @@ var BasicModel = AbstractModel.extend({
defs.push(self._applyOnChange(command[2], rec));
} else if (command[0] === 4) {
// LINK TO
- rec = self.localData[list._cache[command[1]]];
- if (rec) {
- // modifications done on a record are discarded if
- // the onchange uses a LINK TO
- self.discardChanges(rec.id);
- }
- // the dataPoint id will be set when the record will be
- // fetched (for now, this dataPoint may not exist yet)
- list._changes.push({
- operation: 'ADD',
- id: rec ? rec.id : null,
- resID: command[1],
- });
+ linkRecord(list, command[1]);
} else if (command[0] === 5) {
// DELETE ALL
list._changes = [{operation: 'REMOVE_ALL'}];
+ } else if (command[0] === 6) {
+ list._changes = [{operation: 'REMOVE_ALL'}];
+ _.each(command[2], function (resID) {
+ linkRecord(list, resID);
+ });
}
});
- defs.push(self._readUngroupedList(list));
+ var def = self._readUngroupedList(list).then(function () {
+ var x2ManysDef = self._fetchX2ManysBatched(list);
+ var referencesDef = self._fetchReferencesBatched(list);
+ return $.when(x2ManysDef, referencesDef);
+ });
+ defs.push(def);
} else {
var newValue = self._parseServerValue(field, val);
if (newValue !== oldValue) {
@@ -1455,6 +1477,25 @@ var BasicModel = AbstractModel.extend({
}
});
return $.when.apply($, defs);
+
+ // inner function that adds a record (based on its res_id) to a list
+ // dataPoint (used for onchanges that return commands 4 (LINK TO) or
+ // commands 6 (REPLACE WITH))
+ function linkRecord (list, resID) {
+ rec = self.localData[list._cache[resID]];
+ if (rec) {
+ // modifications done on a record are discarded if the onchange
+ // uses a LINK TO or a REPLACE WITH
+ self.discardChanges(rec.id);
+ }
+ // the dataPoint id will be set when the record will be fetched (for
+ // now, this dataPoint may not exist yet)
+ list._changes.push({
+ operation: 'ADD',
+ id: rec ? rec.id : null,
+ resID: resID,
+ });
+ }
},
/**
* When an operation is applied to a x2many field, the field widgets
@@ -1951,7 +1992,9 @@ var BasicModel = AbstractModel.extend({
_.each(list.data, function (dataPoint) {
var record = self.localData[dataPoint];
var value = record.data[fieldName];
- if (value) {
+ // if the reference field has already been fetched, the value is a
+ // datapoint ID, and in this case there's nothing to do
+ if (value && !self.localData[value]) {
var model = value.split(',')[0];
var resID = value.split(',')[1];
if (!(model in toFetch)) {
diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js
index df6172f0d8b..0b0ec09ddf4 100644
--- a/addons/web/static/src/js/views/kanban/kanban_model.js
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -73,7 +73,6 @@ var KanbanModel = BasicModel.extend({
domain: parent.domain.concat([[groupBy,"=",result[0]]]),
fields: parent.fields,
fieldsInfo: parent.fieldsInfo,
- groupedBy: parent.groupedBy,
isOpen: true,
limit: parent.limit,
parentID: parent.id,
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index b57d44e7de2..48078e2a22c 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -54,8 +54,13 @@ ListRenderer.include({
* @returns {Deferred}
*/
start: function () {
- if (this._isEditable()) {
- this.$el.css({height: '100%'});
+ // deliberately use the 'editable' attribute instead of '_isEditable'
+ // function, because the groupBy must not be taken into account to
+ // enable the '_onWindowClicked' handler (otherwise, an editable grouped
+ // list which is reloaded without groupBy wouldn't have this handler
+ // bound, and edited rows couldn't be left by clicking outside the list)
+ if (this.editable) {
+ this.$el.css({height: '100%'}); // seems useless: to remove in master
core.bus.on('click', this, this._onWindowClicked.bind(this));
}
return this._super();
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 0782d667690..16f4d29e22a 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -1154,6 +1154,60 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('list in form: discard newly added element with empty required field', function (assert) {
+ // This test simulates discarding a record that has been created with
+ // one of its required field that is empty. When we discard the changes
+ // on this empty field, it should not assume that this record should be
+ // abandonned, since it has been added (even though it is a new record).
+ assert.expect(6);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({p: [[0, 0, {display_name: 'new record', trululu: false}]]});
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual($('tr.o_data_row').length, 1,
+ "should have created the new record in the o2m");
+
+ var requiredElement = $('td.o_data_cell.o_required_modifier');
+ assert.strictEqual(requiredElement.length, 1,
+ "should have a required field on this record");
+ assert.strictEqual(requiredElement.text(), "",
+ "should have empty string in the required field on this record");
+
+ requiredElement.click();
+ // discard by clicking on body
+ $('body').click();
+
+ assert.strictEqual($('tr.o_data_row').length, 1,
+ "should still have the record in the o2m");
+
+ // update selector of required field element
+ requiredElement = $('td.o_data_cell.o_required_modifier');
+ assert.strictEqual(requiredElement.length, 1,
+ "should still have the required field on this record");
+ assert.strictEqual(requiredElement.text(), "",
+ "should still have empty string in the required field on this record");
+ form.destroy();
+ });
+
QUnit.test('list in form: default_get with x2many create', function (assert) {
assert.expect(5);
@@ -2852,6 +2906,163 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('onchange returning a command 6 for an x2many', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.turtles = [[6, false, [1, 2, 3]]];
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form>' +
+ '<field name="foo"/>' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be one record in the relation");
+
+ // change the value of foo to trigger the onchange
+ form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 3,
+ "there should be three records in the relation");
+
+ form.destroy();
+ });
+
+ QUnit.test('x2many fields inside x2manys are fetched after an onchange', function (assert) {
+ assert.expect(6);
+
+ this.data.turtle.records[0].partner_ids = [1];
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.turtles = [[5], [4, 1], [4, 2], [4, 3]];
+ },
+ };
+
+ var checkRPC = false;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="partner_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (checkRPC && args.method === 'read' && args.model === 'partner') {
+ assert.deepEqual(args.args[1], ['display_name'],
+ "should only read the display_name for the m2m tags");
+ assert.deepEqual(args.args[0], [1],
+ "should only read the display_name of the unknown record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be one record in the relation");
+ assert.strictEqual(form.$('.o_data_row .o_field_widget[name=partner_ids]').text().replace(/\s/g, ''),
+ 'secondrecordaaa', "many2many_tags should be correctly displayed");
+
+ // change the value of foo to trigger the onchange
+ checkRPC = true; // enable flag to check read RPC for the m2m field
+ form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 3,
+ "there should be three records in the relation");
+ assert.strictEqual(form.$('.o_data_row:first .o_field_widget[name=partner_ids]').text().trim(),
+ 'first record', "many2many_tags should be correctly displayed");
+
+ form.destroy();
+ });
+
+ QUnit.test('reference fields inside x2manys are fetched after an onchange', function (assert) {
+ assert.expect(5);
+
+ this.data.turtle.records[1].turtle_ref = 'product,41';
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.turtles = [[5], [4, 1], [4, 2], [4, 3]];
+ },
+ };
+
+ var checkRPC = false;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form>' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="foo"/>' +
+ '<field name="turtles">' +
+ '<tree>' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_ref" class="ref_field"/>' +
+ '</tree>' +
+ '</field>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (checkRPC && args.method === 'name_get') {
+ assert.deepEqual(args.args[0], [37],
+ "should only fetch the name_get of the unknown record");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be one record in the relation");
+ assert.strictEqual(form.$('.ref_field').text().trim(), 'xpad',
+ "reference field should be correctly displayed");
+
+ // change the value of foo to trigger the onchange
+ checkRPC = true; // enable flag to check read RPC for reference field
+ form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 3,
+ "there should be three records in the relation");
+ assert.strictEqual(form.$('.ref_field').text().trim(), 'xpadxphone',
+ "reference fields should be correctly displayed");
+
+ form.destroy();
+ });
+
QUnit.test('embedded one2many with handle widget with minimum setValue calls', function (assert) {
var done = assert.async();
assert.expect(20);
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index 00625e1e30c..daa68c6c638 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -1947,6 +1947,62 @@ QUnit.module('Views', {
kanban.destroy();
});
+ QUnit.test('archive new kanban column', function (assert) {
+ assert.expect(15);
+
+ this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ mockRPC: function (route) {
+ assert.step(route);
+ return this._super.apply(this, arguments);
+ },
+ });
+ var rpcs = ['/web/dataset/call_kw/partner/read_group', '/web/dataset/search_read', '/web/dataset/search_read'];
+ assert.strictEqual(kanban.$('.o_kanban_group').length, 2,
+ "there should be 2 columns");
+
+ // create a column
+ kanban.$('.o_column_quick_create').click();
+ kanban.$('.o_column_quick_create input').val('new colum');
+ kanban.$('.o_column_quick_create button.o_kanban_add').click();
+ rpcs.push('/web/dataset/call_kw/product/name_create');
+ assert.verifySteps(rpcs);
+
+ // add a record inside
+ kanban.$('.o_kanban_group:eq(2) .o_kanban_quick_add i').click();
+ var $quickCreate = kanban.$('.o_kanban_group:eq(2) .o_kanban_quick_create');
+ $quickCreate.find('input').val('new record');
+ $quickCreate.find('button.o_kanban_add').click();
+ assert.strictEqual(kanban.$('.o_kanban_group').length, 3,
+ "there should be 3 columns");
+ rpcs.push('/web/dataset/call_kw/partner/name_create');
+ rpcs.push('/web/dataset/call_kw/partner/read');
+ assert.verifySteps(rpcs);
+
+ var $newColumn = kanban.$('.o_kanban_group:eq(2)');
+ assert.strictEqual($newColumn.find('.o_kanban_record').length, 1,
+ "there should be 1 record in the new column");
+ $newColumn.find('.o_column_archive').click();
+ assert.strictEqual($newColumn.find('.o_kanban_record').length, 0,
+ "there should not be partners anymore");
+ rpcs.push('/web/dataset/call_kw/partner/write');
+ rpcs.push('/web/dataset/search_read');
+ assert.verifySteps(rpcs, "a search_read should be done after archiving the records");
+
+ kanban.destroy();
+ });
+
QUnit.test('load more records in column', function (assert) {
assert.expect(12);
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index 92ae4033562..f1c36aac0a7 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -2751,10 +2751,13 @@ QUnit.module('Views', {
list.destroy();
});
- QUnit.test('grouped list are not editable', function (assert) {
+ QUnit.test('grouped list are not editable (ungrouped first)', function (assert) {
// Editable grouped list views are not supported, so the purpose of this
// test is to check that when a list view is grouped, its editable
// attribute is ignored
+ // In this test, the view isn't grouped at the beginning, so it is first
+ // editable, and then it is reloaded with a groupBy and is no longer
+ // editable
assert.expect(5);
var list = createView({
@@ -2790,6 +2793,59 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test('grouped list are not editable (grouped first)', function (assert) {
+ // Editable grouped list views are not supported, so the purpose of this
+ // test is to check that when a list view is grouped, its editable
+ // attribute is ignored
+ // In this test, the view is grouped at the beginning, so it isn't
+ // editable, and then it is reloaded with no groupBy and becomes editable
+ assert.expect(6);
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
+ intercepts: {
+ switch_view: function (event) {
+ var resID = event.data.res_id || false;
+ assert.step('switch view ' + event.data.view_type + ' ' + resID);
+ },
+ },
+ groupBy: ['bar'],
+ });
+
+ // the view being grouped, it is not editable, so clicking on a record
+ // should open the form view
+ list.$('.o_group_header:first').click(); // open first group
+ list.$('.o_data_cell:first').click();
+
+ // for the same reason, clicking on 'Create' should open the form view
+ list.$buttons.find('.o_list_button_add').click();
+
+ assert.verifySteps(['switch view form 1', 'switch view form false'],
+ "two switch view to form should have been requested");
+
+ // reload without groupBy
+ list.reload({groupBy: []});
+
+ // as the view is no longer grouped, it is editable, so clicking on a
+ // row should switch it in edition
+ list.$('.o_data_cell:first').click();
+
+ assert.verifySteps(['switch view form 1', 'switch view form false'],
+ "no more switch view should have been requested");
+ assert.strictEqual(list.$('.o_selected_row').length, 1,
+ "a row should be in edition");
+
+ // clicking on the body should leave the edition
+ $('body').click();
+ assert.strictEqual(list.$('.o_selected_row').length, 0,
+ "the row should no longer be in edition");
+
+ list.destroy();
+ });
+
QUnit.test('field values are escaped', function (assert) {
assert.expect(1);
var value = '<script>throw Error();</script>';
diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/widgets/ace.js
index 0a89d0d8ce7..64502b41c31 100644
--- a/addons/web_editor/static/src/js/widgets/ace.js
+++ b/addons/web_editor/static/src/js/widgets/ace.js
@@ -58,6 +58,10 @@ function checkXML(xml) {
* @returns {string} formatted xml
*/
function formatXML(xml) {
+ // do nothing if an inline script is present to avoid breaking it
+ if (/<script(?: [^>]*)?>[^<][\s\S]*<\/script>/i.test(xml)) {
+ return xml;
+ }
return window.vkbeautify.xml(xml, 4);
}
/**
diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/widgets/widgets.js
index 8d7b4224b76..a7046eb11e8 100644
--- a/addons/web_editor/static/src/js/widgets/widgets.js
+++ b/addons/web_editor/static/src/js/widgets/widgets.js
@@ -4,6 +4,7 @@ odoo.define('web_editor.widget', function (require) {
var core = require('web.core');
var Dialog = require('web.Dialog');
var Widget = require('web.Widget');
+var utils = require('web.utils');
var weContext = require("web_editor.context");
var QWeb = core.qweb;
@@ -1208,6 +1209,7 @@ var LinkDialog = Dialog.extend({
if (!$e.length) {
$e = this.$('input.url-source:first');
}
+ $e.closest('.form-group').removeClass('has-error');
var val = $e.val();
var label = this.$('#o_link_dialog_label_input').val() || val;
@@ -1228,8 +1230,7 @@ var LinkDialog = Dialog.extend({
var size = this.$("select.link-style").val() || '';
var classes = (this.data.className || "") + (style && style.length ? " btn " : "") + style + " " + size;
var isNewWindow = this.$('input.window-new').prop('checked');
-
- if ($e.hasClass('email-address') && $e.val().indexOf("@") !== -1) {
+ if ($e.hasClass('email-address') && (_.str.startsWith(val, 'mailto:') || (val.indexOf("@") !== -1 && !_.str.startsWith(val, 'http') && !_.str.startsWith(val, 'www')))) {
self.get_data_buy_mail(def, $e, isNewWindow, label, classes, test);
} else {
self.get_data_buy_url(def, $e, isNewWindow, label, classes, test);
@@ -1238,7 +1239,13 @@ var LinkDialog = Dialog.extend({
},
get_data_buy_mail: function (def, $e, isNewWindow, label, classes, test) {
var val = $e.val();
- def.resolve(val.indexOf("mailto:") === 0 ? val : 'mailto:' + val, isNewWindow, label, classes);
+ if (utils.is_email(val, true)) {
+ def.resolve(val.indexOf("mailto:") === 0 ? val : 'mailto:' + val, isNewWindow, label, classes);
+ } else {
+ $e.closest('.form-group').addClass('has-error');
+ $e.focus();
+ def.reject();
+ }
},
get_data_buy_url: function (def, $e, isNewWindow, label, classes, test) {
def.resolve($e.val(), isNewWindow, label, classes);
@@ -1322,5 +1329,9 @@ return {
getCssSelectors: getCssSelectors,
fontIcons: fontIcons,
computeFonts: computeFonts,
+ click_event: click_event,
+ fontIconsDialog: fontIconsDialog,
+ ImageDialog: ImageDialog,
+ VideoDialog: VideoDialog,
};
});
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index d6deb298eb2..64b9934c126 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -520,7 +520,7 @@ class Website(models.Model):
if not force:
domain += [('website_indexed', '=', True)]
#is_visible
- domain += [('website_published', '=', True), '|', ('date_publish', '!=', False), ('date_publish', '>', fields.Datetime.now())]
+ domain += [('website_published', '=', True), '|', ('date_publish', '=', False), ('date_publish', '<=', fields.Datetime.now())]
if query_string:
domain += [('url', 'like', query_string)]
@@ -875,12 +875,12 @@ class Menu(models.Model):
replace_id(mid, new_menu.id)
for menu in data['data']:
menu_id = self.browse(menu['id'])
- if menu_id.page_id:
- menu_id.page_id.write({'url': menu['url']})
# if the url match a website.page, set the m2o relation
page = self.env['website.page'].search([('url', '=', menu['url'])], limit=1)
if page:
menu['page_id'] = page.id
+ elif menu_id.page_id:
+ menu_id.page_id.write({'url': menu['url']})
menu_id.write(menu)
return True
diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml
index abae2c3fdfc..1d788f0db55 100644
--- a/addons/website/views/website_templates.xml
+++ b/addons/website/views/website_templates.xml
@@ -117,13 +117,16 @@
</xpath>
<xpath expr="//t[@t-set='head_website']" position="replace">
<t t-set="head_website">
+ <meta name="generator" content="Odoo"/>
+
<meta t-if="main_object and 'website_indexed' in main_object
and not main_object.website_indexed" name="robots" content="noindex"/>
- <meta name="description" t-att-content="main_object and 'website_meta_description' in main_object
+ <t t-set="meta_description" t-value="main_object and 'website_meta_description' in main_object
and main_object.website_meta_description or website_meta_description"/>
- <meta name="keywords" t-att-content="main_object and 'website_meta_keywords' in main_object
+ <t t-set="meta_keywords" t-value="main_object and 'website_meta_keywords' in main_object
and main_object.website_meta_keywords or website_meta_keywords"/>
- <meta name="generator" content="Odoo"/>
+ <meta t-if="meta_description" name="description" t-att-content="meta_description"/>
+ <meta t-if="meta_keywords" name="keywords" t-att-content="meta_keywords"/>
<!-- OpenGraph tags for Facebook sharing -->
<meta property="og:title" t-att-content="additional_title" />
diff --git a/addons/website_event/models/event.py b/addons/website_event/models/event.py
index cd551f0f451..c2b37b0fab2 100644
--- a/addons/website_event/models/event.py
+++ b/addons/website_event/models/event.py
@@ -23,8 +23,8 @@ class Event(models.Model):
website_menu = fields.Boolean(
'Dedicated Menu', compute='_compute_website_menu', inverse='_set_website_menu',
help="Creates menus Introduction, Location and Register on the page "
- " of the event on the website.", store=True, copy=False)
- menu_id = fields.Many2one('website.menu', 'Event Menu')
+ " of the event on the website.", store=True)
+ menu_id = fields.Many2one('website.menu', 'Event Menu', copy=False)
def _compute_is_participating(self):
# we don't allow public user to see participating label
diff --git a/addons/website_forum/controllers/main.py b/addons/website_forum/controllers/main.py
index 94043cdb0b0..ac84166d528 100644
--- a/addons/website_forum/controllers/main.py
+++ b/addons/website_forum/controllers/main.py
@@ -225,9 +225,9 @@ class WebsiteForum(http.Controller):
@http.route('/forum/get_url_title', type='json', auth="user", methods=['POST'], website=True)
def get_url_title(self, **kwargs):
try:
- req = requests.get(kwargs.get('url'), stream=True)
+ req = requests.get(kwargs.get('url'))
req.raise_for_status()
- arch = lxml.html.parse(req.raw)
+ arch = lxml.html.fromstring(req.content)
return arch.find(".//title").text
except IOError:
return False
diff --git a/addons/website_quote/models/sale_order.py b/addons/website_quote/models/sale_order.py
index a54e8368c24..a798cb01b52 100644
--- a/addons/website_quote/models/sale_order.py
+++ b/addons/website_quote/models/sale_order.py
@@ -70,9 +70,16 @@ class SaleOrder(models.Model):
require_payment = fields.Selection([
(0, 'Online Signature'),
(1, 'Online Payment')], default=_get_default_online_payment, string='Confirmation Mode',
+ compute='_compute_require_payment', store=True,
help="Choose how you want to confirm an order to launch the delivery process. You can either "
"request a digital signature or an upfront payment. With a digital signature, you can "
"request the payment when issuing the invoice.")
+ @api.one
+ @api.depends('template_id')
+ def _compute_require_payment(self):
+ # Do not overwrite if the field is already set
+ if self.template_id and (self.require_payment is None or self.require_payment is False):
+ self.require_payment = self.template_id.require_payment
@api.multi
def copy(self, default=None):
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index 1c3c8bb467b..7764600d14a 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -600,14 +600,11 @@
<input type="hidden" t-if="len(product.product_variant_ids) == 1" class="product_id" name="product_id" t-att-value="product.product_variant_id.id"/>
<t t-if="len(product.product_variant_ids) &gt; 1">
<label label-default="label-default" class="radio" t-foreach="product.product_variant_ids" t-as="variant_id">
- <input type="radio" name="product_id" class="js_product_change" t-att-checked="'checked' if variant_id_index == 0 else None" t-att-value="variant_id.id" t-att-data-lst_price="variant_id.lst_price" t-att-data-price="variant_id.price"/>
+ <input type="radio" name="product_id" class="js_product_change" t-att-checked="'checked' if variant_id_index == 0 else ''" t-att-value="variant_id.id" t-att-data-lst_price="compute_currency(variant_id.lst_price)" t-att-data-price="variant_id.price"/>
<span t-esc="variant_id.name_get()[0][1]"/>
- <span class="badge" t-if="variant_id.price_extra">
- <t t-esc="variant_id.price_extra > 0 and '+' or ''"/><span t-field="variant_id.price_extra" style="white-space: nowrap;" t-options='{
- "widget": "monetary",
- "from_currency": product.currency_id,
- "display_currency": website.currency_id
- }'/>
+ <t t-set="diff_price" t-value="variant_id.price - variant_id.product_tmpl_id.price"/>
+ <span class="badge" t-if="diff_price != 0">
+ <t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(diff_price)" t-esc-options='{"widget": "monetary", "display_currency": "website.currency_id"}'/>
</span>
</label>
</t>
diff --git a/doc/_extensions/autojsdoc/parser/tests/test_typespec.py b/doc/_extensions/autojsdoc/parser/tests/test_typespec.py
index d90fe7995dd..b2a5a5258b2 100644
--- a/doc/_extensions/autojsdoc/parser/tests/test_typespec.py
+++ b/doc/_extensions/autojsdoc/parser/tests/test_typespec.py
@@ -35,6 +35,16 @@ def test_parser():
types.Literal('null'),
])])
])
+ assert types.parse('Function<Array<Object[]>>') == types.Alt([
+ types.Type('Function', [types.Alt([
+ types.Type('Array', [types.Alt([
+ types.Type('Array', [
+ types.Type('Object', [])
+ ])
+ ])])
+ ])])
+ ])
+
def test_tokens():
toks = list(types.tokenize('A'))
@@ -64,6 +74,19 @@ def test_tokens():
(types.OP, '>')
]
+ toks = list(types.tokenize('Function<Array<Object[]>>'))
+ assert toks == [
+ (types.NAME, 'Function'),
+ (types.OP, '<'),
+ (types.NAME, 'Array'),
+ (types.OP, '<'),
+ (types.NAME, 'Object'),
+ (types.OP, '['),
+ (types.OP, ']'),
+ (types.OP, '>'),
+ (types.OP, '>')
+ ]
+
def test_peekable():
p = types.Peekable(range(5))
diff --git a/doc/_extensions/autojsdoc/parser/types.py b/doc/_extensions/autojsdoc/parser/types.py
index ecbccfe8651..8737b26ecc9 100644
--- a/doc/_extensions/autojsdoc/parser/types.py
+++ b/doc/_extensions/autojsdoc/parser/types.py
@@ -29,6 +29,9 @@ def tokenize(typespec):
elif toktype == token.OP:
if string in '|<>[].,':
yield (OP, string)
+ elif string == '>>':
+ yield (OP, '>')
+ yield (OP, '>')
elif string == '*': # maybe?
yield (NAME, 'any')
elif string in '()':
diff --git a/doc/cla/individual/osarrat.md b/doc/cla/individual/osarrat.md
new file mode 100644
index 00000000000..bc1df33fd79
--- /dev/null
+++ b/doc/cla/individual/osarrat.md
@@ -0,0 +1,11 @@
+France, 2018-02-16
+
+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,
+
+Olivier Sarrat olivier.sarrat@laposte.net https://github.com/osarrat
diff --git a/doc/cla/individual/subodhdahal.md b/doc/cla/individual/subodhdahal.md
new file mode 100644
index 00000000000..4e4c389bc99
--- /dev/null
+++ b/doc/cla/individual/subodhdahal.md
@@ -0,0 +1,11 @@
+Nepal, 2018-02-09
+
+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,
+
+Subodh Dahal contact.subodhdahal@gmail.com https://github.com/SubodhDahal
diff --git a/doc/cla/individual/willmakenoise.md b/doc/cla/individual/willmakenoise.md
new file mode 100644
index 00000000000..3c8ac824146
--- /dev/null
+++ b/doc/cla/individual/willmakenoise.md
@@ -0,0 +1,10 @@
+Norway, 2018-02-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,
+Marius Stedjan marius@stedjan.com https://github.com/willmakenoise
diff --git a/doc/reference/cmdline.rst b/doc/reference/cmdline.rst
index d35868a718f..2f1626f6dac 100644
--- a/doc/reference/cmdline.rst
+++ b/doc/reference/cmdline.rst
@@ -54,7 +54,7 @@ Running the server
Maximum allowed virtual memory per worker. If the limit is exceeded,
the worker is killed and recycled at the end of the current request.
- Defaults to 640MB.
+ Defaults to 2048MB.
.. option:: --limit-memory-hard <limit>
@@ -62,7 +62,7 @@ Running the server
immediately killed without waiting for the end of the current request
processing.
- Defaults to 768MB.
+ Defaults to 2560MB.
.. option:: --limit-time-cpu <limit>
diff --git a/doc/setup/deploy.rst b/doc/setup/deploy.rst
index b0a5cf3224e..6a1e22f886c 100644
--- a/doc/setup/deploy.rst
+++ b/doc/setup/deploy.rst
@@ -214,6 +214,13 @@ Instead you must have a proxy redirecting requests whose URL starts with
``/longpolling/`` to the longpolling port. Other request should be proxied to
the :option:`normal HTTP port <odoo-bin --http-port>`
+To achieve such a thing, you'll need to deploy a reverse proxy in front of Odoo,
+like nginx or apache. When doing so, you'll need to forward some more http Headers
+to Odoo, and activate the proxy_mode in Odoo configuration to have Odoo read those
+headers.
+
+
+
Configuration sample
--------------------
@@ -316,16 +323,18 @@ in ``/etc/nginx/sites-enabled/odoo.conf`` set:
# log
access_log /var/log/nginx/odoo.access.log;
error_log /var/log/nginx/odoo.error.log;
-
+
+ # Redirect longpoll requests to odoo longpolling port
+ location /longpolling {
+ proxy_pass http://odoochat;
+ }
+
# Redirect requests to odoo backend server
location / {
proxy_redirect off;
proxy_pass http://odoo;
}
- location /longpolling {
- proxy_pass http://odoochat;
- }
-
+
# common gzip
gzip_types text/css text/less text/plain text/xml application/xml application/json application/javascript;
gzip on;
diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py
index 996efde0721..25d7143036b 100644
--- a/odoo/addons/base/models/res_partner.py
+++ b/odoo/addons/base/models/res_partner.py
@@ -539,7 +539,7 @@ class Partner(models.Model):
self.ensure_one()
if self.company_name:
# Create parent company
- values = dict(name=self.company_name, is_company=True)
+ values = dict(name=self.company_name, is_company=True, vat=self.vat)
values.update(self._update_fields_values(self._address_fields()))
new_company = self.create(values)
# Set new company as my parent
diff --git a/odoo/addons/base/views/ir_model_views.xml b/odoo/addons/base/views/ir_model_views.xml
index f914807dd54..27699e330df 100644
--- a/odoo/addons/base/views/ir_model_views.xml
+++ b/odoo/addons/base/views/ir_model_views.xml
@@ -459,7 +459,7 @@
<group col="4">
<field name="type"/>
<field name="name"/>
- <field name="module"/>
+ <field name="module" can_create="false"/>
<field name="model"/>
<newline/>
<field name="date_update" />
@@ -495,7 +495,7 @@
<form string="ManyToMany Relations">
<group col="4">
<field name="name"/>
- <field name="module"/>
+ <field name="module" can_create="false"/>
<field name="model"/>
<newline/>
<field name="date_update" />
diff --git a/odoo/addons/test_impex/tests/test_export.py b/odoo/addons/test_impex/tests/test_export.py
index a360cfd0ef4..8009f97adc0 100644
--- a/odoo/addons/test_impex/tests/test_export.py
+++ b/odoo/addons/test_impex/tests/test_export.py
@@ -601,7 +601,54 @@ class test_m2m(CreatorCase):
['', u'export.many2many.other:13'],
])
- # essentially same as o2m, so boring
+ def test_multiple_records_subfield(self):
+ r = self.make(self.commands)
+ xid = self.env['ir.model.data'].create({
+ 'name': 'whopwhopwhop',
+ 'module': '__t__',
+ 'model': r._name,
+ 'res_id': r.id,
+ }).complete_name
+ sids = [
+ self.env['ir.model.data'].create({
+ 'name': sub.str,
+ 'module': '__t__',
+ 'model': sub._name,
+ 'res_id': sub.id,
+ }).complete_name
+ for sub in r.value
+ ]
+ r.invalidate_cache()
+
+ self.assertEqual(
+ r._export_rows([['value', 'id']]),
+ [['__t__.record000,__t__.record001,__t__.record010,__t__.record011,__t__.record100']]
+ )
+ self.assertEqual(
+ r.with_context(import_compat=True)._export_rows([['value', 'id']]),
+ [['__t__.record000,__t__.record001,__t__.record010,__t__.record011,__t__.record100']]
+ )
+
+ self.assertEqual(
+ r.with_context(import_compat=False)._export_rows([['id'], ['value', 'id'], ['value', 'value']]),
+ [
+ [xid, u'__t__.record000', u'4'],
+ [u'', u'__t__.record001', u'42'],
+ [u'', u'__t__.record010', u'36'],
+ [u'', u'__t__.record011', u'4'],
+ [u'', u'__t__.record100', u'13']
+ ]
+ )
+ self.assertEqual(
+ r.with_context(import_compat=False)._export_rows([['id'], ['value', 'value'], ['value', 'id']]),
+ [
+ [xid, u'4', u'__t__.record000'],
+ [u'', u'42', u'__t__.record001'],
+ [u'', u'36', u'__t__.record010'],
+ [u'', u'4', u'__t__.record011'],
+ [u'', u'13', u'__t__.record100']
+ ]
+ )
class test_function(CreatorCase):
diff --git a/odoo/models.py b/odoo/models.py
index c03ef37b18e..74507e86fce 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -650,8 +650,22 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
:param fields: list of lists of fields to traverse
:return: list of lists of corresponding values
"""
+ import_compatible = self.env.context.get('import_compat', True)
lines = []
- for record in self:
+
+ def splittor(rs):
+ """ Splits the self recordset in batches of 1000 (to avoid
+ entire-recordset-prefetch-effects) & removes the previous batch
+ from the cache after it's been iterated in full
+ """
+ for idx in range(0, len(rs), 1000):
+ sub = rs[idx: idx+1000]
+ for rec in sub:
+ yield rec
+ rs.invalidate_cache(ids=sub.ids)
+
+ # memory stable but ends up prefetching 275 fields (???)
+ for idx, record in enumerate(splittor(self)):
# main line of record, initially empty
current = [''] * len(fields)
lines.append(current)
@@ -683,8 +697,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
else:
primary_done.append(name)
- # This is a special case, its strange behavior is intended!
- if field.type == 'many2many' and len(path) > 1 and path[1] == 'id':
+ # in import_compat mode, m2m should always be exported as
+ # a comma-separated list of xids in a single cell
+ if import_compatible and field.type == 'many2many' and len(path) > 1 and path[1] == 'id':
xml_ids = [r.__export_xml_id() for r in value]
current[i] = ','.join(xml_ids) or False
continue
@@ -2840,47 +2855,48 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
raise UserError(_('Unable to delete this document because it is used as a default property'))
# Delete the records' properties.
- self.env['ir.property'].search([('res_id', 'in', refs)]).unlink()
+ with self.env.norecompute():
+ self.env['ir.property'].search([('res_id', 'in', refs)]).unlink()
- self.check_access_rule('unlink')
+ self.check_access_rule('unlink')
- cr = self._cr
- Data = self.env['ir.model.data'].sudo().with_context({})
- Defaults = self.env['ir.default'].sudo()
- Attachment = self.env['ir.attachment']
+ cr = self._cr
+ Data = self.env['ir.model.data'].sudo().with_context({})
+ Defaults = self.env['ir.default'].sudo()
+ Attachment = self.env['ir.attachment']
- for sub_ids in cr.split_for_in_conditions(self.ids):
- query = "DELETE FROM %s WHERE id IN %%s" % self._table
- cr.execute(query, (sub_ids,))
+ for sub_ids in cr.split_for_in_conditions(self.ids):
+ query = "DELETE FROM %s WHERE id IN %%s" % self._table
+ cr.execute(query, (sub_ids,))
- # Removing the ir_model_data reference if the record being deleted
- # is a record created by xml/csv file, as these are not connected
- # with real database foreign keys, and would be dangling references.
- #
- # Note: the following steps are performed as superuser to avoid
- # access rights restrictions, and with no context to avoid possible
- # side-effects during admin calls.
- data = Data.search([('model', '=', self._name), ('res_id', 'in', sub_ids)])
- if data:
- data.unlink()
-
- # For the same reason, remove the defaults having some of the
- # records as value
- Defaults.discard_records(self.browse(sub_ids))
-
- # For the same reason, remove the relevant records in ir_attachment
- # (the search is performed with sql as the search method of
- # ir_attachment is overridden to hide attachments of deleted
- # records)
- query = 'SELECT id FROM ir_attachment WHERE res_model=%s AND res_id IN %s'
- cr.execute(query, (self._name, sub_ids))
- attachments = Attachment.browse([row[0] for row in cr.fetchall()])
- if attachments:
- attachments.unlink()
-
- # invalidate the *whole* cache, since the orm does not handle all
- # changes made in the database, like cascading delete!
- self.invalidate_cache()
+ # Removing the ir_model_data reference if the record being deleted
+ # is a record created by xml/csv file, as these are not connected
+ # with real database foreign keys, and would be dangling references.
+ #
+ # Note: the following steps are performed as superuser to avoid
+ # access rights restrictions, and with no context to avoid possible
+ # side-effects during admin calls.
+ data = Data.search([('model', '=', self._name), ('res_id', 'in', sub_ids)])
+ if data:
+ data.unlink()
+
+ # For the same reason, remove the defaults having some of the
+ # records as value
+ Defaults.discard_records(self.browse(sub_ids))
+
+ # For the same reason, remove the relevant records in ir_attachment
+ # (the search is performed with sql as the search method of
+ # ir_attachment is overridden to hide attachments of deleted
+ # records)
+ query = 'SELECT id FROM ir_attachment WHERE res_model=%s AND res_id IN %s'
+ cr.execute(query, (self._name, sub_ids))
+ attachments = Attachment.browse([row[0] for row in cr.fetchall()])
+ if attachments:
+ attachments.unlink()
+
+ # invalidate the *whole* cache, since the orm does not handle all
+ # changes made in the database, like cascading delete!
+ self.invalidate_cache()
# recompute new-style fields
if self.env.recompute and self._context.get('recompute', True):
diff --git a/odoo/service/wsgi_server.py b/odoo/service/wsgi_server.py
index af5ae574187..45f9de07b52 100644
--- a/odoo/service/wsgi_server.py
+++ b/odoo/service/wsgi_server.py
@@ -41,7 +41,7 @@ RPC_FAULT_CODE_ACCESS_ERROR = 4
def xmlrpc_handle_exception_int(e):
if isinstance(e, odoo.exceptions.UserError):
- fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, odoo.tools.ustr(e.value))
+ fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, odoo.tools.ustr(e.name))
elif isinstance(e, odoo.exceptions.RedirectWarning):
fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, str(e))
elif isinstance(e, odoo.exceptions.MissingError):