details: https://code.tryton.org/tryton/commit/f3aef553d05b
branch: default
user: Cédric Krier <[email protected]>
date: Fri Oct 09 00:41:07 2026 +0200
description:
Support creating and writing translated fields in multiple languages at
once
Closes #15140
diffstat:
trytond/CHANGELOG | 1 +
trytond/doc/ref/fields.rst | 3 +
trytond/trytond/model/modelsql.py | 100 +++++++++++++----
trytond/trytond/tests/field_char.py | 12 ++
trytond/trytond/tests/test_field_char.py | 175 +++++++++++++++++++++++++++++++
5 files changed, 268 insertions(+), 23 deletions(-)
diffs (412 lines):
diff -r dba330085af9 -r f3aef553d05b trytond/CHANGELOG
--- a/trytond/CHANGELOG Fri Oct 02 14:58:38 2026 +0200
+++ b/trytond/CHANGELOG Fri Oct 09 00:41:07 2026 +0200
@@ -1,3 +1,4 @@
+* Support creating and writing translated fields in multiple languages at once
* Support reading translated fields in any suffixed language
* Rename delete_many to deletemany in the FileStore
* Restrict the wizard session to the user who created it (issue15206)
diff -r dba330085af9 -r f3aef553d05b trytond/doc/ref/fields.rst
--- a/trytond/doc/ref/fields.rst Fri Oct 02 14:58:38 2026 +0200
+++ b/trytond/doc/ref/fields.rst Fri Oct 09 00:41:07 2026 +0200
@@ -307,6 +307,9 @@
The field is ordered using the similarity with the context value from the
key ``<model name>.<field name>.order`` if it is set.
+Translated :class:`Char` accepts as written and created value a :py:class:`dict
+<dict>` with language code as key and translated string as value.
+
:class:`Char` has some extra arguments:
.. attribute:: Char.size
diff -r dba330085af9 -r f3aef553d05b trytond/trytond/model/modelsql.py
--- a/trytond/trytond/model/modelsql.py Fri Oct 02 14:58:38 2026 +0200
+++ b/trytond/trytond/model/modelsql.py Fri Oct 09 00:41:07 2026 +0200
@@ -1007,6 +1007,7 @@
cursor = transaction.connection.cursor(row_factory=backend.scalar_row)
pool = Pool()
Translation = pool.get('ir.translation')
+ Config = pool.get('ir.configuration')
vlist_orig = vlist
vlist = cls._before_create(vlist)
@@ -1117,9 +1118,16 @@
current_values = [transaction.user, CurrentTimestamp()]
# Insert record
+ stored_language = Config.get_language()
for fname, value in sorted(values.items()):
field = cls._fields[fname]
if not hasattr(field, 'set'):
+ if (getattr(field, 'translate', False)
+ and isinstance(value, dict)):
+ if stored_language in value:
+ value = value.get(stored_language)
+ else:
+ value = value.get(transaction.language)
current_columns.append(Column(table, fname))
current_values.append(field.sql_format(value))
current_column_names.append(fname)
@@ -1147,17 +1155,29 @@
field_names = list(sorted(cls._mptt_fields))
cls._update_mptt(field_names, repeat(new_ids, len(field_names)))
- translation_values = {}
+ translation_values = defaultdict(
+ lambda: defaultdict(lambda: defaultdict(str)))
fields_to_set = {}
for values, new_id in zip(vlist, new_ids):
for fname, value in values.items():
field = cls._fields[fname]
- if (getattr(field, 'translate', False)
- and not hasattr(field, 'set')):
- translation_values.setdefault(
- '%s,%s' % (cls.__name__, fname), {})[new_id] = (
- field.sql_format(value))
- if hasattr(field, 'set'):
+ if not hasattr(field, 'set'):
+ if getattr(field, 'translate', False):
+ if isinstance(value, dict):
+ for language, t_value in value.items():
+ t_value = field.sql_format(t_value)
+ translation_values[
+ f'{cls.__name__},{fname}'][
+ language][
+ new_id] = t_value
+
+ else:
+ value = field.sql_format(value)
+ translation_values[
+ f'{cls.__name__},{fname}'][
+ transaction.language][
+ new_id] = value
+ else:
args = fields_to_set.setdefault(fname, [])
actions = iter(args)
for ids, val in zip(actions, actions):
@@ -1167,9 +1187,10 @@
else:
args.extend(([new_id], value))
- if translation_values:
- for name, translations in translation_values.items():
- Translation.set_ids(name, 'model', Transaction().language,
+ for name, languages in translation_values.items():
+ for language, translations in languages.items():
+ Translation.set_ids(
+ name, 'model', language,
list(translations.keys()), list(translations.values()))
for fname in sorted(fields_to_set, key=cls.index_set_field):
@@ -1544,6 +1565,8 @@
cls.__check_timestamp(all_ids)
cls.__check_domain_rule(all_ids, 'write')
+ translation_values = defaultdict(
+ lambda: defaultdict(lambda: defaultdict(str)))
fields_to_set = {}
actions = iter(args)
for records, values in zip(actions, actions):
@@ -1561,12 +1584,26 @@
columns = [table.write_uid, table.write_date]
update_values = [transaction.user, CurrentTimestamp()]
- store_translation = Transaction().language == Config.get_language()
+ stored_language = Config.get_language()
+ normalized_values = values.copy()
for fname, value in values.items():
field = cls._fields[fname]
if not hasattr(field, 'set'):
- if (not getattr(field, 'translate', False)
- or store_translation):
+ translate = getattr(field, 'translate', False)
+ if translate:
+ if isinstance(value, dict):
+ if stored_language in value:
+ value = value.get(transaction.language)
+ normalized_values[fname] = value
+ store_translation = True
+ else:
+ store_translation = False
+ else:
+ store_translation = (
+ stored_language == transaction.language)
+ else:
+ store_translation = False
+ if not translate or store_translation:
columns.append(Column(table, fname))
update_values.append(field.sql_format(value))
@@ -1582,23 +1619,34 @@
if isinstance(
exception, backend.DatabaseIntegrityError):
cls.__raise_integrity_error(
- exception, values, list(values.keys()),
+ exception,
+ normalized_values, list(normalized_values.keys()),
transaction=transaction)
elif isinstance(exception, backend.DatabaseDataError):
cls.__raise_data_error(
- exception, values, list(values.keys()),
+ exception,
+ normalized_values, list(normalized_values.keys()),
transaction=transaction)
raise
for fname, value in values.items():
field = cls._fields[fname]
- if (getattr(field, 'translate', False)
- and not hasattr(field, 'set')):
- Translation.set_ids(
- '%s,%s' % (cls.__name__, fname), 'model',
- transaction.language, ids,
- [field.sql_format(value)] * len(ids))
- if hasattr(field, 'set'):
+ if not hasattr(field, 'set'):
+ if getattr(field, 'translate', False):
+ if isinstance(value, dict):
+ for language, t_value in value.items():
+ t_value = field.sql_format(t_value)
+ translation_values[
+ f'{cls.__name__},{fname}'][
+ language].update(
+ dict.fromkeys(ids, t_value))
+ else:
+ value = field.sql_format(value)
+ translation_values[
+ f'{cls.__name__},{fname}'][
+ transaction.language].update(
+ dict.fromkeys(ids, value))
+ else:
fields_to_set.setdefault(fname, []).extend((ids, value))
path_fields = cls._path_fields & values.keys()
@@ -1610,7 +1658,13 @@
if mptt_fields:
cls._update_mptt(
list(sorted(mptt_fields)), repeat(ids, len(mptt_fields)),
- values)
+ values.keys())
+
+ for name, languages in translation_values.items():
+ for language, translations in languages.items():
+ Translation.set_ids(
+ name, 'model', language,
+ list(translations.keys()), list(translations.values()))
for fname in sorted(fields_to_set, key=cls.index_set_field):
fargs = fields_to_set[fname]
diff -r dba330085af9 -r f3aef553d05b trytond/trytond/tests/field_char.py
--- a/trytond/trytond/tests/field_char.py Fri Oct 02 14:58:38 2026 +0200
+++ b/trytond/trytond/tests/field_char.py Fri Oct 09 00:41:07 2026 +0200
@@ -50,6 +50,16 @@
char_translated = char.translated('char')
+class CharTranslateRequired(ModelSQL):
+ __name__ = 'test.char_translated_required'
+ char = fields.Char("Char", translate=True, required=True)
+
+
+class CharTranslateSize(ModelSQL):
+ __name__ = 'test.char_translated_size'
+ char = fields.Char("Char", translate=True, size=5)
+
+
class CharUnaccentedOn(ModelSQL):
__name__ = 'test.char_unaccented_on'
char = fields.Char("Char")
@@ -78,6 +88,8 @@
CharSize,
CharSizePYSON,
CharTranslate,
+ CharTranslateRequired,
+ CharTranslateSize,
CharUnaccentedOn,
CharUnaccentedOff,
CharUnaccentedTranslate,
diff -r dba330085af9 -r f3aef553d05b trytond/trytond/tests/test_field_char.py
--- a/trytond/trytond/tests/test_field_char.py Fri Oct 02 14:58:38 2026 +0200
+++ b/trytond/trytond/tests/test_field_char.py Fri Oct 09 00:41:07 2026 +0200
@@ -665,6 +665,181 @@
Char.create([{'char': Literal('Foo')}])
@with_transaction()
+ def test_create_with_translations_dict(self):
+ "Test create with translations dictionary"
+ pool = Pool()
+ Language = pool.get('ir.lang')
+ Char = self.Char()
+ transaction = Transaction()
+
+ english, = Language.search([('code', '=', 'en')])
+ english.translatable = True
+ english.save()
+ french, = Language.search([('code', '=', 'fr')])
+ french.translatable = True
+ french.save()
+
+ record, = Char.create([{
+ 'char': {
+ 'en': "foo",
+ 'fr': "bar",
+ },
+ }])
+ with transaction.set_context(language='en'):
+ record_en = Char(record.id)
+ with transaction.set_context(language='fr'):
+ record_fr = Char(record.id)
+
+ self.assertEqual(record_en.char, "foo")
+ self.assertEqual(record_fr.char, "bar")
+
+ @with_transaction()
+ def test_write_with_translations_dict(self):
+ "Test write with translations dictionary"
+ pool = Pool()
+ Language = pool.get('ir.lang')
+ Char = self.Char()
+ transaction = Transaction()
+
+ english, = Language.search([('code', '=', 'en')])
+ english.translatable = True
+ english.save()
+ french, = Language.search([('code', '=', 'fr')])
+ french.translatable = True
+ french.save()
+
+ record, = Char.create([{}])
+ Char.write([record], {
+ 'char': {
+ 'en': "foo",
+ 'fr': "bar",
+ },
+ })
+ with transaction.set_context(language='en'):
+ record_en = Char(record.id)
+ with transaction.set_context(language='fr'):
+ record_fr = Char(record.id)
+
+ self.assertEqual(record_en.char, "foo")
+ self.assertEqual(record_fr.char, "bar")
+
+ @with_transaction()
+ def test_create_with_translations_dict_size_valid(self):
+ "Test create with translation and size"
+ pool = Pool()
+ Char = pool.get('test.char_translated_size')
+
+ record, = Char.create([{
+ 'char': {
+ 'en': "foo",
+ 'fr': "bar",
+ },
+ }])
+
+ @with_transaction()
+ def test_create_with_translations_dict_size_invalid(self):
+ "Test create with translation and size"
+ pool = Pool()
+ Char = pool.get('test.char_translated_size')
+
+ with self.assertRaises(SizeValidationError):
+ record, = Char.create([{
+ 'char': {
+ 'en': "foobar",
+ 'fr': "bar",
+ },
+ }])
+
+ @with_transaction()
+ def test_write_with_translations_dict_size_valid(self):
+ "Test write with translation and size"
+ pool = Pool()
+ Char = pool.get('test.char_translated_size')
+
+ record, = Char.create([{}])
+
+ Char.write([record], {
+ 'char': {
+ 'en': "foo",
+ 'fr': "bar",
+ },
+ })
+
+ @with_transaction()
+ def test_write_with_translations_dict_size_invalid(self):
+ "Test write with translation and size"
+ pool = Pool()
+ Char = pool.get('test.char_translated_size')
+
+ record, = Char.create([{}])
+
+ with self.assertRaises(SizeValidationError):
+ record, = Char.write([record], {
+ 'char': {
+ 'en': "foobar",
+ 'fr': "bar",
+ },
+ })
+
+ @with_transaction()
+ def test_create_with_translations_dict_required_with_value(self):
+ "Test create with translation required with value"
+ pool = Pool()
+ Char = pool.get('test.char_translated_required')
+
+ record, = Char.create([{
+ 'char': {
+ 'en': "foo",
+ 'fr': "bar",
+ },
+ }])
+
+ @with_transaction()
+ def test_create_with_translations_dict_required_without_value(self):
+ "Test create with translation required without value"
+ pool = Pool()
+ Char = pool.get('test.char_translated_required')
+
+ with self.assertRaises(RequiredValidationError):
+ record, = Char.create([{
+ 'char': {
+ 'en': None,
+ 'fr': "bar",
+ },
+ }])
+
+ @with_transaction()
+ def test_write_with_translations_dict_required_with_value(self):
+ "Test write with translation required with value"
+ pool = Pool()
+ Char = pool.get('test.char_translated_required')
+
+ record, = Char.create([{'char': "Test"}])
+
+ Char.write([record], {
+ 'char': {
+ 'en': "foo",
+ 'fr': "bar",
+ },
+ })
+
+ @with_transaction()
+ def test_write_with_translations_dict_required_without_value(self):
+ "Test create with translation required without value"
+ pool = Pool()
+ Char = pool.get('test.char_translated_required')
+
+ record, = Char.create([{'char': "Test"}])
+
+ with self.assertRaises(RequiredValidationError):
+ Char.write([record], {
+ 'char': {
+ 'en': None,
+ 'fr': "bar",
+ },
+ })
+
+ @with_transaction()
def test_translation_default_language_cache(self):
"""Test set translation for default language does not fill
transactional cache with former value"""