diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-07-26 11:32:43 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-07-26 11:32:43 +0200 |
| commit | e0f9c092ccefc0516d06a9dc7f66e650eee888cf (patch) | |
| tree | 0c404769dc28af133425cf9756a83569c70b31f3 /addons | |
| parent | 7d78795f8b34bc6a8afde18427a1c26901824db3 (diff) | |
| parent | 1d6d790513e3f1177dc0059f2ba4a06de6d87ac2 (diff) | |
[MERGE] forward port branch saas-12.2 up to 1d6d790513e
Diffstat (limited to 'addons')
26 files changed, 760 insertions, 41 deletions
diff --git a/addons/account/wizard/account_invoice_send.py b/addons/account/wizard/account_invoice_send.py index 10e491c3291..409227b0d8d 100644 --- a/addons/account/wizard/account_invoice_send.py +++ b/addons/account/wizard/account_invoice_send.py @@ -84,15 +84,18 @@ class AccountInvoiceSend(models.TransientModel): # This should ideally be fixed in mail_compose_message, so when a fix is made there this whole commit should be reverted. # basically self.body (which could be manually edited) extracts self.template_id, # which is then not translated for each customer. - active_ids = self.env.context.get('active_ids', self.res_id) - active_records = self.env[self.model].browse(active_ids) - langs = active_records.mapped('partner_id.lang') - default_lang = self.env.context.get('lang', 'en_US') - for lang in (set(langs) or [default_lang]): - active_ids_lang = active_records.filtered(lambda r: r.partner_id.lang == lang).ids - self_lang = self.with_context(active_ids=active_ids_lang, lang=lang) - self_lang.onchange_template_id() - self_lang._send_email() + if self.composition_mode == 'mass_mail' and self.template_id: + active_ids = self.env.context.get('active_ids', self.res_id) + active_records = self.env[self.model].browse(active_ids) + langs = active_records.mapped('partner_id.lang') + default_lang = self.env.context.get('lang', 'en_US') + for lang in (set(langs) or [default_lang]): + active_ids_lang = active_records.filtered(lambda r: r.partner_id.lang == lang).ids + self_lang = self.with_context(active_ids=active_ids_lang, lang=lang) + self_lang.onchange_template_id() + self_lang._send_email() + else: + self._send_email() if self.is_print: return self._print_document() return {'type': 'ir.actions.act_window_close'} diff --git a/addons/account/wizard/account_invoice_send_views.xml b/addons/account/wizard/account_invoice_send_views.xml index cae3b296b63..eac9adda58e 100644 --- a/addons/account/wizard/account_invoice_send_views.xml +++ b/addons/account/wizard/account_invoice_send_views.xml @@ -14,6 +14,7 @@ <field name="invoice_ids" invisible="1"/> <field name="email_from" invisible="1" /> <field name="mail_server_id" invisible="1"/> + <field name="reply_to" invisible="1"/> <div name="option_print"> <field name="is_print" /> <b><label for="is_print"/></b> diff --git a/addons/gamification/models/gamification_karma_rank.py b/addons/gamification/models/gamification_karma_rank.py index 78b8e40ac50..1d930fefa4d 100644 --- a/addons/gamification/models/gamification_karma_rank.py +++ b/addons/gamification/models/gamification_karma_rank.py @@ -47,8 +47,19 @@ class KarmaRank(models.Model): @api.multi def write(self, vals): + if 'karma_min' in vals: + previous_ranks = self.env['gamification.karma.rank'].search([], order="karma_min DESC").ids + low = min(vals['karma_min'], self.karma_min) + high = max(vals['karma_min'], self.karma_min) + tools.image_resize_images(vals) res = super(KarmaRank, self).write(vals) - users = self.env['res.users'].sudo().search([('karma', '>', 0)]) - users._recompute_rank() + + if 'karma_min' in vals: + after_ranks = self.env['gamification.karma.rank'].search([], order="karma_min DESC").ids + if previous_ranks != after_ranks: + users = self.env['res.users'].sudo().search([('karma', '>', 0)]) + else: + users = self.env['res.users'].sudo().search([('karma', '>=', low), ('karma', '<=', high)]) + users._recompute_rank() return res diff --git a/addons/gamification/models/res_users.py b/addons/gamification/models/res_users.py index a0ca65ced45..4f9319a4001 100644 --- a/addons/gamification/models/res_users.py +++ b/addons/gamification/models/res_users.py @@ -7,9 +7,11 @@ import logging _logger = logging.getLogger(__name__) + class Users(models.Model): _inherit = 'res.users' + karma = fields.Integer('Karma', default=0) karma_position = fields.Integer('Karma position', compute="_compute_karma_position", store=False) badge_ids = fields.One2many('gamification.badge.user', 'user_id', string='Badges', copy=False) @@ -69,18 +71,32 @@ class Users(models.Model): return True def _rank_changed(self): - if self.rank_id.karma_min > 0: - template = self.env.ref('gamification.mail_template_data_new_rank_reached', raise_if_not_found=False) - if template: - template.send_mail(self.id, force_send=True, notif_layout='mail.mail_notification_light') + """ + Method that can be called on a batch of users with the same new rank + """ + template = self.env.ref('gamification.mail_template_data_new_rank_reached', raise_if_not_found=False) + if template: + for u in self: + if u.rank_id.karma_min > 0: + template.send_mail(u.id, force_send=len(self) == 1, notif_layout='mail.mail_notification_light') def _recompute_rank(self): """ The caller should filter the users on karma > 0 before calling this method to avoid looping on every single users + + Compute rank of each user by user. + For each user, check the rank of this user """ + ranks = [{'rank': rank, 'karma_min': rank.karma_min} for rank in self.env['gamification.karma.rank'].search([], order="karma_min DESC")] + + # 3 is the number of search/requests used by rank in _recompute_rank_bulk() + if len(self) > len(ranks) * 3: + self._recompute_rank_bulk() + return + for user in self: old_rank = user.rank_id if user.karma == 0 and ranks: @@ -96,6 +112,66 @@ class Users(models.Model): if old_rank != user.rank_id: user._rank_changed() + def _recompute_rank_bulk(self): + """ + Compute rank of each user by rank. + For each rank, check which users need to be ranked + + """ + ranks = [{'rank': rank, 'karma_min': rank.karma_min} for rank in + self.env['gamification.karma.rank'].search([], order="karma_min DESC")] + + users_todo = self + + next_rank_id = False + # wtf, next_rank_id should be a related on rank_id.next_rank_id and life might get easier. + # And we only need to recompute next_rank_id on write with min_karma or in the create on rank model. + for r in ranks: + rank_id = r['rank'].id + dom = [ + ('karma', '>=', r['karma_min']), + ('id', 'in', users_todo.ids), + '|', # noqa + '|', ('rank_id', '!=', rank_id), ('rank_id', '=', False), + '|', ('next_rank_id', '!=', next_rank_id), ('next_rank_id', '=', False if next_rank_id else -1), + ] + users = self.env['res.users'].search(dom) + if users: + users_to_notify = self.env['res.users'].search([ + ('karma', '>=', r['karma_min']), + '|', ('rank_id', '!=', rank_id), ('rank_id', '=', False), + ('id', 'in', users.ids), + ]) + users.write({ + 'rank_id': rank_id, + 'next_rank_id': next_rank_id, + }) + users_to_notify._rank_changed() + users_todo -= users + + nothing_to_do_users = self.env['res.users'].search([ + ('karma', '>=', r['karma_min']), + '|', ('rank_id', '=', rank_id), ('next_rank_id', '=', next_rank_id), + ('id', 'in', users_todo.ids), + ]) + users_todo -= nothing_to_do_users + next_rank_id = r['rank'].id + + if ranks: + lower_rank = ranks[-1]['rank'] + users = self.env['res.users'].search([ + ('karma', '>=', 0), + ('karma', '<', lower_rank.karma_min), + '|', ('rank_id', '!=', False), ('next_rank_id', '!=', lower_rank.id), + ('id', 'in', users_todo.ids), + ]) + if users: + users.write({ + 'rank_id': False, + 'next_rank_id': lower_rank.id, + }) + + def _get_next_rank(self): """ For fresh users with 0 karma that don't have a rank_id and next_rank_id yet this method returns the first karma rank (by karma ascending). This acts as a diff --git a/addons/gamification/tests/__init__.py b/addons/gamification/tests/__init__.py index 0219f6754d0..a6a599bf6c8 100644 --- a/addons/gamification/tests/__init__.py +++ b/addons/gamification/tests/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_challenge +from . import test_recompute_rank diff --git a/addons/gamification/tests/test_recompute_rank.py b/addons/gamification/tests/test_recompute_rank.py new file mode 100644 index 00000000000..c82fb4759fd --- /dev/null +++ b/addons/gamification/tests/test_recompute_rank.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import common +from unittest.mock import patch + + +class TestComputeRankCommon(common.TransactionCase): + + def setUp(self): + super(TestComputeRankCommon, self).setUp() + + def _patched_send_mail(*args, **kwargs): + pass + + self.User = self.env['res.users'].with_context(no_reset_password=True, mail_create_nosubscribe=True) + self.users = self.User + + patch_email = patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail) + patch_email.start() + for k in range(-5, 1030, 30): + self.users += self.User.create({ + 'name': str(k), + 'login': "test_recompute_rank_%s" % k, + 'karma': k, + }) + + self.env['gamification.karma.rank'].search([]).unlink() + + self.rank_1 = self.env['gamification.karma.rank'].create({ + 'name': 'rank 1', + 'karma_min': 0, + }) + + self.rank_2 = self.env['gamification.karma.rank'].create({ + 'name': 'rank 2', + 'karma_min': 250, + }) + + self.rank_3 = self.env['gamification.karma.rank'].create({ + 'name': 'rank 3', + 'karma_min': 500, + }) + self.rank_4 = self.env['gamification.karma.rank'].create({ + 'name': 'rank 4', + 'karma_min': 1000, + }) + + patch_email.stop() + + +class test_recompute(TestComputeRankCommon): + + def test_00_initial_compute(self): + + self.assertEqual(len(self.users), 35) + + self.assertEqual( + len(self.rank_1.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_1.karma_min and u.karma < self.rank_2.karma_min]) + ) + self.assertEqual( + len(self.rank_2.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_2.karma_min and u.karma < self.rank_3.karma_min]) + ) + self.assertEqual( + len(self.rank_3.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_3.karma_min and u.karma < self.rank_4.karma_min]) + ) + self.assertEqual( + len(self.rank_4.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_4.karma_min]) + ) + + def test_01_switch_rank(self): + + self.assertEqual(len(self.users), 35) + + self.rank_3.karma_min = 100 + # rank_1 -> rank_3 -> rank_2 -> rank_4 + + self.assertEqual( + len(self.rank_1.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_1.karma_min and u.karma < self.rank_3.karma_min]) + ) + self.assertEqual( + len(self.rank_3.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_3.karma_min and u.karma < self.rank_2.karma_min]) + ) + self.assertEqual( + len(self.rank_2.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_2.karma_min and u.karma < self.rank_4.karma_min]) + ) + self.assertEqual( + len(self.rank_4.user_ids & self.users), + len([u for u in self.users if u.karma >= self.rank_4.karma_min]) + ) + + def test_02_update_rank_without_switch(self): + number_of_users = False + + def _patched_recompute_rank(_self, *args, **kwargs): + nonlocal number_of_users + number_of_users = len(_self & self.users) + + patch_bulk = patch('odoo.addons.gamification.models.res_users.Users._recompute_rank', _patched_recompute_rank) + patch_bulk.start() + self.rank_3.karma_min = 700 + self.assertEqual(number_of_users, 7, "Should just recompute for the 7 users between 500 and 700") + patch_bulk.stop() + + def test_03_test_bulk_call(self): + self.assertEqual(len(self.users), 35) + + def _patched_check_in_bulk(*args, **kwargs): + raise + + patch_bulk = patch('odoo.addons.gamification.models.res_users.Users._recompute_rank_bulk', _patched_check_in_bulk) + patch_bulk.start() + + # call on 5 users should not trigger the bulk function + self.users[0:5]._recompute_rank() + + # call on 50 users should trigger the bulk function + with self.assertRaises(Exception): + self.users[0:50]._recompute_rank() + + patch_bulk.stop() diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index 607f1b8c553..77550f498f3 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -383,7 +383,7 @@ var BasicActivity = AbstractField.extend({ container: $markDoneBtn, title : _t("Feedback"), html: true, - trigger:'click', + trigger: 'manual', placement: 'right', // FIXME: this should work, maybe a bug in the popper lib content : function () { var $popover = $(QWeb.render('mail.activity_feedback_form', { @@ -400,6 +400,11 @@ var BasicActivity = AbstractField.extend({ $popover.find('#activity_feedback').focus(); self._bindPopoverFocusout($(this)); }).popover('show'); + } else { + var popover = $markDoneBtn.data('bs.popover'); + if ($('#' + popover.tip.id).length === 0) { + popover.show(); + } } }, /** diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js index 180fddd5e3b..ade3d5d795b 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -1845,6 +1845,74 @@ QUnit.test('form activity widget: schedule next activity', async function (asser form.destroy(); }); +QUnit.test('form activity widget: do not close activity popover on click', async function (assert) { + assert.expect(3); + this.data.partner.records[0].activity_ids = [1]; + this.data.partner.records[0].activity_state = 'today'; + this.data['mail.activity'].records = [{ + id: 1, + display_name: "An activity", + date_deadline: moment().format("YYYY-MM-DD"), // now + can_write: true, + state: "today", + user_id: 2, + create_user_id: 2, + activity_type_id: 2, + }]; + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + services: this.services, + arch: `<form string="Partners"> + <sheet> + <field name="foo"/> + </sheet> + <div class="oe_chatter"> + <field name="message_ids" widget="mail_thread"/> + <field name="activity_ids" widget="mail_activity"/> + </div> + </form>`, + res_id: 2, + mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback_schedule_next') { + return Promise.resolve(); + } + return this._super.apply(this, arguments); + }, + intercepts: { + do_action(ev) { + ev.data.options.on_close(); + }, + }, + }); + + //Schedule next activity + await testUtils.dom.click(form.$('.o_mail_activity .o_mark_as_done[data-activity-id=1]')); + + for (let i = 0; i < 2; i++) { + // click twice in feedback area + await testUtils.dom.click(form.$('.o_mail_activity_feedback.popover textarea')); + const $popover = form.$('.o_mail_activity_feedback.popover'); + const $PopoverInDom = form.$(`#${$popover.attr('id')}`); + assert.isVisible( + $PopoverInDom, + "the feedback popover should still be visible on click"); + } + await concurrency.delay(250); // wait for popover transition + const $popover = form.$('.o_mail_activity_feedback.popover'); + if (!$popover) { + throw new Error("popover should be in DOM"); + } + const $PopoverInDom = form.$(`#${$popover.attr('id')}`); + assert.isVisible( + $PopoverInDom, + "the feedback popover should still be visible on click"); + + form.destroy(); +}); + QUnit.test('form activity widget: edit next activity', async function (assert) { assert.expect(3); diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 95ae6372c41..a003f2e42c6 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -306,6 +306,7 @@ class MailComposer(models.TransientModel): 'attachment_ids': [attach.id for attach in self.attachment_ids], 'author_id': self.author_id.id, 'email_from': self.email_from, + 'reply_to': self.reply_to, 'record_name': self.record_name, 'no_auto_thread': self.no_auto_thread, 'mail_server_id': self.mail_server_id.id, diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 4236a313c69..4be76675244 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -543,10 +543,12 @@ class ProductTemplate(models.Model): # deleted valid_value_ids = tmpl_id.valid_product_attribute_value_wnva_ids valid_attribute_ids = tmpl_id.valid_product_attribute_wnva_ids + seen_attributes = set(p.attribute_value_ids for p in tmpl_id.product_variant_ids if p.active) for product_id in tmpl_id.product_variant_ids: if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids): - if not product_id.active: + if not product_id.active and product_id.attribute_value_ids not in seen_attributes: variants_to_activate += product_id + seen_attributes.add(product_id.attribute_value_ids) else: variants_to_unlink += product_id diff --git a/addons/product/tests/test_variants.py b/addons/product/tests/test_variants.py index ed7f1f777c5..40d95171449 100644 --- a/addons/product/tests/test_variants.py +++ b/addons/product/tests/test_variants.py @@ -534,3 +534,286 @@ class TestVariantsImages(common.TestProductCommon): self.assertEqual(self.template.image, self.images['red']) self.assertEqual(self.variants[0].image_raw_original, False) self.assertEqual(self.variants[0].image, self.images['red']) + + +class TestVariantsArchive(common.TestProductCommon): + """Once a variant is used on orders/invoices, etc, they can't be unlinked. + As a result, updating attributes on a product template would simply archive the variants instead. + We make sure that at each update, we have the correct number of active and inactive records. + + In these tests, we use the commands sent by the JS framework to the ORM when using the interface + (even though using 3 commands would simplify the code compared to 2 commands). + + Note that: we don't overly test the ids of archived / modified variants. + In some cases _create_product_variants reuse existing variants and in other create new ones, + in a way that is not very predictable (without reading the code). + We do not enshrine the current behaviour in the tests (yet) as it could change with only moderate functional impact. + Consequently, if it is intentional the number of archived variants could change, + in that case update the test; it is there to make sure that a fix is not going to break anything silently. + The most important is to keep correct numbers of active variants. + """ + def setUp(self): + res = super(TestVariantsArchive, self).setUp() + + attribute_values = { + 'color': ['white', 'black'], + 'size': ['s', 'm'], + } + + self.attributes = {} + for attr, attr_values in attribute_values.items(): + attribute = self.env['product.attribute'].create({'name': attr}) + values = [self.env['product.attribute.value'].create({'name': name, 'attribute_id': attribute.id}) + for name in attr_values] + self.attributes.update({attr: {'a': attribute, 'v': values}}) + + attribute_line_ids = [(0, 0, { + 'attribute_id': attr['a'].id, + 'value_ids': [(6, 0, [v.id for v in attr['v']])], + }) for attr in self.attributes.values()] + + self.template = self.env['product.template'].create({ + 'name': 'consume product', + 'attribute_line_ids': attribute_line_ids, + }) + + return res + + def test_update_variant_unlink(self): + """Variants are not used anywhere, so removing an attribute line would + unlink the variants and create new ones. Nothing too fancy here. + """ + lines = self.template.attribute_line_ids + self.assertEqual(len(self.template.product_variant_ids), 4) + # we keep lines[0], remove and copy lines[1] + line_copy_command = {'attribute_id': lines[1].attribute_id.id, 'value_ids': [(6, False, lines[1].value_ids.mapped('id'))]} + self.template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) + self.assertEqual(len(self.template.product_variant_ids), len(lines[0].value_ids), + "Since we only kept line[0], we should have as many variants as it has values") + archived_products = self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', self.template.id)]) + self.assertFalse(archived_products, + "Since they are used nowhere, there should not be any archived variant.") + # we re-add the line we just removed, so we should get new variants + self.template.write({ + 'attribute_line_ids': [ + (4, lines[0].id), + (0, 'virtual', line_copy_command), + ]}) + self.assertEqual(len(self.template.product_variant_ids), 4) + + def test_update_variant_archive_1_value(self): + """We do the same operations on the template as in test_update_variant_unlink, + except we simulate that the variants can't be unlinked. + It follows that variants should be archived instead, so the results should all be different from previous test. + In this test we have a line that have only one possible value: + this is handled differently than the case where we have more than one, + since in principle it does not add new variants. + """ + # we create a new template with a line with only one attribute. + # Since this does not create any additional variants, the behaviour is a little bit different: + # it tries to add the new attribute to existing variants if possible + attribute_line_ids = [(0, 0, { + 'attribute_id': attr['a'].id, + 'value_ids': [(6, 0, [v.id for v in (attr['v'] if attr['a'].display_name == 'color' else attr['v'][:1])])], + }) for attr in self.attributes.values()] + + template = self.env['product.template'].create({ + 'name': 'consume product', + 'attribute_line_ids': attribute_line_ids, + }) + + # create a patch to make as if one variant was undeletable + # (e.g. present in a field with ondelete=restrict) + Product = self.env['product.product'] + no_remove = template.product_variant_ids[0].id + def unlink(self): + if self.id == no_remove: + raise Exception('just') + else: + return super(Product.__class__, self).unlink() + Product._patch_method('unlink', unlink) + + variants_ids_history = [] + + self.assertEqual(len(template.product_variant_ids), 2) + variants_ids_history.append(template.product_variant_ids.ids) + + lines = template.attribute_line_ids + # we keep lines[0], remove and copy lines[1], which is the one with only one possible value + line_copy_command = {'attribute_id': lines[1].attribute_id.id, + 'value_ids': [(6, False, lines[1].value_ids.mapped('id'))]} + template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) + self.assertEqual(len(lines[0].value_ids), 2) + self.assertEqual(len(template.product_variant_ids), len(lines[0].value_ids), + "Since we only kept line[0], we should have as many variants as it has values") + variants_ids_history.append(template.product_variant_ids.ids) + + archived_products_1 = self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', template.id)]) + self.assertEqual(len(archived_products_1), 1, + "One variant should have been unlinked, not the other.") + # we re-add the line we just removed, so we should get new variants + template.write({ + 'attribute_line_ids': [ + (4, lines[0].id), + (0, 'virtual', line_copy_command), + ]}) + self.assertEqual(len(template.product_variant_ids), 2) + variants_ids_history.append(template.product_variant_ids.ids) + + archived_products_2 = self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', template.id)]) + self.assertEqual(len(archived_products_2), 1, + "We did not reactivate this variant.") + + # tests on ids, see the doc_string + self.assertEqual(archived_products_1, archived_products_2) + self.assertEqual(len(set(variants_ids_history[0] + variants_ids_history[1])), 4, + "After the first update, we have new active variants.") + self.assertEqual(len(set(variants_ids_history[1] + variants_ids_history[2])), 2, + "After the second update, we kept the same active variants.") + + Product._revert_method('unlink') + + def test_update_variant_archive_2_value(self): + """We do the same operations on the template as in test_update_variant_unlink, + except we simulate that the variants can't be unlinked. + It follows that variants should be archived instead, so the results should all be different from previous test. + """ + Product = self.env['product.product'] + def unlink(slef): + raise Exception('just') + Product._patch_method('unlink', unlink) + variants_ids_history = [] + + self.assertEqual(len(self.template.product_variant_ids), 4) + variants_ids_history.append(self.template.product_variant_ids.ids) + + lines = self.template.attribute_line_ids + # we keep lines[0], remove and copy lines[1] + line_copy_command = {'attribute_id': lines[1].attribute_id.id, + 'value_ids': [(6, False, lines[1].value_ids.mapped('id'))]} + line_second_copy_command = {'attribute_id': lines[1].attribute_id.id, + 'value_ids': [(6, False, lines[1].value_ids[:1].mapped('id'))]} + self.template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) + self.assertEqual(len(self.template.product_variant_ids), len(lines[0].value_ids), + "Since we only kept line[0], we should have as many variants as it has values") + variants_ids_history.append(self.template.product_variant_ids.ids) + + archived_products = [] + archived_products.append(self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) + self.assertEqual(len(archived_products[0]), 4, + "Since they can't be unlinked, all variants should be archived.") + # we re-add the line we just removed, so we should get new variants + self.template.write({ + 'attribute_line_ids': [ + (4, lines[0].id), + (0, 'virtual', line_copy_command), + ]}) + self.assertEqual(len(self.template.product_variant_ids), 4) + variants_ids_history.append(self.template.product_variant_ids.ids) + + archived_products.append(self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) + self.assertEqual(len(archived_products[1]), 2, + "Since they can't be unlinked, all previous variants should be archived.") + + # we redo the whole remove/readd to check + lines = self.template.attribute_line_ids + self.template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) + self.assertEqual(len(self.template.product_variant_ids), 2) + variants_ids_history.append(self.template.product_variant_ids.ids) + archived_products.append(self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) + self.template.write({ + 'attribute_line_ids': [ + (4, lines[0].id), + (0, 'virtual', line_copy_command), + ]}) + self.assertEqual(len(self.template.product_variant_ids), 4) + variants_ids_history.append(self.template.product_variant_ids.ids) + archived_products.append(self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) + self.assertEqual(len(archived_products[3]), 2) + + # now we test when we try to add the value back on existing products + lines = self.template.attribute_line_ids + self.template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) + self.assertEqual(len(self.template.product_variant_ids), 2) + variants_ids_history.append(self.template.product_variant_ids.ids) + archived_products.append(self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) + self.assertEqual(len(archived_products[4]), 4) + # this time we only add one of the two attributes we've been removing + self.template.write({ + 'attribute_line_ids': [ + (4, lines[0].id), + (0, 'virtual', line_second_copy_command), + ]}) + self.assertEqual(len(self.template.product_variant_ids), 2) + variants_ids_history.append(self.template.product_variant_ids.ids) + archived_products.append(self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', self.template.id)])) + self.assertEqual(len(archived_products[5]), 4) + + Product._revert_method('unlink') + + def test_update_variant_archive_3_value(self): + """In this one, we have unique values for one lines, and one line with two values. + We first remove the unique line to populate archived variants, then remove the two values line. + Finally, we re-add athe two lines, bringing us back to the first situation: + we should not have any more than two variants (rest should be archived) + """ + attribute_line_ids = [(0, 0, { + 'attribute_id': attr['a'].id, + 'value_ids': [(6, 0, [v.id for v in (attr['v'] if attr['a'].display_name == 'color' else attr['v'][:1])])], + }) for attr in self.attributes.values()] + + template = self.env['product.template'].create({ + 'name': 'consume product', + 'attribute_line_ids': attribute_line_ids, + }) + + Product = self.env['product.product'] + def unlink(slef): + raise Exception('just') + Product._patch_method('unlink', unlink) + + variants_ids_history = [] + self.assertEqual(len(template.product_variant_ids), 2) + variants_ids_history.append(template.product_variant_ids.ids) + + lines = template.attribute_line_ids + + template.write({'attribute_line_ids': [(4, lines[0].id), (2, lines[1].id)]}) + self.assertEqual(len(template.product_variant_ids), len(lines[0].value_ids), + "Since we only kept line[0], we should have as many variants as it has values") + variants_ids_history.append(template.product_variant_ids.ids) + + archived_products = [] + archived_products.append(self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', template.id)])) + self.assertEqual(len(archived_products[0]), 2, + "Since they can't be unlinked, all variants should be archived.") + + template.write({'attribute_line_ids': [(2, lines[0].id)]}) + self.assertEqual(len(template.product_variant_ids), 1) + variants_ids_history.append(template.product_variant_ids.ids) + + archived_products.append(self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', template.id)])) + self.assertEqual(len(archived_products[1]), 4, + "Since they can't be unlinked, all previous variants should be archived.") + + # we re-add everything: + template.write({'attribute_line_ids': attribute_line_ids}) + + archived_products.append(self.env['product.product'].search( + [('active', '=', False), ('product_tmpl_id', '=', template.id)])) + self.assertEqual(len(template.product_variant_ids), 2) + self.assertEqual(len(archived_products[2]), 3, + "We should have re-activated one variant only.") + + Product._revert_method('unlink') diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index 77b3cc83152..c651637bc89 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -172,7 +172,7 @@ placeholder="Name, TIN, Email, or Reference" /> <field name="partner_ref"/> - <field name="currency_id" groups="base.group_multi_currency"/> + <field name="currency_id" groups="base.group_multi_currency" force_save="1"/> </group> <group> <field name="date_order"/> diff --git a/addons/sale_management/models/sale_order.py b/addons/sale_management/models/sale_order.py index 6a95dfeaeeb..1b8562b1c54 100644 --- a/addons/sale_management/models/sale_order.py +++ b/addons/sale_management/models/sale_order.py @@ -72,7 +72,11 @@ class SaleOrder(models.Model): price = self.pricelist_id.with_context(uom=line.product_uom_id.id).get_product_price(line.product_id, 1, False) if self.pricelist_id.discount_policy == 'without_discount' and line.price_unit: discount = (line.price_unit - price) / line.price_unit * 100 - price = line.price_unit + # negative discounts (= surcharge) are included in the display price + if discount < 0: + discount = 0 + else: + price = line.price_unit else: price = line.price_unit diff --git a/addons/sale_management/views/sale_order_template_views.xml b/addons/sale_management/views/sale_order_template_views.xml index d049e2925ba..7f93a97091a 100644 --- a/addons/sale_management/views/sale_order_template_views.xml +++ b/addons/sale_management/views/sale_order_template_views.xml @@ -53,7 +53,7 @@ <div> <field name="product_uom_qty" class="oe_inline"/> </div> - <field name="price_unit" invisible="1"/> + <field name="price_unit"/> <label for="discount" groups="sale.group_discount_per_so_line"/> <div groups="sale.group_discount_per_so_line"> <field name="discount" class="oe_inline"/> %% @@ -90,7 +90,7 @@ attrs="{'required': [('display_type', '=', False)]}" /> <field name="discount" groups="sale.group_discount_per_so_line"/> - <field name="price_unit" invisible="1"/> + <field name="price_unit"/> </tree> </field> </page> @@ -101,7 +101,7 @@ <field name="name"/> <field name="quantity"/> <field name="uom_id" groups="uom.group_uom"/> - <field name="price_unit" invisible="1"/> + <field name="price_unit"/> <field name="discount" groups="sale.group_discount_per_so_line"/> </tree> </field> diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index f93f95dd801..fdcce6710b2 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -1054,6 +1054,7 @@ class StockMove(models.Model): def _prepare_extra_move_vals(self, qty): vals = { + 'procure_method': 'make_to_stock', 'origin_returned_move_id': self.origin_returned_move_id.id, 'product_uom_qty': qty, 'picking_id': self.picking_id.id, @@ -1080,13 +1081,17 @@ class StockMove(models.Model): rounding_method='HALF-UP') extra_move_vals = self._prepare_extra_move_vals(extra_move_quantity) extra_move = self.copy(default=extra_move_vals) - if extra_move.picking_id: + + merge_into_self = all(self[field] == extra_move[field] for field in self._prepare_merge_moves_distinct_fields()) + + if merge_into_self and extra_move.picking_id: extra_move = extra_move._action_confirm(merge_into=self) + return extra_move else: extra_move = extra_move._action_confirm() # link it to some move lines. We don't need to do it for move since they should be merged. - if self.exists() and not self.picking_id: + if not merge_into_self: for move_line in self.move_line_ids.filtered(lambda ml: ml.qty_done): if float_compare(move_line.qty_done, extra_move_quantity, precision_rounding=rounding) <= 0: # move this move line to our extra move @@ -1103,7 +1108,7 @@ class StockMove(models.Model): extra_move_quantity -= extra_move_quantity if extra_move_quantity == 0.0: break - return extra_move + return extra_move | self def _unreserve_initial_demand(self, new_move): pass @@ -1124,9 +1129,7 @@ class StockMove(models.Model): for move in moves: if move.state == 'cancel' or move.quantity_done <= 0: continue - # extra move will not be merged in mrp - if not move.picking_id: - moves_todo |= move + moves_todo |= move._create_extra_move() # Split moves where necessary and move quants diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 315113f08f4..77fa8888e1d 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -501,7 +501,7 @@ class StockMoveLine(models.Model): if quantity > available_quantity: # We now have to find the move lines that reserved our now unavailable quantity. We # take care to exclude ourselves and the move lines were work had already been done. - oudated_move_lines_domain = [ + outdated_move_lines_domain = [ ('move_id.state', 'not in', ['done', 'cancel']), ('product_id', '=', product_id.id), ('lot_id', '=', lot_id.id if lot_id else False), @@ -511,14 +511,15 @@ class StockMoveLine(models.Model): ('product_qty', '>', 0.0), ('id', 'not in', ml_to_ignore.ids), ] - oudated_candidates = self.env['stock.move.line'].search(oudated_move_lines_domain) + current_picking_first = lambda cand: cand.picking_id != self.move_id.picking_id + outdated_candidates = self.env['stock.move.line'].search(outdated_move_lines_domain).sorted(current_picking_first) # As the move's state is not computed over the move lines, we'll have to manually # recompute the moves which we adapted their lines. move_to_recompute_state = self.env['stock.move'] rounding = self.product_uom_id.rounding - for candidate in oudated_candidates: + for candidate in outdated_candidates: if float_compare(candidate.product_qty, quantity, precision_rounding=rounding) <= 0: quantity -= candidate.product_qty move_to_recompute_state |= candidate.move_id diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index a62ff9fef98..14c4f133f3c 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -186,6 +186,27 @@ class TestPickShip(TestStockCommon): # the client picking should not be assigned anymore, as we returned partially what we took self.assertEqual(picking_client.state, 'confirmed') + def test_mto_moves_extra_qty(self): + """ Ensure that a move in MTO will support an extra quantity. The extra + move should be created in MTS and should not be merged in the initial + move if it's in MTO. It should also avoid to trigger the rules. + """ + picking_pick, picking_client = self.create_pick_ship() + stock_location = self.env['stock.location'].browse(self.stock_location) + self.productA.write({'route_ids': [(4, self.env.ref('stock.route_warehouse0_mto').id)]}) + self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 10.0) + picking_pick.action_assign() + picking_pick.move_lines[0].move_line_ids[0].qty_done = 15.0 + picking_pick.action_done() + self.assertEqual(picking_pick.state, 'done') + self.assertEqual(picking_client.state, 'assigned') + + picking_client.move_lines[0].move_line_ids[0].qty_done = 15.0 + picking_client.move_lines._action_done() + self.assertEqual(len(picking_client.move_lines), 2) + self.assertEqual(picking_client.move_lines.mapped('procure_method'), ['make_to_order', 'make_to_stock']) + self.assertEqual(picking_client.move_lines.mapped('product_uom_qty'), [10.0, 5.0]) + def test_mto_moves_return_extra(self): picking_pick, picking_client = self.create_pick_ship() stock_location = self.env['stock.location'].browse(self.stock_location) diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 04f818d825d..ea657cbe2a6 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -430,6 +430,7 @@ var PivotModel = AbstractModel.extend({ } if ('domain' in params) { this.data.domain = params.domain; + this.initialDomain = params.domain; } else { this.data.domain = this.initialDomain; } diff --git a/addons/web/static/src/scss/layout_standard.scss b/addons/web/static/src/scss/layout_standard.scss index acc0e07bf1c..bd0b4934259 100644 --- a/addons/web/static/src/scss/layout_standard.scss +++ b/addons/web/static/src/scss/layout_standard.scss @@ -1,3 +1,6 @@ .o_standard_footer { margin-top: 200px; + .list-inline-item { + white-space: nowrap; + } } diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js index fa171f21015..8aeb3a29157 100644 --- a/addons/web/static/tests/views/pivot_tests.js +++ b/addons/web/static/tests/views/pivot_tests.js @@ -4,6 +4,7 @@ odoo.define('web.pivot_tests', function (require) { var core = require('web.core'); var PivotView = require('web.PivotView'); var testUtils = require('web.test_utils'); +var testUtilsDom = require('web.test_utils_dom'); var createActionManager = testUtils.createActionManager; var patchDate = testUtils.mock.patchDate; @@ -16,7 +17,7 @@ QUnit.module('Views', { this.data = { partner: { fields: { - foo: {string: "Foo", type: "integer"}, + foo: {string: "Foo", type: "integer", searchable: true}, bar: {string: "bar", type: "boolean"}, date: {string: "Date", type: "date", store: true, sortable: true}, product_id: {string: "Product", type: "many2one", relation: 'product', store: true}, @@ -1841,6 +1842,71 @@ QUnit.module('Views', { actionManager.destroy(); }); + QUnit.test('Navigation list view for a group and back with breadcrumbs', async function (assert) { + assert.expect(16); + // create an action manager to test the interactions with the search view + var readGroupCount = 0; + var actionManager = await createActionManager({ + data: this.data, + archs: { + 'partner,false,pivot': '<pivot>' + + '<field name="customer" type="row"/>' + + '</pivot>', + 'partner,false,search': '<search><filter name="bayou" string="Bayou" domain="[(\'foo\',\'=\', 12)]"/></search>', + 'partner,false,list': '<tree><field name="foo"/></tree>', + 'partner,false,form': '<form><field name="foo"/></form>', + }, + intercepts: { + do_action: function (event) { + var action = event.data.action; + actionManager.doAction(action); + } + }, + mockRPC: function (route, args) { + if (args.method === 'read_group') { + assert.step('read_group'); + var domain = args.kwargs.domain; + if ([0,1].indexOf(readGroupCount) !== -1) { + assert.deepEqual(domain, [], 'initial domain empty'); + } else if ([2,3,4,5].indexOf(readGroupCount) !== -1) { + assert.deepEqual(domain, [['foo', '=', 12]], + 'domain conserved when back with breadcrumbs'); + } + readGroupCount++; + } + if (route === '/web/dataset/search_read') { + assert.step('search_read'); + var domain = args.domain; + assert.deepEqual(domain, [['foo', '=', 12], ['customer', '=', 1], ['foo', '=', 12]], + 'list domain is correct'); + } + return this._super.apply(this, arguments); + } + }); + + await actionManager.doAction({ + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'pivot']], + }); + + await testUtilsDom.click(actionManager.$('.o_filters_menu_button')); + await testUtilsDom.click(actionManager.$('.o_menu_item:contains("Bayou")')); + + await testUtilsDom.click(actionManager.$('.o_pivot_cell_value:nth(1)')) + + assert.containsOnce(actionManager, '.o_list_view'); + + await testUtilsDom.click(actionManager.$('.o_control_panel .breadcrumb-item:first() a')); + + assert.verifySteps([ + 'read_group', 'read_group', + 'read_group', 'read_group', + 'search_read', + 'read_group', 'read_group']) + actionManager.destroy(); + }); + QUnit.module('Sort in comparison mode', { beforeEach: async function () { this.data.partner.records[0].date = '2016-12-15'; diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py index 44f136dc471..3eb4c376163 100644 --- a/addons/web_editor/models/ir_ui_view.py +++ b/addons/web_editor/models/ir_ui_view.py @@ -251,10 +251,10 @@ class IrUiView(models.Model): if called_view and called_view not in views_to_return: views_to_return += self._views_get(called_view, options=options, bundles=bundles) - extensions = self._view_get_inherited_children(view, options) if not options: - # only active children - extensions = extensions.filtered(lambda view: view.active) + return views_to_return + + extensions = self._view_get_inherited_children(view, options) # Keep options in a deterministic order regardless of their applicability for extension in extensions.sorted(key=lambda v: v.id): diff --git a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js index 9bc9124688b..d13ddf63038 100644 --- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js +++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js @@ -358,6 +358,8 @@ var SnippetEditor = Widget.extend({ ev.preventDefault(); this.trigger_up('cover_will_change'); + this.trigger_up('snippet_will_be_cloned', {$target: this.$target}); + var $clone = this.$target.clone(false); this.trigger_up('request_history_undo_record', {$target: this.$target}); @@ -372,7 +374,7 @@ var SnippetEditor = Widget.extend({ } }, }); - this.trigger_up('snippet_cloned', {$target: $clone}); + this.trigger_up('snippet_cloned', {$target: $clone, $origin: this.$target}); $clone.trigger('content_changed'); }, /** diff --git a/addons/website/models/website_redirect.py b/addons/website/models/website_redirect.py index 511a006b8a7..a0fbda884b0 100644 --- a/addons/website/models/website_redirect.py +++ b/addons/website/models/website_redirect.py @@ -13,6 +13,6 @@ class WebsiteRedirect(models.Model): redirect_type = fields.Selection([('301', 'Moved permanently (301)'), ('302', 'Moved temporarily (302)')], string='Redirection Type', required=True, default="301") url_from = fields.Char('Redirect From', required=True) url_to = fields.Char('Redirect To', required=True) - website_id = fields.Many2one('website', 'Website') + website_id = fields.Many2one('website', 'Website', ondelete='cascade') active = fields.Boolean(default=True) sequence = fields.Integer() diff --git a/addons/website/static/src/js/menu/edit.js b/addons/website/static/src/js/menu/edit.js index a08ca136131..a601ae3fb8a 100644 --- a/addons/website/static/src/js/menu/edit.js +++ b/addons/website/static/src/js/menu/edit.js @@ -21,6 +21,7 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ custom_events: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.custom_events || {}, { content_will_be_destroyed: '_onContentWillBeDestroyed', content_was_recreated: '_onContentWasRecreated', + snippet_will_be_cloned: '_onSnippetWillBeCloned', snippet_cloned: '_onSnippetCloned', snippet_dropped: '_onSnippetDropped', edition_will_stopped: '_onEditionWillStop', @@ -204,8 +205,21 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ this.editModeEnable = false; }, /** + * Called when a snippet is about to be cloned in the page. Notifies the + * WebsiteRoot that is should destroy the animations for this snippet. + * + * @private + * @param {OdooEvent} ev + */ + _onSnippetWillBeCloned: function (ev) { + this.trigger_up('animation_stop_demand', { + $target: ev.data.$target, + }); + }, + /** * Called when a snippet is cloned in the page. Notifies the WebsiteRoot - * that is should start the public widgets for this snippet. + * that is should start the public widgets for this snippet and the snippet it + * was cloned from. * * @private * @param {OdooEvent} ev @@ -215,6 +229,13 @@ var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ editableMode: true, $target: ev.data.$target, }); + // TODO: remove in saas-12.5, undefined $origin will restart #wrapwrap + if (ev.data.$origin) { + this.trigger_up('widgets_start_request', { + editableMode: true, + $target: ev.data.$origin, + }); + } }, /** * Called when a snippet is dropped in the page. Notifies the WebsiteRoot diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py index 0d24a2517ad..b8262bb11b8 100644 --- a/addons/website/tests/test_views.py +++ b/addons/website/tests/test_views.py @@ -610,6 +610,20 @@ class TestCowViewSaving(common.TransactionCase): 'arch': '<div position="inside">, sub ext</div>', 'key': 'II', }) + + # B + # | + # I + # | + # II + + # First, test that children of inactive children are not returned (not multiwebsite related) + self.inherit_view.active = False + views = View.get_related_views('B') + self.assertEqual(views.mapped('key'), ['B', 'I'], "As 'I' is inactive, 'II' (its own child) should not be returned.") + self.inherit_view.active = True + + # Second, test multi-website self.inherit_view.with_context(website_id=1).write({'name': 'Extension'}) # Trigger cow on hierarchy View.create({ 'name': 'II2', diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index eaa855c36ce..2e9ae663d81 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -351,9 +351,11 @@ class WebsiteSale(http.Controller): @http.route(['/shop/pricelist'], type='http', auth="public", website=True, sitemap=False) def pricelist(self, promo, **post): redirect = post.get('r', '/shop/cart') - pricelist = request.env['product.pricelist'].sudo().search([('code', '=', promo)], limit=1) - if not pricelist or (pricelist and not request.website.is_pricelist_available(pricelist.id)): - return request.redirect("%s?code_not_available=1" % redirect) + # empty promo code is used to reset/remove pricelist (see `sale_get_order()`) + if promo: + pricelist = request.env['product.pricelist'].sudo().search([('code', '=', promo)], limit=1) + if (not pricelist or (pricelist and not request.website.is_pricelist_available(pricelist.id))): + return request.redirect("%s?code_not_available=1" % redirect) request.website.sale_get_order(code=promo) return request.redirect(redirect) @@ -684,6 +686,8 @@ class WebsiteSale(http.Controller): if mode[1] == 'billing': order.partner_id = partner_id order.onchange_partner_id() + # This is the *only* thing that the front end user will see/edit anyway when choosing billing address + order.partner_invoice_id = partner_id if not kw.get('use_same'): kw['callback'] = kw.get('callback') or \ (not order.only_services and (mode[0] == 'edit' and '/shop/checkout' or '/shop/address')) |
