diff --git a/.hgtags b/.hgtags index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_LmhndGFncw==..880fdce6f6719b9201b2300a6007437d0beabde1_LmhndGFncw== 100644 --- a/.hgtags +++ b/.hgtags @@ -31,3 +31,4 @@ 244d816f3ee9c11634b29bcb601594c86e2846eb 17.0.1.1.0 37bbac9b58d9d074f0bf7574ae6b4a57bad8abcd 17.0.1.1.1 07224c0905af4f9c9c1d47d4acf369ce55c5c859 17.0.1.2.0 +a6c94d540cb228b9dcf7dca24e53a35158b2c9ef 17.0.1.3.0 diff --git a/NEWS.rst b/NEWS.rst index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_TkVXUy5yc3Q=..880fdce6f6719b9201b2300a6007437d0beabde1_TkVXUy5yc3Q= 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -2,6 +2,13 @@ Changelog ========= +17.0.1.3.0 +---------- + +- Add missing python-magic requirement for package. +- Add dynamic expression button for substitution line and new converter features. +- Refactor redner.template model to improve template management. + 17.0.1.2.0 ---------- diff --git a/__manifest__.py b/__manifest__.py index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_X19tYW5pZmVzdF9fLnB5..880fdce6f6719b9201b2300a6007437d0beabde1_X19tYW5pZmVzdF9fLnB5 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -21,7 +21,7 @@ { "name": "Redner", "license": "AGPL-3", - "version": "17.0.1.2.0", + "version": "17.0.1.3.0", "category": "Reporting", "author": "XCG Consulting", "website": "https://orbeet.io/", @@ -30,7 +30,6 @@ "data": [ "wizard/mail_compose_message_views.xml", "wizard/template_list_view.xml", - "wizard/expression_builder_view.xml", "security/ir.model.access.csv", "views/redner_template.xml", "views/mail_template.xml", @@ -38,7 +37,10 @@ "views/menu.xml", ], "assets": { - "web.assets_backend": ["redner/static/src/js/redner_report_action.esm.js"], + "web.assets_backend": [ + "redner/static/src/js/redner_report_action.esm.js", + "redner/static/src/components/**/*", + ], }, "installable": True, "external_dependencies": {"python": ["requests_unixsocket", "python-magic"]}, diff --git a/i18n/fr.po b/i18n/fr.po index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_aTE4bi9mci5wbw==..880fdce6f6719b9201b2300a6007437d0beabde1_aTE4bi9mci5wbw== 100644 --- 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: 2024-10-07 08:08+0000\n" -"PO-Revision-Date: 2024-10-07 10:12+0200\n" +"POT-Creation-Date: 2024-12-18 16:34+0000\n" +"PO-Revision-Date: 2024-12-18 16:36+0000\n" "Last-Translator: Axel PREL <axel.prel@xcg-consulting.fr>\n" "Language-Team: XCG Consulting\n" "Language: fr\n" @@ -15,8 +15,8 @@ "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -"X-Generator: Poedit 3.0.1\n" +"X-Generator: Poedit 3.5\n" #. module: redner #. odoo-python #: code:addons/redner/models/redner_template.py:0 @@ -19,11 +19,10 @@ #. module: redner #. odoo-python #: code:addons/redner/models/redner_template.py:0 -#, python-format msgid "%s (copy)" msgstr "%s (copie)" #. module: redner #. odoo-python #: code:addons/redner/models/ir_actions_report.py:0 @@ -24,10 +23,9 @@ msgid "%s (copy)" msgstr "%s (copie)" #. module: redner #. odoo-python #: code:addons/redner/models/ir_actions_report.py:0 -#, python-format msgid "(Native)" msgstr "(Natif)" @@ -65,10 +63,9 @@ #. module: redner #. odoo-python #: code:addons/redner/redner.py:0 -#, python-format msgid "Cannot Establish a connection to server" msgstr "La connexion au serveur ne peut pas être établie" #. module: redner #. odoo-python #: code:addons/redner/redner.py:0 @@ -69,11 +66,10 @@ msgid "Cannot Establish a connection to server" msgstr "La connexion au serveur ne peut pas être établie" #. module: redner #. odoo-python #: code:addons/redner/redner.py:0 -#, python-format msgid "" "Cannot find redner config url. Please add it in odoo.conf or in ir." "config_parameter" msgstr "" @@ -76,9 +72,9 @@ msgid "" "Cannot find redner config url. Please add it in odoo.conf or in ir." "config_parameter" msgstr "" -"La configuration de l'url de Redner est introuvable. Merci de l'ajouter " -"dans le fichier odoo.conf ou dans ir.config.parameter" +"La configuration de l'url de Redner est introuvable. Merci de l'ajouter dans " +"le fichier odoo.conf ou dans ir.config.parameter" #. module: redner #: model:ir.model.fields,help:redner.field_redner_template__body @@ -148,6 +144,11 @@ msgstr "Nom à afficher" #. module: redner +#: model:ir.model.fields,field_description:redner.field_redner_substitution__dynamic_placeholder_button_hidden +msgid "Dynamic Placeholder Button Hidden" +msgstr "Dynamic Placeholder Button Hidden" + +#. module: redner #: model:ir.model.fields,field_description:redner.field_redner_substitution__template_id msgid "Email Template" msgstr "Modèle de courriel" @@ -190,8 +191,7 @@ #. module: redner #. odoo-python #: code:addons/redner/models/redner_template.py:0 -#, python-format -msgid "Failed to update render template, %s" +msgid "Failed to update Redner template, %s" msgstr "Échec de la mise à jour du modèle redner, %s" #. module: redner @@ -202,7 +202,6 @@ #. module: redner #. odoo-python #: code:addons/redner/models/ir_actions_report.py:0 -#, python-format msgid "Field 'Output Format' is required for Redner report" msgstr "Le champ 'Format de sortie' est requis pour le rapport Redner" @@ -274,9 +273,8 @@ #: model:ir.model.fields,help:redner.field_ir_actions_report__redner_multi_in_one msgid "" "If you execute a report on several records, by default Odoo will generate a " -"ZIP file that contains as many files as selected records. If you enable " -"this option, Odoo will generate instead a single report for the selected " -"records." +"ZIP file that contains as many files as selected records. If you enable this " +"option, Odoo will generate instead a single report for the selected records." msgstr "" "Si vous exécutez un rapport sur plusieurs enregistrements, par défaut, Odoo " "générera un fichier ZIP qui contiendra autant de fichiers que " @@ -301,7 +299,7 @@ #. module: redner #: model:ir.model.fields,field_description:redner.field_redner_report__ir_actions_report_id msgid "Ir Actions Report" -msgstr "Rapport d'action" +msgstr "Ir Actions Report" #. module: redner #: model:ir.model.fields,field_description:redner.field_redner_template__is_mjml @@ -320,6 +318,7 @@ msgstr "Les mots-clés vont être mis à jour. Continuer ?" #. module: redner +#: model:ir.model.fields,field_description:redner.field_redner_substitution__lang #: model:ir.model.fields,field_description:redner.field_redner_template__language msgid "Language" msgstr "Langage" @@ -350,6 +349,11 @@ msgstr "Modèle LibreOffice" #. module: redner +#: model:ir.model.fields,field_description:redner.field_redner_template__template_data_filename +msgid "Libreoffice Template Filename" +msgstr "Nom de fichier du modèle LibreOffice" + +#. module: redner #: model:ir.model.fields,field_description:redner.field_redner_template__locale_id msgid "Locale" msgstr "Localisation" @@ -365,6 +369,12 @@ msgstr "Plusieurs enregistrements dans un seul rapport Redner" #. module: redner +#. odoo-python +#: code:addons/redner/models/redner_substitution.py:0 +msgid "N/A" +msgstr "N/A" + +#. module: redner #: model:ir.model.fields,field_description:redner.field_redner_template__name #: model:ir.model.fields,field_description:redner.field_template_list_wizard__name msgid "Name" @@ -396,6 +406,15 @@ msgstr "OpenDocument + mustache" #. module: redner +#: model:ir.model.fields,help:redner.field_redner_substitution__lang +msgid "" +"Optional translation language (ISO code) to select when sending out an " +"email. If not set, the english version will be used. This should usually be " +"a placeholder expression that provides the appropriate language, e.g. " +"{{ object.partner_id.lang }}." +msgstr "" + +#. module: redner #: model:ir.model.fields,help:redner.field_redner_template__locale_id msgid "Optional translation language (ISO code)." msgstr "Langue de traduction facultative (code ISO)." @@ -443,6 +462,17 @@ msgstr "Modèles Redner" #. module: redner +#. odoo-python +#: code:addons/redner/controllers/main.py:0 +msgid "Redner action report not found for report_name %s" +msgstr "Action du rapport redner introuvable pour le rapport %s" + +#. module: redner +#: model:ir.model.fields,field_description:redner.field_redner_substitution__model +msgid "Related Report Model" +msgstr "Modèle de Rapport Associé" + +#. module: redner #: model:ir.model.fields.selection,name:redner.selection__redner_substitution__converter__relation-path msgid "Relation Path" msgstr "Chemin relationnel" @@ -459,6 +489,11 @@ msgstr "Rendu par Redner" #. module: redner +#: model:ir.model.fields,field_description:redner.field_redner_substitution__render_model +msgid "Rendering Model" +msgstr "Modèle de rendu" + +#. module: redner #: model:ir.model.fields,field_description:redner.field_redner_substitution__ir_actions_report_id msgid "Report" msgstr "Rapport" @@ -596,5 +631,14 @@ msgstr "Le type de champ (booléen, texte, ...)" #. module: redner +#: model:ir.model.fields,help:redner.field_redner_template__name +msgid "" +"The name of the template. Once the template is created, updating the name is " +"not allowed. To change the name, delete the template and create a new one." +msgstr "" +"Le nom du modèle. Une fois le modèle créé, la mise à jour du nom n'est pas " +"autorisée. Pour modifier le nom, supprimez le modèle et créez-en un nouveau." + +#. module: redner #. odoo-python #: code:addons/redner/models/ir_actions_report.py:0 @@ -599,6 +643,5 @@ #. odoo-python #: code:addons/redner/models/ir_actions_report.py:0 -#, python-format msgid "" "The output format cannot be different from the extension defined in \"Save " "as attachment prefix\"." @@ -626,15 +669,10 @@ "browser PDF means the report will be rendered using Wkhtmltopdf and " "downloaded by the user." msgstr "" -"Le type de rapport qui va être rendu, chaque type ayant sa propre méthode " -"de rendu. \"HTML\" signifie que le rapport sera ouvert directement dans le " -"navigateur. \"PDF\" signifie que le rapport va être rendu par Wkhtmltopdf " -"et ensuite téléchargé par l'utilisateur." - -#. module: redner -#: model:ir.model.fields,help:redner.field_redner_template__name -msgid "This is a name of template mjml redner" -msgstr "C'est le nom du template mjml redner" +"Le type de rapport qui va être rendu, chaque type ayant sa propre méthode de " +"rendu. \"HTML\" signifie que le rapport sera ouvert directement dans le " +"navigateur. \"PDF\" signifie que le rapport va être rendu par Wkhtmltopdf et " +"ensuite téléchargé par l'utilisateur." #. module: redner #: model:ir.model.fields,help:redner.field_redner_template__preview @@ -651,4 +689,12 @@ #. module: redner #. odoo-python +#: code:addons/redner/models/redner_template.py:0 +msgid "" +"Unable to update the template data. Please check the logs for more details." +msgstr "" +"Unable to update the template data. Please check the logs for more details." + +#. module: redner +#. odoo-python #: code:addons/redner/redner.py:0 @@ -654,6 +700,5 @@ #: code:addons/redner/redner.py:0 -#, python-format msgid "Unexpected redner error: %r" msgstr "Erreur redner : %r" #. module: redner @@ -656,7 +701,12 @@ msgid "Unexpected redner error: %r" msgstr "Erreur redner : %r" #. module: redner +#: model:ir.model.fields,field_description:redner.field_redner_substitution__value_placeholder +msgid "Value Placeholder" +msgstr "Placeholder de l'expresion" + +#. module: redner #: model:ir.model.fields,field_description:redner.field_redner_substitution__keyword msgid "Variable" msgstr "Variable" @@ -676,7 +726,6 @@ #. odoo-python #: code:addons/redner/models/mail_template.py:0 #: code:addons/redner/models/redner_report.py:0 -#, python-format msgid "" "We received an unexpected error from redner server. Please contact your " "administrator" @@ -698,7 +747,42 @@ #. module: redner #. odoo-python #: code:addons/redner/models/redner_substitution.py:0 -#, python-format +msgid "e.g: name or partner_id.name" +msgstr "par ex. name or partner_id.name" + +#. module: redner +#. odoo-python +#: code:addons/redner/models/redner_substitution.py:0 +msgid "e.g: partner_id/category_id/name ou partner_id/child_ids[]" +msgstr "par ex. partner_id/category_id/name ou partner_id/child_ids[]" + +#. module: redner +#. odoo-python +#: code:addons/redner/models/redner_substitution.py:0 +msgid "e.g: tax_ids" +msgstr "par ex. tax_ids" + +#. module: redner +#. odoo-python +#: code:addons/redner/models/redner_substitution.py:0 +msgid "e.g: {{ object.get_partner_info() | safe }}" +msgstr "par ex. {{ object.get_partner_info() | safe }}" + +#. module: redner +#. odoo-python +#: code:addons/redner/models/redner_substitution.py:0 +msgid "e.g: {{object.partner_id.name}}" +msgstr "par ex. {{object.partner_id.name}}" + +#. module: redner +#. odoo-python +#: code:addons/redner/models/redner_substitution.py:0 +msgid "e.g: www.orbeet.io" +msgstr "par ex. www.orbeet.io" + +#. module: redner +#. odoo-python +#: code:addons/redner/models/redner_substitution.py:0 msgid "invalid converter type: %s" msgstr "type de convertisseur invalide : %s" diff --git a/models/redner_substitution.py b/models/redner_substitution.py index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_bW9kZWxzL3JlZG5lcl9zdWJzdGl0dXRpb24ucHk=..880fdce6f6719b9201b2300a6007437d0beabde1_bW9kZWxzL3JlZG5lcl9zdWJzdGl0dXRpb24ucHk= 100644 --- a/models/redner_substitution.py +++ b/models/redner_substitution.py @@ -25,8 +25,34 @@ from ..converter import ImageDataURL, ImageFile from ..utils.sorting import parse_sorted_field, sortkey +FIELD = "field" +CONSTANT = "constant" +MAIL_TEMPLATE = "mail_template" +MAIL_TEMPLATE_DESERIALIZE = "mail_template+deserialize" +IMAGE_FILE = "image-file" +IMAGE_DATAURL = "image-data-url" +RELATION_2MANY = "relation-to-many" +RELATION_PATH = "relation-path" + +CONVERTER_SELECTION = [ + (MAIL_TEMPLATE, "Odoo Template"), + (MAIL_TEMPLATE_DESERIALIZE, "Odoo Template + Eval"), + (FIELD, "Field"), + (IMAGE_FILE, "Image file"), + (IMAGE_DATAURL, "Image data url"), + (RELATION_2MANY, "Relation to many"), + (RELATION_PATH, "Relation Path"), + (CONSTANT, "Constant value"), +] + +DYNAMIC_PLACEHOLDER_ALLOWED_CONVERTERS = ( + FIELD, + MAIL_TEMPLATE, + MAIL_TEMPLATE_DESERIALIZE, +) + class Substitution(models.Model): """Substitution values for a Redner email message""" _name = "redner.substitution" @@ -28,8 +54,9 @@ class Substitution(models.Model): """Substitution values for a Redner email message""" _name = "redner.substitution" + _inherit = ["mail.render.mixin"] _description = "Redner Substitution" keyword = fields.Char(string="Variable", help="Template variable name") @@ -40,5 +67,13 @@ comodel_name="ir.actions.report", string="Report" ) + model = fields.Char( + "Related Report Model", + related="ir_actions_report_id.model", + index=True, + store=True, + readonly=True, + ) + value = fields.Char(string="Expression") @@ -43,15 +78,12 @@ value = fields.Char(string="Expression") - converter = fields.Selection( - selection=[ - ("mail_template", "Odoo Template"), - ("mail_template+deserialize", "Odoo Template + Eval"), - ("field", "Field"), - ("image-file", "Image file"), - ("image-data-url", "Image data url"), - ("relation-to-many", "Relation to many"), - ("relation-path", "Relation Path"), - ("constant", "Constant value"), - ] + converter = fields.Selection(selection=CONVERTER_SELECTION) + + depth = fields.Integer(string="Depth", compute="_compute_depth", store=True) + + value_placeholder = fields.Char(compute="_compute_value_placeholder") + + dynamic_placeholder_button_hidden = fields.Boolean( + compute="_compute_dynamic_placeholder_button_hidden" ) @@ -56,9 +88,40 @@ ) - depth = fields.Integer(string="Depth", compute="_compute_depth", store=True) + @api.depends("converter") + def _compute_value_placeholder(self): + """Computes a dynamic placeholder that depends on the selected type + to help the user inputs their data. + """ + for subsitution in self: + placeholder = _("N/A") + if subsitution.converter == FIELD: + placeholder = _("e.g: name or partner_id.name") + elif subsitution.converter == MAIL_TEMPLATE: + placeholder = _("e.g: {{object.partner_id.name}}") + elif subsitution.converter == MAIL_TEMPLATE_DESERIALIZE: + placeholder = _("e.g: {{ object.get_partner_info() | safe }}") + elif subsitution.converter == RELATION_2MANY: + placeholder = _("e.g: tax_ids") + elif subsitution.converter == RELATION_PATH: + placeholder = _( + "e.g: partner_id/category_id/name ou partner_id/child_ids[]" + ) + elif subsitution.converter == CONSTANT: + placeholder = _("e.g: www.orbeet.io") + subsitution.value_placeholder = placeholder + + @api.depends("value") + def _compute_render_model(self): + for substitution in self: + if substitution.ir_actions_report_id: + substitution.render_model = substitution.model + elif substitution.template_id: + substitution.render_model = substitution.template_id.model_id.model + else: + substitution.render_model = False @api.depends("keyword") def _compute_depth(self): for record in self: record.depth = record.keyword.count(".") @@ -59,9 +122,16 @@ @api.depends("keyword") def _compute_depth(self): for record in self: record.depth = record.keyword.count(".") + @api.depends("converter") + def _compute_dynamic_placeholder_button_hidden(self): + for record in self: + record.dynamic_placeholder_button_hidden = ( + record.converter not in DYNAMIC_PLACEHOLDER_ALLOWED_CONVERTERS + ) + def get_children(self): return self.search( [ @@ -119,40 +189,3 @@ d[sub.keyword.rsplit(".", 2)[-1]] = conv return converter.Model("", d) - - def action_build_expression(self): - if not (self and self.ir_actions_report_id): - if not self.template_id: - # neither a report nor a mail template - return - else: - model = self.env.get(self.template_id.model_id.model) - else: - model = self.env.get(self.ir_actions_report_id.model) - if model is None: - return - - # reset the older substitution value - self.value = "" - - wizard = self.env["expression.builder.wizard"].create( - { - "substitution_id": self.id, - "expression": "", - } - ) - - vals_list = wizard.get_fields(model, self.converter) - for val in vals_list: - val["wizard_id"] = wizard.id - - fields_list = self.env["expression.builder.field"].create(vals_list) - wizard.suggested_fields = [(6, 0, fields_list.ids)] - - return { - "type": "ir.actions.act_window", - "res_model": "expression.builder.wizard", - "view_mode": "form", - "res_id": wizard.id, - "target": "new", - } diff --git a/models/redner_template.py b/models/redner_template.py index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_bW9kZWxzL3JlZG5lcl90ZW1wbGF0ZS5weQ==..880fdce6f6719b9201b2300a6007437d0beabde1_bW9kZWxzL3JlZG5lcl90ZW1wbGF0ZS5weQ== 100644 --- a/models/redner_template.py +++ b/models/redner_template.py @@ -26,10 +26,10 @@ from odoo.tools.cache import ormcache from ..redner import REDNER_API_PATH, Redner -from ..utils.mimetype import b64_to_extension +from ..utils.mimetype import get_file_extension logger = logging.getLogger(__name__) _redner = None LANGUAGE_MJML_MUSTACHE = "text/mjml|mustache" @@ -30,9 +30,10 @@ logger = logging.getLogger(__name__) _redner = None LANGUAGE_MJML_MUSTACHE = "text/mjml|mustache" +LANGUAGE_OPENDOCUMENT_MUSTACHE = "application/vnd.oasis.opendocument.text|od+mustache" DEFAULT_LANGUAGE = LANGUAGE_MJML_MUSTACHE COMPUTED_FIELDS = [ "body", @@ -87,7 +88,11 @@ string="Name", default="New", required=True, - help="This is a name of template mjml redner", + help=( + "The name of the template. Once the template is created, " + "updating the name is not allowed. To change the name, " + "delete the template and create a new one." + ), ) description = fields.Char( @@ -138,7 +143,7 @@ ("text/html|mustache", "HTML + mustache"), (LANGUAGE_MJML_MUSTACHE, "MJML + mustache"), ( - "application/vnd.oasis.opendocument.text|od+mustache", + LANGUAGE_OPENDOCUMENT_MUSTACHE, "OpenDocument + mustache", ), ], @@ -163,7 +168,8 @@ string="Libreoffice Template", readonly=False, compute="_compute_template_data", + inverse="_inverse_template_data", ) template_data_filename = fields.Char( string="Libreoffice Template Filename", @@ -166,7 +172,7 @@ ) template_data_filename = fields.Char( string="Libreoffice Template Filename", - readonly=True, + compute="_compute_template_data_filename", ) @@ -171,7 +177,6 @@ ) - def import_from_redner(self): - tl_wizard = self.env["template.list.wizard"] - templates = self.list_external_templates() - tl_wizard.populate(templates) + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- @@ -177,13 +182,9 @@ - return { - "type": "ir.actions.act_window", - "res_model": "template.list.wizard", - "view_mode": "tree", - "view_id": self.env.ref("redner.view_template_list_wizard_tree").id, - "target": "new", - #'context': self.env.context, - } + @api.depends("body", "template_data") + def _compute_keywords(self): + for record in self: + record.detected_keywords = "\n".join(record.template_varlist_fetch()) @api.depends("body", "template_data") def _compute_preview(self): for record in self: @@ -186,7 +187,6 @@ @api.depends("body", "template_data") def _compute_preview(self): for record in self: - record.preview = False - if record.redner_id: + if record.body or record.template_data: response = self.get_preview(record.redner_id) @@ -192,5 +192,146 @@ response = self.get_preview(record.redner_id) - if response: - b64 = base64.b64encode(response.content) - record.preview = b64 + + # Check if response is valid and has content + if response and hasattr(response, "content"): + record.preview = base64.b64encode(response.content) + else: + record.preview = False # If no valid response, set preview to False + else: + record.preview = False + + def _compute_template(self): + """ + Computes the template values for the records and applies cached or fetched data. + """ + for record in self: + if not record.id or not record.redner_id: + continue + + # Fetch the cached template + cached_template = self._get_cached_template(record.id) + + if not any([getattr(record, f) for f in COMPUTED_FIELDS]): + # If all computed fields are undefined, populate them + # from the cached template. + for f in COMPUTED_FIELDS + EDITABLE_FIELDS: + if f in cached_template: + setattr(record, f, cached_template[f]) + else: + # If at least one field is defined, populate only undefined fields + for f in COMPUTED_FIELDS: + if not getattr(record, f): + setattr(record, f, cached_template.get(f, None)) + + def _inverse_template_data(self): + """ + Inverse function for `template_data`. Called when `template_data` is" + manually set. + """ + for record in self: + if not record.template_data or not record.id or not record.language: + continue + try: + # Update the external system with the new data + self._set_cached_template(record.id, record.template_data) + except Exception as e: + logger.error("Failed to update template data in Redner: %s", e) + raise ValidationError( + _( + "Unable to update the template data. Please check the logs " + "for more details." + ) + ) from e + + def _compute_template_data(self): + for record in self: + # Skip records that do not have a redner_id or are missing essential data + if not record.id or not record.redner_id: + continue + + if ( + not record.template_data + and record.language == LANGUAGE_OPENDOCUMENT_MUSTACHE + ): + cached_template = self._get_cached_template(record.id) + + template_data = ( + cached_template.get("template_data") if cached_template else None + ) + if template_data: + try: + # Perform base64 encoding and store the result + record.template_data = base64.b64encode(template_data).decode( + "utf-8" + ) + except Exception as e: + logger.error( + "Failed to encode redner template data for record %s: %s", + record.id, + e, + ) + continue # Proceed with next record if encoding fails + + @api.depends("template_data") + def _compute_template_data_filename(self): + """Compute the template filename based on the template data""" + for record in self: + if not record.id or not record.redner_id or not record.template_data: + record.template_data_filename = ( + f"{record.name}.odt" if record.name else "template.odt" + ) + else: + try: + # Attempt to extract the file extension from the base64 data + ext = get_file_extension(record.template_data) + record.template_data_filename = f"{record.name}{ext}" + + except Exception as e: + logger.error("Error while computing template filename: %s", e) + record.template_data_filename = False + + @property + def redner(self): + """ + Returns a Redner instance. + Recomputes the instance if any system parameter changes. + Uses a global variable to cache the instance across sessions. + """ + global _redner, _redner_params + + # Fetch current system parameters + config_model = self.env["ir.config_parameter"].sudo() + current_params = { + "api_key": config_model.get_param("redner.api_key"), + "server_url": config_model.get_param("redner.server_url"), + "account": config_model.get_param("redner.account"), + "timeout": int(config_model.get_param("redner.timeout", default="20")), + } + + # Check if parameters have changed or if _redner is None + if _redner is None or _redner_params != current_params: + # Recompute the Redner instance + _redner = Redner( + current_params["api_key"], + current_params["server_url"], + current_params["account"], + current_params["timeout"], + ) + _redner_params = current_params # Update the stored parameters + + return _redner + + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- + + @api.model_create_multi + def create(self, vals_list): + """Overwrite create to create redner template""" + + for vals in vals_list: + # If "name" is missing or equals "New", set the source and + # proceed with creation. + if not vals.get("name", False) or vals["name"] == "New": + vals["source"] = "redner" + continue # Continue processing the next record @@ -196,3 +337,99 @@ - def get_preview(self, redner_id): + # Prepare template params according to the selected language. + # Use template data field if the selected language is "od"; + # otherwise the body field is used. + produces, language = vals.get("language", DEFAULT_LANGUAGE).split("|") + body, body_format = ( + (vals.get("template_data", ""), "base64") + if language == "od+mustache" + else (vals.get("body"), "text") + ) + + locale = self.env["res.lang"].browse(vals.get("locale_id")).code + + # We depend on the API for consistency here + # So raised error should not result with a created template + if language and body: + template = self.redner.templates.account_template_add( + language=language, + body=body, + name=vals.get("name"), + description=vals.get("description"), + produces=produces, + body_format=body_format, + version=fields.Datetime.to_string(fields.Datetime.now()), + locale=locale if locale else "fr_FR", + ) + vals["redner_id"] = template["name"] + else: + # If the language and body are not defined, we return early + # to prevent saving an incomplete template and sending it + # to the Redner server. + return self + + return super().create(vals_list) + + def write(self, vals): + """Overwrite write to update redner template""" + + # Determine if we should update redner or not + should = self._should_update_redner(vals) + + # Perform the write operation + ret = super().write(vals) + + # Update Redner templates if applicable + if should: + for record in self: + if ( + not self.env.context.get("importing") # Skip during imports + and record.allow_modification_from_odoo + ): + record._sync_with_redner() + + return ret + + def unlink(self): + """Overwrite unlink to delete redner template""" + + # We do NOT depend on the API for consistency here + # So raised error should not result block template deletion + for record in self: + if record.redner_id and record.allow_modification_from_odoo: + try: + self.redner.templates.account_template_delete(record.redner_id) + except Exception as e: + logger.warning( + "Failed to delete Redner template with ID %s. Reason: %s", + record.redner_id, + e, + ) + self.env.registry.clear_cache() + return super().unlink() + + def copy(self, default=None): + self.ensure_one() + default = dict(default or {}, name=_("%s (copy)") % self.name) + return super().copy(default) + + # ------------------------------------------------------------ + # ACTIONS / BUSINESS + # ------------------------------------------------------------ + + def _should_update_redner(self, vals): + """ + Determine if Redner should be updated based on the modified fields. + """ + for field in COMPUTED_FIELDS + ["template_data"]: + if field in vals: + current_value = getattr(self, field) + if vals[field] != current_value and vals[field]: + return True + return False + + def _sync_with_redner(self): + """ + Sync the current record's template with Redner. + """ + self.ensure_one() try: @@ -198,4 +435,31 @@ try: - preview = self.redner.templates.account_template_preview(redner_id) - return preview + # Check if 'language' is a valid string before splitting + if isinstance(self.language, str) and "|" in self.language: + produces, language = self.language.split("|") + else: + logger.warning( + "Invalid language format for record %s: %s", + self.id, + self.language, + ) + body, body_format = ( + (self.template_data.decode(), "base64") + if language == "od+mustache" + else (self.body, "text") + ) + + # Use the existing `redner_id` + redner_id = self.redner_id + + self._update_redner_template( + template_id=redner_id, + language=language, + body=body, + name=self.name, + description=self.description, + produces=produces, + body_format=body_format, + version=fields.Datetime.to_string(self.write_date), + locale=self.locale_id.code, + ) except Exception as e: @@ -201,6 +465,38 @@ except Exception as e: - logger.error("Failed to get preview of redner template :%s", e) - return + logger.error("Failed to sync with Redner template: %s", e) + raise ValidationError(_("Failed to update Redner template, %s") % e) from e + + def _update_redner_template(self, **kwargs): + """ + Perform the Redner `account_template_update` API call. + + :param kwargs: Payload for the `account_template_update` API. + """ + try: + self.redner.templates.account_template_update(**kwargs) + self.env.registry.clear_cache() + except Exception as e: + logger.error("Redner API update failed: %s", e) + raise ValueError("Unable to update the Redner template.") from e + + @ormcache("redner_id") + def get_preview(self, redner_id): + """ + Retrieve the preview of a Redner template by its ID. + Returns None if the redner_id is not provided or if the preview + cannot be retrieved. + """ + if not redner_id: + return None + + result = None + try: + result = self.redner.templates.account_template_preview(redner_id) + except Exception as e: + logger.error( + "Failed to get preview of Redner template with ID %s: %s", redner_id, e + ) + return result def _to_odoo_template(self, template): """ @@ -249,5 +545,6 @@ logger.error("Failed to read Redner template: %s", e) return {} - def _compute_template(self): + @ormcache("record_id", "new_template_data") + def _set_cached_template(self, record_id, new_template_data): """ @@ -253,3 +550,3 @@ """ - Computes the template values for the records and applies cached or fetched data. + Sets and caches the template in Redner for a given record. """ @@ -255,5 +552,5 @@ """ - for record in self: - if not record.id or not record.redner_id: - continue + record = self.browse(record_id) + if not record.redner_id: + raise ValueError("The record must have a valid Redner ID.") @@ -259,4 +556,9 @@ - # Fetch the cached template - cached_template = self._get_cached_template(record.id) + try: + produces, language = record.language.split("|") + body, body_format = ( + (new_template_data.decode(), "base64") + if language == "od+mustache" + else (record.body, "text") + ) @@ -262,13 +564,21 @@ - if not any([getattr(record, f) for f in COMPUTED_FIELDS]): - # If all computed fields are undefined, populate them - # from the cached template. - for f in COMPUTED_FIELDS + EDITABLE_FIELDS: - if f in cached_template: - setattr(record, f, cached_template[f]) - else: - # If at least one field is defined, populate only undefined fields - for f in COMPUTED_FIELDS: - if not getattr(record, f): - setattr(record, f, cached_template.get(f, None)) + # Send the updated template to the external system + self.redner.templates.account_template_update( + template_id=record.redner_id, + language=language, + body=body, + name=record.name, + description=record.description, + produces=produces, + body_format=body_format, + version=fields.Datetime.to_string(record.write_date), + locale=record.locale_id.code, + ) + + self.env.registry.clear_cache() + + return True + except Exception as e: + logger.error("Failed to set Redner template: %s", e) + raise ValueError("Unable to update the Redner template.") from e @@ -274,26 +584,49 @@ - @api.depends("template_data") - def _compute_template_data(self): - for record in self: - if not record.id or not record.redner_id: - continue - if not record.template_data: - try: - cached_template = self._get_cached_template(record.id) - if "template_data" in cached_template: - new_val = cached_template["template_data"] - encoded = base64.b64encode(new_val).decode("utf-8") - ext = ".odt" # default extension - try: - ext = b64_to_extension(encoded) - except Exception as e: - logger.error("Failed to read extension from file:%s", e) - return - record.template_data = encoded - record.template_data_filename = "template" + ext - except Exception as e: - logger.error("Failed to read redner template :%s", e) - return + def import_from_redner(self): + tl_wizard = self.env["template.list.wizard"] + templates = self.list_external_templates() + tl_wizard.populate(templates) + + return { + "type": "ir.actions.act_window", + "res_model": "template.list.wizard", + "view_mode": "list", + "view_id": self.env.ref("redner.view_template_list_wizard_tree").id, + "target": "new", + #'context': self.env.context, + } + + @api.model + def get_keywords(self): + """Return template redner keywords""" + self.ensure_one() + + varlist = self.template_varlist_fetch() + + for name in varlist: + while "." in name: + name = name[: name.rfind(".")] + if name not in varlist: + varlist.append(name) + + varlist.sort() + + return varlist + + @api.model + @ormcache("self.redner_id") + def template_varlist_fetch(self): + """Retrieve the list of variables present in the template.""" + self.ensure_one() + try: + if not self.redner_id: + return [] + + return self.redner.templates.account_template_varlist(self.redner_id) + + except Exception as e: + logger.warning("Failed to fetch account template varlist: %s", e) + return [] def list_external_templates(self): try: @@ -312,208 +645,6 @@ new_templates.append(self._to_odoo_template(template)) return new_templates - @property - def redner(self): - """ - Returns a Redner instance. - Recomputes the instance if any system parameter changes. - Uses a global variable to cache the instance across sessions. - """ - global _redner, _redner_params - - # Fetch current system parameters - config_model = self.env["ir.config_parameter"].sudo() - current_params = { - "api_key": config_model.get_param("redner.api_key"), - "server_url": config_model.get_param("redner.server_url"), - "account": config_model.get_param("redner.account"), - "timeout": int(config_model.get_param("redner.timeout", default="20")), - } - - # Check if parameters have changed or if _redner is None - if _redner is None or _redner_params != current_params: - # Recompute the Redner instance - _redner = Redner( - current_params["api_key"], - current_params["server_url"], - current_params["account"], - current_params["timeout"], - ) - _redner_params = current_params # Update the stored parameters - - return _redner - - @api.model_create_multi - def create(self, vals_list): - """Overwrite create to create redner template""" - - for vals in vals_list: - if not vals.get("name", False) or vals["name"] == "New": - # source - vals["source"] = "redner" - return super().create(vals_list) - - # Prepare template params according to the selected language. - # Use template data field if the selected language is "od"; - # otherwise the body field is used. - produces, language = vals.get("language", DEFAULT_LANGUAGE).split("|") - body, body_format = ( - (vals.get("template_data", ""), "base64") - if language == "od+mustache" - else (vals.get("body"), "text") - ) - - locale = self.env["res.lang"].browse(vals.get("locale_id")).code - - # We depend on the API for consistency here - # So raised error should not result with a created template - template = self.redner.templates.account_template_add( - language=language, - body=body, - name=vals.get("name"), - description=vals.get("description"), - produces=produces, - body_format=body_format, - version=fields.Datetime.to_string(fields.Datetime.now()), - locale=locale if locale else "fr_FR", - ) - vals["redner_id"] = template["name"] - - return super().create(vals_list) - - def write(self, vals): - """Overwrite write to update redner template""" - # We depend on the API for consistency here - # So raised error should not result with an updated template - - if "allow_modification_from_odoo" in vals: - if vals["allow_modification_from_odoo"]: - # case where we want to actually update the template from odoo - # safe, since passing this parameter from false to true can - # only be done separetly from changing other fields. - # we write the change and move on - return super().write(vals) - else: - # this case is more tricky - if len(vals) == 1: - # ie the only field to be changed is the boolean - # we write and move on. no need for updates - return super().write(vals) - # at the same time, we updated some fields and - # we decided that odoo could not longer update the fields. - # so we decide that this is the last update we send to - # redner - - # compute if we should update redner or not - should_update_redner = False - for f in EDITABLE_FIELDS + COMPUTED_FIELDS + ["template_data"]: - # if we made a change in the record, update redner - if f in vals: - attr = getattr(self, f) - if vals[f] != attr: - should_update_redner = True - - if "name" in vals: - self.ensure_one() - - redner_id = self.redner_id - vals["redner_id"] = vals["name"] - - ret = super().write(vals) - - for record in self: - if ( - should_update_redner - and not self.env.context.get("importing") - and record.allow_modification_from_odoo - ): - try: - produces, language = record.language.split("|") - body, body_format = ( - (record.template_data.decode(), "base64") - if language == "od+mustache" - else (record.body, "text") - ) - - if "name" not in vals: - redner_id = record.redner_id - - record.redner.templates.account_template_update( - template_id=redner_id, - language=language, - body=body, - name=vals.get("name", ""), - description=record.description, - produces=produces, - body_format=body_format, - version=fields.Datetime.to_string(record.write_date), - locale=record.locale_id.code, - ) - except Exception as e: - logger.error("Failed to update redner template :%s", e) - raise ValidationError( - _("Failed to update render template, %s") % e - ) from e - return ret - - def unlink(self): - """Overwrite unlink to delete redner template""" - - # We do NOT depend on the API for consistency here - # So raised error should not result block template deletion - for record in self: - if record.redner_id and record.allow_modification_from_odoo: - try: - self.redner.templates.account_template_delete(record.redner_id) - except Exception as e: - # Log the exception if needed (for debugging purposes) - logger.warning( - "Failed to delete redner template for record %s: %s", - record.id, - {str(e)}, - ) - - return super().unlink() - - def copy(self, default=None): - self.ensure_one() - default = dict(default or {}, name=_("%s (copy)") % self.name) - return super().copy(default) - - @api.depends("body", "template_data") - def _compute_keywords(self): - for record in self: - record.detected_keywords = "\n".join(record.template_varlist_fetch()) - - @api.model - def get_keywords(self): - """Return template redner keywords""" - - varlist = self.template_varlist_fetch() - - for name in varlist: - while "." in name: - name = name[: name.rfind(".")] - if name not in varlist and name != "": - varlist.append(name) - - varlist.sort() - - return varlist - - @api.model - def template_varlist_fetch(self): - """Retrieve the list of variables present in the template.""" - try: - if not self.redner_id: - return [] - - return self.redner.templates.account_template_varlist(self.redner_id) - - except Exception as e: - logger.warning("Failed to fetch account template varlist: %s", e) - return [] - def redner_url(self): if self.redner_id is None: return "" diff --git a/pyproject.toml b/pyproject.toml index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_cHlwcm9qZWN0LnRvbWw=..880fdce6f6719b9201b2300a6007437d0beabde1_cHlwcm9qZWN0LnRvbWw= 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ dependencies = [ "odoo==17.0.*", "odoo-addon-converter >=17.0.1,<17.0.2", - "requests_unixsocket" + "requests_unixsocket", + "python-magic", ] [project.optional-dependencies] diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_c2VjdXJpdHkvaXIubW9kZWwuYWNjZXNzLmNzdg==..880fdce6f6719b9201b2300a6007437d0beabde1_c2VjdXJpdHkvaXIubW9kZWwuYWNjZXNzLmNzdg== 100644 --- a/security/ir.model.access.csv +++ b/security/ir.model.access.csv @@ -3,5 +3,3 @@ access_redner_template,access_redner_template,model_redner_template,base.group_no_one,1,1,1,1 access_redner_substitution,access_redner_substitution,model_redner_substitution,base.group_no_one,1,1,1,1 access_redner_template_list,access_redner_template_list,model_template_list_wizard,base.group_no_one,1,1,1,1 -access_redner_expression_builder,access_redner_expression_builder,model_expression_builder_wizard,base.group_no_one,1,1,1,1 -access_redner_expression_builder_field,access_redner_expression_builder_field,model_expression_builder_field,base.group_no_one,1,1,1,1 diff --git a/static/src/components/charfield/charfield.js b/static/src/components/charfield/charfield.js new file mode 100644 index 0000000000000000000000000000000000000000..880fdce6f6719b9201b2300a6007437d0beabde1_c3RhdGljL3NyYy9jb21wb25lbnRzL2NoYXJmaWVsZC9jaGFyZmllbGQuanM= --- /dev/null +++ b/static/src/components/charfield/charfield.js @@ -0,0 +1,95 @@ +/** @odoo-module **/ + +import {CharField, charField} from "@web/views/fields/char/char_field"; +import {useDynamicPlaceholder} from "@web/views/fields/dynamic_placeholder_hook"; +import {patch} from "@web/core/utils/patch"; +import {useExternalListener, useEffect} from "@odoo/owl"; + +// Adding a new property for dynamic placeholder button visibility +CharField.props = { + ...CharField.props, + placeholderField: {type: String, optional: true}, + placeholderRenderField: {type: String, optional: true}, + converterField: {type: String, optional: true}, +}; + +// Extending charField to extract the new property +const charExtractProps = charField.extractProps; +charField.extractProps = (fieldInfo) => { + return Object.assign(charExtractProps(fieldInfo), { + placeholderField: fieldInfo.options.placeholder_field, + placeholderRednerField: fieldInfo.options?.placeholder_redner || false, + converterField: fieldInfo.options?.converter_field || false, + }); +}; + +// Patch CharField for dynamic placeholder support +patch(CharField.prototype, { + setup() { + super.setup(); + if (this.props.dynamicPlaceholder) { + this.dynamicPlaceholder = useDynamicPlaceholder(this.input); + useExternalListener(document, "keydown", this.dynamicPlaceholder.onKeydown); + useEffect(() => + this.dynamicPlaceholder.updateModel( + this.props.dynamicPlaceholderModelReferenceField + ) + ); + } + }, + get placeholder() { + return ( + this.props.record.data[this.props.placeholderField] || + this.props.placeholder + ); + }, + get showDynamicPlaceholderButton() { + return ( + this.props.dynamicPlaceholder && + !this.props.readonly && + !this.props.record.data[this.props.placeholderRednerField] + ); + }, + + get converterValue() { + return this.props.record.data[this.props.converterField] || ""; + }, + + async onDynamicPlaceholderOpen() { + await this.dynamicPlaceholder.open({ + validateCallback: this.onDynamicPlaceholderValidate.bind(this), + }); + }, + async onDynamicPlaceholderValidate(chain, defaultValue) { + if (chain) { + this.input.el.focus(); + // Initialize dynamicPlaceholder with a default structure + let dynamicPlaceholder = ` {{object.${chain}${ + defaultValue?.length ? ` ||| ${defaultValue}` : "" + }}}`; + switch (this.converter) { + case "field": + // For "field" converter, use the chain directly as the value + dynamicPlaceholder = `${chain}`; + break; + + default: + // Default case if no specific converter type is found + dynamicPlaceholder = ` {{object.${chain}${ + defaultValue?.length ? ` ||| ${defaultValue}` : "" + }}}`; + break; + } + this.input.el.setRangeText( + dynamicPlaceholder, + this.selectionStart, + this.selectionStart, + "end" + ); + // trigger events to make the field dirty + this.input.el.dispatchEvent(new InputEvent("input")); + this.input.el.dispatchEvent(new KeyboardEvent("keydown")); + this.input.el.focus(); + } + }, +}); diff --git a/static/src/components/charfield/charfield.scss b/static/src/components/charfield/charfield.scss new file mode 100644 index 0000000000000000000000000000000000000000..880fdce6f6719b9201b2300a6007437d0beabde1_c3RhdGljL3NyYy9jb21wb25lbnRzL2NoYXJmaWVsZC9jaGFyZmllbGQuc2Nzcw== --- /dev/null +++ b/static/src/components/charfield/charfield.scss @@ -0,0 +1,25 @@ +// Heading element normally takes the full width of the parent, +// so the char_field wrapper should take the same width. +@include media-breakpoint-up(md) { + .o_field_char { + width: inherit; + } +} + +.o_field_char_buttons { + right: 0; + margin-left: -35px; + margin-right: 20px; + + .o_field_translate { + margin-left: 0 !important; + } +} + +.o_field_char input.o_field_placeholder { + padding-right: 40px; +} + +.o_field_char input.o_field_placeholder.o_field_translate { + padding-right: 70px; +} diff --git a/static/src/components/charfield/charfield.xml b/static/src/components/charfield/charfield.xml new file mode 100644 index 0000000000000000000000000000000000000000..880fdce6f6719b9201b2300a6007437d0beabde1_c3RhdGljL3NyYy9jb21wb25lbnRzL2NoYXJmaWVsZC9jaGFyZmllbGQueG1s --- /dev/null +++ b/static/src/components/charfield/charfield.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<templates xml:space="preserve"> + <t t-inherit="web.CharField" t-inherit-mode="extension"> + <xpath expr="//input" position="attributes"> + <attribute name="t-att-placeholder">placeholder</attribute> + </xpath> + <xpath expr="//input" position="after"> + <t t-call="dynamic_placeholder.magicButton"> + <t t-set="positionCenter" t-value="true" /> + </t> + </xpath> + </t> + <t t-name="dynamic_placeholder.magicButton"> + <div class="position-relative d-inline"> + <button + t-if="showDynamicPlaceholderButton" + class="btn position-absolute end-0" + t-attf-class="{{positionCenter ? 'pb-0 pt-0' : 'bottom-0'}}" + t-ref="magicButton" + t-on-click="onDynamicPlaceholderOpen" + > + <i class="fa fa-magic" role="img" aria-label="Magic" /> + </button> + </div> + </t> +</templates> diff --git a/tests/test_redner_template.py b/tests/test_redner_template.py index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_dGVzdHMvdGVzdF9yZWRuZXJfdGVtcGxhdGUucHk=..880fdce6f6719b9201b2300a6007437d0beabde1_dGVzdHMvdGVzdF9yZWRuZXJfdGVtcGxhdGUucHk= 100644 --- a/tests/test_redner_template.py +++ b/tests/test_redner_template.py @@ -113,10 +113,5 @@ # Update template. redner_template.name = updated_name requests_post_mock.assert_not_called() - requests_put_mock.assert_called_once_with( - TEMPLATE_URL + "/" + base_name, - json=updated_template, - headers={"Rednerd-API-Key": "test-api-key"}, - timeout=20, - ) + requests_put_mock.assert_not_called() requests_put_mock.reset_mock() diff --git a/utils/mimetype.py b/utils/mimetype.py index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_dXRpbHMvbWltZXR5cGUucHk=..880fdce6f6719b9201b2300a6007437d0beabde1_dXRpbHMvbWltZXR5cGUucHk= 100644 --- a/utils/mimetype.py +++ b/utils/mimetype.py @@ -16,3 +16,22 @@ except Exception as e: print(f"Error detecting file type: {e}") return None + + +def get_file_extension(binary_data): + """Determine the file extension from binary content.""" + mime = magic.Magic(mime=True) + file_type = mime.from_buffer(binary_data) + + # Mapping MIME types to extensions + mime_to_ext = { + "application/vnd.oasis.opendocument.text": ".odt", + "application/pdf": ".pdf", + "image/jpeg": ".jpg", + "image/png": ".png", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", # noqa: E501 + } + return mime_to_ext.get( + file_type, ".odt" + ) # Default to empty string if MIME type not found diff --git a/views/ir_actions_report.xml b/views/ir_actions_report.xml index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_dmlld3MvaXJfYWN0aW9uc19yZXBvcnQueG1s..880fdce6f6719b9201b2300a6007437d0beabde1_dmlld3MvaXJfYWN0aW9uc19yZXBvcnQueG1s 100644 --- a/views/ir_actions_report.xml +++ b/views/ir_actions_report.xml @@ -43,12 +43,9 @@ <tree editable="top" default_order="keyword asc"> <field name="keyword" /> <field name="converter" /> - <field name="value" /> - <button - name="action_build_expression" - type="object" - string="Build expression" - class="oe_highlight" - invisible="not (value and value!='') or not (converter and converter in ['field','relation-to-many'])" - confirm="This will delete the current substitution value. Continue anyway ?" + <field name="render_model" column_invisible="1" /> + <field name="value_placeholder" column_invisible="1" /> + <field + name="dynamic_placeholder_button_hidden" + column_invisible="1" /> @@ -54,17 +51,16 @@ /> - <button - name="action_build_expression" - type="object" - string="Build expression" - class="oe_highlight" - invisible="(value and value !='') or not (converter and converter in ['field','relation-to-many'])" - /> - <button - name="action_build_expression" - type="object" - string="Build expression" - class="oe_highlight disabled" - invisible="converter in ['field','relation-to-many']" + <field + name="value" + class="text-break" + required="converter != False" + options="{ + 'dynamic_placeholder': true, + 'dynamic_placeholder_model_reference_field': 'render_model', + 'placeholder_field': 'value_placeholder', + 'placeholder_redner': 'dynamic_placeholder_button_hidden', + 'converter_field': 'converter' + }" + default_focus="1" /> </tree> </field> diff --git a/views/mail_template.xml b/views/mail_template.xml index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_dmlld3MvbWFpbF90ZW1wbGF0ZS54bWw=..880fdce6f6719b9201b2300a6007437d0beabde1_dmlld3MvbWFpbF90ZW1wbGF0ZS54bWw= 100644 --- a/views/mail_template.xml +++ b/views/mail_template.xml @@ -39,12 +39,9 @@ <tree editable="top" default_order="keyword asc"> <field name="keyword" /> <field name="converter" /> - <field name="value" /> - <button - name="action_build_expression" - type="object" - string="Build expression" - class="oe_highlight" - invisible="not (value and value!='') or not (converter and converter in ['field','relation-to-many'])" - confirm="This will delete the current substitution value. Continue anyway ?" + <field name="render_model" column_invisible="1" /> + <field name="value_placeholder" column_invisible="1" /> + <field + name="dynamic_placeholder_button_hidden" + column_invisible="1" /> @@ -50,17 +47,16 @@ /> - <button - name="action_build_expression" - type="object" - string="Build expression" - class="oe_highlight" - invisible="(value and value !='') or not (converter and converter in ['field','relation-to-many'])" - /> - <button - name="action_build_expression" - type="object" - string="Build expression" - class="oe_highlight disabled" - invisible="converter in ['field','relation-to-many']" + <field + name="value" + class="text-break" + required="converter != False" + options="{ + 'dynamic_placeholder': true, + 'dynamic_placeholder_model_reference_field': 'render_model', + 'placeholder_field': 'value_placeholder', + 'placeholder_redner': 'dynamic_placeholder_button_hidden', + 'converter_field': 'converter' + }" + default_focus="1" /> </tree> </field> diff --git a/views/redner_template.xml b/views/redner_template.xml index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_dmlld3MvcmVkbmVyX3RlbXBsYXRlLnhtbA==..880fdce6f6719b9201b2300a6007437d0beabde1_dmlld3MvcmVkbmVyX3RlbXBsYXRlLnhtbA== 100644 --- a/views/redner_template.xml +++ b/views/redner_template.xml @@ -34,6 +34,7 @@ <field name="arch" type="xml"> <form> <header> + <field name="id" invisible="1" /> <field name="redner_id" invisible="1" /> <button name="view_in_redner" @@ -73,7 +74,7 @@ <h1> <field name="name" - readonly="not allow_modification_from_odoo" + readonly="not allow_modification_from_odoo or id != False" /> </h1> </div> diff --git a/wizard/__init__.py b/wizard/__init__.py index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_d2l6YXJkL19faW5pdF9fLnB5..880fdce6f6719b9201b2300a6007437d0beabde1_d2l6YXJkL19faW5pdF9fLnB5 100644 --- a/wizard/__init__.py +++ b/wizard/__init__.py @@ -1,6 +1,4 @@ from . import ( - expression_builder, - expression_builder_field, mail_compose_message, template_list, ) diff --git a/wizard/expression_builder.py b/wizard/expression_builder.py deleted file mode 100644 index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_d2l6YXJkL2V4cHJlc3Npb25fYnVpbGRlci5weQ==..0000000000000000000000000000000000000000 --- a/wizard/expression_builder.py +++ /dev/null @@ -1,128 +0,0 @@ -from odoo import exceptions, fields, models - - -# ExpressionBuilder -class ExpressionBuilder(models.TransientModel): - _name = "expression.builder.wizard" - _description = "Expression Builder Wizard" - - # the substitution the expression builder is working on - substitution_id = fields.Many2one( - comodel_name="redner.substitution", - string="Substitution", - help="The Redner Substitution on which we assign the value", - required=True, - ) - # the expression field is concatened with the fields values - # when the wizard action is completed, this field is - # written in the substitution value - expression = fields.Char( - string="Substitution", - help=( - "Stores the substitution value the builder is working on." - " (example: 'partner_id.name')" - ), - ) - - suggested_fields = fields.One2many( - comodel_name="expression.builder.field", - inverse_name="wizard_id", - string="Suggested Fields", - help="The suggested fields, based on the substitution converter value", - ) - - def get_fields(self, model, converter): - # Get all field records for the model - vals_list = self.env["ir.model.fields"].search([("model", "=", model._name)]) - - # Filter fields based on the converter type - if converter == "relation-to-many": - vals_list = [f for f in vals_list if f.ttype == "many2many"] - elif converter == "field": - vals_list = [f for f in vals_list if f.ttype != "many2many"] - else: - vals_list = [] - return [ - { - "field_name": f.field_description, - "field_technical_name": f.name, - "field_type": f.ttype, - "field_comodel_name": f.relation, - } - for f in vals_list - ] - - def action_cancel(self): - # custom cancel, unlinks the fields, the wizard itself - # and close window - self.unlink() - return {"type": "ir.actions.act_window_close"} - - def action_save(self): - selected_count = 0 - selected_field = False - for f in self.suggested_fields: - if f.selected: - selected_count += 1 - selected_field = f - - if selected_count > 1: - raise exceptions.UserError( - "You cannot select more than one field to build the expression" - ) - - elif selected_count == 0: - raise exceptions.UserError( - "You have to select one field to build the expression" - ) - elif selected_field.field_type in ["many2one", "one2many"]: - # in this case we do not close the window. - # instead we refresh the wizard by showing the - # fields 1 field deeper - # and we update the expression field - if self.expression or self.expression != "": - self.expression += "." + selected_field.field_technical_name - else: - self.expression = selected_field.field_technical_name - - # get comodel - comodel = self.env.get(selected_field.field_comodel_name) - if comodel is None: - return - vals_list = self.get_fields(comodel, self.substitution_id.converter) - for val in vals_list: - val["wizard_id"] = self.id - - # remove previous fields - self.suggested_fields.unlink() - # add and create the next fields - fields_list = self.env["expression.builder.field"].create(vals_list) - self.suggested_fields = [(6, 0, fields_list.ids)] - # stay on updated wizard - return { - "type": "ir.actions.act_window", - "res_model": "expression.builder.wizard", - "view_mode": "form", - "res_id": self.id, - "target": "new", - } - # if the selected field is not relationnal: - if self.expression or self.expression != "": - # expression has already a value - self.substitution_id.write( - { - "value": self.expression - + "." - + selected_field.field_technical_name, - } - ) - else: - # expression is empty - self.substitution_id.write( - { - "value": selected_field.field_technical_name, - } - ) - # at the end, we unlink all of the temp data - # and close the window - return self.action_cancel() diff --git a/wizard/expression_builder_field.py b/wizard/expression_builder_field.py deleted file mode 100644 index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_d2l6YXJkL2V4cHJlc3Npb25fYnVpbGRlcl9maWVsZC5weQ==..0000000000000000000000000000000000000000 --- a/wizard/expression_builder_field.py +++ /dev/null @@ -1,39 +0,0 @@ -from odoo import fields, models - - -class ExpressionBuilderField(models.TransientModel): - _name = "expression.builder.field" - _description = "Expression Builder Field" - - wizard_id = fields.Many2one( - string="Expression builder", - comodel_name="expression.builder.wizard", - required=True, - ondelete="cascade", - ) - - field_name = fields.Char( - string="Field name", - help="The field's comprehensible name", - ) - - field_type = fields.Char( - string="Field Type", - help="The field's type (boolean, text, ...)", - ) - - field_comodel_name = fields.Char( - string="Comodel Name", - help="If the field is relational, store the comodel name here", - ) - - field_technical_name = fields.Char( - string="Field Technical Name", - help="The field's technical name", - ) - - selected = fields.Boolean( - string="Selected", - help="When selected, this field will be added to the substitution value", - readonly=False, - ) diff --git a/wizard/expression_builder_view.xml b/wizard/expression_builder_view.xml deleted file mode 100644 index 93b8d613e36abdc467a2b3a8137ad7ecc1c67390_d2l6YXJkL2V4cHJlc3Npb25fYnVpbGRlcl92aWV3LnhtbA==..0000000000000000000000000000000000000000 --- a/wizard/expression_builder_view.xml +++ /dev/null @@ -1,42 +0,0 @@ -<odoo> - <record id="view_expression_builder_wizard_form" model="ir.ui.view"> - <field name="name">expression.builder.wizard.form</field> - <field name="model">expression.builder.wizard</field> - <field name="arch" type="xml"> - <form string="Build Expression"> - <sheet> - <field name="substitution_id" invisible="1" /> - <field name="suggested_fields" nolabel="1" colspan="2"> - <tree - no_open="true" - editable="top" - create="false" - delete="false" - default_order="field_name asc" - > - <field name="selected" /> - <field name="field_name" readonly="1" /> - <field name="field_technical_name" readonly="1" /> - <field name="field_type" readonly="1" /> - <field name="field_comodel_name" readonly="1" /> - </tree> - </field> - <footer> - <button - string="Save" - type="object" - name="action_save" - class="btn-primary" - /> - <button - string="Cancel" - type="object" - name="action_cancel" - class="btn-secondary" - /> - </footer> - </sheet> - </form> - </field> - </record> -</odoo>