From bbb71840c1d766d461a8f177c9be0ed8d3b6792f Mon Sep 17 00:00:00 2001 From: git_admin Date: Mon, 27 Apr 2026 08:15:56 +0000 Subject: [PATCH] Tower: upload cetmix_tower_server 16.0.3.0.1 (via marketplace) --- .../cx_tower_jet_template_dependency.py | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py b/addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py new file mode 100644 index 0000000..6f37dc2 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_template_dependency.py @@ -0,0 +1,168 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CxTowerJetTemplateDependency(models.Model): + """Define dependencies between Jet templates""" + + _name = "cx.tower.jet.template.dependency" + _inherit = "cx.tower.reference.mixin" + _description = "Cetmix Tower Jet Template Dependency" + _log_access = False + + name = fields.Char(related="template_id.name", readonly=True) + template_id = fields.Many2one( + string="Jet", + comodel_name="cx.tower.jet.template", + ondelete="cascade", + required=True, + help="The Jet template that requires another template", + ) + + template_required_id = fields.Many2one( + string="Required Jet", + comodel_name="cx.tower.jet.template", + ondelete="restrict", + required=True, + help="The Jet template that is required to be in a specific state", + domain="[('id', '!=', template_id)]", + ) + + state_required_id = fields.Many2one( + string="Required State", + comodel_name="cx.tower.jet.state", + required=True, + ondelete="restrict", + help="The state of the required Jet", + ) + + _sql_constraints = [ + ( + "unique_template_dependency", + "UNIQUE(template_id, template_required_id)", + "A template can only depend on another template once!", + ), + ] + + @api.constrains( + "template_id", + "template_required_id", + ) + def _check_circular_dependency(self): + """Check if this dependency would create a circular dependency chain""" + for dependency in self: + # Skip if the dependency isn't properly set yet + if not dependency.template_id or not dependency.template_required_id: + continue + + # Self-dependency is not allowed and already prevented by domain constraints + if dependency.template_id == dependency.template_required_id: + raise ValidationError(_("A template cannot depend on itself!")) + + # Build dependency graph + graph = self._build_dependency_graph() + + # Add the new dependency edge being created + if dependency.template_id.id not in graph: + graph[dependency.template_id.id] = set() + graph[dependency.template_id.id].add(dependency.template_required_id.id) + + # Check for circular dependencies + if self._has_cycle(graph, dependency.template_id.id): + raise ValidationError( + _( + "This dependency would create a circular reference chain! " + "Template '%(template)s' would indirectly depend on itself.", + template=dependency.template_id.name, + ) + ) + + @api.depends("template_id", "template_required_id") + def _compute_display_name(self): + for dependency in self: + dependency.display_name = ( + ( + f"{dependency.template_id.name} ->" + f" {dependency.template_required_id.name}" + ) + if dependency.template_id and dependency.template_required_id + else "..." + ) + + def write(self, vals): + """Do not allow modifications after creation""" + # Allow modifications in install mode only to load demo data + if ("template_id" in vals or "template_required_id" in vals) and not ( + self._context.get("install_mode") and self._context.get("install_xmlid") + ): + raise ValidationError( + _( + "You cannot modify an existing template dependency! " + "Please remove it and create a new one." + ) + ) + return super().write(vals) + + def _build_dependency_graph(self): + """Build a directed graph of template dependencies + + Returns: + dict: A dictionary where keys are template IDs and values are + sets of template IDs that are required by the key template + """ + graph = {} + # Get all dependencies in the system + # TODO: This is not efficient, we should find a better way later. + # Eg cache the graph in the template model. + all_deps = self.search([]) + + for dep in all_deps: + from_id = dep.template_id.id + to_id = dep.template_required_id.id + + if from_id not in graph: + graph[from_id] = set() + + graph[from_id].add(to_id) + + # Ensure the to_id is in the graph even if it doesn't require anything + if to_id not in graph: + graph[to_id] = set() + + return graph + + def _has_cycle(self, graph, start_node, visited=None, path=None): + """Check if the graph has a cycle starting from start_node + + Args: + graph (dict): Dependency graph where keys are template IDs and values are + sets of template IDs that the key depends on + start_node (int): Template ID to start the traversal from + visited (set, optional): Set of already visited nodes + path (set, optional): Set of nodes in the current DFS path + + Returns: + bool: True if a cycle is detected, False otherwise + """ + if visited is None: + visited = set() + if path is None: + path = set() + + visited.add(start_node) + path.add(start_node) + + for neighbor in graph.get(start_node, set()): + if neighbor not in visited: + if self._has_cycle(graph, neighbor, visited, path): + return True + elif neighbor in path: + # We found a cycle + return True + + # Remove the current node from the path as we backtrack + path.remove(start_node) + return False