# HG changeset patch
# User Balde Oury <oury.balde@xcg-consulting.fr>
# Date 1742920495 0
#      Tue Mar 25 16:34:55 2025 +0000
# Branch 17.0
# Node ID fa02c774e0aa70f3bb6843bfdb5e4f479fc30bbb
# Parent  e76fc22bf8b12196bd0b1e717d41818fdb812db2
Improve substitution management in ir.actions.report and mail.template with
value_type functionality

- Refactor converter building functionality for improved maintainability.

- Added `substitution_mixin.py` with the new `SubstitutionMixin` abstract model.

- Updated `ir.actions.report` to inherit from `SubstitutionMixin`
and implement `_get_template` to return `redner_tmpl_id`.

- Updated `mail.template` to inherit from `SubstitutionMixin`,
replacing its simpler `action_get_substitutions` with the
mixin's implementation, and added `_get_template`.

diff --git a/NEWS.rst b/NEWS.rst
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -11,6 +11,8 @@
 
 Add more export formats from Typst
 
+Improve substitution management in ir.actions.report and mail.template with value_type functionality.
+
 17.0.1.9.0
 ----------
 
diff --git a/i18n/fr.po b/i18n/fr.po
--- a/i18n/fr.po
+++ b/i18n/fr.po
@@ -6,8 +6,8 @@
 msgstr ""
 "Project-Id-Version: Odoo Server 17.0\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-01-29 20:00+0000\n"
-"PO-Revision-Date: 2025-01-29 20:03+0000\n"
+"POT-Creation-Date: 2025-03-25 15:22+0000\n"
+"PO-Revision-Date: 2025-03-25 15:25+0000\n"
 "Last-Translator: Axel PREL <axel.prel@xcg-consulting.fr>\n"
 "Language-Team: XCG Consulting\n"
 "Language: fr\n"
@@ -332,6 +332,11 @@
 msgstr "Nom du modèle Redner"
 
 #. module: redner
+#: model:ir.model.fields.selection,name:redner.selection__redner_substitution__value_type__
+msgid "Not specified"
+msgstr "Non spécifié"
+
+#. module: redner
 #: model:ir.model.fields.selection,name:redner.selection__redner_template__source__odoo
 msgid "Odoo"
 msgstr "Odoo"
@@ -464,11 +469,21 @@
 msgstr "Sélectionner"
 
 #. module: redner
+#: model:ir.model.fields,field_description:redner.field_redner_substitution__sequence
+msgid "Sequence"
+msgstr "Séquence"
+
+#. module: redner
 #: model_terms:ir.ui.view,arch_db:redner.redner_template_view_form
 msgid "Settings"
 msgstr "Paramètres"
 
 #. module: redner
+#: model:ir.model.fields.selection,name:redner.selection__redner_substitution__value_type__simple
+msgid "Simple"
+msgstr "Simple"
+
+#. module: redner
 #: model:ir.model.fields,field_description:redner.field_redner_template__slug
 msgid "Slug"
 msgstr "Slug"
@@ -490,6 +505,16 @@
 msgstr "Substitution"
 
 #. module: redner
+#: model:ir.model,name:redner.model_substitution_mixin
+msgid "Substitution Mixin"
+msgstr "Mixin Substitution"
+
+#. module: redner
+#: model:ir.model.fields,help:redner.field_redner_substitution__sequence
+msgid "Substitution order, children are placed under their parent."
+msgstr "Ordre de substitution, les enfants sont placés sous leur parent."
+
+#. module: redner
 #: model:ir.model.fields,field_description:redner.field_mail_template__redner_substitution_ids
 #: model_terms:ir.ui.view,arch_db:redner.redner_report_view
 #: model_terms:ir.ui.view,arch_db:redner.view_email_template_form
@@ -586,6 +611,11 @@
 msgstr "Erreur redner : %r"
 
 #. module: redner
+#: model:ir.model.fields,field_description:redner.field_redner_substitution__value_type
+msgid "Value Type"
+msgstr "Type de valeur"
+
+#. module: redner
 #: model:ir.model.fields,field_description:redner.field_redner_substitution__keyword
 msgid "Variable"
 msgstr "Variable"
diff --git a/models/__init__.py b/models/__init__.py
--- a/models/__init__.py
+++ b/models/__init__.py
@@ -1,3 +1,4 @@
+from . import substitution_mixin  # isort:skip
 from . import (
     ir_actions_report,
     mail_template,
diff --git a/models/ir_actions_report.py b/models/ir_actions_report.py
--- a/models/ir_actions_report.py
+++ b/models/ir_actions_report.py
@@ -36,7 +36,8 @@
     file.
     """
 
-    _inherit = "ir.actions.report"
+    _inherit = ["ir.actions.report", "substitution.mixin"]
+    _name = "ir.actions.report"
 
     @api.constrains("redner_filetype", "report_type")
     def _check_redner_filetype(self):
@@ -111,30 +112,6 @@
             filetype = rec.redner_filetype
             rec.is_redner_native_format = fmt.get_format(filetype).native
 
-    def action_get_substitutions(self):
-        """Call by: action button `Get Substitutions from Redner Report`"""
-        self.ensure_one()
-
-        if self.redner_tmpl_id:
-            keywords = self.redner_tmpl_id.get_keywords()
-
-            # Get current substitutions
-            subs = self.substitution_ids.mapped("keyword") or []
-            values = []
-            for key in keywords:
-                # check to avoid duplicate keys
-                if key not in subs:
-                    values.append((0, 0, {"keyword": key}))
-            self.write({"substitution_ids": values})
-
-            # remove obsolete keywords in substitutions model
-            if len(self.substitution_ids) > len(keywords):
-                deprecated_keys = self.substitution_ids.filtered(
-                    lambda s: s.keyword not in keywords
-                )
-                if len(deprecated_keys) > 0:
-                    deprecated_keys.unlink()
-
     def get_report_metadata(self):
         self.ensure_one()
 
@@ -239,3 +216,19 @@
                 attachment_vals["name"],
             )
         return buffer
+
+    # -------------------------------------------------------------------------
+    # MIXIN METHODS
+    # -------------------------------------------------------------------------
+
+    def _get_substitution_field(self):
+        """Return the substitution field name for ir.actions.report."""
+        return "substitution_ids"
+
+    def _get_template(self):
+        """Get template for ir.actions.report."""
+        return self.redner_tmpl_id
+
+    def _get_substitutions(self):
+        """Get substitutions for ir.actions.report."""
+        return self.substitution_ids
diff --git a/models/mail_template.py b/models/mail_template.py
--- a/models/mail_template.py
+++ b/models/mail_template.py
@@ -32,7 +32,8 @@
 class MailTemplate(models.Model):
     """Extended to add features of redner API"""
 
-    _inherit = "mail.template"
+    _inherit = ["mail.template", "substitution.mixin"]
+    _name = "mail.template"
 
     is_redner_template = fields.Boolean(string="Rendered by Redner", default=False)
 
@@ -48,30 +49,6 @@
         string="Substitutions",
     )
 
-    def action_get_substitutions(self):
-        """Call by: action button `Get Substitutions from Redner Template`"""
-        self.ensure_one()
-
-        if self.redner_tmpl_id:
-            keywords = self.redner_tmpl_id.get_keywords()
-
-            # Get current substitutions
-            subs = self.redner_substitution_ids.mapped("keyword") or []
-            values = []
-            for key in keywords:
-                # check to avoid duplicate keys
-                if key not in subs:
-                    values.append((0, 0, {"keyword": key}))
-            self.write({"redner_substitution_ids": values})
-
-            # remove obsolete keywords in substitutions model
-            if len(self.redner_substitution_ids) > len(keywords):
-                deprecated_keys = self.redner_substitution_ids.filtered(
-                    lambda s: s.keyword not in keywords
-                )
-                if len(deprecated_keys) > 0:
-                    deprecated_keys.unlink()
-
     def _patch_email_values(self, values, res_id):
         conv = self.redner_substitution_ids.filtered(
             lambda r: r.depth == 0
@@ -126,3 +103,19 @@
         """Override to add additional variables in mail "render template" func"""
         variables.update({"image": lambda value: image(value)})
         return super().render_variable_hook(variables)
+
+    # -------------------------------------------------------------------------
+    # MIXIN METHODS
+    # -------------------------------------------------------------------------
+
+    def _get_substitution_field(self):
+        """Return the substitution field name for mail.template."""
+        return "redner_substitution_ids"
+
+    def _get_template(self):
+        """Get template for mail.template."""
+        return self.redner_tmpl_id
+
+    def _get_substitutions(self):
+        """Get substitutions for mail.template."""
+        return self.redner_substitution_ids
diff --git a/models/redner_substitution.py b/models/redner_substitution.py
--- a/models/redner_substitution.py
+++ b/models/redner_substitution.py
@@ -89,6 +89,22 @@
         compute="_compute_hide_placeholder_button", string="Hide Placeholder Button"
     )
 
+    value_type = fields.Selection(
+        selection=[
+            ("", "Not specified"),
+            ("simple", "Simple"),
+        ],
+        string="Value Type",
+        default="",
+        required=False,
+    )
+
+    sequence = fields.Integer(
+        string="Sequence",
+        default=10,
+        help="Substitution order, children are placed under their parent.",
+    )
+
     @api.onchange("converter")
     def _onchange_converter(self):
         if self.converter:
@@ -143,50 +159,79 @@
         )
 
     def build_converter(self):
-        d = {}
+        """Build a converter dictionary from substitution records."""
+        converters = {}
         for sub in self:
-            if sub.converter == "mail_template":
-                conv = converter.MailTemplate(sub.value, False)
-            elif sub.converter == "mail_template+deserialize":
-                conv = converter.MailTemplate(sub.value, True)
-            elif sub.converter == "constant":
-                conv = converter.Constant(sub.value)
-            elif sub.converter == "field":
-                if "." in sub.value:
-                    path, name = sub.value.rsplit(".", 1)
-                else:
-                    path, name = None, sub.value
-                conv = converter.Field(name)
-                if path:
-                    conv = converter.relation(path.replace(".", "/"), conv)
-            elif sub.converter == "image-file":
-                if "." in sub.value:
-                    path, name = sub.value.rsplit(".", 1)
-                else:
-                    path, name = None, sub.value
-                conv = ImageFile(name)
-                if path:
-                    conv = converter.relation(path.replace(".", "/"), conv)
-            elif sub.converter == "image-data-url":
-                conv = ImageDataURL(sub.value)
-            elif sub.converter == "relation-to-many":
-                # Unpack the result of finding a field with its sort order into
-                # variable names.
-                value, sorted = parse_sorted_field(sub.value)
-                conv = converter.RelationToMany(
-                    value,
-                    None,
-                    sortkey=sortkey(sorted) if sorted else None,
-                    converter=sub.get_children().build_converter(),
-                )
-            elif sub.converter == "relation-path":
-                conv = converter.relation(
-                    sub.value, sub.get_children().build_converter()
-                )
-            elif sub.converter is False:
-                continue
-            else:
-                raise ValidationError(_("invalid converter type: %s") % sub.converter)
-            d[sub.keyword.rsplit(".", 2)[-1]] = conv
+            if sub.converter:
+                try:
+                    key = sub.keyword.rsplit(".", 2)[-1]
+                    conv = self._create_converter_by_type(sub)
+                    converters[key] = conv
+                except KeyError:
+                    raise ValidationError(
+                        _("invalid converter type: %s") % sub.converter
+                    ) from None
+        return converter.Model(converters)
+
+    def _create_converter_by_type(self, sub):
+        """Create appropriate converter based on type."""
+        converter_map = {
+            "mail_template": lambda: converter.MailTemplate(sub.value, False),
+            "mail_template+deserialize": lambda: converter.MailTemplate(
+                sub.value, True
+            ),
+            "constant": lambda: converter.Constant(sub.value),
+            "field": lambda: self._create_field_or_image_converter(sub, is_image=False),
+            "image-file": lambda: self._create_field_or_image_converter(
+                sub, is_image=True
+            ),
+            "image-data-url": lambda: ImageDataURL(sub.value),
+            "relation-to-many": lambda: self._create_relation_to_many_converter(sub),
+            "relation-path": lambda: converter.relation(
+                sub.value, sub.get_children().build_converter()
+            ),
+        }
+        return converter_map[sub.converter]()
+
+    def _create_field_or_image_converter(self, sub, is_image):
+        """Create a converter for 'field' or 'image-file' types."""
+        path, name = sub.value.rsplit(".", 1) if "." in sub.value else (None, sub.value)
+        conv = ImageFile(name) if is_image else converter.Field(name)
+        return converter.relation(path.replace(".", "/"), conv) if path else conv
 
-        return converter.Model(d)
+    def _create_relation_to_many_converter(self, sub):
+        """Create a converter for 'relation-to-many' type."""
+        # Unpack the result of finding a field with its sort order into
+        # variable names.
+        value, sorted_field = parse_sorted_field(sub.value)
+        return converter.RelationToMany(
+            value,
+            None,
+            sortkey=sortkey(sorted_field) if sorted_field else None,
+            converter=sub.get_children().build_converter(),
+        )
+
+    def create_converter_from_record(self):
+        """
+        Create a converter instance from a single substitution record.
+
+        Returns:
+            converter.BaseConverter: A converter instance based on the record's type,
+                                    or None if no matching converter type found.
+
+        Example:
+            >>> sub = SubstitutionRecord(keyword="field.name", converter="field",
+              value="partner.name")
+            >>> conv = sub.create_converter_from_record()
+            >>> isinstance(conv, converter.Field)
+            True
+            >>> conv.field_name
+            'name'
+        """
+        converter_factories = {
+            "field": lambda: converter.Field(self.value.split(".")[-1]),
+            "constant": lambda: converter.Constant(self.value),
+            # Extend with additional converter types as needed
+        }
+
+        return converter_factories.get(self.converter, lambda: None)()
diff --git a/models/substitution_mixin.py b/models/substitution_mixin.py
new file mode 100644
--- /dev/null
+++ b/models/substitution_mixin.py
@@ -0,0 +1,214 @@
+from odoo import Command, models
+
+
+class SubstitutionMixin(models.AbstractModel):
+    _name = "substitution.mixin"
+    _description = "Substitution Mixin"
+
+    def action_get_substitutions(self):
+        """
+        Shift child substitutions up one level if their parent has a value_type.
+        Example: order_line.test1.field1 becomes order_line.field1 if order_line.test1
+        has a value_type. Handles multiple levels.
+        Call by: action button `Get Substitutions from Render Report`
+        """
+        self.ensure_one()
+
+        # Use a method to get the template, making it adaptable to different models
+        template = self._get_template()
+        if not template:
+            return
+
+        # Get template keywords
+        keywords = template.get_keywords()
+        if not keywords:
+            return
+
+        # Assign initial sequence based on keywords order
+        sequence_map = {kw: (i + 1) * 10 for i, kw in enumerate(keywords)}
+
+        # Step 1: Identify parents with defined value_type
+        typed_parents = {
+            sub.keyword for sub in self._get_substitutions() if sub.value_type
+        }
+
+        # Step 2: Build keyword remapping
+        keyword_mapping = self._build_keyword_remapping(keywords, typed_parents)
+
+        # Step 3: Prepare update commands
+        commands = self._prepare_substitution_commands(
+            keyword_mapping, keywords, sequence_map
+        )
+
+        # Apply changes if any
+        if commands:
+            self.write({self._get_substitution_field(): commands})
+
+    def _get_substitution_field(self):
+        """Abstract method to get the substitution field name; must be
+        implemented by inheriting models.
+        """
+        raise NotImplementedError(
+            "Method '_get_substitution_field' must be implemented."
+        )
+
+    def _get_template(self):
+        """Abstract method to get the template; must be implemented by
+        inheriting models.
+        """
+        raise NotImplementedError("Method '_get_template' must be implemented.")
+
+    def _get_substitutions(self):
+        """Abstract method to get the substitutions; must be implemented by
+        inheriting models.
+        """
+        raise NotImplementedError("Method '_get_substitutions' must be implemented.")
+
+    def _build_keyword_remapping(self, keywords, typed_parents):
+        """
+        Build mapping between original keywords and their remounted versions.
+        Handles recursive remounting when multiple levels have defined value_types.
+
+        :param keywords: List of original keywords from template
+        :param typed_parents: Set of parent keywords with defined value_type
+        :return: Dictionary mapping original keywords to remounted keywords
+        """
+        keyword_mapping = {}
+        processed = set()
+
+        for keyword in keywords:
+            if keyword in processed:
+                continue
+
+            # Get remounted keyword after applying all parent type rules
+            remounted = self._get_remounted_keyword(keyword, typed_parents)
+
+            # Only add to mapping if it changed
+            if remounted != keyword:
+                keyword_mapping[keyword] = remounted
+                processed.add(keyword)
+
+        return keyword_mapping
+
+    def _get_remounted_keyword(self, keyword, typed_parents):
+        """
+        Get the remounted version of a keyword by recursively removing typed parents.
+
+        :param keyword: Original keyword to remount
+        :param typed_parents: Set of parent keywords with defined value_type
+        :return: Remounted keyword with typed parents removed
+        """
+        parts = keyword.split(".")
+        modified = True
+        current = keyword
+
+        # Keep remounting until no more changes
+        while modified:
+            modified = False
+            parts = current.split(".")
+
+            # Check each potential parent in the path
+            for i in range(1, len(parts)):
+                parent = ".".join(parts[:i])
+
+                # If this parent has a type, remount by removing it
+                if parent in typed_parents:
+                    prefix = ".".join(parts[: i - 1]) if i > 1 else ""
+                    suffix = ".".join(parts[i:])
+
+                    new_keyword = f"{prefix}.{suffix}" if prefix else suffix
+
+                    if new_keyword != current:
+                        current = new_keyword
+                        modified = True
+                        break  # Start over with new keyword
+
+        return current
+
+    def _prepare_substitution_commands(self, keyword_mapping, keywords, sequence_map):
+        """
+        Prepare commands to update, create, and delete substitutions.
+
+        :param keyword_mapping: Dictionary mapping original keywords to remounted
+          versions
+        :param keywords: List of original keywords from template
+        :param sequence_map: Dictionary mapping keywords to their sequence numbers
+        :return: List of commands for write method
+        """
+        commands = []
+        processed_keywords = set()
+        substitutions = self._get_substitutions()
+
+        def _build_substitution_vals(sub=None, keyword=None, sequence=None):
+            """Helper to build substitution values dictionary."""
+            if sub:
+                # Use values from an existing substitution record
+                return {
+                    "keyword": keyword or sub.keyword,
+                    "value_type": sub.value_type,
+                    "converter": sub.converter,
+                    "value": sub.value,
+                    "template_id": sub.template_id.id if sub.template_id else False,
+                    "ir_actions_report_id": sub.ir_actions_report_id.id
+                    if sub.ir_actions_report_id
+                    else False,
+                    "sequence": sub.sequence,
+                }
+            # Default empty values
+            return {
+                "keyword": keyword,
+                "value_type": "",
+                "converter": False,
+                "value": False,
+                "sequence": sequence_map.get(keyword, 10)
+                if sequence is None
+                else sequence,
+            }
+
+        # Process remounted keywords
+        for orig_key, new_key in keyword_mapping.items():
+            orig_sub = substitutions.filtered(lambda s, ok=orig_key: s.keyword == ok)
+            new_sub = substitutions.filtered(lambda s, nk=new_key: s.keyword == nk)
+
+            # Handle original substitution exists
+            if orig_sub:
+                # Update or create remounted version
+                if new_sub:
+                    commands.append(
+                        Command.update(
+                            new_sub.id,
+                            _build_substitution_vals(orig_sub),
+                        )
+                    )
+                else:
+                    commands.append(
+                        Command.create(_build_substitution_vals(orig_sub, new_key))
+                    )
+
+                # Delete original
+                commands.append(Command.unlink(orig_sub.id))
+            else:
+                # Create new remounted if it doesn't exist
+                if not new_sub:
+                    commands.append(
+                        Command.create(_build_substitution_vals(keyword=new_key))
+                    )
+
+            processed_keywords.add(new_key)
+
+        # Create missing original keywords that weren't remounted
+        for keyword in keywords:
+            if keyword not in keyword_mapping and keyword not in processed_keywords:
+                existing = substitutions.filtered(lambda s, kw=keyword: s.keyword == kw)
+                if not existing:
+                    commands.append(
+                        Command.create(_build_substitution_vals(keyword=keyword))
+                    )
+                processed_keywords.add(keyword)
+
+        # Delete obsolete substitutions
+        obsolete = substitutions.filtered(lambda s: s.keyword not in processed_keywords)
+        for sub in obsolete:
+            commands.append(Command.unlink(sub.id))
+
+        return commands
diff --git a/views/ir_actions_report.xml b/views/ir_actions_report.xml
--- a/views/ir_actions_report.xml
+++ b/views/ir_actions_report.xml
@@ -41,8 +41,10 @@
                     <group string="Substitutions">
                         <field name="substitution_ids" nolabel="1" colspan="2">
                             <tree editable="top" default_order="keyword asc">
+                                <field name="sequence" widget="handle" />
                                 <field name="keyword" />
                                 <field name="converter" />
+                                <field name="value_type" />
                                 <field name="render_model" column_invisible="1" />
                                 <field name="value_placeholder" column_invisible="1" />
                                 <field
diff --git a/views/mail_template.xml b/views/mail_template.xml
--- a/views/mail_template.xml
+++ b/views/mail_template.xml
@@ -37,8 +37,10 @@
                 <group string="Substitutions" invisible="not is_redner_template">
                     <field name="redner_substitution_ids" nolabel="1" colspan="2">
                         <tree editable="top" default_order="keyword asc">
+                            <field name="sequence" widget="handle" />
                             <field name="keyword" />
                             <field name="converter" />
+                            <field name="value_type" />
                             <field name="render_model" column_invisible="1" />
                             <field name="value_placeholder" column_invisible="1" />
                             <field
# HG changeset patch
# User Balde Oury <oury.balde@xcg-consulting.fr>
# Date 1742922983 0
#      Tue Mar 25 17:16:23 2025 +0000
# Branch 17.0
# Node ID 6083a937fa6076fea4c86e20f6bb12ee9a77764d
# Parent  fa02c774e0aa70f3bb6843bfdb5e4f479fc30bbb
Added tag 17.0.1.10.0 for changeset fa02c774e0aa

diff --git a/.hgtags b/.hgtags
--- a/.hgtags
+++ b/.hgtags
@@ -46,3 +46,4 @@
 a323657b8a717a27bb55d61cda3a412671a2c261 17.0.1.7.0
 d17ccdea52e7628a9d51ba9f62effba23f0c3313 17.0.1.8.0
 0a0181dc70c8c024577e4c5a567a1cf01763995e 17.0.1.9.0
+fa02c774e0aa70f3bb6843bfdb5e4f479fc30bbb 17.0.1.10.0