diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 1b385897262..35ae3065467 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -1424,7 +1424,7 @@ class AccountInvoice(models.Model): res = {} for line in self.tax_line_ids: res.setdefault(line.tax_id.tax_group_id, {'base': 0.0, 'amount': 0.0}) - res[line.tax_id.tax_group_id]['amount'] += line.amount + res[line.tax_id.tax_group_id]['amount'] += line.amount_total res[line.tax_id.tax_group_id]['base'] += line.base res = sorted(res.items(), key=lambda l: l[0].sequence) res = [( diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index cfe33be10ce..dc38e526693 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -481,7 +481,7 @@ class AccountMoveLine(models.Model): # TODO: put the invoice link and partner_id on the account_move invoice_id = fields.Many2one('account.invoice', oldname="invoice") partner_id = fields.Many2one('res.partner', string='Partner', ondelete='restrict') - user_type_id = fields.Many2one('account.account.type', related='account_id.user_type_id', index=True, store=True, oldname="user_type") + user_type_id = fields.Many2one('account.account.type', related='account_id.user_type_id', index=True, store=True, oldname="user_type", readonly=True) tax_exigible = fields.Boolean(string='Appears in VAT report', default=True, help="Technical field used to mark a tax line as exigible in the vat report or not (only exigible journal items are displayed). By default all new journal items are directly exigible, but with the feature cash_basis on taxes, some will become exigible only when the payment is recorded.") parent_state = fields.Char(compute="_compute_parent_state", help="State of the parent account.move") diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index c30c4232a02..246f7268478 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -330,6 +330,12 @@ class AccountReconciliation(models.AbstractModel): allowed_ids = set(Partner.browse(ids).ids) rows = [row for row in rows if row['partner_id'] in allowed_ids] + # Keep mode for future use in JS + if res_type == 'account': + mode = 'accounts' + else: + mode = 'customers' if account_type == 'receivable' else 'suppliers' + # Fetch other data for row in rows: account = Account.browse(row['account_id']) @@ -338,6 +344,7 @@ class AccountReconciliation(models.AbstractModel): partner_id = is_partner and row['partner_id'] or None 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['mode'] = mode row['company_id'] = account.company_id.id # Return the partners with a reconciliation proposition first, since they are most likely to diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py index 32aaa2e3085..4be77ed65b2 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -150,7 +150,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): partners_amount = {} 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): + for line in self.env['account.move.line'].browse(aml_ids).with_context(prefetch_fields=False): partner_id = line.partner_id.id or False if partner_id not in partners_amount: partners_amount[partner_id] = 0.0 diff --git a/addons/account/static/src/js/reconciliation/reconciliation_action.js b/addons/account/static/src/js/reconciliation/reconciliation_action.js index 5dbe30ba482..37844f00b3a 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_action.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_action.js @@ -43,6 +43,8 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, { LineRenderer: ReconciliationRenderer.LineRenderer, // used context params params: ['statement_ids'], + // number of statements/partners/accounts to display + defaultDisplayQty: 10, // number of moves lines displayed in 'match' mode limitMoveLines: 5, }, @@ -59,6 +61,7 @@ var StatementAction = AbstractAction.extend(ControlPanelMixin, { this.params = params; this.model = new this.config.Model(this, { modelName: "account.reconciliation.widget", + defaultDisplayQty: params.params && params.params.defaultDisplayQty || this.config.defaultDisplayQty, limitMoveLines: params.params && params.params.limitMoveLines || this.config.limitMoveLines, }); this.widgets = []; @@ -319,6 +322,7 @@ var ManualAction = StatementAction.extend({ ActionRenderer: ReconciliationRenderer.ManualRenderer, LineRenderer: ReconciliationRenderer.ManualLineRenderer, params: ['company_ids', 'mode', 'partner_ids', 'account_ids'], + defaultDisplayQty: 30, limitMoveLines: 10, }, diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index 93c06fe63df..0ed5377c0b8 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -109,7 +109,7 @@ var StatementModel = BasicModel.extend({ this.valuenow = 0; this.valuemax = 0; this.alreadyDisplayed = []; - this.defaultDisplayQty = 10; + this.defaultDisplayQty = options && options.defaultDisplayQty || 10; this.limitMoveLines = options && options.limitMoveLines || 5; }, @@ -1175,6 +1175,17 @@ var ManualModel = StatementModel.extend({ // Public //-------------------------------------------------------------------------- + /** + * Return a boolean telling if load button needs to be displayed or not + * + * @returns {boolean} true if load more button needs to be displayed + */ + hasMoreLines: function () { + if (this.manualLines.length > this.pagerIndex) { + return true; + } + return false; + }, /** * load data from * - 'account.reconciliation.widget' fetch the lines to reconciliate @@ -1234,10 +1245,12 @@ var ManualModel = StatementModel.extend({ context: context, }) .then(function (result) { - var defs = _.map(result, self._formatLine.bind(self, context.mode)); + self.manualLines = result; self.valuenow = 0; - self.valuemax = Object.keys(self.lines).length; - return $.when.apply($, defs); + self.valuemax = Object.keys(self.manualLines).length; + var lines = self.manualLines.splice(0, self.defaultDisplayQty); + self.pagerIndex = lines.length; + return self.loadData(lines); }); case 'accounts': return self._rpc({ @@ -1247,10 +1260,12 @@ var ManualModel = StatementModel.extend({ context: context, }) .then(function (result) { - var defs = _.map(result, self._formatLine.bind(self, 'accounts')); + self.manualLines = result; self.valuenow = 0; - self.valuemax = Object.keys(self.lines).length; - return $.when.apply($, defs); + self.valuemax = Object.keys(self.manualLines).length; + var lines = self.manualLines.splice(0, self.defaultDisplayQty); + self.pagerIndex = lines.length; + return self.loadData(lines); }); default: var partner_ids = context.partner_ids; @@ -1266,16 +1281,46 @@ var ManualModel = StatementModel.extend({ context: context, }) .then(function (result) { - var defs = _.map(result.accounts, self._formatLine.bind(self, 'accounts')); - defs = defs.concat(_.map(result.customers, self._formatLine.bind(self, 'customers'))); - defs = defs.concat(_.map(result.suppliers, self._formatLine.bind(self, 'suppliers'))); + // Flatten the result + self.manualLines = [].concat(result.accounts, result.customers, result.suppliers) self.valuenow = 0; - self.valuemax = Object.keys(self.lines).length; - return $.when.apply($, defs); + self.valuemax = Object.keys(self.manualLines).length; + var lines = self.manualLines.splice(0, self.defaultDisplayQty); + self.pagerIndex = lines.length; + return self.loadData(lines); }); } }); }, + /** + * Load more partners/accounts + * + * @param {integer} qty quantity to load + * @returns {Deferred} + */ + loadMore: function(qty) { + if (qty === undefined) { + qty = this.defaultDisplayQty; + } + var lines = this.manualLines.splice(this.pagerIndex, qty); + this.pagerIndex += qty; + return this.loadData(lines); + }, + /** + * Method to load informations on lines + * + * @param {Array} lines manualLines to load + * @returns {Deferred} + */ + loadData: function(lines) { + var self = this; + var defs = []; + _.each(lines, function (l) { + defs.push(self._formatLine(l.mode, l)) + }); + return $.when.apply($, defs); + + }, /** * Mark the account or the partner as reconciled * diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 93a8a1818b4..38a72820223 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -436,12 +436,12 @@ var auto_reconciliation = { var data_for_manual_reconciliation_widget = { '[null,null]': { 'customers': [ - {'account_id': 287, 'partner_name': "Agrolait", 'reconciliation_proposition': [], 'currency_id': 3, 'max_date': "2017-02-14 12:30:31", 'last_time_entries_checked': null, 'account_code': "101200", 'partner_id': 8, 'account_name': "101200 Account Receivable"}, - {'account_id': 7, 'partner_name': "Camptocamp", 'reconciliation_proposition': [], 'currency_id': 3, 'max_date': "2017-02-13 14:24:55", 'last_time_entries_checked': null, 'account_code': "101200", 'partner_id': 12, 'account_name': "101200 Account Receivable"} + {'account_id': 287, 'partner_name': "Agrolait", 'reconciliation_proposition': [], 'currency_id': 3, 'max_date': "2017-02-14 12:30:31", 'last_time_entries_checked': null, 'account_code': "101200", 'partner_id': 8, 'account_name': "101200 Account Receivable", 'mode': "customers"}, + {'account_id': 7, 'partner_name': "Camptocamp", 'reconciliation_proposition': [], 'currency_id': 3, 'max_date': "2017-02-13 14:24:55", 'last_time_entries_checked': null, 'account_code': "101200", 'partner_id': 12, 'account_name': "101200 Account Receivable", 'mode': "customers"} ], 'accounts': [ { - 'account_id': 283, 'account_name': "101000 Current Assets", 'currency_id': 3, 'max_date': "2017-02-16 14:32:04", 'last_time_entries_checked': "2017-02-16", 'account_code': "101000", + 'account_id': 283, 'account_name': "101000 Current Assets", 'currency_id': 3, 'max_date': "2017-02-16 14:32:04", 'last_time_entries_checked': "2017-02-16", 'account_code': "101000", 'mode': "accounts", 'reconciliation_proposition': [ {'account_id': 283, 'account_type': "other", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-02-16", 'date': "2017-02-16", 'total_amount_str': "$ 1,000.00", 'partner_id': 8, 'account_name': "101000 Current Assets", 'name': "BNK1/2017/0006: Customer Payment", 'partner_name': "Agrolait", 'total_amount_currency_str': "", 'id': 399, 'credit': 1000.0, 'journal_id': [3, "Bank"], 'amount_str': "$ 1,000.00", 'debit': 0.0, 'account_code': "101000", 'ref': "", 'already_paid': false}, {'account_id': 283, 'account_type': "other", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-03-18", 'date': "2017-02-16", 'total_amount_str': "$ 1,000.00", 'partner_id': 8, 'account_name': "101000 Current Assets", 'name': "INV/2017/0006", 'partner_name': "Agrolait", 'total_amount_currency_str': "", 'id': 402, 'credit': 0.0, 'journal_id': [1, "Customer Invoices"], 'amount_str': "$ 1,000.00", 'debit': 1000.0, 'account_code': "101000", 'ref': "", 'already_paid': false} @@ -455,20 +455,20 @@ var data_for_manual_reconciliation_widget = { {'account_id': 284, 'account_type': "other", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-02-16", 'date': "2017-02-16", 'total_amount_str': "$ 1,000.00", 'partner_id': 8, 'account_name': "101000 Current Assets", 'name': "BNK1/999: Customer Payment", 'partner_name': "Agrolait", 'total_amount_currency_str': "", 'id': 999, 'credit': 1000.0, 'journal_id': [3, "Bank"], 'amount_str': "$ 1,000.00", 'debit': 0.0, 'account_code': "111100", 'ref': "", 'already_paid': false}, {'account_id': 284, 'account_type': "other", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-03-18", 'date': "2017-02-16", 'total_amount_str': "$ 1,000.00", 'partner_id': 8, 'account_name': "101000 Current Assets", 'name': "INV/998", 'partner_name': "Agrolait", 'total_amount_currency_str': "", 'id': 998, 'credit': 0.0, 'journal_id': [1, "Customer Invoices"], 'amount_str': "$ 1,000.00", 'debit': 1000.0, 'account_code': "111100", 'ref': "", 'already_paid': false} ], - 'currency_id': 3, 'max_date': "2017-02-14 12:36:05", 'last_time_entries_checked': null, 'account_code': "111100", 'partner_id': 8, 'account_name': "Account Payable" + 'currency_id': 3, 'max_date': "2017-02-14 12:36:05", 'last_time_entries_checked': null, 'account_code': "111100", 'partner_id': 8, 'account_name': "Account Payable", 'mode': "suppliers" }, { 'account_id': 284, 'partner_name': "Camptocamp", 'reconciliation_proposition': [ {'account_id': 284, 'account_type': "other", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-02-16", 'date': "2017-02-16", 'total_amount_str': "$ 1,000.00", 'partner_id': 12, 'account_name': "101000 Current Assets", 'name': "BNK1/1999: Customer Payment", 'partner_name': "Camptocamp", 'total_amount_currency_str': "", 'id': 1999, 'credit': 1000.0, 'journal_id': [3, "Bank"], 'amount_str': "$ 1,000.00", 'debit': 0.0, 'account_code': "111100", 'ref': "", 'already_paid': false}, {'account_id': 284, 'account_type': "other", 'amount_currency_str': "", 'currency_id': false, 'date_maturity': "2017-03-18", 'date': "2017-02-16", 'total_amount_str': "$ 1,000.00", 'partner_id': 12, 'account_name': "101000 Current Assets", 'name': "INV/1998", 'partner_name': "Camptocamp", 'total_amount_currency_str': "", 'id': 1998, 'credit': 0.0, 'journal_id': [1, "Customer Invoices"], 'amount_str': "$ 1,000.00", 'debit': 1000.0, 'account_code': "111100", 'ref': "", 'already_paid': false} ], - 'currency_id': 3, 'max_date': "2017-02-14 12:36:05", 'last_time_entries_checked': null, 'account_code': "111100", 'partner_id': 12, 'account_name': "Account Payable" + 'currency_id': 3, 'max_date': "2017-02-14 12:36:05", 'last_time_entries_checked': null, 'account_code': "111100", 'partner_id': 12, 'account_name': "Account Payable", 'mode': "suppliers" } ] }, '["partner",null,"receivable"]': [ - {'account_id': 287, 'partner_name': "Agrolait", 'reconciliation_proposition': [], 'currency_id': 3, 'max_date': "2017-02-14 12:30:31", 'last_time_entries_checked': null, 'account_code': "101200", 'partner_id': 8, 'account_name': "101200 Account Receivable"}, - {'account_id': 287, 'partner_name': "Camptocamp", 'reconciliation_proposition': [], 'currency_id': 3, 'max_date': "2017-02-13 14:24:55", 'last_time_entries_checked': null, 'account_code': "101200", 'partner_id': 12, 'account_name': "101200 Account Receivable"} + {'account_id': 287, 'partner_name': "Agrolait", 'reconciliation_proposition': [], 'currency_id': 3, 'max_date': "2017-02-14 12:30:31", 'last_time_entries_checked': null, 'account_code': "101200", 'partner_id': 8, 'account_name': "101200 Account Receivable", 'mode': "customers"}, + {'account_id': 287, 'partner_name': "Camptocamp", 'reconciliation_proposition': [], 'currency_id': 3, 'max_date': "2017-02-13 14:24:55", 'last_time_entries_checked': null, 'account_code': "101200", 'partner_id': 12, 'account_name': "101200 Account Receivable", 'mode': "customers"} ] }; diff --git a/addons/account/wizard/account_reconcile.py b/addons/account/wizard/account_reconcile.py index ea6aa9caae4..5706ada741f 100644 --- a/addons/account/wizard/account_reconcile.py +++ b/addons/account/wizard/account_reconcile.py @@ -39,7 +39,7 @@ class AccountMoveLineReconcile(models.TransientModel): credit += line.credit debit += line.debit precision = self.env.user.company_id.currency_id.decimal_places - writeoff = float_round(debit - credit, precision_digits=precision) + writeoff = float_round(debit - credit, precision_digits=precision) + 0.0 #remove -0.0 result credit = float_round(credit, precision_digits=precision) debit = float_round(debit, precision_digits=precision) return {'trans_nbr': len(lines), 'credit': credit, 'debit': debit, 'writeoff': writeoff} diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index de04da82b7f..39ebef74e21 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -439,6 +439,7 @@ class MrpProduction(models.Model): if move: if quantity > 0: move[0].write({'product_uom_qty': quantity}) + move.unit_factor = quantity / move.raw_material_production_id.product_qty elif quantity < 0: # Do not remove 0 lines if move[0].quantity_done > 0: raise UserError(_('Lines need to be deleted, but can not as you still have some quantities to consume in them. ')) diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index 63aff0fc7ef..f4ff433bf71 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -1558,11 +1558,21 @@ var ReceiptScreenWidget = ScreenWidget.extend({ }; }, print_web: function() { - if($.browser.safari){ + if ($.browser.safari) { document.execCommand('print', false, null); - } - else{ - window.print(); + } else { + try { + window.print(); + } catch(err) { + if (navigator.userAgent.toLowerCase().indexOf("android") > -1) { + this.gui.show_popup('error',{ + 'title':_t('Printing is not supported on some android browsers'), + 'body': _t('Printing is not supported on some android browsers due to no default printing protocol is available. It is possible to print your tickets by making use of an IoT Box.'), + }); + } else { + throw err; + } + } } this.pos.get_order()._printed = true; }, diff --git a/addons/report_intrastat/report/report_intrastat_report.py b/addons/report_intrastat/report/report_intrastat_report.py index 2e274b3ec3b..83f412fd06e 100644 --- a/addons/report_intrastat/report/report_intrastat_report.py +++ b/addons/report_intrastat/report/report_intrastat_report.py @@ -36,12 +36,12 @@ class ReportIntrastat(models.Model): intrastat.id as intrastat_id, upper(inv_country.code) as code, sum(case when inv_line.price_unit is not null - then inv_line.price_unit * inv_line.quantity + then inv_line.price_unit * (1.0 - coalesce(inv_line.discount, 0.0) / 100.0) * inv_line.quantity else 0 end) as value, sum( - case when uom.category_id != puom.category_id then (pt.weight * inv_line.quantity) - else (pt.weight * inv_line.quantity * uom.factor) end + case when uom.category_id != puom.category_id then (coalesce(nullif(pp.weight, 0), pt.weight) * inv_line.quantity) + else (coalesce(nullif(pp.weight, 0), pt.weight) * inv_line.quantity * uom.factor) end ) as weight, sum( case when uom.category_id != puom.category_id then inv_line.quantity 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 69fbe44b14b..1e5df5ef030 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -3241,6 +3241,7 @@ var BasicModel = AbstractModel.extend({ _.each(element._changes, function (command) { if (command.operation === 'DELETE' || command.operation === 'FORGET' || + (command.operation === 'ADD' && !command.isNew)|| command.operation === 'REMOVE_ALL') { return; } diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 61aaa29a664..875a6031f66 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -6246,6 +6246,51 @@ QUnit.module('relational_fields', { form.destroy(); }); + QUnit.test('one2many, onchange, edition and multipage...', function (assert) { + assert.expect(7); + + this.data.partner.onchanges = { + turtles: function (obj) { + obj.turtles = [[5]].concat(obj.turtles); + } + }; + + this.data.partner.records[0].turtles = [1,2,3]; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'
' + + '' + + '' + + '' + + '' + + '' + + '
', + res_id: 1, + mockRPC: function (route, args) { + assert.step(args.method + ' ' + args.model) + return this._super(route, args); + }, + viewOptions: { + mode: 'edit', + }, + }); + form.$('.o_field_x2many_list_row_add a').click(); + form.$('.o_field_x2many_list_row_add a').click(); + + assert.verifySteps([ + 'read partner', + 'read turtle', + 'default_get turtle', + 'onchange partner', + 'default_get turtle', + 'onchange partner', + ]); + form.destroy(); + }); + QUnit.test('one2many with CREATE onchanges correctly refreshed', function (assert) { assert.expect(5); diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js index 1e745d7cceb..485edaad13c 100644 --- a/addons/web_editor/static/src/js/editor/rte.js +++ b/addons/web_editor/static/src/js/editor/rte.js @@ -42,7 +42,10 @@ var History = function History($editable) { $editable.removeAttr('contentEditable').removeProp('contentEditable'); } + $editable.trigger('content_will_be_destroyed'); $editable.html(oSnap.contents).scrollTop(oSnap.scrollTop); + $editable.trigger('content_was_recreated'); + $('.oe_overlay').remove(); $('.note-control-selection').hide(); @@ -290,6 +293,20 @@ var RTEWidget = Widget.extend({ var $editable = this.editable(); + // When a undo/redo is performed, the whole DOM is changed so we have + // to prepare for it (website will restart animations for example) + // TODO should be better handled + $editable.on('content_will_be_destroyed', function (ev) { + self.trigger_up('content_will_be_destroyed', { + $target: $(ev.currentTarget), + }); + }); + $editable.on('content_was_recreated', function (ev) { + self.trigger_up('content_was_recreated', { + $target: $(ev.currentTarget), + }); + }); + $editable.addClass('o_editable') .data('rte', this) .each(function () { diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index 999131020bf..2ec8506321c 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -340,6 +340,7 @@ var SnippetEditor = Widget.extend({ } }, }); + this.trigger_up('snippet_cloned', {$target: $clone}); }, /** * Called when the overlay dimensions/positions should be recomputed. diff --git a/addons/website/static/src/js/menu/edit.js b/addons/website/static/src/js/menu/edit.js index 0397b1f8aca..40da42a82d4 100644 --- a/addons/website/static/src/js/menu/edit.js +++ b/addons/website/static/src/js/menu/edit.js @@ -17,6 +17,9 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ edit: '_startEditMode', }), custom_events: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.custom_events || {}, { + content_will_be_destroyed: '_onContentWillBeDestroyed', + content_was_recreated: '_onContentWasRecreated', + snippet_cloned: '_onSnippetCloned', snippet_dropped: '_onSnippetDropped', }), @@ -87,6 +90,44 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ // Handlers //-------------------------------------------------------------------------- + /** + * Called when content will be destroyed in the page. Notifies the + * WebsiteRoot that is should stop the animations. + * + * @private + * @param {OdooEvent} ev + */ + _onContentWillBeDestroyed: function (ev) { + this.trigger_up('animation_stop_demand', { + $target: ev.data.$target, + }); + }, + /** + * Called when content will be recreated in the page. Notifies the + * WebsiteRoot that is should start the animations. + * + * @private + * @param {OdooEvent} ev + */ + _onContentWasRecreated: function (ev) { + this.trigger_up('animation_start_demand', { + editableMode: true, + $target: ev.data.$target, + }); + }, + /** + * Called when a snippet is cloned in the page. Notifies the WebsiteRoot + * that is should start the animations for this snippet. + * + * @private + * @param {OdooEvent} ev + */ + _onSnippetCloned: function (ev) { + this.trigger_up('animation_start_demand', { + editableMode: true, + $target: ev.data.$target, + }); + }, /** * Called when a snippet is dropped in the page. Notifies the WebsiteRoot * that is should start the animations for this snippet. diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index ba95563d790..465fcb1d153 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -5,7 +5,7 @@ from odoo.addons import decimal_precision as dp from odoo.tools import pycompat from odoo.tools.translate import html_translate -from odoo.tools import float_is_zero +from odoo.tools import float_compare class ProductStyle(models.Model): @@ -199,8 +199,14 @@ class Product(models.Model): for p, p2 in pycompat.izip(self, self2): taxes = partner.property_account_position_id.map_tax(p.sudo().taxes_id.filtered(lambda x: x.company_id == company_id), p, partner) p.website_price = taxes.compute_all(p2.price, pricelist.currency_id, quantity=qty, product=p2, partner=partner)[ret] - price_without_pricelist = taxes.compute_all(p.list_price, pricelist.currency_id)[ret] - p.website_price_difference = False if float_is_zero(price_without_pricelist - p.website_price, precision_rounding=pricelist.currency_id.rounding) else True + # We must convert the price_without_pricelist in the same currency than the + # website_price, otherwise the comparison doesn't make sense. Moreover, we show a price + # difference only if the website price is lower + price_without_pricelist = p.list_price + if company_id.currency_id != pricelist.currency_id: + price_without_pricelist = company_id.currency_id.compute(price_without_pricelist, pricelist.currency_id) + price_without_pricelist = taxes.compute_all(price_without_pricelist, pricelist.currency_id)[ret] + p.website_price_difference = True if float_compare(price_without_pricelist, p.website_price, precision_rounding=pricelist.currency_id.rounding) > 0 else False p.website_public_price = taxes.compute_all(p2.lst_price, quantity=qty, product=p2, partner=partner)[ret] @api.multi diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index 3f325be94ec..86ed21a7312 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -90,6 +90,9 @@ odoo.define('website_sale.website_sale', function (require) { var product_ids = []; var product_dom = $(event.target).closest(".js_product").find(".js_add_cart_variants"); var qty = $(event.target).closest('form').find('input[name="add_qty"]').val(); + if ($('body').hasClass('editor_enable')) { + return; + } var attribute_value_ids = product_dom.data("attribute_value_ids"); _.each(attribute_value_ids, function(entry) { product_ids.push(entry[0]); @@ -123,7 +126,7 @@ odoo.define('website_sale.website_sale', function (require) { $(oe_website_sale).on("change", ".oe_cart input.js_quantity[data-product-id]", function () { var $input = $(this); - if ($input.data('update_change')) { + if ($input.data('update_change') || $('body').hasClass('editor_enable')) { return; } var value = parseInt($input.val() || 0, 10); @@ -192,6 +195,9 @@ odoo.define('website_sale.website_sale', function (require) { // hack to add and remove from cart with json $(oe_website_sale).on('click', 'a.js_add_cart_json', function (ev) { + if ($('body').hasClass('editor_enable')) { + return; + } ev.preventDefault(); var $link = $(ev.currentTarget); var $input = $link.parent().find("input"); diff --git a/odoo/addons/base/models/ir_property.py b/odoo/addons/base/models/ir_property.py index 8c789f1f4bf..ddf8696a627 100644 --- a/odoo/addons/base/models/ir_property.py +++ b/odoo/addons/base/models/ir_property.py @@ -63,9 +63,9 @@ class Property(models.Model): @api.multi def _update_values(self, values): - value = values.pop('value', None) - if not value: + if 'value' not in values: return values + value = values.pop('value') prop = None type_ = values.get('type') @@ -81,7 +81,9 @@ class Property(models.Model): raise UserError(_('Invalid type')) if field == 'value_reference': - if isinstance(value, models.BaseModel): + if not value: + value = False + elif isinstance(value, models.BaseModel): value = '%s,%d' % (value._name, value.id) elif isinstance(value, pycompat.integer_types): field_id = values.get('fields_id') diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py index 266847481cc..6739a47422b 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -716,6 +716,12 @@ class TestFields(common.TransactionCase): self.assertEqual(record.sudo(user1).tag_id, tag0.browse()) self.assertEqual(record.sudo(user2).tag_id, tag0) + record.sudo(user1).foo = False + record.invalidate_cache() + self.assertEqual(record.sudo(user0).foo, 'main') + self.assertEqual(record.sudo(user1).foo, False) + self.assertEqual(record.sudo(user2).foo, 'default') + # create company record and attribute company_record = self.env['test_new_api.company'].create({'foo': 'ABC'}) attribute_record = self.env['test_new_api.company.attr'].create({ diff --git a/odoo/addons/test_new_api/tests/test_onchange.py b/odoo/addons/test_new_api/tests/test_onchange.py index 03cad7f4009..fc72c500e50 100644 --- a/odoo/addons/test_new_api/tests/test_onchange.py +++ b/odoo/addons/test_new_api/tests/test_onchange.py @@ -108,12 +108,12 @@ class TestOnChange(common.TransactionCase): def test_onchange_one2many(self): """ test the effect of onchange() on one2many fields """ - BODY = "What a beautiful day!" USER = self.env.user # create an independent message - message = self.Message.create({'body': BODY}) - self.assertEqual(message.name, "[%s] %s" % ('', USER.name)) + message1 = self.Message.create({'body': "ABC"}) + message2 = self.Message.create({'body': "ABC"}) + self.assertEqual(message1.name, "[%s] %s" % ('', USER.name)) field_onchange = self.Discussion._onchange_spec() self.assertEqual(field_onchange.get('name'), '1') @@ -130,12 +130,14 @@ class TestOnChange(common.TransactionCase): 'moderator': False, 'participants': [], 'messages': [ - (4, message.id), + (4, message1.id), + (4, message2.id), + (1, message2.id, {'body': "XYZ"}), (0, 0, { 'name': "[%s] %s" % ('', USER.name), - 'body': BODY, + 'body': "ABC", 'author': USER.id, - 'size': len(BODY), + 'size': 3, 'important': False, }), ], @@ -143,20 +145,27 @@ class TestOnChange(common.TransactionCase): self.env.cache.invalidate() result = self.Discussion.onchange(values, 'name', field_onchange) self.assertIn('messages', result['value']) - self.assertItemsEqual(result['value']['messages'], [ + self.assertEqual(result['value']['messages'], [ (5,), - (1, message.id, { + (1, message1.id, { 'name': "[%s] %s" % ("Foo", USER.name), - 'body': message.body, - 'author': message.author.name_get()[0], - 'size': message.size, - 'important': message.important, + 'body': "ABC", + 'author': USER.name_get()[0], + 'size': 3, + 'important': False, + }), + (1, message2.id, { + 'name': "[%s] %s" % ("Foo", USER.name), + 'body': "XYZ", # this must be sent back + 'author': USER.name_get()[0], + 'size': 3, + 'important': False, }), (0, 0, { 'name': "[%s] %s" % ("Foo", USER.name), - 'body': BODY, + 'body': "ABC", 'author': USER.name_get()[0], - 'size': len(BODY), + 'size': 3, 'important': False, }), ]) @@ -167,7 +176,8 @@ class TestOnChange(common.TransactionCase): result = self.Discussion.with_context(generate_dummy_message=True).onchange(values, 'name', one_level_fields) self.assertEqual(result['value']['messages'], [ (5,), - (4, message.id), + (4, message1.id), + (4, message2.id), (0, 0, {}), (0, 0, {}), ]) @@ -259,12 +269,16 @@ class TestOnChange(common.TransactionCase): 'name': partner2.name, 'lines': [ (5,), - (1, line1.id, {'name': partner2.name, - 'partner': (partner2.id, partner2.name), - 'tags': [(5,)]}), - (0, 0, {'name': partner2.name, - 'partner': (partner2.id, partner2.name), - 'tags': [(5,)]}), + (1, line1.id, { + 'name': partner2.name, + 'partner': (partner2.id, partner2.name), + 'tags': [(5,)], + }), + (0, 0, { + 'name': partner2.name, + 'partner': (partner2.id, partner2.name), + 'tags': [(5,)], + }), ], }) @@ -280,18 +294,35 @@ class TestOnChange(common.TransactionCase): self.env.cache.invalidate() result = multi.onchange(values, 'partner', field_onchange) - self.assertEqual(result['value'], { + expected_value = { 'name': partner2.name, 'lines': [ (5,), - (1, line1.id, {'name': partner2.name, - 'partner': (partner2.id, partner2.name), - 'tags': [(5,)]}), - (0, 0, {'name': partner2.name, - 'partner': (partner2.id, partner2.name), - 'tags': [(5,), (0, 0, {'name': 'Tag'})]}), + (1, line1.id, { + 'name': partner2.name, + 'partner': (partner2.id, partner2.name), + 'tags': [(5,)], + }), + (0, 0, { + 'name': partner2.name, + 'partner': (partner2.id, partner2.name), + 'tags': [(5,), (0, 0, {'name': 'Tag'})], + }), ], - }) + } + self.assertEqual(result['value'], expected_value) + + # ensure ID is not returned when asked and a many2many record is set to be created + self.env.cache.invalidate() + + result = multi.onchange(values, 'partner', dict(field_onchange, **{'lines.tags.id': None})) + self.assertEqual(result['value'], expected_value) + + # ensure inverse of one2many field is not returned + self.env.cache.invalidate() + + result = multi.onchange(values, 'partner', dict(field_onchange, **{'lines.multi': None})) + self.assertEqual(result['value'], expected_value) def test_onchange_specific(self): """ test the effect of field-specific onchange method """ @@ -323,8 +354,7 @@ class TestOnChange(common.TransactionCase): self.assertIn('participants', result['value']) self.assertItemsEqual( result['value']['participants'], - [(5,)] + [(1, user.id, {'display_name': user.display_name}) - for user in discussion.participants + demo], + [(5,)] + [(4, user.id) for user in discussion.participants + demo], ) def test_onchange_default(self): @@ -360,6 +390,8 @@ class TestOnChange(common.TransactionCase): self.assertEqual(len(discussion.messages), 3) messages = [(4, msg.id) for msg in discussion.messages] messages[0] = (1, messages[0][1], {'body': 'test onchange'}) + lines = ["%s:%s" % (m.name, m.body) for m in discussion.messages] + lines[0] = "%s:%s" % (discussion.messages[0].name, 'test onchange') values = { 'name': discussion.name, 'moderator': demo.id, @@ -370,8 +402,7 @@ class TestOnChange(common.TransactionCase): } result = discussion.onchange(values, 'messages', field_onchange) self.assertIn('message_concat', result['value']) - self.assertEqual(result['value']['message_concat'], - "\n".join(["%s:%s" % (m.name, m.body) for m in discussion.messages])) + self.assertEqual(result['value']['message_concat'], "\n".join(lines)) def test_onchange_one2many_with_domain_on_related_field(self): """ test the value of the one2many field when defined with a domain on a related field""" @@ -417,28 +448,22 @@ class TestOnChange(common.TransactionCase): 'categories': [(4, cat.id) for cat in discussion.categories], 'messages': [(4, msg.id) for msg in discussion.messages], 'participants': [(4, usr.id) for usr in discussion.participants], - 'message_changes': 0, 'important_messages': [(4, msg.id) for msg in discussion.important_messages], 'important_emails': [(4, eml.id) for eml in discussion.important_emails], } + self.env.cache.invalidate() result = discussion.onchange(values, 'name', field_onchange) - # When one2many domain contains non-computed field, things are ok - self.assertEqual(result['value']['important_messages'], - [(5,)] + [(4, msg.id) for msg in discussion.important_messages]) - - # But here with commit 5676d81, we get value of: [(2, email.id)] self.assertEqual( result['value']['important_emails'], - [(5,), - (1, email.id, { - 'name': u'[Foo Bar] %s' % USER.name, - 'body': email.body, - 'author': USER.name_get()[0], - 'important': True, - 'email_to': demo.email, - 'size': email.size, - })] + [(5,), (1, email.id, { + 'name': u'[Foo Bar] %s' % USER.name, + 'body': BODY, + 'author': USER.name_get()[0], + 'size': len(BODY), + 'important': True, + 'email_to': demo.email, + })], ) def test_onchange_related(self): diff --git a/odoo/models.py b/odoo/models.py index e9304afa8ef..2843ba54ecd 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -852,6 +852,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # avoid broken transaction) and keep going cr.execute('ROLLBACK TO SAVEPOINT model_load_save') except Exception as e: + _logger.exception("Error while loading record") message = (_(u'Unknown error during import:') + u' %s: %s' % (type(e), e)) moreinfo = _('Resolve other errors first') messages.append(dict(info, type='error', message=message, moreinfo=moreinfo)) @@ -4981,31 +4982,101 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if not all(name in self._fields for name in names): return {} - # filter out keys in field_onchange that do not refer to actual fields - dotnames = [] - for dotname in field_onchange: - try: - model = self.browse() - for name in dotname.split('.'): - model = model[name] - dotnames.append(dotname) - except Exception: - pass + def PrefixTree(model, dotnames): + """ Return a prefix tree for sequences of field names. """ + if not dotnames: + return {} + # group dotnames by prefix + suffixes = defaultdict(list) + for dotname in dotnames: + # name, *names = dotname.split('.', 1) + names = dotname.split('.', 1) + name = names.pop(0) + suffixes[name].extend(names) + # fill in prefix tree in fields order + tree = OrderedDict() + for name, field in model._fields.items(): + if name in suffixes: + tree[name] = subtree = PrefixTree(model[name], suffixes[name]) + if subtree and field.type == 'one2many': + subtree.pop(field.inverse_name, None) + return tree + + class Snapshot(dict): + """ A dict with the values of a record, following a prefix tree. """ + __slots__ = () + + def __init__(self, record, tree): + # put record in dict to include it when comparing snapshots + super(Snapshot, self).__init__({'': record, '': tree}) + for name, subnames in tree.items(): + # x2many fields are serialized as a list of line snapshots + self[name] = ( + [Snapshot(line, subnames) for line in record[name]] + if subnames else record[name] + ) + + def diff(self, other): + """ Return the values in ``self`` that differ from ``other``. + Requires record cache invalidation for correct output! + """ + record = self[''] + result = {} + for name, subnames in self[''].items(): + if (name == 'id') or (other.get(name) == self[name]): + continue + if not subnames: + field = record._fields[name] + result[name] = field.convert_to_onchange(self[name], record, {}) + else: + # x2many fields: serialize value as commands + result[name] = commands = [(5,)] + for line_snapshot in self[name]: + line = line_snapshot[''] + if not line.id: + # new line: send diff from scratch + line_diff = line_snapshot.diff({}) + commands.append((0, line.id.ref or 0, line_diff)) + else: + # existing line: check diff from database + # (requires a clean record cache!) + line_diff = line_snapshot.diff(Snapshot(line, subnames)) + if line_diff: + # send all fields because the web client + # might need them to evaluate modifiers + line_diff = line_snapshot.diff({}) + commands.append((1, line.id, line_diff)) + else: + commands.append((4, line.id)) + return result + + nametree = PrefixTree(self.browse(), field_onchange) + + # prefetch x2many lines without data (for the initial snapshot) + for name, subnames in nametree.items(): + if subnames and values.get(name): + # retrieve all ids in commands, and read the expected fields + line_ids = [] + for cmd in values[name]: + if cmd[0] in (1, 4): + line_ids.append(cmd[1]) + elif cmd[0] == 6: + line_ids.extend(cmd[2]) + lines = self.browse()[name].browse(line_ids) + lines.read(list(subnames), load='_classic_write') # create a new record with values, and attach ``self`` to it with env.do_in_onchange(): record = self.new(values) - values = {name: record[name] for name in record._cache} # attach ``self`` with a different context (for cache consistency) record._origin = self.with_context(__onchange=True) - # load fields on secondary records, to avoid false changes + # make a snapshot based on the initial values of record with env.do_in_onchange(): - for dotname in dotnames: - record.mapped(dotname) + snapshot0 = snapshot1 = Snapshot(record, nametree) # determine which field(s) should be triggered an onchange - todo = list(names) or list(values) + todo = list(names or nametree) done = set() # dummy assignment: trigger invalidations on the record @@ -5021,7 +5092,6 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): record[name] = value result = {} - dirty = set() # process names in order (or the keys of values if no name given) while todo: @@ -5035,34 +5105,17 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if field_onchange.get(name): record._onchange_eval(name, field_onchange[name], result) - # force re-evaluation of function fields on secondary records - for dotname in dotnames: - record.mapped(dotname) + # make a snapshot (this forces evaluation of computed fields) + snapshot1 = Snapshot(record, nametree) # determine which fields have been modified - for name, oldval in values.items(): - field = self._fields[name] - newval = record[name] - if newval != oldval or ( - field.type in ('one2many', 'many2many') and newval._is_dirty() - ): + for name in nametree: + if snapshot1[name] != snapshot0[name]: todo.append(name) - dirty.add(name) - # determine subfields for field.convert_to_onchange() below - Tree = lambda: defaultdict(Tree) - subnames = Tree() - for dotname in dotnames: - subtree = subnames - for name in dotname.split('.'): - subtree = subtree[name] - - # collect values from dirty fields - with env.do_in_onchange(): - result['value'] = { - name: self._fields[name].convert_to_onchange(record[name], record, subnames[name]) - for name in dirty - } + # determine values that have changed by comparing snapshots + self.invalidate_cache() + result['value'] = snapshot1.diff(snapshot0) return result