# 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