+
diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py
index f7ccabb3089..35742c6de79 100644
--- a/addons/stock_account/models/stock.py
+++ b/addons/stock_account/models/stock.py
@@ -156,24 +156,50 @@ class StockMove(models.Model):
@api.model
def _get_in_base_domain(self, company_id=False):
+ # Domain:
+ # - state is done
+ # - coming from a location without company, or an inventory location within the same company
+ # - going to a location within the same company
domain = [
('state', '=', 'done'),
- ('location_id.company_id', '=', False),
- ('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id)
+ '&',
+ '|',
+ ('location_id.company_id', '=', False),
+ '&',
+ ('location_id.usage', 'in', ['inventory', 'production']),
+ ('location_id.company_id', '=', company_id or self.env.user.company_id.id),
+ ('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id),
]
return domain
@api.model
def _get_all_base_domain(self, company_id=False):
+ # Domain:
+ # - state is done
+ # Then, either 'in' or 'out' moves.
+ # 'in' moves:
+ # - coming from a location without company, or an inventory location within the same company
+ # - going to a location within the same company
+ # 'out' moves:
+ # - coming from to a location within the same company
+ # - going to a location without company, or an inventory location within the same company
domain = [
('state', '=', 'done'),
'|',
'&',
- ('location_id.company_id', '=', False),
+ '|',
+ ('location_id.company_id', '=', False),
+ '&',
+ ('location_id.usage', 'in', ['inventory', 'production']),
+ ('location_id.company_id', '=', company_id or self.env.user.company_id.id),
('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id),
'&',
('location_id.company_id', '=', company_id or self.env.user.company_id.id),
- ('location_dest_id.company_id', '=', False)
+ '|',
+ ('location_dest_id.company_id', '=', False),
+ '&',
+ ('location_dest_id.usage', '=', 'inventory'),
+ ('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id),
]
return domain
diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py
index ca66f7154f5..1315586a208 100644
--- a/addons/stock_account/tests/test_stockvaluation.py
+++ b/addons/stock_account/tests/test_stockvaluation.py
@@ -3899,3 +3899,48 @@ class TestStockValuation(TransactionCase):
self.assertEqual(self.product1.with_context(to_date=Datetime.to_string(date6)).stock_value, 100)
self.assertEqual(self.product1.qty_at_date, 10)
self.assertEqual(self.product1.stock_value, 100)
+
+ def test_inventory_fifo_1(self):
+ """ Make an inventory from a location with a company set, and ensure the product has a stock
+ value. When the product is sold, ensure there is no remaining quantity on the original move
+ and no stock value.
+ """
+ self.product1.standard_price = 15
+ self.product1.product_tmpl_id.cost_method = 'fifo'
+ inventory_location = self.product1.property_stock_inventory
+ inventory_location.company_id = self.env.user.company_id.id
+
+ # Start Inventory: 12 units
+ move1 = self.env['stock.move'].create({
+ 'name': 'Adjustment of 12 units',
+ 'location_id': inventory_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 12.0,
+ })
+ move1._action_confirm()
+ move1._action_assign()
+ move1.move_line_ids.qty_done = 12.0
+ move1._action_done()
+
+ self.assertAlmostEqual(move1.value, 180.0)
+ self.assertAlmostEqual(move1.remaining_qty, 12.0)
+ self.assertAlmostEqual(self.product1.stock_value, 180.0)
+
+ # Sell the 12 units
+ move2 = self.env['stock.move'].create({
+ 'name': 'Sell 12 units',
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 12.0,
+ })
+ move2._action_confirm()
+ move2._action_assign()
+ move2.move_line_ids.qty_done = 12.0
+ move2._action_done()
+
+ self.assertAlmostEqual(move1.remaining_qty, 0.0)
+ self.assertAlmostEqual(self.product1.stock_value, 0.0)
diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js
index c7533aabae0..9c74dcb953e 100644
--- a/addons/web/static/src/js/core/domain.js
+++ b/addons/web/static/src/js/core/domain.js
@@ -263,8 +263,8 @@ var Domain = collections.Tree.extend({
});
break;
case 'previous_year':
- leftBoundaryParams.years = leftBoundaryParams.years ? leftBoundaryParams.years-- : -1;
- rightBoundaryParams.years = rightBoundaryParams.years ? rightBoundaryParams.years-- : -1;
+ leftBoundaryParams.years = leftBoundaryParams.years ? leftBoundaryParams.years - 1 : -1;
+ rightBoundaryParams.years = rightBoundaryParams.years ? rightBoundaryParams.years - 1 : -1;
break;
}
diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js
index af31c252898..b40da7ca0a5 100644
--- a/addons/web/static/src/js/views/calendar/calendar_controller.js
+++ b/addons/web/static/src/js/views/calendar/calendar_controller.js
@@ -272,6 +272,7 @@ var CalendarController = AbstractController.extend({
res_model: this.modelName,
context: context,
title: title,
+ view_id: this.formViewId || false,
disable_multiple_selection: true,
on_saved: function () {
if (event.data.on_save) {
diff --git a/addons/web/static/src/scss/layout_background.scss b/addons/web/static/src/scss/layout_background.scss
index 9b137fde8c1..caeb1f0c2bf 100644
--- a/addons/web/static/src/scss/layout_background.scss
+++ b/addons/web/static/src/scss/layout_background.scss
@@ -82,3 +82,10 @@
}
}
}
+
+/* special case for displaying report in iframe */
+.o_in_iframe {
+ .o_background_header {
+ min-width: 0;
+ }
+}
diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js
index 5a1ecba8264..6d37751dc0a 100644
--- a/addons/web/static/tests/views/calendar_tests.js
+++ b/addons/web/static/tests/views/calendar_tests.js
@@ -2381,6 +2381,41 @@ QUnit.module('Views', {
calendar.destroy();
});
+ QUnit.test('form_view_id attribute works with popup (for creating events)', function (assert) {
+ assert.expect(1);
+
+ var calendar = createView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: '
'+
+ ''+
+ '',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "load_views") {
+ assert.strictEqual(args.kwargs.views[0][0], 1,
+ "should load view with id 1");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ calendar.destroy();
+ });
+
QUnit.test('calendar fallback to form view id in action if necessary', function (assert) {
assert.expect(1);
diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js
index 0ec306c10f7..ecc0dc4cb1d 100644
--- a/addons/website/static/src/js/backend/dashboard.js
+++ b/addons/website/static/src/js/backend/dashboard.js
@@ -397,7 +397,8 @@ var Dashboard = AbstractAction.extend({
container: $analytics_chart_2[0],
options: {
title: 'All',
- width: '100%'
+ width: '100%',
+ tooltip: {isHtml: true},
}
}
});
diff --git a/addons/website_crm_partner_assign/models/crm_lead.py b/addons/website_crm_partner_assign/models/crm_lead.py
index 4f1cb4cfe81..a4fad8e8732 100644
--- a/addons/website_crm_partner_assign/models/crm_lead.py
+++ b/addons/website_crm_partner_assign/models/crm_lead.py
@@ -4,7 +4,7 @@
import random
from odoo import api, fields, models, _
-from odoo.exceptions import AccessDenied
+from odoo.exceptions import AccessDenied, AccessError
class CrmLead(models.Model):
@@ -270,3 +270,35 @@ class CrmLead(models.Model):
return {
'id': lead.id
}
+
+ #
+ # DO NOT FORWARD PORT IN MASTER
+ # instead, crm.lead should implement portal.mixin
+ #
+ @api.multi
+ def get_access_action(self, access_uid=None):
+ """ Instead of the classic form view, redirect to the online document for
+ portal users or if force_website=True in the context. """
+ self.ensure_one()
+
+ user, record = self.env.user, self
+ if access_uid:
+ try:
+ record.check_access_rights('read')
+ record.check_access_rule("read")
+ except AccessError:
+ return super(CrmLead, self).get_access_action(access_uid)
+ user = self.env['res.users'].sudo().browse(access_uid)
+ record = self.sudo(user)
+ if user.share or self.env.context.get('force_website'):
+ try:
+ record.check_access_rights('read')
+ record.check_access_rule('read')
+ except AccessError:
+ pass
+ else:
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': '/my/opportunity/%s' % record.id,
+ }
+ return super(CrmLead, self).get_access_action(access_uid)
diff --git a/addons/website_crm_partner_assign/tests/test_partner_assign.py b/addons/website_crm_partner_assign/tests/test_partner_assign.py
index dde78e0d410..77e65d1b8d0 100644
--- a/addons/website_crm_partner_assign/tests/test_partner_assign.py
+++ b/addons/website_crm_partner_assign/tests/test_partner_assign.py
@@ -151,3 +151,8 @@ class TestPartnerLeadPortal(TestCrmCases):
self.assertEqual(opportunity.team_id, salesmanteam, 'The created opportunity should have the same team as the salesman default team of the opportunity creator.')
self.assertEqual(opportunity.partner_assigned_id, self.portal_partner, 'Assigned Partner of created opportunity is the (portal) creator.')
+
+ def test_portal_mixin_url(self):
+ record_action = self.lead.get_access_action(self.portal_user.id)
+ self.assertEqual(record_action['url'], '/my/opportunity/%s' % self.lead.id)
+ self.assertEqual(record_action['type'], 'ir.actions.act_url')
diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py
index 2d6704bc6ab..ac325e501c5 100644
--- a/addons/website_sale/models/sale_order.py
+++ b/addons/website_sale/models/sale_order.py
@@ -119,11 +119,27 @@ class SaleOrder(models.Model):
'pricelist': order.pricelist_id.id,
})
product = self.env['product.product'].with_context(product_context).browse(product_id)
- pu = product.price
- if order.pricelist_id and order.partner_id:
- order_line = order._cart_find_product_line(product.id)
- if order_line:
- pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id)
+ discount = 0
+
+ if order.pricelist_id.discount_policy == 'without_discount':
+ # This part is pretty much a copy-paste of the method '_onchange_discount' of
+ # 'sale.order.line'.
+ price, rule_id = order.pricelist_id.with_context(product_context).get_product_price_rule(product, qty or 1.0, order.partner_id)
+ pu, currency = request.env['sale.order.line'].with_context(product_context)._get_real_price_currency(product, rule_id, qty, product.uom_id, order.pricelist_id.id)
+ if pu != 0:
+ if order.pricelist_id.currency_id != currency:
+ # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency
+ date = order.date_order or fields.Date.today()
+ pu = currency._convert(pu, order.pricelist_id.currency_id, order.company_id, date)
+ discount = (pu - price) / pu * 100
+ if discount < 0:
+ discount = 0
+ else:
+ pu = product.price
+ if order.pricelist_id and order.partner_id:
+ order_line = order._cart_find_product_line(product.id)
+ if order_line:
+ pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id)
return {
'product_id': product_id,
@@ -131,6 +147,7 @@ class SaleOrder(models.Model):
'order_id': order_id,
'product_uom': product.uom_id.id,
'price_unit': pu,
+ 'discount': discount,
}
@api.multi
diff --git a/odoo/__init__.py b/odoo/__init__.py
index 6bfe00b8499..aa27b79ae03 100644
--- a/odoo/__init__.py
+++ b/odoo/__init__.py
@@ -62,6 +62,23 @@ import time
if hasattr(time, 'tzset'):
time.tzset()
+# ----------------------------------------------------------
+# module babel hack
+# make sure unicode [world] territory is "001"
+# whereas "unitag" notation is "AA"
+# http://www.unicode.org/reports/tr35/#unicode_region_subtag
+# http://www.unicode.org/reports/tr35/#unicode_region_subtag_validity
+# BCP47: https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
+# ----------------------------------------------------------
+import babel
+_babelCoreParseLocale = babel.core.parse_locale
+def _babelCoreParseLocale_unitag(identifier, sep='_'):
+ lang, territory, script, variant = _babelCoreParseLocale(identifier, sep)
+ territory = '001' if territory == 'AA' else territory
+ return lang, territory, script, variant
+
+babel.core.parse_locale = _babelCoreParseLocale_unitag
+
#----------------------------------------------------------
# Shortcuts
#----------------------------------------------------------
diff --git a/odoo/addons/base/models/res_bank.py b/odoo/addons/base/models/res_bank.py
index a4e75a2532c..d9a0433ecc2 100644
--- a/odoo/addons/base/models/res_bank.py
+++ b/odoo/addons/base/models/res_bank.py
@@ -66,7 +66,7 @@ class ResPartnerBank(models.Model):
_name = 'res.partner.bank'
_rec_name = 'acc_number'
_description = 'Bank Accounts'
- _order = 'sequence'
+ _order = 'sequence, id'
@api.model
def get_supported_account_types(self):
@@ -84,7 +84,7 @@ class ResPartnerBank(models.Model):
bank_id = fields.Many2one('res.bank', string='Bank')
bank_name = fields.Char(related='bank_id.name', readonly=False)
bank_bic = fields.Char(related='bank_id.bic', readonly=False)
- sequence = fields.Integer()
+ sequence = fields.Integer(default=10)
currency_id = fields.Many2one('res.currency', string='Currency')
company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.user.company_id, ondelete='cascade')
qr_code_valid = fields.Boolean(string="Has all required arguments", compute="_validate_qr_code_arguments")
diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py
index b7f6a5e3f89..59b80f9185c 100644
--- a/odoo/addons/base/models/res_partner.py
+++ b/odoo/addons/base/models/res_partner.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
+import collections
import datetime
import hashlib
import pytz
@@ -272,8 +273,32 @@ class Partner(models.Model):
@api.depends('is_company', 'parent_id.commercial_partner_id')
def _compute_commercial_partner(self):
+ self.env.cr.execute("""
+ WITH RECURSIVE cpid(id, parent_id, commercial_partner_id, final) AS (
+ SELECT
+ id, parent_id, id,
+ (coalesce(is_company, false) OR parent_id IS NULL) as final
+ FROM res_partner
+ WHERE id = ANY(%s)
+ UNION
+ SELECT
+ cpid.id, p.parent_id, p.id,
+ (coalesce(is_company, false) OR p.parent_id IS NULL) as final
+ FROM res_partner p
+ JOIN cpid ON (cpid.parent_id = p.id)
+ WHERE NOT cpid.final
+ )
+ SELECT cpid.id, cpid.commercial_partner_id
+ FROM cpid
+ WHERE final AND id = ANY(%s);
+ """, [self.ids, self.ids])
+
+ d = dict(self.env.cr.fetchall())
for partner in self:
- if partner.is_company or not partner.parent_id:
+ fetched = d.get(partner.id)
+ if fetched is not None:
+ partner.commercial_partner_id = fetched
+ elif partner.is_company or not partner.parent_id:
partner.commercial_partner_id = partner
else:
partner.commercial_partner_id = partner.parent_id.commercial_partner_id
@@ -467,21 +492,25 @@ class Partner(models.Model):
self.update_address(onchange_vals)
# 2. To DOWNSTREAM: sync children
- if self.child_ids:
- # 2a. Commercial Fields: sync if commercial entity
- if self.commercial_partner_id == self:
- commercial_fields = self._commercial_fields()
- if any(field in values for field in commercial_fields):
- self._commercial_sync_to_children()
- for child in self.child_ids.filtered(lambda c: not c.is_company):
- if child.commercial_partner_id != self.commercial_partner_id :
- self._commercial_sync_to_children()
- break
- # 2b. Address fields: sync if address changed
- address_fields = self._address_fields()
- if any(field in values for field in address_fields):
- contacts = self.child_ids.filtered(lambda c: c.type == 'contact')
- contacts.update_address(values)
+ self._children_sync(values)
+
+ def _children_sync(self, values):
+ if not self.child_ids:
+ return
+ # 2a. Commercial Fields: sync if commercial entity
+ if self.commercial_partner_id == self:
+ commercial_fields = self._commercial_fields()
+ if any(field in values for field in commercial_fields):
+ self._commercial_sync_to_children()
+ for child in self.child_ids.filtered(lambda c: not c.is_company):
+ if child.commercial_partner_id != self.commercial_partner_id:
+ self._commercial_sync_to_children()
+ break
+ # 2b. Address fields: sync if address changed
+ address_fields = self._address_fields()
+ if any(field in values for field in address_fields):
+ contacts = self.child_ids.filtered(lambda c: c.type == 'contact')
+ contacts.update_address(values)
@api.multi
def _handle_first_contact_creation(self):
@@ -558,11 +587,53 @@ class Partner(models.Model):
tools.image_resize_images(vals, return_big=False)
tools.image_resize_images(vals, return_medium=False, return_small=False, preserve_aspect_ratio=True)
partners = super(Partner, self).create(vals_list)
+
+ if self.env.context.get('_partners_skip_fields_sync'):
+ return partners
+
for partner, vals in zip(partners, vals_list):
partner._fields_sync(vals)
partner._handle_first_contact_creation()
return partners
+ def _load_records_create(self, vals_list):
+ partners = super(Partner, self.with_context(_partners_skip_fields_sync=True))._load_records_create(vals_list)
+
+ # batch up first part of _fields_sync
+ # group partners by commercial_partner_id (if not self) and parent_id (if type == contact)
+ groups = collections.defaultdict(list)
+ for partner, vals in zip(partners, vals_list):
+ cp_id = None
+ if vals.get('parent_id') and partner.commercial_partner_id != partner:
+ cp_id = partner.commercial_partner_id.id
+
+ add_id = None
+ if partner.parent_id and partner.type == 'contact':
+ add_id = partner.parent_id.id
+ groups[(cp_id, add_id)].append(partner.id)
+
+ for (cp_id, add_id), children in groups.items():
+ # values from parents (commercial, regular) written to their common children
+ to_write = {}
+ # commercial fields from commercial partner
+ if cp_id:
+ to_write = self.browse(cp_id)._update_fields_values(self._commercial_fields())
+ # address fields from parent
+ if add_id:
+ parent = self.browse(add_id)
+ for f in self._address_fields():
+ v = parent[f]
+ if v:
+ to_write[f] = v.id if isinstance(v, models.BaseModel) else v
+ if to_write:
+ self.browse(children).write(to_write)
+
+ # do the second half of _fields_sync the "normal" way
+ for partner, vals in zip(partners, vals_list):
+ partner._children_sync(vals)
+ partner._handle_first_contact_creation()
+ return partners
+
@api.multi
def create_company(self):
self.ensure_one()
diff --git a/odoo/addons/base/tests/test_base.py b/odoo/addons/base/tests/test_base.py
index 5e1a24958bf..a823c65dc31 100644
--- a/odoo/addons/base/tests/test_base.py
+++ b/odoo/addons/base/tests/test_base.py
@@ -252,6 +252,41 @@ class TestBase(TransactionCase):
self.assertEqual(leaf111.address_get([]),
{'contact': branch11.id}, 'Invalid address resolution, branch11 should now be contact')
+ def test_commercial_partner_nullcompany(self):
+ """ The commercial partner is the first/nearest ancestor-or-self which
+ is a company or doesn't have a parent
+ """
+ P = self.env['res.partner']
+ p0 = P.create({'name': '0', 'email': '0'})
+ self.assertEqual(p0.commercial_partner_id, p0, "partner without a parent is their own commercial partner")
+
+ p1 = P.create({'name': '1', 'email': '1', 'parent_id': p0.id})
+ self.assertEqual(p1.commercial_partner_id, p0, "partner's parent is their commercial partner")
+ p12 = P.create({'name': '12', 'email': '12', 'parent_id': p1.id})
+ self.assertEqual(p12.commercial_partner_id, p0, "partner's GP is their commercial partner")
+
+ p2 = P.create({'name': '2', 'email': '2', 'parent_id': p0.id, 'is_company': True})
+ self.assertEqual(p2.commercial_partner_id, p2, "partner flagged as company is their own commercial partner")
+ p21 = P.create({'name': '21', 'email': '21', 'parent_id': p2.id})
+ self.assertEqual(p21.commercial_partner_id, p2, "commercial partner is closest ancestor with themselves as commercial partner")
+
+ p3 = P.create({'name': '3', 'email': '3', 'is_company': True})
+ self.assertEqual(p3.commercial_partner_id, p3, "being both parent-less and company should be the same as either")
+
+ notcompanies = p0 | p1 | p12 | p21
+ self.env.cr.execute('update res_partner set is_company=null where id = any(%s)', [notcompanies.ids])
+ for parent in notcompanies:
+ p = P.create({
+ 'name': parent.name + '_sub',
+ 'email': parent.email + '_sub',
+ 'parent_id': parent.id,
+ })
+ self.assertEqual(
+ p.commercial_partner_id,
+ parent.commercial_partner_id,
+ "check that is_company=null is properly handled when looking for ancestor"
+ )
+
def test_50_res_partner_commercial_sync(self):
res_partner = self.env['res.partner']
p0 = res_partner.create({'name': 'Sigurd Sunknife',
diff --git a/odoo/addons/base/views/res_bank_views.xml b/odoo/addons/base/views/res_bank_views.xml
index b83bcb46222..dcec863abfa 100644
--- a/odoo/addons/base/views/res_bank_views.xml
+++ b/odoo/addons/base/views/res_bank_views.xml
@@ -69,6 +69,7 @@
+
+
+ Recursive Relation Test View
+ test_testing_utilities.recursive
+
+
+
+
diff --git a/odoo/addons/test_testing_utilities/models.py b/odoo/addons/test_testing_utilities/models.py
index fbdec47a7cf..0c04bc06e8d 100644
--- a/odoo/addons/test_testing_utilities/models.py
+++ b/odoo/addons/test_testing_utilities/models.py
@@ -188,6 +188,13 @@ class O2MSub3(models.Model):
r.name = str(r.v)
+class O2MRecursive(models.Model):
+ _name = _description = 'test_testing_utilities.recursive'
+
+ one_to_many_id = fields.Many2one('test_testing_utilities.recursive', readonly=True)
+ many_to_one_ids = fields.One2many('test_testing_utilities.recursive', 'one_to_many_id', readonly=True)
+
+
class O2MOnchangeParent(models.Model):
_name = 'test_testing_utilities.onchange_parent'
_description = 'Testing Utilities Onchange Parent'
@@ -233,3 +240,24 @@ class O2MChangeSub(models.Model):
parent = fields.Many2one('test_testing_utilities.onchange_count')
name = fields.Char()
+
+class O2MReadonlySubfield(models.Model):
+ _name = 'o2m_readonly_subfield_parent'
+ _description = _name
+
+ line_ids = fields.One2many('o2m_readonly_subfield_child', 'parent_id')
+
+class O2MReadonlySubfieldChild(models.Model):
+ _name = _description = 'o2m_readonly_subfield_child'
+
+ name = fields.Char()
+ parent_id = fields.Many2one('o2m_readonly_subfield_parent')
+ f = fields.Integer(compute='_compute_f', inverse='_inverse_f', readonly=True)
+
+ @api.depends('name')
+ def _compute_f(self):
+ for r in self:
+ r.f = len(r.name) if r.name else 0
+
+ def _inverse_f(self):
+ raise AssertionError("Inverse of f should not be called")
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 c13dbd73a60..0191a4da7ad 100644
--- a/odoo/addons/test_testing_utilities/tests/test_form_impl.py
+++ b/odoo/addons/test_testing_utilities/tests/test_form_impl.py
@@ -359,7 +359,7 @@ class TestO2M(TransactionCase):
self.assertEqual(
[get(s) for s in r.subs],
- [("5", 2, 5)]
+ [("5", 0, 5)]
)
def test_o2m_inner_default(self):
@@ -409,7 +409,10 @@ class TestO2M(TransactionCase):
self.assertEqual(sub.value, 1)
self.assertEqual(sub.v, 1)
- def test_m2o_readonly(self):
+ def test_readonly_o2m(self):
+ """ Tests that o2m fields flagged as readonly (readonly="1" in the
+ view) can't be written to
+ """
r = self.env['test_testing_utilities.parent'].create({
'subs': [(0, 0, {})]
})
@@ -422,6 +425,20 @@ class TestO2M(TransactionCase):
with self.assertRaises(AssertionError):
f.subs.remove(index=0)
+ def test_o2m_readonly_subfield(self):
+ """ Tests that readonly is applied to the field of the o2m = not sent
+ as part of the create / write values
+ """
+ f = Form(self.env['o2m_readonly_subfield_parent'])
+ with f.line_ids.new() as new_line:
+ new_line.name = "ok"
+ self.assertEqual(new_line.f, 2)
+ r = f.save()
+ self.assertEqual(
+ (r.line_ids.name, r.line_ids.f),
+ ('ok', 2)
+ )
+
def test_o2m_dyn_onchange(self):
f = Form(self.env['test_testing_utilities.onchange_parent'], view='test_testing_utilities.m2o_onchange_view')
@@ -467,6 +484,9 @@ class TestO2M(TransactionCase):
self.assertEqual(commands(), [0, 2, 2], "should contain 1 '0' command and 2 deletions")
self.assertEqual(len(r.line_ids), 1)
+ def test_o2m_self_recursive(self):
+ Form(self.env['test_testing_utilities.recursive'], view='test_testing_utilities.o2m_recursive_relation_view')
+
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
diff --git a/odoo/tests/common.py b/odoo/tests/common.py
index f5b04f7e307..6f9472f6ee5 100644
--- a/odoo/tests/common.py
+++ b/odoo/tests/common.py
@@ -1110,7 +1110,7 @@ class Form(object):
else:
self._init_from_defaults(self._model)
- def _o2m_set_edition_view(self, descr, node):
+ def _o2m_set_edition_view(self, descr, node, level):
default_view = next(
(m for m in node.get('mode', 'tree').split(',') if m != 'form'),
'tree'
@@ -1138,17 +1138,10 @@ class Form(object):
edition = views['tree']
edition['tree'] = subarch
- self._process_fvg(submodel, edition)
+ # don't recursively process o2ms in o2ms
+ self._process_fvg(submodel, edition, level=level-1)
descr['views']['edition'] = edition
- def _get_node(self, f):
- """ Find etree node for the field ``f`` in the current arch
- """
- return next(
- n for n in self._view['tree'].iter('field')
- if n.get('name') == f
- )
-
def __str__(self):
return "<%s %s(%s)>" % (
type(self).__name__,
@@ -1156,16 +1149,16 @@ class Form(object):
self._values.get('id', False),
)
- def _process_fvg(self, model, fvg):
+ def _process_fvg(self, model, fvg, level=2):
""" Post-processes to augment the fields_view_get with:
* an id field (may not be present if not in the view but needed)
* pre-processed modifiers (map of modifier name to json-loaded domain)
* pre-processed onchanges list
"""
- fvg['fields']['id'] = {'type': 'id'}
+ fvg['fields'].setdefault('id', {'type': 'id'})
# pre-resolve modifiers & bind to arch toplevel
- modifiers = fvg['modifiers'] = {}
+ modifiers = fvg['modifiers'] = {'id': {'required': False, 'readonly': True}}
contexts = fvg['contexts'] = {}
order = fvg['fields_ordered'] = []
for f in fvg['tree'].xpath('//field[not(ancestor::field)]'):
@@ -1181,10 +1174,9 @@ class Form(object):
contexts[fname] = ctx
descr = fvg['fields'].get(fname) or {'type': None}
- if descr['type'] == 'one2many':
- self._o2m_set_edition_view(descr, f)
+ if level and descr['type'] == 'one2many':
+ self._o2m_set_edition_view(descr, f, level)
- fvg['modifiers']['id'] = {'required': False, 'readonly': True}
fvg['onchange'] = model._onchange_spec(fvg)
def _init_from_defaults(self, model):
@@ -1242,12 +1234,13 @@ class Form(object):
return O2MProxy(self, field)
return v
- def _get_modifier(self, field, modifier, default=False):
- d = self._view['modifiers'][field].get(modifier, default)
+ def _get_modifier(self, field, modifier, default=False, modmap=None, vals=None):
+ d = (modmap or self._view['modifiers'])[field].get(modifier, default)
if isinstance(d, bool):
return d
- vals = self._values
+ if vals is None:
+ vals = self._values
stack = []
for it in reversed(d):
if it == '!':
@@ -1262,7 +1255,9 @@ class Form(object):
stack.append(e1 or e2)
elif isinstance(it, list):
f, op, val = it
- field_val = vals[f]
+ # hack-ish handling of parent.
modifiers
+ f, n = re.subn(r'^parent\.', '', f, 1)
+ field_val = (vals['•parent•'] if n else vals)[f]
stack.append(self._OPS[op](field_val, val))
else:
raise ValueError("Unknown domain element %s" % it)
@@ -1361,7 +1356,8 @@ class Form(object):
load/save
"""
values = {}
- for f in self._view['fields']:
+ fields = self._view['fields']
+ for f in fields:
v = self._values[f]
if self._get_modifier(f, 'required'):
assert v is not False, "{} is a required field".format(f)
@@ -1371,10 +1367,34 @@ class Form(object):
continue
if self._get_modifier(f, 'readonly'):
- node = self._get_node(f)
+ node = _get_node(self._view, f)
if not node.get('force_save'):
continue
+ if fields[f]['type'] == 'one2many':
+ view = fields[f]['views']['edition']
+ modifiers = view['modifiers']
+ oldvals = v
+ v = []
+
+ nodes = {
+ n.get('name'): n
+ for n in view['tree'].iter('field')
+ }
+ nodes['id'] = etree.Element('field', attrib={'name': 'id'})
+
+ for (c, rid, vs) in oldvals:
+ if c in (0, 1):
+ items = list(getattr(vs, 'changed_items', vs.items)())
+ # FIXME: should be more extensive processing of o2m defaults
+ vs.setdefault('id', False)
+ vs['•parent•'] = self._values
+ vs = {
+ k: v for k, v in items
+ if nodes[k].get('force_save') or not self._get_modifier(k, 'readonly', modmap=modifiers, vals=vs)
+ }
+ v.append((c, rid, vs))
+
values[f] = v
return values
@@ -1409,11 +1429,15 @@ class Form(object):
values = {}
for k, v in self._values.items():
if f[k]['type'] == 'one2many':
- # web client sends a 4 for unmodified o2m rows
- values[k] = [
- (4, rid, False) if (c == 1 and not vs) else (c, rid, vs)
- for (c, rid, vs) in v
- ]
+ it = values[k] = []
+ for (c, rid, vs) in v:
+ if c == 1 and not vs:
+ # web client sends a 4 for unmodified o2m rows
+ it.append((4, rid, False))
+ elif c == 1 and isinstance(vs, UpdateDict):
+ it.append((1, rid, dict(vs.changed_items())))
+ else:
+ it.append((c, rid, vs))
else:
values[k] = v
return values
@@ -1512,9 +1536,16 @@ class O2MForm(Form):
if self._index is None:
commands.append((0, 0, values))
else:
- (c, _, vs) = commands[proxy._command_index(self._index)]
- assert c in (0, 1)
- vs.update(values)
+ index = proxy._command_index(self._index)
+ (c, id_, vs) = commands[index]
+ if c == 0:
+ vs.update(values)
+ elif c == 1:
+ vs = UpdateDict(vs)
+ vs.update(values)
+ commands[index] = (1, id_, vs)
+ else:
+ raise AssertionError("Expected command type 0 or 1, found %s" % c)
# FIXME: should be called when performing on change => value needs to be serialised into parent every time?
proxy._parent._perform_onchange([proxy._field])
@@ -1523,20 +1554,31 @@ class O2MForm(Form):
""" Validates values and returns only fields modified since
load/save
"""
- values = {}
- for f in self._view['fields']:
- v = self._values[f]
- if self._get_modifier(f, 'required'):
- assert v is not False, "{} is a required field".format(f)
+ values = UpdateDict(self._values)
+ values._changed.update(self._changed)
+
+ for f in self._view['fields']:
+ if self._get_modifier(f, 'required'):
+ assert self._values[f] is not False, "{} is a required field".format(f)
- # skip unmodified fields
- if f not in self._changed:
- continue
- # if self._get_modifier(f, 'readonly'):
- # continue
- values[f] = v
return values
+class UpdateDict(dict):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._changed = set()
+
+ def changed_items(self):
+ return (
+ (k, v) for k, v in self.items()
+ if k in self._changed
+ )
+
+ def update(self, *args, **kw):
+ super().update(*args, **kw)
+ if args and isinstance(args[0], UpdateDict):
+ self._changed.update(args[0]._changed)
+
class X2MProxy(object):
_parent = None
_field = None
@@ -1738,6 +1780,13 @@ def record_to_values(fields, record):
r[f] = v
return r
+def _get_node(view, f, *arg):
+ """ Find etree node for the field ``f`` in the view's arch
+ """
+ return next((
+ n for n in view['tree'].iter('field')
+ if n.get('name') == f
+ ), *arg)
def tagged(*tags):
"""
diff --git a/odoo/tools/date_utils.py b/odoo/tools/date_utils.py
index 26bfdabd835..702913ff04e 100644
--- a/odoo/tools/date_utils.py
+++ b/odoo/tools/date_utils.py
@@ -73,6 +73,10 @@ def get_fiscal_year(date, day=31, month=12):
date_from = date_to + relativedelta(days=1)
max_day = calendar.monthrange(date_to.year + 1, date_to.month)[1]
date_to = type(date)(date.year + 1, month, min(day, max_day))
+
+ # Force at 29 February instead of 28 in case of leap year.
+ if date_to.month == 2 and date_to.day == 28 and max_day == 29:
+ date_to += relativedelta(days=1)
return date_from, date_to