diff --git a/addons/cetmix_tower_server/models/cx_tower_template_mixin.py b/addons/cetmix_tower_server/models/cx_tower_template_mixin.py new file mode 100644 index 0000000..cb9678c --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_template_mixin.py @@ -0,0 +1,215 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from jinja2 import Environment, Template, meta +from jinja2.exceptions import TemplateSyntaxError + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class CxTowerTemplateMixin(models.AbstractModel): + """Used to implement template rendering functions. + Inherit in your model in case you want to render variable values in it. + """ + + _name = "cx.tower.template.mixin" + _description = "Cetmix Tower template rendering mixin" + + code = fields.Text(help="This field will be rendered using variables") + variable_ids = fields.Many2many( + string="Variables", + comodel_name="cx.tower.variable", + compute="_compute_variable_ids", + store=True, + copy=False, + ) + + @classmethod + def _get_depends_fields(cls): + """ + Define dependent fields for the `variable_ids` computation. + + This method should be overridden in inheriting models to provide + a list of fields that influence the computation of `variable_ids`. + These fields are used in the `@api.depends` decorator to trigger + recomputation when their values change. + + Returns: + list: A list of field names (str) that are dependencies for + the `variable_ids` computation. Default is an empty list. + + Example: + In a subclass, override as follows: + >>> @classmethod + >>> def _get_depends_fields(cls): + >>> return ["code", "path"] + """ + return [] + + @api.depends(lambda self: self._get_depends_fields()) + def _compute_variable_ids(self): + """ + Compute the values of the `variable_ids` + field based on model-specific dependencies. + + This method retrieves the dependent fields using `_get_depends_fields` + and dynamically calculates the values of `variable_ids` using the + `_prepare_variable_commands` method. + + If no dependent fields or relation parameters are defined, the field + is reset to an empty list. + + Example: + If dependent fields include `code` and `path`, and the model-specific + logic links them to variables, this method will update the `variable_ids` + field accordingly. + + Raises: + ValidationError: If the field metadata is incorrectly defined or + missing required attributes. + + Returns: + None: The field `variable_ids` is updated in-place for each record. + """ + depends_fields = self._get_depends_fields() + + for record in self: + if depends_fields: + record.variable_ids = record._prepare_variable_commands(depends_fields) + else: + record.variable_ids = [(5, 0, 0)] + + def render_code(self, pythonic_mode=False, **kwargs): + """Render record 'code' field using variables from kwargs + Call to render recordset of the inheriting models + Args: + pythonic_mode (Bool): If True, all variables in kwargs are converted to + strings and wrapped in double quotes. + Default is False. + **kwargs (dict): {variable: value, ...} + Returns: + dict {record_id: rendered_code, ...} + """ + return { + rec.id: self.render_code_custom(rec.code, pythonic_mode, **kwargs) + for rec in self + } + + def render_code_custom(self, code, pythonic_mode=False, **kwargs): + """ + Render custom code using variables from kwargs + + This method renders a template string (code) using the variables provided + in kwargs. If pythonic_mode is enabled, all variables are automatically + converted to strings and enclosed in double quotes before rendering. + + Args: + code (Text): code to render (eg 'some {{ custom }} text') + pythonic_mode (Bool): If True, all variables in kwargs are converted to + strings and wrapped in double quotes. + Default is False. + **kwargs (dict): {variable: value, ...} + Returns: + rendered_code (text): The resulting string after rendering the template with + the provided variables. + """ + + # Return the original code if it's empty. + # So if it's False then we preserve the original 'False' value. + if not code: + return code + + try: + if pythonic_mode: + kwargs = { + key: self._make_value_pythonic(value) + for key, value in kwargs.items() + } + return Template(code, trim_blocks=True).render(kwargs) + except Exception as e: + raise UserError(str(e)) from e + + def get_variables(self): + """Get the list of variables for templates + Call to get variables for recordset of the inheriting models + + Returns: + dict {'record_id': {variables}...} + NB: 'record_id' is String + """ + res = {} + for rec in self: + res[str(rec.id)] = self.get_variables_from_code(rec.code) + return res + + def get_variables_from_code(self, code): + """Get the list of variables for templates + Call to get variables from custom code string + + Args: + code (Text) custom code (eg 'Custom {{ var }} {{ var2 }} ...') + Returns: + variables (List) variables (eg ['var','var2',..]) + """ + env = Environment() + try: + ast = env.parse(code) + undeclared_variables = meta.find_undeclared_variables(ast) + return list(undeclared_variables) if undeclared_variables else [] + except TemplateSyntaxError as e: + raise ValidationError(_("Variable syntax error: %s", e)) from e + + def _prepare_variable_commands(self, field_names, force_record=None): + """ + Prepares commands to set variable references from the given fields. + + Args: + field_names (list): List of field names to extract variable references from. + force_record (record, optional): A record to use instead of the current one. + + Returns: + list: An Odoo command to assign or clear variable references. + """ + record = force_record or self + record.ensure_one() + + all_references = set() + for field_name in field_names: + value = getattr(record, field_name, None) + if value: + all_references.update(self.get_variables_from_code(value)) + + if all_references: + variables = self.env["cx.tower.variable"].search( + [("reference", "in", list(all_references))] + ) + command = [(6, 0, variables.ids)] + else: + command = [(5, 0, 0)] + + return command + + def _make_value_pythonic(self, value): + """Prepares value for use in 'pythonic' mode + by enclosing strings into double quotes + + Args: + value (Char): value to process + + Returns: + Char: processed value + """ + + # Nothing to do here + if isinstance(value, bool) or value is None: + result = value + + # Handle nested dicts such as system variables + elif isinstance(value, dict): + result = {} + for key, val in value.items(): + result.update({key: self._make_value_pythonic(val)}) + else: + # Enclose in double quotes + result = f'"{value}"' + return result