summaryrefslogtreecommitdiff
path: root/odoo
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-04-12 13:37:44 +0200
committerChristophe Simonis <chs@odoo.com>2019-04-12 13:37:44 +0200
commit0a09a4d8a819ef06db47e798f0465e500004e4c1 (patch)
treeb9d66d60a1e9b5ff6030be62fff89df410b744ee /odoo
parentdb97bb7f1136dc3ebde9924b79364a698a63765d (diff)
parent120359bf0c98191051ac19ae2e5fde53ca7d2ae5 (diff)
[MERGE] forward port branch saas-12.3 up to 120359bf0c9
Diffstat (limited to 'odoo')
-rw-r--r--odoo/addons/base/data/res.lang.csv2
-rw-r--r--odoo/addons/base/models/ir_mail_server.py2
-rw-r--r--odoo/addons/base/models/ir_model.py11
-rw-r--r--odoo/addons/base/models/ir_module.py9
-rw-r--r--odoo/addons/base/models/ir_translation.py41
-rw-r--r--odoo/addons/base/models/res_bank.py4
-rw-r--r--odoo/addons/base/models/res_currency.py2
-rw-r--r--odoo/addons/base/models/res_partner.py103
-rw-r--r--odoo/addons/base/tests/test_base.py35
-rw-r--r--odoo/addons/base/views/res_bank_views.xml3
-rw-r--r--odoo/addons/test_main_flows/static/src/js/tour.js2
-rw-r--r--odoo/addons/test_testing_utilities/ir.model.access.csv3
-rw-r--r--odoo/addons/test_testing_utilities/menu.xml13
-rw-r--r--odoo/addons/test_testing_utilities/models.py28
-rw-r--r--odoo/addons/test_testing_utilities/tests/test_form_impl.py24
-rw-r--r--odoo/fields.py6
-rw-r--r--odoo/models.py5
-rw-r--r--odoo/tests/common.py136
-rw-r--r--odoo/tools/date_utils.py4
19 files changed, 364 insertions, 69 deletions
diff --git a/odoo/addons/base/data/res.lang.csv b/odoo/addons/base/data/res.lang.csv
index b28e9dfc5d6..7f864be4fbe 100644
--- a/odoo/addons/base/data/res.lang.csv
+++ b/odoo/addons/base/data/res.lang.csv
@@ -1,7 +1,7 @@
"id","name","code","iso_code","direction","grouping","decimal_point","thousands_sep","date_format","time_format","translatable","week_start"
"base.lang_en","English (US)","en_US","en","Left-to-Right","[3,0]",".",",","%m/%d/%Y","%H:%M:%S","True","7"
"base.lang_am_ET","Amharic / አምሃርኛ","am_ET","am_ET","Left-to-Right","[3,0]",".",",","%d/%m/%Y","%I:%M:%S","True","7"
-"base.lang_ar","Arabic / الْعَرَبيّة","ar_AA","ar","Right-to-Left","[3,0]",".",",","%d %b, %Y","%I:%M:%S","True","6"
+"base.lang_ar","Arabic / الْعَرَبيّة","ar_001","ar","Right-to-Left","[3,0]",".",",","%d %b, %Y","%I:%M:%S","True","6"
"base.lang_ar_SY","Arabic (Syria) / الْعَرَبيّة","ar_SY","ar_SY","Right-to-Left","[3,0]",".",",","%d %b, %Y","%I:%M:%S","True","6"
"base.lang_eu_ES","Basque / Euskara","eu_ES","eu_ES","Left-to-Right","[]",",",,"%a, %Y.eko %bren %da","%H:%M:%S","True","1"
"base.lang_bn_IN","Bengali / বাংলা","bn_IN","bn_IN","Left-to-Right","[]",",",,"%A %d %b %Y","%I:%M:%S","True","1"
diff --git a/odoo/addons/base/models/ir_mail_server.py b/odoo/addons/base/models/ir_mail_server.py
index b7597ebaa77..6773e1f61cc 100644
--- a/odoo/addons/base/models/ir_mail_server.py
+++ b/odoo/addons/base/models/ir_mail_server.py
@@ -483,6 +483,8 @@ class IrMailServer(models.Model):
# do not quit() a pre-established smtp_session
if not smtp_session:
smtp.quit()
+ except smtplib.SMTPServerDisconnected:
+ raise
except Exception as e:
params = (ustr(smtp_server), e.__class__.__name__, ustr(e))
msg = _("Mail delivery failed via SMTP server '%s'.\n%s: %s") % params
diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py
index eb03dfcafcd..b7a923dfa49 100644
--- a/odoo/addons/base/models/ir_model.py
+++ b/odoo/addons/base/models/ir_model.py
@@ -185,6 +185,11 @@ class IrModel(models.Model):
self._cr.execute('DROP VIEW "%s"' % table)
elif kind == 'r':
self._cr.execute('DROP TABLE "%s" CASCADE' % table)
+ # discard all translations for this model
+ self._cr.execute("""
+ DELETE FROM ir_translation
+ WHERE type IN ('model', 'model_terms') AND name LIKE %s
+ """, [model.model + ',%'])
else:
_logger.warning('The model %s could not be dropped because it did not exist in the registry.', model.model)
return True
@@ -574,6 +579,12 @@ class IrModelFields(models.Model):
tables_to_drop.add(rel_name)
if field.state == 'manual' and is_model:
model._pop_field(field.name)
+ if field.translate:
+ # discard all translations for this field
+ self._cr.execute("""
+ DELETE FROM ir_translation
+ WHERE type IN ('model', 'model_terms') AND name=%s
+ """, ['%s,%s' % (field.model, field.name)])
if tables_to_drop:
# drop the relation tables that are not used by other fields
diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py
index 27c81dd2702..ddccfb2c3ca 100644
--- a/odoo/addons/base/models/ir_module.py
+++ b/odoo/addons/base/models/ir_module.py
@@ -19,6 +19,7 @@ from docutils.core import publish_string
from docutils.transforms import Transform, writer_aux
from docutils.writers.html4css1 import Writer
import lxml.html
+import psycopg2
import odoo
from odoo import api, fields, models, modules, tools, _
@@ -542,6 +543,14 @@ class Module(models.Model):
@api.multi
def _button_immediate_function(self, function):
+ try:
+ # This is done because the installation/uninstallation/upgrade can modify a currently
+ # running cron job and prevent it from finishing, and since the ir_cron table is locked
+ # during execution, the lock won't be released until timeout.
+ self._cr.execute("SELECT * FROM ir_cron FOR UPDATE NOWAIT")
+ except psycopg2.OperationalError:
+ raise UserError(_("The server is busy right now, module operations are not possible at"
+ " this time, please try again later."))
function(self)
self._cr.commit()
diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py
index 2d9744abada..bf560adf467 100644
--- a/odoo/addons/base/models/ir_translation.py
+++ b/odoo/addons/base/models/ir_translation.py
@@ -670,6 +670,47 @@ class IrTranslation(models.Model):
self._modified_model(field.model_name)
@api.model
+ def _upsert_translations(self, vals_list):
+ """ Insert or update translations of type 'model' or 'model_terms'.
+
+ This method is used for creations of translations where the given
+ ``vals_list`` is trusted to be the right values and potential
+ conflicts should be updated to the new given value.
+ """
+ rows_by_type = defaultdict(list)
+ for vals in vals_list:
+ rows_by_type[vals['type']].append((
+ vals['name'], vals['lang'], vals['res_id'], vals['src'], vals['type'],
+ vals.get('module'), vals['value'], vals.get('state'), vals.get('comments'),
+ ))
+
+ if rows_by_type['model']:
+ query = """
+ INSERT INTO ir_translation (name, lang, res_id, src, type,
+ module, value, state, comments)
+ VALUES {}
+ ON CONFLICT (type, lang, name, res_id) WHERE type='model'
+ DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
+ (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
+ EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
+ WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
+ """.format(", ".join(["%s"] * len(rows_by_type['model'])))
+ self.env.cr.execute(query, rows_by_type['model'])
+
+ if rows_by_type['model_terms']:
+ query = """
+ INSERT INTO ir_translation (name, lang, res_id, src, type,
+ module, value, state, comments)
+ VALUES {}
+ ON CONFLICT (type, name, lang, res_id, md5(src))
+ DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
+ (EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
+ EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
+ WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
+ """.format(", ".join(["%s"] * len(rows_by_type['model_terms'])))
+ self.env.cr.execute(query, rows_by_type['model_terms'])
+
+ @api.model
def translate_fields(self, model, id, field=None):
""" Open a view for translating the field(s) of the record (model, id). """
main_lang = 'en_US'
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_currency.py b/odoo/addons/base/models/res_currency.py
index c75ddfe0021..09a6d019f38 100644
--- a/odoo/addons/base/models/res_currency.py
+++ b/odoo/addons/base/models/res_currency.py
@@ -115,7 +115,7 @@ class Currency(models.Model):
fractional_value = int(parts[2] or 0)
lang_code = self.env.context.get('lang') or self.env.user.lang
- lang = self.env['res.lang'].search([('code', '=', lang_code)])
+ lang = self.env['res.lang'].with_context(active_test=False).search([('code', '=', lang_code)])
amount_words = tools.ustr('{amt_value} {amt_word}').format(
amt_value=_num2words(integer_value, lang=lang.iso_code),
amt_word=self.currency_unit_label,
diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py
index 603fbd6c4d2..49979b9e2c5 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
@@ -286,8 +287,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
@@ -483,21 +508,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):
@@ -574,11 +603,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 @@
<form string="Bank account">
<group>
<group>
+ <field name="sequence" invisible="1"/>
<field name="acc_number"/>
<field name="acc_type"/>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
@@ -89,7 +90,7 @@
<field name="model">res.partner.bank</field>
<field name="arch" type="xml">
<tree string="Bank Accounts">
- <field name="sequence" invisible="1"/>
+ <field name="sequence" widget="handle"/>
<field name="acc_number"/>
<field name="bank_name"/>
<field name="company_id" groups="base.group_multi_company"/>
diff --git a/odoo/addons/test_main_flows/static/src/js/tour.js b/odoo/addons/test_main_flows/static/src/js/tour.js
index 4b08c5b1b22..5113a81dc6e 100644
--- a/odoo/addons/test_main_flows/static/src/js/tour.js
+++ b/odoo/addons/test_main_flows/static/src/js/tour.js
@@ -773,7 +773,7 @@ tour.register('main_flow_tour', {
position: "right",
}, {
edition: "enterprise",
- trigger: ".button_close_statement",
+ trigger: ".button_back_to_statement",
content: _t('<p><b>Close this statement.</p>'),
position: "bottom",
}]);
diff --git a/odoo/addons/test_testing_utilities/ir.model.access.csv b/odoo/addons/test_testing_utilities/ir.model.access.csv
index c069b568ad4..c43b736c531 100644
--- a/odoo/addons/test_testing_utilities/ir.model.access.csv
+++ b/odoo/addons/test_testing_utilities/ir.model.access.csv
@@ -12,7 +12,10 @@ access_test_testing_utilities_parent,access_test_testing_utilities_parent,model_
access_test_testing_utilities_sub,access_test_testing_utilities_sub,model_test_testing_utilities_sub,,1,0,0,0
access_test_testing_utilities_sub2,access_test_testing_utilities_sub2,model_test_testing_utilities_sub2,,1,0,0,0
access_test_testing_utilities_sub3,access_test_testing_utilities_sub3,model_test_testing_utilities_sub3,,1,0,0,0
+access_test_testing_utilities_recursive,access_test_testing_utilities_recursive,model_test_testing_utilities_recursive,,1,0,0,0
access_test_testing_utilities_onchange_parent,access_test_testing_utilities_onchange_parent,model_test_testing_utilities_onchange_parent,,1,0,0,0
access_test_testing_utilities_onchange_line,access_test_testing_utilities_onchange_line,model_test_testing_utilities_onchange_line,,1,0,0,0
access_model_test_testing_utilities_onchange_count,access_model_test_testing_utilities_onchange_count,model_test_testing_utilities_onchange_count,,1,1,1,1
access_test_testing_utilities_onchange_count_sub,access_test_testing_utilities_onchange_count_sub,model_test_testing_utilities_onchange_count_sub,,1,1,1,1
+access_o2m_readonly_subfield_parent,access_o2m_readonly_subfield_parent,model_o2m_readonly_subfield_parent,,1,1,1,1
+access_o2m_readonly_subfield_child,access_o2m_readonly_subfield_child,model_o2m_readonly_subfield_child,,1,1,1,1
diff --git a/odoo/addons/test_testing_utilities/menu.xml b/odoo/addons/test_testing_utilities/menu.xml
index fa5f4f37163..491a74dda46 100644
--- a/odoo/addons/test_testing_utilities/menu.xml
+++ b/odoo/addons/test_testing_utilities/menu.xml
@@ -151,4 +151,17 @@
</form>
</field>
</record>
+
+ <record id="o2m_recursive_relation_view" model="ir.ui.view">
+ <field name="name">Recursive Relation Test View</field>
+ <field name="model">test_testing_utilities.recursive</field>
+ <field name="arch" type="xml">
+ <form>
+ <sheet>
+ <field name="one_to_many_id"/>
+ <field name="many_to_one_ids"/>
+ </sheet>
+ </form>
+ </field>
+ </record>
</odoo>
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/fields.py b/odoo/fields.py
index 8d977e6cde3..5d0cf903232 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -945,7 +945,11 @@ class Field(MetaField('DummyField', (object,), {})):
"""
indexname = '%s_%s_index' % (model._table, self.name)
if self.index:
- sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name])
+ try:
+ with model._cr.savepoint():
+ sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name])
+ except psycopg2.OperationalError:
+ _schema.error("Unable to add index for %s", self)
else:
sql.drop_index(model._cr, indexname, model._table)
diff --git a/odoo/models.py b/odoo/models.py
index 7178f27f24c..8ebd58f4b1d 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -4311,11 +4311,12 @@ Fields:
if vals['lang'] == old.env.lang and field.translate is True:
# force a source if the new_val was not changed by copy override
if new_val == old[name]:
- vals['source'] = old_wo_lang[name]
+ new_wo_lang[name] = old_wo_lang[name]
+ vals['src'] = old_wo_lang[name]
# the value should be the new value (given by copy())
vals['value'] = new_val
vals_list.append(vals)
- Translation.create(vals_list)
+ Translation._upsert_translations(vals_list)
@api.multi
@api.returns('self', lambda value: value.id)
diff --git a/odoo/tests/common.py b/odoo/tests/common.py
index aaad8b8e9e5..fade4e747cb 100644
--- a/odoo/tests/common.py
+++ b/odoo/tests/common.py
@@ -1126,7 +1126,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'
@@ -1154,17 +1154,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__,
@@ -1172,16 +1165,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)]'):
@@ -1197,10 +1190,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):
@@ -1258,12 +1250,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 == '!':
@@ -1278,7 +1271,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.<field> 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)
@@ -1377,7 +1372,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)
@@ -1387,10 +1383,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
@@ -1425,11 +1445,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
@@ -1446,12 +1470,13 @@ class Form(object):
return []
v = []
- c = {t[1]: t[2] for t in current if t[0] in (1, 2)} if current else {}
+ c = {t[1] for t in current if t[0] in (1, 2)} if current else set()
# which view should this be???
subfields = descr['views']['edition']['fields']
+ # TODO: simplistic, unlikely to work if e.g. there's a 5 inbetween other commands
for command in value:
if command[0] in (0, 1):
- c.pop(command[1], None) # remove record from currents
+ c.discard(command[1])
v.append((command[0], command[1], {
k: self._cleanup_onchange(
subfields[k], v, None
@@ -1460,9 +1485,11 @@ class Form(object):
if k in subfields
}))
elif command[0] == 2:
+ c.discard(command[1])
v.append((2, command[1], False))
elif command[0] == 4:
- v.append((1, command[1], c.pop(command[1], {})))
+ c.discard(command[1])
+ v.append((1, command[1], {}))
elif command[0] == 5:
v = []
# explicitly mark all non-relinked (or modified) records as deleted
@@ -1528,9 +1555,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])
@@ -1539,20 +1573,31 @@ class O2MForm(Form):
""" Validates values and returns only fields modified since
load/save
"""
- values = {}
+ values = UpdateDict(self._values)
+ values._changed.update(self._changed)
+
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)
+ 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
@@ -1754,6 +1799,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