diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py index 269091f284335215bdd643464488eeb3688b3f23..332c16454d29486235f1742ef5f5087c2315027c 100644 --- a/odoo/addons/base/models/ir_model.py +++ b/odoo/addons/base/models/ir_model.py @@ -970,6 +970,7 @@ class IrModelConstraint(models.Model): name = fields.Char(string='Constraint', required=True, index=True, help="PostgreSQL constraint or foreign key name.") definition = fields.Char(help="PostgreSQL constraint definition") + message = fields.Char(help="Error message returned when the constraint is violated.", translate=True) model = fields.Many2one('ir.model', required=True, ondelete="cascade", index=True) module = fields.Many2one('ir.module.module', required=True, index=True) type = fields.Char(string='Constraint Type', required=True, size=1, index=True, @@ -1033,9 +1034,10 @@ class IrModelConstraint(models.Model): default['name'] = self.name + '_copy' return super(IrModelConstraint, self).copy(default) - def _reflect_constraint(self, model, conname, type, definition, module): - """ Reflect the given constraint, to make it possible to delete it later - when the module is uninstalled. ``type`` is either 'f' or 'u' + def _reflect_constraint(self, model, conname, type, definition, module, message=None): + """ Reflect the given constraint, and return its corresponding record. + The reflection makes it possible to remove a constraint when its + corresponding module is uninstalled. ``type`` is either 'f' or 'u' depending on the constraint being a foreign key or not. """ if not module: @@ -1044,23 +1046,28 @@ class IrModelConstraint(models.Model): return assert type in ('f', 'u') cr = self._cr - query = """ SELECT type, definition + query = """ SELECT c.id, type, definition, message FROM ir_model_constraint c, ir_module_module m WHERE c.module=m.id AND c.name=%s AND m.name=%s """ cr.execute(query, (conname, module)) cons = cr.dictfetchone() if not cons: query = """ INSERT INTO ir_model_constraint - (name, date_init, date_update, module, model, type, definition) + (name, date_init, date_update, module, model, type, definition, message) VALUES (%s, now() AT TIME ZONE 'UTC', now() AT TIME ZONE 'UTC', (SELECT id FROM ir_module_module WHERE name=%s), - (SELECT id FROM ir_model WHERE model=%s), %s, %s) """ - cr.execute(query, (conname, module, model._name, type, definition)) - elif cons['type'] != type or (definition and cons['definition'] != definition): + (SELECT id FROM ir_model WHERE model=%s), %s, %s, %s) + RETURNING id""" + cr.execute(query, (conname, module, model._name, type, definition, message)) + return self.browse(cr.fetchone()[0]) + + cons_id = cons.pop('id') + if cons != dict(type=type, definition=definition, message=message): query = """ UPDATE ir_model_constraint - SET date_update=now() AT TIME ZONE 'UTC', type=%s, definition=%s - WHERE name=%s AND module=(SELECT id FROM ir_module_module WHERE name=%s) """ - cr.execute(query, (type, definition, conname, module)) + SET date_update=now() AT TIME ZONE 'UTC', type=%s, definition=%s, message=%s + WHERE id=%s""" + cr.execute(query, (type, definition, message, cons_id)) + return self.browse(cons_id) def _reflect_model(self, model): """ Reflect the _sql_constraints of the given model. """ @@ -1075,10 +1082,16 @@ class IrModelConstraint(models.Model): for constraint in getattr(cls, '_local_sql_constraints', ()) } - for (key, definition, _) in model._sql_constraints: + data_list = [] + for (key, definition, message) in model._sql_constraints: conname = '%s_%s' % (model._table, key) module = constraint_module.get(key) - self._reflect_constraint(model, conname, 'u', cons_text(definition), module) + record = self._reflect_constraint(model, conname, 'u', cons_text(definition), module, message) + if record: + xml_id = '%s.constraint_%s' % (module, conname) + data_list.append(dict(xml_id=xml_id, record=record)) + + self.env['ir.model.data']._update_xmlids(data_list) class IrModelRelation(models.Model): diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py index 5b5feca350ebdd1c3d5bd8a54f7e730489234bdc..a381f871fe29c6d79396480949670ffe297597af 100644 --- a/odoo/addons/base/models/ir_translation.py +++ b/odoo/addons/base/models/ir_translation.py @@ -18,7 +18,6 @@ TRANSLATION_TYPE = [ ('selection', 'Selection'), ('code', 'Code'), ('constraint', 'Constraint'), - ('sql_constraint', 'SQL Constraint') ] @@ -127,9 +126,9 @@ class IrTranslationImport(object): cr.execute(""" INSERT INTO %s(name, lang, res_id, src, type, value, module, state, comments) SELECT name, lang, res_id, src, type, value, module, state, comments FROM %s - WHERE type IN ('selection', 'constraint', 'sql_constraint') + WHERE type IN ('selection', 'constraint') AND noupdate IS NOT TRUE - ON CONFLICT (type, lang, name, md5(src)) WHERE type IN ('selection', 'constraint', 'sql_constraint') + ON CONFLICT (type, lang, name, md5(src)) WHERE type IN ('selection', 'constraint') 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 != ''; """ % (self._model_table, self._table)) @@ -259,7 +258,7 @@ class IrTranslation(models.Model): if not tools.index_exists(self._cr, 'ir_translation_model_unique'): self._cr.execute("CREATE UNIQUE INDEX ir_translation_model_unique ON ir_translation (type, lang, name, res_id) WHERE type = 'model'") if not tools.index_exists(self._cr, 'ir_translation_selection_unique'): - self._cr.execute("CREATE UNIQUE INDEX ir_translation_selection_unique ON ir_translation (type, lang, name, md5(src)) WHERE type IN ('selection', 'constraint', 'sql_constraint')") + self._cr.execute("CREATE UNIQUE INDEX ir_translation_selection_unique ON ir_translation (type, lang, name, md5(src)) WHERE type IN ('selection', 'constraint')") return res @api.model diff --git a/odoo/addons/base/views/ir_model_views.xml b/odoo/addons/base/views/ir_model_views.xml index 2f557eb23868beb0f9e737889f28ea01242e43c3..6c5d894283f5a7717b2bb5fe32c6e7b28a520bc5 100644 --- a/odoo/addons/base/views/ir_model_views.xml +++ b/odoo/addons/base/views/ir_model_views.xml @@ -468,6 +468,9 @@ <field name="date_update" /> <field name="date_init" /> </group> + <group> + <field name="message"/> + </group> </sheet> </form> </field> diff --git a/odoo/addons/test_new_api/assets.xml b/odoo/addons/test_new_api/assets.xml index 8d0103a311daf415bf9dfbc8a0b3d9b7e0b661cc..8bd75bceab5a642b5fb0c1e181fd83f6705c182d 100644 --- a/odoo/addons/test_new_api/assets.xml +++ b/odoo/addons/test_new_api/assets.xml @@ -2,6 +2,7 @@ <odoo> <template id="assets_tests" name="Test New Api Assets Tests" inherit_id="web.assets_tests"> <xpath expr="." position="inside"> + <script type="text/javascript" src="/test_new_api/static/tests/tours/constraint.js"></script> <script type="text/javascript" src="/test_new_api/static/tests/tours/x2many.js"></script> </xpath> </template> diff --git a/odoo/addons/test_new_api/i18n/fr.po b/odoo/addons/test_new_api/i18n/fr.po new file mode 100644 index 0000000000000000000000000000000000000000..1046a07806f1703376c7d733667c0b918f00874d --- /dev/null +++ b/odoo/addons/test_new_api/i18n/fr.po @@ -0,0 +1,21 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * test_new_api +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0alpha1\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-07-08 06:43+0000\n" +"PO-Revision-Date: 2019-07-08 06:43+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: test_new_api +#: model:ir.model.constraint,message:test_new_api.constraint_test_new_api_category_positive_color +msgid "The color code must be positive !" +msgstr "La couleur doit être une valeur positive !" diff --git a/odoo/addons/test_new_api/i18n/test_new_api.pot b/odoo/addons/test_new_api/i18n/test_new_api.pot new file mode 100644 index 0000000000000000000000000000000000000000..68c64e61c5a9a9dd6b4f349f7628bde697273e10 --- /dev/null +++ b/odoo/addons/test_new_api/i18n/test_new_api.pot @@ -0,0 +1,21 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * test_new_api +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0alpha1\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-07-08 06:43+0000\n" +"PO-Revision-Date: 2019-07-08 06:43+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: test_new_api +#: model:ir.model.constraint,message:test_new_api.constraint_test_new_api_category_positive_color +msgid "The color code must be positive !" +msgstr "" diff --git a/odoo/addons/test_new_api/models.py b/odoo/addons/test_new_api/models.py index fad38c5919c4ca05e0d368fbc20ec102f0edd1f1..1d7d034e22abe508edaef7c4ea280a05c72e3649 100644 --- a/odoo/addons/test_new_api/models.py +++ b/odoo/addons/test_new_api/models.py @@ -24,6 +24,10 @@ class Category(models.Model): discussions = fields.Many2many('test_new_api.discussion', 'test_new_api_discussion_category', 'category', 'discussion') + _sql_constraints = [ + ('positive_color', 'CHECK(color >= 0)', 'The color code must be positive !') + ] + @api.depends('name', 'parent.display_name') # this definition is recursive def _compute_display_name(self): for cat in self: diff --git a/odoo/addons/test_new_api/static/tests/tours/constraint.js b/odoo/addons/test_new_api/static/tests/tours/constraint.js new file mode 100644 index 0000000000000000000000000000000000000000..0ef3ba54b77435cb8a3c7c9e8e3621a1cfa405a1 --- /dev/null +++ b/odoo/addons/test_new_api/static/tests/tours/constraint.js @@ -0,0 +1,33 @@ +odoo.define('web.test.constraint', function (require) { + 'use strict'; + + var tour = require("web_tour.tour"); + var inc; + + tour.register('sql_constaint', { + url: '/web?debug=1#action=test_new_api.action_categories', + test: true, + }, [ + { + content: "wait web client", + trigger: '.breadcrumb:contains(Categories)', + }, { // create test category + content: "create new category", + trigger: 'button.o_list_button_add', + }, { + content: "insert content", + trigger: 'input.o_required_modifier', + run: 'text Test Category', + }, { // try to insert a value that will raise the SQL constraint + content: "insert invalid value", + trigger: 'input[name="color"]', + run: 'text -1', + }, { // save + content: "save category", + trigger: 'button.o_form_button_save', + }, { // check popup content + content: "check notification box", + trigger: '.o_notification_content:contains(The color code must be positive !)', + run: function () {}, // it's a check + }]); +}); diff --git a/odoo/addons/test_new_api/tests/test_ui.py b/odoo/addons/test_new_api/tests/test_ui.py index 8578df24571de34eaf8d640d4e7f9c04e233c2fd..bca50d6b7931225f15ec0dad3e1bfddd51833c57 100644 --- a/odoo/addons/test_new_api/tests/test_ui.py +++ b/odoo/addons/test_new_api/tests/test_ui.py @@ -1,4 +1,5 @@ import odoo.tests +from odoo.tools import mute_logger @odoo.tests.common.tagged('post_install', '-at_install') @@ -14,3 +15,21 @@ class TestUi(odoo.tests.HttpCase): # too small or there are too many items preceding it in the tests menu self.start_tour("/web#action=test_new_api.action_discussions", 'widget_x2many', step_delay=100, login="admin", timeout=120) + + +class TestUiTranslation(odoo.tests.HttpCase): + + @mute_logger('odoo.sql_db', 'odoo.http') + def test_01_sql_constraints(self): + # Raise an SQL constraint and test the message + self.env['ir.translation']._load_module_terms(['test_new_api'], ['fr_FR']) + constraint = self.env.ref('test_new_api.constraint_test_new_api_category_positive_color') + message = constraint.with_context(lang='fr_FR').message + self.assertEqual(message, "La couleur doit être une valeur positive !") + + # TODO: make the test work with French translations. As the transaction + # is rollbacked at insert and a new cusor is opened, can not test that + # the message is translated (_load_module_terms is also) rollbacked. + # Test individually the external id and loading of translation. + self.start_tour("/web#action=test_new_api.action_categories", + 'sql_constaint', login="admin") diff --git a/odoo/addons/test_new_api/views.xml b/odoo/addons/test_new_api/views.xml index c78be2e09e0908f7ddb223afb1af2225aa16c5e1..830210c4d870e9a45cd531c2e06a099a9a9819b6 100644 --- a/odoo/addons/test_new_api/views.xml +++ b/odoo/addons/test_new_api/views.xml @@ -245,6 +245,7 @@ <field name="parent"/> <field name="root_categ"/> <field name="dummy"/> + <field name="color"/> </group> </sheet> </form> diff --git a/odoo/models.py b/odoo/models.py index c71cebd2fd402993cbc0caf4a99da61f9ce9bfa6..e23120f36d9b939ed43145c178737a1beb95cfc9 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -584,9 +584,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): @classmethod def _init_constraints_onchanges(cls): - # store sql constraint error messages - for (key, _, msg) in cls._sql_constraints: - cls.pool._sql_error[cls._table + '_' + key] = msg + # store list of sql constraint qualified names + for (key, _, _) in cls._sql_constraints: + cls.pool._sql_constraints.add(cls._table + '_' + key) # reset properties memoized on cls cls._constraint_methods = BaseModel._constraint_methods diff --git a/odoo/modules/registry.py b/odoo/modules/registry.py index abdcc3cc240ba9e4813b11181b3fa253e06681ab..99a920f233dd99dfa5bd4a8749d4b097f30cdfdd 100644 --- a/odoo/modules/registry.py +++ b/odoo/modules/registry.py @@ -105,7 +105,7 @@ class Registry(Mapping): def init(self, db_name): self.models = {} # model name/model instance mapping - self._sql_error = {} + self._sql_constraints = set() self._init = True self._assertion_report = assertion_report.assertion_report() self._fields_by_model = None diff --git a/odoo/service/model.py b/odoo/service/model.py index 61f63c6815557147a0abdc7356ed97949781d37b..9c2ff3b4487c4753914944acd62853dd1eada27b 100644 --- a/odoo/service/model.py +++ b/odoo/service/model.py @@ -10,7 +10,7 @@ import time import odoo from odoo.exceptions import UserError, ValidationError, QWebException from odoo.models import check_method_name -from odoo.tools.translate import translate +from odoo.tools.translate import translate, translate_sql_constraint from odoo.tools.translate import _ from . import security @@ -73,18 +73,13 @@ def check(f): # We open a *new* cursor here, one reason is that failed SQL # queries (as in IntegrityError) will invalidate the current one. - cr = False - - try: - cr = odoo.sql_db.db_connect(dbname).cursor() - res = translate(cr, name=False, source_type=ttype, - lang=lang, source=src) - if res: - return res + with odoo.sql_db.db_connect(dbname).cursor() as cr: + if ttype == 'sql_constraint': + res = translate_sql_constraint(cr, key=key, lang=lang) else: - return src - finally: - if cr: cr.close() + res = translate(cr, name=False, source_type=ttype, + lang=lang, source=src) + return res or src def _(src): return tr(src, 'code') @@ -114,9 +109,9 @@ def check(f): time.sleep(wait_time) except IntegrityError as inst: registry = odoo.registry(dbname) - for key in registry._sql_error.keys(): - if key in inst.pgerror: - raise ValidationError(tr(registry._sql_error[key], 'sql_constraint') or inst.pgerror) + key = inst.diag.constraint_name + if key in registry._sql_constraints: + raise ValidationError(tr(key, 'sql_constraint') or inst.pgerror) if inst.pgcode in (errorcodes.NOT_NULL_VIOLATION, errorcodes.FOREIGN_KEY_VIOLATION, errorcodes.RESTRICT_VIOLATION): msg = _('The operation cannot be completed, probably due to the following:\n- deletion: you may be trying to delete a record while other records still reference it\n- creation/update: a mandatory field is not correctly set') _logger.debug("IntegrityError", exc_info=True) diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index 9dfd9ec09e0dfa64a4b71bfa13c5729e2b59999d..de4ca66fb39aaee2fc08023b9946997f3436c642 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -352,6 +352,21 @@ def translate(cr, name, source_type, lang, source=None): res = res_trans and res_trans[0] or False return res +def translate_sql_constraint(cr, key, lang): + cr.execute(""" + SELECT COALESCE(t.value, c.message) as message + FROM ir_model_constraint c + LEFT JOIN + (SELECT res_id, value FROM ir_translation + WHERE type='model' + AND name='ir.model.constraint,message' + AND lang=%s + AND value!='') AS t + ON c.id=t.res_id + WHERE name=%s and type='u' + """, (lang, key)) + return cr.fetchone()[0] + class GettextAlias(object): def _get_db(self): @@ -445,7 +460,7 @@ class GettextAlias(object): if cr: # Try to use ir.translation to benefit from global cache if possible env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {}) - res = env['ir.translation']._get_source(None, ('code','sql_constraint'), lang, source) + res = env['ir.translation']._get_source(None, ('code',), lang, source) else: _logger.debug('no context cursor detected, skipping translation for "%r"', source) else: @@ -594,6 +609,9 @@ class PoFileReader: match = re.match(r'(sql_constraint|constraint):([\w.]+)', occurrence) if match: type, model = match.groups() + if type == "sql_constraint": + _logger.info("Skipped deprecated occurrence %s", occurrence) + continue yield { 'type': type, 'name': model, @@ -921,17 +939,15 @@ def trans_generate(lang, modules, cr): if not callable(msg): push_translation(encode(module), term_type, encode(model), 0, msg) - def push_local_constraints(module, model, cons_type='sql_constraints'): + def push_local_constraints(module, model): """ Climb up the class hierarchy and ignore inherited constraints from other modules. """ - term_type = 'sql_constraint' if cons_type == 'sql_constraints' else 'constraint' - msg_pos = 2 if cons_type == 'sql_constraints' else 1 for cls in model.__class__.__mro__: if getattr(cls, '_module', None) != module: continue - constraints = getattr(cls, '_local_' + cons_type, []) + constraints = getattr(cls, '_local_constraints', []) for constraint in constraints: - push_constraint_msg(module, term_type, model._name, constraint[msg_pos]) - + push_constraint_msg(module, 'constraint', model._name, constraint[1]) + cr.execute(query_models, query_param) for (_, model, module) in cr.fetchall(): @@ -940,9 +956,7 @@ def trans_generate(lang, modules, cr): continue Model = env[model] if Model._constraints: - push_local_constraints(module, Model, 'constraints') - if Model._sql_constraints: - push_local_constraints(module, Model, 'sql_constraints') + push_local_constraints(module, Model) installed_modules = [ m['name']