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"""

Reply via email to