# 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