Skip to content
Snippets Groups Projects
Commit fa02c774 authored by oury.balde's avatar oury.balde
Browse files

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`.
parent e76fc22b
No related branches found
1 merge request!91Improve substitution management in ir.actions.report and mail.template
This commit is part of merge request !91. Comments created here will be created in the context of that merge request.
...@@ -11,6 +11,8 @@ ...@@ -11,6 +11,8 @@
Add more export formats from Typst 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 17.0.1.9.0
---------- ----------
......
...@@ -6,8 +6,8 @@ ...@@ -6,8 +6,8 @@
msgstr "" msgstr ""
"Project-Id-Version: Odoo Server 17.0\n" "Project-Id-Version: Odoo Server 17.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-29 20:00+0000\n" "POT-Creation-Date: 2025-03-25 15:22+0000\n"
"PO-Revision-Date: 2025-01-29 20:03+0000\n" "PO-Revision-Date: 2025-03-25 15:25+0000\n"
"Last-Translator: Axel PREL <axel.prel@xcg-consulting.fr>\n" "Last-Translator: Axel PREL <axel.prel@xcg-consulting.fr>\n"
"Language-Team: XCG Consulting\n" "Language-Team: XCG Consulting\n"
"Language: fr\n" "Language: fr\n"
...@@ -332,6 +332,11 @@ ...@@ -332,6 +332,11 @@
msgstr "Nom du modèle Redner" msgstr "Nom du modèle Redner"
#. module: 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 #: model:ir.model.fields.selection,name:redner.selection__redner_template__source__odoo
msgid "Odoo" msgid "Odoo"
msgstr "Odoo" msgstr "Odoo"
...@@ -464,8 +469,13 @@ ...@@ -464,8 +469,13 @@
msgstr "Sélectionner" msgstr "Sélectionner"
#. module: redner #. 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 #: model_terms:ir.ui.view,arch_db:redner.redner_template_view_form
msgid "Settings" msgid "Settings"
msgstr "Paramètres" msgstr "Paramètres"
#. module: redner #. module: redner
...@@ -467,8 +477,13 @@ ...@@ -467,8 +477,13 @@
#: model_terms:ir.ui.view,arch_db:redner.redner_template_view_form #: model_terms:ir.ui.view,arch_db:redner.redner_template_view_form
msgid "Settings" msgid "Settings"
msgstr "Paramètres" msgstr "Paramètres"
#. module: redner #. 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 #: model:ir.model.fields,field_description:redner.field_redner_template__slug
msgid "Slug" msgid "Slug"
msgstr "Slug" msgstr "Slug"
...@@ -490,6 +505,16 @@ ...@@ -490,6 +505,16 @@
msgstr "Substitution" msgstr "Substitution"
#. module: redner #. 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: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.redner_report_view
#: model_terms:ir.ui.view,arch_db:redner.view_email_template_form #: model_terms:ir.ui.view,arch_db:redner.view_email_template_form
...@@ -586,6 +611,11 @@ ...@@ -586,6 +611,11 @@
msgstr "Erreur redner : %r" msgstr "Erreur redner : %r"
#. module: redner #. 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 #: model:ir.model.fields,field_description:redner.field_redner_substitution__keyword
msgid "Variable" msgid "Variable"
msgstr "Variable" msgstr "Variable"
......
from . import substitution_mixin # isort:skip
from . import ( from . import (
ir_actions_report, ir_actions_report,
mail_template, mail_template,
......
...@@ -36,7 +36,8 @@ ...@@ -36,7 +36,8 @@
file. file.
""" """
_inherit = "ir.actions.report" _inherit = ["ir.actions.report", "substitution.mixin"]
_name = "ir.actions.report"
@api.constrains("redner_filetype", "report_type") @api.constrains("redner_filetype", "report_type")
def _check_redner_filetype(self): def _check_redner_filetype(self):
...@@ -111,30 +112,6 @@ ...@@ -111,30 +112,6 @@
filetype = rec.redner_filetype filetype = rec.redner_filetype
rec.is_redner_native_format = fmt.get_format(filetype).native 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): def get_report_metadata(self):
self.ensure_one() self.ensure_one()
...@@ -239,3 +216,19 @@ ...@@ -239,3 +216,19 @@
attachment_vals["name"], attachment_vals["name"],
) )
return buffer 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
...@@ -32,7 +32,8 @@ ...@@ -32,7 +32,8 @@
class MailTemplate(models.Model): class MailTemplate(models.Model):
"""Extended to add features of redner API""" """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) is_redner_template = fields.Boolean(string="Rendered by Redner", default=False)
...@@ -48,30 +49,6 @@ ...@@ -48,30 +49,6 @@
string="Substitutions", 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): def _patch_email_values(self, values, res_id):
conv = self.redner_substitution_ids.filtered( conv = self.redner_substitution_ids.filtered(
lambda r: r.depth == 0 lambda r: r.depth == 0
...@@ -126,3 +103,19 @@ ...@@ -126,3 +103,19 @@
"""Override to add additional variables in mail "render template" func""" """Override to add additional variables in mail "render template" func"""
variables.update({"image": lambda value: image(value)}) variables.update({"image": lambda value: image(value)})
return super().render_variable_hook(variables) 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
...@@ -89,6 +89,22 @@ ...@@ -89,6 +89,22 @@
compute="_compute_hide_placeholder_button", string="Hide Placeholder Button" 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") @api.onchange("converter")
def _onchange_converter(self): def _onchange_converter(self):
if self.converter: if self.converter:
...@@ -143,5 +159,6 @@ ...@@ -143,5 +159,6 @@
) )
def build_converter(self): def build_converter(self):
d = {} """Build a converter dictionary from substitution records."""
converters = {}
for sub in self: for sub in self:
...@@ -147,45 +164,38 @@ ...@@ -147,45 +164,38 @@
for sub in self: for sub in self:
if sub.converter == "mail_template": if sub.converter:
conv = converter.MailTemplate(sub.value, False) try:
elif sub.converter == "mail_template+deserialize": key = sub.keyword.rsplit(".", 2)[-1]
conv = converter.MailTemplate(sub.value, True) conv = self._create_converter_by_type(sub)
elif sub.converter == "constant": converters[key] = conv
conv = converter.Constant(sub.value) except KeyError:
elif sub.converter == "field": raise ValidationError(
if "." in sub.value: _("invalid converter type: %s") % sub.converter
path, name = sub.value.rsplit(".", 1) ) from None
else: return converter.Model(converters)
path, name = None, sub.value
conv = converter.Field(name) def _create_converter_by_type(self, sub):
if path: """Create appropriate converter based on type."""
conv = converter.relation(path.replace(".", "/"), conv) converter_map = {
elif sub.converter == "image-file": "mail_template": lambda: converter.MailTemplate(sub.value, False),
if "." in sub.value: "mail_template+deserialize": lambda: converter.MailTemplate(
path, name = sub.value.rsplit(".", 1) sub.value, True
else: ),
path, name = None, sub.value "constant": lambda: converter.Constant(sub.value),
conv = ImageFile(name) "field": lambda: self._create_field_or_image_converter(sub, is_image=False),
if path: "image-file": lambda: self._create_field_or_image_converter(
conv = converter.relation(path.replace(".", "/"), conv) sub, is_image=True
elif sub.converter == "image-data-url": ),
conv = ImageDataURL(sub.value) "image-data-url": lambda: ImageDataURL(sub.value),
elif sub.converter == "relation-to-many": "relation-to-many": lambda: self._create_relation_to_many_converter(sub),
# Unpack the result of finding a field with its sort order into "relation-path": lambda: converter.relation(
# variable names. sub.value, sub.get_children().build_converter()
value, sorted = parse_sorted_field(sub.value) ),
conv = converter.RelationToMany( }
value, return converter_map[sub.converter]()
None,
sortkey=sortkey(sorted) if sorted else None, def _create_field_or_image_converter(self, sub, is_image):
converter=sub.get_children().build_converter(), """Create a converter for 'field' or 'image-file' types."""
) path, name = sub.value.rsplit(".", 1) if "." in sub.value else (None, sub.value)
elif sub.converter == "relation-path": conv = ImageFile(name) if is_image else converter.Field(name)
conv = converter.relation( return converter.relation(path.replace(".", "/"), conv) if path else conv
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
...@@ -191,2 +201,37 @@ ...@@ -191,2 +201,37 @@
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)()
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
...@@ -41,5 +41,6 @@ ...@@ -41,5 +41,6 @@
<group string="Substitutions"> <group string="Substitutions">
<field name="substitution_ids" nolabel="1" colspan="2"> <field name="substitution_ids" nolabel="1" colspan="2">
<tree editable="top" default_order="keyword asc"> <tree editable="top" default_order="keyword asc">
<field name="sequence" widget="handle" />
<field name="keyword" /> <field name="keyword" />
<field name="converter" /> <field name="converter" />
...@@ -44,5 +45,6 @@ ...@@ -44,5 +45,6 @@
<field name="keyword" /> <field name="keyword" />
<field name="converter" /> <field name="converter" />
<field name="value_type" />
<field name="render_model" column_invisible="1" /> <field name="render_model" column_invisible="1" />
<field name="value_placeholder" column_invisible="1" /> <field name="value_placeholder" column_invisible="1" />
<field <field
......
...@@ -37,5 +37,6 @@ ...@@ -37,5 +37,6 @@
<group string="Substitutions" invisible="not is_redner_template"> <group string="Substitutions" invisible="not is_redner_template">
<field name="redner_substitution_ids" nolabel="1" colspan="2"> <field name="redner_substitution_ids" nolabel="1" colspan="2">
<tree editable="top" default_order="keyword asc"> <tree editable="top" default_order="keyword asc">
<field name="sequence" widget="handle" />
<field name="keyword" /> <field name="keyword" />
<field name="converter" /> <field name="converter" />
...@@ -40,5 +41,6 @@ ...@@ -40,5 +41,6 @@
<field name="keyword" /> <field name="keyword" />
<field name="converter" /> <field name="converter" />
<field name="value_type" />
<field name="render_model" column_invisible="1" /> <field name="render_model" column_invisible="1" />
<field name="value_placeholder" column_invisible="1" /> <field name="value_placeholder" column_invisible="1" />
<field <field
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment