summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-07-26 11:32:43 +0200
committerChristophe Simonis <chs@odoo.com>2019-07-26 11:32:43 +0200
commite0f9c092ccefc0516d06a9dc7f66e650eee888cf (patch)
tree0c404769dc28af133425cf9756a83569c70b31f3 /addons
parent7d78795f8b34bc6a8afde18427a1c26901824db3 (diff)
parent1d6d790513e3f1177dc0059f2ba4a06de6d87ac2 (diff)
[MERGE] forward port branch saas-12.2 up to 1d6d790513e
Diffstat (limited to 'addons')
-rw-r--r--addons/account/wizard/account_invoice_send.py21
-rw-r--r--addons/account/wizard/account_invoice_send_views.xml1
-rw-r--r--addons/gamification/models/gamification_karma_rank.py15
-rw-r--r--addons/gamification/models/res_users.py84
-rw-r--r--addons/gamification/tests/__init__.py1
-rw-r--r--addons/gamification/tests/test_recompute_rank.py128
-rw-r--r--addons/mail/static/src/js/activity.js7
-rw-r--r--addons/mail/static/tests/chatter_tests.js68
-rw-r--r--addons/mail/wizard/mail_compose_message.py1
-rw-r--r--addons/product/models/product_template.py4
-rw-r--r--addons/product/tests/test_variants.py283
-rw-r--r--addons/purchase/views/purchase_views.xml2
-rw-r--r--addons/sale_management/models/sale_order.py6
-rw-r--r--addons/sale_management/views/sale_order_template_views.xml6
-rw-r--r--addons/stock/models/stock_move.py15
-rw-r--r--addons/stock/models/stock_move_line.py7
-rw-r--r--addons/stock/tests/test_move2.py21
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js1
-rw-r--r--addons/web/static/src/scss/layout_standard.scss3
-rw-r--r--addons/web/static/tests/views/pivot_tests.js68
-rw-r--r--addons/web_editor/models/ir_ui_view.py6
-rw-r--r--addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js4
-rw-r--r--addons/website/models/website_redirect.py2
-rw-r--r--addons/website/static/src/js/menu/edit.js23
-rw-r--r--addons/website/tests/test_views.py14
-rw-r--r--addons/website_sale/controllers/main.py10
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'))