diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py
index 269091f2843..332c16454d2 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 5b5feca350e..a381f871fe2 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 2f557eb2386..6c5d894283f 100644
--- a/odoo/addons/base/views/ir_model_views.xml
+++ b/odoo/addons/base/views/ir_model_views.xml
@@ -468,6 +468,9 @@
+
+
+
diff --git a/odoo/addons/test_new_api/assets.xml b/odoo/addons/test_new_api/assets.xml
index 8d0103a311d..8bd75bceab5 100644
--- a/odoo/addons/test_new_api/assets.xml
+++ b/odoo/addons/test_new_api/assets.xml
@@ -2,6 +2,7 @@
+
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 00000000000..1046a07806f
--- /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 00000000000..68c64e61c5a
--- /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 fad38c5919c..1d7d034e22a 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 00000000000..0ef3ba54b77
--- /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 8578df24571..bca50d6b793 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 c78be2e09e0..830210c4d87 100644
--- a/odoo/addons/test_new_api/views.xml
+++ b/odoo/addons/test_new_api/views.xml
@@ -245,6 +245,7 @@
+
diff --git a/odoo/models.py b/odoo/models.py
index c71cebd2fd4..e23120f36d9 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 abdcc3cc240..99a920f233d 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 61f63c68155..9c2ff3b4487 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 9dfd9ec09e0..de4ca66fb39 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']