[MERGE] forward port branch 12.0 up to b82df99d7a
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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -1858,6 +1858,73 @@ QUnit.test('form activity widget: schedule next activity', function (assert) {
|
||||
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
|
||||
state: "today",
|
||||
user_id: 2,
|
||||
create_user_id: 2,
|
||||
activity_type_id: 2,
|
||||
}];
|
||||
|
||||
const form = 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 $.when();
|
||||
}
|
||||
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', 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,
|
||||
|
||||
@@ -538,10 +538,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')
|
||||
|
||||
@@ -169,7 +169,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>
|
||||
|
||||
@@ -1053,6 +1053,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,
|
||||
@@ -1079,13 +1080,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
|
||||
@@ -1102,7 +1107,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
|
||||
@@ -1123,9 +1128,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
|
||||
|
||||
@@ -185,6 +185,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)
|
||||
|
||||
@@ -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 animations for this snippet.
|
||||
* that is should start the animations 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('animation_start_demand', {
|
||||
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',
|
||||
|
||||
@@ -349,9 +349,11 @@ class WebsiteSale(ProductConfiguratorController):
|
||||
@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)
|
||||
@@ -685,6 +687,8 @@ class WebsiteSale(ProductConfiguratorController):
|
||||
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(...):
|
||||
|
||||
@@ -1155,6 +1155,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