diff options
| author | Christophe Simonis <chs@odoo.com> | 2018-11-28 17:44:46 +0100 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2018-11-28 17:44:46 +0100 |
| commit | efe7ca16b77edce73bf538ccb70678a52afdfd1d (patch) | |
| tree | ad5af92d3bb397fd58fd861e6ce8e3abd2902ec6 | |
| parent | b09f624f20781d65baf2060df40383898e6f86e5 (diff) | |
| parent | bb6f6c57f97b091f3f68161039de980eb9ce739d (diff) | |
[MERGE] forward port branch 11.0 up to bb6f6c57f97
34 files changed, 522 insertions, 116 deletions
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index afd0fe1019b..1b385897262 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -1471,6 +1471,7 @@ class AccountInvoiceLine(models.Model): help="Gives the sequence of this line when displaying the invoice.") invoice_id = fields.Many2one('account.invoice', string='Invoice Reference', ondelete='cascade', index=True) + invoice_type = fields.Selection(related='invoice_id.type', readonly=True) uom_id = fields.Many2one('uom.uom', string='Unit of Measure', ondelete='set null', index=True, oldname='uos_id') product_id = fields.Many2one('product.product', string='Product', diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index af59be29d4f..c30c4232a02 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -339,7 +339,11 @@ class AccountReconciliation(models.AbstractModel): rec_prop = self._get_move_line_reconciliation_proposition(account.id, partner_id) row['reconciliation_proposition'] = self._prepare_move_lines(rec_prop, target_currency=currency) row['company_id'] = account.company_id.id - return rows + + # Return the partners with a reconciliation proposition first, since they are most likely to + # be reconciled. + return [r for r in rows if r['reconciliation_proposition']] + [r for r in rows if not r['reconciliation_proposition']] + @api.model def process_move_lines(self, data): diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index e744f2214ac..11fdffcebcc 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -60,8 +60,11 @@ <form string="Invoice Line"> <group> <group> + <field name="partner_id" invisible="1"/> + <field name="invoice_type" invisible="1"/> + <field name="company_id" invisible="1"/> <field name="product_id" - context="parent and {'partner_id': parent.partner_id}"/> + context="{'partner_id': partner_id}"/> <label for="quantity"/> <div> <field name="quantity" class="oe_inline"/> @@ -73,9 +76,9 @@ </group> <group> <field name="analytic_tag_ids" groups="analytic.group_analytic_accounting" widget="many2many_tags" options="{'color_field': 'color'}"/> - <field domain="[('company_id', '=', parent.company_id)]" name="account_id" groups="account.group_account_user"/> - <field name="invoice_line_tax_ids" context="{'type':parent.type}" domain="[('type_tax_use','!=','none'),('company_id', '=', parent.company_id)]" widget="many2many_tags" options="{'no_create': True}"/> - <field domain="[('company_id', '=', parent.company_id)]" name="account_analytic_id" groups="analytic.group_analytic_accounting"/> + <field domain="[('company_id', '=', company_id)]" name="account_id" groups="account.group_account_user"/> + <field name="invoice_line_tax_ids" context="{'type': invoice_type}" domain="[('type_tax_use','!=','none'),('company_id', '=', company_id)]" widget="many2many_tags" options="{'no_create': True}"/> + <field domain="[('company_id', '=', company_id)]" name="account_analytic_id" groups="analytic.group_analytic_accounting"/> <field name="analytic_tag_ids" widget="many2many_tags" groups="analytic.group_analytic_tags"/> <field name="company_id" groups="base.group_multi_company" readonly="1"/> </group> diff --git a/addons/hr_attendance/models/hr_employee.py b/addons/hr_attendance/models/hr_employee.py index 83e44105c00..388fe60a4c0 100644 --- a/addons/hr_attendance/models/hr_employee.py +++ b/addons/hr_attendance/models/hr_employee.py @@ -49,7 +49,9 @@ class HrEmployee(models.Model): @api.depends('attendance_ids') def _compute_last_attendance_id(self): for employee in self: - employee.last_attendance_id = employee.attendance_ids and employee.attendance_ids[0] or False + employee.last_attendance_id = self.env['hr.attendance'].search([ + ('employee_id', '=', employee.id), + ], limit=1) @api.depends('last_attendance_id.check_in', 'last_attendance_id.check_out', 'last_attendance_id') def _compute_attendance_state(self): diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 60fe5487083..92ff7d4b3ab 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -441,6 +441,7 @@ class HrExpense(models.Model): 'employee_id': employee.id, 'product_id': product.id, 'product_uom_id': product.uom_id.id, + 'tax_ids': [(4, tax.id, False) for tax in product.supplier_taxes_id], 'quantity': 1, 'unit_amount': price, 'company_id': employee.company_id.id, diff --git a/addons/hr_expense/tests/test_expenses.py b/addons/hr_expense/tests/test_expenses.py index 976ba9188ae..b25b968ce7b 100644 --- a/addons/hr_expense/tests/test_expenses.py +++ b/addons/hr_expense/tests/test_expenses.py @@ -72,3 +72,23 @@ class TestAccountEntry(TestExpenseCommon): self.assertEquals(len(self.analytic_account.line_ids), 1, "Analytic Account should have only one line") self.assertAlmostEquals(self.analytic_account.line_ids[0].amount, -636.36, "Amount on the only AAL is wrong") self.assertEquals(self.analytic_account.line_ids[0].product_id, self.product_expense, "Product of AAL should be the one from the expense") + + def test_expense_from_email(self): + user_demo = self.env.ref('base.user_demo') + self.tax.price_include = False + + message_parsed = { + 'message_id': 'the-world-is-a-ghetto', + 'subject': '[AT] 9876', + 'email_from': 'demo@yourcompany.example.com', + 'to': 'catchall@yourcompany.com', + 'body': "Don't you know, that for me, and for you", + 'attachments': [], + } + + expense = self.env['hr.expense'].message_new(message_parsed) + + self.assertEquals(expense.product_id, self.product) + self.assertEquals(expense.tax_ids.ids, [self.tax.id]) + self.assertEquals(expense.total_amount, 10863.60) + self.assertTrue(expense.employee_id in user_demo.employee_ids) diff --git a/addons/hr_payroll/models/hr_payslip.py b/addons/hr_payroll/models/hr_payslip.py index c9d14c47495..93ee4d42393 100644 --- a/addons/hr_payroll/models/hr_payslip.py +++ b/addons/hr_payroll/models/hr_payslip.py @@ -194,7 +194,7 @@ class HrPayslip(models.Model): current_leave_struct['number_of_days'] += leave_time / work_hours # compute worked days - work_data = contract.employee_id.get_work_days_data(day_from, day_to, calendar=contract.resource_calendar_id) + work_data = contract.employee_id.with_context(no_tz_convert=True).get_work_days_data(day_from, day_to, calendar=contract.resource_calendar_id) attendances = { 'name': _("Normal Working Days paid at 100%"), 'sequence': 1, diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 4671c71b1ba..1d53888c9f3 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -61,7 +61,18 @@ class MailMail(models.Model): # notification field: if not set, set if mail comes from an existing mail.message if 'notification' not in values and values.get('mail_message_id'): values['notification'] = True - return super(MailMail, self).create(values) + new_mail = super(MailMail, self).create(values) + if values.get('attachment_ids'): + new_mail.attachment_ids.check(mode='read') + return new_mail + + @api.multi + def write(self, vals): + res = super(MailMail, self).write(vals) + if vals.get('attachment_ids'): + for mail in self: + mail.attachment_ids.check(mode='read') + return res @api.multi def unlink(self): diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index e71c4522d80..97a2b729d83 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -12,7 +12,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+/\n]{3,}=*)\n*([\'"])', re.I) +_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])(?: data-filename="([^"]*)")?', re.I) class Message(models.Model): @@ -764,12 +764,13 @@ class Message(models.Model): def base64_to_boundary(match): key = match.group(2) if not data_to_url.get(key): - name = 'image%s' % len(data_to_url) + name = match.group(4) if match.group(4) else 'image%s' % len(data_to_url) attachment = Attachments.create({ 'name': name, 'datas': match.group(2), 'datas_fname': name, - 'res_model': 'mail.message', + 'res_model': values.get('model'), + 'res_id': values.get('res_id'), }) attachment.generate_access_token() values['attachment_ids'].append((4, attachment.id)) @@ -780,6 +781,10 @@ class Message(models.Model): # delegate creation of tracking after the create as sudo to avoid access rights issues tracking_values_cmd = values.pop('tracking_value_ids', False) message = super(Message, self).create(values) + + if values.get('attachment_ids'): + message.attachment_ids.check(mode='read') + if tracking_values_cmd: message.sudo().write({'tracking_value_ids': tracking_values_cmd}) @@ -800,6 +805,9 @@ class Message(models.Model): if 'model' in vals or 'res_id' in vals: self._invalidate_documents() res = super(Message, self).write(vals) + if vals.get('attachment_ids'): + for mail in self: + mail.attachment_ids.check(mode='read') if 'notification_ids' in vals or 'model' in vals or 'res_id' in vals: self._invalidate_documents() return res diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index f21de36c28f..25ba71e0e75 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -191,6 +191,8 @@ class MailThread(models.AbstractModel): @api.multi def _get_message_needaction(self): res = dict((res_id, 0) for res_id in self.ids) + if not res: + return # search for unread messages, directly in SQL to improve performances self._cr.execute(""" SELECT msg.res_id FROM mail_message msg diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index ecf21840aff..ded7f8b7d43 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -78,7 +78,7 @@ class Partner(models.Model): company = user.company_id if record and hasattr(record, 'company_id'): - company = record.company_id + company = record.company_id.sudo() return { 'message': message, diff --git a/addons/mail/wizard/invite.py b/addons/mail/wizard/invite.py index 579c304b191..c7ae892bd3a 100644 --- a/addons/mail/wizard/invite.py +++ b/addons/mail/wizard/invite.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from lxml import etree +from lxml.html import builder as html + from odoo import _, api, fields, models @@ -12,18 +15,27 @@ class Invite(models.TransientModel): @api.model def default_get(self, fields): result = super(Invite, self).default_get(fields) + if self._context.get('mail_invite_follower_channel_only'): + result['send_mail'] = False + if 'message' not in fields: + return result + user_name = self.env.user.name_get()[0][1] model = result.get('res_model') res_id = result.get('res_id') - if self._context.get('mail_invite_follower_channel_only'): - result['send_mail'] = False - if 'message' in fields and model and res_id: - model_name = self.env['ir.model']._get(model).display_name - document_name = self.env[model].browse(res_id).name_get()[0][1] - message = _('<div><p>Hello,</p><p>%s invited you to follow %s document: %s.</p></div>') % (user_name, model_name, document_name) - result['message'] = message - elif 'message' in fields: - result['message'] = _('<div><p>Hello,</p><p>%s invited you to follow a new document.</p></div>') % user_name + if model and res_id: + document = self.env['ir.model']._get(model).display_name + title = self.env[model].browse(res_id).display_name + msg_fmt = _('%(user_name)s invited you to follow %(document)s document: %(title)s') + else: + msg_fmt = _('%(user_name)s invited you to follow a new document.') + + text = msg_fmt % locals() + message = html.DIV( + html.P(_('Hello,')), + html.P(text) + ) + result['message'] = etree.tostring(message) return result res_model = fields.Char('Related Document Model', required=True, index=True, help='Model of the followed resource') diff --git a/addons/purchase_requisition/models/purchase_requisition.py b/addons/purchase_requisition/models/purchase_requisition.py index 06f53a446f4..a0514cc3509 100644 --- a/addons/purchase_requisition/models/purchase_requisition.py +++ b/addons/purchase_requisition/models/purchase_requisition.py @@ -363,8 +363,8 @@ class PurchaseOrder(models.Model): self.order_line = order_lines @api.multi - def button_confirm(self): - res = super(PurchaseOrder, self).button_confirm() + def button_approve(self, force=False): + res = super(PurchaseOrder, self).button_approve(force=force) for po in self: if not po.requisition_id: continue diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index 198e2769c38..833ea6e8af3 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -437,8 +437,9 @@ class ResourceCalendar(models.Model): if not end_dt: end_dt = datetime.datetime.combine(start_dt.date(), datetime.time.max) - start_dt = to_naive_user_tz(start_dt, self.env.user) - end_dt = to_naive_user_tz(end_dt, self.env.user) + if not self.env.context.get('no_tz_convert', False): + start_dt = to_naive_user_tz(start_dt, self.env.user) + end_dt = to_naive_user_tz(end_dt, self.env.user) for day in rrule.rrule(rrule.DAILY, dtstart=start_dt, diff --git a/addons/resource/models/resource_mixin.py b/addons/resource/models/resource_mixin.py index c08bd66d628..9afef9ca40e 100644 --- a/addons/resource/models/resource_mixin.py +++ b/addons/resource/models/resource_mixin.py @@ -57,6 +57,7 @@ class ResourceMixin(models.AbstractModel): days_count = 0.0 total_work_time = timedelta() calendar = calendar or self.resource_calendar_id + calendar = calendar.with_context(no_tz_convert=self.env.context.get('no_tz_convert', False)) for day_intervals in calendar._iter_work_intervals( from_datetime, to_datetime, self.resource_id.id, compute_leaves=True, domain=domain): diff --git a/addons/sale/static/src/js/sale.js b/addons/sale/static/src/js/sale.js index 374508f721b..267b65c6069 100644 --- a/addons/sale/static/src/js/sale.js +++ b/addons/sale/static/src/js/sale.js @@ -19,30 +19,39 @@ KanbanRecord.include({ */ _onSalesTeamTargetClick: function (ev) { ev.preventDefault(); + var self = this; this.$target_input = $('<input>'); this.$('.o_kanban_primary_bottom:last').html(this.$target_input); this.$('.o_kanban_primary_bottom:last').prepend(_t("Set an invoicing target: ")); this.$target_input.focus(); + this.$target_input.on({ + blur: this._onSalesTeamTargetSet.bind(this), + keydown: function (ev) { + if (ev.keyCode === $.ui.keyCode.ENTER) { + self._onSalesTeamTargetSet(); + } + }, + }); + }, + /** + * Mostly a handler for what happens to the input "this.$target_input" + * + * @private + * @param {JqueryEvent} ev + * + */ + _onSalesTeamTargetSet: function () { var self = this; - this.$target_input.blur(function() { - var value = Number(self.$target_input.val()); - if (isNaN(value)) { - self.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid.")); - } else { - self._rpc({ - model: 'crm.team', - method: 'write', - args: [[self.id], { 'invoiced_target': value }], - }) - .done(function() { - self.trigger_up('kanban_record_update', {id: self.id}); - }); + var value = Number(this.$target_input.val()); + if (isNaN(value)) { + this.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid.")); + } else { + this.trigger_up('kanban_record_update', {invoiced_target: value}); // TODO: previous lines can be refactored as follows (in master) // self.trigger_up('kanban_record_update', {invoiced_target: value}); - } - }); + } }, }); diff --git a/addons/sale/static/tests/sales_team_dashboard_tests.js b/addons/sale/static/tests/sales_team_dashboard_tests.js index b1043dd64e1..e73b35a78c0 100644 --- a/addons/sale/static/tests/sales_team_dashboard_tests.js +++ b/addons/sale/static/tests/sales_team_dashboard_tests.js @@ -23,7 +23,7 @@ QUnit.module('Sales Team Dashboard', { }); QUnit.test('edit target with several o_kanban_primary_bottom divs', function (assert) { - assert.expect(4); + assert.expect(6); var kanban = createView({ View: KanbanView, @@ -33,6 +33,7 @@ QUnit.test('edit target with several o_kanban_primary_bottom divs', function (as '<templates>' + '<t t-name="kanban-box">' + '<div class="container o_kanban_card_content">' + + '<field name="invoiced_target" />' + '<a href="#" class="sales_team_target_definition o_inline_link">' + 'Click to define a target</a>' + '<div class="col-12 o_kanban_primary_bottom"/>' + @@ -46,9 +47,12 @@ QUnit.test('edit target with several o_kanban_primary_bottom divs', function (as assert.strictEqual(args.args[1].invoiced_target, 123, "new value is correctly saved"); } + if (args.method === 'read') { // Read happens after the write + assert.deepEqual(args.args[1], ['invoiced_target', 'display_name'], + 'the read (after write) should ask for invoiced_target'); + } return this._super.apply(this, arguments); }, - }); assert.strictEqual(kanban.$('.o_kanban_view .sales_team_target_definition').length, 1, @@ -64,6 +68,54 @@ QUnit.test('edit target with several o_kanban_primary_bottom divs', function (as kanban.$('.o_kanban_primary_bottom:last input').val('123'); kanban.$('.o_kanban_primary_bottom:last input').blur(); + assert.strictEqual(kanban.$('.o_kanban_record').text(), "123Click to define a target", + 'The kanban record should display the updated target value'); + + kanban.destroy(); +}); + +QUnit.test('edit target supports push Enter', function (assert) { + assert.expect(3); + + var kanban = createView({ + View: KanbanView, + model: 'crm.team', + data: this.data, + arch: '<kanban>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div class="container o_kanban_card_content">' + + '<field name="invoiced_target" />' + + '<a href="#" class="sales_team_target_definition o_inline_link">' + + 'Click to define a target</a>' + + '<div class="col-12 o_kanban_primary_bottom"/>' + + '<div class="col-12 o_kanban_primary_bottom bottom_block"/>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.strictEqual(args.args[1].invoiced_target, 123, + "new value is correctly saved"); + } + if (args.method === 'read') { // Read happens after the write + assert.deepEqual(args.args[1], ['invoiced_target', 'display_name'], + 'the read (after write) should ask for invoiced_target'); + } + return this._super.apply(this, arguments); + }, + }); + + kanban.$('a.sales_team_target_definition').click(); + + kanban.$('.o_kanban_primary_bottom:last input').focus(); + kanban.$('.o_kanban_primary_bottom:last input').val('123'); + kanban.$('.o_kanban_primary_bottom:last input').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER, keyCode: $.ui.keyCode.ENTER})); + + assert.strictEqual(kanban.$('.o_kanban_record').text(), "123Click to define a target", + 'The kanban record should display the updated target value'); + kanban.destroy(); }); diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index c42c193999c..409741aa0a5 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -268,7 +268,8 @@ class StockMove(models.Model): # Update the standard price with the price of the last used candidate, if any. if new_standard_price and move.product_id.cost_method == 'fifo': - move.product_id.sudo().standard_price = new_standard_price + move.product_id.sudo().with_context(force_company=move.company_id.id) \ + .standard_price = new_standard_price # If there's still quantity to value but we're out of candidates, we fall in the # negative stock use case. We chose to value the out move at the price of the diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index c078069b074..b51fa49ed9a 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -2096,6 +2096,18 @@ var FieldProgressBar = AbstractField.extend({ this.$('.o_progressbar_value').focus().select(); } }, + /** + * The progress bar has more than one field/value to deal with + * i.e. max_value + * + * @override + * @private + */ + _reset: function () { + this._super.apply(this, arguments); + var new_max_value = this.recordData[this.nodeOptions.max_value]; + this.max_value = new_max_value !== undefined ? new_max_value : this.max_value; + }, isSet: function () { return true; }, diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 2bb98932c68..b6fbc0cd924 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -468,6 +468,7 @@ var FieldMany2One = AbstractField.extend({ initial_ids: ids ? _.map(ids, function (x) { return x[0]; }) : undefined, initial_view: view, disable_multiple_selection: true, + no_create: !self.can_create, on_selected: function (records) { self.reinitialize(records[0]); self.activate(); diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index 9b6e5a5d192..2dc154d6eae 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -141,6 +141,7 @@ var AbstractView = Class.extend({ modelName: params.modelName, res_id: params.currentId, res_ids: params.ids, + orderedBy: params.context ? params.context.orderedBy : [], }; if (params.modelName) { this.loadParams.modelName = params.modelName; 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 d236673b0d5..69fbe44b14b 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -3365,7 +3365,7 @@ var BasicModel = AbstractModel.extend({ model: params.modelName, offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0), openGroupByDefault: params.openGroupByDefault, - orderedBy: params.orderedBy || (params.context && params.context.orderedBy) || [], + orderedBy: params.orderedBy || [], orderedResIDs: params.orderedResIDs, parentID: params.parentID, rawContext: params.rawContext, @@ -4279,7 +4279,7 @@ var BasicModel = AbstractModel.extend({ if (orderData1 > orderData2) { return order.asc ? 1 : -1; } - return compareRecords(record1ID, record2ID, level + 1); + return compareRecords(resId1, resId2, level + 1); }; utils.stableSort(list.res_ids, compareRecords); } diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index c3cb0c3c404..9e94db14cb7 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -4888,6 +4888,48 @@ QUnit.module('basic_fields', { form.destroy(); }); + + QUnit.module('FieldProgressBar'); + + QUnit.test('Field ProgressBar: max_value should update', function (assert) { + assert.expect(2); + + this.data.partner.records = this.data.partner.records.slice(0,1); + this.data.partner.records[0].qux = 2; + + this.data.partner.onchanges = { + display_name: function (obj) { + obj.int_field = 999; + obj.qux = 5; + } + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="display_name" />' + + '<field name="qux" invisible="1" />' + + '<field name="int_field" widget="progressbar" options="{\'current_value\': \'int_field\', \'max_value\': \'qux\'}" />' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '10 / 2', + 'The initial value of the progress bar should be correct'); + + // trigger the onchange + form.$('.o_input[name=display_name]').val('new name').trigger('input'); + + assert.strictEqual(form.$('.o_progressbar_value').text(), '999 / 5', + 'The value of the progress bar should be correct after the update'); + + form.destroy(); + }); }); }); }); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index ca6cd680269..61aaa29a664 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -2733,6 +2733,48 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('x2many default_order multiple fields', function (assert) { + assert.expect(7); + + this.data.partner.records = [ + {int_field: 10, id: 1, display_name: "record1"}, + {int_field: 12, id: 2, display_name: "record2"}, + {int_field: 11, id: 3, display_name: "record3"}, + {int_field: 12, id: 4, display_name: "record4"}, + {int_field: 10, id: 5, display_name: "record5"}, + {int_field: 10, id: 6, display_name: "record6"}, + {int_field: 11, id: 7, display_name: "record7"}, + ]; + + this.data.partner.records[0].p = [1, 7, 4, 5, 2, 6, 3]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="p" >' + + '<tree default_order="int_field,id">' + + '<field name="id"/>' + + '<field name="int_field"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + }); + + var $recordList = form.$('.o_field_x2many_list .o_data_row'); + var expectedOrderId = ['1', '5', '6', '3', '7', '2', '4']; + + _.each($recordList, function(record, index) { + var $record = $(record); + assert.strictEqual($record.find('.o_data_cell').eq(0).text(), expectedOrderId[index], + 'The record should be the right place. Index: ' + index); + }); + + form.destroy(); + }); + QUnit.test('many2many list add *many* records, remove, re-add', function (assert) { assert.expect(5); @@ -2873,6 +2915,41 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many from a model that has been sorted', function (assert) { + assert.expect(1); + + /* On a standard list view, sort your records by a field + * Click on a record which contains a x2m with multiple records in it + * The x2m shouldn't take the orderedBy of the parent record (the one on the form) + */ + + this.data.partner.records[0].turtles = [3, 2]; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners">' + + '<field name="turtles">' + + '<tree>' + + '<field name="turtle_foo"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + context: { + orderedBy: [{ + name: 'foo', + asc: false, + }] + }, + }); + + assert.strictEqual(form.$('.o_field_one2many[name=turtles] tbody').text().trim(), "kawablip", + 'The o2m should not have been sorted.'); + + form.destroy(); + }); + QUnit.test('O2M List with pager, decoration and default_order: add and cancel adding', function (assert) { assert.expect(3); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index 66e5859a1bf..a6cf176468b 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -476,6 +476,66 @@ QUnit.module('Views', { testUtils.unpatch(ListController); parent.destroy(); }); + + QUnit.test('propagate can_create onto the search popup o2m', function (assert) { + assert.expect(3); + + this.data.instrument.records = [ + {id: 1, name: 'Tromblon1'}, + {id: 2, name: 'Tromblon2'}, + {id: 3, name: 'Tromblon3'}, + {id: 4, name: 'Tromblon4'}, + {id: 5, name: 'Tromblon5'}, + {id: 6, name: 'Tromblon6'}, + {id: 7, name: 'Tromblon7'}, + {id: 8, name: 'Tromblon8'}, + ]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="name"/>' + + '<field name="instrument" can_create="false"/>' + + '</form>', + res_id: 1, + archs: { + 'instrument,false,list': '<tree>'+ + '<field name="name"/>'+ + '</tree>', + 'instrument,false,search': '<search>'+ + '<field name="name"/>'+ + '</search>', + }, + viewOptions: { + mode: 'edit', + }, + + mockRPC: function(route, args) { + if (args.method === 'get_formview_id') { + return $.when(false); + } + return this._super(route, args); + }, + }); + + form.$('.o_field_widget[name="instrument"] .o_input').click(); + + assert.notOk($('.ui-autocomplete a:contains(Create and Edit)').length, + 'Create and edit not present in dropdown'); + + $('.ui-autocomplete a:contains(Search More)').trigger('mouseenter').trigger('click'); + + var $modal = $('.modal-dialog.modal-lg'); + + assert.strictEqual($modal.length, 1, 'Modal present'); + + assert.strictEqual($modal.find('.modal-footer button').text(), "Cancel", + 'Only the cancel button is present in modal'); + + form.destroy(); + }); }); }); diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index f7de11fe485..6fb01886d20 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -54,7 +54,7 @@ class SaleOrder(models.Model): abandoned_delay = float(self.env['ir.config_parameter'].sudo().get_param('website_sale.cart_abandoned_delay', '1.0')) abandoned_datetime = fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=abandoned_delay)) for order in self: - domain = order.date_order <= abandoned_datetime and order.team_id.team_type == 'website' and order.state == 'draft' and order.partner_id.id != self.env.ref('base.public_partner').id and order.order_line + domain = order.date_order and order.date_order <= abandoned_datetime and order.team_id.team_type == 'website' and order.state == 'draft' and order.partner_id.id != self.env.ref('base.public_partner').id and order.order_line order.is_abandoned_cart = bool(domain) def _search_abandoned_cart(self, operator, value): diff --git a/doc/cla/corporate/camptocamp.md b/doc/cla/corporate/camptocamp.md index 206fad26646..e4206b66474 100644 --- a/doc/cla/corporate/camptocamp.md +++ b/doc/cla/corporate/camptocamp.md @@ -34,3 +34,4 @@ Damien Crier damien.crier@camptocamp.com https://github.com/damdam-s Simone Orsi simahawk@gmail.com https://github.com/simahawk Patrick Tombez patrick.tombez@camptocamp.com https://github.com/p-tombez Julien Coux julien.coux@camptocamp.com https://github.com/jcoux +Alexandre Saunier alexandre.saunier@camptocamp.com https://github.com/asaunier diff --git a/doc/setup/install.rst b/doc/setup/install.rst index c1f65abafd1..add4f22962b 100644 --- a/doc/setup/install.rst +++ b/doc/setup/install.rst @@ -217,10 +217,13 @@ command is available and configured properly, run the following commands : $ sudo systemctl start postgresql In order to print PDF reports, you must install wkhtmltopdf_ yourself: -the version of wkhtmltopdf_ available in debian repositories does not support -headers and footers so it can not be installed automatically. -The recommended version is 0.12.1 and is available on `the wkhtmltopdf download page`_, -in the archive section. +the version of wkhtmltopdf_ available in Debian repositories does +not support headers and footers so it is not used as a direct dependency. +The recommended version is 0.12.5 and is available on +`the wkhtmltopdf download page`_, in the archive section. Previously +recommended version 0.12.1 is a good alternative. +More details on the various versions and their respective quirks can be +found in our `wiki <https://github.com/odoo/odoo/wiki/Wkhtmltopdf>`_. Repository ^^^^^^^^^^ @@ -606,7 +609,7 @@ official Odoo `docker image <https://registry.hub.docker.com/_/odoo/>`_ page. http://www.enterprisedb.com/products-services-training/pgdownload .. _Quilt: http://en.wikipedia.org/wiki/Quilt_(software) .. _saas: https://www.odoo.com/page/start -.. _the wkhtmltopdf download page: https://github.com/wkhtmltopdf/wkhtmltopdf/releases/tag/0.12.1 +.. _the wkhtmltopdf download page: https://github.com/wkhtmltopdf/wkhtmltopdf/releases/tag/0.12.5 .. _UAC: http://en.wikipedia.org/wiki/User_Account_Control .. _wkhtmltopdf: http://wkhtmltopdf.org .. _pip: https://pip.pypa.io diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py index 4ec7c98cf3a..e3865a476ff 100644 --- a/odoo/addons/base/models/ir_translation.py +++ b/odoo/addons/base/models/ir_translation.py @@ -438,15 +438,15 @@ class IrTranslation(models.Model): if not callable(field.translate): return - trans = self.env['ir.translation'] - outdated = trans - discarded = trans + Translation = self.env['ir.translation'] + outdated = Translation + discarded = Translation for record in records: # get field value and terms to translate value = record[field.name] terms = set(field.get_trans_terms(value)) - record_trans = trans.search([ + translations = Translation.search([ ('type', '=', 'model'), ('name', '=', "%s,%s" % (field.model_name, field.name)), ('res_id', '=', record.id), @@ -454,25 +454,32 @@ class IrTranslation(models.Model): if not terms: # discard all translations for that field - discarded += record_trans + discarded += translations continue - # remap existing translations on terms when possible - trans_src = record_trans.mapped('src') - for trans in record_trans: - if trans.src == trans.value: - discarded += trans - elif trans.src not in terms: - matches = get_close_matches(trans.src, terms, 1, 0.9) - if matches: - if matches[0] in trans_src: - # there is already a translation for this term; discard this one - discarded += trans - else: - trans.write({'src': matches[0], 'state': trans.state}) - trans_src.append(matches[0]) # avoid reuse of term - else: - outdated += trans + # remap existing translations on terms when possible; each term + # should be translated at most once per language + done = set() # {(src, lang), ...} + translations_to_match = [] + + for translation in translations: + if translation.src == translation.value: + discarded += translation + elif translation.src in terms: + done.add((translation.src, translation.lang)) + else: + translations_to_match.append(translation) + + for translation in translations_to_match: + matches = get_close_matches(translation.src, terms, 1, 0.9) + src = matches[0] if matches else None + if not src: + outdated += translation + elif (src, translation.lang) in done: + discarded += translation + else: + translation.write({'src': src, 'state': translation.state}) + done.add((src, translation.lang)) # process outdated and discarded translations outdated.write({'state': 'to_translate'}) diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index b3268f359f9..84d740ded24 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -448,6 +448,8 @@ class Users(models.Model): @api.model def check_credentials(self, password): """ Override this method to plug additional authentication methods""" + if not password: + raise AccessDenied() user = self.sudo().search([('id', '=', self._uid), ('password', '=', password)]) if not user: raise AccessDenied() diff --git a/odoo/addons/base/tests/test_translate.py b/odoo/addons/base/tests/test_translate.py index e8a76b12c86..399508e9fcd 100644 --- a/odoo/addons/base/tests/test_translate.py +++ b/odoo/addons/base/tests/test_translate.py @@ -310,30 +310,36 @@ class TestTranslation(TransactionCase): class TestXMLTranslation(TransactionCase): def setUp(self): super(TestXMLTranslation, self).setUp() - self.env['ir.translation'].load_module_terms(['base'], ['fr_FR']) + self.env['ir.translation'].load_module_terms(['base'], ['fr_FR', 'nl_NL']) + + def create_view(self, archf, terms, **kwargs): + view = self.env['ir.ui.view'].create({ + 'name': 'test', + 'model': 'res.partner', + 'arch': archf % terms, + }) + for lang, trans_terms in kwargs.items(): + for src, val in pycompat.izip(terms, trans_terms): + self.env['ir.translation'].create({ + 'type': 'model', + 'name': 'ir.ui.view,arch_db', + 'lang': lang, + 'res_id': view.id, + 'src': src, + 'value': val, + 'state': 'translated', + }) + return view def test_copy(self): """ Create a simple view, fill in translations, and copy it. """ - env_en = self.env(context={}) - env_fr = self.env(context={'lang': 'fr_FR'}) - archf = '<form string="%s"><div>%s</div><div>%s</div></form>' terms_en = ('Knife', 'Fork', 'Spoon') terms_fr = ('Couteau', 'Fourchette', 'Cuiller') - view0 = self.env['ir.ui.view'].create({ - 'name': 'test', - 'model': 'res.partner', - 'arch': archf % terms_en, - }) - for src, value in list(pycompat.izip(terms_en, terms_fr)): - self.env['ir.translation'].create({ - 'type': 'model', - 'name': 'ir.ui.view,arch_db', - 'lang': 'fr_FR', - 'res_id': view0.id, - 'src': src, - 'value': value, - }) + view0 = self.create_view(archf, terms_en, fr_FR=terms_fr) + + env_en = self.env(context={}) + env_fr = self.env(context={'lang': 'fr_FR'}) # check translated field self.assertEqual(view0.with_env(env_en).arch_db, archf % terms_en) @@ -360,17 +366,29 @@ class TestXMLTranslation(TransactionCase): archf = '<form string="%s"><div>%s</div><div>%s</div></form>' terms_en = ('Knife', 'Fork', 'Spoon') terms_fr = (' Couteau', 'Fourchette ', ' Cuiller ') - view0 = self.env['ir.ui.view'].create({ - 'name': 'test', - 'model': 'res.partner', - 'arch': archf % terms_en, - }) - for src, value in list(pycompat.izip(terms_en, terms_fr)): - self.env['ir.translation'].create({ - 'type': 'model', - 'name': 'ir.ui.view,arch_db', - 'lang': 'fr_FR', - 'res_id': view0.id, - 'src': src, - 'value': value, - }) + self.create_view(archf, terms_en, fr_FR=terms_fr) + + def test_sync(self): + """ Check translations after minor change in source terms. """ + archf = '<form string="X">%s</form>' + terms_en = ('Bread and cheeze',) + terms_fr = ('Pain et fromage',) + terms_nl = ('Brood and kaas',) + view = self.create_view(archf, terms_en, fr_FR=terms_fr, nl_NL=terms_nl) + + env_en = self.env(context={}) + env_fr = self.env(context={'lang': 'fr_FR'}) + env_nl = self.env(context={'lang': 'nl_NL'}) + + self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en) + self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr) + self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl) + + # modify source term in view (fixed type in 'cheeze') + terms_en = ('Bread and cheese',) + view.write({'arch_db': archf % terms_en}) + + # check whether translations have been synchronized + self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en) + self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr) + self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl) diff --git a/odoo/addons/test_new_api/tests/test_onchange.py b/odoo/addons/test_new_api/tests/test_onchange.py index 1bfdee908a7..03cad7f4009 100644 --- a/odoo/addons/test_new_api/tests/test_onchange.py +++ b/odoo/addons/test_new_api/tests/test_onchange.py @@ -1,4 +1,8 @@ # -*- coding: utf-8 -*- +try: + from unittest.mock import patch +except ImportError: + from mock import patch from odoo.tests import common @@ -465,3 +469,32 @@ class TestOnChange(common.TransactionCase): result = Message.onchange(value, ['message', 'message_name', 'message_currency'], field_onchange) self.assertEqual(result['value'], onchange_result) + + def test_onchange_many2one_one2many(self): + """ Setting a many2one field should not read the inverse one2many. """ + discussion = self.env.ref('test_new_api.discussion_0') + field_onchange = self.Message._onchange_spec() + self.assertEqual(field_onchange.get('discussion'), '1') + + values = { + 'discussion': discussion.id, + 'name': "[%s] %s" % ('', self.env.user.name), + 'body': False, + 'author': self.env.uid, + 'size': 0, + } + + called = [False] + orig_read = type(discussion).read + + def mock_read(self, fields=None, load='_classic_read'): + if discussion in self and 'messages' in (fields or ()): + called[0] = True + return orig_read(self, fields, load) + + # changing 'discussion' on message should not read 'messages' on discussion + with patch.object(type(discussion), 'read', mock_read, create=True): + self.env.cache.invalidate() + self.Message.onchange(values, 'discussion', field_onchange) + + self.assertFalse(called[0], "discussion.messages has been read") diff --git a/odoo/api.py b/odoo/api.py index d1b1872e7f5..075d9ac3cab 100644 --- a/odoo/api.py +++ b/odoo/api.py @@ -983,6 +983,12 @@ class Cache(object): value = self._data[field][record.id].get(key, SpecialValue(None)) return default if isinstance(value, SpecialValue) else value + def get_special(self, record, field, default=None): + """ Return the special value of ``field`` for ``record``. """ + key = field.cache_key(record) + value = self._data[field][record.id].get(key) + return value.get if isinstance(value, SpecialValue) else default + def set_special(self, record, field, getter): """ Set the value of ``field`` for ``record`` to return ``getter()``. """ key = field.cache_key(record) diff --git a/odoo/fields.py b/odoo/fields.py index 39fe7825e30..d8efade6691 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -2054,6 +2054,26 @@ class Many2one(_Relational): return super(Many2one, self).convert_to_onchange(value, record, names) +class _RelationalMultiUpdate(object): + """ A getter to update the value of an x2many field, without reading its + value until necessary. + """ + __slots__ = ['record', 'field', 'value'] + + def __init__(self, record, field, value): + self.record = record + self.field = field + self.value = value + + def __call__(self): + # determine the current field's value, and update it in cache only + record, field, value = self.record, self.field, self.value + cache = record.env.cache + cache.remove(record, field) + val = field.convert_to_cache(record[field.name] | value, record, validate=False) + cache.set(record, field, val) + return val + class _RelationalMulti(_Relational): """ Abstract class for relational fields *2many. """ @@ -2065,21 +2085,16 @@ class _RelationalMulti(_Relational): """ Update the cached value of ``self`` for ``records`` with ``value``. """ cache = records.env.cache for record in records: - if cache.contains(record, self): + special = cache.get_special(record, self) + if isinstance(special, _RelationalMultiUpdate): + # include 'value' in the existing _RelationalMultiUpdate; this + # avoids reading the field's value (which may be large) + special.value |= value + elif cache.contains(record, self): val = self.convert_to_cache(record[self.name] | value, record, validate=False) cache.set(record, self, val) else: - cache.set_special(record, self, self._update_getter(record, value)) - - def _update_getter(self, record, value): - def getter(): - # determine the current field's value, and update it in cache only - cache = record.env.cache - cache.remove(record, self) - val = self.convert_to_cache(record[self.name] | value, record, validate=False) - cache.set(record, self, val) - return val - return getter + cache.set_special(record, self, _RelationalMultiUpdate(record, self, value)) def convert_to_cache(self, value, record, validate=True): # cache format: tuple(ids) |
