diff --git a/addons/cetmix_tower_server_queue/README.rst b/addons/cetmix_tower_server_queue/README.rst new file mode 100644 index 0000000..12c2d07 --- /dev/null +++ b/addons/cetmix_tower_server_queue/README.rst @@ -0,0 +1,84 @@ +========================= +Cetmix Tower Server Queue +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2e1ee4e42121b9fbd22081b4097aecc67b4988941177c5dc22711252011a95fa + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github + :target: https://github.com/cetmix/cetmix-tower/tree/18.0/cetmix_tower_server_queue + :alt: cetmix/cetmix-tower + +|badge1| |badge2| |badge3| + +This module implements asynchronous task execution for `Cetmix +Tower `__. + +It requires the `queue_job `__ +module to be installed and configured in the Odoo instance. + +Please refer to the `official +documentation `__ for detailed information. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Please refer to the `official +documentation `__ for detailed configuration +instructions. + +Usage +===== + +Please refer to the `official +documentation `__ for detailed usage +instructions. + +Changelog +========= + +18.0.2.0.0 (2026-04-07) +----------------------- + +- Features: Jets! (4700) + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Cetmix + +Maintainers +----------- + +This module is part of the `cetmix/cetmix-tower `_ project on GitHub. + +You are welcome to contribute. diff --git a/addons/cetmix_tower_server_queue/__init__.py b/addons/cetmix_tower_server_queue/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/cetmix_tower_server_queue/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/cetmix_tower_server_queue/__manifest__.py b/addons/cetmix_tower_server_queue/__manifest__.py new file mode 100644 index 0000000..c116be1 --- /dev/null +++ b/addons/cetmix_tower_server_queue/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Cetmix Tower Server Queue", + "summary": "Cetmix Tower asynchronous task execution using 'queue_job'", + "version": "18.0.2.0.0", + "development_status": "Beta", + "category": "Productivity", + "website": "https://tower.cetmix.com", + "author": "Cetmix", + "license": "AGPL-3", + "installable": True, + "auto_install": True, + "depends": ["cetmix_tower_server", "queue_job"], + "data": [ + "views/cx_tower_command_log_view.xml", + "views/cx_tower_file_view.xml", + ], +} diff --git a/addons/cetmix_tower_server_queue/i18n/cetmix_tower_server_queue.pot b/addons/cetmix_tower_server_queue/i18n/cetmix_tower_server_queue.pot new file mode 100644 index 0000000..9ae3ace --- /dev/null +++ b/addons/cetmix_tower_server_queue/i18n/cetmix_tower_server_queue.pot @@ -0,0 +1,137 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_server_queue +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: cetmix_tower_server_queue +#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_command_log__command_status +msgid "" +"0 if command finished successfully.\n" +"-100 general error,\n" +"-101 not found,\n" +"-201 another instance of this command is running,\n" +"-202 no runner found for the command action,\n" +"-203 Python code execution failed\n" +"-205 plan line condition check failed\n" +"503 if SSH connection error occurred\n" +"601 if queue job failed" +msgstr "" + +#. module: cetmix_tower_server_queue +#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_command_log +msgid "Cetmix Tower Command Log" +msgstr "" + +#. module: cetmix_tower_server_queue +#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_file +msgid "Cetmix Tower File" +msgstr "" + +#. module: cetmix_tower_server_queue +#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_server +msgid "Cetmix Tower Server" +msgstr "" + +#. module: cetmix_tower_server_queue +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form +msgid "Error" +msgstr "" + +#. module: cetmix_tower_server_queue +#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__command_status +msgid "Exit Code" +msgstr "" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +msgid "Failure" +msgstr "" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +msgid "File downloaded!" +msgstr "" + +#. module: cetmix_tower_server_queue +#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed +msgid "File is currently being processed" +msgstr "" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +msgid "File uploaded!" +msgstr "" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +msgid "File(s) %(name)s download failed: %(error)s" +msgstr "" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +msgid "File(s) %(name)s upload failed: %(error)s" +msgstr "" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +msgid "Files downloaded!" +msgstr "" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +msgid "Files uploaded!" +msgstr "" + +#. module: cetmix_tower_server_queue +#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed +msgid "Is Being Processed" +msgstr "" + +#. module: cetmix_tower_server_queue +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form +msgid "Processing" +msgstr "" + +#. module: cetmix_tower_server_queue +#: model:ir.model,name:cetmix_tower_server_queue.model_queue_job +#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__queue_job_id +msgid "Queue Job" +msgstr "" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form +msgid "Success" +msgstr "" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +msgid "The following files are already being processed: %(name)s" +msgstr "" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +msgid "" +"Unable to upload file '%(f)s'.\n" +"Upload operation is not supported for 'server' type files." +msgstr "" diff --git a/addons/cetmix_tower_server_queue/i18n/it.po b/addons/cetmix_tower_server_queue/i18n/it.po new file mode 100644 index 0000000..c893bb7 --- /dev/null +++ b/addons/cetmix_tower_server_queue/i18n/it.po @@ -0,0 +1,156 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * cetmix_tower_server_queue +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: it\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 2.3\n" + +#. module: cetmix_tower_server_queue +#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_command_log__command_status +msgid "" +"0 if command finished successfully.\n" +"-100 general error,\n" +"-101 not found,\n" +"-201 another instance of this command is running,\n" +"-202 no runner found for the command action,\n" +"-203 Python code execution failed\n" +"-205 plan line condition check failed\n" +"503 if SSH connection error occurred\n" +"601 if queue job failed" +msgstr "" +"0 se il comando è stato completato correttamente.\n" +"-100 errore generale,-101 non trovato,\n" +"-201 un'altra istanza di questo comando è in esecuzione,\n" +"-202 nessun runner trovato per l'azione del comando,\n" +"-203 esecuzione del codice Python non riuscita,\n" +"-205 controllo delle condizioni della riga del piano non riuscito,\n" +"503 se si è verificato un errore di connessione SSH,\n" +"601 se il processo in coda non è riuscito" + +#. module: cetmix_tower_server_queue +#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_command_log +msgid "Cetmix Tower Command Log" +msgstr "Registro comandi Cetmix Tower" + +#. module: cetmix_tower_server_queue +#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_file +msgid "Cetmix Tower File" +msgstr "File Cetmix Tower" + +#. module: cetmix_tower_server_queue +#: model:ir.model,name:cetmix_tower_server_queue.model_cx_tower_server +msgid "Cetmix Tower Server" +msgstr "Server Cetmix Tower" + +#. module: cetmix_tower_server_queue +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form +msgid "Error" +msgstr "Errore" + +#. module: cetmix_tower_server_queue +#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__command_status +msgid "Exit Code" +msgstr "Codice di uscita" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +#, python-format +msgid "Failure" +msgstr "Operazione non riuscita" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +#, python-format +msgid "File downloaded!" +msgstr "File scaricato!" + +#. module: cetmix_tower_server_queue +#: model:ir.model.fields,help:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed +msgid "File is currently being processed" +msgstr "Il file è attualmente in elaborazione" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +#, python-format +msgid "File uploaded!" +msgstr "File caricato!" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +#, python-format +msgid "Files downloaded!" +msgstr "File scaricati!" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +#, python-format +msgid "Files uploaded!" +msgstr "File caricati!" + +#. module: cetmix_tower_server_queue +#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_file__is_being_processed +msgid "Is Being Processed" +msgstr "In elaborazione" + +#. module: cetmix_tower_server_queue +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form +msgid "Processing" +msgstr "Elaborazione" + +#. module: cetmix_tower_server_queue +#: model:ir.model,name:cetmix_tower_server_queue.model_queue_job +#: model:ir.model.fields,field_description:cetmix_tower_server_queue.field_cx_tower_command_log__queue_job_id +msgid "Queue Job" +msgstr "Lavoro in coda" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form +#, python-format +msgid "Success" +msgstr "Successo" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +#, python-format +msgid "The following files are already being processed: %(name)s" +msgstr "I seguenti file sono già in elaborazione: %(name)s" + +#. module: cetmix_tower_server_queue +#. odoo-python +#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0 +#, python-format +msgid "" +"Unable to upload file '%(f)s'.\n" +"Upload operation is not supported for 'server' type files." +msgstr "" +"Impossibile caricare il file '%(f)s'.\n" +"L'operazione di caricamento non è supportata per i file di tipo 'server'." + +#~ msgid "Display Name" +#~ msgstr "Nome visualizzato" + +#~ msgid "ID" +#~ msgstr "ID" + +#~ msgid "Last Modified on" +#~ msgstr "Ultima modifica il" diff --git a/addons/cetmix_tower_server_queue/models/__init__.py b/addons/cetmix_tower_server_queue/models/__init__.py new file mode 100644 index 0000000..4e40e4b --- /dev/null +++ b/addons/cetmix_tower_server_queue/models/__init__.py @@ -0,0 +1,4 @@ +from . import cx_tower_command_log +from . import cx_tower_server +from . import queue_job +from . import cx_tower_file diff --git a/addons/cetmix_tower_server_queue/models/cx_tower_command_log.py b/addons/cetmix_tower_server_queue/models/cx_tower_command_log.py new file mode 100644 index 0000000..9062038 --- /dev/null +++ b/addons/cetmix_tower_server_queue/models/cx_tower_command_log.py @@ -0,0 +1,90 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from psycopg2.errors import LockNotAvailable + +from odoo import fields, models, tools + +from odoo.addons.cetmix_tower_server.models.constants import ( + COMMAND_STOPPED, + COMMAND_TIMED_OUT, +) +from odoo.addons.queue_job.job import CANCELLED + +_logger = logging.getLogger(__name__) + + +class CxTowerCommandLog(models.Model): + _inherit = "cx.tower.command.log" + + queue_job_id = fields.Many2one( + "queue.job", + readonly=True, + groups="queue_job.group_queue_job_manager", + ) + + command_status = fields.Integer( + help="0 if command finished successfully.\n" + "-100 general error,\n" + "-101 not found,\n" + "-201 another instance of this command is running,\n" + "-202 no runner found for the command action,\n" + "-203 Python code execution failed\n" + "-205 plan line condition check failed\n" + "503 if SSH connection error occurred\n" + "601 if queue job failed" + ) + + def finish( + self, finish_date=None, status=None, response=None, error=None, **kwargs + ): + """Finish the command log + + Args: + finish_date (Datetime, optional): Command finish date. Defaults to None. + status (Integer, optional): Command status. Defaults to None. + response (Text, optional): Command response. Defaults to None. + error (Text, optional): Command error. Defaults to None. + """ + + # Filter out command logs that are already stopped + command_logs_to_process = self.filtered( + lambda log: log.command_status != COMMAND_STOPPED + ) + if not command_logs_to_process: + return + + # Lock and process each record individually + locked_logs = self.browse() + for command_log in command_logs_to_process: + try: + with self.env.cr.savepoint(), tools.mute_logger("odoo.sql_db"): + self.env.cr.execute( + f"SELECT command_status FROM {self._table} WHERE id = %s FOR UPDATE NOWAIT", # noqa: E501 + (command_log.id,), + ) + locked_logs |= command_log + except LockNotAvailable as e: + _logger.warning( + "Could not acquire lock on command log %s, skipping: %s", + command_log.id, + e, + ) + continue + + if not locked_logs: + return + + # Update the related queue job state if the command timed out + if status == COMMAND_TIMED_OUT: + for command_log in locked_logs: + if command_log.queue_job_id: + command_log.queue_job_id.sudo()._change_job_state( + CANCELLED, result=error + ) + + return super(CxTowerCommandLog, locked_logs).finish( + finish_date, status, response, error, **kwargs + ) diff --git a/addons/cetmix_tower_server_queue/models/cx_tower_file.py b/addons/cetmix_tower_server_queue/models/cx_tower_file.py new file mode 100644 index 0000000..327ffa1 --- /dev/null +++ b/addons/cetmix_tower_server_queue/models/cx_tower_file.py @@ -0,0 +1,184 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class CxTowerFile(models.Model): + _inherit = "cx.tower.file" + + is_being_processed = fields.Boolean( + copy=False, + help="File is currently being processed", + ) + + def _check_files_being_processed(self, raise_error): + """ + Check if any file in the recordset is being processed. + True if at least one file is already processing and raise_error is False. + False if no files are currently being processed. + The caller uses the boolean to decide whether to continue or abort. + """ + processing_files = self.filtered(lambda rec: rec.is_being_processed) + if processing_files: + if raise_error: + raise UserError( + _( + "The following files are already being processed: %(name)s", + name=", ".join(processing_files.mapped("name")), + ) + ) + else: + return True + return False + + def upload(self, raise_error=False): + """ + Trigger asynchronous upload via job queue. + """ + # Check if the file is already being processed + if self._check_files_being_processed(raise_error): + return + + self.write({"server_response": False, "is_being_processed": True}) + + # Enqueue the upload if not already in a queue job; + # otherwise, execute immediately + if not self.env.context.get("job_uuid"): + self.with_delay()._do_upload(raise_error=raise_error) + else: + self._do_upload(raise_error=raise_error) + + def download(self, raise_error=False): + """ + Trigger asynchronous download via job queue. + """ + + # Check if the file is already being processed + if self._check_files_being_processed(raise_error): + return + + self.write({"server_response": False, "is_being_processed": True}) + + # Enqueue the download if not already in a queue job; + # otherwise, execute immediately + if not self.env.context.get("job_uuid"): + self.with_delay()._do_download(raise_error=raise_error) + else: + self._do_download(raise_error=raise_error) + + def _do_upload(self, raise_error=True): + """ + Uploads the files within a job context and notifies the user on success. + Logs the error if an exception occurs; + failure state is managed by the parent method. + """ + try: + with self.env.cr.savepoint(): + result = super().upload(raise_error=raise_error) + single_msg = _("File uploaded!") + plural_msg = _("Files uploaded!") + self.env.user.notify_success( + message=single_msg if len(self) == 1 else plural_msg, + title=_("Success"), + # This notification should not be sticky + # to avoid blocking the user's screen + sticky=False, + ) + return result + except Exception as e: + if not raise_error: + self.env.user.notify_danger( + message=_( + "File(s) %(name)s upload failed: %(error)s", + name=", ".join(self.mapped("name")), + error=str(e), + ), + title=_("Failure"), + sticky=self.env["ir.config_parameter"] + .sudo() + .get_param("cetmix_tower_server.notification_type_error", "sticky") + == "sticky", + ) + _logger.error("File %s upload failed: %s", str(self), str(e)) + else: + raise + finally: + self.write({"is_being_processed": False}) + + def _do_download(self, raise_error=True): + """ + Downloads the files within a job context and notifies the user on success. + Logs the error if an exception occurs; + failure state is managed by the parent method. + """ + try: + with self.env.cr.savepoint(): + result = super().download(raise_error=raise_error) + single_msg = _("File downloaded!") + plural_msg = _("Files downloaded!") + self.env.user.notify_success( + message=single_msg if len(self) == 1 else plural_msg, + title=_("Success"), + # This notification should not be sticky + # to avoid blocking the user's screen + sticky=False, + ) + return result + except Exception as e: + if not raise_error: + self.env.user.notify_danger( + message=_( + "File(s) %(name)s download failed: %(error)s", + name=", ".join(self.mapped("name")), + error=str(e), + ), + title=_("Failure"), + sticky=self.env["ir.config_parameter"] + .sudo() + .get_param("cetmix_tower_server.notification_type_error", "sticky") + == "sticky", + ) + _logger.error("File %s download failed: %s", str(self), str(e)) + else: + raise + finally: + self.write({"is_being_processed": False}) + + def action_pull_from_server(self): + """ + Pull file from server without notification. + """ + tower_files = self.filtered(lambda file_: file_.source == "tower") + server_files = self - tower_files + + tower_files.action_get_current_server_code() + + server_files.download(raise_error=False) + + def action_push_to_server(self): + """ + Push the file to server without success notification. + """ + server_files = self.filtered(lambda file_: file_.source == "server") + if server_files: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Failure"), + "message": _( + "Unable to upload file '%(f)s'.\n" + "Upload operation is not supported for 'server' type files.", + f=", ".join(server_files.mapped("rendered_name")), + ), + "sticky": False, + }, + } + + self.upload(raise_error=False) diff --git a/addons/cetmix_tower_server_queue/models/cx_tower_server.py b/addons/cetmix_tower_server_queue/models/cx_tower_server.py new file mode 100644 index 0000000..a9a4b5a --- /dev/null +++ b/addons/cetmix_tower_server_queue/models/cx_tower_server.py @@ -0,0 +1,86 @@ +# Copyright (C) 2022 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import models + + +class CxTowerServer(models.Model): + _inherit = "cx.tower.server" + + def _command_runner_wrapper( + self, + command, + log_record, + rendered_command_code, + sudo=None, + rendered_command_path=None, + ssh_connection=None, + **kwargs, + ): + # If the flight plan log has an entry on the parent flight plan log, + # it means that this flight plan was launched from another plan, + # this plan should be launched as a synchronous command to + # preserve the order of execution of commands with actions + # "Run Flight Plan", "Trigger Jet Action" and "Create Waypoint". + # Use runner only if command log record is provided. + if ( + log_record + and not log_record.plan_log_id.parent_flight_plan_log_id + and command.action + not in [ + "jet_action", + "create_waypoint", + ] + ): + job = self.with_delay()._queue_command_runner_wrapper( + command=command, + log_record=log_record, + rendered_command_code=rendered_command_code, + sudo=sudo, + rendered_command_path=rendered_command_path, + ssh_connection=None, # Always None for queued jobs + **kwargs, + ) + log_record.sudo().queue_job_id = job.db_record().id + + # Otherwise fallback to `super` to return the command output + else: + return super()._command_runner_wrapper( + command=command, + log_record=log_record, + rendered_command_code=rendered_command_code, + sudo=sudo, + rendered_command_path=rendered_command_path, + ssh_connection=ssh_connection, + **kwargs, + ) + + def _queue_command_runner_wrapper( + self, + command, + log_record, + rendered_command_code, + sudo=None, + rendered_command_path=None, + ssh_connection=None, + **kwargs, + ): + # avoid executing command if plan was stopped + log_record.invalidate_recordset(["plan_log_id"]) + plan_log_id = log_record.plan_log_id + if plan_log_id: + plan_log_id.invalidate_recordset(["is_stopped"]) + + # If plan was stopped, stop the command + if plan_log_id.is_stopped: + log_record.stop() + return + + return self._command_runner( + command=command, + log_record=log_record, + rendered_command_code=rendered_command_code, + sudo=sudo, + rendered_command_path=rendered_command_path, + ssh_connection=ssh_connection, + **kwargs, + ) diff --git a/addons/cetmix_tower_server_queue/models/queue_job.py b/addons/cetmix_tower_server_queue/models/queue_job.py new file mode 100644 index 0000000..7b66eea --- /dev/null +++ b/addons/cetmix_tower_server_queue/models/queue_job.py @@ -0,0 +1,23 @@ +# Copyright 2013-2020 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) +from odoo import models + + +class QueueJob(models.Model): + _inherit = "queue.job" + + QUEUE_JOB_ERROR = 601 + + def write(self, vals): + """ + Override write method to update command status + and write error information in the log record + """ + if vals.get("state") == "failed": + log_record = self.kwargs.get("log_record") + if log_record: + log_record.finish( + status=self.QUEUE_JOB_ERROR, + error=vals.get("exc_info"), + ) + return super().write(vals) diff --git a/addons/cetmix_tower_server_queue/pyproject.toml b/addons/cetmix_tower_server_queue/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/addons/cetmix_tower_server_queue/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/addons/cetmix_tower_server_queue/readme/CONFIGURE.md b/addons/cetmix_tower_server_queue/readme/CONFIGURE.md new file mode 100644 index 0000000..8c717e5 --- /dev/null +++ b/addons/cetmix_tower_server_queue/readme/CONFIGURE.md @@ -0,0 +1 @@ +Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions. diff --git a/addons/cetmix_tower_server_queue/readme/DESCRIPTION.md b/addons/cetmix_tower_server_queue/readme/DESCRIPTION.md new file mode 100644 index 0000000..54e6fc3 --- /dev/null +++ b/addons/cetmix_tower_server_queue/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This module implements asynchronous task execution for [Cetmix Tower](https://cetmix.com/tower). + +It requires the [queue_job](https://github.com/OCA/queue/queue_job) module to be installed and configured in the Odoo instance. + +Please refer to the [official documentation](https://cetmix.com/tower) for detailed information. diff --git a/addons/cetmix_tower_server_queue/readme/HISTORY.md b/addons/cetmix_tower_server_queue/readme/HISTORY.md new file mode 100644 index 0000000..b0cfcea --- /dev/null +++ b/addons/cetmix_tower_server_queue/readme/HISTORY.md @@ -0,0 +1,3 @@ +## 18.0.2.0.0 (2026-04-07) + +- Features: Jets! (4700) diff --git a/addons/cetmix_tower_server_queue/readme/USAGE.md b/addons/cetmix_tower_server_queue/readme/USAGE.md new file mode 100644 index 0000000..901f5a6 --- /dev/null +++ b/addons/cetmix_tower_server_queue/readme/USAGE.md @@ -0,0 +1 @@ +Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions. diff --git a/addons/cetmix_tower_server_queue/readme/newsfragments/.gitkeep b/addons/cetmix_tower_server_queue/readme/newsfragments/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/addons/cetmix_tower_server_queue/static/description/icon.png b/addons/cetmix_tower_server_queue/static/description/icon.png new file mode 100644 index 0000000..2507f55 Binary files /dev/null and b/addons/cetmix_tower_server_queue/static/description/icon.png differ diff --git a/addons/cetmix_tower_server_queue/static/description/index.html b/addons/cetmix_tower_server_queue/static/description/index.html new file mode 100644 index 0000000..4893443 --- /dev/null +++ b/addons/cetmix_tower_server_queue/static/description/index.html @@ -0,0 +1,441 @@ + + + + + +Cetmix Tower Server Queue + + + +
+

Cetmix Tower Server Queue

+ + +

Beta License: AGPL-3 cetmix/cetmix-tower

+

This module implements asynchronous task execution for Cetmix +Tower.

+

It requires the queue_job +module to be installed and configured in the Odoo instance.

+

Please refer to the official +documentation for detailed information.

+

Table of contents

+ +
+

Configuration

+

Please refer to the official +documentation for detailed configuration +instructions.

+
+
+

Usage

+

Please refer to the official +documentation for detailed usage +instructions.

+
+
+

Changelog

+
+

18.0.2.0.0 (2026-04-07)

+
    +
  • Features: Jets! (4700)
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Cetmix
  • +
+
+
+

Maintainers

+

This module is part of the cetmix/cetmix-tower project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/addons/cetmix_tower_server_queue/tests/__init__.py b/addons/cetmix_tower_server_queue/tests/__init__.py new file mode 100644 index 0000000..306c04b --- /dev/null +++ b/addons/cetmix_tower_server_queue/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_command +from . import test_command_log +from . import test_file diff --git a/addons/cetmix_tower_server_queue/tests/test_command.py b/addons/cetmix_tower_server_queue/tests/test_command.py new file mode 100644 index 0000000..2f043d9 --- /dev/null +++ b/addons/cetmix_tower_server_queue/tests/test_command.py @@ -0,0 +1,145 @@ +from datetime import timedelta +from unittest.mock import patch + +from odoo.fields import Datetime +from odoo.tools import mute_logger + +from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon + + +class TestTowerCommand(TestTowerCommon): + """Test suite for verifying zombie command detection and related + queue job cancellation. + + Tests in this class verify that commands which have been running + longer than the timeout are properly detected as zombies, and their + associated queue jobs are cancelled. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Set command timeout to 10 seconds + cls.env["ir.config_parameter"].sudo().set_param( + "cetmix_tower_server.command_timeout", "10" + ) + # Set old time to 20 seconds ago (older than timeout) + # to simulate running command in past + now = Datetime.now() + cls.old_time = now - timedelta(seconds=20) + + def _patch_command_runner(self, command_type, runner_method): + """Helper to patch a command runner to simulate a zombie command. + + Args: + command_type: Type of command runner to patch ('ssh' or 'python_code') + runner_method: Original method to wrap + + Returns: + A context manager that applies the patch + """ + + def _wrapper(*args, **kwargs): + # Modify args to disable log record finishing + args = list(args) + if len(args) > 1: + args[1] = False # Set log_record to False + return runner_method(*args, **kwargs) + + return patch.object( + self.registry["cx.tower.server"], + f"_command_runner_{command_type}", + _wrapper, + ) + + def _verify_zombie_command_job_cancellation(self, command_action): + """Verify zombie command is detected and job is cancelled. + + Args: + command_action: Action type ('ssh_command' or 'python_code') + """ + # check zombie command logs + domain = [ + ("is_running", "=", True), + ("start_date", "=", self.old_time), + ("command_action", "=", command_action), + ] + zombie_command_logs = self.env["cx.tower.command.log"].search(domain) + + self.assertEqual( + len(zombie_command_logs), 1, "Zombie command log should be created" + ) + self.assertTrue( + zombie_command_logs.queue_job_id, + "Zombie command log should have queue job", + ) + + job = zombie_command_logs.queue_job_id + self.assertTrue(job.exists(), "Zombie command job should exist") + + self.assertEqual(job.state, "pending", "Zombie command job should be pending") + + # run process to kill zombie command + self.server_test_1._check_zombie_commands() + + # check that command log is cancelled + self.assertEqual( + job.state, "cancelled", "Zombie command job should be cancelled" + ) + + def test_check_zombie_ssh_command_queue(self): + """ + Test that zombie ssh command is killed and job is cancelled + """ + # Create test commands + ssh_command = self.Command.create( + { + "name": "Test SSH Command", + "code": "ls -la", + "action": "ssh_command", + } + ) + + # patch command runner to not finish log record + cx_tower_server_obj = self.registry["cx.tower.server"] + _command_runner_ssh_super = cx_tower_server_obj._command_runner_ssh + + with self._patch_command_runner("ssh", _command_runner_ssh_super): + # run zombie command with log creation in past + self.server_test_1.run_command( + ssh_command, log={"start_date": self.old_time} + ) + + # check zombie command logs + self._verify_zombie_command_job_cancellation("ssh_command") + + @mute_logger("py.warnings") + def test_check_zombie_python_command_queue(self): + """ + Test that zombie python command is killed and job is cancelled + """ + # Create test commands + python_command = self.Command.create( + { + "name": "Test Python Command", + "code": "print('test')", + "action": "python_code", + } + ) + + # patch command runner to not finish log record + cx_tower_server_obj = self.registry["cx.tower.server"] + _command_runner_python_code_super = ( + cx_tower_server_obj._command_runner_python_code + ) + + with self._patch_command_runner( + "python_code", _command_runner_python_code_super + ): + # run zombie command with log creation in past + self.server_test_1.run_command( + python_command, log={"start_date": self.old_time} + ) + + # check zombie command logs + self._verify_zombie_command_job_cancellation("python_code") diff --git a/addons/cetmix_tower_server_queue/tests/test_command_log.py b/addons/cetmix_tower_server_queue/tests/test_command_log.py new file mode 100644 index 0000000..5bef9eb --- /dev/null +++ b/addons/cetmix_tower_server_queue/tests/test_command_log.py @@ -0,0 +1,37 @@ +from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon +from odoo.addons.queue_job.job import Job + + +class TestTowerCommand(TestTowerCommon): + """ + Test cases for command log state on queue_job failure + """ + + def test_command_log_state_on_job_fail(self): + command = self.env["cx.tower.command"].create( + { + "name": "Test Command", + "action": "ssh_command", + "code": "echo 'Hello World'", + } + ) + self.assertTrue(command.id, "Command should be created successfully") + + self.server_test_1.run_command(command=command) + command_log = self.env["cx.tower.command.log"].search( + [("command_id", "=", command.id)], order="id desc", limit=1 + ) + self.assertTrue(command_log, "Command log should be created") + + job = command_log.queue_job_id + self.assertTrue(job, "Queue job should be associated with command log") + + job_obj = Job.load(self.env, job.uuid) + job_obj.set_failed() + job_obj.store() + self.assertEqual(job.state, "failed", "Job should be in failed state") + self.assertEqual( + command_log.command_status, + self.env["queue.job"].QUEUE_JOB_ERROR, + "Command log should be in failed state", + ) diff --git a/addons/cetmix_tower_server_queue/tests/test_file.py b/addons/cetmix_tower_server_queue/tests/test_file.py new file mode 100644 index 0000000..c04a229 --- /dev/null +++ b/addons/cetmix_tower_server_queue/tests/test_file.py @@ -0,0 +1,201 @@ +from odoo import exceptions + +from odoo.addons.cetmix_tower_server.tests.common import TestTowerCommon +from odoo.addons.queue_job.tests.common import trap_jobs + + +class TestCxTowerFileQueue(TestTowerCommon): + def setUp(self): + super().setUp() + self.file_template = self.FileTemplate.create( + { + "name": "Test", + "file_name": "test.txt", + "server_dir": "/var/tmp", + "code": "Hello, world!", + } + ) + + def test_async_upload_operations(self): + """Test that upload operations are processed asynchronously""" + # Create unique files specifically for this test + upload_file = self.File.create( + { + "source": "tower", + "template_id": self.file_template.id, + "server_id": self.server_test_1.id, + "name": "upload_test_1", + "auto_sync": False, + } + ) + + upload_file_2 = self.File.create( + { + "name": "upload_test_2", + "source": "server", + "server_id": self.server_test_1.id, + "server_dir": "/var/tmp", + "auto_sync": False, + } + ) + + with trap_jobs() as trap: + upload_file.upload() + upload_file_2.upload() + + self.assertEqual(len(trap.enqueued_jobs), 2) + + upload_file.write({"server_response": "ok", "is_being_processed": False}) + upload_file_2.write({"server_response": "ok", "is_being_processed": False}) + + # Refresh records to get updated values + upload_file.invalidate_recordset() + upload_file_2.invalidate_recordset() + + # Verify the expected state + self.assertEqual(upload_file.server_response, "ok") + self.assertFalse(upload_file.is_being_processed) + + self.assertEqual(upload_file_2.server_response, "ok") + self.assertFalse(upload_file_2.is_being_processed) + + def test_async_download_operations(self): + """Test that download operations are processed asynchronously""" + # Create unique files specifically for this test + download_file = self.File.create( + { + "source": "tower", + "template_id": self.file_template.id, + "server_id": self.server_test_1.id, + "name": "download_test_1", + "auto_sync": False, + } + ) + + download_file_2 = self.File.create( + { + "name": "download_test_2", + "source": "server", + "server_id": self.server_test_1.id, + "server_dir": "/var/tmp", + "auto_sync": False, + } + ) + + with trap_jobs() as trap: + download_file.download() + download_file_2.download() + + # Verify jobs were created + self.assertEqual(len(trap.enqueued_jobs), 2) + + download_file.write({"server_response": "ok", "is_being_processed": False}) + download_file_2.write( + {"server_response": "ok", "is_being_processed": False} + ) + + # Refresh records to get updated values + download_file.invalidate_recordset() + download_file_2.invalidate_recordset() + + # Verify the expected state + self.assertEqual(download_file.server_response, "ok") + self.assertFalse(download_file.is_being_processed) + + self.assertEqual(download_file_2.server_response, "ok") + self.assertFalse(download_file_2.is_being_processed) + + def test_upload_error_handling(self): + """Test error handling in async upload operations""" + error_file = self.File.create( + { + "source": "tower", + "template_id": self.file_template.id, + "server_id": self.server_test_1.id, + "name": "error_handling_test", + "auto_sync": False, + } + ) + + # Set context to force the mock in ssh_upload_file to raise error + error_context = {"raise_upload_error": "Forced upload error"} + + with trap_jobs() as trap: + # This will trigger job creation but the job would fail if executed + error_file.with_context(**error_context).upload(raise_error=True) + + # Verify job was created + self.assertEqual(len(trap.enqueued_jobs), 1) + + # Simulate what would happen if the job executed and failed + error_file.write({"server_response": "error", "is_being_processed": False}) + error_file.invalidate_recordset() + + self.assertEqual(error_file.server_response, "error") + self.assertFalse(error_file.is_being_processed) + + def test_download_error_handling(self): + """Test error handling in async download operations""" + error_file = self.File.create( + { + "source": "server", + "server_id": self.server_test_1.id, + "server_dir": "/var/tmp", + "name": "download_error_test", + } + ) + + # Set context to force the mock in ssh_download_file to raise error + error_context = {"raise_download_error": "Forced download error"} + + with trap_jobs() as trap: + # This will trigger job creation but the job would fail if executed + error_file.with_context(**error_context).download(raise_error=True) + + # Verify job was created + self.assertEqual(len(trap.enqueued_jobs), 1) + + # Simulate what would happen if the job executed and failed + error_file.write({"server_response": "error", "is_being_processed": False}) + error_file.invalidate_recordset() + + self.assertEqual(error_file.server_response, "error") + self.assertFalse(error_file.is_being_processed) + + def test_already_processing_check(self): + """Test that files being processed cannot be processed again""" + processing_file = self.File.create( + { + "source": "tower", + "template_id": self.file_template.id, + "server_id": self.server_test_1.id, + "name": "processing_test_file", + "is_being_processed": True, + } + ) + + self.assertTrue(processing_file.is_being_processed) + + # Test with raising error + with self.assertRaises(exceptions.UserError): + processing_file.upload(raise_error=True) + + # Test without raising error - should not create job + with trap_jobs() as trap: + processing_file.upload(raise_error=False) + # No job should be created since file is already being processed + self.assertEqual(len(trap.enqueued_jobs), 0) + + # Verify still marked as processing + self.assertTrue(processing_file.is_being_processed) + + # Same tests for download + with self.assertRaises(exceptions.UserError): + processing_file.download(raise_error=True) + + with trap_jobs() as trap: + processing_file.download(raise_error=False) + # No job should be created + self.assertEqual(len(trap.enqueued_jobs), 0) + + self.assertTrue(processing_file.is_being_processed) diff --git a/addons/cetmix_tower_server_queue/views/cx_tower_command_log_view.xml b/addons/cetmix_tower_server_queue/views/cx_tower_command_log_view.xml new file mode 100644 index 0000000..0840695 --- /dev/null +++ b/addons/cetmix_tower_server_queue/views/cx_tower_command_log_view.xml @@ -0,0 +1,16 @@ + + + + cx.tower.command.log.view.form + cx.tower.command.log + + + + + + + + diff --git a/addons/cetmix_tower_server_queue/views/cx_tower_file_view.xml b/addons/cetmix_tower_server_queue/views/cx_tower_file_view.xml new file mode 100644 index 0000000..296093f --- /dev/null +++ b/addons/cetmix_tower_server_queue/views/cx_tower_file_view.xml @@ -0,0 +1,55 @@ + + + + cx.tower.file.view.form + cx.tower.file + + + + + + + + + + + + + + cx.tower.queue.file.view.list + cx.tower.file + + + + + + + + + is_being_processed + + + not is_being_processed and server_response == 'ok' + + + not is_being_processed and server_response not in ('ok', False) + + + + +