diff --git a/addons/cetmix_tower_server/models/cx_tower_jet_template_install.py b/addons/cetmix_tower_server/models/cx_tower_jet_template_install.py new file mode 100644 index 0000000..8f833b7 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_jet_template_install.py @@ -0,0 +1,474 @@ +import logging + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class CxTowerJetTemplateInstall(models.Model): + """ + Used to track installation of Jet Templates on servers. + """ + + _name = "cx.tower.jet.template.install" + _description = "Jet Template Install/Uninstall" + _order = "create_date desc" + _rec_name = "jet_template_id" + + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + required=True, + help="Template to install/uninstall", + ) + server_id = fields.Many2one( + comodel_name="cx.tower.server", + index=True, + ondelete="cascade", + required=True, + help="Server to install/uninstall the template on", + ) + action = fields.Selection( + selection=[("install", "Install"), ("uninstall", "Uninstall")], + default="install", + ) + date_done = fields.Datetime(string="Completed on", readonly=True) + line_ids = fields.One2many( + comodel_name="cx.tower.jet.template.install.line", + inverse_name="jet_template_install_id", + auto_join=True, + string="Templates to install", + help="Complete list of templates to install/uninstall including dependencies", + ) + current_line_id = fields.Many2one( + comodel_name="cx.tower.jet.template.install.line", + string="Currently Installing", + help="Line that is currently being installed", + ) + state = fields.Selection( + selection=[ + ("processing", "Processing"), + ("done", "Done"), + ("failed", "Failed"), + ], + default="processing", + index=True, + ) + + @api.model + def install(self, server, template): + """Install the template on the server. + + Args: + server (cx.tower.server()): The server to install the template on. + template (cx.tower.jet.template()): The template to install. + + Returns: + cx.tower.jet.template.install(): The installation record. + """ + server.ensure_one() + template.ensure_one() + + # Compose the list of templates to install + # NB: templates will be installed later in reverse order + # to ensure that dependencies are satisfied + template_to_process = [template] + template._check_dependency_satisfaction( + server + ) + + # Prepare the template install lines + template_to_process_lines = [] + order = 0 + for t in template_to_process: + template_to_process_lines.append( + (0, 0, {"jet_template_id": t.id, "order": order}) + ) + order += 1 + + # Create a new install record + install_record = self.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "line_ids": template_to_process_lines, + } + ) + + # Send notification + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_template_install_action" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Installation") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": install_record.id, + "views": [(False, "form")], + } + ) + + self.env.user.notify_info( + message=_( + "%(timestamp)s
" "Installing template on server '%(server_name)s'", + server_name=server.name, + timestamp=fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ), + ), + title=template.name, + sticky=False, # explicitly set to False to avoid blocking the user's screen + action=action, + ) + + # Launch the installation + install_record._process_install() + + # Return the installation record + return install_record + + @api.model + def uninstall(self, server, template): + """Uninstall the template from the server. + NB: only one template can be uninstalled at a time. + + Args: + server (cx.tower.server()): The server to uninstall the template from. + template (cx.tower.jet.template()): The template to uninstall. + """ + server.ensure_one() + template.ensure_one() + + # Create a new install record + install_record = self.create( + { + "jet_template_id": template.id, + "server_id": server.id, + "line_ids": [(0, 0, {"jet_template_id": template.id, "order": 0})], + "action": "uninstall", + } + ) + + # Send notification + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_template_install_action" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Installation") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": install_record.id, + "views": [(False, "form")], + } + ) + + self.env.user.notify_info( + message=_( + "%(timestamp)s
" + "Uninstalling template on server '%(server_name)s'", + server_name=server.name, + timestamp=fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ), + ), + title=template.name, + sticky=False, # explicitly set to False to avoid blocking the user's screen + action=action, + ) + + # Launch the installation + install_record._process_install() + + # Return the installation record + return install_record + + def _process_install(self): + """ + Process the installation or uninstallation of the template. + """ + self.ensure_one() + + # We are not using `while` because flight plans + # may run asynchronously and we don't want to + # block the execution of the function + + # Continue only if the job is still processing + if self.state != "processing": + return + + # Exit if there are some lines currently being installed + if self.current_line_id: + return + + # Get the template to install + installation_tasks = self.line_ids.sorted("order", reverse=True) + for installation_task in installation_tasks: + # Pick the templates only in the "To Process" state + if installation_task.state != "to_process": + continue + + # Get the flight plan to install the template + if self.action == "install": + flight_plan = installation_task.jet_template_id.plan_install_id # pylint: disable=no-member + else: + flight_plan = installation_task.jet_template_id.plan_uninstall_id # pylint: disable=no-member + + # Run the corresponding flight plan + if flight_plan: + # Update the current template install line + self.write( + { + "current_line_id": installation_task.id, + } + ) + + # Add the install record to the flight plan params + plan_params = { + "jet_template_install_id": self.id, # pylint: disable=no-member + } + with self.env.cr.savepoint(): + # Run the flight plan (exceptions handled inside the flight plan) + self.server_id.run_flight_plan( + flight_plan=flight_plan, + jet_template=installation_task.jet_template_id, + **{"plan_log": plan_params}, + ) + # Flight plan will trigger the `_process_install` function again + # if the flight plan is finished successfully. + # So we don't need continue the loop in this case. + return + + # Mark the installation task as "Done" + # because nothing else is to be done here. + installation_task.write( + { + "state": "done", + } + ) + # Add to the list of installed templates + if self.action == "install": + installation_task.jet_template_id.write( + {"server_ids": [(4, self.server_id.id)]} + ) + else: + installation_task.jet_template_id.write( + {"server_ids": [(3, self.server_id.id)]} + ) + + # Refresh the frontend views + self.env.user.reload_views( + model="cx.tower.jet.template.install", + rec_ids=[self.id], + ) + + # Mark the installation as done + now = fields.Datetime.now() + self.write( + { + "state": "done", + "date_done": now, + } + ) + + # Refresh the frontend views + self.env.user.reload_views( + model="cx.tower.jet.template.install", rec_ids=[self.id] + ) + self.env.user.reload_views( + model="cx.tower.server", view_types=["form"], rec_ids=[self.server_id.id] + ) + self.env.user.reload_views( + model="cx.tower.jet.template", + view_types=["form"], + rec_ids=[self.jet_template_id.id], + ) + + # 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" + ) + # Send notification to the user + if notification_type_success: + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_template_install_action" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Installation") + 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
" + "%(action)s completed on server '%(server_name)s'", + action=_("Installation") + if self.action == "install" + else _("Uninstallation"), + server_name=self.server_id.name, + timestamp=fields.Datetime.context_timestamp(self, now), + ), + title=self.jet_template_id.name, # pylint: disable=no-member + sticky=notification_type_success == "sticky", + action=action, + ) + + def _flight_plan_finished(self, plan_status): + """ + Triggered when a flight plan that is used for installing/uninstalling + a template is finished. + + Args: + plan_status (int): The exit code of the flight plan. + """ + self.ensure_one() + + # Validate callback state + if not self.current_line_id: + _logger.warning( + "Callback invoked with no current_line_id for install %s", self.id + ) + return + + if self.state != "processing": + _logger.warning( + "Callback invoked for install %s in state %s", self.id, self.state + ) + return + + # Flight plan finished successfully + if plan_status == 0: + # Mark current line as done + self.current_line_id.write( # pylint: disable=no-member + { + "state": "done", + } + ) + # Add template to the list of installed templates + # or remove it from the list if it is being uninstalled + if self.action == "install": + self.current_line_id.jet_template_id.write( # pylint: disable=no-member + {"server_ids": [(4, self.server_id.id)]} + ) + else: + self.current_line_id.jet_template_id.write( # pylint: disable=no-member + {"server_ids": [(3, self.server_id.id)]} + ) + + # Remove the link to the current line and continue + self.write({"current_line_id": False}) + + # Refresh the frontend views + self.env.user.reload_views( + model="cx.tower.jet.template.install", + rec_ids=[self.id], + ) + self._process_install() + else: + # Mark current line as failed + self.current_line_id.write( # pylint: disable=no-member + { + "state": "failed", + } + ) + # Clear the current line link + self.write( + { + "state": "failed", + "date_done": fields.Datetime.now(), + "current_line_id": False, + } + ) + + # Set all other 'to_process' lines as failed + self.line_ids.filtered(lambda line: line.state == "to_process").write( + { + "state": "failed", + } + ) + + # Refresh the frontend views + self.env.user.reload_views( + model="cx.tower.jet.template.install", + rec_ids=[self.id], + ) + # Send notification to the user + # Check if notifications are enabled + ICP_sudo = self.env["ir.config_parameter"].sudo() + notification_type_error = ICP_sudo.get_param( + "cetmix_tower_server.notification_type_error" + ) + if notification_type_error: + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.cx_tower_jet_template_install_action" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Installation") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": self.id, + "views": [(False, "form")], + } + ) + # Send error notification + self.env.user.notify_danger( + message=_( + "%(timestamp)s
" + "%(action)s failed on server '%(server_name)s'", + action=_("Installation") + if self.action == "install" + else _("Uninstallation"), + server_name=self.server_id.name, + timestamp=fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ), + ), + title=self.jet_template_id.name, + sticky=notification_type_error == "sticky", + action=action, + ) + + def action_view_flight_plan_logs(self): + """Open flight plan logs related to this installation""" + self.ensure_one() + + return { + "name": _( + "Flight Plan Logs - %(install_name)s", + install_name=self.jet_template_id.name, + ), + "type": "ir.actions.act_window", + "res_model": "cx.tower.plan.log", + "view_mode": "tree,form", + "domain": [("jet_template_install_id", "=", self.id)], # pylint: disable=no-member + }