diff --git a/.travis.yml b/.travis.yml index f8f7def57..6fd40d591 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,8 +25,10 @@ env: matrix: - LINT_CHECK="1" - TRANSIFEX="1" - - TESTS="1" ODOO_REPO="odoo/odoo" EXCLUDE="database_cleanup,auth_session_timeout,auth_brute_force,base_import_odoo" - - TESTS="1" ODOO_REPO="OCA/OCB" EXCLUDE="database_cleanup,auth_session_timeout,auth_brute_force,base_import_odoo" + - TESTS="1" ODOO_REPO="odoo/odoo" EXCLUDE="database_cleanup,auth_session_timeout,auth_brute_force,base_import_odoo,onchange_helper,test_onchange_helper" + - TESTS="1" ODOO_REPO="OCA/OCB" EXCLUDE="database_cleanup,auth_session_timeout,auth_brute_force,base_import_odoo,onchange_helper,test_onchange_helper" + - TESTS="1" ODOO_REPO="odoo/odoo" INCLUDE="onchange_helper,test_onchange_helper" + - TESTS="1" ODOO_REPO="OCA/OCB" INCLUDE="onchange_helper,test_onchange_helper" - TESTS="1" ODOO_REPO="odoo/odoo" INCLUDE="database_cleanup" - TESTS="1" ODOO_REPO="OCA/OCB" INCLUDE="database_cleanup" - TESTS="1" ODOO_REPO="odoo/odoo" INCLUDE="auth_session_timeout" diff --git a/onchange_helper/README.rst b/onchange_helper/README.rst index c30d33249..794346959 100644 --- a/onchange_helper/README.rst +++ b/onchange_helper/README.rst @@ -56,6 +56,7 @@ Contributors * Guewen Baconnier * Florian da Costa +* Laurent Mignon Maintainer ---------- diff --git a/onchange_helper/__manifest__.py b/onchange_helper/__manifest__.py index 0d1b9b6a1..eb8f6207d 100644 --- a/onchange_helper/__manifest__.py +++ b/onchange_helper/__manifest__.py @@ -3,10 +3,11 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). {'name': 'Onchange Helper', - 'version': '10.0.1.0.0', + 'version': '10.0.2.0.0', 'summary': 'Technical module that ease execution of onchange in Python code', - 'author': 'Akretion,Camptocamp,Odoo Community Association (OCA)', - 'website': 'http://www.akretion.com', + 'author': 'Akretion,Camptocamp, ACSONE SA/NV, ' + 'Odoo Community Association (OCA)', + 'website': 'https://github.com/OCA/server-tools', 'license': 'AGPL-3', 'category': 'Generic Modules', 'depends': ['base'], diff --git a/onchange_helper/models/models.py b/onchange_helper/models/models.py index 3c40ec23e..d532fbde3 100644 --- a/onchange_helper/models/models.py +++ b/onchange_helper/models/models.py @@ -1,51 +1,219 @@ # -*- coding: utf-8 -*- # © 2016-2017 Akretion (http://www.akretion.com) # © 2016-2017 Camptocamp (http://www.camptocamp.com/) +# © 2019 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import api, models +from odoo import api, models, fields class Base(models.AbstractModel): - _inherit = 'base' + _inherit = "base" @api.model - def _get_new_values(self, record, on_change_result): - vals = on_change_result.get('value', {}) - new_values = {} - for fieldname, value in vals.iteritems(): - if fieldname not in record: - column = self._fields[fieldname] - if value and column.type == 'many2one': - value = value[0] # many2one are tuple (id, name) - new_values[fieldname] = value - return new_values - - def play_onchanges(self, values, onchange_fields): - onchange_specs = self._onchange_spec() - # we need all fields in the dict even the empty ones - # otherwise 'onchange()' will not apply changes to them - all_values = values.copy() - - # If self is a record (play onchange on existing record) - # we take the value of the field - # If self is an empty record we will have an empty value + def _compute_onchange_dirty( + self, original_record, modified_record, fieldname_onchange=None + ): + """ + Return the list of dirty fields. (designed to be called by + play_onchanges) + The list of dirty fields is computed from the list marked as dirty + on the record. Form this list, we remove the fields for which the value + into the original record is the same as the one into the current record + :param original_record: + :return: changed values + """ + dirties = [] + if fieldname_onchange: + for field_name, field in modified_record._fields.items(): + # special case. We consider that a related field is modified + # if a modified field is in the first position of the path + # to traverse to get the value. + if field.related and field.related[0].startswith( + fieldname_onchange + ): + dirties.append(field_name) + for field_name in modified_record._get_dirty(): + original_value = original_record[field_name] + new_value = modified_record[field_name] + if original_value == new_value: + continue + dirties.append(field_name) + for field_name, field in modified_record._fields.items(): + new_value = modified_record[field_name] + if field.type not in ("one2many", "many2many"): + continue + # if the field is a one2many or many2many, check that any + # item is a new Id + if models.NewId in [type(i.id) for i in new_value]: + dirties.append(field_name) + continue + # if the field is a one2many or many2many, check if any item + # is dirty + for r in new_value: + if r._get_dirty(): + ori = [ + r1 + for r1 in original_record[field_name] + if r1.id == r.id + ][0] + # if fieldname_onchange is None avoid recurssion... + if fieldname_onchange and self._compute_onchange_dirty( + ori, r + ): + dirties.append(field_name) + break + return dirties + + def _convert_to_onchange(self, record, field, value): + if field.type == "many2one": + # for many2one, we keep the id and don't call the + # convert_on_change to avoid the call to name_get by the + # convert_to_onchange + if value.id: + return value.id + return False + elif field.type in ("one2many", "many2many"): + result = [(5,)] + for record in value: + vals = {} + for name in record._cache: + if name in models.LOG_ACCESS_COLUMNS: + continue + v = record[name] + f = record._fields[name] + if f.type == "many2one" and isinstance(v.id, models.NewId): + continue + vals[name] = self._convert_to_onchange(record, f, v) + if not record.id: + result.append((0, 0, vals)) + elif vals: + result.append((1, record.id, vals)) + else: + result.append((4, record.id)) + return result + else: + return field.convert_to_onchange(value, record, [field.name]) + + def play_onchanges(self, values, onchange_fields=None): + """ + Play the onchange methods defined on the current record and return the + changed values. + The record is not modified by the onchange. + + The intend of this method is to provide a way to get on the server side + the values returned by the onchange methods when called by the UI. + This method is useful in B2B contexts where records are created or + modified from server side. + + The returned values are those changed by the execution of onchange + methods registered for the onchange_fields according to the provided + values. As consequence, the result will not contain the modifications + that could occurs by the execution of compute methods registered for + the same onchange_fields. + + It's on purpose that we avoid to trigger the compute methods for the + onchange_fields. These compute methods will be triggered when calling + the create or write method. In this way we avoid to compute useless + information. + + + :param values: dict of input value that + :param onchange_fields: fields for which onchange methods will be + played. If not provided, the list of field is based on the values keys. + Order in onchange_fields is very important as onchanges methods will + be played in that order. + :return: changed values + + This method reimplement the onchange method to be able to work on the + current recordset if provided. + """ + env = self.env if self: self.ensure_one() - record_values = self._convert_to_write(self.read()[0]) - else: - record_values = {} - for field in self._fields: - if field not in all_values: - all_values[field] = record_values.get(field, False) - - new_values = {} - for field in onchange_fields: - onchange_values = self.onchange(all_values, field, onchange_specs) - new_values.update(self._get_new_values(values, onchange_values)) - all_values.update(new_values) - - return { - f: v for f, v in all_values.iteritems() - if not (self._fields[f].compute and not self._fields[f].inverse) - and (f in values or f in new_values)} + + if not isinstance(onchange_fields, list): + onchange_fields = [onchange_fields] + + if not onchange_fields: + onchange_fields = values.keys() + + # filter out keys in field_onchange that do not refer to actual fields + names = [n for n in onchange_fields if n in self._fields] + + # create a new record with values, and attach ``self`` to it + with env.do_in_onchange(): + # keep a copy of the original record. + # attach ``self`` with a different context (for cache consistency) + origin = self.with_context(__onchange=True) + origin_dirty = set(self._get_dirty()) + fields.copy_cache(self, origin.env) + if self: + record = self + record.update(values) + else: + # initialize with default values, they may be used in onchange + new_values = self.default_get(self._fields.keys()) + new_values.update(values) + record = self.new(new_values) + values = {name: record[name] for name in record._cache} + record._origin = origin + + # determine which field(s) should be triggered an onchange + todo = list(names) or list(values) + done = set() + + # dummy assignment: trigger invalidations on the record + with env.do_in_onchange(): + for name in todo: + if name == "id": + continue + value = record[name] + field = self._fields[name] + if field.type == "many2one" and field.delegate and not value: + # do not nullify all fields of parent record for new + # records + continue + record[name] = value + + dirty = set() + + # process names in order (or the keys of values if no name given) + while todo: + name = todo.pop(0) + if name in done: + continue + done.add(name) + + with env.do_in_onchange(): + # apply field-specific onchange methods + record._onchange_eval(name, "1", {}) + + # determine which fields have been modified + dirties = self._compute_onchange_dirty(origin, record, name) + dirty |= set(dirties) + todo.extend(dirties) + + # prepare the result to return a dictionary with the new values for + # the dirty fields + result = {} + for name in dirty: + field = self._fields[name] + value = record[name] + if field.type == "many2one" and isinstance(value.id, models.NewId): + continue + result[name] = self._convert_to_onchange(record, field, value) + + # reset dirty values into the current record + if self: + to_reset = dirty | set(values.keys()) + with env.do_in_onchange(): + for name in to_reset: + original = origin[name] + new = self[name] + if original == new: + continue + self[name] = origin[name] + env.dirty[record].discard(name) + env.dirty[record].update(origin_dirty) + return result diff --git a/onchange_helper/tests/test_onchange.py b/onchange_helper/tests/test_onchange.py index 22e155294..101a997de 100644 --- a/onchange_helper/tests/test_onchange.py +++ b/onchange_helper/tests/test_onchange.py @@ -1,26 +1,64 @@ # -*- coding: utf-8 -*- # Copyright 2018 Akretion (http://www.akretion.com). +# Copyright 2019 ACSONE SA/NV # @author Sébastien BEAU # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import openerp.tests.common as common +import mock +import odoo.tests.common as common class TestOnchange(common.TransactionCase): def test_playing_onchange_on_model(self): - result = self.env['res.partner'].play_onchanges({ - 'company_type': 'company', - }, ['company_type']) - self.assertEqual(result['is_company'], True) + res_partner = self.env["res.partner"] + with mock.patch.object( + res_partner.__class__, "write" + ) as patched_write: + result = self.env["res.partner"].play_onchanges( + {"company_type": "company"}, ["company_type"] + ) + patched_write.assert_not_called() + self.assertEqual(result["is_company"], True) def test_playing_onchange_on_record(self): - company = self.env.ref('base.main_company') - result = company.play_onchanges({ - 'email': 'contact@akretion.com'}, - ['email']) + company = self.env.ref("base.main_company") + with mock.patch.object(company.__class__, "write") as patched_write: + result = company.play_onchanges( + {"email": "contact@akretion.com"}, ["email"] + ) + patched_write.assert_not_called() + modified_fields = set(result.keys()) + self.assertSetEqual( + modified_fields, {"rml_footer", "rml_footer_readonly"} + ) self.assertEqual( - result['rml_footer'], - u'Phone: +1 555 123 8069 | Email: contact@akretion.com | ' - u'Website: http://www.example.com') - self.assertEqual(company.email, u'info@yourcompany.example.com') + result["rml_footer"], + u"Phone: +1 555 123 8069 | Email: contact@akretion.com | " + u"Website: http://www.example.com", + ) + self.assertEqual(result["rml_footer_readonly"], result["rml_footer"]) + + # check that the original record is not modified + self.assertFalse(company._get_dirty()) + self.assertEqual(company.email, u"info@yourcompany.example.com") + + def test_onchange_record_with_dirty_field(self): + company = self.env.ref("base.main_company") + company._set_dirty("name") + self.assertListEqual(company._get_dirty(), ["name"]) + company.play_onchanges({"email": "contact@akretion.com"}, ["email"]) + self.assertListEqual(company._get_dirty(), ["name"]) + + def test_onchange_wrong_key(self): + res_partner = self.env["res.partner"] + with mock.patch.object( + res_partner.__class__, "write" + ) as patched_write: + # we specify a wrong field name... This field should be + # ignored + result = self.env["res.partner"].play_onchanges( + {"company_type": "company"}, ["company_type", "wrong_key"] + ) + patched_write.assert_not_called() + self.assertEqual(result["is_company"], True) diff --git a/setup/test_onchange_helper/odoo/__init__.py b/setup/test_onchange_helper/odoo/__init__.py new file mode 100644 index 000000000..de40ea7ca --- /dev/null +++ b/setup/test_onchange_helper/odoo/__init__.py @@ -0,0 +1 @@ +__import__('pkg_resources').declare_namespace(__name__) diff --git a/setup/test_onchange_helper/odoo/addons/__init__.py b/setup/test_onchange_helper/odoo/addons/__init__.py new file mode 100644 index 000000000..de40ea7ca --- /dev/null +++ b/setup/test_onchange_helper/odoo/addons/__init__.py @@ -0,0 +1 @@ +__import__('pkg_resources').declare_namespace(__name__) diff --git a/setup/test_onchange_helper/odoo/addons/test_onchange_helper b/setup/test_onchange_helper/odoo/addons/test_onchange_helper new file mode 120000 index 000000000..d3646f7a5 --- /dev/null +++ b/setup/test_onchange_helper/odoo/addons/test_onchange_helper @@ -0,0 +1 @@ +../../../../test_onchange_helper \ No newline at end of file diff --git a/setup/test_onchange_helper/setup.py b/setup/test_onchange_helper/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/test_onchange_helper/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test_onchange_helper/README.rst b/test_onchange_helper/README.rst new file mode 100644 index 000000000..29b3e6caa --- /dev/null +++ b/test_onchange_helper/README.rst @@ -0,0 +1,9 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +==================== +Onchange Helper TEST +==================== + +*This module is only intended to test the onchange_helper addon.* diff --git a/test_onchange_helper/__init__.py b/test_onchange_helper/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/test_onchange_helper/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/test_onchange_helper/__manifest__.py b/test_onchange_helper/__manifest__.py new file mode 100644 index 000000000..93855c767 --- /dev/null +++ b/test_onchange_helper/__manifest__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Onchange Helper TEST", + "summary": """ + Test addon for the onchange_helper addon""", + "version": "10.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://acsone.eu/", + "depends": ["onchange_helper"], + "data": [ + 'security/test_onchange_helper_multi_line.xml', + 'security/test_onchange_helper_multi.xml', + "security/test_onchange_helper_emailmessage.xml", + "security/test_onchange_helper_message.xml", + "security/test_onchange_helper_discussion.xml", + "security/test_onchange_helper_category.xml", + ], + "demo": [ + "demo/test_onchange_helper_discussion.xml", + "demo/test_onchange_helper_message.xml", + ] +} diff --git a/test_onchange_helper/demo/test_onchange_helper_discussion.xml b/test_onchange_helper/demo/test_onchange_helper_discussion.xml new file mode 100644 index 000000000..06e119d08 --- /dev/null +++ b/test_onchange_helper/demo/test_onchange_helper_discussion.xml @@ -0,0 +1,12 @@ + + + + + + + Stuff + + + + diff --git a/test_onchange_helper/demo/test_onchange_helper_message.xml b/test_onchange_helper/demo/test_onchange_helper_message.xml new file mode 100644 index 000000000..5860fd0b5 --- /dev/null +++ b/test_onchange_helper/demo/test_onchange_helper_message.xml @@ -0,0 +1,23 @@ + + + + + + + + Hey dude! + + + + + + What's up? + + + + + This is a much longer message + + + diff --git a/test_onchange_helper/models/__init__.py b/test_onchange_helper/models/__init__.py new file mode 100644 index 000000000..0002916fc --- /dev/null +++ b/test_onchange_helper/models/__init__.py @@ -0,0 +1,6 @@ +from . import test_onchange_helper_category +from . import test_onchange_helper_discussion +from . import test_onchange_helper_message +from . import test_onchange_helper_emailmessage +from . import test_onchange_helper_multi +from . import test_onchange_helper_multi_line diff --git a/test_onchange_helper/models/test_onchange_helper_category.py b/test_onchange_helper/models/test_onchange_helper_category.py new file mode 100644 index 000000000..7533f74b6 --- /dev/null +++ b/test_onchange_helper/models/test_onchange_helper_category.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class TestOnchangeHelperCategory(models.Model): + + _name = "test_onchange_helper.category" + _description = "Test Onchange Helper Category" + + name = fields.Char(required=True) + parent = fields.Many2one(_name) + root_categ = fields.Many2one(_name) + display_name = fields.Char() + computed_display_name = fields.Char( + compute="_compute_computed_display_name" + ) + + @api.onchange("name", "parent") + def _onchange_name_or_parent(self): + if self.parent: + self.display_name = self.parent.display_name + " / " + self.name + else: + self.display_name = self.name + + @api.onchange("parent") + def _onchange_parent(self): + current = self + while current.parent: + current = current.parent + if current == self: + self.root_categ = False + else: + self.root_categ = current + + @api.depends("name", "parent.display_name") + def _compute_computed_display_name(self): + for cat in self: + if cat.parent: + self.display_name = cat.parent.display_name + " / " + cat.name + else: + cat.display_name = cat.name diff --git a/test_onchange_helper/models/test_onchange_helper_discussion.py b/test_onchange_helper/models/test_onchange_helper_discussion.py new file mode 100644 index 000000000..9e3f2aa5b --- /dev/null +++ b/test_onchange_helper/models/test_onchange_helper_discussion.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class TestOnchangeHelperDiscussion(models.Model): + + _name = "test_onchange_helper.discussion" + _description = "Test Onchange Helper Discussion" + + name = fields.Char( + string="Title", + required=True, + help="General description of what this discussion is about.", + ) + moderator = fields.Many2one("res.users") + categories = fields.Many2many( + "test_onchange_helper.category", + "test_onchange_helper_discussion_category", + "discussion", + "category", + ) + participants = fields.Many2many("res.users") + messages = fields.One2many("test_onchange_helper.message", "discussion") + message_concat = fields.Text(string="Message concatenate") + important_messages = fields.One2many( + "test_onchange_helper.message", + "discussion", + domain=[("important", "=", True)], + ) + very_important_messages = fields.One2many( + "test_onchange_helper.message", + "discussion", + domain=lambda self: self._domain_very_important(), + ) + emails = fields.One2many("test_onchange_helper.emailmessage", "discussion") + important_emails = fields.One2many( + "test_onchange_helper.emailmessage", + "discussion", + domain=[("important", "=", True)], + ) + + def _domain_very_important(self): + """Ensure computed O2M domains work as expected.""" + return [("important", "=", True)] + + @api.onchange("name") + def _onchange_name(self): + # test onchange modifying one2many field values + # update body of existings messages and emails + for message in self.messages: + message.body = "not last dummy message" + for message in self.important_messages: + message.body = "not last dummy message" + # add new dummy message + message_vals = self.messages._add_missing_default_values( + {"body": "dummy message", "important": True} + ) + self.messages |= self.messages.new(message_vals) + self.important_messages |= self.messages.new(message_vals) + + @api.onchange("moderator") + def _onchange_moderator(self): + self.participants |= self.moderator + + @api.onchange("messages") + def _onchange_messages(self): + self.message_concat = "\n".join( + ["%s:%s" % (m.name, m.body) for m in self.messages] + ) diff --git a/test_onchange_helper/models/test_onchange_helper_emailmessage.py b/test_onchange_helper/models/test_onchange_helper_emailmessage.py new file mode 100644 index 000000000..252222c5f --- /dev/null +++ b/test_onchange_helper/models/test_onchange_helper_emailmessage.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class TestOnchangeHelperEmailmessage(models.Model): + + _name = "test_onchange_helper.emailmessage" + _description = "Test Onchange Helper Emailmessage" + _inherits = {"test_onchange_helper.message": "message"} + + message = fields.Many2one( + "test_onchange_helper.message", + "Message", + required=True, + ondelete="cascade", + ) + email_to = fields.Char("To") diff --git a/test_onchange_helper/models/test_onchange_helper_message.py b/test_onchange_helper/models/test_onchange_helper_message.py new file mode 100644 index 000000000..64af72ce8 --- /dev/null +++ b/test_onchange_helper/models/test_onchange_helper_message.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class TestOnchangeHelperMessage(models.Model): + + _name = "test_onchange_helper.message" + _description = "Test Onchange Helper Message" + + discussion = fields.Many2one( + "test_onchange_helper.discussion", ondelete="cascade" + ) + body = fields.Text() + author = fields.Many2one("res.users", default=lambda self: self.env.user) + name = fields.Char(string="Title", compute="_compute_name", store=True) + display_name = fields.Char( + string="Abstract", compute="_compute_display_name" + ) + discussion_name = fields.Char( + related="discussion.name", string="Discussion Name" + ) + author_partner = fields.Many2one( + "res.partner", + compute="_compute_author_partner", + search="_search_author_partner", + ) + important = fields.Boolean() + + @api.one + @api.depends("author.name", "discussion.name") + def _compute_name(self): + self.name = "[%s] %s" % ( + self.discussion.name or "", + self.author.name or "", + ) + + @api.one + @api.depends("author.name", "discussion.name", "body") + def _compute_display_name(self): + stuff = "[%s] %s: %s" % ( + self.author.name, + self.discussion.name or "", + self.body or "", + ) + self.display_name = stuff[:80] + + @api.one + @api.depends("author", "author.partner_id") + def _compute_author_partner(self): + self.author_partner = self.author.partner_id + + @api.model + def _search_author_partner(self, operator, value): + return [("author.partner_id", operator, value)] diff --git a/test_onchange_helper/models/test_onchange_helper_multi.py b/test_onchange_helper/models/test_onchange_helper_multi.py new file mode 100644 index 000000000..ae55e295a --- /dev/null +++ b/test_onchange_helper/models/test_onchange_helper_multi.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class TestOnchangeHelperMulti(models.Model): + + _name = "test_onchange_helper.multi" + _description = "Test Onchange Helper Multi" + + name = fields.Char(related="partner.name", readonly=True) + partner = fields.Many2one("res.partner") + lines = fields.One2many("test_onchange_helper.multi.line", "multi") + + @api.onchange("name") + def _onchange_name(self): + for line in self.lines: + line.name = self.name + + @api.onchange("partner") + def _onchange_partner(self): + for line in self.lines: + line.partner = self.partner diff --git a/test_onchange_helper/models/test_onchange_helper_multi_line.py b/test_onchange_helper/models/test_onchange_helper_multi_line.py new file mode 100644 index 000000000..841873be9 --- /dev/null +++ b/test_onchange_helper/models/test_onchange_helper_multi_line.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class TestOnchangeHelperMultiLine(models.Model): + + _name = "test_onchange_helper.multi.line" + _description = "Test Onchange Helper Multi Line" + + multi = fields.Many2one("test_onchange_helper.multi", ondelete="cascade") + name = fields.Char() + partner = fields.Many2one("res.partner") diff --git a/test_onchange_helper/readme/CONTRIBUTORS.rst b/test_onchange_helper/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..586a941fd --- /dev/null +++ b/test_onchange_helper/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Laurent Mignon (https://acsone.eu) \ No newline at end of file diff --git a/test_onchange_helper/readme/DESCRIPTION.rst b/test_onchange_helper/readme/DESCRIPTION.rst new file mode 100644 index 000000000..bacdee0ee --- /dev/null +++ b/test_onchange_helper/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +*This module is only intended to test the onchange_helper addon.* diff --git a/test_onchange_helper/security/test_onchange_helper_category.xml b/test_onchange_helper/security/test_onchange_helper_category.xml new file mode 100644 index 000000000..2ff570df3 --- /dev/null +++ b/test_onchange_helper/security/test_onchange_helper_category.xml @@ -0,0 +1,17 @@ + + + + + + + test_onchange_helper.category access name + + + + + + + + + diff --git a/test_onchange_helper/security/test_onchange_helper_discussion.xml b/test_onchange_helper/security/test_onchange_helper_discussion.xml new file mode 100644 index 000000000..9efab1419 --- /dev/null +++ b/test_onchange_helper/security/test_onchange_helper_discussion.xml @@ -0,0 +1,17 @@ + + + + + + + test_onchange_helperdiscussion access name + + + + + + + + + diff --git a/test_onchange_helper/security/test_onchange_helper_emailmessage.xml b/test_onchange_helper/security/test_onchange_helper_emailmessage.xml new file mode 100644 index 000000000..8150dc30c --- /dev/null +++ b/test_onchange_helper/security/test_onchange_helper_emailmessage.xml @@ -0,0 +1,17 @@ + + + + + + + test_onchange_helperemailmessage access name + + + + + + + + + diff --git a/test_onchange_helper/security/test_onchange_helper_message.xml b/test_onchange_helper/security/test_onchange_helper_message.xml new file mode 100644 index 000000000..75ae9cd81 --- /dev/null +++ b/test_onchange_helper/security/test_onchange_helper_message.xml @@ -0,0 +1,17 @@ + + + + + + + test_onchange_helper.message access name + + + + + + + + + diff --git a/test_onchange_helper/security/test_onchange_helper_multi.xml b/test_onchange_helper/security/test_onchange_helper_multi.xml new file mode 100644 index 000000000..1edcc14a5 --- /dev/null +++ b/test_onchange_helper/security/test_onchange_helper_multi.xml @@ -0,0 +1,17 @@ + + + + + + + test.onchange.helper.multi access name + + + + + + + + + diff --git a/test_onchange_helper/security/test_onchange_helper_multi_line.xml b/test_onchange_helper/security/test_onchange_helper_multi_line.xml new file mode 100644 index 000000000..6a20c302f --- /dev/null +++ b/test_onchange_helper/security/test_onchange_helper_multi_line.xml @@ -0,0 +1,17 @@ + + + + + + + test.onchange.helper.multi.line access name + + + + + + + + + diff --git a/test_onchange_helper/static/description/icon.png b/test_onchange_helper/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/test_onchange_helper/static/description/icon.png differ diff --git a/test_onchange_helper/tests/__init__.py b/test_onchange_helper/tests/__init__.py new file mode 100644 index 000000000..73370c825 --- /dev/null +++ b/test_onchange_helper/tests/__init__.py @@ -0,0 +1 @@ +from . import test_onchange_helper diff --git a/test_onchange_helper/tests/test_onchange_helper.py b/test_onchange_helper/tests/test_onchange_helper.py new file mode 100644 index 000000000..ee774e669 --- /dev/null +++ b/test_onchange_helper/tests/test_onchange_helper.py @@ -0,0 +1,378 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import mock +from contextlib import contextmanager +import odoo.tests.common as common + + +class TestOnchangeHelper(common.TransactionCase): + def setUp(self): + super(TestOnchangeHelper, self).setUp() + self.Category = self.env["test_onchange_helper.category"] + self.Message = self.env["test_onchange_helper.message"] + self.Discussion = self.env["test_onchange_helper.discussion"] + + @contextmanager + def assertNoOrmWrite(self, model): + with mock.patch.object( + model.__class__, "create" + ) as mocked_create, mock.patch.object( + model.__class__, "write" + ) as mocked_write: + yield + mocked_create.assert_not_called() + mocked_write.assert_not_called() + + def test_play_onhanges_no_recompute(self): + # play_onchanges must not trigger recomputes except if an onchange + # method access a computed field. + # changing 'discussion' should recompute 'name' + values = {"name": "Cat Name"} + self.env.invalidate_all() + with self.assertNoOrmWrite(self.Category): + result = self.Category.play_onchanges(values, ["name"]) + self.assertNotIn("computed_display_name", result) + + def test_play_onchanges_many2one_new_record(self): + root = self.Category.create({"name": "root"}) + + values = {"name": "test", "parent": root.id, "root_categ": False} + + self.env.invalidate_all() + with self.assertNoOrmWrite(self.Category): + result = self.Category.play_onchanges(values, "parent") + self.assertIn("root_categ", result) + self.assertEqual(result["root_categ"], root.id) + + values.update(result) + values["parent"] = False + + self.env.invalidate_all() + with self.assertNoOrmWrite(self.Category): + result = self.Category.play_onchanges(values, "parent") + # since the root_categ is already False into values the field is not + # changed by the onchange + self.assertNotIn("root_categ", result) + + def test_play_onchanges_many2one_existing_record(self): + root = self.Category.create({"name": "root"}) + + values = {"name": "test", "parent": root.id, "root_categ": False} + + self.env.invalidate_all() + with self.assertNoOrmWrite(self.Category): + result = self.Category.play_onchanges(values, "parent") + self.assertIn("root_categ", result) + self.assertEqual(result["root_categ"], root.id) + + # create child catefory + values.update(result) + child = self.Category.create(values) + self.assertEqual(root.id, child.root_categ.id) + + # since the parent is set to False and the root_categ + values = {"parent": False} + self.env.invalidate_all() + with self.assertNoOrmWrite(child): + result = child.play_onchanges(values, "parent") + + self.assertIn("root_categ", result) + self.assertEqual(result["root_categ"], False) + + def test_play_onchange_one2many_new_record(self): + """ test the effect of play_onchanges() on one2many fields on new + record""" + BODY = "What a beautiful day!" + USER = self.env.user + + # create an independent message + message = self.Message.create({"body": BODY}) + + # modify discussion name + values = { + "name": "Foo", + "categories": [], + "moderator": False, + "participants": [], + "messages": [ + (4, message.id), + ( + 0, + 0, + { + "name": "[%s] %s" % ("", USER.name), + "body": BODY, + "author": USER.id, + "important": False, + }, + ), + ], + } + self.env.invalidate_all() + with self.assertNoOrmWrite(self.Discussion): + result = self.Discussion.play_onchanges(values, "name") + self.assertIn("messages", result) + self.assertItemsEqual( + result["messages"], + [ + (5,), + ( + 1, + message.id, + { + "name": "[%s] %s" % ("Foo", USER.name), + "body": "not last dummy message", + "author": message.author.id, + "important": message.important, + }, + ), + ( + 0, + 0, + { + "name": "[%s] %s" % ("Foo", USER.name), + "body": "not last dummy message", + "author": USER.id, + "important": False, + }, + ), + ( + 0, + 0, + { + "name": "[%s] %s" % ("Foo", USER.name), + "body": "dummy message", + "author": USER.id, + "important": True, + }, + ), + ], + ) + + self.assertIn("important_messages", result) + self.assertItemsEqual( + result["important_messages"], + [ + (5,), + ( + 0, + 0, + { + "author": USER.id, + "body": "dummy message", + "important": True, + }, + ), + ], + ) + + def test_play_onchange_one2many_existing_record(self): + """ test the effect of play_onchanges() on one2many fields on existing + record""" + BODY = "What a beautiful day!" + USER = self.env.user + + # create an independent message + message = self.Message.create({"body": BODY}) + + # modify discussion name + values = { + "name": "Foo", + "categories": [], + "moderator": False, + "participants": [], + "messages": [ + (4, message.id), + ( + 0, + 0, + { + "name": "[%s] %s" % ("", USER.name), + "body": BODY, + "author": USER.id, + "important": False, + }, + ), + ], + } + discussion = self.Discussion.create(values) + + values = {"name": "New foo"} + with self.assertNoOrmWrite(discussion): + result = discussion.play_onchanges(values, "name") + self.assertIn("messages", result) + self.assertItemsEqual( + result["messages"], + [ + (5,), + ( + 1, + discussion.messages[0].id, + { + "name": "[%s] %s" % ("New foo", USER.name), + "body": "not last dummy message", + "author": message.author.id, + "important": message.important, + "discussion": discussion.id, + }, + ), + ( + 1, + discussion.messages[1].id, + { + "name": "[%s] %s" % ("New foo", USER.name), + "body": "not last dummy message", + "author": USER.id, + "important": False, + "discussion": discussion.id, + }, + ), + ( + 0, + 0, + { + "name": "[%s] %s" % ("New foo", USER.name), + "body": "dummy message", + "author": USER.id, + "important": True, + "discussion": discussion.id, + }, + ), + ], + ) + + self.assertIn("important_messages", result) + self.assertItemsEqual( + result["important_messages"], + [ + (5,), + ( + 0, + 0, + { + "author": USER.id, + "body": "dummy message", + "important": True, + "discussion": discussion.id, + }, + ), + ], + ) + + def test_onchange_specific(self): + """test that only the id is added if a new item is added to an + existing relation""" + discussion = self.env.ref("test_onchange_helper.discussion_demo_0") + demo = self.env.ref("base.user_demo") + + # first remove demo user from participants + discussion.participants -= demo + self.assertNotIn(demo, discussion.participants) + + # check that demo_user is added to participants when set as moderator + values = { + "name": discussion.name, + "moderator": demo.id, + "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], + } + self.env.invalidate_all() + with self.assertNoOrmWrite(discussion): + result = discussion.play_onchanges(values, "moderator") + + self.assertIn("participants", result) + self.assertItemsEqual( + result["participants"], + [(5,)] + [(4, user.id) for user in discussion.participants + demo], + ) + + def test_onchange_one2many_value(self): + """ test that the values provided for a one2many field inside are used + by the play_onchanges """ + discussion = self.env.ref("test_onchange_helper.discussion_demo_0") + demo = self.env.ref("base.user_demo") + + self.assertEqual(len(discussion.messages), 3) + messages = [(4, msg.id) for msg in discussion.messages] + messages[0] = (1, messages[0][1], {"body": "test onchange"}) + values = { + "name": discussion.name, + "moderator": demo.id, + "categories": [(4, cat.id) for cat in discussion.categories], + "messages": messages, + "participants": [(4, usr.id) for usr in discussion.participants], + "message_concat": False, + } + with self.assertNoOrmWrite(discussion): + result = discussion.play_onchanges(values, "messages") + self.assertIn("message_concat", result) + self.assertEqual( + result["message_concat"], + "\n".join( + ["%s:%s" % (m.name, m.body) for m in discussion.messages] + ), + ) + + def test_onchange_one2many_line(self): + """ test that changes on a field used as first position into the + related path of a related field will trigger the onchange also on the + related field """ + partner = self.env.ref("base.res_partner_1") + multi = self.env["test_onchange_helper.multi"].create( + {"partner": partner.id} + ) + line = multi.lines.create({"multi": multi.id}) + + values = multi._convert_to_write( + {key: multi[key] for key in ("partner", "lines")} + ) + self.assertEqual( + values, {"partner": partner.id, "lines": [(6, 0, [line.id])]} + ) + + # modify 'partner' + # -> set 'partner' on all lines + # -> recompute 'name' (related on partner) + # -> set 'name' on all lines + partner = self.env.ref("base.res_partner_2") + values = { + "partner": partner.id, + "lines": [ + (6, 0, [line.id]), + (0, 0, {"name": False, "partner": False}), + ], + } + + self.env.invalidate_all() + with self.assertNoOrmWrite(multi): + result = multi.play_onchanges(values, "partner") + self.assertEqual( + result, + { + "name": partner.name, + "lines": [ + (5,), + ( + 1, + line.id, + { + "name": partner.name, + "partner": partner.id, + "multi": multi.id, + }, + ), + ( + 0, + 0, + { + "name": partner.name, + "partner": partner.id, + "multi": multi.id, + }, + ), + ], + }, + )