8 Commits

26 changed files with 21 additions and 1821 deletions

View File

@@ -1,122 +0,0 @@
=========================
Cetmix Tower Server Queue
=========================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:bcdbf27340bb59ec9a0cf443b108e2d6b27cf7c64466b47585fbd02410ef071b
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |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/16.0/cetmix_tower_server_queue
:alt: cetmix/cetmix-tower
|badge1| |badge2| |badge3|
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.
**Table of contents**
.. contents::
:local:
Configuration
=============
Please refer to the `official
documentation <https://cetmix.com/tower>`__ for detailed configuration
instructions.
Usage
=====
Please refer to the `official
documentation <https://cetmix.com/tower>`__ for detailed usage
instructions.
Changelog
=========
16.0.2.0.0 (2026-03-23)
-----------------------
- Features: Jets! (4700)
16.0.1.2.0 (2025-11-12)
-----------------------
- Features: Use the 'web_notify' module to send user notifications.
(5074)
16.0.1.1.4 (2025-11-05)
-----------------------
- Bugfixes: Finish multiple commands at once. (5062)
16.0.1.1.3 (2025-10-13)
-----------------------
- Features: Terminate running flight plan manually (3410)
16.0.1.1.0 (2025-07-16)
-----------------------
- Features: cetmix_tower_server_queue: Add async file upload/download
via job queue (3720)
- Features: Terminate command with error if job has failed (4718)
16.0.1.0.2 (2025-05-16)
-----------------------
- Features: 'sudo' parameter is not passed to command. (4678)
16.0.1.0.1 (2025-05-09)
-----------------------
- Bugfixes: Non-critical issues and performance improvements. (4611)
16.0.1.0.0
----------
Release for Odoo 16.0
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/cetmix/cetmix-tower/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 <https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cetmix_tower_server_queue%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
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 <https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server_queue>`_ project on GitHub.
You are welcome to contribute.

View File

@@ -1 +0,0 @@
from . import models

View File

@@ -1,19 +0,0 @@
# 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": "16.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",
],
}

View File

@@ -1,150 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * cetmix_tower_server_queue
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.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
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
msgid "Failure"
msgstr ""
#. 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 ""
#. 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
#, python-format
msgid "File uploaded!"
msgstr ""
#. module: cetmix_tower_server_queue
#. odoo-python
#: code:addons/cetmix_tower_server_queue/models/cx_tower_file.py:0
#, python-format
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
#, python-format
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
#, python-format
msgid "Files downloaded!"
msgstr ""
#. 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 ""
#. 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
#: 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 ""
#. 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 ""
#. 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 ""

View File

@@ -1,148 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * cetmix_tower_server_queue
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.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.-100 errore generale,-101 non trovato,-201 un'altra istanza di questo comando è in esecuzione,-202 nessun runner trovato per l'azione del comando,-203 esecuzione del codice Python non riuscita,-205 controllo delle condizioni della riga del piano non riuscito,503 se si è verificato un errore di connessione SSH,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 comando 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 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 "Fallimento"
#. 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 è in lavorazione"
#. 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 lavorazione"
#. module: cetmix_tower_server_queue
#: model_terms:ir.ui.view,arch_db:cetmix_tower_server_queue.cx_tower_file_view_form
msgid "Processing"
msgstr "Lavorazione"
#. 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 "Accoda lavoro"
#. 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 fase di 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"

View File

@@ -1,4 +0,0 @@
from . import cx_tower_command_log
from . import cx_tower_server
from . import queue_job
from . import cx_tower_file

View File

@@ -1,82 +0,0 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
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
# Avoid finishing the command log multiple times at the same time
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 IN %s FOR UPDATE NOWAIT", # noqa: E501
(tuple(command_logs_to_process.ids),),
)
except Exception as e:
_logger.error(
"Could not acquire lock on command logs %s, skipping finish: %s",
command_logs_to_process.ids,
e,
)
return
# Update the related queue job state if the command timed out
if status == COMMAND_TIMED_OUT:
for command_log in command_logs_to_process:
if command_log.queue_job_id:
command_log.queue_job_id.sudo()._change_job_state(
CANCELLED, result=error
)
return super(CxTowerCommandLog, command_logs_to_process).finish(
finish_date, status, response, error, **kwargs
)

View File

@@ -1,184 +0,0 @@
# 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)

View File

@@ -1,86 +0,0 @@
# 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=ssh_connection,
**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,
)

View File

@@ -1,23 +0,0 @@
# 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)

View File

@@ -1,3 +0,0 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"

View File

@@ -1 +0,0 @@
Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions.

View File

@@ -1,5 +0,0 @@
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.

View File

@@ -1,39 +0,0 @@
## 16.0.2.0.0 (2026-03-23)
- Features: Jets! (4700)
## 16.0.1.2.0 (2025-11-12)
- Features: Use the 'web_notify' module to send user notifications. (5074)
## 16.0.1.1.4 (2025-11-05)
- Bugfixes: Finish multiple commands at once. (5062)
## 16.0.1.1.3 (2025-10-13)
- Features: Terminate running flight plan manually (3410)
## 16.0.1.1.0 (2025-07-16)
- Features: cetmix_tower_server_queue: Add async file upload/download via job queue (3720)
- Features: Terminate command with error if job has failed (4718)
## 16.0.1.0.2 (2025-05-16)
- Features: 'sudo' parameter is not passed to command. (4678)
## 16.0.1.0.1 (2025-05-09)
- Bugfixes: Non-critical issues and performance improvements. (4611)
## 16.0.1.0.0
Release for Odoo 16.0

View File

@@ -1 +0,0 @@
Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,491 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Cetmix Tower Server Queue</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="cetmix-tower-server-queue">
<h1 class="title">Cetmix Tower Server Queue</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:bcdbf27340bb59ec9a0cf443b108e2d6b27cf7c64466b47585fbd02410ef071b
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server_queue"><img alt="cetmix/cetmix-tower" src="https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github" /></a></p>
<p>This module implements asynchronous task execution for <a class="reference external" href="https://cetmix.com/tower">Cetmix
Tower</a>.</p>
<p>It requires the <a class="reference external" href="https://github.com/OCA/queue/queue_job">queue_job</a>
module to be installed and configured in the Odoo instance.</p>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed information.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a></li>
<li><a class="reference internal" href="#usage" id="toc-entry-2">Usage</a></li>
<li><a class="reference internal" href="#changelog" id="toc-entry-3">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-4">16.0.2.0.0 (2026-03-23)</a></li>
<li><a class="reference internal" href="#section-2" id="toc-entry-5">16.0.1.2.0 (2025-11-12)</a></li>
<li><a class="reference internal" href="#section-3" id="toc-entry-6">16.0.1.1.4 (2025-11-05)</a></li>
<li><a class="reference internal" href="#section-4" id="toc-entry-7">16.0.1.1.3 (2025-10-13)</a></li>
<li><a class="reference internal" href="#section-5" id="toc-entry-8">16.0.1.1.0 (2025-07-16)</a></li>
<li><a class="reference internal" href="#section-6" id="toc-entry-9">16.0.1.0.2 (2025-05-16)</a></li>
<li><a class="reference internal" href="#section-7" id="toc-entry-10">16.0.1.0.1 (2025-05-09)</a></li>
<li><a class="reference internal" href="#section-8" id="toc-entry-11">16.0.1.0.0</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-12">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-13">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-14">Authors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-15">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#toc-entry-1">Configuration</a></h1>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed configuration
instructions.</p>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-2">Usage</a></h1>
<p>Please refer to the <a class="reference external" href="https://cetmix.com/tower">official
documentation</a> for detailed usage
instructions.</p>
</div>
<div class="section" id="changelog">
<h1><a class="toc-backref" href="#toc-entry-3">Changelog</a></h1>
<div class="section" id="section-1">
<h2><a class="toc-backref" href="#toc-entry-4">16.0.2.0.0 (2026-03-23)</a></h2>
<ul class="simple">
<li>Features: Jets! (4700)</li>
</ul>
</div>
<div class="section" id="section-2">
<h2><a class="toc-backref" href="#toc-entry-5">16.0.1.2.0 (2025-11-12)</a></h2>
<ul class="simple">
<li>Features: Use the web_notify module to send user notifications.
(5074)</li>
</ul>
</div>
<div class="section" id="section-3">
<h2><a class="toc-backref" href="#toc-entry-6">16.0.1.1.4 (2025-11-05)</a></h2>
<ul class="simple">
<li>Bugfixes: Finish multiple commands at once. (5062)</li>
</ul>
</div>
<div class="section" id="section-4">
<h2><a class="toc-backref" href="#toc-entry-7">16.0.1.1.3 (2025-10-13)</a></h2>
<ul class="simple">
<li>Features: Terminate running flight plan manually (3410)</li>
</ul>
</div>
<div class="section" id="section-5">
<h2><a class="toc-backref" href="#toc-entry-8">16.0.1.1.0 (2025-07-16)</a></h2>
<ul class="simple">
<li>Features: cetmix_tower_server_queue: Add async file upload/download
via job queue (3720)</li>
<li>Features: Terminate command with error if job has failed (4718)</li>
</ul>
</div>
<div class="section" id="section-6">
<h2><a class="toc-backref" href="#toc-entry-9">16.0.1.0.2 (2025-05-16)</a></h2>
<ul class="simple">
<li>Features: sudo parameter is not passed to command. (4678)</li>
</ul>
</div>
<div class="section" id="section-7">
<h2><a class="toc-backref" href="#toc-entry-10">16.0.1.0.1 (2025-05-09)</a></h2>
<ul class="simple">
<li>Bugfixes: Non-critical issues and performance improvements. (4611)</li>
</ul>
</div>
<div class="section" id="section-8">
<h2><a class="toc-backref" href="#toc-entry-11">16.0.1.0.0</a></h2>
<p>Release for Odoo 16.0</p>
</div>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-12">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/cetmix/cetmix-tower/issues/new?body=module:%20cetmix_tower_server_queue%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-13">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-14">Authors</a></h2>
<ul class="simple">
<li>Cetmix</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-15">Maintainers</a></h2>
<p>This module is part of the <a class="reference external" href="https://github.com/cetmix/cetmix-tower/tree/16.0/cetmix_tower_server_queue">cetmix/cetmix-tower</a> project on GitHub.</p>
<p>You are welcome to contribute.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,3 +0,0 @@
from . import test_command
from . import test_command_log
from . import test_file

View File

@@ -1,145 +0,0 @@
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")

View File

@@ -1,37 +0,0 @@
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",
)

View File

@@ -1,201 +0,0 @@
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)

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_command_log_view_form" model="ir.ui.view">
<field name="name">cx.tower.command.log.view.form</field>
<field name="model">cx.tower.command.log</field>
<field
name="inherit_id"
ref="cetmix_tower_server.cx_tower_command_log_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='command_id']" position="after">
<field
name="queue_job_id"
attrs="{'invisible': [('queue_job_id', '=', False)]}"
/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,56 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_file_view_form" model="ir.ui.view">
<field name="name">cx.tower.file.view.form</field>
<field name="model">cx.tower.file</field>
<field name="inherit_id" ref="cetmix_tower_server.cx_tower_file_view_form" />
<field name="arch" type="xml">
<xpath expr="//form/sheet/group" position="before">
<field name="is_being_processed" invisible="1" />
<field name="server_response" invisible="1" />
<widget
name="web_ribbon"
title="Processing"
bg_color="bg-info"
attrs="{'invisible': [('is_being_processed', '=', False)]}"
/>
<widget
name="web_ribbon"
title="Success"
bg_color="bg-success"
attrs="{'invisible': ['|', ('is_being_processed', '=', True), ('server_response', '!=', 'ok')]}"
/>
<widget
name="web_ribbon"
title="Error"
bg_color="bg-danger"
attrs="{'invisible': ['|', ('is_being_processed', '=', True), ('server_response', 'in', ('ok', False))]}"
/>
</xpath>
</field>
</record>
<record id="cx_tower_queue_file_view_tree" model="ir.ui.view">
<field name="name">cx.tower.queue.file.view.tree</field>
<field name="model">cx.tower.file</field>
<field name="inherit_id" ref="cetmix_tower_server.cx_tower_file_view_tree" />
<field name="arch" type="xml">
<xpath expr="//tree" position="inside">
<field name="is_being_processed" invisible="1" />
<field name="server_response" invisible="1" />
</xpath>
<xpath expr="//tree" position="attributes">
<attribute name="decoration-info">
is_being_processed == True
</attribute>
<attribute name="decoration-success">
is_being_processed != True and server_response == 'ok'
</attribute>
<attribute name="decoration-danger">
is_being_processed != True and server_response not in ('ok', False)
</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,20 @@
{
"name": "OdooSky Demo Addon",
"summary": "Smoke-test addon — proves the v3 in-cluster build pipeline works end-to-end.",
"description": """
A minimal Odoo module used by OdooSky platform tests to prove that a tagged
addon in odoo-tower/odoo-addons builds into an OCI image inside the customer
cluster and mounts into the Odoo pod. No business logic; just a manifest and
an empty __init__.py so Odoo recognizes it.
""",
"author": "OdooSky",
"website": "https://odoosky.org",
"category": "Tools",
"version": "18.0.1.0.0",
"license": "LGPL-3",
"depends": ["base"],
"data": [],
"installable": True,
"application": False,
"auto_install": False,
}

View File

@@ -0,0 +1 @@
<section><h1>OdooSky Demo Addon</h1><p>Platform smoke-test addon. Safe to install / uninstall.</p></section>