diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-04-12 13:37:44 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-04-12 13:37:44 +0200 |
| commit | 0a09a4d8a819ef06db47e798f0465e500004e4c1 (patch) | |
| tree | b9d66d60a1e9b5ff6030be62fff89df410b744ee /odoo | |
| parent | db97bb7f1136dc3ebde9924b79364a698a63765d (diff) | |
| parent | 120359bf0c98191051ac19ae2e5fde53ca7d2ae5 (diff) | |
[MERGE] forward port branch saas-12.3 up to 120359bf0c9
Diffstat (limited to 'odoo')
| -rw-r--r-- | odoo/addons/base/data/res.lang.csv | 2 | ||||
| -rw-r--r-- | odoo/addons/base/models/ir_mail_server.py | 2 | ||||
| -rw-r--r-- | odoo/addons/base/models/ir_model.py | 11 | ||||
| -rw-r--r-- | odoo/addons/base/models/ir_module.py | 9 | ||||
| -rw-r--r-- | odoo/addons/base/models/ir_translation.py | 41 | ||||
| -rw-r--r-- | odoo/addons/base/models/res_bank.py | 4 | ||||
| -rw-r--r-- | odoo/addons/base/models/res_currency.py | 2 | ||||
| -rw-r--r-- | odoo/addons/base/models/res_partner.py | 103 | ||||
| -rw-r--r-- | odoo/addons/base/tests/test_base.py | 35 | ||||
| -rw-r--r-- | odoo/addons/base/views/res_bank_views.xml | 3 | ||||
| -rw-r--r-- | odoo/addons/test_main_flows/static/src/js/tour.js | 2 | ||||
| -rw-r--r-- | odoo/addons/test_testing_utilities/ir.model.access.csv | 3 | ||||
| -rw-r--r-- | odoo/addons/test_testing_utilities/menu.xml | 13 | ||||
| -rw-r--r-- | odoo/addons/test_testing_utilities/models.py | 28 | ||||
| -rw-r--r-- | odoo/addons/test_testing_utilities/tests/test_form_impl.py | 24 | ||||
| -rw-r--r-- | odoo/fields.py | 6 | ||||
| -rw-r--r-- | odoo/models.py | 5 | ||||
| -rw-r--r-- | odoo/tests/common.py | 136 | ||||
| -rw-r--r-- | odoo/tools/date_utils.py | 4 |
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 |
