# Copyright (C) 2022 Cetmix OÜ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging from odoo import _, api, fields, models from odoo.tools.safe_eval import wrap_module _logger = logging.getLogger(__name__) re = wrap_module( __import__("re"), [ "match", "fullmatch", "search", "sub", "subn", "split", "findall", "finditer", "compile", "template", "escape", "error", ], ) class TowerVariable(models.Model): """Variables""" _name = "cx.tower.variable" _description = "Cetmix Tower Variable" _inherit = [ "cx.tower.reference.mixin", "cx.tower.access.mixin", "cx.tower.tag.mixin", ] _order = "name" DEFAULT_VALIDATION_MESSAGE = _("Invalid value!") value_ids = fields.One2many( string="Values", comodel_name="cx.tower.variable.value", inverse_name="variable_id", ) value_ids_count = fields.Integer( string="Value Count", compute="_compute_variable_counters" ) option_ids = fields.One2many( comodel_name="cx.tower.variable.option", inverse_name="variable_id", string="Options", auto_join=True, ) variable_type = fields.Selection( selection=[("s", "String"), ("o", "Options")], default="s", required=True, string="Type", ) applied_expression = fields.Text( help="Python expression to apply to the variable value. \n" "You can use general python sting functions and 're' module " "for regex operations. " "Use 'value' variable to refer to the variable value, use 'result'" " to assign the final result that will be used as a variable value.\n" "Eg 'result = value.lower().replace(' ', '_')'", ) validation_pattern = fields.Char( help="Regex pattern to validate the variable values using the " "'re.match' function. Eg. ^[a-z0-9]+$ \n" "If empty, the variable values will not be validated.", ) validation_message = fields.Char( translate=True, help="Message to display when the variable value is invalid. \n" "First line will be added automatically: " "`Variable:, Value: `\n" "Eg: `Variable: Customer Name, Value: Test\nInvalid value!`\n" "If empty, the default message will be used.", ) note = fields.Text( help="Additional notes about the variable. \n" "This field will be displayed in the variable form.", ) # --- Link to records where the variable is used command_ids = fields.Many2many( comodel_name="cx.tower.command", relation="cx_tower_command_variable_rel", column1="variable_id", column2="command_id", copy=False, ) command_ids_count = fields.Integer( string="Command Count", compute="_compute_variable_counters" ) plan_line_ids = fields.Many2many( comodel_name="cx.tower.plan.line", relation="cx_tower_plan_line_variable_rel", column1="variable_id", column2="plan_line_id", copy=False, ) plan_line_ids_count = fields.Integer( string="Plan Line Count", compute="_compute_variable_counters" ) file_ids = fields.Many2many( comodel_name="cx.tower.file", relation="cx_tower_file_variable_rel", column1="variable_id", column2="file_id", copy=False, ) file_ids_count = fields.Integer( string="File Count", compute="_compute_variable_counters" ) file_template_ids = fields.Many2many( comodel_name="cx.tower.file.template", relation="cx_tower_file_template_variable_rel", column1="variable_id", column2="file_template_id", copy=False, ) file_template_ids_count = fields.Integer( string="File Template Count", compute="_compute_variable_counters" ) variable_value_ids = fields.Many2many( comodel_name="cx.tower.variable.value", relation="cx_tower_variable_value_variable_rel", column1="variable_id", column2="variable_value_id", copy=False, ) variable_value_ids_count = fields.Integer( string="Variable Value Count", compute="_compute_variable_counters" ) _sql_constraints = [("name_uniq", "unique (name)", "Variable names must be unique")] def _compute_variable_counters(self): """Count number of variable values for the variable""" for rec in self: rec.update( { "variable_value_ids_count": len(rec.variable_value_ids), "command_ids_count": len(rec.command_ids), "plan_line_ids_count": len(rec.plan_line_ids), "file_ids_count": len(rec.file_ids), "file_template_ids_count": len(rec.file_template_ids), "value_ids_count": len(rec.value_ids), } ) def action_open_values(self): self.ensure_one() context = self.env.context.copy() context.update( { "default_variable_id": self.id, } ) return { "type": "ir.actions.act_window", "name": _("Variable Values"), "res_model": "cx.tower.variable.value", "views": [[False, "tree"]], "target": "current", "context": context, "domain": [("variable_id", "=", self.id)], } def action_open_commands(self): """Open the commands where the variable is used""" self.ensure_one() action = self.env["ir.actions.act_window"]._for_xml_id( "cetmix_tower_server.action_cx_tower_command" ) action.update( { "domain": [("variable_ids", "in", self.ids)], } ) return action def action_open_plan_lines(self): """Open the plan lines where the variable is used""" self.ensure_one() return { "type": "ir.actions.act_window", "name": _("Plan Lines"), "res_model": "cx.tower.plan.line", "views": [ [False, "tree"], [ self.env.ref("cetmix_tower_server.cx_tower_plan_line_view_form").id, "form", ], ], "target": "current", "domain": [("variable_ids", "in", self.ids)], } def action_open_files(self): """Open the files where the variable is used""" self.ensure_one() action = self.env["ir.actions.act_window"]._for_xml_id( "cetmix_tower_server.cx_tower_file_action" ) action.update( { "domain": [("variable_ids", "in", self.ids)], } ) return action def action_open_file_templates(self): """Open the file templates where the variable is used""" self.ensure_one() action = self.env["ir.actions.act_window"]._for_xml_id( "cetmix_tower_server.cx_tower_file_template_action" ) action.update( { "domain": [("variable_ids", "in", self.ids)], } ) return action def action_open_variable_values(self): """Open the variable values where the variable is used""" self.ensure_one() return { "type": "ir.actions.act_window", "name": _("Variable Values"), "res_model": "cx.tower.variable.value", "views": [[False, "tree"]], "target": "current", "domain": [("variable_ids", "in", self.ids)], } @api.model def _get_eval_context(self, value_char=None): """ Evaluation context to pass to safe_eval to evaluate the Python expression used in the `applied_expression` field Args: value_char (Char): variable value Returns: dict: evaluation context """ return { "re": re, "value": value_char, } # Reference rename propagation def write(self, vals): """Override the write method to propagate variable reference updates. Records the old reference values, performs the write, and if the reference field has changed, initiates propagation to update related records. """ old_refs = ( {rec.id: rec.reference for rec in self} if "reference" in vals else {} ) res = super().write(vals) if "reference" in vals: for rec in self: old_ref = old_refs.get(rec.id) if old_ref and old_ref != rec.reference: rec._propagate_reference_change(old_ref, rec.reference) return res def _propagate_reference_change(self, old_ref, new_ref): """Replace all occurrences of an old variable reference with a new one. Compiles a pattern matching the old Jinja-style reference, then searches across configured models and fields to substitute any matches, preserving formatting. """ pattern = re.compile(r"(\{\{\s*)" + re.escape(old_ref) + r"(\s*\}\})") def _replace(text): """Helper to replace old_ref with new_ref in the given text.""" return pattern.sub(lambda m: f"{m.group(1)}{new_ref}{m.group(2)}", text) model_fields_map = self._get_propagation_field_mapping() for model_name, field_names in model_fields_map.items(): Model = self.env[model_name] if model_name == "cx.tower.variable.value": domain = [("variable_id", "=", self.id)] else: domain = [("variable_ids", "in", self.ids)] for record in Model.search(domain): vals = {} for field_name in field_names: value = record[field_name] if isinstance(value, str) and old_ref in value: new_value = _replace(value) if new_value != value: vals[field_name] = new_value if vals: record.with_context(skip_reference_propagation=True).write(vals) _logger.debug( "Variable reference updated in %s(%s): %s", model_name, record.id, ", ".join(vals.keys()), ) def _get_propagation_field_mapping(self): """Return the mapping of models to fields for reference change propagation. The returned dict maps each model name to a list of field names that may contain variable references requiring updates. """ return { "cx.tower.command": ["code", "path"], "cx.tower.file": ["code", "server_dir", "name"], "cx.tower.file.template": ["code", "server_dir", "file_name"], "cx.tower.variable.value": ["value_char"], "cx.tower.plan.line": ["condition"], } def _get_dependent_model_relation_fields(self): """Check cx.tower.reference.mixin for the function documentation""" res = super()._get_dependent_model_relation_fields() return res + ["value_ids"] def _validate_value(self, value_char=None): """ Validate the variable value Args: value_char (Char): variable value Returns: (Boolean, Char): (is_valid, validation_message) """ self.ensure_one() if ( not self.validation_pattern or not value_char or re.match(self.validation_pattern, value_char) ): return True, None message = self.validation_message or self.DEFAULT_VALIDATION_MESSAGE return ( False, _( "Variable: %(var)s, Value: %(val)s\n%(msg)s", msg=message, var=self.name, val=value_char, ), )