# HG changeset patch # User Balde Oury <oury.balde@xcg-consulting.fr> # Date 1734444033 0 # Tue Dec 17 14:00:33 2024 +0000 # Branch 18.0 # Node ID b4d7830ecd569274bc4f4de3453c5669368d1b33 # Parent 7e4a9e871c6fac7a9b92f06c85592aa931d4c99b Refactor redner.template model to improve template management diff --git a/NEWS.rst b/NEWS.rst --- a/NEWS.rst +++ b/NEWS.rst @@ -2,6 +2,11 @@ Changelog ========= +18.0.1.0.4 +---------- + +Refactor redner.template model to improve template management. + 18.0.1.0.3 ---------- diff --git a/__manifest__.py b/__manifest__.py --- a/__manifest__.py +++ b/__manifest__.py @@ -21,7 +21,7 @@ { "name": "Redner", "license": "AGPL-3", - "version": "18.0.1.0.3", + "version": "18.0.1.0.4", "category": "Reporting", "author": "XCG Consulting", "website": "https://orbeet.io/", diff --git a/models/redner_template.py b/models/redner_template.py --- a/models/redner_template.py +++ b/models/redner_template.py @@ -26,13 +26,14 @@ 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" +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,49 +168,340 @@ string="Libreoffice Template", readonly=False, compute="_compute_template_data", + inverse="_inverse_template_data", ) template_data_filename = fields.Char( string="Libreoffice Template Filename", - readonly=True, + compute="_compute_template_data_filename", ) - def import_from_redner(self): - tl_wizard = self.env["template.list.wizard"] - templates = self.list_external_templates() - tl_wizard.populate(templates) + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- - 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.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: - response = self.get_preview(record.redner_id) - b64 = base64.b64encode(response.content) - record.preview = b64 + if record.body or record.template_data: + response = self.get_preview(record.redner_id) + + # 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")), + } - def get_preview(self, redner_id): + # 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 + + # 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: - 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: - 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): """ Convert the external template to the Odoo format. - This function assumes the existence of this logic in the original `to_cache`. """ - # Implement the transformation logic here - # define a odoo_like template to put into the cache language = "{}|{}".format(template.get("produces"), template.get("language")) odoo_template = { @@ -249,51 +545,88 @@ 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): """ - Computes the template values for the records and applies cached or fetched data. + Sets and caches the template in Redner for a given record. """ - 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.") - # 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") + ) - 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 - @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 = f"{record.name}{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,203 +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: - pass - - 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: - 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/tests/test_redner_template.py b/tests/test_redner_template.py --- 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 --- 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/redner_template.xml b/views/redner_template.xml --- 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> # HG changeset patch # User Balde Oury <oury.balde@xcg-consulting.fr> # Date 1734444153 0 # Tue Dec 17 14:02:33 2024 +0000 # Branch 18.0 # Node ID 277162113e09c062d2e9c264681989fc18f2d5fd # Parent b4d7830ecd569274bc4f4de3453c5669368d1b33 Added tag 18.0.1.0.4 for changeset b4d7830ecd56 diff --git a/.hgtags b/.hgtags --- a/.hgtags +++ b/.hgtags @@ -32,3 +32,4 @@ e94d63bf437de0659004cdf649ad3f418d555ec4 18.0.1.0.1 36dcec66b1c134aab072935b3e7e322c040a4ca2 18.0.1.0.2 b03479db80cd1185c50e02fc10e489fbc92abe56 18.0.1.0.3 +b4d7830ecd569274bc4f4de3453c5669368d1b33 18.0.1.0.4