diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index c66b1efa82d..0144258ecb8 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -646,8 +646,11 @@ class AccountMoveLine(models.Model): #compute the default credit/debit of the next line in case of a manual entry balance = 0 for line in self._context['line_ids']: - if line[2]: + if line[2]: # in case of command 0: add a record with values balance += line[2].get('debit', 0) - line[2].get('credit', 0) + elif line[0] == 2: # line has been deleted + line_obj = self.browse(line[1]) + balance -= line_obj.debit - line_obj.credit if balance < 0: rec.update({'debit': -balance}) if balance > 0: diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 0cbb74dd7a4..0da4a3da3fe 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -259,6 +259,7 @@ class account_register_payments(models.TransientModel): 'currency_id': self.currency_id.id, 'partner_id': invoices[0].commercial_partner_id.id, 'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type], + 'multi': False, } @api.multi diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py index 95a01bfd0c2..8748a03ff34 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -77,7 +77,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): partner_ids = [partner['partner_id'] for partner in partners if partner['partner_id']] lines = dict((partner['partner_id'] or False, []) for partner in partners) if not partner_ids: - return [], [], [] + return [], [], {} # This dictionary will store the not due amount of all partners undue_amounts = {} diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index 87bd379879d..f42943f6027 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -130,6 +130,20 @@ var StatementModel = BasicModel.extend({ var line = this.getLine(handle); var prop = _.clone(_.find(line.mv_lines, {'id': mv_line_id})); this._addProposition(line, prop); + + // Check whether we have some propositions left + // If we don't, it means we are on an empty page + // so let's go back to the previous page + // Through the offset + var propLineIds = _.map(line.reconciliation_proposition, function(prop) { + return prop.id; + }); + var leftOversProps = _.filter(line.mv_lines, function(mv_line) { + return propLineIds.indexOf(mv_line.id) === -1; + }); + if (line.offset && !leftOversProps.length) { + line.offset -= line.limitMoveLines; + } return $.when(this._computeLine(line), this._performMoveLine(handle)); }, /** @@ -665,7 +679,7 @@ var StatementModel = BasicModel.extend({ return !isNaN(prop.id) && prop.already_paid; }), 'id'), "new_aml_dicts": _.map(_.filter(props, function (prop) { - return isNaN(prop.id); + return isNaN(prop.id) && prop.display; }), self._formatToProcessReconciliation.bind(self, line)), }; @@ -1262,7 +1276,7 @@ var ManualModel = StatementModel.extend({ }); } else { var mv_line_ids = _.pluck(_.filter(props, function (prop) {return !isNaN(prop.id);}), 'id'); - var new_mv_line_dicts = _.map(_.filter(props, function (prop) {return isNaN(prop.id);}), self._formatToProcessReconciliation.bind(self, line)); + var new_mv_line_dicts = _.map(_.filter(props, function (prop) {return isNaN(prop.id) && prop.display;}), self._formatToProcessReconciliation.bind(self, line)); process_reconciliations.push({ id: null, type: null, diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index 7578f9c4d1d..8a4ac8b25e7 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -423,7 +423,8 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { // mv_lines var $mv_lines = this.$('.match table tbody').empty(); - _.each(state.mv_lines.slice(0, state.limitMoveLines), function (line) { + var stateMvLines = state.mv_lines || []; + _.each(stateMvLines.slice(0, state.limitMoveLines), function (line) { var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state})); if (!isNaN(line.id)) { $('') @@ -432,9 +433,9 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { } $mv_lines.append($line); }); - this.$('.match .fa-chevron-right').toggleClass('disabled', state.mv_lines.length <= state.limitMoveLines); + this.$('.match .fa-chevron-right').toggleClass('disabled', stateMvLines.length <= state.limitMoveLines); this.$('.match .fa-chevron-left').toggleClass('disabled', !state.offset); - this.$('.match').css('max-height', !state.mv_lines.length && !state.filter.length ? '0px' : ''); + this.$('.match').css('max-height', !stateMvLines.length && !state.filter.length ? '0px' : ''); // balance this.$('.popover').remove(); diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 4fb3f997368..93a8a1818b4 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -1720,5 +1720,165 @@ QUnit.module('account', { clientAction.destroy(); }); + + QUnit.test('Manual Reconciliation: remove a prop to attain balance and reconcile', function (assert) { + assert.expect(5); + + // tweak the data to fit our needs + this.params.data_for_manual_reconciliation_widget['[283, null, "", 0, 6]'] = _.extend({}, this.params.data_for_manual_reconciliation_widget['[null,null]']); + this.params.data_for_manual_reconciliation_widget['[283, null, "", 0, 6]'].accounts[0].reconciliation_proposition = [ + {account_id: 283, account_type: "other", amount_currency_str: "", currency_id: false, date_maturity: "2017-03-18", date: "2017-02-16", + total_amount_str: "$ 500.00", partner_id: 8, account_name: "101000 Current Assets", name: "INV/2017/0987", partner_name: "Agrolait", + total_amount_currency_str: "", id: 999, credit: 0.0, journal_id: [1, "Customer Invoices"], amount_str: "$ 500.00", debit: 500.0, + account_code: "101000", ref: "", already_paid: false} + ]; + + var clientAction = new ReconciliationClientAction.ManualAction(null, this.params.options); + testUtils.addMockEnvironment(clientAction, { + data: this.params.data, + mockRPC: function (route, args) { + if (args.method === 'process_move_lines') { + assert.deepEqual(args.args, + [ + [{id: null, type: null, + mv_line_ids: [399, 402], + new_mv_line_dicts: []} + ] + ], "should call process_move_lines without the new mv line dict"); + } + + return this._super(route, args); + }, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$" + } + } + }, + }); + + clientAction.appendTo($('#qunit-fixture')); + + // The first reconciliation "line" is where it happens + var widget = clientAction.widgets[0]; + + // Add first prop + widget.$('.match .cell_account_code:first').trigger('click'); + assert.notOk( widget.$('.cell_right .line_info_button').length, + "should not display the partial reconciliation alert"); + + // Add second prop + widget.$('.match .cell_account_code:first').trigger('click'); + + // Check that a create form is here + var writeOffCreate = widget.$('div.create'); + + assert.equal(writeOffCreate.length, 1, + 'A write-off creation should be present'); + + assert.equal(writeOffCreate.find('input[name=amount]').val(), 500, + 'The right amount should be proposed for the write-off'); + + // remove the first line, the other two will balance one another + widget.$('tr[data-line-id="999"] td:first').click() + + var $buttonReconcile = widget.$('button.o_reconcile:not(hidden)'); + assert.equal($buttonReconcile.length, 1, + 'The reconcile button must be visible'); + + $buttonReconcile.click(); + + clientAction.destroy(); + }); + + QUnit.test('Manual Reconciliation: No lines for account', function (assert) { + assert.expect(2); + + var clientAction = new ReconciliationClientAction.ManualAction(null, this.params.options); + testUtils.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$" + } + } + }, + }); + + clientAction.appendTo($('#qunit-fixture')); + + // The second reconciliation "line" is where it happens + var widget = clientAction.widgets[1]; + + var emptyLine = widget.$('tr.mv_line'); + + assert.notOk('data-line-id' in emptyLine.getAttributes(), + 'Empty line should be empty'); + + emptyLine.find('td:first').click(); + + // Check that a create form is here + var writeOffCreate = widget.$('div.create .create_account_id'); + + assert.equal(writeOffCreate.length, 1, + 'A write-off creation should be present'); + + clientAction.destroy(); + }); + + QUnit.test('Automatic Reconciliation: Don\'t loose pager when adding last line of a page', function (assert) { + assert.expect(2); + + function standard_partner (list, partner_id) { + _.each(list, function (item) { + item.partner_id = partner_id; + }); + }; + + this.params.options.params.limitMoveLines = 1; + standard_partner(this.params.mv_lines['[5,"",0,6]'], 8); + + // Overlap of those requests is normal + // the pager is implemented as: + // if there is at least 1 more line than my limit, the pager is active + this.params.mv_lines['[5,"",0,2]'] = this.params.mv_lines['[5,"",0,6]'].slice(0,2); + this.params.mv_lines['[5,"",1,2]'] = this.params.mv_lines['[5,"",0,6]'].slice(1,2); + + var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); + testUtils.addMockEnvironment(clientAction, { + data: this.params.data, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$" + } + } + }, + }); + clientAction.appendTo($('#qunit-fixture')); + + var widget = clientAction.widgets[0]; + + var $nextPage = widget.$('.match .match_controls .fa-chevron-right:not(disabled)'); + assert.ok($nextPage.length, + 'We should have a next page'); + $nextPage.click(); + + // Add the second's page record + widget.$('.match tr[data-line-id=112] td:first').click(); + + assert.ok(widget.$('.match tr[data-line-id=109]').length, + 'the record of the first page must be proposed'); + + clientAction.destroy(); + }); }); }); diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 996eff10cd2..141388fbfd2 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -320,7 +320,7 @@ - + @@ -364,6 +364,18 @@ + + company.res.partner.bank.form + res.partner.bank + + primary + + + 1 + + + + account.bank.journal.tree account.journal diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js index acdf3460ecc..b2b944cc631 100644 --- a/addons/im_livechat/static/src/js/im_livechat.js +++ b/addons/im_livechat/static/src/js/im_livechat.js @@ -2,6 +2,7 @@ odoo.define('im_livechat.im_livechat', function (require) { "use strict"; var bus = require('bus.bus').bus; +var concurrency = require('web.concurrency'); var config = require('web.config'); var core = require('web.core'); var session = require('web.session'); @@ -313,6 +314,7 @@ var Feedback = Widget.extend({ this.channel_uuid = channel_uuid; this.server_origin = session.origin; this.rating = undefined; + this.dp = new concurrency.DropPrevious(); }, on_click_smiley: function (ev) { @@ -348,7 +350,7 @@ var Feedback = Widget.extend({ rate: this.rating, reason : options.reason }; - return session.rpc('/im_livechat/feedback', args).then(function () { + this.dp.add(session.rpc('/im_livechat/feedback', args)).then(function () { if (options.close) { var emoji = RATING_TO_EMOJI[self.rating] || "??" ; var content = _.str.sprintf(_t("Rating: %s"), emoji); diff --git a/addons/purchase_stock/models/stock.py b/addons/purchase_stock/models/stock.py index a108f0bec2a..b3d336ceddc 100644 --- a/addons/purchase_stock/models/stock.py +++ b/addons/purchase_stock/models/stock.py @@ -215,6 +215,6 @@ class PushedFlow(models.Model): _inherit = "stock.location.path" def _prepare_move_copy_values(self, move_to_copy, new_date): - res = super()._prepare_move_copy_values(move_to_copy, new_date) + res = super(PushedFlow, self)._prepare_move_copy_values(move_to_copy, new_date) res['purchase_line_id'] = None return res diff --git a/addons/stock/views/product_views.xml b/addons/stock/views/product_views.xml index f59ae7baf5c..5b58670e5ab 100644 --- a/addons/stock/views/product_views.xml +++ b/addons/stock/views/product_views.xml @@ -11,7 +11,6 @@
- The following routes will apply to the products in this category taking into account parent categories:
diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index 6c05076ef98..4720d166a4c 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -547,10 +547,11 @@ function dragAndDrop($el, $to, options) { var toOffset = $to.offset(); toOffset.top += $to.outerHeight()/2; toOffset.left += $to.outerWidth()/2; + var vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1; if (position === 'top') { - toOffset.top -= $to.outerHeight()/2; + toOffset.top -= $to.outerHeight()/2 + vertical_offset; } else if (position === 'bottom') { - toOffset.top += $to.outerHeight()/2; + toOffset.top += $to.outerHeight()/2 - vertical_offset; } else if (position === 'left') { toOffset.left -= $to.outerWidth()/2; } else if (position === 'right') { diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py index 13594353abf..64c6bc05ab9 100644 --- a/addons/website_sale/models/website.py +++ b/addons/website_sale/models/website.py @@ -60,8 +60,8 @@ class Website(models.Model): if not pricelists: # no pricelist for this country, or no GeoIP pricelists |= all_pl.filtered(lambda pl: not show_visible or pl.selectable or pl.id in (current_pl, order_pl)) - else: - pricelists |= all_pl.filtered(lambda pl: not show_visible and pl.sudo().code) + if not show_visible and not country_code: + pricelists |= all_pl.filtered(lambda pl: pl.sudo().code) # This method is cached, must not return records! See also #8795 return pricelists.ids diff --git a/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml b/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml index 6be0378297c..3ee63021073 100644 --- a/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml +++ b/addons/website_sale_wishlist/views/website_sale_wishlist_template.xml @@ -69,7 +69,7 @@
- + diff --git a/doc/cla/individual/jothimani-r.md b/doc/cla/individual/jothimani-r.md new file mode 100644 index 00000000000..b8049074c4c --- /dev/null +++ b/doc/cla/individual/jothimani-r.md @@ -0,0 +1,11 @@ +India, 2018-07-31 + +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, + +Jothimani Rajagopal jothimani1991@gmail.com https://github.com/jothimani-r diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index 6d29690b7e9..7963261f06a 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -883,7 +883,7 @@ class UsersImplied(models.Model): for user in self.with_context({}): gs = set(concat(g.trans_implied_ids for g in user.groups_id)) vals = {'groups_id': [(4, g.id) for g in gs]} - super(UsersImplied, self).write(vals) + super(UsersImplied, user).write(vals) return res #---------------------------------------------------------- diff --git a/odoo/addons/base/tests/__init__.py b/odoo/addons/base/tests/__init__.py index 3e6195ed523..36916cc53b7 100644 --- a/odoo/addons/base/tests/__init__.py +++ b/odoo/addons/base/tests/__init__.py @@ -33,3 +33,4 @@ from . import test_xmlrpc from . import test_res_partner_bank from . import test_reports from . import test_tests_tags +from . import test_non_regression diff --git a/odoo/addons/base/tests/test_non_regression.py b/odoo/addons/base/tests/test_non_regression.py new file mode 100644 index 00000000000..1f75578079a --- /dev/null +++ b/odoo/addons/base/tests/test_non_regression.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +""" + Non-Regression Tests +""" + +from openerp.tests.common import TransactionCase + + +class TestNR(TransactionCase): + def test_issue26036(self): + U = self.env["res.users"] + G = self.env["res.groups"] + + group_A = G.create({"name": "A"}) + group_AA = G.create({"name": "AA", "implied_ids": [(6, 0, [group_A.id])]}) + group_B = G.create({"name": "B"}) + group_BB = G.create({"name": "BB", "implied_ids": [(6, 0, [group_B.id])]}) + group_C = G.create({"name": "C"}) + + user_a = U.create({"name": "a", "login": "a", "groups_id": [(6, 0, [group_AA.id])]}) + user_b = U.create({"name": "b", "login": "b", "groups_id": [(6, 0, [group_BB.id])]}) + + (user_a + user_b).write({"groups_id": [(4, group_C.id)]}) + + self.assertEqual(user_a.groups_id, (group_AA + group_A + group_C)) + self.assertEqual(user_b.groups_id, (group_BB + group_B + group_C)) diff --git a/odoo/addons/base/tests/test_qweb.py b/odoo/addons/base/tests/test_qweb.py index 310c5220897..3b0f6d768cc 100644 --- a/odoo/addons/base/tests/test_qweb.py +++ b/odoo/addons/base/tests/test_qweb.py @@ -5,7 +5,6 @@ import collections import json import os.path import re -import sys from lxml import etree @@ -481,12 +480,16 @@ class TestQWebNS(TransactionCase): """ }) - error_msg = "Can't convert 'int' object to str implicitly" - if sys.version_info >= (3, 6): - error_msg = "must be str, not int" - with self.assertRaisesRegexp(QWebException, error_msg): + + try: + "" + 0 + except TypeError as e: + error_msg = e.args[0] + + with self.assertRaises(QWebException, msg=error_msg): view1.render() + from copy import deepcopy class FileSystemLoader(object): def __init__(self, path): diff --git a/odoo/addons/test_new_api/tests/test_onchange.py b/odoo/addons/test_new_api/tests/test_onchange.py index a1b2e4edfee..1bfdee908a7 100644 --- a/odoo/addons/test_new_api/tests/test_onchange.py +++ b/odoo/addons/test_new_api/tests/test_onchange.py @@ -139,10 +139,14 @@ class TestOnChange(common.TransactionCase): self.env.cache.invalidate() result = self.Discussion.onchange(values, 'name', field_onchange) self.assertIn('messages', result['value']) - self.assertEqual(result['value']['messages'], [ + self.assertItemsEqual(result['value']['messages'], [ (5,), (1, message.id, { 'name': "[%s] %s" % ("Foo", USER.name), + 'body': message.body, + 'author': message.author.name_get()[0], + 'size': message.size, + 'important': message.important, }), (0, 0, { 'name': "[%s] %s" % ("Foo", USER.name), @@ -252,7 +256,8 @@ class TestOnChange(common.TransactionCase): 'lines': [ (5,), (1, line1.id, {'name': partner2.name, - 'partner': (partner2.id, partner2.name)}), + 'partner': (partner2.id, partner2.name), + 'tags': [(5,)]}), (0, 0, {'name': partner2.name, 'partner': (partner2.id, partner2.name), 'tags': [(5,)]}), @@ -260,17 +265,6 @@ class TestOnChange(common.TransactionCase): }) # do it again, but this time with a new tag on the second line - expected_new_tag_result = { - 'name': partner2.name, - 'lines': [ - (5,), - (1, line1.id, {'name': partner2.name, - 'partner': (partner2.id, partner2.name)}), - (0, 0, {'name': partner2.name, - 'partner': (partner2.id, partner2.name), - 'tags': [(5,), (0, 0, {'name': 'Tag'})]}), - ], - } values = { 'name': partner1.name, 'partner': partner2.id, # this one just changed @@ -282,13 +276,18 @@ class TestOnChange(common.TransactionCase): self.env.cache.invalidate() result = multi.onchange(values, 'partner', field_onchange) - self.assertEqual(result['value'], expected_new_tag_result) - - # 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_new_tag_result) + self.assertEqual(result['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'})]}), + ], + }) def test_onchange_specific(self): """ test the effect of field-specific onchange method """ @@ -320,7 +319,8 @@ class TestOnChange(common.TransactionCase): self.assertIn('participants', result['value']) self.assertItemsEqual( result['value']['participants'], - [(5,)] + [(4, user.id) for user in discussion.participants + demo], + [(5,)] + [(1, user.id, {'display_name': user.display_name}) + for user in discussion.participants + demo], ) def test_onchange_default(self): @@ -356,8 +356,6 @@ 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, @@ -368,7 +366,8 @@ 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(lines)) + self.assertEqual(result['value']['message_concat'], + "\n".join(["%s:%s" % (m.name, m.body) for m in discussion.messages])) 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""" @@ -414,15 +413,28 @@ 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})], + [(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, + })] ) def test_onchange_related(self): diff --git a/odoo/models.py b/odoo/models.py index 30b0f3db0a0..c720f1cd5d2 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -4982,114 +4982,28 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if not all(name in self._fields for name in names): return {} - class PrefixTree(OrderedDict): - """ A prefix tree for sequences of field names. The tree is a - dictionary that associates each given field name to its - corresponding subtree (in fields order):: - - # tree corresponding to dotnames - # ['name', 'line_ids.product_id', 'line_ids.tags_ids.name'] - { - 'name': {}, - 'line_ids': { - 'product_id': {}, - 'tags_ids': { - 'name': {}, - }, - }, - } - """ - def __init__(self, model, dotnames): - super(PrefixTree, self).__init__() - if not dotnames: - return - # group dotnames by prefix - suffixes = defaultdict(list) - for dotname in dotnames: - names = dotname.split('.', 1) - name_suffixes = suffixes[names[0]] - if len(names) > 1: - name_suffixes.append(names[1]) - # fill in self in fields order - for name in model._fields: - if name in suffixes: - self[name] = PrefixTree(model[name], suffixes[name]) - - def dotnames(self): - """ Iterate over the sequences of field names. """ - for name, subnames in self.items(): - yield name - for dotname in subnames.dotnames(): - yield "%s.%s" % (name, dotname) - - nametree = PrefixTree(self.browse(), field_onchange) - dotnames = list(nametree.dotnames()) - - def snapshot(record, tree=nametree): - """ Return a dict with the values of record, following nametree. """ - vals = {} - for name, subnames in tree.items(): - if subnames: - # x2many fields as {line: snapshot(line), ...} - vals[name] = OrderedDict( - (line, snapshot(line, subnames)) - for line in record[name] - ) - else: - vals[name] = record[name] - return vals - - def diff(record, old, new, tree=nametree): - """ Return the values that differ between snapshots. - The snapshot ``old`` may be empty (for new records). - """ - result = {} - for name, subnames in tree.items(): - if name == 'id': - continue - if old and old[name] == new[name]: - continue - field = record._fields[name] - if not subnames: - result[name] = field.convert_to_onchange(new[name], record, {}) - continue - # x2many fields: serialize value as commands - result[name] = commands = [(5,)] - old_val = old.get(name) or {} - for line, vals in new[name].items(): - vals0 = (old_val.get(line) or snapshot(line, subnames)) if line.id else {} - line_diff = diff(line, vals0, vals, subnames) - if not line.id: - commands.append((0, line.id.ref or 0, line_diff)) - elif line_diff: - commands.append((1, line.id, line_diff)) - else: - commands.append((4, line.id)) - return result - - # 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') + # 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 # 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 nametree} + 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) - # make a snapshot based on the initial values of record + # load fields on secondary records, to avoid false changes with env.do_in_onchange(): - before = snapshot(record) + for dotname in dotnames: + record.mapped(dotname) # determine which field(s) should be triggered an onchange todo = list(names) or list(values) @@ -5108,6 +5022,7 @@ 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: @@ -5133,14 +5048,22 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): field.type in ('one2many', 'many2many') and newval._is_dirty() ): todo.append(name) + dirty.add(name) - # make a snapshot based on the final values of record + # 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(): - after = snapshot(record) - - # determine values that have changed by comparing snapshots - self.invalidate_cache() - result['value'] = diff(record, before, after) + result['value'] = { + name: self._fields[name].convert_to_onchange(record[name], record, subnames[name]) + for name in dirty + } return result