diff --git a/addons/cetmix_tower_server/models/cx_tower_jet.py b/addons/cetmix_tower_server/models/cx_tower_jet.py new file mode 100644 index 0000000..0be05bb --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet.py @@ -0,0 +1,1703 @@ +# Copyright (C) 2024 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import ast +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, ValidationError + +from .constants import ( + JET_ACTION_NOT_AVAILABLE, + JET_DEPENDENCIES_NOT_SATISFIED, + JET_STATE_ERROR, +) +from .tools import generate_random_id + +_logger = logging.getLogger(__name__) + + +class CxTowerJet(models.Model): + """Jets represent application instances that can be managed independently""" + + _name = "cx.tower.jet" + _description = "Cetmix Tower Jet" + _inherit = [ + "cx.tower.reference.mixin", + "cx.tower.variable.mixin", + "cx.tower.metadata.mixin", + "mail.thread", + "mail.activity.mixin", + "cx.tower.tag.mixin", + "cx.tower.access.role.mixin", + ] + _order = "sequence, name" + _mail_post_access = "read" + + active = fields.Boolean(default=True) + deletable = fields.Boolean( + readonly=True, + default=True, + help="This field is set by the jet actions. " + "If enabled, the jet can be deleted", + ) + url = fields.Char(string="URL", help="Jet URL, eg 'https://meme.example.com'") + color = fields.Integer(related="state_id.color", readonly=True) + icon = fields.Image( + string="Icon image", + related="jet_template_id.icon", + readonly=True, + store=False, + help="Jet icon, computed from the template by default", + ) + sequence = fields.Integer(default=10, help="Used to sort jets in views") + partner_id = fields.Many2one( + comodel_name="res.partner", + help="Partner associated with this jet", + ) + note = fields.Text() + + jet_cloned_from_id = fields.Many2one( + comodel_name="cx.tower.jet", + string="Cloned from", + readonly=True, + copy=False, + help="Jet this jet was cloned from. " + "This field is set when the jet is cloned from another jet.", + ) + + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + required=True, + ondelete="restrict", + help="Template that this jet is based on", + ) + jet_template_domain = fields.Binary( + compute="_compute_jet_template_domain", + ) + server_id = fields.Many2one( + comodel_name="cx.tower.server", + required=True, + ondelete="restrict", + help="Server where this jet is running", + ) + server_allowed_domain = fields.Binary( + compute="_compute_server_allowed_domain", + ) + file_ids = fields.One2many( + comodel_name="cx.tower.file", + inverse_name="jet_id", + string="Files", + help="Files of this jet", + ) + server_log_ids = fields.One2many( + comodel_name="cx.tower.server.log", + inverse_name="jet_id", + copy=False, + ) + scheduled_task_ids = fields.Many2many( + comodel_name="cx.tower.scheduled.task", + relation="cx_tower_scheduled_task_jet_rel", + column1="jet_id", + column2="scheduled_task_id", + string="Scheduled Tasks", + ) + + # -- Jet Requests + served_jet_request_id = fields.Many2one( + comodel_name="cx.tower.jet.request", + help="Request this jet is currently serving", + readonly=True, + copy=False, + ) + + # -- Dependencies + jet_requires_ids = fields.One2many( + comodel_name="cx.tower.jet.dependency", + inverse_name="jet_id", + string="Requires", + help="Other jets this jet depends on", + compute="_compute_jet_requires_ids", + store=True, + groups="cetmix_tower_server.group_manager", + copy=False, + ) + jet_required_by_ids = fields.One2many( + comodel_name="cx.tower.jet.dependency", + inverse_name="jet_depends_on_id", + string="Required By", + help="Jets that depend on this jet", + groups="cetmix_tower_server.group_manager", + copy=False, + readonly=True, + ) + + # -- States and actions + state_id = fields.Many2one( + comodel_name="cx.tower.jet.state", + string="Current State", + tracking=True, + domain="[('id', 'in', jet_template_state_ids)]", + copy=False, + ) + state = fields.Char( + related="state_id.reference", + readonly=True, + store=True, + index=True, + string="State Reference", + help="Current state of the jet. " + "NB: this is " + "the reference of the state, not the name.", + ) + jet_template_state_ids = fields.One2many( + comodel_name="cx.tower.jet.state", + compute="_compute_state_available_ids", + ) + state_available_ids = fields.One2many( + comodel_name="cx.tower.jet.state", + compute="_compute_state_available_ids", + help="Available states for the jet. " + "Click on the button to transition to the state.", + copy=False, + ) + + target_state_id = fields.Many2one( + comodel_name="cx.tower.jet.state", + string="Target State", + readonly=True, + copy=False, + help="Destination state to which the jet is currently transitioning", + ) + show_available_states = fields.Boolean( + help="Show available states in the jet view", + compute="_compute_show_available_states", + inverse="_inverse_show_available_states", + groups="cetmix_tower_server.group_manager", + ) + action_available_ids = fields.Many2many( + comodel_name="cx.tower.jet.action", + compute="_compute_available_actions", + string="Available Actions", + help="Available actions for the jet. " + "Click on the button to trigger the action.", + ) + current_action_id = fields.Many2one( + comodel_name="cx.tower.jet.action", + string="Executing Action", + readonly=True, + copy=False, + ) + current_command_log_id = fields.Many2one( + comodel_name="cx.tower.command.log", + string="Executing Command Log", + groups="cetmix_tower_server.group_manager", + readonly=True, + copy=False, + ) + + # -- Waypoints + is_waypoints_available = fields.Boolean( + compute="_compute_is_waypoints_available", + readonly=True, + ) + waypoint_ids = fields.One2many( + comodel_name="cx.tower.jet.waypoint", + inverse_name="jet_id", + string="Waypoints", + help="Waypoints of the jet", + copy=False, + ) + waypoint_id = fields.Many2one( + comodel_name="cx.tower.jet.waypoint", + help="Current waypoint of the jet", + readonly=True, + copy=False, + tracking=True, + ) + + # -- Variables used for configuration + variable_value_ids = fields.One2many( + inverse_name="jet_id", + ) + + # -- Logs + command_log_ids = fields.One2many( + comodel_name="cx.tower.command.log", + inverse_name="jet_id", + copy=False, + ) + plan_log_ids = fields.One2many( + comodel_name="cx.tower.plan.log", + inverse_name="jet_id", + copy=False, + ) + + # -- Access. Add relation for mixin fields + user_ids = fields.Many2many( + relation="cx_tower_jet_user_rel", + ) + manager_ids = fields.Many2many( + relation="cx_tower_jet_manager_rel", + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Compute methods + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @api.depends("name", "state_id") + def _compute_display_name(self): + """Compute the display name of the jet""" + for jet in self: + jet.display_name = f"{jet.name} ({jet.state})" if jet.state else jet.name + + @api.depends("server_id") + def _compute_jet_template_domain(self): + """Compute the domain of the jet template""" + for jet in self: + jet.jet_template_domain = ( + [("server_ids", "in", [jet.server_id.id])] if jet.server_id else [] + ) + + @api.depends("jet_template_id") + def _compute_server_allowed_domain(self): + """Compute the domain of the server allowed""" + for jet in self: + jet.server_allowed_domain = ( + [("id", "in", jet.jet_template_id.server_ids.ids)] + if jet.jet_template_id and jet.jet_template_id.server_ids + else [] + ) + + @api.depends("jet_template_id", "jet_template_id.action_ids") + def _compute_state_available_ids(self): + """Compute the available states for the jet""" + for jet in self: + if not jet.jet_template_id: + jet.update( + { + "jet_template_state_ids": False, + "state_available_ids": False, + } + ) + continue + actions = jet.jet_template_id.action_ids + if not actions: + jet.update( + { + "jet_template_state_ids": False, + "state_available_ids": False, + } + ) + continue + # Compute effective access level for the user + effective_user_access_level = jet._get_user_effective_access_level() + jet.update( + { + "jet_template_state_ids": actions.state_from_id + | actions.state_transit_id + | actions.state_to_id, + "state_available_ids": ( + actions.state_to_id - jet.state_id + ).filtered( + lambda s, + access_level=effective_user_access_level: s.access_level + <= access_level + ), + } + ) + + @api.depends( + "state_id", + "jet_template_id", + "jet_template_id.action_ids", + "jet_template_id.action_ids.state_from_id", + "jet_template_id.action_ids.state_to_id", + "jet_template_id.action_ids.priority", + ) + def _compute_available_actions(self): + """Compute available actions based on current state and template""" + for jet in self: + if not jet.jet_template_id: + jet.action_available_ids = False + continue + + # Find actions in the template that start from the current state + actions = jet.jet_template_id.action_ids.filtered( + lambda a, state=jet.state_id: a.state_from_id == state + ) + jet.update({"action_available_ids": actions}) + + @api.depends("jet_template_id", "jet_template_id.template_requires_ids") + def _compute_jet_requires_ids(self): + """Compute the dependencies of the jets""" + for jet in self: + jet_template_dependencies = jet.jet_template_id.template_requires_ids + + final_vals = [] + + # 1. Check removed dependencies + if jet_template_dependencies: + jet_dependencies_to_remove = jet.jet_requires_ids.filtered( + lambda d, + jtd=jet_template_dependencies: d.jet_template_dependency_id + not in jtd + ) + else: + jet_dependencies_to_remove = jet.jet_requires_ids + + if jet_dependencies_to_remove: + final_vals = [(3, dep.id) for dep in jet_dependencies_to_remove] + + # Check new template dependencies + if jet_template_dependencies: + if jet.jet_requires_ids: + new_jet_template_dependencies = jet_template_dependencies.filtered( + lambda d, j=jet: d.id + not in j.jet_requires_ids.jet_template_dependency_id.ids + ) + else: + new_jet_template_dependencies = jet_template_dependencies + for dep in new_jet_template_dependencies: + final_vals.append( + ( + 0, + 0, + { + "jet_id": jet.id, + "jet_template_dependency_id": dep.id, + }, + ) + ) + if final_vals: + jet.jet_requires_ids = final_vals + + @api.depends_context("uid") + def _compute_show_available_states(self): + """Compute if available states should be shown for the jet""" + # Set all records at once to avoid multiple writes + self.show_available_states = ( + self.env.user.cetmix_tower_show_jet_available_states + ) + + def _inverse_show_available_states(self): + """Inverse the show available states for the jet""" + for jet in self: + if jet.show_available_states is not None: + jet.env.user.cetmix_tower_show_jet_available_states = ( + jet.show_available_states + ) + + @api.depends("jet_template_id", "jet_template_id.waypoint_template_ids") + def _compute_is_waypoints_available(self): + """Compute if waypoints are available for the jet""" + for jet in self: + jet.is_waypoints_available = bool(jet.jet_template_id.waypoint_template_ids) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Constraints + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @api.constrains("server_id", "jet_template_id") + def _check_jet_limit_per_server(self): + """Check if the jet limit per server is reached""" + for jet in self: + if ( + jet.jet_template_id.limit_per_server + and jet.jet_template_id.limit_per_server > 0 + ): + if jet.jet_template_id.limit_per_server < len( + jet.jet_template_id.jet_ids.filtered( + lambda j, s=jet.server_id: j.server_id == s + ) + ): + raise ValidationError( + _( + "Jet limit per server reached for" + " '%(jet)s' on server '%(server)s'!", + jet=jet.display_name, + server=jet.server_id.display_name, + ) + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # ORM methods + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + @api.model_create_multi + def create(self, vals_list): + """ + Create jets + - Generate jet reference if not provided + """ + + for vals in vals_list: + if not vals.get("reference"): + vals["reference"] = generate_random_id( + sections=3, population=4, separator="_" + ) + jets = super().create(vals_list) + + # Create server logs and scheduled tasks + for jet in jets: + # Server logs + for server_log in jet.jet_template_id.server_log_ids: + jet_log = server_log.copy( + { + "jet_id": jet.id, + "server_id": jet.server_id.id, + "jet_template_id": False, + } + ) + if server_log.log_type == "file": + jet_log.file_id = server_log.file_template_id.create_file( + server=jet.server_id, jet=jet, if_file_exists="skip" + ).id + + # Scheduled tasks + jet.scheduled_task_ids = jet.jet_template_id.scheduled_task_ids + + return jets + + def write(self, vals): + """Handle the entry into the new state""" + # Allow modifications in install mode only to load demo data + if ("jet_template_id" in vals or "server_id" in vals) and not ( + self._context.get("install_mode") and self._context.get("install_xmlid") + ): + raise ValidationError( + _( + "Jet template and server cannot be changed" + " once the jet is created!" + ) + ) + if "state_id" in vals: + for jet in self: + jet._on_state_exit(state=jet.state_id) + res = super().write(vals) + for jet in self: + jet._on_state_enter(state=jet.state_id) + else: + res = super().write(vals) + return res + + def unlink(self): + """ + Unlink all related files + """ + + # Check if the jet is deletable + not_deletable_jets = self.filtered(lambda j: not j.deletable) + if not_deletable_jets: + raise ValidationError( + _( + "Following jets cannot be deleted as they are not deletable: %s", + not_deletable_jets.mapped("display_name"), + ) + ) + files = self.file_ids + res = super().unlink() + + # Unlink files only after the records are deleted + # This is done to avoid deleting the files while + # the 'unlink' method fails due to some reason. + if files: + files.unlink() + return res + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Odoo Actions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def action_run_command(self): + """ + Returns wizard action to select command and run it + """ + context = self.env.context.copy() + context["default_jet_ids"] = self.ids + return { + "type": "ir.actions.act_window", + "name": _("Run Command"), + "res_model": "cx.tower.command.run.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + def action_run_flight_plan(self): + """ + Returns wizard action to select flightplan and run it + """ + context = self.env.context.copy() + context["default_jet_ids"] = self.ids + return { + "type": "ir.actions.act_window", + "name": _("Run Flight Plan"), + "res_model": "cx.tower.plan.run.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + + def action_open_command_logs(self): + """ + Open current server command log records + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_command_log" + ) + action["domain"] = [("jet_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_open_plan_logs(self): + """ + Open current server flightplan log records + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_plan_log" + ) + action["domain"] = [("jet_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_open_state_wizard(self): + """Open the jet state wizard""" + context = self.env.context.copy() + context["default_jet_ids"] = [(6, 0, self.ids)] + action = { + "type": "ir.actions.act_window", + "res_model": "cx.tower.jet.state.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + return action + + def action_open_action_wizard(self): + """Open the jet action wizard""" + context = self.env.context.copy() + context["default_jet_ids"] = [(6, 0, self.ids)] + action = { + "type": "ir.actions.act_window", + "res_model": "cx.tower.jet.action.wizard", + "view_mode": "form", + "target": "new", + "context": context, + } + return action + + def action_open_files(self): + """ + Open files of the current server + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_file_action" + ) + action["domain"] = [("jet_id", "=", self.id)] # pylint: disable=no-member + + context = self._context.copy() + if "context" in action and isinstance((action["context"]), str): + context.update(ast.literal_eval(action["context"])) + else: + context.update(action.get("context", {})) + + # Remove group_by from context + context.pop("group_by", None) + context.update( + { + "default_jet_id": self.id, + "default_server_id": self.server_id.id, + } + ) + action["context"] = context + return action + + def action_open_requires_jets(self): + """ + Open required jets of the current jet + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_action" + ) + action["domain"] = [("jet_required_by_ids.jet_id", "=", self.id)] # pylint: disable=no-member + return action + + def action_open_required_by_jets(self): + """ + Open dependant jets of the current jet + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_action" + ) + action["domain"] = [("jet_requires_ids.jet_depends_on_id", "=", self.id)] # pylint: disable=no-member + return action + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # General functions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _get_user_effective_access_level(self): + """ + Get the effective access level for the current user. + If user is manager but is not added as a manager to the jet, + his access level is considered as user. + Returns: + str: The effective access level for the current user. + see _selection_access_level() in cx.tower.access.mixin + """ + self.ensure_one() + user_access_level = self.env.user._cetmix_tower_access_level() + if user_access_level == "2" and self.env.user not in self.manager_ids: + return "1" + return user_access_level + + def get_variable_value(self, variable_reference, no_fallback=False): + """ + Return the value of a variable for the current jet. + NB: this function follows the value application order. + Jet->Jet Template->Server->Global + + Args: + variable_reference (Char): The reference of the variable + to get the value for + no_fallback (bool): If True, will return current record value + without checking fallback values. + + Returns: + str: The value of the variable for the current record or None + """ + self.ensure_one() + if no_fallback: + return super().get_variable_value(variable_reference, no_fallback) + variable = self.env["cx.tower.variable"].get_by_reference(variable_reference) + if not variable: + return None + values = variable._get_variable_values_by_references( + variable_references=[variable_reference], jet=self + ) + return values[variable_reference] + + def run_command( + self, + command, + path=None, + sudo=None, + ssh_connection=None, + **kwargs, + ): + """Run command on selected Jet. + A helper function that calls the corresponding server function. + + Important: this method raises an exception if the jet + is currently executing an action. + You should handle this exception in your code. + + Args: + command (cx.tower.command()): Command record + path (Char): directory where command is run. + Provide in case you need to override default command value + sudo (Boolean): use sudo + Defaults to None + ssh_connection (SSH client instance, optional): SSH connection. + Pass to reuse existing connection. + This is useful in case you would like to speed up + the ssh command running. Returns: + + Returns: + dict(): command running result if `no_command_log` + context value == True else None + """ + self.ensure_one() + + # Raise an exception if jets is currently executing an action + if self.current_action_id: + raise ValidationError( + _( + "Jet '%(jet)s' is currently executing an action", + jet=self.display_name, + ) + ) + + return self.server_id.run_command( + command=command, + path=path, + sudo=sudo, + ssh_connection=ssh_connection, + jet=self, + **kwargs, + ) + + def run_flight_plan(self, flight_plan, jet_template=None, **kwargs): + """ + Runs flight plan on the current jet. + + Important: this method raises an exception if the jet + is currently executing an action. + You should handle this exception in your code. + + Args: + flight_plan (cx.tower.plan()): flight plan to run + jet_template (cx.tower.jet.template()): jet template + to run the flight plan on + kwargs (dict): Optional arguments + Following are supported but not limited to: + - "plan_log": {values passed to flightplan logger} + - "log": {values passed to logger} + - "key": {values passed to key parser} + - "variable_values", dict(): custom variable values + in the format of `{variable_reference: variable_value}` + eg `{'odoo_version': '16.0'}` + Will be applied only if user has write access to the server. + Raises: + ValidationError: If the jet is currently executing an action. + Returns: + log_record (cx.tower.plan.log()): plan log record + """ + + self.ensure_one() + + # Raise an exception if jets is currently executing an action + # TODO: keep an eye on this method in case we use it + # directly in actions. + if self.current_action_id: + raise ValidationError( + _( + "Jet '%(jet)s' is currently executing an action", + jet=self.display_name, + ) + ) + + return self.server_id.run_flight_plan( + flight_plan=flight_plan, + jet_template=jet_template, + jet=self, + **kwargs, + ) + + def bring_to_state(self, state_reference): + """ + Bring the jet to a specific state. + This is a wrapper around the _bring_to_state method meant to be used + in various automatic actions. + + IMPORTANT: alway prefer using this method over the _bring_to_state method + in automation (eg Python commands) because it will check the access level + of the user to the state and raise an exception if the user is not allowed + to set the state. + + Use `_bring_to_state` method directly if you want to provide a state + object instead of a reference. + + Args: + state_reference (Char): The reference of the state to bring the jet to. + Returns: + The jet is brought into the target state. + In case of an error, the jet is brought into the error state + if the latter is defined. + + Raises: + ValidationError: If the state is not found. + AccessError: If the user is not allowed to set the state. + """ + self.ensure_one() + state = self.env["cx.tower.jet.state"].get_by_reference(state_reference) + if not state: + raise ValidationError( + _( + "State '%(state)s' not found for jet '%(jet)s'", + state=state_reference, + jet=self.display_name, + ) + ) + + if state.access_level > self._get_user_effective_access_level(): + raise AccessError( + _("You are not allowed to set the '%(state)s' state!", state=state.name) + ) + + self._bring_to_state(state) + + def clone(self, server=None, name=None, state=None, **kwargs): + """ + Create a new jet from this template on the given server. + + Following configuration variables will be available in the flight plan: + `__original_jet__`: The reference of the original jet + `__requested_state__`: The reference of the requested state + the new jet was requested to be in. + + Use these variables in the flight plan to identify the original jet + and the requested state. + + Args: + server (cx.tower.server()): The server to clone the jet on. + If not provided, the jet will be cloned on the same server. + name (str): The name of the new jet. + If not provided, a random name will be generated. + state (cx.tower.jet.state()): The state to bring the new jet to. + + Kwargs: + field values to populate in the new jet record. + NB: configuration variables are provided as follows: + (dict): Custom configuration variables + Following format is used: + `variable_reference`: `variable_value_char` + eg: + {'branch': 'prod', 'odoo_version': '16.0'} + Returns: + cx.tower.jet(): The new jet or False if the cloning has failed + """ + self.ensure_one() + + jet_template = self.jet_template_id + if not server: + server = self.server_id + same_server = True + else: + same_server = server.id == self.server_id.id + + # Check if template allows cloning on the same server + if same_server and not jet_template.plan_clone_same_server_id: + raise ValidationError( + _( + "Cloning on the same server is not allowed" + " for template '%(template)s'", + template=jet_template.name, + ) + ) + # Check if template allows cloning to a different server + if not same_server and not jet_template.plan_clone_different_server_id: + raise ValidationError( + _( + "Cloning to a different server is not allowed" + " for template '%(template)s'", + template=jet_template.name, + ) + ) + # Check if the jet creation is allowed on the given server + if not jet_template._allow_jet_creation(server): + return False + + # Prepare the jet custom values + kwargs.update( + { + "jet_cloned_from_id": self.id, + } + ) + + # Create a new jet + jet = jet_template.create_jet( + server, name=name or self._default_cloned_jet_name(), **kwargs + ) + + # Set scheduled tasks of the original jet to the new jet + jet.scheduled_task_ids = self.scheduled_task_ids + + # Set server logs of the original jet to the new jet + # Delete the server logs of the new jet if the original jet + # has no server logs + if self.server_log_ids: + jet.server_log_ids = [ + log.copy({"jet_id": False, "server_id": False}).id + for log in self.server_log_ids + ] + # Create files for file-type server logs + for jet_log in jet.server_log_ids: + if jet_log.log_type == "command": + continue + if jet_log.log_type == "file": + jet_log.file_id = jet_log.file_template_id.create_file( + server=jet.server_id, jet=jet, if_file_exists="skip" + ).id + else: + jet.server_log_ids.unlink() + + # NB: we are not passing the state as we need to run + # the clone flight plan first. + # The plan should take care of the state transition + # using the configuration variables. + # Update the custom values in the kwargs + + variable_values = { + "__original_jet__": self.reference, + "__original_server__": self.server_id.reference, + "__requested_jet_state__": state.reference if state else None, + } + + if same_server and jet_template.plan_clone_same_server_id: + jet.run_flight_plan( + jet_template.plan_clone_same_server_id, variable_values=variable_values + ) + elif not same_server and jet_template.plan_clone_different_server_id: + jet.run_flight_plan( + jet_template.plan_clone_different_server_id, + variable_values=variable_values, + ) + + return jet + + def _default_cloned_jet_name(self): + """Return default cloned jet name""" + self.ensure_one() + return f"{self.name} (clone)" + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Jet actions, state transitions, jet requests + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _trigger_action( + self, action, from_transition=False, raise_if_not_available=True, **kwargs + ): + """Trigger an action on the jet. + + The function flow is: + + 1. Bring the jet into the transit state. + 2. Execute the flight plan if defined. + 3. Bring the jet into the target state. + + Success: + The jet is brought into the target state. + + Error: + The jet is brought into the error state if it is defined. + Otherwise, the jet is brought into the initial state. + + + Args: + action (cx.tower.jet.action()): The action to trigger + from_transition (bool): True if the action is triggered + from a transition. + This is used to distinguish between a user directly + triggering the action and a transition from one state + to another. + raise_if_not_available (bool): + True if the function should raise an exception + if the action is not available for this jet. + **kwargs: Additional arguments: + - current_command_log: Optional command log record to track execution + + Returns: + dict: A dictionary with the following keys: + - status: The status of the action + - error: The error message if the action is not available + + Raises: + ValidationError: If the action is not available for this jet. + """ + self.ensure_one() + + # TODO: put action in the queue if jet is busy + + # Action properties must be accessible despite of the user group + action = action.sudo() + + # Get current command log + current_command_log = kwargs.get("current_command_log") + + # Ensure the action is available for this jet + if action.id not in self.action_available_ids.ids: + error = _( + "Action '%(action)s' is not available for jet" + " '%(jet)s' in state '%(state)s'", + action=action.name, + jet=self.name, # pylint: disable=no-member + state=self.state_id.name if self.state_id else _("Undefined"), + ) + if raise_if_not_available: + raise ValidationError(error) + if current_command_log: + current_command_log.finish( + status=JET_ACTION_NOT_AVAILABLE, + error=error, + ) + return {"status": JET_ACTION_NOT_AVAILABLE, "error": error} + + # Update the jet state + transit_state = action.state_transit_id + target_state = action.state_to_id + + # Check if the jet is already in the target state + # TODO: handle the case when destination state + # is the same as the current state. + # Eg when a jet is restarted. + if self.state_id == target_state and from_transition: + self.sudo().write({"target_state_id": None}) + self._finalize_transition(failed=False) + return {"status": 0, "error": None} + + # Set target state if not already set + if not self.target_state_id: + self.sudo().write({"target_state_id": target_state}) + + # Check if all dependencies are satisfied + # if starting from an undefined state. + # This is typical for a newly created jet. + if not self.state_id and not self._control_dependencies(): + # The process will be resumed + # when the dependencies are satisfied + error = _("Jet dependencies are not satisfied") + if current_command_log: + current_command_log.finish( + status=JET_DEPENDENCIES_NOT_SATISFIED, + error=error, + ) + return {"status": JET_DEPENDENCIES_NOT_SATISFIED, "error": error} + + self.sudo().write( + { + "state_id": transit_state, + "current_action_id": action.id, + "current_command_log_id": current_command_log.id + if current_command_log + else False, + } + ) + if action.plan_id: + # Run the flight plan + plan_kwargs = { + "plan_log": { + "jet_action_id": action.id, + }, + } + # Populate custom variable values from current command log + current_command_log = self.current_command_log_id + if current_command_log and current_command_log.variable_values: + plan_kwargs["variable_values"] = current_command_log.variable_values + + # Run the flight plan + with self.env.cr.savepoint(): + self.server_id.sudo().run_flight_plan( + flight_plan=action.plan_id, + jet=self, + **plan_kwargs, + ) + # Flight plan will trigger the `_flight_plan_finished` function again + # if the flight plan is finished successfully. + # So we don't need continue the loop in this case. + return {"status": 0, "error": None} + + # Set the state to the destination state if no plan is defined + final_vals = { + "state_id": target_state, + "current_action_id": False, + } + + # Reset the target state if the jet has reached the target state + if target_state == self.target_state_id: + final_vals["target_state_id"] = None + + self.sudo().write(final_vals) + + # Continue the chain of actions if the final state is not reached yet + if self.target_state_id: + self._bring_to_state(self.target_state_id) + + # Trigger the transition finished event + self._finalize_transition(failed=False) + return {"status": 0, "error": None} + + def _bring_to_state(self, state=None): + """ + Bring the jet to a specific state. + + The function flow is: + + 1. Compute the path of actions to bring the jet + to the target state. + 2. Set the target state. + 3. Trigger the first action in the path. + This will trigger a chain of actions until the jet is brought + into the target state. + + IMPORTANT: this method uses sudo() to bypass access rules. + This means that this method must be used with caution and only in cases + where the access level is not important. + For external automation including Python commands always prefer using + the bring_to_state() method instead. + For example: + ```python + jet = self.env["cx.tower.jet"].browse(jet_id) + jet.bring_to_state(state_reference) + ``` + + Args: + state (cx.tower.jet.state()): The state to bring the jet to + + Returns: + The jet is brought into the first state of the path. + In case of an error, the jet is brought into the error state + if the latter is defined. + + Raises: + ValidationError: If the path is not found. + """ + self.ensure_one() + + # Use sudo to bypass access rules + self = self.sudo() + + # Exit if jet is already in the target state + if self.state_id == state: + return + + # Compute the path of actions to bring the jet to the target state + path = self.jet_template_id._get_action_path( + state_from=self.state_id, state_to=state + ) + if not path: + raise ValidationError( + _( + "No path found to bring the jet %(jet)s to the state '%(state)s'", + jet=self.name, # pylint: disable=no-member + state=state.name if state else _("Undefined"), + ) + ) + + # Set the target state if not already set + if not self.target_state_id: + self.write( + { + "target_state_id": state, + } + ) + + # Trigger the first action in the path + self._trigger_action(path[0], from_transition=True) + + def _flight_plan_finished(self, plan_status): + """ + Handle the completion of a flight plan. + + Args: + plan_status (int): The status of the flight plan + (0: success, other: failure) + """ + self.ensure_one() + + # Used in case this is the last action in the chain + transition_failed = False + + # Reset the current action + vals = {"current_action_id": False} + + # If the flight plan is finished successfully, + # we bring the jet to the destination state + # of the current action + if plan_status == 0: + # Set the state to the destination state + vals["state_id"] = ( + self.current_action_id.state_to_id + and self.current_action_id.state_to_id.id + ) + + # Reset the target state if the jet has reached the target state + # This will stop the chain of actions + if self.target_state_id == self.current_action_id.state_to_id: + vals["target_state_id"] = None + + # If the flight plan is finished with an error, + # we bring the jet to the error state if it is defined + # or back to the initial state if not + # Reset the target state because we cannot continue the chain of actions + else: + vals.update( + { + "state_id": ( + self.current_action_id.state_error_id + and self.current_action_id.state_error_id.id + ) + or ( + self.current_action_id.state_from_id + and self.current_action_id.state_from_id.id + ), + "target_state_id": None, + } + ) + transition_failed = True + + self.sudo().write(vals) + + # Continue the chain of actions if the final state is not reached yet + if self.target_state_id: + self._bring_to_state(self.target_state_id) + else: + # Trigger the transition finished event + self._finalize_transition(failed=transition_failed) + + def _finalize_transition(self, failed=False): + """ + Handle the completion of a state transition. + + Args: + failed (bool): True if the transition failed, False otherwise + """ + self.ensure_one() + + # 1. Finalize the jet request if it exists + if self.served_jet_request_id: + self.served_jet_request_id._finalize(failed=failed) + + # 2. Finalize the command log if transition was + # triggered from a command + command_log = self.current_command_log_id + if command_log: + # Reset the current command log id + # Using sudo to bypass write access rules + self.sudo().write({"current_command_log_id": False}) + + # Prepare the command log finish values + if failed: + error = _( + "Action failed for jet %(jet)s.", + jet=self.name, # pylint: disable=no-member + ) + response = None + status = JET_STATE_ERROR + else: + response = _( + "Jet %(jet)s was moved to the '%(state)s' state.", + jet=self.name, # pylint: disable=no-member + state=self.state_id.name if self.state_id else _("Undefined"), + ) + status = 0 + error = None + + # Finish the command log + command_log.finish( + status=status, + response=response, + error=error, + ) + + # 3. Notify the jet that it is available + self._on_is_available() + + def _serve_jet_request(self, jet_request): + """ + Serve a jet request. + + Args: + jet_request (cx.tower.jet.request()): The jet request to serve + """ + self.ensure_one() + + # Save the request + # Using sudo to bypass write access rules + self.sudo().write({"served_jet_request_id": jet_request.id}) + + # State is reached, finalize the request + if self.state_id == jet_request.state_requested_id: + jet_request._finalize(failed=False) + else: + # Trigger the jet to bring itself to the required state + jet_request.state = "processing" + self._bring_to_state(jet_request.state_requested_id) + + def _finalize_jet_request(self, jet_request): + """ + This function is called when a jet request issued by this jet is finalized. + + Args: + jet_request (cx.tower.jet.request()): The jet request that was finalized + """ + self.ensure_one() + + # On success, update the dependency and + if jet_request.state == "success": + # Update the dependency if the request was for a dependency + dependency = jet_request.for_dependency_id + if dependency: + dependency.jet_depends_on_id = jet_request.jet_id + # Proceed with the state transition if all dependencies are satisfied + # and the transition is still in progress + if self._control_dependencies() and self.target_state_id: + self._bring_to_state(self.target_state_id) + else: + # Stop transition if the request failed + # Using sudo to bypass write access rules + self.sudo().write({"target_state_id": False}) + # Mark served jet request as failed + if self.served_jet_request_id: + self.served_jet_request_id._finalize(failed=True) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Waypoints + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def create_waypoint( + self, + waypoint_template, + name=None, + fly_here=False, + ignore_busy=False, + created_from_command_log=None, + **metadata, + ): + """Create a new waypoint for the jet. + + The jet must not be busy unless ignore_busy is True. + When created_from_command_log is provided, the waypoint stores it so that + the waypoint callback can finish the command log when the waypoint + reaches ready/current or error. + + Args: + waypoint_template (cx.tower.jet.waypoint.template or str): + The waypoint template or reference to create the waypoint from. + name (str, optional): The name of the waypoint. Defaults to None. + fly_here (bool, optional): Whether to fly to the waypoint after creation. + Defaults to False. + ignore_busy (bool, optional): Whether to ignore the busy state and create + the waypoint anyway. + Useful when creating waypoints from jet actions. + Defaults to False. + created_from_command_log (cx.tower.command.log, optional): Command log + that created this waypoint; the waypoint callback will finish it. + Defaults to None. + **metadata: Additional metadata to pass to the waypoint. + + Returns: + cx.tower.jet.waypoint + + Raises: + ValidationError: If the waypoint template is not found + or does not belong to the jet template, or if the jet is busy. + """ + self.ensure_one() + + # Check if the jet is busy + if self._is_busy() and not ignore_busy: + _logger.error( + "Cannot create waypoint for jet %s because it is busy", self.name + ) + raise ValidationError( + _("Cannot create waypoint for jet %s because it is busy", self.name) + ) + + # Resolve the waypoint template + if isinstance(waypoint_template, str): + waypoint_reference = waypoint_template + waypoint_template = self.env[ + "cx.tower.jet.waypoint.template" + ].get_by_reference(waypoint_reference) + if not waypoint_template: + _logger.error("Waypoint template %s not found", waypoint_reference) + raise ValidationError( + _("Waypoint template %s not found", waypoint_reference) + ) + + # Check if the waypoint template belongs to the jet template + if waypoint_template.jet_template_id != self.jet_template_id: + _logger.error( + "Waypoint template %s does not belong to the jet template %s", + waypoint_template.name, + self.jet_template_id.name, + ) + raise ValidationError( + _( + "Waypoint template %(waypoint_template)s does not belong " + "to the jet template %(jet_template)s", + waypoint_template=waypoint_template.name, + jet_template=self.jet_template_id.name, + ) + ) + + # Prepare the waypoint values + waypoint_values = self._prepare_waypoint_values( + waypoint_template=waypoint_template, + name=name, + **metadata, + ) + if created_from_command_log: + waypoint_values["created_from_command_log_id"] = created_from_command_log.id + + # Create the waypoint + waypoint = self.env["cx.tower.jet.waypoint"].create(waypoint_values) + waypoint.prepare(is_destination=fly_here) + return waypoint + + def _prepare_waypoint_values(self, waypoint_template, name=None, **metadata): + """Prepare the waypoint values + + Args: + waypoint_template (cx.tower.jet.waypoint.template): The waypoint template + name (Char, optional): The name of the waypoint. + """ + self.ensure_one() + + # Prepare the waypoint values + vals = { + "waypoint_template_id": waypoint_template.id, + "name": name if name else _("Auto-generated waypoint"), + "jet_id": self.id, + } + if metadata: + vals["metadata"] = metadata + + return vals + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Event handling + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _on_state_exit(self, state=None): + """ + Handle the exit of the jet from a state. + + Args: + state (cx.tower.jet.state()): The state jet is exiting + """ + self.ensure_one() + # TODO: Implement the logic to handle the exit of the jet from a state + pass + + def _on_state_enter(self, state=None): + """ + Handle the entry of the jet into a state. + + Args: + state (cx.tower.jet.state()): The state jet is entering + """ + self.ensure_one() + + # Refresh the frontend views + self.env.user.reload_views(model="cx.tower.jet", rec_ids=[self.id]) + + def _on_jet_request_completed(self, jet_request): + """ + Handle the completion of a jet request. + """ + self.ensure_one() + # TODO: Implement the logic to handle the completion of a jet request + pass + + def _on_is_available(self): + """ + Handle the event when the jet is not busy anymore. + """ + + # Process pending requests + jet_request_obj = self.env["cx.tower.jet.request"].sudo() + + # 1. Requests where the jet is requested explicitly + explicit_requests = jet_request_obj.search( + [ + ("jet_id", "=", self.id), # pylint: disable=no-member + ("state", "=", "new"), + ] + ) + if explicit_requests: + # Check which state is required by the request + # TODO: IMPORTANT: we must find a workaround to avoid infinite loops + # when different jets keep requesting the same target jet in different + # states and the target jet keeps jumping from one state to another. + + # Finalize all requests that request the same state as the jet + same_state_requests = explicit_requests.filtered( + lambda r: r.state_requested_id == self.state_id + ) + for request in same_state_requests: + request._finalize(failed=False) + + # Pick the first request that requests a different state + remaining_requests = explicit_requests - same_state_requests + if remaining_requests: + self._serve_jet_request(remaining_requests[0]) + return + + # 2. Requests where the jet is requested implicitly via template + if self._accepts_new_links(): + implicit_requests = jet_request_obj.search( + [ + ("server_id", "=", self.server_id.id), # pylint: disable=no-member + ("jet_template_id", "=", self.jet_template_id.id), # pylint: disable=no-member + ("jet_id", "=", False), + ("state", "=", "new"), + ] + ) + same_state_requests = implicit_requests.filtered( + lambda r: r.state_requested_id == self.state_id + ) + if same_state_requests: + # Set current jet as the target jet for the requests + same_state_requests.write({"jet_id": self.id}) # pylint: disable=no-member + for request in same_state_requests: + request._finalize(failed=False) + + # Pick the first request that requests a different state + remaining_requests = implicit_requests - same_state_requests + if remaining_requests: + remaining_request = remaining_requests[0] + # Set current jet as the target jet for the request + remaining_request.write({"jet_id": self.id}) # pylint: disable=no-member + self._serve_jet_request(remaining_request) + return + + # Send success notification when everything is done + # Use context timestamp to avoid timezone issues + context_timestamp = fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ) + + # Check if notifications are enabled + ICP_sudo = self.env["ir.config_parameter"].sudo() + notification_type_success = ICP_sudo.get_param( + "cetmix_tower_server.notification_type_success" + ) + if notification_type_success: + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_action" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Jet") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": self.id, + "views": [(False, "form")], + } + ) + # Send success notification + self.env.user.notify_success( + message=_( + "%(timestamp)s
" "Available in the '%(name)s' state", + name=self.state_id.name if self.state_id else _("Undefined"), + timestamp=context_timestamp, + ), + title=self.name, + sticky=notification_type_success == "sticky", + action=action, + ) + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Status and busyness + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _accepts_new_links(self): + """ + Check if the jet is available to accept new links from other jets. + + Returns: + bool: True if the jet is available to accept new links from other jets, + False otherwise + """ + self.ensure_one() + # TODO: Implement the logic to check if the jet is available + # to accept new links from other jets + return True + + def _is_busy(self): + """ + Check if the jet is busy with some other action. + Overwrite this function to implement custom logic. + + Returns: + bool: True if the jet is busy with some other action, + False otherwise + """ + self.ensure_one() + + # Jet is considered busy if it is currently transitioning to another state + busy = bool(self.target_state_id) + return busy + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Manage dependencies + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + def _control_dependencies(self): + """ + Check if dependencies are satisfied. + If some dependencies are missing, it creates a new jet request to ensure + that a jet that is required by that dependency is available. + + Returns: + bool: True if all dependencies are satisfied, False otherwise + """ + self.ensure_one() + + all_dependencies_satisfied = True + + jet_request_obj = self.env["cx.tower.jet.request"] + + # Check if jets are present in the required state + for jet_dependency in self.jet_requires_ids: + jet_template_dependency = jet_dependency.jet_template_dependency_id + if ( + jet_dependency.jet_depends_on_id + and jet_dependency.jet_depends_on_id.state_id + == jet_template_dependency.state_required_id + ): + # The dependency is satisfied, continue to the next dependency + continue + + # Create a new jet request to ensure we have the required jet + # in the required state + jet_request_obj._create_request( + server=self.server_id, + jet_template=jet_template_dependency.template_required_id, + state=jet_template_dependency.state_required_id, + requested_by_jet=self, + for_dependency=jet_dependency, + ) + # Stop here as it will be resumed when the jet request is finalized + all_dependencies_satisfied = False + break + + return all_dependencies_satisfied + + def _get_dependent_jets_by_template(self, jet_template): + """ + Check all dependencies of the jet and returns all jets + of the given template. + Both dependent and this jet depends on jets are returned. + + Args: + jet_template (cx.tower.jet.template()): The jet template + + Returns: + cx.tower.jet(): Recordset of jets + """ + self.ensure_one() + + # Check L1 jets this jet depends on + l1_jets = self.jet_requires_ids.filtered( + lambda r: r.jet_depends_on_id.jet_template_id == jet_template + ).jet_depends_on_id + # Check L1 jets that depend on this jet + l2_jets = self.jet_required_by_ids.filtered( + lambda r: r.jet_id.jet_template_id == jet_template + ).jet_id + + # TODO: check the entire dependency tree + return l1_jets | l2_jets + + def get_dependent_jets_by_template_reference(self, jet_template_reference): + """ + A wrapper for _get_dependent_jets_by_template that allows + to use the reference of the jet template instead of the record. + Designed to be used in the Python commands. + + Args: + jet_template_reference (str): The reference of the jet template + + Returns: + cx.tower.jet(): Recordset of jets with the given template + that depend on the current jet. + """ + self.ensure_one() + + jet_template = self.jet_template_id.get_by_reference(jet_template_reference) + if jet_template: + return self._get_dependent_jets_by_template(jet_template) + return False + + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + # Access role mixin functions + # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + def _get_post_create_fields(self): + """ + Add fields that should be populated after jet template creation + """ + res = super()._get_post_create_fields() + return res + ["variable_value_ids", "server_log_ids"]