import logging from datetime import timedelta from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models _logger = logging.getLogger(__name__) class CxTowerScheduledTask(models.Model): _name = "cx.tower.scheduled.task" _description = "Scheduled Task" _inherit = ["cx.tower.access.role.mixin", "cx.tower.reference.mixin"] _order = "sequence, next_call" active = fields.Boolean(default=True) sequence = fields.Integer(default=10) server_ids = fields.Many2many( "cx.tower.server", "cx_tower_scheduled_task_server_rel", "scheduled_task_id", "server_id", string="Servers", ) server_template_ids = fields.Many2many( string="Server Templates", comodel_name="cx.tower.server.template", relation="cx_tower_server_template_scheduled_task_rel", column1="scheduled_task_id", column2="server_template_id", ) action = fields.Selection( [("command", "Command"), ("plan", "Flight Plan")], required=True ) command_id = fields.Many2one("cx.tower.command", string="Command") plan_id = fields.Many2one(string="Flight Plan", comodel_name="cx.tower.plan") is_running = fields.Boolean(default=False, readonly=True) interval_number = fields.Integer(default=1, help="Repeat every x.") interval_type = fields.Selection( [ ("minutes", "Minutes"), ("hours", "Hours"), ("days", "Days"), ("weeks", "Weeks"), ("months", "Months"), ], string="Interval Unit", default="months", ) next_call = fields.Datetime( string="Next Execution Date", required=True, default=fields.Datetime.now, help="Next planned execution date for this task.", ) last_call = fields.Datetime( string="Last Execution Date", help="Previous time the task ran successfully." ) custom_variable_value_ids = fields.One2many( "cx.tower.scheduled.task.cv", "scheduled_task_id", string="Custom Variable Values", ) warning_message = fields.Text( compute="_compute_warning_message", ) # ---- Access. Add relation for mixin fields user_ids = fields.Many2many( relation="cx_tower_scheduled_task_user_rel", ) manager_ids = fields.Many2many( relation="cx_tower_scheduled_task_manager_rel", ) _sql_constraints = [ ( "interval_positive", "CHECK (interval_number > 0)", "Interval number must be greater than zero.", ), ] @api.depends("interval_number", "interval_type") def _compute_warning_message(self): """ Show warning on the task form if interval in the scheduled task is less than interval in the underlaying cron job. """ cron = self.env.ref( "cetmix_tower_server.ir_cron_run_scheduled_tasks", raise_if_not_found=False ) if not cron: self.warning_message = False return # Using now's date as the base point ensures a consistent and comparable # reference when calculating the next scheduled execution for both the cron # and the tasks. now = fields.Datetime.now() # _get_next_call is designed for tasks, but can also be used for the # cron record, as both share the same interval fields. This keeps interval # comparison logic consistent. cron_next = self._get_next_call(cron, now) for task in self: task_next = self._get_next_call(task, now) if task_next < cron_next: task.warning_message = _( "The selected task interval is too low in relation to the general " "system settings. This may lead to task execution delays." ) else: task.warning_message = False def action_run(self): """ Run scheduled action and reschedule next call. """ return self._run() def action_open_command_logs(self): """ Open current scheduled task command log records """ action = self.env["ir.actions.actions"]._for_xml_id( "cetmix_tower_server.action_cx_tower_command_log" ) action["domain"] = [("scheduled_task_id", "=", self.id)] # pylint: disable=no-member return action def action_open_plan_logs(self): """ Open current scheduled task flightplan log records """ action = self.env["ir.actions.actions"]._for_xml_id( "cetmix_tower_server.action_cx_tower_plan_log" ) action["domain"] = [("scheduled_task_id", "=", self.id)] # pylint: disable=no-member return action @api.model def _run_scheduled_tasks(self): """ Cron: finds due tasks and runs their actions (command/plan). Handles errors per-task and reserves tasks atomically to avoid double execution. """ now = fields.Datetime.now() due_tasks = self.search( [ ("next_call", "<=", now), ("active", "=", True), ("is_running", "=", False), ] ) if not due_tasks: return due_tasks.with_context(from_cron=True)._run() def _run(self): """ Run scheduled action and reschedule next call. """ tasks = self._reserve_tasks() if not tasks: return if self.env.context.get("from_cron"): # WARNING: Explicit commit! # This commit is made **only** when called from cron (context["from_cron"]). # Reason: To atomically reserve scheduled tasks by setting is_running=True, # so that only one cron worker processes each task, even if multiple workers # pick up the cron job at the same time. Without this commit, the change # would not be visible to other transactions until the end of the cron # transaction, leading to a race condition and possible double execution. # Explicit commits are strongly discouraged in Odoo business logic and # should be used only with clear justification and in strictly controlled # contexts (like this cron scenario). Never add this commit for general # business flows! self.env.cr.commit() # pylint: disable=invalid-commit errors = [] for task in tasks: try: with self.env.cr.savepoint(): if task.action == "command" and task.command_id: task._run_command() elif task.action == "plan" and task.plan_id: task._run_plan() except Exception as e: _logger.exception(f"Scheduled task {task.id} failed: {e}") task_error = _( "Unable to run scheduled task '%(f)s'. Error: %(e)s", f=task.display_name, e=e, ) errors.append(task_error) finally: finished_at = fields.Datetime.now() # Always update the scheduling, even if the task failed task.write( { "last_call": finished_at, "next_call": self._get_next_call(task, finished_at), "is_running": False, } ) if errors: return { "type": "ir.actions.client", "tag": "display_notification", "params": { "title": _("Failure"), "message": "\n".join(errors), }, } return { "type": "ir.actions.client", "tag": "display_notification", "params": { "title": _("Success"), "message": _("Scheduled tasks run successfully."), }, } def _get_next_call(self, task, from_date): """ Calculate next_call datetime """ num = task.interval_number or 1 intervals = { "minutes": timedelta(minutes=num), "hours": timedelta(hours=num), "days": timedelta(days=num), "weeks": timedelta(weeks=num), "months": relativedelta(months=num), } return from_date + intervals.get(task.interval_type, timedelta()) def _run_command(self): """Run command on selected servers.""" variable_values = { value.variable_id.reference: value.value_char for value in self.custom_variable_value_ids } kwargs = { "log": {"scheduled_task_id": self.id}, "variable_values": variable_values, } for server in self.server_ids: server.run_command(self.command_id, **kwargs) def _run_plan(self): """Run flight plan on selected servers.""" variable_values = { value.variable_id.reference: value.value_char for value in self.custom_variable_value_ids } kwargs = { "plan_log": {"scheduled_task_id": self.id}, "variable_values": variable_values, } for server in self.server_ids: server.run_flight_plan(self.plan_id, **kwargs) def _reserve_tasks(self, limit=None): """ Atomically select and lock free tasks for processing. """ sql = """ SELECT id FROM cx_tower_scheduled_task WHERE is_running = FALSE AND id IN %s ORDER BY id """ params = [tuple(self.ids)] if limit: sql += " LIMIT %s" params.append(limit) sql += " FOR UPDATE SKIP LOCKED" self.env.cr.execute(sql, tuple(params)) task_ids = [row[0] for row in self.env.cr.fetchall()] if not task_ids: return self.browse() tasks = self.browse(task_ids) tasks.write({"is_running": True}) return tasks