diff --git a/addons/cetmix_tower_server/models/cx_tower_plan_log.py b/addons/cetmix_tower_server/models/cx_tower_plan_log.py new file mode 100644 index 0000000..51042b3 --- /dev/null +++ b/addons/cetmix_tower_server/models/cx_tower_plan_log.py @@ -0,0 +1,532 @@ +# 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 .constants import PLAN_IS_EMPTY, PLAN_STOPPED + +_logger = logging.getLogger(__name__) + + +class CxTowerPlanLog(models.Model): + """Flight Plan Log""" + + _name = "cx.tower.plan.log" + _description = "Cetmix Tower Flight Plan Log" + _order = "start_date desc, id desc" + + active = fields.Boolean(default=True) + name = fields.Char(compute="_compute_name", compute_sudo=True, store=True) + label = fields.Char( + help="Custom label. Can be used for search/tracking", + index="trigram", + unaccent=False, + ) + server_id = fields.Many2one( + comodel_name="cx.tower.server", required=True, index=True, ondelete="cascade" + ) + jet_template_id = fields.Many2one( + comodel_name="cx.tower.jet.template", + readonly=True, + index=True, + ondelete="cascade", + ) + jet_template_install_id = fields.Many2one( + string="Jet Template Install Job", + comodel_name="cx.tower.jet.template.install", + readonly=True, + ondelete="cascade", + index=True, + help="Jet Template Install/Uninstall record being run. ", + ) + jet_id = fields.Many2one( + comodel_name="cx.tower.jet", + readonly=True, + index=True, + ondelete="cascade", + ) + jet_action_id = fields.Many2one( + comodel_name="cx.tower.jet.action", + readonly=True, + help="Used to track flight plans executed by jet actions", + ) + waypoint_id = fields.Many2one( + comodel_name="cx.tower.jet.waypoint", + help="Waypoint this plan log belongs to", + ) + + plan_id = fields.Many2one( + string="Flight Plan", + comodel_name="cx.tower.plan", + required=True, + index=True, + ondelete="cascade", + ) + access_level = fields.Selection( + related="plan_id.access_level", + readonly=True, + store=True, + index=True, + ) + + # -- Time + start_date = fields.Datetime(string="Started") + finish_date = fields.Datetime(string="Finished") + duration = fields.Float( + help="Time consumed for execution, seconds", + compute="_compute_duration", + store=True, + ) + duration_current = fields.Float( + string="Duration, sec", + compute="_compute_duration_current", + help="For how long a flight plan is already running", + ) + + # -- Commands + is_running = fields.Boolean( + help="Plan is being executed right now", compute="_compute_duration", store=True + ) + is_stopped = fields.Boolean( + string="Stopped", default=False, help="Flight plan was stopped by user" + ) + plan_line_executed_id = fields.Many2one( + comodel_name="cx.tower.plan.line", + help="Flight Plan line that is being currently executed", + ) + command_log_ids = fields.One2many( + comodel_name="cx.tower.command.log", inverse_name="plan_log_id", auto_join=True + ) + plan_status = fields.Integer( + string="Status", + help="0 if plan is finished successfully. \n" + "-301 if another instance of this flight plan is running, \n" + "-302 if plan is empty, \n" + "-303 if plan reference is missing, \n" + "-304 if plan line reference is missing, \n" + "-306 if plan is not compatible with server,\n" + "-308 if plan is stopped by user", + ) + custom_message = fields.Text( + help="Custom message to be displayed in the plan log", + ) + parent_flight_plan_log_id = fields.Many2one( + "cx.tower.plan.log", string="Main Log", ondelete="cascade" + ) + scheduled_task_id = fields.Many2one( + "cx.tower.scheduled.task", + ondelete="set null", + help="Scheduled task that triggered this flight plan", + ) + variable_values = fields.Json( + default={}, + help="Custom variable values passed to the flight plan", + ) + + @api.depends("server_id.name", "name") + def _compute_name(self): + for rec in self: + rec.name = ": ".join((rec.server_id.name, rec.plan_id.name)) # type: ignore + + @api.depends("start_date", "finish_date") + def _compute_duration(self): + for plan_log in self: + # Not started yet + if not plan_log.start_date: + continue + + # If plan is finished, compute duration + if plan_log.finish_date: + plan_log.update( + { + "duration": ( + plan_log.finish_date - plan_log.start_date + ).total_seconds(), + "is_running": False, + } + ) + continue + + # If plan is running, set is_running to True + plan_log.is_running = True + + @api.depends("is_running") + def _compute_duration_current(self): + """Shows relative time between now() and start time for running plans, + and computed duration for finished ones. + """ + now = fields.Datetime.now() + for plan_log in self: + if plan_log.is_running: + plan_log.duration_current = (now - plan_log.start_date).total_seconds() + else: + plan_log.duration_current = plan_log.duration + + def start(self, server, plan, start_date=None, **kwargs): + """ + Runs plan on server. + Creates initial log records for each command that cannot be executed until + it finds the first executable command. + + Args: + server (cx.tower.server()) server. + plan (cx.tower.plan()) Flight Plan. + start_date (datetime) flight plan start date time. + **kwargs (dict): optional values + Following keys are supported but not limited to: + - "plan_log": {values passed to flightplan logger} + - "log": {values passed to logger} + - "key": {values passed to key parser} + - "no_command_log" (bool): If True, no logs will be recorded for + non-executable lines. + - "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. + Returns: + cx.tower.plan.log(): New flightplan log record. + """ + + def get_executable_line( + plan, server, jet_template=None, jet=None, variable_values=None + ): + """ + Generator to get each line and check if it's executable. + Args: + plan (cx.tower.plan()): Flight Plan. + server (cx.tower.server()): Server. + jet_template (cx.tower.jet.template()): Jet Template. + jet (cx.tower.jet()): Jet. + Returns: + tuple: (line, is_executable) + """ + for line in plan.line_ids: + yield ( + line, + line._is_executable_line( + server=server, + jet_template=jet_template, + jet=jet, + variable_values=variable_values, + ), + ) + + vals = { + "server_id": server.id, + "plan_id": plan.id, + "is_running": True, + "start_date": start_date or fields.Datetime.now(), + } + + # Extract and apply plan log kwargs + plan_log_kwargs = kwargs.get("plan_log") + if plan_log_kwargs: + vals.update(plan_log_kwargs) + + # Extract and apply variable values + variable_values = kwargs.get("variable_values") + if variable_values: + vals["variable_values"] = variable_values + + plan_log = self.sudo().create(vals) + + # Process each line until the first executable one is found + for line, is_executable in get_executable_line( + plan=plan, + server=server, + jet_template=plan_log.jet_template_id, + jet=plan_log.jet_id, + variable_values=variable_values, + ): + if is_executable: + line._run(server=server, plan_log_record=plan_log, **kwargs) + break + else: + if self._context.get("no_command_log"): + continue + line._skip( + server, + plan_log, + log={ + "variable_values": dict(variable_values or {}), + "jet_template_id": plan_log.jet_template_id.id + if plan_log.jet_template_id + else None, + "jet_id": plan_log.jet_id.id if plan_log.jet_id else None, + }, + ) + break + else: + plan_log.finish(plan_status=PLAN_IS_EMPTY) + + return plan_log + + def stop(self): + """ + Force stop this plan log (and currently running command if possible). + """ + user_name = self.env.user.name + for log in self: + if not log.is_running: + continue + + # Finish plan log + log.finish( + plan_status=PLAN_STOPPED, + custom_message=_("Stopped by user %(user)s", user=user_name), + is_stopped=True, + ) + + # Stop running command + running_cmd_logs = log.command_log_ids.filtered(lambda c: c.is_running) + running_cmd_logs.stop() + + def action_stop(self): + """ + Action to stop the running plans. + """ + self.stop() + + if len(self) > 1: # more than one plan is running + title = _("Flight Plans Stopped") + message = ", ".join([plan.name for plan in self]) + else: + title = _("Flight Plan Stopped") + message = self.name + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": title, + "message": message, + "sticky": False, + "next": { + "type": "ir.actions.act_window_close", + }, + }, + } + + def finish(self, plan_status, **kwargs): + """Finish plan execution + + Args: + plan_status (Integer) plan execution code + **kwargs (dict): optional values + """ + values = { + "is_running": False, + "plan_status": plan_status, + "finish_date": fields.Datetime.now(), + } + + self.ensure_one() + + # Apply kwargs + if kwargs: + values.update(kwargs) + self.sudo().write(values) + + # Call the plan finished hook + # Use try/except to ensure that the plan finished hook is called + try: + # Savepoint to ensure that values are stored + # if something goes wrong. + with self.env.cr.savepoint(): + self._plan_finished() + except Exception as e: + _logger.warning( + "Post-finish hook for plan '%s' failed: %s", self.plan_id.name, e + ) + # Continue with the rest of the logic + + # Jet Template action: only if it's not a sub-plan + # NB: Jet Template is always set automatically even if + # it's not provided explicitly when the plan is run. + if not self.jet_template_id or self.parent_flight_plan_log_id: + return + + # Waypoint action: only if it's not a sub-plan + if self.waypoint_id: + try: + with self.env.cr.savepoint(): + self.waypoint_id._plan_finished(self) + except Exception as e: + _logger.warning( + "Post-finish hook for waypoint '%s' failed: %s", + self.waypoint_id.name, + e, + ) + + # Finish template install/uninstall + if self.jet_template_install_id: + try: + with self.env.cr.savepoint(): + self.jet_template_install_id._flight_plan_finished( + plan_status=self.plan_status, + ) + except Exception as e: + _logger.warning( + "Post-finish hook for template install/uninstall " + "'%s'" + " failed: %s", + self.jet_template_install_id.name, + e, + ) + + # Jet + if self.jet_id and self.jet_action_id: + try: + with self.env.cr.savepoint(): + self.jet_id._flight_plan_finished( + plan_status=self.plan_status, + ) + except Exception as e: + _logger.warning( + "Post-finish hook for jet '%s' failed: %s", self.jet_id.name, e + ) + + def record(self, server, plan, status, **kwargs): + """ + Record plan log without running it. + + Args: + server (cx.tower.server()) server. + plan (cx.tower.plan()) Flight Plan. + status (int) plan execution code + start_date (datetime) flight plan start date time. + finish_date (datetime) flight plan finish date time. + **kwargs (dict): optional values + Following keys are supported but not limited to: + - "plan_log": {values passed to flightplan logger} + - "log": {values passed to logger} + - "key": {values passed to key parser} + - "no_command_log" (bool): If True, no logs will be recorded for + non-executable lines. + Returns: + cx.tower.plan.log(): New flightplan log record. + """ + + vals = { + "server_id": server.id, + "plan_id": plan.id, + "start_date": fields.Datetime.now(), + } + + # Extract and apply plan log kwargs + plan_log_kwargs = kwargs.get("plan_log") + if plan_log_kwargs: + vals.update(plan_log_kwargs) + + plan_log = self.sudo().create(vals) + plan_log.finish(plan_status=status) + return plan_log + + def _plan_finished(self): + """Triggered when flightplan in finished + Inherit to implement your own hooks + + Returns: + bool: True if event was handled + """ + self.ensure_one() + + # Do not notify if a plan that was run from another plan has been executed + if self.parent_flight_plan_log_id: + return True + + # 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" + ) + notification_type_error = ICP_sudo.get_param( + "cetmix_tower_server.notification_type_error" + ) + + # Prepare notifications + if not notification_type_success and not notification_type_error: + return True + + # Use context timestamp to avoid timezone issues + context_timestamp = fields.Datetime.context_timestamp( + self, fields.Datetime.now() + ) + + # Action for button + action = self.env["ir.actions.act_window"]._for_xml_id( + "cetmix_tower_server.action_cx_tower_plan_log" + ) + + context = self.env.context.copy() + params = dict(context.get("params") or {}) + params["button_name"] = _("View Log") + context["params"] = params + + # Add record id and context to the action + action.update( + { + "context": context, + "res_id": self.id, + "views": [(False, "form")], + } + ) + + # Send notification only if not a jet-related plan + if ( + self.plan_status == 0 + and notification_type_success + and not self.jet_template_id + ): + # Success notification + self.create_uid.notify_success( + message=_( + "%(timestamp)s
" "Flight Plan '%(name)s' finished successfully", + name=self.plan_id.name, + timestamp=context_timestamp, + ), + title=self.server_id.name, + sticky=notification_type_success == "sticky", + action=action, + ) + + # Error notification + # They are shown for jet-related plans and template installation/uninstallation + # as well to simplify the debugging process. + if self.plan_status != 0 and notification_type_error: + self.create_uid.notify_danger( + message=_( + "%(timestamp)s
" + "Flight Plan '%(name)s'" + " finished with error", + name=self.plan_id.name, + timestamp=context_timestamp, + ), + title=self.server_id.name, + sticky=notification_type_error == "sticky", + action=action, + ) + + return True + + def _plan_command_finished(self, command_log): + """This function is triggered when a command from this log is finished. + Next action is triggered based on command status (ak exit code) + + Args: + command_log (cx.tower.command.log()): Command log object + + """ + self.ensure_one() + + # Prevent scheduling further actions if this log was stopped + if self.is_stopped: + return + + # Update plan log variable values from command log + # Overwrite with command log values (last command's values take precedence) + self.variable_values = command_log.variable_values + + # Get next line to execute + self.plan_id._run_next_action(command_log) # type: ignore