[MERGE] forward port branch saas-12.2 up to 1d6d790513
This commit is contained in:
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
.o_standard_footer {
|
||||
margin-top: 200px;
|
||||
.list-inline-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
@@ -203,9 +204,22 @@ 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
Canada, 2019-07-22
|
||||
|
||||
I hereby agree to the terms of the Odoo Individual Contributor License
|
||||
Agreement v1.0.
|
||||
|
||||
I declare that I am authorized and able to make this agreement and sign this
|
||||
declaration.
|
||||
|
||||
Signed,
|
||||
|
||||
David Dufresne 27902736+ddufresne@users.noreply.github.com https://github.com/ddufresne
|
||||
@@ -17,7 +17,7 @@ its sub-called methods.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from odoo.tools.profiler import profile
|
||||
from odoo.tools.misc import profile
|
||||
[...]
|
||||
@profile('/temp/prof.profile')
|
||||
@api.multi
|
||||
@@ -37,10 +37,15 @@ A tool called *xdot* will display the resulting graph:
|
||||
|
||||
xdot /temp/prof.xdot
|
||||
|
||||
The profiler can be also used without saving data in a file:
|
||||
Log a method
|
||||
============
|
||||
|
||||
Another profiler can be used to log statistics on a method:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from odoo.tools.profiler import profile
|
||||
[...]
|
||||
@profile
|
||||
@api.model
|
||||
def mymethod(...):
|
||||
|
||||
@@ -1168,6 +1168,23 @@ class GroupsView(models.Model):
|
||||
return res
|
||||
|
||||
|
||||
class ModuleCategory(models.Model):
|
||||
_inherit = "ir.module.category"
|
||||
|
||||
@api.multi
|
||||
def write(self, values):
|
||||
res = super().write(values)
|
||||
if "name" in values:
|
||||
self.env["res.groups"]._update_user_groups_view()
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
def unlink(self):
|
||||
res = super().unlink()
|
||||
self.env["res.groups"]._update_user_groups_view()
|
||||
return res
|
||||
|
||||
|
||||
class UsersView(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user