summaryrefslogtreecommitdiff
path: root/odoo/addons/test_testing_utilities
diff options
context:
space:
mode:
authorXavier Morel <xmo@odoo.com>2020-05-14 10:30:52 +0000
committerXavier Morel <xmo@odoo.com>2020-05-15 14:53:20 +0000
commitac3cb21836ff9abb3d6a5630287042fb935ab86a (patch)
treeb9056f9b666522ddd734ba14dfb8e4d3793294dc /odoo/addons/test_testing_utilities
parent99910b526ed792235a778863b993d10cf4e77cd7 (diff)
[FIX] SSF: handle o2m nesting
The SSF had pretty much punted on nested o2m as that's... not usually a concern because few people are insane enough to put o2ms in their o2ms in their views. And also because even if somebody did, sometimes nothing would break because it turns out to be pretty difficult to actually get into *using* those things. MRP managed to do it though, and repro-ing required copying over parts of mrp.production and then understanding why it didn't fail: * obviously needs an o2m (f1) which contains an o2m (f2), in the view (an invisible tree inside a visible tree) * needs an onchange which somehow updates the sub-o2m * needs to actually trigger an onchange on the root form for f1, meaning f1 must be a dependency of a compute field or something (here I just marked every damn field as on_change as the optimisation of "don't call onchange when there's no need to" doesn't matter) Also needs to be working on an existing record with existing lines *and sublines* as the issue occurs with records to update. The issue here is that `_onchange_values` would clean up f1 e.g. send nothing for unmodified entries, and only send modified fields otherwise, but it would only do so for the toplevel, meaning the sub-level would not go through this step, and could send UPDATE commands with an `id` field (set to the original value but still). This would then proceed to blow up while loading the record, as id fields are not writeable. The fix is to perform `_onchange_values` recursively. Do that using a separate helper in order to avoid blowing up on override and whatnot, or faffling about with weird branching to get the "default" values in case they're not provided, the root function can get all the relevant bits and call the helper with them, then the helper does that setup internally and calls itself directly. An other issue I stumbled upon when investigating is a similar problem on *save*, due to an implementation detail of the SSF: UPDATE commands are fetched lazily. `_values_to_save` took care of "hydrating" all update commands (and validating and filtering them) of modified o2m fields, but as it would not do so recursively a modified f2 would not get properly hydrated and filtered, and could try to write `None` onto existing records. closes odoo/odoo#51350 X-original-commit: 3dfb4cfad849936748cc6a5b8f712e100461a86f Signed-off-by: Xavier Morel (xmo) <xmo@odoo.com>
Diffstat (limited to 'odoo/addons/test_testing_utilities')
-rw-r--r--odoo/addons/test_testing_utilities/__init__.py1
-rw-r--r--odoo/addons/test_testing_utilities/ir.model.access.csv4
-rw-r--r--odoo/addons/test_testing_utilities/nested_o2m.py159
-rw-r--r--odoo/addons/test_testing_utilities/tests/test_form_impl.py95
4 files changed, 259 insertions, 0 deletions
diff --git a/odoo/addons/test_testing_utilities/__init__.py b/odoo/addons/test_testing_utilities/__init__.py
index a0fdc10fe11..309c948418b 100644
--- a/odoo/addons/test_testing_utilities/__init__.py
+++ b/odoo/addons/test_testing_utilities/__init__.py
@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
+from . import nested_o2m
diff --git a/odoo/addons/test_testing_utilities/ir.model.access.csv b/odoo/addons/test_testing_utilities/ir.model.access.csv
index 2e7786fdcc4..c060e4b77e9 100644
--- a/odoo/addons/test_testing_utilities/ir.model.access.csv
+++ b/odoo/addons/test_testing_utilities/ir.model.access.csv
@@ -22,3 +22,7 @@ access_o2m_readonly_subfield_child,access_o2m_readonly_subfield_child,model_o2m_
access_test_testing_utilities_req_bool,access_test_testing_utilities_req_bool,model_test_testing_utilities_req_bool,,1,0,0,0
access_o2m_changes_children,access_o2m_changes_children,model_o2m_changes_children,base.group_user,1,0,0,0
access_o2m_changes_children_lines,access_o2m_changes_children_lines,model_o2m_changes_children_lines,base.group_user,1,0,0,0
+access_ttu_product,access_ttu_product,model_ttu_product,base.group_user,1,0,0,0
+access_ttu_root,access_ttu_root,model_ttu_root,base.group_user,1,0,0,0
+access_ttu_child,access_ttu_child,model_ttu_child,base.group_user,1,0,0,0
+access_ttu_grandchild,access_ttu_grandchild,model_ttu_grandchild,base.group_user,1,0,0,0
diff --git a/odoo/addons/test_testing_utilities/nested_o2m.py b/odoo/addons/test_testing_utilities/nested_o2m.py
new file mode 100644
index 00000000000..dc73f9c817f
--- /dev/null
+++ b/odoo/addons/test_testing_utilities/nested_o2m.py
@@ -0,0 +1,159 @@
+from lxml.builder import E
+
+from odoo import fields, models, api
+
+class Product(models.Model):
+ _name = _description = 'ttu.product'
+
+class Root(models.Model):
+ _name = _description = 'ttu.root'
+
+ product_id = fields.Many2one('ttu.product')
+ product_qty = fields.Integer()
+ qty_producing = fields.Integer()
+ qty_produced = fields.Integer(compute='_get_produced_qty')
+
+ move_raw_ids = fields.One2many('ttu.child', 'root_raw_id')
+ move_finished_ids = fields.One2many('ttu.child', 'root_id')
+
+ @api.depends('move_finished_ids.move_line_ids.qty_done')
+ def _get_produced_qty(self):
+ for r in self:
+ r.qty_produced = sum(r.mapped('move_finished_ids.move_line_ids.qty_done'))
+ @api.onchange('qty_producing')
+ def _onchange_producing(self):
+ production_move = self.move_finished_ids.filtered(
+ lambda move: move.product_id == self.product_id
+ )
+ if not production_move:
+ # Happens when opening the mo?
+ return
+ for line in production_move.move_line_ids:
+ line.qty_done = 0
+ qty_producing = self.qty_producing - self.qty_produced
+ vals = production_move._set_quantity_done_prepare_vals(qty_producing)
+ if vals['to_create']:
+ for res in vals['to_create']:
+ production_move.move_line_ids.new(res)
+ if vals['to_write']:
+ for move_line, res in vals['to_write']:
+ move_line.update(res)
+
+ for move in (self.move_raw_ids | self.move_finished_ids.filtered(lambda m: m.product_id != self.product_id)):
+ new_qty = qty_producing * move.unit_factor
+ for line in move.move_line_ids:
+ line.qty_done = 0
+ vals = move._set_quantity_done_prepare_vals(new_qty)
+ if vals['to_create']:
+ for res in vals['to_create']:
+ move.move_line_ids.new(res)
+ if vals['to_write']:
+ for move_line, res in vals['to_write']:
+ move_line.update(res)
+
+ def _get_default_form_view(self):
+ move_subview = E.tree(
+ {'editable': 'bottom'},
+ E.field(name='product_id'),
+ E.field(name='unit_factor'),
+ E.field(name='quantity_done'),
+ E.field(
+ {'name': 'move_line_ids', 'invisible': '1'},
+ E.tree(
+ E.field(name='qty_done', invisible='1'),
+ E.field(name='product_id', invisible='1'),
+ E.field(name='move_id', invisible='1'),
+ E.field(name='id', invisible='1'),
+ )
+ )
+ )
+
+ t = E.form(
+ E.field(name='product_id'),
+ E.field(name='product_qty'),
+ E.field(name='qty_producing'),
+ E.field({'name': 'move_raw_ids', 'on_change': '1'}, move_subview),
+ E.field({'name': 'move_finished_ids', 'on_change': '1'}, move_subview),
+ )
+ # deoptimise to ensure we call onchange most of the time, as im the real
+ # case this is done as a result of the metric fuckton of computes, but
+ # here the near complete lack of computes causes most of the onchange
+ # triggers to get disabled
+ for f in t.iter('field'):
+ f.set('on_change', '1')
+ return t
+
+
+class Child(models.Model):
+ _name = _description = 'ttu.child'
+
+ product_id = fields.Many2one('ttu.product')
+ unit_factor = fields.Integer(default=1, required=True) # should be computed but we can ignore that
+ quantity_done = fields.Integer(
+ compute='_quantity_done_compute',
+ inverse='_quantity_done_set'
+ )
+
+ root_raw_id = fields.Many2one('ttu.root')
+ root_id = fields.Many2one('ttu.root')
+ move_line_ids = fields.One2many('ttu.grandchild', 'move_id')
+
+ def _set_quantity_done_prepare_vals(self, qty):
+ res = {'to_write': [], 'to_create': []}
+ for ml in self.move_line_ids:
+ ml_qty = ml.product_uom_qty - ml.qty_done
+ if ml_qty <= 0:
+ continue
+
+ taken_qty = min(qty, ml_qty)
+
+ res['to_write'].append((ml, {'qty_done': ml.qty_done + taken_qty}))
+ qty -= taken_qty
+
+ if qty <= 0:
+ break
+
+ if qty > 0:
+ res['to_create'].append({
+ 'move_id': self.id,
+ 'product_id': self.product_id.id,
+ 'product_uom_qty': 0,
+ 'qty_done': qty,
+ })
+ return res
+
+ @api.depends('move_line_ids.qty_done')
+ def _quantity_done_compute(self):
+ for move in self:
+ move.quantity_done = sum(move.mapped('move_line_ids.qty_done'))
+
+ def _quantity_done_set(self):
+ quantity_done = self[0].quantity_done # any call to create will invalidate `move.quantity_done`
+ for move in self:
+ move_lines = move.move_line_ids
+ if not move_lines:
+ if quantity_done:
+ # do not impact reservation here
+ move_line = self.env['ttu.grandchild'].create({
+ 'move_id': move.id,
+ 'product_id': move.product_id.id,
+ 'product_uom_qty': 0,
+ 'qty_done': quantity_done,
+ })
+ move.write({'move_line_ids': [(4, move_line.id)]})
+ elif len(move_lines) == 1:
+ move_lines[0].qty_done = quantity_done
+ else:
+ # Bypass the error if we're trying to write the same value.
+ ml_quantity_done = sum(l.qty_done for l in move_lines)
+ assert quantity_done == ml_quantity_done, "Cannot set the done quantity from this stock move, work directly with the move lines."
+
+
+class Grandchild(models.Model):
+ _name = _description = 'ttu.grandchild'
+
+ product_id = fields.Many2one('ttu.product')
+ product_uom_qty = fields.Integer()
+ qty_done = fields.Integer()
+
+ move_id = fields.Many2one('ttu.child')
diff --git a/odoo/addons/test_testing_utilities/tests/test_form_impl.py b/odoo/addons/test_testing_utilities/tests/test_form_impl.py
index cc2620f9e46..6e108b43fb1 100644
--- a/odoo/addons/test_testing_utilities/tests/test_form_impl.py
+++ b/odoo/addons/test_testing_utilities/tests/test_form_impl.py
@@ -572,6 +572,101 @@ class TestO2M(TransactionCase):
self.assertEqual(r.mapped('line_ids.vv'), [1, 2])
self.assertEqual(r.mapped('line_ids.v'), [7, 7])
+class TestNestedO2M(TransactionCase):
+ def test_id_cannot_be_assigned(self):
+ # MO with:
+ # produces product0
+ # produces 1 (product_qty)
+ # flexible BOM produces 1
+ # bom consumes 4x product 1
+ # bom consumes 1x product 2
+ product0 = self.env['ttu.product'].create({}).id
+ product1 = self.env['ttu.product'].create({}).id
+ product2 = self.env['ttu.product'].create({}).id
+ # create pseudo-MO in post-asigned state
+ obj = self.env['ttu.root'].create({
+ 'product_id': product0,
+ 'product_qty': 1.0,
+ # qty_producing=0 (onchange)
+ # qty_produced=0 (computed)
+ 'move_raw_ids': [
+ (0, 0, {
+ 'product_id': product2,
+ # quantity_done=0 (computed)
+ 'move_line_ids': [(0, 0, {
+ 'product_id': product2,
+ 'product_uom_qty': 1.0,
+ 'qty_done': 0.0 # -> 1.0
+ })] # -> new line with qty=0, qty_done=2
+ }),
+ (0, 0, {
+ 'product_id': product1,
+ 'unit_factor': 4,
+ 'move_line_ids': [(0, 0, {
+ 'product_id': product1,
+ 'product_uom_qty': 4.0,
+ 'qty_done': 0.0 # -> 4.0
+ })] # -> new line with qty=0, qty_done=8
+ })
+ ],
+ 'move_finished_ids': [(0, 0, {'product_id': product0})]
+ # -> new line with qty=0, qty_done=3
+ })
+ form = Form(obj)
+ form.qty_producing = 1
+ form._perform_onchange(['move_raw_ids'])
+ form.save()
+
+ def test_empty_update(self):
+ # MO with:
+ # produces product0
+ # produces 1 (product_qty)
+ # flexible BOM produces 1
+ # bom consumes 4x product 1
+ # bom consumes 1x product 2
+ product0 = self.env['ttu.product'].create({}).id
+ product1 = self.env['ttu.product'].create({}).id
+ product2 = self.env['ttu.product'].create({}).id
+ product4 = self.env['ttu.product'].create({})
+ # create pseudo-MO in post-asigned state
+ obj = self.env['ttu.root'].create({
+ 'product_id': product0,
+ 'product_qty': 1.0,
+ # qty_producing=0 (onchange)
+ # qty_produced=0 (computed)
+ 'move_raw_ids': [
+ (0, 0, {
+ 'product_id': product2,
+ # quantity_done=0 (computed)
+ 'move_line_ids': [(0, 0, {
+ 'product_id': product2,
+ 'product_uom_qty': 1.0,
+ 'qty_done': 0.0 # -> 1.0
+ })] # -> new line with qty=0, qty_done=2
+ }),
+ (0, 0, {
+ 'product_id': product1,
+ 'unit_factor': 4,
+ 'move_line_ids': [(0, 0, {
+ 'product_id': product1,
+ 'product_uom_qty': 4.0,
+ 'qty_done': 0.0 # -> 4.0
+ })] # -> new line with qty=0, qty_done=8
+ })
+ ],
+ 'move_finished_ids': [(0, 0, {'product_id': product0})]
+ # -> new line with qty=0, qty_done=3
+ })
+ form = Form(obj)
+ form.qty_producing = 1
+ form.save()
+ with form.move_raw_ids.new() as move:
+ move.product_id = product4
+ move.quantity_done = 10
+ # Check that this new product is not updated by qty_producing
+ form.qty_producing = 2
+ form.save()
+
class TestEdition(TransactionCase):
""" These use the context manager form as we don't need the record
post-save (we already have it) and it's easier to see what bits act on