Compare commits
215 Commits
cetmix_tow
...
queue_job/
| Author | SHA1 | Date | |
|---|---|---|---|
| 96a2eeda3a | |||
| a6209db573 | |||
| bfc350252a | |||
| 64efc9b0b4 | |||
| 8d4ddfb7d2 | |||
| 447b8431e6 | |||
| 007783c1e2 | |||
| 72a4524aed | |||
| 7e37a29bee | |||
| 1f0cf23801 | |||
| 999a996df8 | |||
| 8966de83af | |||
| 403368df7a | |||
| fef59e7a73 | |||
| c2285f865e | |||
| 34d8248b79 | |||
| f64852997f | |||
| fcf45b130e | |||
| fd4665364d | |||
| 91a344cbc2 | |||
| 7b8f5090db | |||
| e2039f54f4 | |||
| 445b34f452 | |||
| c3a4151359 | |||
| c05ba71bcd | |||
| 389a32d760 | |||
| 609ef99c44 | |||
| 71e98f5b3f | |||
| 25052f2e2d | |||
| a5c0f76f89 | |||
| 81d2547e9d | |||
| a0c172c649 | |||
| 8a65785c52 | |||
| 85fff4657e | |||
| 114449be53 | |||
| df1dabb253 | |||
| 65094d2031 | |||
| 9d8a226283 | |||
| 7bff54cb58 | |||
| 4f9f60b121 | |||
| f0cee69a24 | |||
| 0d6e910d3e | |||
| 64f515e11b | |||
| ef22709eb7 | |||
| 65c6df9940 | |||
| cbc12f44b8 | |||
| 45eba87eda | |||
| 510be1ffcb | |||
| 9ceb54d29c | |||
| 942da80b9c | |||
| 3da4cc2dec | |||
| b4572fa6f1 | |||
| 01f5ee1c46 | |||
| 952b235888 | |||
| f98c11412d | |||
| a8e27776d3 | |||
| 6038b70592 | |||
| e259a897fe | |||
| 05027ef13c | |||
| d65b12bc80 | |||
| 905d4a6c04 | |||
| a213ef10a8 | |||
| f2b16e50a7 | |||
| 4d25cf4ade | |||
| 82b2acd792 | |||
| 7522999082 | |||
| f8e694b71a | |||
| 83cbdf54e9 | |||
| 7744f3212d | |||
| b55049d482 | |||
| 54f981fd25 | |||
| 7d753b772a | |||
| cd8e63eb08 | |||
| 29f5780312 | |||
| 6dd6679e9a | |||
| 26c795216e | |||
| 5b40d83c0c | |||
| 22279e8c98 | |||
| 09bc143899 | |||
| d29af3f5ad | |||
| 7441874199 | |||
| 068638b20a | |||
| 5c65820935 | |||
| 748b61b2f6 | |||
| 70d359dd8d | |||
| c4d093c497 | |||
| 39ccc6bde5 | |||
| 8df4722e8b | |||
| fe3a822173 | |||
| 7d9a1eefbb | |||
| c74f5414af | |||
| 98387bc517 | |||
| a6e739601e | |||
| e3b372f3d0 | |||
| 8f8e41943a | |||
| 7af8e80303 | |||
| 9f86d4807c | |||
| 1a082b425c | |||
| 48fcec14c5 | |||
| d54a6b9d08 | |||
| 26e1be3a4f | |||
| 10cd0f3bc1 | |||
| d2ec4529cc | |||
| a1bf9980cb | |||
| 42292618bb | |||
| 07d598c857 | |||
| 757ec36790 | |||
| 7441e29889 | |||
| c48a8ddc63 | |||
| c31ba607e5 | |||
| 97eafd2fcf | |||
| b3e06b7bbd | |||
| ddc65dc558 | |||
| 8dc88a671f | |||
| 928a2661bb | |||
| 7f9278fc8f | |||
| bc99107f8e | |||
| db6cbffd60 | |||
| 55df443de3 | |||
| e28e930732 | |||
| 2ffa038703 | |||
| 5c6a987442 | |||
| 5f26a8f675 | |||
| 0f25bd4d77 | |||
| 41a6368228 | |||
| 20ec0b6fd6 | |||
| 71655a3923 | |||
| 06103e090a | |||
| f78d7b8d35 | |||
| b87a626ee7 | |||
| 5c587f8e7d | |||
| 14645156c6 | |||
| 9af897fa59 | |||
| 6b447e3364 | |||
| 162e2aa3e8 | |||
| 68fa068d8b | |||
| d481df1702 | |||
| 2f6ce319ba | |||
| 8093696ec8 | |||
| 53d1657954 | |||
| 87eae8f9c1 | |||
| 1b5655d1aa | |||
| 01ec5954bb | |||
| 0d853abbc3 | |||
| fada6f30ff | |||
| c10bbc8f8a | |||
| 492d828ca3 | |||
| 343a0700b6 | |||
| c582038d23 | |||
| 4c70b26e1d | |||
| 56b120ae6f | |||
| 3ef03aea6e | |||
| 9897dcfa04 | |||
| 01b7ffd8d3 | |||
| 0ed1b40384 | |||
| 0a1b6e156a | |||
| f09ad65b7a | |||
| 92b30574c7 | |||
| f5eb897143 | |||
| 8ed74a3aed | |||
| 7158e9210f | |||
| 9444f8805a | |||
| 2095fde1f4 | |||
| 922c8a49d5 | |||
| 7acf00fc4d | |||
| 86b416cb47 | |||
| 09ed1d8731 | |||
| 022f0cb891 | |||
| 8e4a3d8d4a | |||
| 97f60c2aa5 | |||
| 7fb3d0a77d | |||
| 82d2d1eff6 | |||
| 1ed5e88c7c | |||
| a1f473b8a3 | |||
| 0ed968a17b | |||
| 1a3e7389fa | |||
| 8199d0022d | |||
| 8eb03de70b | |||
| 1a43c797c3 | |||
| 668ff3da60 | |||
| a3d8b01582 | |||
| 380afede5e | |||
| bf85022852 | |||
| 76f3b5cd0d | |||
| 818c86a758 | |||
| a718da84af | |||
| c7b7860fd6 | |||
| 31da31ec45 | |||
| 2fd5aa0787 | |||
| d47e45ae64 | |||
| 1fea3621f5 | |||
| 6855e3711a | |||
| 26f2040905 | |||
| a52b141017 | |||
| 5d988b1cb8 | |||
| 4fc18d865b | |||
| 262a6e4b84 | |||
| e450738fd7 | |||
| 83ec459ca5 | |||
| 4de853d788 | |||
| ad6cbac1f8 | |||
| 2fcd451339 | |||
| 762547c1f5 | |||
| 25cc185aee | |||
| 0fc6a1d6f3 | |||
| 76991aecae | |||
| f7c03a7122 | |||
| dc0fa2dff7 | |||
| 99043f1c52 | |||
| 73a89f15e6 | |||
| b4a3b13ee0 | |||
| 3d30491875 | |||
| dda64246c5 | |||
| a0d1d19687 | |||
| 66c81b2a91 |
117
addons/cetmix_tower_server_queue/README.rst
Normal file
117
addons/cetmix_tower_server_queue/README.rst
Normal file
@@ -0,0 +1,117 @@
|
||||
=========================
|
||||
Cetmix Tower Server Queue
|
||||
=========================
|
||||
|
||||
..
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:b40d3d39da3d8e2545c72b63aa3f14bdb1aaafbfbfbbb51e07ba599400427b8d
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |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.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.
|
||||
1
addons/cetmix_tower_server_queue/__init__.py
Normal file
1
addons/cetmix_tower_server_queue/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
19
addons/cetmix_tower_server_queue/__manifest__.py
Normal file
19
addons/cetmix_tower_server_queue/__manifest__.py
Normal file
@@ -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": "16.0.1.2.2",
|
||||
"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",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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 ""
|
||||
148
addons/cetmix_tower_server_queue/i18n/it.po
Normal file
148
addons/cetmix_tower_server_queue/i18n/it.po
Normal file
@@ -0,0 +1,148 @@
|
||||
# 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"
|
||||
4
addons/cetmix_tower_server_queue/models/__init__.py
Normal file
4
addons/cetmix_tower_server_queue/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import cx_tower_command_log
|
||||
from . import cx_tower_server
|
||||
from . import queue_job
|
||||
from . import cx_tower_file
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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
|
||||
)
|
||||
184
addons/cetmix_tower_server_queue/models/cx_tower_file.py
Normal file
184
addons/cetmix_tower_server_queue/models/cx_tower_file.py
Normal file
@@ -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)
|
||||
77
addons/cetmix_tower_server_queue/models/cx_tower_server.py
Normal file
77
addons/cetmix_tower_server_queue/models/cx_tower_server.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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 action “Run flight plan”.
|
||||
# Use runner only if command log record is provided.
|
||||
if log_record and not log_record.plan_log_id.parent_flight_plan_log_id:
|
||||
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,
|
||||
)
|
||||
23
addons/cetmix_tower_server_queue/models/queue_job.py
Normal file
23
addons/cetmix_tower_server_queue/models/queue_job.py
Normal file
@@ -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)
|
||||
3
addons/cetmix_tower_server_queue/pyproject.toml
Normal file
3
addons/cetmix_tower_server_queue/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
||||
1
addons/cetmix_tower_server_queue/readme/CONFIGURE.md
Normal file
1
addons/cetmix_tower_server_queue/readme/CONFIGURE.md
Normal file
@@ -0,0 +1 @@
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions.
|
||||
5
addons/cetmix_tower_server_queue/readme/DESCRIPTION.md
Normal file
5
addons/cetmix_tower_server_queue/readme/DESCRIPTION.md
Normal file
@@ -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.
|
||||
34
addons/cetmix_tower_server_queue/readme/HISTORY.md
Normal file
34
addons/cetmix_tower_server_queue/readme/HISTORY.md
Normal file
@@ -0,0 +1,34 @@
|
||||
## 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
|
||||
1
addons/cetmix_tower_server_queue/readme/USAGE.md
Normal file
1
addons/cetmix_tower_server_queue/readme/USAGE.md
Normal file
@@ -0,0 +1 @@
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions.
|
||||
BIN
addons/cetmix_tower_server_queue/static/description/icon.png
Normal file
BIN
addons/cetmix_tower_server_queue/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
484
addons/cetmix_tower_server_queue/static/description/index.html
Normal file
484
addons/cetmix_tower_server_queue/static/description/index.html
Normal file
@@ -0,0 +1,484 @@
|
||||
<!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:b40d3d39da3d8e2545c72b63aa3f14bdb1aaafbfbfbbb51e07ba599400427b8d
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<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.1.2.0 (2025-11-12)</a></li>
|
||||
<li><a class="reference internal" href="#section-2" id="toc-entry-5">16.0.1.1.4 (2025-11-05)</a></li>
|
||||
<li><a class="reference internal" href="#section-3" id="toc-entry-6">16.0.1.1.3 (2025-10-13)</a></li>
|
||||
<li><a class="reference internal" href="#section-4" id="toc-entry-7">16.0.1.1.0 (2025-07-16)</a></li>
|
||||
<li><a class="reference internal" href="#section-5" id="toc-entry-8">16.0.1.0.2 (2025-05-16)</a></li>
|
||||
<li><a class="reference internal" href="#section-6" id="toc-entry-9">16.0.1.0.1 (2025-05-09)</a></li>
|
||||
<li><a class="reference internal" href="#section-7" id="toc-entry-10">16.0.1.0.0</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-11">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="toc-entry-12">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="toc-entry-13">Authors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="toc-entry-14">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.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-2">
|
||||
<h2><a class="toc-backref" href="#toc-entry-5">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-3">
|
||||
<h2><a class="toc-backref" href="#toc-entry-6">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-4">
|
||||
<h2><a class="toc-backref" href="#toc-entry-7">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-5">
|
||||
<h2><a class="toc-backref" href="#toc-entry-8">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-6">
|
||||
<h2><a class="toc-backref" href="#toc-entry-9">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-7">
|
||||
<h2><a class="toc-backref" href="#toc-entry-10">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-11">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-12">Credits</a></h1>
|
||||
<div class="section" id="authors">
|
||||
<h2><a class="toc-backref" href="#toc-entry-13">Authors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Cetmix</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2><a class="toc-backref" href="#toc-entry-14">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>
|
||||
3
addons/cetmix_tower_server_queue/tests/__init__.py
Normal file
3
addons/cetmix_tower_server_queue/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import test_command
|
||||
from . import test_command_log
|
||||
from . import test_file
|
||||
145
addons/cetmix_tower_server_queue/tests/test_command.py
Normal file
145
addons/cetmix_tower_server_queue/tests/test_command.py
Normal file
@@ -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")
|
||||
37
addons/cetmix_tower_server_queue/tests/test_command_log.py
Normal file
37
addons/cetmix_tower_server_queue/tests/test_command_log.py
Normal file
@@ -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",
|
||||
)
|
||||
201
addons/cetmix_tower_server_queue/tests/test_file.py
Normal file
201
addons/cetmix_tower_server_queue/tests/test_file.py
Normal file
@@ -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)
|
||||
@@ -0,0 +1,20 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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>
|
||||
190
addons/cetmix_tower_webhook/README.rst
Normal file
190
addons/cetmix_tower_webhook/README.rst
Normal file
@@ -0,0 +1,190 @@
|
||||
====================
|
||||
Cetmix Tower Webhook
|
||||
====================
|
||||
|
||||
..
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:6b28bb3bec0ce3e160c08d87fdf2735a4ca2fc271dbf3e361152240f0f02437c
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |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_webhook
|
||||
:alt: cetmix/cetmix-tower
|
||||
|
||||
|badge1| |badge2| |badge3|
|
||||
|
||||
This module implements incoming webhooks for `Cetmix
|
||||
Tower <https://tower.cetmix.com>`__. Webhooks are authorised using
|
||||
customisable authenticators which can be pre-configured and reused
|
||||
across multiple webhooks. Webhooks and authenticators can be exported
|
||||
and imported using YAML format, which makes them easily sharable.
|
||||
|
||||
This module is a part of Cetmix Tower, however it can be used to manage
|
||||
any other odoo applications.
|
||||
|
||||
Please refer to the `official
|
||||
documentation <https://tower.cetmix.com>`__ for detailed information.
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Use Cases / Context
|
||||
===================
|
||||
|
||||
Although Odoo has native support of webhooks staring 17.0, they still
|
||||
have some limitations. Another option is the OCA 'endpoint' module which
|
||||
although is more flexible still makes it usable with Cetmix Tower more
|
||||
complicated.
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
Configure an Authenticator
|
||||
--------------------------
|
||||
|
||||
**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to
|
||||
configure authenticators.**
|
||||
|
||||
- Go to "Cetmix Tower > Settings > Automation > Webhook Authenticators"
|
||||
and click "New".
|
||||
|
||||
**Complete the following fields:**
|
||||
|
||||
- Name. Authenticator name
|
||||
- Reference. Unique reference. Leave this field blank to auto generate
|
||||
it
|
||||
- Code. Code that is used to authenticate the request. You can use all
|
||||
Cetmix Tower - Python command variables except for the server plus the
|
||||
following webhook specific ones:
|
||||
- headers: dictionary that contains the request headers
|
||||
- raw_data: string with the raw HTTP request body
|
||||
- payload: dictionary that contains the JSON payload or the GET
|
||||
parameters of the request
|
||||
|
||||
**The code returns the result variable in the following format:**
|
||||
|
||||
.. code:: python
|
||||
|
||||
result = {"allowed": <bool, mandatory, default=False>, "http_code": <int, optional>, "message": <str, optional>}
|
||||
|
||||
eg:
|
||||
|
||||
.. code:: python
|
||||
|
||||
result = {"allowed": True}
|
||||
result = {"allowed": False, "http_code": 403, "message": "Sorry..."}
|
||||
|
||||
Configure a Webhook
|
||||
-------------------
|
||||
|
||||
**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to
|
||||
configure webhooks.**
|
||||
|
||||
- Go to "Cetmix Tower > Settings > Automation > Webhooks" and click
|
||||
"New".
|
||||
|
||||
**Complete the following fields:**
|
||||
|
||||
- Enabled. Uncheck this field to disable the webhook without deleting it
|
||||
- Name. Authenticator name
|
||||
- Reference. Unique reference. Leave this field blank to auto generate
|
||||
it
|
||||
- Authenticator. Select an Authenticator used for this webhook
|
||||
- Endpoint. Webhook andpoint. The complete webhook URL will be
|
||||
<your_tower_url>/cetmix_tower_webhooks/
|
||||
- Run as User. Select a user to run the webhook on behalf of. CAREFUL!
|
||||
You must realize and understand what you are doing, including all the
|
||||
possible consequences when selecting a specific user.
|
||||
- Code. Code that processes the request. You can use all Cetmix Tower
|
||||
Python command variables (except for the server) plus the following
|
||||
webhook-specific one:
|
||||
|
||||
- headers: dictionary that contains the request headers
|
||||
- payload: dictionary that contains the JSON payload or the GET
|
||||
parameters of the request
|
||||
|
||||
Webhook code returns a result using the Cetmix Tower Python command
|
||||
pattern:
|
||||
|
||||
.. code:: python
|
||||
|
||||
result = {"exit_code": <int, default=0>, "message": <string, default=None}
|
||||
|
||||
**To configure the time for which the webhook call logs are stored:**
|
||||
|
||||
- Go to "Cetmix Tower > Settings > General Settings"
|
||||
- Put a number of days into the "Keep Webhook Logs for (days)" field.
|
||||
Default value is 30.
|
||||
|
||||
Please refer to the `official
|
||||
documentation <https://tower.cetmix.com>`__ for detailed configuration
|
||||
instructions.
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
When a request is received, Cetmix Tower will search for the webhook
|
||||
with the matching endpoint and authenticate the request using the
|
||||
selected authenticator. In case of successful authentication webhook
|
||||
code is run. Each webhook call is logged. Logs are available under the
|
||||
"Cetmix Tower > Logs > Webhook Calls" menu or under the "Logs" button
|
||||
directly in the Webhook.
|
||||
|
||||
Please refer to the `official
|
||||
documentation <https://tower.cetmix.com>`__ for detailed usage
|
||||
instructions.
|
||||
|
||||
Changelog
|
||||
=========
|
||||
|
||||
16.0.1.0.4 (2025-12-11)
|
||||
-----------------------
|
||||
|
||||
- Features: Improve search views, implement the search panel for
|
||||
selected views. (5139)
|
||||
|
||||
16.0.1.0.3 (2025-10-21)
|
||||
-----------------------
|
||||
|
||||
- Features: Use native functions to convert payload to dict (5024)
|
||||
|
||||
16.0.1.0.2 (2025-10-06)
|
||||
-----------------------
|
||||
|
||||
- Bugfixes: Export related variables and secrets (4980)
|
||||
|
||||
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_webhook%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_webhook>`_ project on GitHub.
|
||||
|
||||
You are welcome to contribute.
|
||||
2
addons/cetmix_tower_webhook/__init__.py
Normal file
2
addons/cetmix_tower_webhook/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
28
addons/cetmix_tower_webhook/__manifest__.py
Normal file
28
addons/cetmix_tower_webhook/__manifest__.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Copyright Cetmix OÜ 2025
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
{
|
||||
"name": "Cetmix Tower Webhook",
|
||||
"summary": "Webhook implementation for Cetmix Tower",
|
||||
"version": "16.0.1.0.5",
|
||||
"development_status": "Beta",
|
||||
"category": "Productivity",
|
||||
"website": "https://tower.cetmix.com",
|
||||
"live_test_url": "https://tower.cetmix.com/download",
|
||||
"images": ["static/description/banner.png"],
|
||||
"author": "Cetmix",
|
||||
"license": "AGPL-3",
|
||||
"installable": True,
|
||||
"depends": ["cetmix_tower_yaml"],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"views/cx_tower_webhook_authenticator_views.xml",
|
||||
"views/cx_tower_webhook_log_views.xml",
|
||||
"views/cx_tower_webhook_views.xml",
|
||||
"views/cx_tower_variable_views.xml",
|
||||
"views/res_config_settings_views.xml",
|
||||
"views/menuitems.xml",
|
||||
],
|
||||
"demo": [
|
||||
"demo/demo_data.xml",
|
||||
],
|
||||
}
|
||||
4
addons/cetmix_tower_webhook/controllers/__init__.py
Normal file
4
addons/cetmix_tower_webhook/controllers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import main
|
||||
250
addons/cetmix_tower_webhook/controllers/main.py
Normal file
250
addons/cetmix_tower_webhook/controllers/main.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import Response, request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CetmixTowerWebhookController(http.Controller):
|
||||
"""
|
||||
Handles incoming requests to Tower webhooks.
|
||||
"""
|
||||
|
||||
@http.route(
|
||||
["/cetmix_tower_webhooks/<string:endpoint>"],
|
||||
type="http",
|
||||
auth="public",
|
||||
methods=["POST", "GET"],
|
||||
csrf=False,
|
||||
save_session=False,
|
||||
)
|
||||
def cetmix_webhook(self, endpoint, **kwargs):
|
||||
"""
|
||||
Process an incoming webhook request.
|
||||
|
||||
Workflow:
|
||||
1. Extract request headers, body, and HTTP method.
|
||||
2. Match the request against a registered webhook.
|
||||
3. Authenticate the request if required.
|
||||
4. Execute the webhook code.
|
||||
5. Log the request and return the response.
|
||||
|
||||
Args:
|
||||
endpoint (str): The requested webhook endpoint.
|
||||
**kwargs: Additional request parameters.
|
||||
|
||||
Returns:
|
||||
Response: HTTP JSON response containing the result message.
|
||||
"""
|
||||
# Step 1: Extract request data
|
||||
headers = self._extract_webhook_request_headers()
|
||||
raw_data = self._extract_webhook_request_raw_data()
|
||||
http_method = request.httprequest.method.lower()
|
||||
|
||||
# Step 2. Find webhook
|
||||
webhook = (
|
||||
request.env["cx.tower.webhook"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("endpoint", "=", endpoint),
|
||||
("method", "=", http_method),
|
||||
("active", "=", True),
|
||||
],
|
||||
)
|
||||
)
|
||||
payload = self._extract_webhook_request_payload(webhook)
|
||||
|
||||
log_model = request.env["cx.tower.webhook.log"].sudo()
|
||||
log_values = log_model._prepare_values(
|
||||
webhook=webhook,
|
||||
endpoint=endpoint,
|
||||
request_method=http_method,
|
||||
request_payload=payload,
|
||||
request_headers=headers,
|
||||
authentication_status="not_required",
|
||||
code_status="skipped",
|
||||
)
|
||||
|
||||
if not webhook:
|
||||
log_values.update(
|
||||
{
|
||||
"authentication_status": "failed",
|
||||
"http_status": 404,
|
||||
}
|
||||
)
|
||||
return self._finalize_webhook_response(
|
||||
message="Webhook not found",
|
||||
error_message="Webhook not found",
|
||||
**log_values,
|
||||
)
|
||||
|
||||
# Step 3. Authenticate
|
||||
auth_status, auth_error, http_auth_code = "success", None, 200
|
||||
if webhook.authenticator_id:
|
||||
if not webhook.authenticator_id.is_ip_allowed(self._get_remote_addr()):
|
||||
auth_status, auth_error, http_auth_code = (
|
||||
"failed",
|
||||
"Address not allowed",
|
||||
403,
|
||||
)
|
||||
log_values.update(
|
||||
{
|
||||
"error_message": auth_error,
|
||||
"http_status": http_auth_code,
|
||||
"authentication_status": auth_status,
|
||||
}
|
||||
)
|
||||
return self._finalize_webhook_response(
|
||||
message=auth_error,
|
||||
**log_values,
|
||||
)
|
||||
|
||||
try:
|
||||
with request.env.cr.savepoint():
|
||||
auth_result = webhook.authenticator_id.sudo().authenticate(
|
||||
headers=headers,
|
||||
raw_data=raw_data,
|
||||
payload=payload,
|
||||
)
|
||||
if not auth_result.get("allowed"):
|
||||
raise Exception(
|
||||
auth_result.get("message", "Authentication not allowed")
|
||||
)
|
||||
except Exception as e:
|
||||
auth_status, auth_error, http_auth_code = "failed", str(e), 403
|
||||
else:
|
||||
auth_status = "not_required"
|
||||
|
||||
if auth_status == "failed":
|
||||
# Authentication failed
|
||||
log_values.update(
|
||||
{
|
||||
"error_message": auth_error,
|
||||
"http_status": http_auth_code,
|
||||
"authentication_status": auth_status,
|
||||
}
|
||||
)
|
||||
return self._finalize_webhook_response(
|
||||
message=auth_error,
|
||||
**log_values,
|
||||
)
|
||||
|
||||
# Step 4. Execute webhook code
|
||||
code_status, error_message, http_code, message = "success", None, 200, "OK"
|
||||
try:
|
||||
with request.env.cr.savepoint():
|
||||
code_result = webhook.execute(payload, headers=headers)
|
||||
if code_result.get("exit_code") != 0:
|
||||
raise Exception(code_result.get("message"))
|
||||
message = code_result.get("message") or "OK"
|
||||
except Exception as e:
|
||||
code_status, error_message, http_code, message = "failed", str(e), 500, None
|
||||
|
||||
# Step 5. Update log
|
||||
log_values.update(
|
||||
{
|
||||
"code_status": code_status,
|
||||
"error_message": error_message,
|
||||
"http_status": http_code,
|
||||
"result_message": message,
|
||||
"authentication_status": auth_status,
|
||||
}
|
||||
)
|
||||
|
||||
return self._finalize_webhook_response(
|
||||
message=message or error_message or "", **log_values
|
||||
)
|
||||
|
||||
def _extract_webhook_request_payload(self, webhook):
|
||||
"""
|
||||
Extract the request payload depending on HTTP method and content type.
|
||||
|
||||
Args:
|
||||
webhook (cx.tower.webhook): Webhook record with configuration
|
||||
(may be empty).
|
||||
|
||||
Returns:
|
||||
dict: Parsed payload as a dictionary. Empty if parsing fails.
|
||||
"""
|
||||
http_method = request.httprequest.method
|
||||
try:
|
||||
if http_method.upper() == "POST":
|
||||
content_type = webhook.content_type if webhook else "json"
|
||||
return self._get_payload_by_content_type(content_type)
|
||||
elif http_method.upper() == "GET":
|
||||
return request.httprequest.args.to_dict(flat=True)
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def _get_payload_by_content_type(self, content_type):
|
||||
"""
|
||||
Return the request payload for POST requests according to content type.
|
||||
|
||||
Args:
|
||||
content_type (str): Payload format, e.g. "json" or "form".
|
||||
|
||||
Returns:
|
||||
dict: Parsed payload as a dictionary.
|
||||
"""
|
||||
if content_type == "form":
|
||||
return request.httprequest.form.to_dict(flat=True)
|
||||
data = request.httprequest.data
|
||||
return json.loads(data or "{}") if data else {}
|
||||
|
||||
def _extract_webhook_request_headers(self):
|
||||
"""
|
||||
Extract request headers.
|
||||
|
||||
Returns:
|
||||
dict: Request headers as a dictionary.
|
||||
"""
|
||||
return dict(request.httprequest.headers)
|
||||
|
||||
def _extract_webhook_request_raw_data(self):
|
||||
"""
|
||||
Return raw request body.
|
||||
|
||||
Returns:
|
||||
bytes: Raw HTTP request body.
|
||||
"""
|
||||
return request.httprequest.data
|
||||
|
||||
def _finalize_webhook_response(self, message, **kwargs):
|
||||
"""
|
||||
Create a log entry and return final HTTP response.
|
||||
|
||||
Args:
|
||||
message (str): Response message text.
|
||||
**kwargs: Log values for `cx.tower.webhook.log`.
|
||||
|
||||
Returns:
|
||||
Response: HTTP JSON response with message and status code.
|
||||
"""
|
||||
try:
|
||||
with request.env.cr.savepoint():
|
||||
request.env["cx.tower.webhook.log"].sudo().create_from_call(**kwargs)
|
||||
except Exception:
|
||||
# don't break controller if logging fails
|
||||
_logger.error("Failed to create log entry", exc_info=True)
|
||||
|
||||
return Response(
|
||||
status=kwargs.get("http_status"),
|
||||
response=json.dumps({"message": message or ""}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def _get_remote_addr(self):
|
||||
"""
|
||||
Return the remote IP address of the current request.
|
||||
|
||||
Returns:
|
||||
str: Remote client IP address.
|
||||
"""
|
||||
return request.httprequest.remote_addr
|
||||
38
addons/cetmix_tower_webhook/demo/demo_data.xml
Normal file
38
addons/cetmix_tower_webhook/demo/demo_data.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- Webhook Authenticators -->
|
||||
<record id="webhook_authenticator_1" model="cx.tower.webhook.authenticator">
|
||||
<field name="name">Demo Webhook Authenticator 1</field>
|
||||
<field name="reference">demo_webhook_authenticator_1</field>
|
||||
<field name="allowed_ip_addresses">192.168.1.10,192.168.2.0/24,10.0.0.1</field>
|
||||
<field name="code">result = {"allowed": True}</field>
|
||||
</record>
|
||||
|
||||
<record id="webhook_authenticator_2" model="cx.tower.webhook.authenticator">
|
||||
<field name="name">Demo Webhook Authenticator 2</field>
|
||||
<field name="reference">demo_webhook_authenticator_2</field>
|
||||
<field
|
||||
name="code"
|
||||
>result = {"allowed": False, "http_code": 403, "message": "Sorry..."}</field>
|
||||
</record>
|
||||
|
||||
<!-- Webhooks -->
|
||||
<record id="webhook_1" model="cx.tower.webhook">
|
||||
<field name="name">Demo Webhook 1</field>
|
||||
<field name="reference">demo_webhook_1</field>
|
||||
<field name="authenticator_id" ref="webhook_authenticator_1" />
|
||||
<field name="endpoint">demo_webhook_1</field>
|
||||
<field name="code">result = {"exit_code": 0, "message": "OK"}</field>
|
||||
</record>
|
||||
|
||||
<record id="webhook_2" model="cx.tower.webhook">
|
||||
<field name="name">Demo Webhook 2</field>
|
||||
<field name="reference">demo_webhook_2</field>
|
||||
<field name="authenticator_id" ref="webhook_authenticator_2" />
|
||||
<field name="endpoint">demo_webhook_2</field>
|
||||
<field name="method">get</field>
|
||||
<field name="code">result = {"exit_code": 0, "message": "OK"}</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
793
addons/cetmix_tower_webhook/i18n/cetmix_tower_webhook.pot
Normal file
793
addons/cetmix_tower_webhook/i18n/cetmix_tower_webhook.pot
Normal file
@@ -0,0 +1,793 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * cetmix_tower_webhook
|
||||
#
|
||||
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_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/constants.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"<h3>Help for Webhook Authenticator Python Code</h3>\n"
|
||||
"<div style=\"margin-bottom: 10px;\">\n"
|
||||
" <p>\n"
|
||||
" The Python code for the webhook authenticator must return the <code>result</code> variable, which is a dictionary.<br>\n"
|
||||
" <strong>Allowed keys:</strong>\n"
|
||||
" <ul>\n"
|
||||
" <li><code>allowed</code> (<b>bool</b>, required): Authentication result. <code>True</code> if allowed, <code>False</code> otherwise.</li>\n"
|
||||
" <li><code>http_code</code> (<b>int</b>, optional): HTTP status code to return if authentication fails (default is 403).</li>\n"
|
||||
" <li><code>message</code> (<b>str</b>, optional): Error message to show to the client.</li>\n"
|
||||
" </ul>\n"
|
||||
" <strong>Examples:</strong>\n"
|
||||
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
|
||||
"# Allow all requests\n"
|
||||
"result = {\"allowed\": True}\n"
|
||||
"\n"
|
||||
"# Deny with custom code and message\n"
|
||||
"result = {\"allowed\": False, \"http_code\": 401, \"message\": \"Unauthorized request\"}\n"
|
||||
" </pre>\n"
|
||||
" </p>\n"
|
||||
" <strong>Available variables:</strong>\n"
|
||||
"</div>\n"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/constants.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"<h3>Help for Webhook Python Code</h3>\n"
|
||||
"<div style=\"margin-bottom: 10px;\">\n"
|
||||
" <p>\n"
|
||||
" The webhook Python code must set the <code>result</code> variable, which is a dictionary.<br>\n"
|
||||
" <strong>Allowed keys:</strong>\n"
|
||||
" <ul>\n"
|
||||
" <li><code>exit_code</code> (<b>int</b>, optional, default=0): Exit code (0 means success, other values indicate failure).</li>\n"
|
||||
" <li><code>message</code> (<b>str</b>, optional): Message to return in the HTTP response and log.</li>\n"
|
||||
" </ul>\n"
|
||||
" <strong>Example:</strong>\n"
|
||||
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
|
||||
"# Simple successful result\n"
|
||||
"result = {\"exit_code\": 0, \"message\": \"Webhook processed successfully\"}\n"
|
||||
"\n"
|
||||
"# Failure example\n"
|
||||
"result = {\"exit_code\": 1, \"message\": \"Something went wrong\"}\n"
|
||||
" </pre>\n"
|
||||
" </p>\n"
|
||||
" <strong>Available variables:</strong>\n"
|
||||
"</div>\n"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/constants.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"# Please refer to the 'Help' tab and documentation for more information.\n"
|
||||
"#\n"
|
||||
"# You can return authenticator result in the 'result' variable which is a dictionary:\n"
|
||||
"# result = {\"allowed\": <bool, mandatory, default=False>, \"http_code\": <int, optional>, \"message\": <str, optional>}\n"
|
||||
"# default value is {\"allowed\": False}\n"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/constants.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"# Please refer to the 'Help' tab and documentation for more information.\n"
|
||||
"#\n"
|
||||
"# You can return webhook result in the 'result' variable which is a dictionary:\n"
|
||||
"# result = {\"exit_code\": <int, default=0>, \"message\": <string, default=None}\n"
|
||||
"# default value is {\"exit_code\": 0, \"message\": None}\n"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
msgid "10.0.0.1,192.168.1.0/24"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_action
|
||||
msgid "Add a new webhook"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_authenticator_action
|
||||
msgid "Add a new webhook authenticator"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__allowed_ip_addresses
|
||||
msgid "Allowed IPs"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Auth Failed"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Auth Status"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__authentication_status
|
||||
msgid "Authentication Status"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Authentication code error: %s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__authenticator_id
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__authenticator_id
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Authenticator"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__reference
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__reference
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__reference
|
||||
msgid ""
|
||||
"Can contain English letters, digits and '_'. Leave blank to autogenerate"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_variable
|
||||
msgid "Cetmix Tower Variable"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "Code"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Code Failed"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__code_help
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code_help
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code_help
|
||||
msgid "Code Help"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Code Status"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__allowed_ip_addresses
|
||||
msgid ""
|
||||
"Comma-separated list of IP addresses and/or subnets (e.g. "
|
||||
"192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e)."
|
||||
" Requests from other addresses will be denied."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__trusted_proxy_ips
|
||||
msgid ""
|
||||
"Comma-separated list of trusted proxy IP addresses or CIDR ranges (e.g., "
|
||||
"10.0.0.1,192.168.1.0/24). Only these proxies can set X-Forwarded-For "
|
||||
"headers."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_res_config_settings
|
||||
msgid "Config Settings"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Content Type"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__country_id
|
||||
msgid "Country"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__country_id
|
||||
msgid "Country of the client that made the request."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__create_uid
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__create_uid
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__create_uid
|
||||
msgid "Created by"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__create_date
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__create_date
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__create_date
|
||||
msgid "Created on"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Dictionary containing the request payload (JSON for POST, params for GET)"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Dictionary of request headers"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Disabled"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__display_name
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__display_name
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__display_name
|
||||
msgid "Display Name"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__active
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Enabled"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__endpoint
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__endpoint
|
||||
msgid "Endpoint"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.constraint,message:cetmix_tower_webhook.constraint_cx_tower_webhook_endpoint_method_uniq
|
||||
msgid "Endpoint and method must be unique!"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Endpoint must start and end with a letter or digit, and may contain "
|
||||
"underscores, dashes, and slashes in between"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
msgid ""
|
||||
"Enter Python code here. Help about Python expression is available in the "
|
||||
"help tab of this document"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid ""
|
||||
"Enter Python code here. Help about Python expression is available in the "
|
||||
"help tab of this document."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__error_message
|
||||
msgid "Error Message"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__error_message
|
||||
msgid "Error message in case of authentication or code failure."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_eval_mixin
|
||||
msgid "Eval context/code helper for Cetmix Tower Webhook"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_webhook.action_cx_tower_webhook_authenticator_export_yaml
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_webhook.action_cx_tower_webhook_export_yaml
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "Export YAML"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__failed
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__failed
|
||||
msgid "Failed"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__content_type__form
|
||||
msgid "Form URL-Encoded"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__full_url
|
||||
msgid "Full URL of the webhook"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__full_url
|
||||
msgid "Full Url"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__method__get
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__request_method__get
|
||||
msgid "GET"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Group By"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "HTTP 200"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__http_status
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "HTTP Status"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__http_status
|
||||
msgid "HTTP status code returned to the client."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_headers
|
||||
msgid "Headers of the received HTTP request (JSON-encoded)."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "Help"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__content_type
|
||||
msgid ""
|
||||
"How the payload is expected to be sent to this webhook: as JSON body or as "
|
||||
"URL-encoded form data"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__id
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__id
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__id
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__ip_address
|
||||
msgid "IP Address"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__ip_address
|
||||
msgid "IP address of the client that made the request."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Invalid allowed IP/CIDR entry: %s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Invalid trusted proxy entry: %s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__content_type__json
|
||||
msgid "JSON"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_res_config_settings__cetmix_tower_webhook_log_duration
|
||||
msgid "Keep Webhook Logs for (days)"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook____last_update
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator____last_update
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__write_uid
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__write_uid
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__write_date
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__write_date
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__log_count
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__log_count
|
||||
msgid "Log Count"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "Logs"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__result_message
|
||||
msgid "Message returned by the webhook code or authenticator (if any)."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__method
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Method"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__name
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__name
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__name
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Name/Reference"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_log_action
|
||||
msgid "No webhook logs found"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__not_required
|
||||
msgid "Not Required"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__method__post
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__request_method__post
|
||||
msgid "POST"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__content_type
|
||||
msgid "Payload Type"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_payload
|
||||
msgid "Payload/body of the received HTTP request (JSON-encoded)."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Raw body of the request (bytes)"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__reference
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__reference
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__reference
|
||||
msgid "Reference"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_headers
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
|
||||
msgid "Request Headers"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_method
|
||||
msgid "Request Method"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_payload
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
|
||||
msgid "Request Payload"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
|
||||
msgid "Response Payload"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__result_message
|
||||
msgid "Result Message"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__authentication_status
|
||||
msgid "Result of authentication for this webhook call."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__code_status
|
||||
msgid "Result of webhook code execution."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__user_id
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__user_id
|
||||
msgid "Run as User"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_search
|
||||
msgid "Search Webhook Authenticators"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Search Webhooks"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__secret_ids
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__secret_ids
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__secret_ids
|
||||
msgid "Secrets"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__user_id
|
||||
msgid ""
|
||||
"Select a user to run the webhook from behalf of. If not set, the webhook will run as the current user.\n"
|
||||
"CAREFUL! You must realise and understand what you are doing including all the possible consequences when selecting a specific user"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__authenticator_id
|
||||
msgid "Select an Authenticator used for this webhook"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__method
|
||||
msgid "Select the HTTP method for this webhook"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_method
|
||||
msgid "Select the HTTP method for this webhook."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_res_config_settings__cetmix_tower_webhook_log_duration
|
||||
msgid ""
|
||||
"Set the number of days to keep webhook logs. Old logs will be deleted "
|
||||
"automatically."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.res_config_settings_view_form
|
||||
msgid ""
|
||||
"Set the number of days to keep webhook logs. Old logs will be deleted automatically.\n"
|
||||
" <br/>"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__skipped
|
||||
msgid "Skipped"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__success
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__success
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__code
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code
|
||||
msgid "This field will be rendered using variables"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__trusted_proxy_ips
|
||||
msgid "Trusted Proxy IPs"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__user_id
|
||||
msgid "User as which the webhook code was executed (if set)."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__variable_ids
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__variable_ids
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__variable_ids
|
||||
msgid "Variables"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_ids
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Webhook"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_authenticator
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_authenticator_ids
|
||||
msgid "Webhook Authenticator"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_authenticator_ids_count
|
||||
msgid "Webhook Authenticator Count"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_authenticator_action
|
||||
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook_authenticator
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form
|
||||
msgid "Webhook Authenticators"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_log
|
||||
msgid "Webhook Call Log"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook_log
|
||||
msgid "Webhook Calls"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__code_status
|
||||
msgid "Webhook Code Status"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_ids_count
|
||||
msgid "Webhook Count"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_log_action
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Webhook Logs"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
|
||||
#, python-format
|
||||
msgid "Webhook code execution error: %(error)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__endpoint
|
||||
msgid ""
|
||||
"Webhook endpoint. The complete URL will be "
|
||||
"<your_tower_url>/cetmix_tower_webhooks/<endpoint>"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id
|
||||
msgid "Webhook that received the call."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_eval_mixin.py:0
|
||||
#, python-format
|
||||
msgid "Webhook/Authenticator code error: result is not a dict"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_action
|
||||
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form
|
||||
msgid "Webhooks"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "YAML"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__yaml_code
|
||||
msgid "Yaml Code"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
msgid ""
|
||||
"e.g.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e"
|
||||
msgstr ""
|
||||
822
addons/cetmix_tower_webhook/i18n/it.po
Normal file
822
addons/cetmix_tower_webhook/i18n/it.po
Normal file
@@ -0,0 +1,822 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * cetmix_tower_webhook
|
||||
#
|
||||
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: 8bit\n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/constants.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"<h3>Help for Webhook Authenticator Python Code</h3>\n"
|
||||
"<div style=\"margin-bottom: 10px;\">\n"
|
||||
" <p>\n"
|
||||
" The Python code for the webhook authenticator must return the <code>result</code> variable, which is a dictionary.<br>\n"
|
||||
" <strong>Allowed keys:</strong>\n"
|
||||
" <ul>\n"
|
||||
" <li><code>allowed</code> (<b>bool</b>, required): Authentication result. <code>True</code> if allowed, <code>False</code> otherwise.</li>\n"
|
||||
" <li><code>http_code</code> (<b>int</b>, optional): HTTP status code to return if authentication fails (default is 403).</li>\n"
|
||||
" <li><code>message</code> (<b>str</b>, optional): Error message to show to the client.</li>\n"
|
||||
" </ul>\n"
|
||||
" <strong>Examples:</strong>\n"
|
||||
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
|
||||
"# Allow all requests\n"
|
||||
"result = {\"allowed\": True}\n"
|
||||
"\n"
|
||||
"# Deny with custom code and message\n"
|
||||
"result = {\"allowed\": False, \"http_code\": 401, \"message\": \"Unauthorized request\"}\n"
|
||||
" </pre>\n"
|
||||
" </p>\n"
|
||||
" <strong>Available variables:</strong>\n"
|
||||
"</div>\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"<h3>Aiuto per il codice Python di autenticazione webhook</h3>\n"
|
||||
"<div style=\"margin-bottom: 10px;\">\n"
|
||||
" <p>\n"
|
||||
" Il codice Python per l'autenticazione webhook deve restituire la variabile <code>result</code>, che è un dizionario.<br>\n"
|
||||
" <strong>Chiavi consentite:</strong>\n"
|
||||
" <ul>\n"
|
||||
" <li><code>allowed</code> (<b>bool</b>, richiesto): risulatato autenticazione. <code>True</code> se abilitato, <code>False</code> altrimenti.</li>\n"
|
||||
" <li><code>http_code</code> (<b>int</b>, opzionalel): codice stato HTTP da restituire se l'autenticazione fallisce (predefinito 403).</li>\n"
|
||||
" <li><code>message</code> (<b>str</b>, opzionale): messaggio di errore da visualizzare al cliente.</li>\n"
|
||||
" </ul>\n"
|
||||
" <strong>Esempi:</strong>\n"
|
||||
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
|
||||
"# Consenti tutte le richieste\n"
|
||||
"result = {\"allowed\": True}\n"
|
||||
"\n"
|
||||
"# Nega con codie e messaggio personalizzati\n"
|
||||
"result = {\"allowed\": False, \"http_code\": 401, \"message\": \"Richiesta non autorizzata\"}\n"
|
||||
" </pre>\n"
|
||||
" </p>\n"
|
||||
" <strong>Variabili disponibili:</strong>\n"
|
||||
"</div>\n"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/constants.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"\n"
|
||||
"<h3>Help for Webhook Python Code</h3>\n"
|
||||
"<div style=\"margin-bottom: 10px;\">\n"
|
||||
" <p>\n"
|
||||
" The webhook Python code must set the <code>result</code> variable, which is a dictionary.<br>\n"
|
||||
" <strong>Allowed keys:</strong>\n"
|
||||
" <ul>\n"
|
||||
" <li><code>exit_code</code> (<b>int</b>, optional, default=0): Exit code (0 means success, other values indicate failure).</li>\n"
|
||||
" <li><code>message</code> (<b>str</b>, optional): Message to return in the HTTP response and log.</li>\n"
|
||||
" </ul>\n"
|
||||
" <strong>Example:</strong>\n"
|
||||
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
|
||||
"# Simple successful result\n"
|
||||
"result = {\"exit_code\": 0, \"message\": \"Webhook processed successfully\"}\n"
|
||||
"\n"
|
||||
"# Failure example\n"
|
||||
"result = {\"exit_code\": 1, \"message\": \"Something went wrong\"}\n"
|
||||
" </pre>\n"
|
||||
" </p>\n"
|
||||
" <strong>Available variables:</strong>\n"
|
||||
"</div>\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"<h3>Aiuto per codice Python webhook</h3>\n"
|
||||
"<div style=\"margin-bottom: 10px;\">\n"
|
||||
" <p>\n"
|
||||
" Il codice Python webhook deve impostare la variabile <code>result</code>, che è un dizionario.<br>\n"
|
||||
" <strong>Chiavi consentitie:</strong>\n"
|
||||
" <ul>\n"
|
||||
" <li><code>exit_code</code> (<b>int</b>, opzionale, predefinito=0): codice di uscita (0 significa successo, altri valori indicano fallimento).</li>\n"
|
||||
" <li><code>message</code> (<b>str</b>, opzionale): messaggio da restituire nella risposta HTTP e nel log.</li>\n"
|
||||
" </ul>\n"
|
||||
" <strong>Esempio:</strong>\n"
|
||||
" <pre style='background:#f7f7f7; padding:6px; border-radius:4px'>\n"
|
||||
"# Risultato successo semplce\n"
|
||||
"result = {\"exit_code\": 0, \"message\": \"Webhook elaborato con successo\"}\n"
|
||||
"\n"
|
||||
"# Esempio di fallimento\n"
|
||||
"result = {\"exit_code\": 1, \"message\": \"Qualcosa è andato storto\"}\n"
|
||||
" </pre>\n"
|
||||
" </p>\n"
|
||||
" <strong>Variabili disponibili:</strong>\n"
|
||||
"</div>\n"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/constants.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"# Please refer to the 'Help' tab and documentation for more information.\n"
|
||||
"#\n"
|
||||
"# You can return authenticator result in the 'result' variable which is a dictionary:\n"
|
||||
"# result = {\"allowed\": <bool, mandatory, default=False>, \"http_code\": <int, optional>, \"message\": <str, optional>}\n"
|
||||
"# default value is {\"allowed\": False}\n"
|
||||
msgstr ""
|
||||
"# Fare riferimento alla libuetta 'Help' e alla documentazione per informazioni aggiuntive.\n"
|
||||
"#\n"
|
||||
"# Si può restituire il risultato dell'autenticazione nella variabile 'result' che è un dizionario:\n"
|
||||
"# result = {\"allowed\": <bool, mandatory, default=False>, \"http_code\": <int, optional>, \"message\": <str, optional>}\n"
|
||||
"# il valore predefinito è {\"allowed\": False}\n"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/constants.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"# Please refer to the 'Help' tab and documentation for more information.\n"
|
||||
"#\n"
|
||||
"# You can return webhook result in the 'result' variable which is a dictionary:\n"
|
||||
"# result = {\"exit_code\": <int, default=0>, \"message\": <string, default=None}\n"
|
||||
"# default value is {\"exit_code\": 0, \"message\": None}\n"
|
||||
msgstr ""
|
||||
"# Fare riferimento alla libuetta 'Help' e alla documentazione per informazioni aggiuntive.\n"
|
||||
"#\n"
|
||||
"# Si può restituire il risultato del webhook nella variabile 'result' che è un dizionario:\n"
|
||||
"# result = {\"exit_code\": <int, default=0>, \"message\": <string, default=None}\n"
|
||||
"# valore predefinoto {\"exit_code\": 0, \"message\": None}\n"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
msgid "10.0.0.1,192.168.1.0/24"
|
||||
msgstr "10.0.0.1,192.168.1.0/24"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_action
|
||||
msgid "Add a new webhook"
|
||||
msgstr "Aggiungi un nuovo webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_authenticator_action
|
||||
msgid "Add a new webhook authenticator"
|
||||
msgstr "Aggiungi un nuovo autenticatore webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "All"
|
||||
msgstr "Tutti"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__allowed_ip_addresses
|
||||
msgid "Allowed IPs"
|
||||
msgstr "IP consentiti"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Auth Failed"
|
||||
msgstr "Autenticazione fallita"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Auth Status"
|
||||
msgstr "Stato autenticazione"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__authentication_status
|
||||
msgid "Authentication Status"
|
||||
msgstr "Stato autenticazione"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Authentication code error: %s"
|
||||
msgstr "Codice errore autenticazione: %s"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__authenticator_id
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__authenticator_id
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Authenticator"
|
||||
msgstr "Autenticatore"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__reference
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__reference
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__reference
|
||||
msgid "Can contain English letters, digits and '_'. Leave blank to autogenerate"
|
||||
msgstr "Può contenere lettere inglesi, numeri e '_'. Lasciare vuoto per generarlo automaticamente"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_variable
|
||||
msgid "Cetmix Tower Variable"
|
||||
msgstr "Variabile Cetmix Tower"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "Code"
|
||||
msgstr "Codice"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Code Failed"
|
||||
msgstr "Codice fallito"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__code_help
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code_help
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code_help
|
||||
msgid "Code Help"
|
||||
msgstr "Aiuto codice"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Code Status"
|
||||
msgstr "Stato codice"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__allowed_ip_addresses
|
||||
msgid "Comma-separated list of IP addresses and/or subnets (e.g. 192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e). Requests from other addresses will be denied."
|
||||
msgstr "Elenco indirizzi IP e/o sottoreti separati da virgola (es. 192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e). Richieste da altri indirizzi verranno rifiutate."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__trusted_proxy_ips
|
||||
msgid "Comma-separated list of trusted proxy IP addresses or CIDR ranges (e.g., 10.0.0.1,192.168.1.0/24). Only these proxies can set X-Forwarded-For headers."
|
||||
msgstr "Elenco di indirizzi IP affidabili o intervalli CIDR separati da virgola (es. 10.0.0.1,192.168.1.0/24). Solo questi proxies possono impostare header X-Forwarded-For."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_res_config_settings
|
||||
msgid "Config Settings"
|
||||
msgstr "Impostazioni configurazione"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Content Type"
|
||||
msgstr "Tipo contenuto"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__country_id
|
||||
msgid "Country"
|
||||
msgstr "Nazione"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__country_id
|
||||
msgid "Country of the client that made the request."
|
||||
msgstr "Nazione del client che ha fatto la richiesta."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__create_uid
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__create_uid
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__create_uid
|
||||
msgid "Created by"
|
||||
msgstr "Creato da"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__create_date
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__create_date
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__create_date
|
||||
msgid "Created on"
|
||||
msgstr "Creato il"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Dictionary containing the request payload (JSON for POST, params for GET)"
|
||||
msgstr "Dizionario che contiene le informazioni richieste (JSON per POST, parametri per GET)"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Dictionary of request headers"
|
||||
msgstr "Dizionario degli header richiesti"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Disabled"
|
||||
msgstr "Disabilitato"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__display_name
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__display_name
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__display_name
|
||||
msgid "Display Name"
|
||||
msgstr "Nome visualizzato"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__active
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Enabled"
|
||||
msgstr "Abilitato"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__endpoint
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__endpoint
|
||||
msgid "Endpoint"
|
||||
msgstr "Endpoint"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.constraint,message:cetmix_tower_webhook.constraint_cx_tower_webhook_endpoint_method_uniq
|
||||
msgid "Endpoint and method must be unique!"
|
||||
msgstr "L'endpoint e il motodo devono essere univoci!"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
|
||||
#, python-format
|
||||
msgid "Endpoint must start and end with a letter or digit, and may contain underscores, dashes, and slashes in between"
|
||||
msgstr "L'endpoint deve iniziare e finire con una lettera o un numero e può contenere underscore, trattini e slash nel mezzo"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
msgid "Enter Python code here. Help about Python expression is available in the help tab of this document"
|
||||
msgstr "Inserire qui codice Python. L'aiuto per l'espressione Python è disponibile nella linguetta di aiuto di questo documento"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "Enter Python code here. Help about Python expression is available in the help tab of this document."
|
||||
msgstr "Inserire qui codice Python. L'aiuto per l'espressione Python è disponibile nella linguetta di aiuto di questo documento."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
|
||||
msgid "Error"
|
||||
msgstr "Errore"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__error_message
|
||||
msgid "Error Message"
|
||||
msgstr "Messaggio di errore"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__error_message
|
||||
msgid "Error message in case of authentication or code failure."
|
||||
msgstr "Messaggio di errore nel caso di fallimento dell'autenticazione o del codice."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_eval_mixin
|
||||
msgid "Eval context/code helper for Cetmix Tower Webhook"
|
||||
msgstr "Aiuto per la valutazione del context/codice per il webhook Cetmix Tower"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_webhook.action_cx_tower_webhook_authenticator_export_yaml
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_webhook.action_cx_tower_webhook_export_yaml
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "Export YAML"
|
||||
msgstr "Esporta YAML"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__failed
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__failed
|
||||
msgid "Failed"
|
||||
msgstr "Fallito"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__content_type__form
|
||||
msgid "Form URL-Encoded"
|
||||
msgstr "Da URL codificato"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__full_url
|
||||
msgid "Full URL of the webhook"
|
||||
msgstr "URL completo del webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__full_url
|
||||
msgid "Full Url"
|
||||
msgstr "URL completo"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__method__get
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__request_method__get
|
||||
msgid "GET"
|
||||
msgstr "GET"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Group By"
|
||||
msgstr "Raggruppa per"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "HTTP 200"
|
||||
msgstr "HTTP 200"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__http_status
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "HTTP Status"
|
||||
msgstr "Stato HTTP"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__http_status
|
||||
msgid "HTTP status code returned to the client."
|
||||
msgstr "Codice stato HTTP restituito al client."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_headers
|
||||
msgid "Headers of the received HTTP request (JSON-encoded)."
|
||||
msgstr "Header della richiesta HHTP rivevuta (codificata JSON)."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "Help"
|
||||
msgstr "Aiuto"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__content_type
|
||||
msgid "How the payload is expected to be sent to this webhook: as JSON body or as URL-encoded form data"
|
||||
msgstr "Come è previsto che vengano inviati i dati a questo webhook: come struttura JSON o come URL codificato dai dati"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__id
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__id
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__id
|
||||
msgid "ID"
|
||||
msgstr "ID"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__ip_address
|
||||
msgid "IP Address"
|
||||
msgstr "Indirizzo IP"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__ip_address
|
||||
msgid "IP address of the client that made the request."
|
||||
msgstr "Indirizzo IP del client che ha effettuato la richiesta."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Invalid allowed IP/CIDR entry: %s"
|
||||
msgstr "Valore IP/CIDR consentito non valido: %s"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Invalid trusted proxy entry: %s"
|
||||
msgstr "Valore proxy validato non valido: %s"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__content_type__json
|
||||
msgid "JSON"
|
||||
msgstr "JSON"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_res_config_settings__cetmix_tower_webhook_log_duration
|
||||
msgid "Keep Webhook Logs for (days)"
|
||||
msgstr "Mantenere i registri del webhook per (giorni)"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook____last_update
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator____last_update
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr "Ultima modifica il"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__write_uid
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__write_uid
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr "Ultima modifica di"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__write_date
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__write_date
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr "Ultimo aggiornamento di"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__log_count
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__log_count
|
||||
msgid "Log Count"
|
||||
msgstr "Conteggio registro"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "Logs"
|
||||
msgstr "Registri"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__result_message
|
||||
msgid "Message returned by the webhook code or authenticator (if any)."
|
||||
msgstr "Messaggio restituito dal codice webhook o dall'autenticatore (se presente)."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__method
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Method"
|
||||
msgstr "Metodo"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__name
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__name
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__name
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
|
||||
msgid "Name"
|
||||
msgstr "nome"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.actions.act_window,help:cetmix_tower_webhook.cx_tower_webhook_log_action
|
||||
msgid "No webhook logs found"
|
||||
msgstr "Nessun registro webhook trovato"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__not_required
|
||||
msgid "Not Required"
|
||||
msgstr "Non richiesto"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook__method__post
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__request_method__post
|
||||
msgid "POST"
|
||||
msgstr "POST"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__content_type
|
||||
msgid "Payload Type"
|
||||
msgstr "Tipo dati"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_payload
|
||||
msgid "Payload/body of the received HTTP request (JSON-encoded)."
|
||||
msgstr "Dati/corpo della richiesta HTTP ricevuta (codificata JSON)."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_authenticator.py:0
|
||||
#, python-format
|
||||
msgid "Raw body of the request (bytes)"
|
||||
msgstr "Corpo grezzo della richiesta (vyte)"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__reference
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__reference
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__reference
|
||||
msgid "Reference"
|
||||
msgstr "Riferimento"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_headers
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
|
||||
msgid "Request Headers"
|
||||
msgstr "Geader richiesta"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_method
|
||||
msgid "Request Method"
|
||||
msgstr "Metodo richiesta"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__request_payload
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
|
||||
msgid "Request Payload"
|
||||
msgstr "Dati richiesta"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_form
|
||||
msgid "Response Payload"
|
||||
msgstr "Dati risposta"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__result_message
|
||||
msgid "Result Message"
|
||||
msgstr "Messaggio risultato"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__authentication_status
|
||||
msgid "Result of authentication for this webhook call."
|
||||
msgstr "Risultato dell'autenticazione di questa chiamata webhook."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__code_status
|
||||
msgid "Result of webhook code execution."
|
||||
msgstr "Risultato dell'esecuzione del codice webhook."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__user_id
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__user_id
|
||||
msgid "Run as User"
|
||||
msgstr "Esegui come utente"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_search
|
||||
msgid "Search Webhook Authenticators"
|
||||
msgstr "Cerca autenticatori webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "Search Webhooks"
|
||||
msgstr "Cerca webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__secret_ids
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__secret_ids
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__secret_ids
|
||||
msgid "Secrets"
|
||||
msgstr "Segreti"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__user_id
|
||||
msgid ""
|
||||
"Select a user to run the webhook from behalf of. If not set, the webhook will run as the current user.\n"
|
||||
"CAREFUL! You must realise and understand what you are doing including all the possible consequences when selecting a specific user"
|
||||
msgstr "Selezionare un utente per cui eseguire il webhook. Se non impostato, il webhook verrà eseguito per conto dell'utente corrente.ATTENZIONE! Bisogna essere consapevoli e comprendere cosa si sta facendo, comprese tutte le possibili conseguenze quando si seleziona un utente specifico."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__authenticator_id
|
||||
msgid "Select an Authenticator used for this webhook"
|
||||
msgstr "Selezionare un autenticatore per questo webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__method
|
||||
msgid "Select the HTTP method for this webhook"
|
||||
msgstr "Selezionare il metodo HTTP per questo webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__request_method
|
||||
msgid "Select the HTTP method for this webhook."
|
||||
msgstr "Selezionare il metodo HTTP per questo webhook."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_res_config_settings__cetmix_tower_webhook_log_duration
|
||||
msgid "Set the number of days to keep webhook logs. Old logs will be deleted automatically."
|
||||
msgstr "Impostare il numero di giorni di mantenimento dei registri webhook."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.res_config_settings_view_form
|
||||
msgid ""
|
||||
"Set the number of days to keep webhook logs. Old logs will be deleted automatically.\n"
|
||||
" <br/>"
|
||||
msgstr "Imposta il numero di giorni per cui conservare i registri dei webhook. I vecchi registri verranno eliminati automaticamente. <br/>"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__skipped
|
||||
msgid "Skipped"
|
||||
msgstr "Saltato"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__authentication_status__success
|
||||
#: model:ir.model.fields.selection,name:cetmix_tower_webhook.selection__cx_tower_webhook_log__code_status__success
|
||||
msgid "Success"
|
||||
msgstr "Successo"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__code
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__code
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__code
|
||||
msgid "This field will be rendered using variables"
|
||||
msgstr "Questo campo verrà visualizzato utilizzando le variabili"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__trusted_proxy_ips
|
||||
msgid "Trusted Proxy IPs"
|
||||
msgstr "IP proxy autorizzati"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_search
|
||||
msgid "User"
|
||||
msgstr "Utente"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__user_id
|
||||
msgid "User as which the webhook code was executed (if set)."
|
||||
msgstr "Utente per conto del quale è stato eseguito il codice webhook (se impostato)."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__variable_ids
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__variable_ids
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__variable_ids
|
||||
msgid "Variables"
|
||||
msgstr "Variabili"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_ids
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Webhook"
|
||||
msgstr "Webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_authenticator
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_authenticator_ids
|
||||
msgid "Webhook Authenticator"
|
||||
msgstr "Autenticatore webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_authenticator_ids_count
|
||||
msgid "Webhook Authenticator Count"
|
||||
msgstr "Conteggio autenticatore webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_authenticator_action
|
||||
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook_authenticator
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form
|
||||
msgid "Webhook Authenticators"
|
||||
msgstr "Autenticatori webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model,name:cetmix_tower_webhook.model_cx_tower_webhook_log
|
||||
msgid "Webhook Call Log"
|
||||
msgstr "Registro chiamata webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook_log
|
||||
msgid "Webhook Calls"
|
||||
msgstr "Chiamate webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_log__code_status
|
||||
msgid "Webhook Code Status"
|
||||
msgstr "Stato codice webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_variable__webhook_ids_count
|
||||
msgid "Webhook Count"
|
||||
msgstr "Conteggio webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_log_action
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_log_view_search
|
||||
msgid "Webhook Logs"
|
||||
msgstr "Registri webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook.py:0
|
||||
#, python-format
|
||||
msgid "Webhook code execution error: %(error)s"
|
||||
msgstr "Errore esecuzione codie webhook: %(error)s"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook__endpoint
|
||||
msgid "Webhook endpoint. The complete URL will be <your_tower_url>/cetmix_tower_webhooks/<endpoint>"
|
||||
msgstr "Endpoint del webhook. L'URL completo sarà <your_tower_url>/cetmix_tower_webhooks/<endpoint>"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,help:cetmix_tower_webhook.field_cx_tower_webhook_log__webhook_id
|
||||
msgid "Webhook that received the call."
|
||||
msgstr "Webhook che ha ricevuto la chiamata"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_webhook/models/cx_tower_webhook_eval_mixin.py:0
|
||||
#, python-format
|
||||
msgid "Webhook/Authenticator code error: result is not a dict"
|
||||
msgstr "Errore codice webhook/autenticatore: il risultato non è un dizionario"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_webhook.cx_tower_webhook_action
|
||||
#: model:ir.ui.menu,name:cetmix_tower_webhook.menu_cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_variable_view_form
|
||||
msgid "Webhooks"
|
||||
msgstr "Webhook"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "YAML"
|
||||
msgstr "YAML"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_authenticator__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_webhook.field_cx_tower_webhook_eval_mixin__yaml_code
|
||||
msgid "Yaml Code"
|
||||
msgstr "Codice YAML"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML"
|
||||
msgstr "Bisogna essere membro del gruppo \"YAML/Export\" per esportare i dati come YAML"
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_view_form
|
||||
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML."
|
||||
msgstr "Bisogna essere membro del gruppo \"YAML/Export\" per esportare i dati come YAML."
|
||||
|
||||
#. module: cetmix_tower_webhook
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_webhook.cx_tower_webhook_authenticator_view_form
|
||||
msgid "e.g.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e"
|
||||
msgstr "es.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e"
|
||||
|
||||
9
addons/cetmix_tower_webhook/models/__init__.py
Normal file
9
addons/cetmix_tower_webhook/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import cx_tower_webhook_eval_mixin
|
||||
from . import cx_tower_webhook_authenticator
|
||||
from . import cx_tower_webhook_log
|
||||
from . import cx_tower_webhook
|
||||
from . import cx_tower_variable
|
||||
from . import res_config_settings
|
||||
79
addons/cetmix_tower_webhook/models/constants.py
Normal file
79
addons/cetmix_tower_webhook/models/constants.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# flake8: noqa: E501
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import _
|
||||
|
||||
# Default Python code used in Webhook Authenticator
|
||||
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE = _(
|
||||
"""# Please refer to the 'Help' tab and documentation for more information.
|
||||
#
|
||||
# You can return authenticator result in the 'result' variable which is a dictionary:
|
||||
# result = {"allowed": <bool, mandatory, default=False>, "http_code": <int, optional>, "message": <str, optional>}
|
||||
# default value is {"allowed": False}
|
||||
"""
|
||||
)
|
||||
|
||||
# Default Python code help used in Webhook Authenticator
|
||||
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP = _(
|
||||
"""
|
||||
<h3>Help for Webhook Authenticator Python Code</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<p>
|
||||
The Python code for the webhook authenticator must return the <code>result</code> variable, which is a dictionary.<br>
|
||||
<strong>Allowed keys:</strong>
|
||||
<ul>
|
||||
<li><code>allowed</code> (<b>bool</b>, required): Authentication result. <code>True</code> if allowed, <code>False</code> otherwise.</li>
|
||||
<li><code>http_code</code> (<b>int</b>, optional): HTTP status code to return if authentication fails (default is 403).</li>
|
||||
<li><code>message</code> (<b>str</b>, optional): Error message to show to the client.</li>
|
||||
</ul>
|
||||
<strong>Examples:</strong>
|
||||
<pre style='background:#f7f7f7; padding:6px; border-radius:4px'>
|
||||
# Allow all requests
|
||||
result = {"allowed": True}
|
||||
|
||||
# Deny with custom code and message
|
||||
result = {"allowed": False, "http_code": 401, "message": "Unauthorized request"}
|
||||
</pre>
|
||||
</p>
|
||||
<strong>Available variables:</strong>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# Default Python code used in Webhook
|
||||
DEFAULT_WEBHOOK_CODE = _(
|
||||
"""# Please refer to the 'Help' tab and documentation for more information.
|
||||
#
|
||||
# You can return webhook result in the 'result' variable which is a dictionary:
|
||||
# result = {"exit_code": <int, default=0>, "message": <string, default=None}
|
||||
# default value is {"exit_code": 0, "message": None}
|
||||
"""
|
||||
)
|
||||
|
||||
# Default Python code help used in Webhook
|
||||
DEFAULT_WEBHOOK_CODE_HELP = _(
|
||||
"""
|
||||
<h3>Help for Webhook Python Code</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<p>
|
||||
The webhook Python code must set the <code>result</code> variable, which is a dictionary.<br>
|
||||
<strong>Allowed keys:</strong>
|
||||
<ul>
|
||||
<li><code>exit_code</code> (<b>int</b>, optional, default=0): Exit code (0 means success, other values indicate failure).</li>
|
||||
<li><code>message</code> (<b>str</b>, optional): Message to return in the HTTP response and log.</li>
|
||||
</ul>
|
||||
<strong>Example:</strong>
|
||||
<pre style='background:#f7f7f7; padding:6px; border-radius:4px'>
|
||||
# Simple successful result
|
||||
result = {"exit_code": 0, "message": "Webhook processed successfully"}
|
||||
|
||||
# Failure example
|
||||
result = {"exit_code": 1, "message": "Something went wrong"}
|
||||
</pre>
|
||||
</p>
|
||||
<strong>Available variables:</strong>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
85
addons/cetmix_tower_webhook/models/cx_tower_variable.py
Normal file
85
addons/cetmix_tower_webhook/models/cx_tower_variable.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class CxTowerVariable(models.Model):
|
||||
_inherit = "cx.tower.variable"
|
||||
|
||||
# --- Link to records where the variable is used
|
||||
webhook_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.webhook",
|
||||
relation="cx_tower_webhook_variable_rel",
|
||||
column1="variable_id",
|
||||
column2="webhook_id",
|
||||
copy=False,
|
||||
)
|
||||
webhook_ids_count = fields.Integer(
|
||||
string="Webhook Count", compute="_compute_webhook_ids_count"
|
||||
)
|
||||
webhook_authenticator_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.webhook.authenticator",
|
||||
relation="cx_tower_webhook_authenticator_variable_rel",
|
||||
column1="variable_id",
|
||||
column2="webhook_authenticator_id",
|
||||
copy=False,
|
||||
)
|
||||
webhook_authenticator_ids_count = fields.Integer(
|
||||
string="Webhook Authenticator Count", compute="_compute_webhook_ids_count"
|
||||
)
|
||||
|
||||
def _compute_webhook_ids_count(self):
|
||||
"""
|
||||
Count number of webhooks and webhook authenticators for the variable
|
||||
"""
|
||||
for rec in self:
|
||||
rec.update(
|
||||
{
|
||||
"webhook_ids_count": len(rec.webhook_ids),
|
||||
"webhook_authenticator_ids_count": len(
|
||||
rec.webhook_authenticator_ids
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def action_open_webhooks(self):
|
||||
"""Open the webhooks where the variable is used"""
|
||||
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"cetmix_tower_webhook.cx_tower_webhook_action"
|
||||
)
|
||||
action.update(
|
||||
{
|
||||
"domain": [("variable_ids", "in", self.ids)],
|
||||
}
|
||||
)
|
||||
return action
|
||||
|
||||
def action_open_webhook_authenticators(self):
|
||||
"""Open the webhook authenticators where the variable is used"""
|
||||
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"cetmix_tower_webhook.cx_tower_webhook_authenticator_action"
|
||||
)
|
||||
action.update(
|
||||
{
|
||||
"domain": [("variable_ids", "in", self.ids)],
|
||||
}
|
||||
)
|
||||
return action
|
||||
|
||||
def _get_propagation_field_mapping(self):
|
||||
"""
|
||||
Override to add webhook and webhook authenticator
|
||||
to the propagation field mapping.
|
||||
"""
|
||||
res = super()._get_propagation_field_mapping()
|
||||
res.update(
|
||||
{
|
||||
"cx.tower.webhook": ["code"],
|
||||
"cx.tower.webhook.authenticator": ["code"],
|
||||
}
|
||||
)
|
||||
return res
|
||||
217
addons/cetmix_tower_webhook/models/cx_tower_webhook.py
Normal file
217
addons/cetmix_tower_webhook/models/cx_tower_webhook.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import re
|
||||
|
||||
from odoo import SUPERUSER_ID, _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .constants import DEFAULT_WEBHOOK_CODE, DEFAULT_WEBHOOK_CODE_HELP
|
||||
|
||||
|
||||
class CxTowerWebhook(models.Model):
|
||||
_name = "cx.tower.webhook"
|
||||
_inherit = [
|
||||
"cx.tower.webhook.eval.mixin",
|
||||
]
|
||||
_description = "Webhook"
|
||||
|
||||
active = fields.Boolean(
|
||||
default=True,
|
||||
string="Enabled",
|
||||
)
|
||||
authenticator_id = fields.Many2one(
|
||||
comodel_name="cx.tower.webhook.authenticator",
|
||||
required=True,
|
||||
help="Select an Authenticator used for this webhook",
|
||||
)
|
||||
endpoint = fields.Char(
|
||||
required=True,
|
||||
copy=False,
|
||||
help="Webhook endpoint. The complete URL will be "
|
||||
"<your_tower_url>/cetmix_tower_webhooks/<endpoint>",
|
||||
)
|
||||
full_url = fields.Char(
|
||||
compute="_compute_full_url",
|
||||
help="Full URL of the webhook",
|
||||
)
|
||||
method = fields.Selection(
|
||||
[
|
||||
("post", "POST"),
|
||||
("get", "GET"),
|
||||
],
|
||||
default="post",
|
||||
required=True,
|
||||
help="Select the HTTP method for this webhook",
|
||||
)
|
||||
content_type = fields.Selection(
|
||||
[
|
||||
("json", "JSON"),
|
||||
("form", "Form URL-Encoded"),
|
||||
],
|
||||
string="Payload Type",
|
||||
default="json",
|
||||
required=True,
|
||||
help="How the payload is expected to be sent to this webhook: "
|
||||
"as JSON body or as URL-encoded form data",
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
comodel_name="res.users",
|
||||
string="Run as User",
|
||||
help="Select a user to run the webhook from behalf of. If not set, "
|
||||
"the webhook will run as the current user.\n"
|
||||
"CAREFUL! You must realise and understand what you are doing including "
|
||||
"all the possible consequences when selecting a specific user",
|
||||
default=SUPERUSER_ID,
|
||||
required=True,
|
||||
copy=False,
|
||||
)
|
||||
log_count = fields.Integer(
|
||||
compute="_compute_log_count",
|
||||
)
|
||||
variable_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.variable",
|
||||
relation="cx_tower_webhook_variable_rel",
|
||||
column1="webhook_id",
|
||||
column2="variable_id",
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"endpoint_method_uniq",
|
||||
"unique(endpoint, method)",
|
||||
"Endpoint and method must be unique!",
|
||||
),
|
||||
]
|
||||
|
||||
def _compute_log_count(self):
|
||||
"""Compute log count."""
|
||||
result = self.env["cx.tower.webhook.log"].read_group(
|
||||
domain=[("webhook_id", "in", self.ids)],
|
||||
fields=["webhook_id"],
|
||||
groupby=["webhook_id"],
|
||||
)
|
||||
mapped_data = {r["webhook_id"][0]: r["webhook_id_count"] for r in result}
|
||||
for rec in self:
|
||||
rec.log_count = mapped_data.get(rec.id, 0)
|
||||
|
||||
@api.depends("endpoint")
|
||||
def _compute_full_url(self):
|
||||
"""Compute full URL."""
|
||||
base_url = (
|
||||
self.env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param("web.base.url", "")
|
||||
.rstrip("/")
|
||||
)
|
||||
for rec in self:
|
||||
rec.full_url = f"{base_url}/cetmix_tower_webhooks/{rec.endpoint}"
|
||||
|
||||
@api.constrains("endpoint")
|
||||
def _check_endpoint_format(self):
|
||||
"""Validate endpoint format."""
|
||||
pattern = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9_/-]*[A-Za-z0-9])?$")
|
||||
for rec in self:
|
||||
if rec.endpoint and not pattern.fullmatch(rec.endpoint):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Endpoint must start and end with a letter or digit, "
|
||||
"and may contain underscores, dashes, and slashes in between"
|
||||
)
|
||||
)
|
||||
|
||||
def _default_eval_code(self):
|
||||
"""
|
||||
Returns the default code for the webhook.
|
||||
"""
|
||||
return DEFAULT_WEBHOOK_CODE
|
||||
|
||||
def _get_default_python_eval_code_help(self):
|
||||
"""
|
||||
Returns the default code help for the webhook.
|
||||
"""
|
||||
return DEFAULT_WEBHOOK_CODE_HELP
|
||||
|
||||
def _get_python_eval_odoo_objects(self, **kwargs):
|
||||
"""
|
||||
Override to add custom Odoo objects.
|
||||
"""
|
||||
res = {
|
||||
"headers": {
|
||||
"import": kwargs.get("headers"),
|
||||
"help": _("Dictionary of request headers"),
|
||||
},
|
||||
"payload": {
|
||||
"import": kwargs.get("payload"),
|
||||
"help": _(
|
||||
"Dictionary containing the request payload "
|
||||
"(JSON for POST, params for GET)"
|
||||
),
|
||||
},
|
||||
}
|
||||
res.update(super()._get_python_eval_odoo_objects(**kwargs))
|
||||
return res
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
"""Override to add fields to YAML export."""
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"active",
|
||||
"authenticator_id",
|
||||
"endpoint",
|
||||
"method",
|
||||
"code",
|
||||
"content_type",
|
||||
"variable_ids",
|
||||
"secret_ids",
|
||||
]
|
||||
return res
|
||||
|
||||
def execute(self, payload=None, raise_on_error=True, **kwargs):
|
||||
"""
|
||||
Run the webhook code and return a validated result.
|
||||
Handles errors and checks result format.
|
||||
|
||||
Args:
|
||||
payload (dict): The webhook payload. If not provided,
|
||||
the payload will be empty.
|
||||
raise_on_error (bool): Raise ValidationError on error if True.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'exit_code': <int>,
|
||||
'message': <str>
|
||||
}
|
||||
"""
|
||||
self.ensure_one()
|
||||
self_with_user = self.with_user(self.user_id)
|
||||
payload = payload or {}
|
||||
|
||||
try:
|
||||
result = self_with_user._run_webhook_eval_code(
|
||||
self_with_user.code,
|
||||
context_extra={"payload": payload, "headers": kwargs.get("headers")},
|
||||
default_result={"exit_code": 0, "message": None},
|
||||
)
|
||||
except Exception as e:
|
||||
if raise_on_error:
|
||||
raise ValidationError(
|
||||
_("Webhook code execution error: %(error)s", error=e)
|
||||
) from e
|
||||
result = {
|
||||
"exit_code": 1,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def action_view_logs(self):
|
||||
"""Open logs related to this webhook."""
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id(
|
||||
"cetmix_tower_webhook.cx_tower_webhook_log_action"
|
||||
)
|
||||
action["domain"] = [("webhook_id", "=", self.id)]
|
||||
return action
|
||||
@@ -0,0 +1,381 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
from .constants import (
|
||||
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE,
|
||||
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CxTowerWebhookAuthenticator(models.Model):
|
||||
_name = "cx.tower.webhook.authenticator"
|
||||
_inherit = [
|
||||
"cx.tower.webhook.eval.mixin",
|
||||
]
|
||||
_description = "Webhook Authenticator"
|
||||
|
||||
log_count = fields.Integer(
|
||||
compute="_compute_log_count",
|
||||
)
|
||||
allowed_ip_addresses = fields.Text(
|
||||
string="Allowed IPs",
|
||||
help="Comma-separated list of IP addresses and/or subnets "
|
||||
"(e.g. 192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e). " # noqa: E501
|
||||
"Requests from other addresses will be denied.",
|
||||
)
|
||||
trusted_proxy_ips = fields.Text(
|
||||
string="Trusted Proxy IPs",
|
||||
help="Comma-separated list of trusted proxy IP addresses or CIDR ranges "
|
||||
"(e.g., 10.0.0.1,192.168.1.0/24). "
|
||||
"Only these proxies can set X-Forwarded-For headers.",
|
||||
)
|
||||
variable_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.variable",
|
||||
relation="cx_tower_webhook_authenticator_variable_rel",
|
||||
column1="webhook_authenticator_id",
|
||||
column2="variable_id",
|
||||
)
|
||||
|
||||
@api.constrains("trusted_proxy_ips")
|
||||
def _check_trusted_proxy_ips(self):
|
||||
"""
|
||||
Validate 'trusted_proxy_ips' entries. Accepts single IPs and CIDR ranges
|
||||
(IPv4/IPv6). Empty value is allowed.
|
||||
"""
|
||||
for rec in self:
|
||||
invalid = self._validate_ip_token((rec.trusted_proxy_ips or "").strip())
|
||||
if invalid:
|
||||
raise ValidationError(_("Invalid trusted proxy entry: %s") % invalid)
|
||||
|
||||
@api.constrains("allowed_ip_addresses")
|
||||
def _check_allowed_ip_addresses(self):
|
||||
"""
|
||||
Validate 'allowed_ip_addresses' entries. Accepts single IPs and CIDR
|
||||
ranges (IPv4/IPv6). Empty value is allowed (means allow all).
|
||||
"""
|
||||
for rec in self:
|
||||
invalid = self._validate_ip_token((rec.allowed_ip_addresses or "").strip())
|
||||
if invalid:
|
||||
raise ValidationError(_("Invalid allowed IP/CIDR entry: %s") % invalid)
|
||||
|
||||
def _compute_log_count(self):
|
||||
"""Compute log count."""
|
||||
result = self.env["cx.tower.webhook.log"].read_group(
|
||||
domain=[("authenticator_id", "in", self.ids)],
|
||||
fields=["authenticator_id"],
|
||||
groupby=["authenticator_id"],
|
||||
)
|
||||
mapped_data = {
|
||||
r["authenticator_id"][0]: r["authenticator_id_count"] for r in result
|
||||
}
|
||||
for rec in self:
|
||||
rec.log_count = mapped_data.get(rec.id, 0)
|
||||
|
||||
def _default_eval_code(self):
|
||||
"""
|
||||
Return the default Python code for the webhook authenticator.
|
||||
|
||||
Returns:
|
||||
str: Default authenticator code.
|
||||
"""
|
||||
return DEFAULT_WEBHOOK_AUTHENTICATOR_CODE
|
||||
|
||||
def _get_default_python_eval_code_help(self):
|
||||
"""
|
||||
Return the default help text for the authenticator code.
|
||||
|
||||
Returns:
|
||||
str: Code help description.
|
||||
"""
|
||||
return DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP
|
||||
|
||||
def _get_python_eval_odoo_objects(self, **kwargs):
|
||||
"""
|
||||
Extend the Python evaluation context with custom Odoo objects.
|
||||
|
||||
Args:
|
||||
**kwargs: Extra context values, e.g.:
|
||||
- "headers": request headers (dict)
|
||||
- "raw_data": request body (bytes)
|
||||
- "payload": parsed request payload (dict)
|
||||
|
||||
Returns:
|
||||
dict: Mapping of variables available in evaluation context.
|
||||
"""
|
||||
res = {
|
||||
"headers": {
|
||||
"import": kwargs.get("headers"),
|
||||
"help": _("Dictionary of request headers"),
|
||||
},
|
||||
"raw_data": {
|
||||
"import": kwargs.get("raw_data"),
|
||||
"help": _("Raw body of the request (bytes)"),
|
||||
},
|
||||
"payload": {
|
||||
"import": kwargs.get("payload"),
|
||||
"help": _(
|
||||
"Dictionary containing the request payload "
|
||||
"(JSON for POST, params for GET)"
|
||||
),
|
||||
},
|
||||
}
|
||||
res.update(super()._get_python_eval_odoo_objects(**kwargs))
|
||||
return res
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
"""
|
||||
Extend fields available for YAML export.
|
||||
|
||||
Returns:
|
||||
list[str]: List of field names.
|
||||
"""
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"code",
|
||||
"allowed_ip_addresses",
|
||||
"trusted_proxy_ips",
|
||||
"variable_ids",
|
||||
"secret_ids",
|
||||
]
|
||||
return res
|
||||
|
||||
def authenticate(self, raise_on_error=True, **kwargs):
|
||||
"""
|
||||
Run the authenticator code and return result.
|
||||
|
||||
Args:
|
||||
raise_on_error (bool): Raise ValidationError on error if True.
|
||||
kwargs: Additional variables passed to the code context, e.g.:
|
||||
- "headers": request headers (dict)
|
||||
- "raw_data": request body (bytes)
|
||||
- "payload": parsed request payload (dict)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"allowed": <bool>,
|
||||
"http_code": <int, optional>,
|
||||
"message": <str, optional>,
|
||||
}
|
||||
"""
|
||||
self.ensure_one()
|
||||
try:
|
||||
result = self._run_webhook_eval_code(
|
||||
self.code,
|
||||
context_extra={
|
||||
"headers": kwargs.get("headers"),
|
||||
"raw_data": kwargs.get("raw_data"),
|
||||
"payload": kwargs.get("payload"),
|
||||
},
|
||||
default_result={"allowed": False},
|
||||
)
|
||||
except Exception as e:
|
||||
if raise_on_error:
|
||||
raise ValidationError(_("Authentication code error: %s") % e) from e
|
||||
result = {
|
||||
"allowed": False,
|
||||
"http_code": 500,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def action_view_logs(self):
|
||||
"""
|
||||
Open the action displaying logs related to this authenticator.
|
||||
|
||||
Returns:
|
||||
dict: Action dictionary for `ir.actions.act_window`.
|
||||
"""
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id(
|
||||
"cetmix_tower_webhook.cx_tower_webhook_log_action"
|
||||
)
|
||||
action["domain"] = [("authenticator_id", "=", self.id)]
|
||||
return action
|
||||
|
||||
def is_ip_allowed(self, remote_addr):
|
||||
"""
|
||||
Proxy-aware allowlist check.
|
||||
|
||||
Steps:
|
||||
1) Compute the effective client IP.
|
||||
2) If 'allowed_ip_addresses' is empty: allow everyone (backward compatible).
|
||||
3) Otherwise, allow only if the client IP belongs to any network in
|
||||
'allowed_ip_addresses'.
|
||||
|
||||
Args:
|
||||
remote_addr (str): Immediate TCP peer IP (controller-provided).
|
||||
|
||||
Returns:
|
||||
bool: True if client IP is allowed, False otherwise.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
client_ip = self._effective_client_ip(remote_addr)
|
||||
if not client_ip:
|
||||
return False
|
||||
|
||||
spec = (self.allowed_ip_addresses or "").strip()
|
||||
if not spec:
|
||||
return True
|
||||
|
||||
allowed_nets = self._parse_ip_list_to_networks(spec)
|
||||
if not allowed_nets:
|
||||
# Misconfigured allowlist: fail closed
|
||||
return False
|
||||
|
||||
return any(client_ip in net for net in allowed_nets)
|
||||
|
||||
def _effective_client_ip(self, remote_addr):
|
||||
"""
|
||||
Compute the effective client IP for the current HTTP request.
|
||||
|
||||
Security model:
|
||||
- The immediate TCP peer is 'remote_addr'
|
||||
(or request.httprequest.remote_addr).
|
||||
- X-Forwarded-For / X-Real-IP are honored ONLY if the immediate peer
|
||||
is within 'trusted_proxy_ips' (single IPs or CIDR ranges).
|
||||
- If not behind a trusted proxy, headers are ignored to prevent spoofing.
|
||||
|
||||
Args:
|
||||
remote_addr (str): Immediate TCP peer IP passed by the controller.
|
||||
|
||||
Returns:
|
||||
ipaddress.IPv4Address|ipaddress.IPv6Address|None:
|
||||
Effective client IP or None.
|
||||
"""
|
||||
immediate_peer = remote_addr or getattr(
|
||||
getattr(request, "httprequest", None), "remote_addr", None
|
||||
)
|
||||
if not immediate_peer:
|
||||
return None
|
||||
|
||||
try:
|
||||
immediate_ip = ipaddress.ip_address(immediate_peer)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
client_ip = immediate_ip # default to immediate peer
|
||||
trusted_nets = self._parse_ip_list_to_networks(
|
||||
(self.trusted_proxy_ips or "").strip()
|
||||
)
|
||||
headers = getattr(getattr(request, "httprequest", None), "headers", {}) or {}
|
||||
is_trusted_proxy = (
|
||||
any(immediate_ip in net for net in trusted_nets) if trusted_nets else False
|
||||
)
|
||||
|
||||
if is_trusted_proxy:
|
||||
candidate = self._extract_ip_from_header(headers.get("X-Forwarded-For"))
|
||||
if not candidate:
|
||||
candidate = self._extract_ip_from_header(headers.get("X-Real-IP"))
|
||||
if candidate:
|
||||
try:
|
||||
client_ip = ipaddress.ip_address(candidate)
|
||||
except ValueError:
|
||||
# Fall back to immediate peer if candidate is invalid.
|
||||
_logger.warning("Invalid IP/CIDR entry")
|
||||
|
||||
return client_ip
|
||||
|
||||
def _extract_ip_from_header(self, header_value):
|
||||
"""
|
||||
Extract the first valid IP from a proxy-provided header.
|
||||
|
||||
Behavior:
|
||||
- For X-Forwarded-For, the left-most entry is
|
||||
considered the original client IP.
|
||||
- For X-Real-IP, the value itself is considered.
|
||||
- Any non-IP tokens are skipped.
|
||||
|
||||
Args:
|
||||
header_value (str): Raw header value (may contain commas for XFF).
|
||||
|
||||
Returns:
|
||||
str|None: Compressed IPv4/IPv6 string, or None if nothing valid is found.
|
||||
"""
|
||||
if not header_value:
|
||||
return None
|
||||
|
||||
for token in header_value.split(","):
|
||||
ip_str = token.strip()
|
||||
if not ip_str:
|
||||
continue
|
||||
try:
|
||||
return ipaddress.ip_address(ip_str).compressed
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_ip_list_to_networks(spec):
|
||||
"""
|
||||
Convert a CSV of IPs/CIDRs into a list of ip_network objects.
|
||||
Single IPs are normalized to /32 (IPv4) or /128 (IPv6).
|
||||
|
||||
Args:
|
||||
spec (str): CSV of IPs/CIDRs.
|
||||
|
||||
Returns:
|
||||
list[ipaddress.IPv4Network|ipaddress.IPv6Network]
|
||||
"""
|
||||
nets = []
|
||||
if not spec:
|
||||
return nets
|
||||
for part in spec.split(","):
|
||||
s = (part or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
nets.append(ipaddress.ip_network(s, strict=False))
|
||||
continue
|
||||
except ValueError:
|
||||
_logger.warning(
|
||||
"Invalid IP/CIDR entry encountered in "
|
||||
"trusted_proxy_ips configuration."
|
||||
)
|
||||
try:
|
||||
ip = ipaddress.ip_address(s)
|
||||
nets.append(
|
||||
ipaddress.ip_network(
|
||||
ip.exploded + ("/32" if ip.version == 4 else "/128")
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
# Ignore invalid entries silently; validation is handled by constraints.
|
||||
continue
|
||||
return nets
|
||||
|
||||
def _validate_ip_token(self, spec):
|
||||
"""
|
||||
Return the first invalid token from a CSV of IPs/CIDRs,
|
||||
or None if all valid.
|
||||
Accepts single IPs and CIDR ranges (IPv4/IPv6).
|
||||
Empty/whitespace tokens are ignored.
|
||||
"""
|
||||
if not spec:
|
||||
return None
|
||||
for part in spec.split(","):
|
||||
s = (part or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
ipaddress.ip_network(s, strict=False)
|
||||
continue
|
||||
except ValueError:
|
||||
_logger.warning("Invalid IP/CIDR entry encountered")
|
||||
pass
|
||||
try:
|
||||
ipaddress.ip_address(s)
|
||||
except ValueError:
|
||||
return s
|
||||
return None
|
||||
@@ -0,0 +1,201 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
|
||||
class CxTowerWebhookEvalMixin(models.AbstractModel):
|
||||
_name = "cx.tower.webhook.eval.mixin"
|
||||
_inherit = [
|
||||
"cx.tower.template.mixin",
|
||||
"cx.tower.key.mixin",
|
||||
"cx.tower.yaml.mixin",
|
||||
"cx.tower.reference.mixin",
|
||||
]
|
||||
_description = "Eval context/code helper for Cetmix Tower Webhook"
|
||||
|
||||
code_help = fields.Html(
|
||||
compute="_compute_code_help",
|
||||
default=lambda self: self._default_eval_code_help(),
|
||||
compute_sudo=True,
|
||||
)
|
||||
code = fields.Text(
|
||||
default=lambda self: self._default_eval_code(),
|
||||
required=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_depends_fields(cls):
|
||||
"""Add code to the depends fields."""
|
||||
return ["code"]
|
||||
|
||||
def _compute_code_help(self):
|
||||
"""
|
||||
Compute code help
|
||||
"""
|
||||
self.code_help = self._default_eval_code_help()
|
||||
|
||||
def _default_eval_code_help(self):
|
||||
"""
|
||||
Return the default code help text for webhook or authenticator.
|
||||
|
||||
We use default because the computation method for this field
|
||||
would not be triggered before this record is saved. And we need
|
||||
to show the value instantly.
|
||||
|
||||
Returns:
|
||||
str: HTML-formatted help string containing available objects and libraries.
|
||||
"""
|
||||
available_libraries = self._get_python_eval_odoo_objects()
|
||||
available_libraries.update(self._get_python_eval_libraries())
|
||||
help_text_fragments = []
|
||||
for key, value in available_libraries.items():
|
||||
if key == "server":
|
||||
# Server is not available in the webhook/authenticator eval context
|
||||
continue
|
||||
help_text_fragments.append(f"<li><code>{key}</code>: {value['help']}</li>")
|
||||
|
||||
help_text = "<ul>" + "".join(help_text_fragments) + "</ul>"
|
||||
return f"{self._get_default_python_eval_code_help()}{help_text}"
|
||||
|
||||
def _get_python_eval_odoo_objects(self, **kwargs):
|
||||
"""
|
||||
Return Odoo objects available in the eval context.
|
||||
|
||||
Args:
|
||||
**kwargs: Optional context values.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of object names to their import values and help.
|
||||
"""
|
||||
return self.env["cx.tower.command"]._get_python_command_odoo_objects()
|
||||
|
||||
def _get_python_eval_libraries(self):
|
||||
"""
|
||||
Return Python libraries available in the eval context.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of library names to their import values and help.
|
||||
"""
|
||||
return self.env["cx.tower.command"]._get_python_command_libraries()
|
||||
|
||||
def _get_default_python_eval_code_help(self):
|
||||
"""
|
||||
Return the default help text for eval code.
|
||||
|
||||
Returns:
|
||||
str: Help text.
|
||||
"""
|
||||
return ""
|
||||
|
||||
def _default_eval_code(self):
|
||||
"""
|
||||
Return the default code for webhook or authenticator.
|
||||
|
||||
Returns:
|
||||
str: Default Python code.
|
||||
"""
|
||||
return ""
|
||||
|
||||
def _prepare_webhook_eval_context(self, context_extra=None, default_result=None):
|
||||
"""
|
||||
Build the evaluation context for webhook or authenticator
|
||||
safe_eval.
|
||||
|
||||
Args:
|
||||
context_extra (dict): Additional context variables
|
||||
(payload, headers, etc).
|
||||
default_result (dict): Default value for the 'result' variable.
|
||||
|
||||
Returns:
|
||||
dict: Prepared eval context.
|
||||
"""
|
||||
context_extra = context_extra or {}
|
||||
# Get the Odoo objects first
|
||||
imports = self._get_python_eval_odoo_objects(**context_extra)
|
||||
|
||||
# Update with the libraries
|
||||
imports.update(self._get_python_eval_libraries())
|
||||
eval_context = {key: value["import"] for key, value in imports.items()}
|
||||
|
||||
# Remove server from eval context
|
||||
eval_context.pop("server", None)
|
||||
|
||||
# Set default result
|
||||
default_result = default_result or {}
|
||||
eval_context["result"] = default_result.copy()
|
||||
|
||||
return eval_context
|
||||
|
||||
def _run_webhook_eval_code(self, code, **kwargs):
|
||||
"""
|
||||
Helper to execute user code safely. Returns the 'result' variable from context.
|
||||
|
||||
Args:
|
||||
code (str): User code to run
|
||||
kwargs:
|
||||
key (dict): Extra keys for secret parser
|
||||
context_extra (dict): Extra context variables (payload, headers, etc)
|
||||
default_result (dict): Default value for the 'result' variable
|
||||
|
||||
Returns:
|
||||
dict: The 'result' variable from context
|
||||
"""
|
||||
eval_context = self._prepare_webhook_eval_context(**kwargs)
|
||||
|
||||
if not code:
|
||||
# if code is empty, return the default result
|
||||
return eval_context["result"]
|
||||
|
||||
# prepare the code for evaluation
|
||||
code_and_secrets = self.env["cx.tower.key"]._parse_code_and_return_key_values(
|
||||
code, pythonic_mode=True, **kwargs.get("key", {})
|
||||
)
|
||||
secrets = code_and_secrets.get("key_values")
|
||||
webhook_code = code_and_secrets["code"]
|
||||
|
||||
code = self.env["cx.tower.key"]._parse_code(
|
||||
webhook_code, pythonic_mode=True, **kwargs.get("key", {})
|
||||
)
|
||||
|
||||
# execute user code
|
||||
safe_eval(
|
||||
code,
|
||||
eval_context,
|
||||
mode="exec",
|
||||
nocopy=True,
|
||||
)
|
||||
result = eval_context["result"]
|
||||
return self._parse_eval_code_result(result, secrets=secrets, **kwargs)
|
||||
|
||||
def _parse_eval_code_result(self, result, secrets=None, **kwargs):
|
||||
"""
|
||||
Post-processes the result returned from webhook/authenticator eval code.
|
||||
|
||||
If 'secrets' are provided, all occurrences of secret values in the
|
||||
'message' field of result will be replaced with a spoiler string to
|
||||
prevent sensitive information leakage.
|
||||
|
||||
Args:
|
||||
result (dict): The dict returned from the executed eval code,
|
||||
expected to have at least a 'message' key.
|
||||
secrets (dict, optional): A mapping of secret key-value
|
||||
pairs used for replacement in 'message'.
|
||||
|
||||
Returns:
|
||||
dict: The processed result with secrets masked in the 'message'
|
||||
field, if applicable.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
raise ValidationError(
|
||||
_("Webhook/Authenticator code error: result is not a dict")
|
||||
)
|
||||
|
||||
if secrets and isinstance(result.get("message"), str):
|
||||
result["message"] = self.env["cx.tower.key"]._replace_with_spoiler(
|
||||
result["message"], secrets
|
||||
)
|
||||
|
||||
return result
|
||||
195
addons/cetmix_tower_webhook/models/cx_tower_webhook_log.py
Normal file
195
addons/cetmix_tower_webhook/models/cx_tower_webhook_log.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class CxTowerWebhookLog(models.Model):
|
||||
_name = "cx.tower.webhook.log"
|
||||
_description = "Webhook Call Log"
|
||||
_order = "create_date desc"
|
||||
_rec_name = "display_name"
|
||||
|
||||
webhook_id = fields.Many2one(
|
||||
comodel_name="cx.tower.webhook",
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
help="Webhook that received the call.",
|
||||
)
|
||||
endpoint = fields.Char(
|
||||
readonly=True,
|
||||
)
|
||||
authenticator_id = fields.Many2one(
|
||||
comodel_name="cx.tower.webhook.authenticator",
|
||||
readonly=True,
|
||||
)
|
||||
request_method = fields.Selection(
|
||||
[
|
||||
("post", "POST"),
|
||||
("get", "GET"),
|
||||
],
|
||||
default="post",
|
||||
required=True,
|
||||
help="Select the HTTP method for this webhook.",
|
||||
)
|
||||
request_headers = fields.Text(
|
||||
help="Headers of the received HTTP request (JSON-encoded).",
|
||||
)
|
||||
request_payload = fields.Text(
|
||||
help="Payload/body of the received HTTP request (JSON-encoded).",
|
||||
)
|
||||
authentication_status = fields.Selection(
|
||||
[
|
||||
("success", "Success"),
|
||||
("failed", "Failed"),
|
||||
("not_required", "Not Required"),
|
||||
],
|
||||
required=True,
|
||||
default="failed",
|
||||
help="Result of authentication for this webhook call.",
|
||||
)
|
||||
code_status = fields.Selection(
|
||||
[
|
||||
("success", "Success"),
|
||||
("failed", "Failed"),
|
||||
("skipped", "Skipped"),
|
||||
],
|
||||
string="Webhook Code Status",
|
||||
required=True,
|
||||
default="skipped",
|
||||
help="Result of webhook code execution.",
|
||||
)
|
||||
http_status = fields.Integer(
|
||||
string="HTTP Status",
|
||||
help="HTTP status code returned to the client.",
|
||||
)
|
||||
result_message = fields.Text(
|
||||
help="Message returned by the webhook code or authenticator (if any).",
|
||||
)
|
||||
error_message = fields.Text(
|
||||
help="Error message in case of authentication or code failure.",
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
comodel_name="res.users",
|
||||
string="Run as User",
|
||||
help="User as which the webhook code was executed (if set).",
|
||||
)
|
||||
ip_address = fields.Char(
|
||||
string="IP Address",
|
||||
help="IP address of the client that made the request.",
|
||||
)
|
||||
country_id = fields.Many2one(
|
||||
comodel_name="res.country",
|
||||
help="Country of the client that made the request.",
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute="_compute_display_name",
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
@api.depends("webhook_id", "endpoint", "http_status")
|
||||
def _compute_display_name(self):
|
||||
"""Compute display name."""
|
||||
for rec in self:
|
||||
rec.display_name = (
|
||||
f"{rec.webhook_id.display_name or ''} ({rec.endpoint}) "
|
||||
f"[{rec.http_status or ''}]"
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_country_id(self):
|
||||
"""
|
||||
Return the country ID of the client based on geoip information.
|
||||
|
||||
Returns:
|
||||
int | bool: Country ID if found, otherwise False.
|
||||
"""
|
||||
country_code = None
|
||||
if request and hasattr(request, "geoip") and request.geoip:
|
||||
country_code = request.geoip.get("country_code")
|
||||
if country_code:
|
||||
country = (
|
||||
self.env["res.country"]
|
||||
.sudo()
|
||||
.search([("code", "=", country_code)], limit=1)
|
||||
)
|
||||
if country:
|
||||
return country.id
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _get_ip_address(self):
|
||||
"""
|
||||
Return the IP address of the client making the request.
|
||||
|
||||
Returns:
|
||||
str | None: IP address string, or None if unavailable.
|
||||
"""
|
||||
if not request:
|
||||
return None
|
||||
# Check for forwarded IP (common proxy headers)
|
||||
forwarded_for = request.httprequest.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
# Return the first IP in the chain
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
return request.httprequest.remote_addr
|
||||
|
||||
@api.model
|
||||
def create_from_call(self, **kwargs):
|
||||
"""
|
||||
Create a log entry from webhook call parameters.
|
||||
|
||||
Args:
|
||||
**kwargs: Values passed to `_prepare_values`.
|
||||
|
||||
Returns:
|
||||
CxTowerWebhookLog: Newly created log record.
|
||||
"""
|
||||
values = self._prepare_values(**kwargs)
|
||||
return self.create(values)
|
||||
|
||||
@api.model
|
||||
def _prepare_values(self, webhook=None, **kwargs):
|
||||
"""
|
||||
Prepare values for creating a webhook log record.
|
||||
|
||||
Args:
|
||||
webhook (RecordSet, optional): Webhook record.
|
||||
**kwargs: Additional fields such as endpoint, request_method, etc.
|
||||
|
||||
Returns:
|
||||
dict: Prepared values for log creation.
|
||||
"""
|
||||
vals = {
|
||||
"webhook_id": webhook.id if webhook else None,
|
||||
"endpoint": webhook.endpoint if webhook else kwargs.get("endpoint"),
|
||||
"authenticator_id": webhook.authenticator_id.id if webhook else None,
|
||||
"request_method": webhook.method
|
||||
if webhook
|
||||
else kwargs.get("request_method"),
|
||||
"user_id": webhook.user_id.id if webhook else None,
|
||||
"ip_address": self._get_ip_address(),
|
||||
"country_id": self._get_country_id(),
|
||||
**kwargs,
|
||||
}
|
||||
return vals
|
||||
|
||||
@api.autovacuum
|
||||
def _gc_delete_old_logs(self):
|
||||
"""
|
||||
Remove old webhook log records beyond configured retention period.
|
||||
|
||||
This method is automatically triggered by Odoo's autovacuum.
|
||||
"""
|
||||
days = int(
|
||||
self.env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param("cetmix_tower_webhook.webhook_log_duration", 30)
|
||||
)
|
||||
cutoff = fields.Datetime.now() - timedelta(days=days)
|
||||
logs_to_delete = self.sudo().search([("create_date", "<", cutoff)])
|
||||
logs_to_delete.unlink()
|
||||
13
addons/cetmix_tower_webhook/models/res_config_settings.py
Normal file
13
addons/cetmix_tower_webhook/models/res_config_settings.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = "res.config.settings"
|
||||
|
||||
cetmix_tower_webhook_log_duration = fields.Integer(
|
||||
string="Keep Webhook Logs for (days)",
|
||||
help="Set the number of days to keep webhook logs. "
|
||||
"Old logs will be deleted automatically.",
|
||||
default=30,
|
||||
config_parameter="cetmix_tower_webhook.webhook_log_duration",
|
||||
)
|
||||
3
addons/cetmix_tower_webhook/pyproject.toml
Normal file
3
addons/cetmix_tower_webhook/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
||||
58
addons/cetmix_tower_webhook/readme/CONFIGURE.md
Normal file
58
addons/cetmix_tower_webhook/readme/CONFIGURE.md
Normal file
@@ -0,0 +1,58 @@
|
||||
## Configure an Authenticator
|
||||
|
||||
**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to configure authenticators.**
|
||||
|
||||
- Go to "Cetmix Tower > Settings > Automation > Webhook Authenticators" and click "New".
|
||||
|
||||
**Complete the following fields:**
|
||||
|
||||
- Name. Authenticator name
|
||||
- Reference. Unique reference. Leave this field blank to auto generate it
|
||||
- Code. Code that is used to authenticate the request. You can use all Cetmix Tower - Python command variables except for the server plus the following webhook specific ones:
|
||||
- headers: dictionary that contains the request headers
|
||||
- raw_data: string with the raw HTTP request body
|
||||
- payload: dictionary that contains the JSON payload or the GET parameters of the request
|
||||
|
||||
**The code returns the result variable in the following format:**
|
||||
|
||||
```python
|
||||
result = {"allowed": <bool, mandatory, default=False>, "http_code": <int, optional>, "message": <str, optional>}
|
||||
```
|
||||
|
||||
eg:
|
||||
|
||||
```python
|
||||
result = {"allowed": True}
|
||||
result = {"allowed": False, "http_code": 403, "message": "Sorry..."}
|
||||
```
|
||||
|
||||
## Configure a Webhook
|
||||
|
||||
**⚠️ WARNING: You must be a member of the "Cetmix Tower/Root" group to configure webhooks.**
|
||||
|
||||
- Go to "Cetmix Tower > Settings > Automation > Webhooks" and click "New".
|
||||
|
||||
**Complete the following fields:**
|
||||
|
||||
- Enabled. Uncheck this field to disable the webhook without deleting it
|
||||
- Name. Authenticator name
|
||||
- Reference. Unique reference. Leave this field blank to auto generate it
|
||||
- Authenticator. Select an Authenticator used for this webhook
|
||||
- Endpoint. Webhook andpoint. The complete webhook URL will be <your_tower_url>/cetmix_tower_webhooks/<endpoint>
|
||||
- Run as User. Select a user to run the webhook on behalf of. CAREFUL! You must realize and understand what you are doing, including all the possible consequences when selecting a specific user.
|
||||
- Code. Code that processes the request. You can use all Cetmix Tower Python command variables (except for the server) plus the following webhook-specific one:
|
||||
- headers: dictionary that contains the request headers
|
||||
- payload: dictionary that contains the JSON payload or the GET parameters of the request
|
||||
|
||||
Webhook code returns a result using the Cetmix Tower Python command pattern:
|
||||
|
||||
```python
|
||||
result = {"exit_code": <int, default=0>, "message": <string, default=None}
|
||||
```
|
||||
|
||||
**To configure the time for which the webhook call logs are stored:**
|
||||
|
||||
- Go to "Cetmix Tower > Settings > General Settings"
|
||||
- Put a number of days into the "Keep Webhook Logs for (days)" field. Default value is 30.
|
||||
|
||||
Please refer to the [official documentation](https://tower.cetmix.com) for detailed configuration instructions.
|
||||
2
addons/cetmix_tower_webhook/readme/CONTEXT.md
Normal file
2
addons/cetmix_tower_webhook/readme/CONTEXT.md
Normal file
@@ -0,0 +1,2 @@
|
||||
Although Odoo has native support of webhooks staring 17.0, they still have some limitations.
|
||||
Another option is the OCA 'endpoint' module which although is more flexible still makes it usable with Cetmix Tower more complicated.
|
||||
5
addons/cetmix_tower_webhook/readme/DESCRIPTION.md
Normal file
5
addons/cetmix_tower_webhook/readme/DESCRIPTION.md
Normal file
@@ -0,0 +1,5 @@
|
||||
This module implements incoming webhooks for [Cetmix Tower](https://tower.cetmix.com). Webhooks are authorised using customisable authenticators which can be pre-configured and reused across multiple webhooks. Webhooks and authenticators can be exported and imported using YAML format, which makes them easily sharable.
|
||||
|
||||
This module is a part of Cetmix Tower, however it can be used to manage any other odoo applications.
|
||||
|
||||
Please refer to the [official documentation](https://tower.cetmix.com) for detailed information.
|
||||
13
addons/cetmix_tower_webhook/readme/HISTORY.md
Normal file
13
addons/cetmix_tower_webhook/readme/HISTORY.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## 16.0.1.0.4 (2025-12-11)
|
||||
|
||||
- Features: Improve search views, implement the search panel for selected views. (5139)
|
||||
|
||||
|
||||
## 16.0.1.0.3 (2025-10-21)
|
||||
|
||||
- Features: Use native functions to convert payload to dict (5024)
|
||||
|
||||
|
||||
## 16.0.1.0.2 (2025-10-06)
|
||||
|
||||
- Bugfixes: Export related variables and secrets (4980)
|
||||
3
addons/cetmix_tower_webhook/readme/USAGE.md
Normal file
3
addons/cetmix_tower_webhook/readme/USAGE.md
Normal file
@@ -0,0 +1,3 @@
|
||||
When a request is received, Cetmix Tower will search for the webhook with the matching endpoint and authenticate the request using the selected authenticator. In case of successful authentication webhook code is run. Each webhook call is logged. Logs are available under the "Cetmix Tower > Logs > Webhook Calls" menu or under the "Logs" button directly in the Webhook.
|
||||
|
||||
Please refer to the [official documentation](https://tower.cetmix.com) for detailed usage instructions.
|
||||
4
addons/cetmix_tower_webhook/security/ir.model.access.csv
Normal file
4
addons/cetmix_tower_webhook/security/ir.model.access.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_cx_tower_webhook,Tower Webhook,model_cx_tower_webhook,cetmix_tower_server.group_root,1,1,1,1
|
||||
access_cx_tower_webhook_authenticator,Tower Webhook Authenticator,model_cx_tower_webhook_authenticator,cetmix_tower_server.group_root,1,1,1,1
|
||||
access_cx_tower_webhook_log,Tower Webhook Log,model_cx_tower_webhook_log,cetmix_tower_server.group_root,1,0,0,0
|
||||
|
BIN
addons/cetmix_tower_webhook/static/description/banner.png
Normal file
BIN
addons/cetmix_tower_webhook/static/description/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
addons/cetmix_tower_webhook/static/description/icon.png
Normal file
BIN
addons/cetmix_tower_webhook/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
549
addons/cetmix_tower_webhook/static/description/index.html
Normal file
549
addons/cetmix_tower_webhook/static/description/index.html
Normal file
@@ -0,0 +1,549 @@
|
||||
<!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 Webhook</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-webhook">
|
||||
<h1 class="title">Cetmix Tower Webhook</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:6b28bb3bec0ce3e160c08d87fdf2735a4ca2fc271dbf3e361152240f0f02437c
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<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_webhook"><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 incoming webhooks for <a class="reference external" href="https://tower.cetmix.com">Cetmix
|
||||
Tower</a>. Webhooks are authorised using
|
||||
customisable authenticators which can be pre-configured and reused
|
||||
across multiple webhooks. Webhooks and authenticators can be exported
|
||||
and imported using YAML format, which makes them easily sharable.</p>
|
||||
<p>This module is a part of Cetmix Tower, however it can be used to manage
|
||||
any other odoo applications.</p>
|
||||
<p>Please refer to the <a class="reference external" href="https://tower.cetmix.com">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="#use-cases-context" id="toc-entry-1">Use Cases / Context</a></li>
|
||||
<li><a class="reference internal" href="#configuration" id="toc-entry-2">Configuration</a><ul>
|
||||
<li><a class="reference internal" href="#configure-an-authenticator" id="toc-entry-3">Configure an Authenticator</a></li>
|
||||
<li><a class="reference internal" href="#configure-a-webhook" id="toc-entry-4">Configure a Webhook</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference internal" href="#usage" id="toc-entry-5">Usage</a></li>
|
||||
<li><a class="reference internal" href="#changelog" id="toc-entry-6">Changelog</a><ul>
|
||||
<li><a class="reference internal" href="#section-1" id="toc-entry-7">16.0.1.0.4 (2025-12-11)</a></li>
|
||||
<li><a class="reference internal" href="#section-2" id="toc-entry-8">16.0.1.0.3 (2025-10-21)</a></li>
|
||||
<li><a class="reference internal" href="#section-3" id="toc-entry-9">16.0.1.0.2 (2025-10-06)</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-10">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="toc-entry-11">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="toc-entry-12">Authors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="toc-entry-13">Maintainers</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="use-cases-context">
|
||||
<h1><a class="toc-backref" href="#toc-entry-1">Use Cases / Context</a></h1>
|
||||
<p>Although Odoo has native support of webhooks staring 17.0, they still
|
||||
have some limitations. Another option is the OCA ‘endpoint’ module which
|
||||
although is more flexible still makes it usable with Cetmix Tower more
|
||||
complicated.</p>
|
||||
</div>
|
||||
<div class="section" id="configuration">
|
||||
<h1><a class="toc-backref" href="#toc-entry-2">Configuration</a></h1>
|
||||
<div class="section" id="configure-an-authenticator">
|
||||
<h2><a class="toc-backref" href="#toc-entry-3">Configure an Authenticator</a></h2>
|
||||
<p><strong>⚠️ WARNING: You must be a member of the “Cetmix Tower/Root” group to
|
||||
configure authenticators.</strong></p>
|
||||
<ul class="simple">
|
||||
<li>Go to “Cetmix Tower > Settings > Automation > Webhook Authenticators”
|
||||
and click “New”.</li>
|
||||
</ul>
|
||||
<p><strong>Complete the following fields:</strong></p>
|
||||
<ul class="simple">
|
||||
<li>Name. Authenticator name</li>
|
||||
<li>Reference. Unique reference. Leave this field blank to auto generate
|
||||
it</li>
|
||||
<li>Code. Code that is used to authenticate the request. You can use all
|
||||
Cetmix Tower - Python command variables except for the server plus the
|
||||
following webhook specific ones:</li>
|
||||
<li>headers: dictionary that contains the request headers</li>
|
||||
<li>raw_data: string with the raw HTTP request body</li>
|
||||
<li>payload: dictionary that contains the JSON payload or the GET
|
||||
parameters of the request</li>
|
||||
</ul>
|
||||
<p><strong>The code returns the result variable in the following format:</strong></p>
|
||||
<pre class="code python literal-block">
|
||||
<span class="n">result</span> <span class="o">=</span> <span class="p">{</span><span class="s2">"allowed"</span><span class="p">:</span> <span class="o"><</span><span class="nb">bool</span><span class="p">,</span> <span class="n">mandatory</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="kc">False</span><span class="o">></span><span class="p">,</span> <span class="s2">"http_code"</span><span class="p">:</span> <span class="o"><</span><span class="nb">int</span><span class="p">,</span> <span class="n">optional</span><span class="o">></span><span class="p">,</span> <span class="s2">"message"</span><span class="p">:</span> <span class="o"><</span><span class="nb">str</span><span class="p">,</span> <span class="n">optional</span><span class="o">></span><span class="p">}</span>
|
||||
</pre>
|
||||
<p>eg:</p>
|
||||
<pre class="code python literal-block">
|
||||
<span class="n">result</span> <span class="o">=</span> <span class="p">{</span><span class="s2">"allowed"</span><span class="p">:</span> <span class="kc">True</span><span class="p">}</span><span class="w">
|
||||
</span><span class="n">result</span> <span class="o">=</span> <span class="p">{</span><span class="s2">"allowed"</span><span class="p">:</span> <span class="kc">False</span><span class="p">,</span> <span class="s2">"http_code"</span><span class="p">:</span> <span class="mi">403</span><span class="p">,</span> <span class="s2">"message"</span><span class="p">:</span> <span class="s2">"Sorry..."</span><span class="p">}</span>
|
||||
</pre>
|
||||
</div>
|
||||
<div class="section" id="configure-a-webhook">
|
||||
<h2><a class="toc-backref" href="#toc-entry-4">Configure a Webhook</a></h2>
|
||||
<p><strong>⚠️ WARNING: You must be a member of the “Cetmix Tower/Root” group to
|
||||
configure webhooks.</strong></p>
|
||||
<ul class="simple">
|
||||
<li>Go to “Cetmix Tower > Settings > Automation > Webhooks” and click
|
||||
“New”.</li>
|
||||
</ul>
|
||||
<p><strong>Complete the following fields:</strong></p>
|
||||
<ul class="simple">
|
||||
<li>Enabled. Uncheck this field to disable the webhook without deleting it</li>
|
||||
<li>Name. Authenticator name</li>
|
||||
<li>Reference. Unique reference. Leave this field blank to auto generate
|
||||
it</li>
|
||||
<li>Authenticator. Select an Authenticator used for this webhook</li>
|
||||
<li>Endpoint. Webhook andpoint. The complete webhook URL will be
|
||||
<your_tower_url>/cetmix_tower_webhooks/</li>
|
||||
<li>Run as User. Select a user to run the webhook on behalf of. CAREFUL!
|
||||
You must realize and understand what you are doing, including all the
|
||||
possible consequences when selecting a specific user.</li>
|
||||
<li>Code. Code that processes the request. You can use all Cetmix Tower
|
||||
Python command variables (except for the server) plus the following
|
||||
webhook-specific one:<ul>
|
||||
<li>headers: dictionary that contains the request headers</li>
|
||||
<li>payload: dictionary that contains the JSON payload or the GET
|
||||
parameters of the request</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<p>Webhook code returns a result using the Cetmix Tower Python command
|
||||
pattern:</p>
|
||||
<pre class="code python literal-block">
|
||||
<span class="n">result</span> <span class="o">=</span> <span class="p">{</span><span class="s2">"exit_code"</span><span class="p">:</span> <span class="o"><</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="o">></span><span class="p">,</span> <span class="s2">"message"</span><span class="p">:</span> <span class="o"><</span><span class="n">string</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="kc">None</span><span class="p">}</span>
|
||||
</pre>
|
||||
<p><strong>To configure the time for which the webhook call logs are stored:</strong></p>
|
||||
<ul class="simple">
|
||||
<li>Go to “Cetmix Tower > Settings > General Settings”</li>
|
||||
<li>Put a number of days into the “Keep Webhook Logs for (days)” field.
|
||||
Default value is 30.</li>
|
||||
</ul>
|
||||
<p>Please refer to the <a class="reference external" href="https://tower.cetmix.com">official
|
||||
documentation</a> for detailed configuration
|
||||
instructions.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="usage">
|
||||
<h1><a class="toc-backref" href="#toc-entry-5">Usage</a></h1>
|
||||
<p>When a request is received, Cetmix Tower will search for the webhook
|
||||
with the matching endpoint and authenticate the request using the
|
||||
selected authenticator. In case of successful authentication webhook
|
||||
code is run. Each webhook call is logged. Logs are available under the
|
||||
“Cetmix Tower > Logs > Webhook Calls” menu or under the “Logs” button
|
||||
directly in the Webhook.</p>
|
||||
<p>Please refer to the <a class="reference external" href="https://tower.cetmix.com">official
|
||||
documentation</a> for detailed usage
|
||||
instructions.</p>
|
||||
</div>
|
||||
<div class="section" id="changelog">
|
||||
<h1><a class="toc-backref" href="#toc-entry-6">Changelog</a></h1>
|
||||
<div class="section" id="section-1">
|
||||
<h2><a class="toc-backref" href="#toc-entry-7">16.0.1.0.4 (2025-12-11)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Improve search views, implement the search panel for
|
||||
selected views. (5139)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-2">
|
||||
<h2><a class="toc-backref" href="#toc-entry-8">16.0.1.0.3 (2025-10-21)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Use native functions to convert payload to dict (5024)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-3">
|
||||
<h2><a class="toc-backref" href="#toc-entry-9">16.0.1.0.2 (2025-10-06)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Export related variables and secrets (4980)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h1><a class="toc-backref" href="#toc-entry-10">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_webhook%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-11">Credits</a></h1>
|
||||
<div class="section" id="authors">
|
||||
<h2><a class="toc-backref" href="#toc-entry-12">Authors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Cetmix</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2><a class="toc-backref" href="#toc-entry-13">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_webhook">cetmix/cetmix-tower</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
7
addons/cetmix_tower_webhook/tests/__init__.py
Normal file
7
addons/cetmix_tower_webhook/tests/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import test_cx_tower_webhook_authenticator
|
||||
from . import test_cx_tower_webhook_log
|
||||
from . import test_cx_tower_webhook
|
||||
from . import test_webhook_controller
|
||||
38
addons/cetmix_tower_webhook/tests/common.py
Normal file
38
addons/cetmix_tower_webhook/tests/common.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
|
||||
class CetmixTowerWebhookCommon(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Set base url for correct link generation
|
||||
self.web_base_url = "https://example.com"
|
||||
self.env["ir.config_parameter"].sudo().set_param(
|
||||
"web.base.url", self.web_base_url
|
||||
)
|
||||
|
||||
# Create simple authenticator that allows all requests
|
||||
self.WebhookAuthenticator = self.env["cx.tower.webhook.authenticator"]
|
||||
self.simple_authenticator = self.WebhookAuthenticator.create(
|
||||
{
|
||||
"name": "Simple Authenticator",
|
||||
"code": "result = {'allowed': True, 'message': 'OK'}",
|
||||
}
|
||||
)
|
||||
|
||||
# Create Simple Webhook
|
||||
self.Webhook = self.env["cx.tower.webhook"]
|
||||
self.simple_webhook = self.Webhook.create(
|
||||
{
|
||||
"name": "Simple Webhook",
|
||||
"endpoint": "simple_webhook",
|
||||
"code": "result = {'exit_code': 0, 'message': 'OK'}",
|
||||
"authenticator_id": self.simple_authenticator.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Log model
|
||||
self.Log = self.env["cx.tower.webhook.log"]
|
||||
154
addons/cetmix_tower_webhook/tests/test_cx_tower_webhook.py
Normal file
154
addons/cetmix_tower_webhook/tests/test_cx_tower_webhook.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .common import CetmixTowerWebhookCommon
|
||||
|
||||
|
||||
class TestCetmixTowerWebhook(CetmixTowerWebhookCommon):
|
||||
def test_simple_webhook_success(self):
|
||||
"""
|
||||
Test that webhook is successful
|
||||
"""
|
||||
result = self.simple_webhook.execute(
|
||||
headers={}, payload={}, raw_data="", raise_on_error=False
|
||||
)
|
||||
self.assertEqual(result["exit_code"], 0)
|
||||
|
||||
def test_simple_webhook_without_optional_params(self):
|
||||
"""
|
||||
Test that webhook is successful without optional params
|
||||
"""
|
||||
result = self.simple_webhook.execute(raise_on_error=False)
|
||||
self.assertEqual(result["exit_code"], 0)
|
||||
|
||||
def test_webhook_code_custom_message(self):
|
||||
"""
|
||||
Test that custom message is returned from webhook code
|
||||
"""
|
||||
self.simple_webhook.write(
|
||||
{"code": "result = {'exit_code': 0, 'message': 'Webhook OK!'}"}
|
||||
)
|
||||
result = self.simple_webhook.execute(raise_on_error=False)
|
||||
self.assertEqual(result["exit_code"], 0)
|
||||
self.assertEqual(result["message"], "Webhook OK!")
|
||||
|
||||
def test_webhook_code_failure(self):
|
||||
"""
|
||||
Test that webhook returns error when code sets exit_code != 0
|
||||
"""
|
||||
self.simple_webhook.write(
|
||||
{"code": "result = {'exit_code': 42, 'message': 'Error occurred'}"}
|
||||
)
|
||||
result = self.simple_webhook.execute(raise_on_error=False)
|
||||
self.assertEqual(result["exit_code"], 42)
|
||||
self.assertEqual(result["message"], "Error occurred")
|
||||
|
||||
def test_webhook_code_raises_exception(self):
|
||||
"""
|
||||
Test that exception in webhook code is handled and returns exit_code 1
|
||||
"""
|
||||
self.simple_webhook.write({"code": "raise Exception('Webhook boom!')"})
|
||||
result = self.simple_webhook.execute(raise_on_error=False)
|
||||
self.assertEqual(result["exit_code"], 1)
|
||||
self.assertIn("Webhook boom!", result["message"])
|
||||
|
||||
def test_webhook_code_returns_non_dict(self):
|
||||
"""
|
||||
Test that webhook fails gracefully if code returns non-dict
|
||||
"""
|
||||
self.simple_webhook.write({"code": "result = 'not a dict'"})
|
||||
result = self.simple_webhook.execute(raise_on_error=False)
|
||||
self.assertEqual(result["exit_code"], 1)
|
||||
self.assertEqual(
|
||||
result["message"], "Webhook/Authenticator code error: result is not a dict"
|
||||
)
|
||||
|
||||
def test_webhook_execute_raises_exception(self):
|
||||
"""
|
||||
Test that webhook raises ValidationError if raise_on_error is True
|
||||
"""
|
||||
self.simple_webhook.write({"code": "raise Exception('Validation failed!')"})
|
||||
with self.assertRaises(ValidationError):
|
||||
self.simple_webhook.execute(raise_on_error=True)
|
||||
|
||||
def test_webhook_execute_with_payload(self):
|
||||
"""
|
||||
Test that webhook receives and processes payload correctly
|
||||
"""
|
||||
self.simple_webhook.write(
|
||||
{
|
||||
"code": "result = {'exit_code': 0, 'message': str(payload.get('key', 'none'))}" # noqa: E501
|
||||
}
|
||||
)
|
||||
payload = {"key": "value123"}
|
||||
result = self.simple_webhook.execute(payload=payload, raise_on_error=False)
|
||||
self.assertEqual(result["exit_code"], 0)
|
||||
self.assertEqual(result["message"], "value123")
|
||||
|
||||
def test_webhook_execute_with_user(self):
|
||||
"""
|
||||
Test that webhook executes as specified user
|
||||
"""
|
||||
test_user = self.env.ref("base.user_demo")
|
||||
self.simple_webhook.user_id = test_user
|
||||
self.simple_webhook.write(
|
||||
{"code": "result = {'exit_code': 0, 'message': user.login}"}
|
||||
)
|
||||
result = self.simple_webhook.execute(raise_on_error=False)
|
||||
self.assertEqual(result["message"], test_user.login)
|
||||
|
||||
def test_webhook_context_isolation(self):
|
||||
"""
|
||||
Test that only payload is available in eval context;
|
||||
extra kwargs are not accessible
|
||||
"""
|
||||
self.simple_webhook.write(
|
||||
{
|
||||
"code": (
|
||||
"fail = []\n"
|
||||
"for var in ['headers', 'raw_data', 'custom_param']:\n"
|
||||
" try:\n"
|
||||
" _ = eval(var)\n"
|
||||
" fail.append(var)\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
"if fail:\n"
|
||||
" result = {'exit_code': 99, 'message': 'Leaked vars: ' + ','.join(fail)}\n" # noqa: E501
|
||||
"else:\n"
|
||||
" result = {'exit_code': 0, 'message': 'Context clean'}\n"
|
||||
)
|
||||
}
|
||||
)
|
||||
result = self.simple_webhook.execute(
|
||||
payload={"key": "val"},
|
||||
headers={"x": "y"},
|
||||
raw_data="boom",
|
||||
custom_param="xxx",
|
||||
raise_on_error=False,
|
||||
)
|
||||
self.assertEqual(result["exit_code"], 0, result["message"])
|
||||
self.assertIn("Context clean", result["message"])
|
||||
|
||||
def test_webhook_execute_runs_as_user_id(self):
|
||||
"""
|
||||
Test that the webhook code is always executed as the specified user_id,
|
||||
regardless of the caller's user context or extra kwargs.
|
||||
"""
|
||||
# set specific user
|
||||
test_user = self.env.ref("base.user_demo")
|
||||
self.simple_webhook.user_id = test_user
|
||||
self.simple_webhook.write(
|
||||
{"code": "result = {'exit_code': 0, 'message': user.login}"}
|
||||
)
|
||||
|
||||
# run execute() with another user and try to pass user_id via kwargs
|
||||
other_user = self.env.ref("base.user_admin")
|
||||
result = self.simple_webhook.with_user(other_user).execute(
|
||||
payload={},
|
||||
user_id=self.env.ref("base.user_root").id, # try to pass own user_id
|
||||
raise_on_error=False,
|
||||
)
|
||||
# the result should be from user_demo anyway
|
||||
self.assertEqual(result["message"], test_user.login)
|
||||
@@ -0,0 +1,143 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .common import CetmixTowerWebhookCommon
|
||||
|
||||
|
||||
class TestCetmixTowerWebhookAuthenticator(CetmixTowerWebhookCommon):
|
||||
def test_simple_authentication_success(self):
|
||||
"""
|
||||
Test that authentication is successful
|
||||
"""
|
||||
# check that authentication is successful for authenticator
|
||||
# that allows all requests
|
||||
result = self.simple_authenticator.authenticate(
|
||||
headers={}, payload={}, raw_data=""
|
||||
)
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
def test_simple_authentication_without_optional_params(self):
|
||||
"""
|
||||
Test that authentication is successful without optional params
|
||||
"""
|
||||
result = self.simple_authenticator.authenticate()
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
def test_token_authentication_success(self):
|
||||
"""
|
||||
Test that authentication is successful for authenticator that allows requests
|
||||
with specific token in header
|
||||
"""
|
||||
auth_token_header = "X-Token"
|
||||
auth_token = "secret123"
|
||||
code = f"result = {{'allowed': headers.get('{auth_token_header}') == '{auth_token}'}}" # noqa: E501
|
||||
self.simple_authenticator.code = code
|
||||
result = self.simple_authenticator.authenticate(
|
||||
headers={auth_token_header: auth_token}
|
||||
)
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
def test_token_authentication_failure(self):
|
||||
"""
|
||||
Test that authentication is failed for authenticator that allows
|
||||
requests with specific token in header
|
||||
"""
|
||||
auth_token_header = "X-Token"
|
||||
auth_token = "secret123"
|
||||
code = f"result = {{'allowed': headers.get('{auth_token_header}') == '{auth_token}'}}" # noqa: E501
|
||||
self.simple_authenticator.code = code
|
||||
result = self.simple_authenticator.authenticate(
|
||||
headers={auth_token_header: "wrong_token"}, raise_on_error=False
|
||||
)
|
||||
self.assertFalse(result["allowed"])
|
||||
|
||||
def test_token_authentication_failure_without_optional_params(self):
|
||||
"""
|
||||
Test that authentication is failed without optional params
|
||||
"""
|
||||
auth_token_header = "X-Token"
|
||||
auth_token = "secret123"
|
||||
code = f"result = {{'allowed': headers.get('{auth_token_header}') == '{auth_token}'}}" # noqa: E501
|
||||
self.simple_authenticator.code = code
|
||||
result = self.simple_authenticator.authenticate(raise_on_error=False)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(result["http_code"], 500)
|
||||
self.assertIn("object has no attribute 'get'", result["message"])
|
||||
|
||||
def test_authentication_code_error(self):
|
||||
"""
|
||||
Test that authentication is failed with invalid code
|
||||
"""
|
||||
self.simple_authenticator.code = "1/0"
|
||||
result = self.simple_authenticator.authenticate(raise_on_error=False)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(result["http_code"], 500)
|
||||
self.assertEqual(result["message"], "division by zero")
|
||||
|
||||
# test with raise_on_error=True
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
self.simple_authenticator.authenticate()
|
||||
self.assertEqual(
|
||||
str(e.exception), "Authentication code error: division by zero"
|
||||
)
|
||||
|
||||
def test_authenticator_custom_http_code_and_message(self):
|
||||
"""
|
||||
Test that custom http_code and message returned from code are respected
|
||||
"""
|
||||
message = "I am a teapot!"
|
||||
self.simple_authenticator.code = (
|
||||
f"result = {{'allowed': False, 'http_code': 418, 'message': '{message}'}}"
|
||||
)
|
||||
result = self.simple_authenticator.authenticate(headers={})
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(result.get("http_code"), 418)
|
||||
self.assertEqual(result.get("message"), message)
|
||||
|
||||
def test_authenticator_returns_non_dict(self):
|
||||
"""
|
||||
Test that authentication fails if code returns non-dict result
|
||||
"""
|
||||
self.simple_authenticator.write({"code": "result = 'not a dict'"})
|
||||
result = self.simple_authenticator.authenticate(
|
||||
headers={}, raise_on_error=False
|
||||
)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(result["http_code"], 500)
|
||||
self.assertIn("result is not a dict", result["message"])
|
||||
|
||||
def test_authentication_with_raw_data(self):
|
||||
"""
|
||||
Test that authentication works with raw_data and without headers
|
||||
"""
|
||||
self.simple_authenticator.write(
|
||||
{"code": "result = {'allowed': raw_data == 'magic'}"}
|
||||
)
|
||||
result = self.simple_authenticator.authenticate(raw_data="magic")
|
||||
self.assertTrue(result["allowed"])
|
||||
result = self.simple_authenticator.authenticate(raw_data="not_magic")
|
||||
self.assertFalse(result["allowed"])
|
||||
|
||||
def test_authentication_code_exception(self):
|
||||
"""
|
||||
Test that authentication code exception is captured in result['message']
|
||||
"""
|
||||
self.simple_authenticator.write({"code": "raise Exception('custom failure')"})
|
||||
result = self.simple_authenticator.authenticate(
|
||||
headers={}, raise_on_error=False
|
||||
)
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertEqual(result["http_code"], 500)
|
||||
self.assertIn("custom failure", result["message"])
|
||||
|
||||
def test_authentication_minimal_false(self):
|
||||
"""
|
||||
Test minimal code with only allowed: False
|
||||
"""
|
||||
self.simple_authenticator.write({"code": "result = {'allowed': False}"})
|
||||
result = self.simple_authenticator.authenticate(headers={})
|
||||
self.assertFalse(result["allowed"])
|
||||
self.assertIsNone(result.get("http_code"))
|
||||
self.assertIsNone(result.get("message"))
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .common import CetmixTowerWebhookCommon
|
||||
|
||||
|
||||
class TestCetmixTowerWebhookLog(CetmixTowerWebhookCommon):
|
||||
def test_create_log_from_call(self):
|
||||
"""Test creating a log entry via create_from_call()."""
|
||||
vals = {
|
||||
"result_message": "Manual log",
|
||||
"http_status": 201,
|
||||
"authentication_status": "success",
|
||||
"code_status": "success",
|
||||
"request_payload": json.dumps({"foo": "bar"}),
|
||||
"request_headers": json.dumps({"X-Test": "test"}),
|
||||
"webhook_id": self.simple_webhook.id,
|
||||
}
|
||||
log = self.Log.create_from_call(webhook=self.simple_webhook, **vals)
|
||||
self.assertEqual(log.webhook_id, self.simple_webhook)
|
||||
self.assertEqual(log.result_message, "Manual log")
|
||||
self.assertEqual(log.http_status, 201)
|
||||
self.assertEqual(log.authentication_status, "success")
|
||||
self.assertIn("foo", log.request_payload)
|
||||
self.assertIn("X-Test", log.request_headers)
|
||||
|
||||
def test_gc_delete_old_logs(self):
|
||||
"""Test auto-removal of old logs via _gc_delete_old_logs()."""
|
||||
# Create an "old" log
|
||||
old_log = self.Log.create_from_call(
|
||||
webhook=self.simple_webhook,
|
||||
authentication_status="success",
|
||||
code_status="success",
|
||||
http_status=200,
|
||||
)
|
||||
# Set create_date in the past (we cannot use write
|
||||
# because the create_date is MAGIC Field)
|
||||
past_date = (datetime.now() - timedelta(days=100)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.env.cr.execute(
|
||||
"UPDATE cx_tower_webhook_log SET create_date = %s WHERE id = %s",
|
||||
(past_date, old_log.id),
|
||||
)
|
||||
self.env.invalidate_all()
|
||||
# Create a new log
|
||||
new_log = self.Log.create_from_call(
|
||||
webhook=self.simple_webhook,
|
||||
authentication_status="success",
|
||||
code_status="success",
|
||||
http_status=200,
|
||||
)
|
||||
# Set log duration to 30 days
|
||||
self.env["ir.config_parameter"].sudo().set_param(
|
||||
"cetmix_tower_webhook.webhook_log_duration", 30
|
||||
)
|
||||
# Enter test mode to run the autovacuum cron because
|
||||
# `_run_vacuum_cleaner` makes a commit
|
||||
self.registry.enter_test_mode(self.cr)
|
||||
self.addCleanup(self.registry.leave_test_mode)
|
||||
env = self.env(cr=self.registry.cursor())
|
||||
|
||||
# Run the autovacuum cron
|
||||
env.ref("base.autovacuum_job").method_direct_trigger()
|
||||
|
||||
self.assertFalse(self.Log.browse(old_log.id).exists())
|
||||
self.assertTrue(self.Log.browse(new_log.id).exists())
|
||||
608
addons/cetmix_tower_webhook/tests/test_webhook_controller.py
Normal file
608
addons/cetmix_tower_webhook/tests/test_webhook_controller.py
Normal file
@@ -0,0 +1,608 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.tests import HttpCase, tagged
|
||||
|
||||
|
||||
@tagged("-at_install", "post_install")
|
||||
class TestCxTowerWebhookController(HttpCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
env = cls.env
|
||||
# Authenticator that always allows requests
|
||||
cls.authenticator = env["cx.tower.webhook.authenticator"].create(
|
||||
{"name": "Always OK", "code": "result = {'allowed': True}"}
|
||||
)
|
||||
# POST webhook
|
||||
cls.webhook_post = env["cx.tower.webhook"].create(
|
||||
{
|
||||
"name": "Test Webhook POST",
|
||||
"endpoint": "webhook_post",
|
||||
"method": "post",
|
||||
"authenticator_id": cls.authenticator.id,
|
||||
"code": "result = {'exit_code': 0, 'message': 'POST ok'}",
|
||||
}
|
||||
)
|
||||
# GET webhook
|
||||
cls.webhook_get = env["cx.tower.webhook"].create(
|
||||
{
|
||||
"name": "Test Webhook GET",
|
||||
"endpoint": "webhook_get",
|
||||
"method": "get",
|
||||
"authenticator_id": cls.authenticator.id,
|
||||
"code": "result = {'exit_code': 0, 'message': 'GET ok'}",
|
||||
}
|
||||
)
|
||||
# Log model
|
||||
cls.Log = env["cx.tower.webhook.log"]
|
||||
|
||||
def url_for(self, endpoint):
|
||||
"""Helper to build webhook url"""
|
||||
url = f"/cetmix_tower_webhooks/{endpoint}"
|
||||
return self.base_url() + url
|
||||
|
||||
def assert_log(self, log=None, request_payload=None, **expected):
|
||||
"""
|
||||
Universal log checker for webhook log model.
|
||||
Checks expected field values and substrings.
|
||||
"""
|
||||
self.assertIsNotNone(log, "Log record was not created")
|
||||
if request_payload is not None:
|
||||
try:
|
||||
log_payload = log.request_payload
|
||||
# try to convert both to Python dict for comparison
|
||||
if isinstance(log_payload, str):
|
||||
log_payload = log_payload.strip()
|
||||
self.assertDictEqual(
|
||||
json.loads(
|
||||
log_payload.replace("'", '"')
|
||||
), # try to make JSON from possible str(dict)
|
||||
json.loads(request_payload),
|
||||
)
|
||||
except Exception as ex:
|
||||
self.fail(
|
||||
f"Payload comparison failed: {ex}\nLog: {log.request_payload}\nExpected: {request_payload}" # noqa: E501
|
||||
)
|
||||
for field, value in expected.items():
|
||||
if field == "request_payload":
|
||||
continue # Already checked
|
||||
actual = getattr(log, field)
|
||||
self.assertEqual(actual, value, f"{field}: expected {value}, got {actual}")
|
||||
|
||||
def test_post_webhook_success(self):
|
||||
"""Success test for POST request with correct payload."""
|
||||
data = json.dumps({"some": "data"})
|
||||
response = self.url_open(
|
||||
self.url_for(self.webhook_post.endpoint),
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b"POST ok", response.content)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
|
||||
self.assert_log(
|
||||
log,
|
||||
code_status="success",
|
||||
authentication_status="success",
|
||||
http_status=200,
|
||||
endpoint=self.webhook_post.endpoint,
|
||||
request_payload=data,
|
||||
)
|
||||
|
||||
def test_get_webhook_success(self):
|
||||
"""Success test for GET request with correct payload."""
|
||||
response = self.url_open(
|
||||
f"{self.url_for(self.webhook_get.endpoint)}?foo=bar",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b"GET ok", response.content)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", self.webhook_get.id)])
|
||||
self.assert_log(
|
||||
log,
|
||||
code_status="success",
|
||||
authentication_status="success",
|
||||
http_status=200,
|
||||
endpoint=self.webhook_get.endpoint,
|
||||
)
|
||||
self.assertIn("foo", log.request_payload)
|
||||
|
||||
def test_webhook_not_found(self):
|
||||
"""Test request to a non-existing webhook endpoint."""
|
||||
data = json.dumps({"test": 1})
|
||||
response = self.url_open(
|
||||
self.url_for("missing"),
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn(b"Webhook not found", response.content)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", False)])
|
||||
self.assert_log(
|
||||
log,
|
||||
code_status="skipped",
|
||||
authentication_status="failed",
|
||||
http_status=404,
|
||||
endpoint="missing",
|
||||
error_message="Webhook not found",
|
||||
request_payload=data,
|
||||
)
|
||||
|
||||
def test_wrong_method(self):
|
||||
"""
|
||||
Test GET request to POST-only webhook.
|
||||
"""
|
||||
response = self.url_open(
|
||||
self.url_for(self.webhook_post.endpoint),
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn(b"Webhook not found", response.content)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", False)])
|
||||
self.assert_log(
|
||||
log,
|
||||
code_status="skipped",
|
||||
authentication_status="failed",
|
||||
http_status=404,
|
||||
error_message="Webhook not found",
|
||||
endpoint=self.webhook_post.endpoint,
|
||||
request_method="get",
|
||||
)
|
||||
|
||||
def test_missing_payload_post(self):
|
||||
"""
|
||||
Test POST request with empty payload.
|
||||
"""
|
||||
# use opener instead of url_open to avoid checking of data
|
||||
response = self.opener.post(
|
||||
self.url_for(self.webhook_post.endpoint),
|
||||
timeout=1200000,
|
||||
headers={"Content-Type": "application/json"},
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b"POST ok", response.content)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
|
||||
self.assert_log(
|
||||
log,
|
||||
code_status="success",
|
||||
authentication_status="success",
|
||||
http_status=200,
|
||||
endpoint=self.webhook_post.endpoint,
|
||||
request_payload="{}",
|
||||
)
|
||||
|
||||
def test_authentication_failed(self):
|
||||
"""
|
||||
Test POST request with authenticator that always denies.
|
||||
"""
|
||||
bad_auth = self.env["cx.tower.webhook.authenticator"].create(
|
||||
{
|
||||
"name": "Never OK",
|
||||
"code": "result = {'allowed': False, 'custom_message': 'Forbidden'}",
|
||||
}
|
||||
)
|
||||
webhook = self.env["cx.tower.webhook"].create(
|
||||
{
|
||||
"name": "Forbidden Webhook",
|
||||
"endpoint": "forbidden",
|
||||
"method": "post",
|
||||
"authenticator_id": bad_auth.id,
|
||||
"code": "result = {'exit_code': 0, 'message': 'Should not run'}",
|
||||
}
|
||||
)
|
||||
data = json.dumps({"fail": 1})
|
||||
response = self.url_open(
|
||||
self.url_for(webhook.endpoint),
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn(b"Authentication not allowed", response.content)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", webhook.id)])
|
||||
self.assert_log(
|
||||
log,
|
||||
code_status="skipped",
|
||||
authentication_status="failed",
|
||||
http_status=403,
|
||||
endpoint=webhook.endpoint,
|
||||
request_payload=data,
|
||||
)
|
||||
|
||||
def test_webhook_code_failure(self):
|
||||
"""
|
||||
Test POST request to a webhook that raises an exception in code.
|
||||
"""
|
||||
self.webhook_post.code = "raise Exception('Some error!')"
|
||||
response = self.url_open(
|
||||
self.url_for(self.webhook_post.endpoint),
|
||||
data=json.dumps({}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertIn(b"Some error!", response.content)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
|
||||
self.assert_log(
|
||||
log,
|
||||
code_status="failed",
|
||||
authentication_status="success",
|
||||
http_status=500,
|
||||
endpoint=self.webhook_post.endpoint,
|
||||
request_payload="{}",
|
||||
)
|
||||
self.assertIn("Some error!", log.error_message)
|
||||
|
||||
def test_json_headers_are_stored(self):
|
||||
"""
|
||||
Test that request headers and payload are saved in webhook log record.
|
||||
"""
|
||||
payload = {"foo": "bar"}
|
||||
headers = {"X-Test-Header": "xxx", "Content-Type": "application/json"}
|
||||
response = self.url_open(
|
||||
self.url_for(self.webhook_post.endpoint),
|
||||
data=json.dumps(payload),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
|
||||
self.assert_log(
|
||||
log,
|
||||
code_status="success",
|
||||
authentication_status="success",
|
||||
http_status=200,
|
||||
endpoint=self.webhook_post.endpoint,
|
||||
)
|
||||
self.assertIn("foo", log.request_payload)
|
||||
self.assertIn("X-Test-Header", log.request_headers)
|
||||
self.assertIn(log.result_message, response.text)
|
||||
|
||||
def test_log_contains_ip(self):
|
||||
"""
|
||||
Test that the log contains the client's IP address and country (if available).
|
||||
"""
|
||||
payload = {"check": "ip"}
|
||||
self.url_open(
|
||||
self.url_for(self.webhook_post.endpoint),
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
|
||||
self.assertTrue(log.ip_address)
|
||||
|
||||
def test_inactive_webhook(self):
|
||||
"""Test that inactive webhooks are not callable."""
|
||||
self.webhook_post.active = False
|
||||
response = self.url_open(
|
||||
self.url_for(self.webhook_post.endpoint),
|
||||
data=json.dumps({"a": 1}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn(b"Webhook not found", response.content)
|
||||
|
||||
def test_authenticator_code_raises(self):
|
||||
"""
|
||||
Test that if authenticator's code raises an error,
|
||||
proper log is created and 403 returned.
|
||||
"""
|
||||
bad_auth = self.env["cx.tower.webhook.authenticator"].create(
|
||||
{"name": "Broken Auth", "code": "raise Exception('auth fail')"}
|
||||
)
|
||||
webhook = self.env["cx.tower.webhook"].create(
|
||||
{
|
||||
"name": "Web with bad auth",
|
||||
"endpoint": "bad_auth",
|
||||
"method": "post",
|
||||
"authenticator_id": bad_auth.id,
|
||||
"code": "result = {'exit_code': 0, 'message': 'Should not run'}",
|
||||
}
|
||||
)
|
||||
response = self.url_open(
|
||||
self.url_for(webhook.endpoint),
|
||||
data=json.dumps({"x": 1}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn(b"auth fail", response.content)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", webhook.id)])
|
||||
self.assert_log(
|
||||
log,
|
||||
code_status="skipped",
|
||||
authentication_status="failed",
|
||||
http_status=403,
|
||||
endpoint=webhook.endpoint,
|
||||
)
|
||||
self.assertIn("auth fail", log.error_message)
|
||||
|
||||
def test_post_webhook_json_content_type(self):
|
||||
"""
|
||||
Test POST request with content_type json.
|
||||
"""
|
||||
self.webhook_post.content_type = "json"
|
||||
self.webhook_post.code = "result = {'exit_code': 0, 'message': 'POST JSON ok'}"
|
||||
|
||||
data = json.dumps({"json_test": "ok"})
|
||||
response = self.url_open(
|
||||
self.url_for(self.webhook_post.endpoint),
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b"POST JSON ok", response.content)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
|
||||
self.assert_log(
|
||||
log,
|
||||
code_status="success",
|
||||
authentication_status="success",
|
||||
http_status=200,
|
||||
endpoint=self.webhook_post.endpoint,
|
||||
request_payload=data,
|
||||
)
|
||||
|
||||
def test_post_webhook_form_content_type(self):
|
||||
"""
|
||||
Test POST request with content_type form.
|
||||
"""
|
||||
self.webhook_post.content_type = "form"
|
||||
self.webhook_post.code = "result = {'exit_code': 0, 'message': 'POST FORM ok'}"
|
||||
|
||||
data = {"form_field": "ok"}
|
||||
response = self.url_open(
|
||||
self.url_for(self.webhook_post.endpoint),
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b"POST FORM ok", response.content)
|
||||
|
||||
log = self.Log.search([("webhook_id", "=", self.webhook_post.id)])
|
||||
self.assertIn("form_field", log.request_payload)
|
||||
|
||||
def test_authenticator_ipv4_and_ipv6(self):
|
||||
"""
|
||||
Test IP filter for IPv4, IPv6, and networks
|
||||
by monkeypatching REMOTE_ADDR in environ.
|
||||
"""
|
||||
auth = self.env["cx.tower.webhook.authenticator"].create(
|
||||
{
|
||||
"name": "IP Test",
|
||||
"allowed_ip_addresses": "203.0.113.5,2001:db8::42,198.51.100.0/24,2001:db8:abcd::/48", # noqa: E501
|
||||
"code": "result = {'allowed': True}",
|
||||
}
|
||||
)
|
||||
webhook = self.env["cx.tower.webhook"].create(
|
||||
{
|
||||
"name": "IP Webhook",
|
||||
"endpoint": "webhook_iptest",
|
||||
"method": "post",
|
||||
"authenticator_id": auth.id,
|
||||
"code": "result = {'exit_code': 0, 'message': 'IP OK'}",
|
||||
}
|
||||
)
|
||||
|
||||
data = json.dumps({"ip": "test"})
|
||||
|
||||
def do_req(ip):
|
||||
# Patch _get_remote_addr to simulate requests coming
|
||||
# from different IP addresses
|
||||
with patch(
|
||||
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
|
||||
return_value=ip,
|
||||
):
|
||||
return self.url_open(
|
||||
self.url_for(webhook.endpoint),
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
# IPv4 address allowed
|
||||
resp = do_req("203.0.113.5")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(b"IP OK", resp.content)
|
||||
|
||||
# IPv6 address allowed
|
||||
resp = do_req("2001:db8::42")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(b"IP OK", resp.content)
|
||||
|
||||
# IPv4 network allowed
|
||||
resp = do_req("198.51.100.99")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(b"IP OK", resp.content)
|
||||
|
||||
# IPv6 network allowed
|
||||
resp = do_req("2001:db8:abcd::abcd")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(b"IP OK", resp.content)
|
||||
|
||||
# Denied IPv4 address
|
||||
resp = do_req("203.0.113.99")
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertIn(b"Address not allowed", resp.content)
|
||||
|
||||
# Denied IPv6 address
|
||||
resp = do_req("2001:db8:ffff::1")
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertIn(b"Address not allowed", resp.content)
|
||||
|
||||
def _make_proxy_webhook(
|
||||
self,
|
||||
allowed,
|
||||
trusted=None,
|
||||
code="result = {'exit_code': 0, 'message': 'OK via proxy'}",
|
||||
):
|
||||
"""
|
||||
Helper to create a webhook with a dedicated authenticator configured
|
||||
for proxy-aware tests.
|
||||
"""
|
||||
auth = self.env["cx.tower.webhook.authenticator"].create(
|
||||
{
|
||||
"name": "Proxy Aware",
|
||||
"allowed_ip_addresses": allowed,
|
||||
"trusted_proxy_ips": trusted or "",
|
||||
"code": "result = {'allowed': True}",
|
||||
}
|
||||
)
|
||||
wh = self.env["cx.tower.webhook"].create(
|
||||
{
|
||||
"name": "Proxy Webhook",
|
||||
"endpoint": "proxy_webhook",
|
||||
"method": "post",
|
||||
"authenticator_id": auth.id,
|
||||
"code": code,
|
||||
}
|
||||
)
|
||||
return wh, auth
|
||||
|
||||
def test_proxy_headers_ignored_without_trusted_proxy(self):
|
||||
"""
|
||||
When trusted_proxy_ips is empty, XFF/X-Real-IP must be ignored.
|
||||
We fallback to immediate peer (proxy IP), which is not allowed -> 403.
|
||||
"""
|
||||
# Allow only the real client network, not the proxy itself
|
||||
webhook, _auth = self._make_proxy_webhook(
|
||||
allowed="203.0.113.0/24", trusted=None
|
||||
)
|
||||
|
||||
data = json.dumps({"k": "v"})
|
||||
proxy_ip = "10.0.0.5" # immediate peer (undocumented as trusted)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Forwarded-For": "203.0.113.7, 10.0.0.5", # should be ignored
|
||||
"X-Real-IP": "203.0.113.7", # should be ignored
|
||||
}
|
||||
with patch(
|
||||
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
|
||||
return_value=proxy_ip,
|
||||
):
|
||||
resp = self.url_open(
|
||||
self.url_for(webhook.endpoint), data=data, headers=headers
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertIn(b"Address not allowed", resp.content)
|
||||
|
||||
def test_proxy_xff_honored_with_trusted_proxy(self):
|
||||
"""
|
||||
With trusted proxy configured, take the left-most IP from X-Forwarded-For.
|
||||
"""
|
||||
webhook, _auth = self._make_proxy_webhook(
|
||||
allowed="203.0.113.0/24",
|
||||
trusted="10.0.0.5",
|
||||
code="result = {'exit_code': 0, 'message': 'OK XFF'}",
|
||||
)
|
||||
|
||||
data = json.dumps({"k": "v"})
|
||||
proxy_ip = "10.0.0.5"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
# XFF list: client, proxy
|
||||
"X-Forwarded-For": "203.0.113.7, 10.0.0.5",
|
||||
}
|
||||
with patch(
|
||||
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
|
||||
return_value=proxy_ip,
|
||||
):
|
||||
resp = self.url_open(
|
||||
self.url_for(webhook.endpoint), data=data, headers=headers
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(b"OK XFF", resp.content)
|
||||
|
||||
def test_proxy_x_real_ip_fallback_when_xff_missing(self):
|
||||
"""
|
||||
If XFF is missing/invalid but trusted proxy is set, fall back to X-Real-IP.
|
||||
"""
|
||||
webhook, _auth = self._make_proxy_webhook(
|
||||
allowed="203.0.113.0/24",
|
||||
trusted="10.0.0.5",
|
||||
code="result = {'exit_code': 0, 'message': 'OK X-Real-IP'}",
|
||||
)
|
||||
|
||||
data = json.dumps({"k": "v"})
|
||||
proxy_ip = "10.0.0.5"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Forwarded-For": "garbage, not_an_ip", # invalids should be skipped
|
||||
"X-Real-IP": "203.0.113.8",
|
||||
}
|
||||
with patch(
|
||||
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
|
||||
return_value=proxy_ip,
|
||||
):
|
||||
resp = self.url_open(
|
||||
self.url_for(webhook.endpoint), data=data, headers=headers
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(b"OK X-Real-IP", resp.content)
|
||||
|
||||
def test_proxy_invalid_headers_fall_back_to_immediate_peer(self):
|
||||
"""
|
||||
If headers are invalid even with trusted proxy, fall back to immediate peer.
|
||||
Since the proxy IP is not in allowlist, the request is denied.
|
||||
"""
|
||||
webhook, _auth = self._make_proxy_webhook(
|
||||
allowed="203.0.113.0/24", # does NOT include proxy IP
|
||||
trusted="10.0.0.5",
|
||||
)
|
||||
|
||||
data = json.dumps({"k": "v"})
|
||||
proxy_ip = "10.0.0.5"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Forwarded-For": "not_an_ip, also_bad",
|
||||
"X-Real-IP": "bad_ip_value",
|
||||
}
|
||||
with patch(
|
||||
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
|
||||
return_value=proxy_ip,
|
||||
):
|
||||
resp = self.url_open(
|
||||
self.url_for(webhook.endpoint), data=data, headers=headers
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertIn(b"Address not allowed", resp.content)
|
||||
|
||||
def test_proxy_allows_via_immediate_peer_when_proxy_ip_in_allowlist(self):
|
||||
"""
|
||||
If headers are ignored/invalid, but the proxy IP itself is allowed,
|
||||
access should be granted based on immediate peer.
|
||||
"""
|
||||
webhook, _auth = self._make_proxy_webhook(
|
||||
allowed="10.0.0.5", # allow the proxy itself
|
||||
trusted="", # no trusted proxies => headers ignored
|
||||
code="result = {'exit_code': 0, 'message': 'OK immediate peer'}",
|
||||
)
|
||||
|
||||
data = json.dumps({"k": "v"})
|
||||
proxy_ip = "10.0.0.5"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Forwarded-For": "203.0.113.7", # should be ignored
|
||||
}
|
||||
with patch(
|
||||
"odoo.addons.cetmix_tower_webhook.controllers.main.CetmixTowerWebhookController._get_remote_addr",
|
||||
return_value=proxy_ip,
|
||||
):
|
||||
resp = self.url_open(
|
||||
self.url_for(webhook.endpoint), data=data, headers=headers
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(b"OK immediate peer", resp.content)
|
||||
@@ -0,0 +1,41 @@
|
||||
<odoo>
|
||||
<record id="cx_tower_variable_view_form" model="ir.ui.view">
|
||||
<field name="name">cx.tower.variable.view.form</field>
|
||||
<field name="model">cx.tower.variable</field>
|
||||
<field
|
||||
name="inherit_id"
|
||||
ref="cetmix_tower_server.cx_tower_variable_view_form"
|
||||
/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button
|
||||
name="action_open_webhooks"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-link"
|
||||
attrs="{'invisible': [('webhook_ids_count', '=', 0)]}"
|
||||
>
|
||||
<field
|
||||
name="webhook_ids_count"
|
||||
widget="statinfo"
|
||||
string="Webhooks"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
name="action_open_webhook_authenticators"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-key"
|
||||
attrs="{'invisible': [('webhook_authenticator_ids_count', '=', 0)]}"
|
||||
>
|
||||
<field
|
||||
name="webhook_authenticator_ids_count"
|
||||
widget="statinfo"
|
||||
string="Webhook Authenticators"
|
||||
/>
|
||||
</button>
|
||||
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="cx_tower_webhook_authenticator_view_form" model="ir.ui.view">
|
||||
<field name="name">cx.tower.webhook.authenticator.view.form</field>
|
||||
<field name="model">cx.tower.webhook.authenticator</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button
|
||||
type="object"
|
||||
name="action_view_logs"
|
||||
string="Logs"
|
||||
icon="fa-list"
|
||||
class="oe_stat_button"
|
||||
attrs="{'invisible': [('log_count', '=', 0)]}"
|
||||
>
|
||||
<field name="log_count" widget="statinfo" string="Logs" />
|
||||
</button>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name" />
|
||||
<field name="reference" />
|
||||
<field
|
||||
name="variable_ids"
|
||||
widget="many2many_tags"
|
||||
readonly="1"
|
||||
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
|
||||
attrs="{'invisible': [('variable_ids', '=', [])]}"
|
||||
/>
|
||||
<field
|
||||
name="secret_ids"
|
||||
widget="many2many_tags"
|
||||
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
|
||||
attrs="{'invisible': [('secret_ids', '=', [])]}"
|
||||
/>
|
||||
</group>
|
||||
<group>
|
||||
<field
|
||||
name="allowed_ip_addresses"
|
||||
placeholder="e.g.: 192.168.1.10, 192.168.2.0/24, 2001:db8::/32, 2a00:1450:4001:824::200e"
|
||||
/>
|
||||
<field
|
||||
name="trusted_proxy_ips"
|
||||
placeholder="10.0.0.1,192.168.1.0/24"
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page name="code" string="Code">
|
||||
<field
|
||||
name="code"
|
||||
widget="ace_tower"
|
||||
options="{'mode': 'python'}"
|
||||
placeholder="Enter Python code here. Help about Python expression is available in the help tab of this document"
|
||||
/>
|
||||
</page>
|
||||
<page string="Help" name="python_help_info">
|
||||
<field name="code_help" />
|
||||
</page>
|
||||
<page name="yaml" string="YAML">
|
||||
<div groups="!cetmix_tower_yaml.group_export">
|
||||
<h3
|
||||
>You must be a member of the "YAML/Export" group to export data as YAML</h3>
|
||||
</div>
|
||||
<button
|
||||
type="object"
|
||||
groups="cetmix_tower_yaml.group_export"
|
||||
class="oe_highlight"
|
||||
name="action_open_yaml_export_wizard"
|
||||
string="Export YAML"
|
||||
/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="cx_tower_webhook_authenticator_view_tree" model="ir.ui.view">
|
||||
<field name="name">cx.tower.webhook.authenticator.view.tree</field>
|
||||
<field name="model">cx.tower.webhook.authenticator</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="name" />
|
||||
<field name="reference" optional="show" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="cx_tower_webhook_authenticator_view_search" model="ir.ui.view">
|
||||
<field name="name">cx.tower.webhook.authenticator.view.search</field>
|
||||
<field name="model">cx.tower.webhook.authenticator</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Webhook Authenticators">
|
||||
<field name="name" />
|
||||
<field name="reference" />
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="cx_tower_webhook_authenticator_action" model="ir.actions.act_window">
|
||||
<field name="name">Webhook Authenticators</field>
|
||||
<field name="res_model">cx.tower.webhook.authenticator</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="cx_tower_webhook_authenticator_view_search" />
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new webhook authenticator
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record
|
||||
id="action_cx_tower_webhook_authenticator_export_yaml"
|
||||
model="ir.actions.act_window"
|
||||
>
|
||||
<field name="name">Export YAML</field>
|
||||
<field name="res_model">cx.tower.yaml.export.wiz</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="model_cx_tower_webhook_authenticator" />
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
185
addons/cetmix_tower_webhook/views/cx_tower_webhook_log_views.xml
Normal file
185
addons/cetmix_tower_webhook/views/cx_tower_webhook_log_views.xml
Normal file
@@ -0,0 +1,185 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="cx_tower_webhook_log_view_form" model="ir.ui.view">
|
||||
<field name="name">cx.tower.webhook.log.view.form</field>
|
||||
<field name="model">cx.tower.webhook.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form create="0">
|
||||
<header>
|
||||
<field
|
||||
name="code_status"
|
||||
widget="statusbar"
|
||||
options="{'clickable': False}"
|
||||
statusbar_visible="success,failed,skipped"
|
||||
/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="display_name" class="oe_edit_only" />
|
||||
<h1>
|
||||
<field
|
||||
name="display_name"
|
||||
placeholder="Name"
|
||||
required="1"
|
||||
/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="webhook_id" />
|
||||
<field name="authenticator_id" />
|
||||
<field name="endpoint" />
|
||||
<field name="request_method" />
|
||||
<field name="http_status" />
|
||||
<field name="authentication_status" />
|
||||
</group>
|
||||
<group>
|
||||
<field name="user_id" />
|
||||
<field name="ip_address" />
|
||||
<field name="country_id" />
|
||||
<field name="create_date" />
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page name="request" string="Request Payload">
|
||||
<field
|
||||
name="request_payload"
|
||||
widget="ace"
|
||||
options="{'mode': 'json'}"
|
||||
readonly="1"
|
||||
/>
|
||||
</page>
|
||||
<page name="request_headers" string="Request Headers">
|
||||
<field
|
||||
name="request_headers"
|
||||
widget="ace"
|
||||
options="{'mode': 'json'}"
|
||||
readonly="1"
|
||||
/>
|
||||
</page>
|
||||
<page
|
||||
name="response"
|
||||
string="Response Payload"
|
||||
attrs="{'invisible': [('code_status', '!=', 'success')]}"
|
||||
>
|
||||
<field
|
||||
name="result_message"
|
||||
widget="ace"
|
||||
options="{'mode': 'json'}"
|
||||
readonly="1"
|
||||
/>
|
||||
</page>
|
||||
<page
|
||||
name="error"
|
||||
string="Error"
|
||||
attrs="{'invisible': [('code_status', '!=', 'failed'), ('authentication_status', '!=', 'failed')]}"
|
||||
>
|
||||
<code>
|
||||
<field name="error_message" readonly="1" />
|
||||
</code>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="cx_tower_webhook_log_view_tree" model="ir.ui.view">
|
||||
<field name="name">cx.tower.webhook.log.view.tree</field>
|
||||
<field name="model">cx.tower.webhook.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree
|
||||
decoration-info="authentication_status == 'success' and code_status == 'success'"
|
||||
decoration-danger="authentication_status == 'failed' or code_status == 'failed'"
|
||||
decoration-warning="authentication_status == 'not_required' or code_status == 'skipped'"
|
||||
>
|
||||
<field name="create_date" />
|
||||
<field name="webhook_id" />
|
||||
<field name="endpoint" />
|
||||
<field name="request_method" />
|
||||
<field name="http_status" />
|
||||
<field name="authentication_status" />
|
||||
<field name="code_status" />
|
||||
<field name="user_id" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="cx_tower_webhook_log_view_search" model="ir.ui.view">
|
||||
<field name="name">cx.tower.webhook.log.view.search</field>
|
||||
<field name="model">cx.tower.webhook.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Webhook Logs">
|
||||
<field name="webhook_id" />
|
||||
<field name="endpoint" />
|
||||
<field name="authentication_status" />
|
||||
<field name="code_status" />
|
||||
<field name="http_status" />
|
||||
<field name="user_id" />
|
||||
<field name="ip_address" />
|
||||
<field name="create_date" />
|
||||
<filter
|
||||
name="auth_failed"
|
||||
string="Auth Failed"
|
||||
domain="[('authentication_status','=','failed')]"
|
||||
/>
|
||||
<filter
|
||||
name="code_failed"
|
||||
string="Code Failed"
|
||||
domain="[('code_status','=','failed')]"
|
||||
/>
|
||||
<filter
|
||||
name="http_200"
|
||||
string="HTTP 200"
|
||||
domain="[('http_status','=',200)]"
|
||||
/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter
|
||||
name="group_by_webhook"
|
||||
string="Webhook"
|
||||
context="{'group_by': 'webhook_id'}"
|
||||
/>
|
||||
<filter
|
||||
name="group_by_method"
|
||||
string="Method"
|
||||
context="{'group_by': 'request_method'}"
|
||||
/>
|
||||
<filter
|
||||
name="group_by_user"
|
||||
string="User"
|
||||
context="{'group_by': 'user_id'}"
|
||||
/>
|
||||
<filter
|
||||
name="group_by_auth_status"
|
||||
string="Auth Status"
|
||||
context="{'group_by': 'authentication_status'}"
|
||||
/>
|
||||
<filter
|
||||
name="group_by_code_status"
|
||||
string="Code Status"
|
||||
context="{'group_by': 'code_status'}"
|
||||
/>
|
||||
<filter
|
||||
name="group_by_http_status"
|
||||
string="HTTP Status"
|
||||
context="{'group_by': 'http_status'}"
|
||||
/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="cx_tower_webhook_log_action" model="ir.actions.act_window">
|
||||
<field name="name">Webhook Logs</field>
|
||||
<field name="res_model">cx.tower.webhook.log</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="cx_tower_webhook_log_view_search" />
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No webhook logs found
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
207
addons/cetmix_tower_webhook/views/cx_tower_webhook_views.xml
Normal file
207
addons/cetmix_tower_webhook/views/cx_tower_webhook_views.xml
Normal file
@@ -0,0 +1,207 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="cx_tower_webhook_view_form" model="ir.ui.view">
|
||||
<field name="name">cx.tower.webhook.view.form</field>
|
||||
<field name="model">cx.tower.webhook</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<widget
|
||||
name="web_ribbon"
|
||||
title="Disabled"
|
||||
bg_color="bg-danger"
|
||||
attrs="{'invisible': [('active', '=', True)]}"
|
||||
/>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button
|
||||
type="object"
|
||||
name="action_view_logs"
|
||||
string="Logs"
|
||||
icon="fa-list"
|
||||
class="oe_stat_button"
|
||||
attrs="{'invisible': [('log_count', '=', 0)]}"
|
||||
>
|
||||
<field name="log_count" widget="statinfo" string="Logs" />
|
||||
</button>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name" />
|
||||
<field name="reference" />
|
||||
<field name="active" />
|
||||
<field name="authenticator_id" />
|
||||
<field
|
||||
name="variable_ids"
|
||||
widget="many2many_tags"
|
||||
readonly="1"
|
||||
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
|
||||
attrs="{'invisible': [('variable_ids', '=', [])]}"
|
||||
/>
|
||||
<field
|
||||
name="secret_ids"
|
||||
widget="many2many_tags"
|
||||
groups="cetmix_tower_server.group_manager,cetmix_tower_server.group_root"
|
||||
attrs="{'invisible': [('secret_ids', '=', [])]}"
|
||||
/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="endpoint" />
|
||||
<field
|
||||
name="full_url"
|
||||
attrs="{'invisible': [('endpoint', '=', False)]}"
|
||||
widget="CopyClipboardChar"
|
||||
options="{'string': 'Copy'}"
|
||||
/>
|
||||
<field name="method" />
|
||||
<field
|
||||
name="content_type"
|
||||
attrs="{'invisible': [('method', '=', 'get')]}"
|
||||
/>
|
||||
<field name="user_id" context="{'active_test': False}" />
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page name="code" string="Code">
|
||||
<field
|
||||
name="code"
|
||||
widget="ace_tower"
|
||||
options="{'mode': 'python'}"
|
||||
placeholder="Enter Python code here. Help about Python expression is available in the help tab of this document."
|
||||
/>
|
||||
</page>
|
||||
<page string="Help" name="python_help_info">
|
||||
<field name="code_help" />
|
||||
</page>
|
||||
<page name="yaml" string="YAML">
|
||||
<div groups="!cetmix_tower_yaml.group_export">
|
||||
<h3
|
||||
>You must be a member of the "YAML/Export" group to export data as YAML.</h3>
|
||||
</div>
|
||||
<button
|
||||
type="object"
|
||||
groups="cetmix_tower_yaml.group_export"
|
||||
class="oe_highlight"
|
||||
name="action_open_yaml_export_wizard"
|
||||
string="Export YAML"
|
||||
/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="cx_tower_webhook_view_tree" model="ir.ui.view">
|
||||
<field name="name">cx.tower.webhook.view.tree</field>
|
||||
<field name="model">cx.tower.webhook</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-muted="not active">
|
||||
<field name="name" />
|
||||
<field name="reference" optional="show" />
|
||||
<field name="authenticator_id" optional="show" />
|
||||
<field name="user_id" optional="show" />
|
||||
<field name="endpoint" optional="show" />
|
||||
<field name="method" optional="show" />
|
||||
<field name="content_type" optional="show" />
|
||||
<field name="active" widget="boolean_toggle" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="cx_tower_webhook_view_search" model="ir.ui.view">
|
||||
<field name="name">cx.tower.webhook.view.search</field>
|
||||
<field name="model">cx.tower.webhook</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Webhooks">
|
||||
<field
|
||||
name="name"
|
||||
string="Name/Reference"
|
||||
filter_domain="['|', ('name', 'ilike', self), ('reference', 'ilike', self)]"
|
||||
/>
|
||||
<field name="endpoint" />
|
||||
<filter
|
||||
string="All"
|
||||
name="all"
|
||||
domain="['|', ('active', '=', True), ('active', '=', False)]"
|
||||
/>
|
||||
<filter
|
||||
name="filter_enabled"
|
||||
string="Enabled"
|
||||
domain="[('active', '=', True)]"
|
||||
/>
|
||||
<filter
|
||||
name="filter_disabled"
|
||||
string="Disabled"
|
||||
domain="[('active', '=', False)]"
|
||||
/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter
|
||||
name="group_by_method"
|
||||
string="Method"
|
||||
context="{'group_by': 'method'}"
|
||||
/>
|
||||
<filter
|
||||
name="group_by_authenticator_id"
|
||||
string="Authenticator"
|
||||
context="{'group_by': 'authenticator_id'}"
|
||||
/>
|
||||
<filter
|
||||
name="group_by_user_id"
|
||||
string="User"
|
||||
context="{'group_by': 'user_id'}"
|
||||
/>
|
||||
<filter
|
||||
name="group_by_content_type"
|
||||
string="Content Type"
|
||||
context="{'group_by': 'content_type'}"
|
||||
/>
|
||||
</group>
|
||||
<searchpanel>
|
||||
<field
|
||||
name="method"
|
||||
string="Method"
|
||||
icon="fa-cog"
|
||||
enable_counters="1"
|
||||
/>
|
||||
<field
|
||||
name="content_type"
|
||||
string="Content Type"
|
||||
icon="fa-file"
|
||||
enable_counters="1"
|
||||
/>
|
||||
<field
|
||||
name="authenticator_id"
|
||||
string="Authenticator"
|
||||
icon="fa-shield"
|
||||
enable_counters="1"
|
||||
/>
|
||||
</searchpanel>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="cx_tower_webhook_action" model="ir.actions.act_window">
|
||||
<field name="name">Webhooks</field>
|
||||
<field name="res_model">cx.tower.webhook</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="cx_tower_webhook_view_search" />
|
||||
<field name="context">{'search_default_all': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a new webhook
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_cx_tower_webhook_export_yaml" model="ir.actions.act_window">
|
||||
<field name="name">Export YAML</field>
|
||||
<field name="res_model">cx.tower.yaml.export.wiz</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="model_cx_tower_webhook" />
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
29
addons/cetmix_tower_webhook/views/menuitems.xml
Normal file
29
addons/cetmix_tower_webhook/views/menuitems.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<odoo>
|
||||
|
||||
<menuitem
|
||||
id="menu_cetmix_tower_webhook_authenticator"
|
||||
name="Webhook Authenticators"
|
||||
action="cx_tower_webhook_authenticator_action"
|
||||
parent="cetmix_tower_server.menu_cx_tower_automation_root"
|
||||
groups="cetmix_tower_server.group_root"
|
||||
sequence="3"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_cetmix_tower_webhook"
|
||||
name="Webhooks"
|
||||
action="cx_tower_webhook_action"
|
||||
parent="cetmix_tower_server.menu_cx_tower_automation_root"
|
||||
groups="cetmix_tower_server.group_root"
|
||||
sequence="4"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_cetmix_tower_webhook_log"
|
||||
name="Webhook Calls"
|
||||
action="cx_tower_webhook_log_action"
|
||||
parent="cetmix_tower_server.menu_cx_tower_log_root"
|
||||
sequence="150"
|
||||
/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field
|
||||
name="inherit_id"
|
||||
ref="cetmix_tower_server.res_config_settings_view_form"
|
||||
/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@id='cetmix_tower_settings']" position="inside">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="cetmix_tower_webhook_log_duration">
|
||||
<label for="cetmix_tower_webhook_log_duration" />
|
||||
<div class="text-muted">
|
||||
Set the number of days to keep webhook logs. Old logs will be deleted automatically.
|
||||
<br />
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="mt16">
|
||||
<field name="cetmix_tower_webhook_log_duration" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -7,7 +7,7 @@ Cetmix Tower YAML
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:7f55d44d4d4b9239195643b7169c1a5f98ad8a36c3cc80686d357a9829beb856
|
||||
!! source digest: sha256:96e8f3f1df3ab25b952a9534d0914149740cc036b62efe2c7795f9d2d9636177
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
|
||||
@@ -50,16 +50,6 @@ instructions.
|
||||
Changelog
|
||||
=========
|
||||
|
||||
16.0.3.1.0 (2026-03-30)
|
||||
-----------------------
|
||||
|
||||
- Features: Deferred import of related records. (5323)
|
||||
|
||||
16.0.3.0.0 (2026-03-23)
|
||||
-----------------------
|
||||
|
||||
- Features: Jets! (4700)
|
||||
|
||||
16.0.2.0.1 (2025-10-29)
|
||||
-----------------------
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"name": "Cetmix Tower YAML",
|
||||
"summary": "Cetmix Tower YAML export/import",
|
||||
"version": "16.0.3.1.0",
|
||||
"version": "16.0.2.0.3",
|
||||
"development_status": "Beta",
|
||||
"category": "Productivity",
|
||||
"website": "https://tower.cetmix.com",
|
||||
@@ -28,7 +28,6 @@
|
||||
"views/cx_tower_shortcut_view.xml",
|
||||
"views/cx_tower_scheduled_task_view.xml",
|
||||
"views/cx_tower_key_view.xml",
|
||||
"views/cx_tower_jet_template_view.xml",
|
||||
"views/cx_tower_yaml_manifest_template_views.xml",
|
||||
"views/cx_tower_yaml_manifest_author_views.xml",
|
||||
"wizards/cx_tower_yaml_export_wiz.xml",
|
||||
|
||||
@@ -91,12 +91,6 @@ msgstr ""
|
||||
msgid "Authors"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model.fields,help:cetmix_tower_yaml.field_cx_tower_scheduled_task_cv__reference
|
||||
msgid ""
|
||||
"Can contain English letters, digits and '_'. Leave blank to autogenerate"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_command
|
||||
msgid "Cetmix Tower Command"
|
||||
@@ -127,31 +121,6 @@ msgstr ""
|
||||
msgid "Cetmix Tower Flight Plan Line Action"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_jet_action
|
||||
msgid "Cetmix Tower Jet Action"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_jet_state
|
||||
msgid "Cetmix Tower Jet State"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_jet_template
|
||||
msgid "Cetmix Tower Jet Template"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_jet_template_dependency
|
||||
msgid "Cetmix Tower Jet Template Dependency"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_jet_waypoint_template
|
||||
msgid "Cetmix Tower Jet Waypoint Template"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_key
|
||||
msgid "Cetmix Tower Key/Secret Storage"
|
||||
@@ -307,21 +276,6 @@ msgstr ""
|
||||
msgid "Custom license text when license type is Custom."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_scheduled_task_cv
|
||||
msgid "Custom variable values for scheduled tasks"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.py:0
|
||||
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Deferred relation resolution failed:\n"
|
||||
"%(details)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__manifest_description
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__manifest_description
|
||||
@@ -381,7 +335,6 @@ msgstr ""
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_command_export_yaml
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_file_template_export_yaml
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_jet_template_export_yaml
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_key_export_yaml
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_os_export_yaml
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_plan_export_yaml
|
||||
@@ -394,7 +347,6 @@ msgstr ""
|
||||
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_variable_value_export_yaml
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_command_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_file_template_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_jet_template_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_plan_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_view_form
|
||||
@@ -636,7 +588,6 @@ msgid "Models to create records in"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_scheduled_task_cv__name
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_manifest_author__name
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_manifest_tmpl__name
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
|
||||
@@ -712,24 +663,6 @@ msgstr ""
|
||||
msgid "Provide Custom License Text when License is set to 'Custom'."
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Record %(record_model)s '%(record_reference)s': field '%(field)s' could not "
|
||||
"resolve %(target_model)s '%(target_reference)s'"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Record '%(record)s': field '%(field)s' could not resolve %(target_model)s "
|
||||
"'%(target_reference)s'"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#. odoo-python
|
||||
#: code:addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py:0
|
||||
@@ -762,11 +695,6 @@ msgstr ""
|
||||
msgid "Records of the following models were created or updated: %(models)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_scheduled_task_cv__reference
|
||||
msgid "Reference"
|
||||
msgstr ""
|
||||
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__remove_empty_values
|
||||
msgid "Remove Empty x2m Field Values"
|
||||
@@ -946,7 +874,6 @@ msgstr ""
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_command_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_file_template_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_jet_template_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_plan_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_view_form
|
||||
@@ -1026,11 +953,6 @@ msgstr ""
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_command__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_file__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_file_template__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_jet_action__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_jet_state__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_jet_template__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_jet_template_dependency__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_jet_waypoint_template__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_key__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_key_value__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_os__yaml_code
|
||||
@@ -1038,7 +960,6 @@ msgstr ""
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line_action__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_scheduled_task__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_scheduled_task_cv__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_log__yaml_code
|
||||
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_template__yaml_code
|
||||
@@ -1082,7 +1003,6 @@ msgstr ""
|
||||
#. module: cetmix_tower_yaml
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_command_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_file_template_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_jet_template_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_plan_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_view_form
|
||||
|
||||
@@ -15,13 +15,7 @@ from . import cx_tower_key_value
|
||||
from . import cx_tower_server_log
|
||||
from . import cx_tower_shortcut
|
||||
from . import cx_tower_scheduled_task
|
||||
from . import cx_tower_scheduled_task_cv
|
||||
from . import cx_tower_file
|
||||
from . import cx_tower_server
|
||||
from . import cx_tower_yaml_manifest_template
|
||||
from . import cx_tower_yaml_manifest_author
|
||||
from . import cx_tower_jet_template
|
||||
from . import cx_tower_jet_template_dependency
|
||||
from . import cx_tower_jet_state
|
||||
from . import cx_tower_jet_action
|
||||
from . import cx_tower_jet_waypoint_template
|
||||
|
||||
@@ -19,25 +19,13 @@ class CxTowerCommand(models.Model):
|
||||
"tag_ids",
|
||||
"path",
|
||||
"file_template_id",
|
||||
"if_file_exists",
|
||||
"disconnect_file",
|
||||
"flight_plan_id",
|
||||
"jet_template_id",
|
||||
"jet_action_id",
|
||||
"waypoint_template_id",
|
||||
"fly_here",
|
||||
"code",
|
||||
"no_split_for_sudo",
|
||||
"server_status",
|
||||
"variable_ids",
|
||||
"secret_ids",
|
||||
"no_split_for_sudo",
|
||||
"if_file_exists",
|
||||
"disconnect_file",
|
||||
]
|
||||
return res
|
||||
|
||||
def _get_deferred_m2o_import_fields(self):
|
||||
"""Return m2o command fields resolved after the main import pass."""
|
||||
return {
|
||||
"jet_template_id": "cx.tower.jet.template",
|
||||
"jet_action_id": "cx.tower.jet.action",
|
||||
"waypoint_template_id": "cx.tower.jet.waypoint.template",
|
||||
}
|
||||
|
||||
@@ -21,20 +21,3 @@ class CxTowerPlan(models.Model):
|
||||
"line_ids",
|
||||
]
|
||||
return res
|
||||
|
||||
def _get_deferred_x2m_import_fields(self):
|
||||
"""Defer plan lines whose command is not resolvable during nested import.
|
||||
|
||||
Deep YAML (e.g. a command's waypoint inlines a jet template whose plans
|
||||
reference that same command) creates a forward reference: plan lines are
|
||||
prepared before the command exists in the database. Queue those lines
|
||||
and create them after the main import pass when ``command_id`` can be
|
||||
resolved.
|
||||
"""
|
||||
return {
|
||||
"line_ids": {
|
||||
"child_model": "cx.tower.plan.line",
|
||||
"deferred_field": "command_id",
|
||||
"target_model": "cx.tower.command",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,24 +19,5 @@ class CxTowerScheduledTask(models.Model):
|
||||
"interval_type",
|
||||
"next_call",
|
||||
"last_call",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
"custom_variable_value_ids",
|
||||
]
|
||||
return res
|
||||
|
||||
def _get_deferred_x2m_import_fields(self):
|
||||
"""Return scheduled-task child records resolved after import."""
|
||||
return {
|
||||
"custom_variable_value_ids": {
|
||||
"child_model": "cx.tower.scheduled.task.cv",
|
||||
"deferred_field": "variable_value_id",
|
||||
"target_model": "cx.tower.variable.value",
|
||||
"skip_empty": True,
|
||||
}
|
||||
}
|
||||
|
||||
23
addons/cetmix_tower_yaml/models/cx_tower_server_log.py
Normal file
23
addons/cetmix_tower_yaml/models/cx_tower_server_log.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerServerLog(models.Model):
|
||||
_name = "cx.tower.server.log"
|
||||
_inherit = [
|
||||
"cx.tower.server.log",
|
||||
"cx.tower.yaml.mixin",
|
||||
]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"log_type",
|
||||
"command_id",
|
||||
"use_sudo",
|
||||
"file_template_id",
|
||||
"file_id",
|
||||
]
|
||||
return res
|
||||
41
addons/cetmix_tower_yaml/models/cx_tower_server_template.py
Normal file
41
addons/cetmix_tower_yaml/models/cx_tower_server_template.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerServerTemplate(models.Model):
|
||||
_name = "cx.tower.server.template"
|
||||
_inherit = [
|
||||
"cx.tower.server.template",
|
||||
"cx.tower.yaml.mixin",
|
||||
]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"color",
|
||||
"os_id",
|
||||
"tag_ids",
|
||||
"note",
|
||||
"ssh_port",
|
||||
"ssh_username",
|
||||
"ssh_key_id",
|
||||
"ssh_auth_mode",
|
||||
"use_sudo",
|
||||
"variable_value_ids",
|
||||
"server_log_ids",
|
||||
"shortcut_ids",
|
||||
"scheduled_task_ids",
|
||||
"flight_plan_id",
|
||||
"plan_delete_id",
|
||||
]
|
||||
return res
|
||||
|
||||
def _get_force_x2m_resolve_models(self):
|
||||
res = super()._get_force_x2m_resolve_models()
|
||||
|
||||
# Add Flight Plan in order to always try to use existing one
|
||||
# This is useful to avoid duplicating existing plans
|
||||
res += ["cx.tower.plan", "cx.tower.shortcut", "cx.tower.scheduled.task"]
|
||||
return res
|
||||
22
addons/cetmix_tower_yaml/models/cx_tower_shortcut.py
Normal file
22
addons/cetmix_tower_yaml/models/cx_tower_shortcut.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerShortcut(models.Model):
|
||||
_name = "cx.tower.shortcut"
|
||||
_inherit = ["cx.tower.shortcut", "cx.tower.yaml.mixin"]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"sequence",
|
||||
"access_level",
|
||||
"action",
|
||||
"command_id",
|
||||
"use_sudo",
|
||||
"plan_id",
|
||||
"note",
|
||||
]
|
||||
return res
|
||||
16
addons/cetmix_tower_yaml/models/cx_tower_tag.py
Normal file
16
addons/cetmix_tower_yaml/models/cx_tower_tag.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerTag(models.Model):
|
||||
_name = "cx.tower.tag"
|
||||
_inherit = ["cx.tower.tag", "cx.tower.yaml.mixin"]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"color",
|
||||
]
|
||||
return res
|
||||
23
addons/cetmix_tower_yaml/models/cx_tower_variable.py
Normal file
23
addons/cetmix_tower_yaml/models/cx_tower_variable.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerVariable(models.Model):
|
||||
_name = "cx.tower.variable"
|
||||
_inherit = ["cx.tower.variable", "cx.tower.yaml.mixin"]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"access_level",
|
||||
"variable_type",
|
||||
"option_ids",
|
||||
"applied_expression",
|
||||
"validation_pattern",
|
||||
"validation_message",
|
||||
"note",
|
||||
"tag_ids",
|
||||
]
|
||||
return res
|
||||
18
addons/cetmix_tower_yaml/models/cx_tower_variable_option.py
Normal file
18
addons/cetmix_tower_yaml/models/cx_tower_variable_option.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerVariableOption(models.Model):
|
||||
_name = "cx.tower.variable.option"
|
||||
_inherit = ["cx.tower.variable.option", "cx.tower.yaml.mixin"]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"sequence",
|
||||
"access_level",
|
||||
"name",
|
||||
"value_char",
|
||||
]
|
||||
return res
|
||||
20
addons/cetmix_tower_yaml/models/cx_tower_variable_value.py
Normal file
20
addons/cetmix_tower_yaml/models/cx_tower_variable_value.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerVariableValue(models.Model):
|
||||
_name = "cx.tower.variable.value"
|
||||
_inherit = ["cx.tower.variable.value", "cx.tower.yaml.mixin"]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"sequence",
|
||||
"access_level",
|
||||
"variable_id",
|
||||
"value_char",
|
||||
"variable_ids",
|
||||
"required",
|
||||
]
|
||||
return res
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class CxTowerYamlManifestAuthor(models.Model):
|
||||
"""Author of a YAML manifest (can be one or many)."""
|
||||
|
||||
_name = "cx.tower.yaml.manifest.author"
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"yaml_manifest_author_name_uniq",
|
||||
"unique(name)",
|
||||
"Author name must be unique.",
|
||||
)
|
||||
]
|
||||
_description = "YAML Manifest Author"
|
||||
_order = "name"
|
||||
|
||||
name = fields.Char(required=True, translate=False)
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import re
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class CxTowerYamlManifestTemplate(models.Model):
|
||||
"""Pre-defined YAML manifest template storing common metadata
|
||||
such as authors, website, license, and currency for reuse
|
||||
during YAML exports."""
|
||||
|
||||
_name = "cx.tower.yaml.manifest.tmpl"
|
||||
_description = "YAML Manifest Template"
|
||||
_order = "name"
|
||||
|
||||
name = fields.Char(
|
||||
required=True,
|
||||
help="Name of the manifest template.",
|
||||
)
|
||||
website = fields.Char(help="Website URL for the manifest.")
|
||||
|
||||
author_ids = fields.Many2many(
|
||||
"cx.tower.yaml.manifest.author",
|
||||
string="Authors",
|
||||
help="List of author names to include in the YAML manifest.",
|
||||
)
|
||||
|
||||
license = fields.Selection(
|
||||
selection=lambda self: self._selection_license(),
|
||||
help="License used for the code snippet.",
|
||||
)
|
||||
license_text = fields.Text(
|
||||
help="Custom license text when license type is Custom.",
|
||||
)
|
||||
|
||||
currency = fields.Selection(
|
||||
selection=lambda self: self._selection_currency(),
|
||||
help="Currency for pricing information.",
|
||||
)
|
||||
|
||||
version = fields.Char(
|
||||
help="Version in Major.Minor.Patch format, e.g. 1.0.0",
|
||||
default="1.0.0",
|
||||
)
|
||||
|
||||
file_prefix = fields.Char(
|
||||
string="File prefix",
|
||||
help="Add prefix to the exported YAML file name when this template is selected",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _selection_license(self):
|
||||
"""Return available license options for manifest."""
|
||||
return [
|
||||
("agpl-3", "AGPL-3"),
|
||||
("lgpl-3", "LGPL-3"),
|
||||
("mit", "MIT"),
|
||||
("custom", _("Custom")),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _selection_currency(self):
|
||||
"""Return available currency options for manifest pricing."""
|
||||
return [
|
||||
("EUR", _("Euro")),
|
||||
("USD", _("US Dollar")),
|
||||
]
|
||||
|
||||
@api.constrains("license", "license_text")
|
||||
def _check_license_text_for_custom(self):
|
||||
"""Ensure that custom license text is provided when license is 'custom'."""
|
||||
for rec in self:
|
||||
if rec.license == "custom" and not (rec.license_text or "").strip():
|
||||
raise ValidationError(
|
||||
_("Provide Custom License Text when License is set to 'Custom'.")
|
||||
)
|
||||
|
||||
@api.constrains("version")
|
||||
def _check_version_format(self):
|
||||
"""Ensure the template version follows the x.y.z semantic format.
|
||||
|
||||
The version must consist of three non-negative integers (major, minor, patch)
|
||||
separated by dots—for example, “1.2.3”. Raises a ValidationError otherwise.
|
||||
"""
|
||||
semver = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
for rec in self:
|
||||
if rec.version and not semver.match(rec.version):
|
||||
raise ValidationError(
|
||||
_("Version must be in the Major.Minor.Patch format, e.g. 1.2.3")
|
||||
)
|
||||
577
addons/cetmix_tower_yaml/models/cx_tower_yaml_mixin.py
Normal file
577
addons/cetmix_tower_yaml/models/cx_tower_yaml_mixin.py
Normal file
@@ -0,0 +1,577 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomDumper(yaml.Dumper):
|
||||
"""Custom dumper to ensures code
|
||||
is properly dumped in YAML
|
||||
"""
|
||||
|
||||
def represent_scalar(self, tag, value, style=None):
|
||||
if isinstance(value, str) and "\n" in value:
|
||||
style = "|"
|
||||
return super().represent_scalar(tag, value, style)
|
||||
|
||||
|
||||
class YamlExportCollector:
|
||||
"""
|
||||
Collector for YAML export.
|
||||
Tracks unique records by their (model_name, reference) tuple to avoid duplicates.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the collector.
|
||||
"""
|
||||
self.added_references = set()
|
||||
|
||||
def add(self, key):
|
||||
"""
|
||||
Add a record to the collector if its reference is unique.
|
||||
:param key: tuple, key of the record
|
||||
"""
|
||||
if key and key not in self.added_references:
|
||||
self.added_references.add(key)
|
||||
|
||||
def is_added(self, key):
|
||||
"""
|
||||
Check by (model, reference) tuple.
|
||||
:param key: tuple, key of the record
|
||||
:return: bool
|
||||
"""
|
||||
return key in self.added_references
|
||||
|
||||
|
||||
class CxTowerYamlMixin(models.AbstractModel):
|
||||
"""Used to implement YAML rendering functions.
|
||||
Inherit in your model in case you want to YAML instance of the records.
|
||||
"""
|
||||
|
||||
_name = "cx.tower.yaml.mixin"
|
||||
_description = "Cetmix Tower YAML rendering mixin"
|
||||
|
||||
# File format version in order to track compatibility
|
||||
CETMIX_TOWER_YAML_VERSION = 1
|
||||
|
||||
# TO_YAML_* used to convert from Odoo field values to YAML
|
||||
TO_YAML_ACCESS_LEVEL = {"1": "user", "2": "manager", "3": "root"}
|
||||
|
||||
# TO_TOWER_* used to convert from YAML field values to Tower ones
|
||||
TO_TOWER_ACCESS_LEVEL = {"user": "1", "manager": "2", "root": "3"}
|
||||
|
||||
yaml_code = fields.Text(
|
||||
compute="_compute_yaml_code",
|
||||
inverse="_inverse_yaml_code",
|
||||
groups="cetmix_tower_yaml.group_export,cetmix_tower_yaml.group_import",
|
||||
)
|
||||
|
||||
def _compute_yaml_code(self):
|
||||
"""Compute YAML code based on model record data"""
|
||||
|
||||
# This is used for the file name.
|
||||
# Eg cx.tower.command record will have 'command_' prefix.
|
||||
for record in self:
|
||||
# We are reading field list for each record
|
||||
# because list of fields can differ from record to record
|
||||
record.yaml_code = self._convert_dict_to_yaml(
|
||||
record._prepare_record_for_yaml()
|
||||
)
|
||||
|
||||
def _inverse_yaml_code(self):
|
||||
"""Compose record based on provided YAML"""
|
||||
for record in self:
|
||||
if record.yaml_code:
|
||||
record_yaml_dict = yaml.safe_load(record.yaml_code)
|
||||
record_vals = record._post_process_yaml_dict_values(record_yaml_dict)
|
||||
record.update(record_vals)
|
||||
|
||||
@api.constrains("yaml_code")
|
||||
def _check_yaml_code_write_access(self):
|
||||
"""
|
||||
Check if user has access to create records from YAML.
|
||||
This is checked only when user already has access to export YAML.
|
||||
Otherwise, the field is not accessible due to security group.
|
||||
"""
|
||||
if self.env.user.has_group("cetmix_tower_yaml.group_export") and (
|
||||
not self.env.user.has_group("cetmix_tower_yaml.group_import")
|
||||
and not self.env.user._is_superuser()
|
||||
):
|
||||
raise AccessError(_("You are not allowed to create records from YAML"))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# Handle validation error when field values are not valid
|
||||
try:
|
||||
return super().create(vals_list)
|
||||
except ValueError as e:
|
||||
raise ValidationError(str(e)) from e
|
||||
|
||||
def write(self, vals):
|
||||
# Handle validation error when field values are not valid
|
||||
try:
|
||||
return super().write(vals)
|
||||
except ValueError as e:
|
||||
raise ValidationError(str(e)) from e
|
||||
|
||||
def action_open_yaml_export_wizard(self):
|
||||
"""Open YAML export wizard"""
|
||||
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "cx.tower.yaml.export.wiz",
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
def _convert_dict_to_yaml(self, values):
|
||||
"""Converts Python dictionary to YAML string.
|
||||
|
||||
This is a helper function that is designed to be used
|
||||
by any models that need to convert a dictionary to YAML.
|
||||
|
||||
Args:
|
||||
values (Dict): Dictionary containing data
|
||||
to be converted to YAML format
|
||||
Returns:
|
||||
Text: YAML string
|
||||
Raises:
|
||||
ValidationError: If values is not a dictionary
|
||||
or YAML conversion fails
|
||||
"""
|
||||
if not isinstance(values, dict):
|
||||
raise ValidationError(_("Values must be a dictionary"))
|
||||
try:
|
||||
yaml_code = yaml.dump(
|
||||
values,
|
||||
Dumper=CustomDumper,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
)
|
||||
return yaml_code
|
||||
except (yaml.YAMLError, UnicodeEncodeError) as e:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Failed to convert dictionary" " to YAML: %(error)s",
|
||||
error=str(e),
|
||||
)
|
||||
) from e
|
||||
|
||||
def _prepare_record_for_yaml(self):
|
||||
"""Reads and processes current record before converting it to YAML
|
||||
|
||||
Returns:
|
||||
dict: values ready for YAML conversion
|
||||
"""
|
||||
self.ensure_one()
|
||||
yaml_keys = self._get_fields_for_yaml()
|
||||
record_dict = self.read(fields=yaml_keys)[0]
|
||||
return self._post_process_record_values(record_dict)
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
"""Get ist of field to be present in YAML
|
||||
|
||||
Set 'no_yaml_service_fields' context key to skip
|
||||
service fields creation (cetmix_tower_yaml_version, cetmix_tower_model)
|
||||
|
||||
Returns:
|
||||
list(): list of fields to be used as YAML keys
|
||||
"""
|
||||
return ["reference"]
|
||||
|
||||
def _get_force_x2m_resolve_models(self):
|
||||
"""List of models that will always try to be resolved
|
||||
when referenced in x2m related fields.
|
||||
|
||||
This is useful for models that should always use existing records
|
||||
instead of creating new ones when referenced in x2m related fields.
|
||||
Such as variables or tags.
|
||||
|
||||
Returns:
|
||||
List: list of models that will always try to be resolved
|
||||
"""
|
||||
return [
|
||||
"cx.tower.variable",
|
||||
"cx.tower.variable.option",
|
||||
"cx.tower.tag",
|
||||
"cx.tower.os",
|
||||
"cx.tower.key",
|
||||
]
|
||||
|
||||
def _post_process_record_values(self, values):
|
||||
"""Post process record values
|
||||
before converting them to YAML
|
||||
|
||||
Args:
|
||||
values (dict): values returned by 'read' method
|
||||
|
||||
Context:
|
||||
explode_related_record: if set will return entire record dictionary
|
||||
not just a reference
|
||||
remove_empty_values: if set will remove empty values from the record
|
||||
|
||||
Returns:
|
||||
dict(): processed values
|
||||
"""
|
||||
collector = self._context.get("yaml_collector")
|
||||
ref = values.get("reference")
|
||||
collector_key = (self._name, ref) if ref else None
|
||||
|
||||
if collector and collector_key and collector.is_added(collector_key):
|
||||
return {"reference": ref}
|
||||
|
||||
# We don't need id because we are not using it
|
||||
values.pop("id", None)
|
||||
|
||||
# Add YAML format version and model
|
||||
if not self._context.get("no_yaml_service_fields"):
|
||||
model_name = self._name.replace("cx.tower.", "").replace(".", "_")
|
||||
model_values = {
|
||||
"cetmix_tower_model": model_name,
|
||||
}
|
||||
else:
|
||||
model_values = {}
|
||||
|
||||
# Parse access level
|
||||
access_level = values.pop("access_level", None)
|
||||
if access_level:
|
||||
model_values.update(
|
||||
{"access_level": self.TO_YAML_ACCESS_LEVEL[access_level]}
|
||||
)
|
||||
|
||||
values = {**model_values, **values}
|
||||
# Copy values to avoid modifying the original values
|
||||
new_values = values.copy()
|
||||
|
||||
# Check if we need to return a record dict or just a reference
|
||||
# Use context value first, revert to the record setting if not defined
|
||||
explode_related_record = self._context.get("explode_related_record")
|
||||
|
||||
# Check if we need to remove empty values
|
||||
# Currently only x2m fields are supported
|
||||
remove_empty_values = self._context.get("remove_empty_values")
|
||||
|
||||
# Post process m2o and x2m fields
|
||||
for key, value in values.items():
|
||||
# IMPORTANT: Odoo naming patterns must be followed for related fields.
|
||||
# This is why we are checking for the field name ending here.
|
||||
# Further checks for the field type are done
|
||||
# in _process_relation_field_value()
|
||||
if key.endswith("_id") or key.endswith("_ids"):
|
||||
if not value and remove_empty_values:
|
||||
del new_values[key]
|
||||
else:
|
||||
processed_value = self.with_context(
|
||||
explode_related_record=explode_related_record
|
||||
)._process_relation_field_value(key, value, record_mode=True)
|
||||
new_values.update({key: processed_value})
|
||||
|
||||
if collector and collector_key:
|
||||
collector.add(collector_key)
|
||||
|
||||
return new_values
|
||||
|
||||
def _post_process_yaml_dict_values(self, values):
|
||||
"""Post process dictionary values generated from YAML code
|
||||
|
||||
Args:
|
||||
values (dict): Dictionary generated from YAML
|
||||
|
||||
Returns:
|
||||
dict(): Post-processed values
|
||||
"""
|
||||
|
||||
# Remove model data because it is not a field
|
||||
if "cetmix_tower_model" in values:
|
||||
values.pop("cetmix_tower_model")
|
||||
|
||||
# Parse access level
|
||||
if "access_level" in values:
|
||||
values_access_level = values["access_level"]
|
||||
access_level = self.TO_TOWER_ACCESS_LEVEL.get(values_access_level)
|
||||
if access_level:
|
||||
values.update({"access_level": access_level})
|
||||
else:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Wrong value for 'access_level' key: %(acv)s",
|
||||
acv=values_access_level,
|
||||
)
|
||||
)
|
||||
|
||||
# Leave supported keys only
|
||||
supported_keys = self._get_fields_for_yaml()
|
||||
filtered_values = {k: v for k, v in values.items() if k in supported_keys}
|
||||
|
||||
# Post process m2o fields
|
||||
for key, value in filtered_values.items():
|
||||
# IMPORTANT: Odoo naming patterns must be followed for related fields.
|
||||
# This is why we are checking for the field name ending here.
|
||||
# Further checks for the field type are done
|
||||
# in _process_relation_field_value()
|
||||
if key.endswith("_id") or key.endswith("_ids"):
|
||||
processed_value = self.with_context(
|
||||
explode_related_record=True
|
||||
)._process_relation_field_value(key, value, record_mode=False)
|
||||
filtered_values.update({key: processed_value})
|
||||
|
||||
return filtered_values
|
||||
|
||||
def _process_relation_field_value(self, field, value, record_mode=False):
|
||||
"""Post process One2many, Many2many or Many2one value
|
||||
|
||||
Args:
|
||||
field (Char): Field the value belongs to
|
||||
value (Char): Value to process
|
||||
record_mode (Bool): If True process value as a record value
|
||||
else process value as a YAML value
|
||||
Context:
|
||||
explode_related_record: if set will return entire record dictionary
|
||||
not just a reference
|
||||
Returns:
|
||||
dict() or Char: record dictionary if fetch_record else reference
|
||||
"""
|
||||
# Step 1: Return False if the value is not set or the field is not found
|
||||
if not value:
|
||||
return False
|
||||
|
||||
field_obj = self._fields.get(field)
|
||||
if not field_obj:
|
||||
return False
|
||||
|
||||
# Step 2: Return False if the field type doesn't match
|
||||
# or comodel is not defined
|
||||
field_type = field_obj.type
|
||||
if (
|
||||
field_type not in ["one2many", "many2many", "many2one"]
|
||||
or not field_obj.comodel_name
|
||||
):
|
||||
return False
|
||||
|
||||
comodel = self.env[field_obj.comodel_name]
|
||||
explode_related_record = self._context.get("explode_related_record")
|
||||
|
||||
# Step 3: process value based on the field type
|
||||
if field_type == "many2one":
|
||||
return self._process_m2o_value(
|
||||
comodel, value, explode_related_record, record_mode
|
||||
)
|
||||
if field_type in ["one2many", "many2many"]:
|
||||
return self._process_x2m_values(
|
||||
comodel, field_type, value, explode_related_record, record_mode
|
||||
)
|
||||
|
||||
# Step 4: fall back if field type is not supported
|
||||
return False
|
||||
|
||||
def _process_m2o_value(
|
||||
self, comodel, value, explode_related_record, record_mode=False
|
||||
):
|
||||
"""Post process many2one value
|
||||
Args:
|
||||
comodel (BaseClass): Model the value belongs to
|
||||
value (Char): Value to process
|
||||
explode_related_record (Bool): If True return entire record dict
|
||||
instead of a reference
|
||||
record_mode (Bool): If True process value as a record value
|
||||
else process value as a YAML value
|
||||
|
||||
Returns:
|
||||
dict() or Char: record dictionary if fetch_record else reference
|
||||
"""
|
||||
|
||||
# -- (Record -> YAML)
|
||||
if record_mode:
|
||||
# Retrieve the record based on the ID provided in the value
|
||||
record = comodel.browse(value[0])
|
||||
|
||||
# If the context specifies to explode the related record,
|
||||
# return its dictionary representation
|
||||
if explode_related_record:
|
||||
return (
|
||||
record.with_context(
|
||||
no_yaml_service_fields=True
|
||||
)._prepare_record_for_yaml()
|
||||
if record
|
||||
else False
|
||||
)
|
||||
|
||||
# Otherwise, return just the reference (or False if record does not exist)
|
||||
return record.reference if record else False
|
||||
|
||||
# -- (YAML -> Record)
|
||||
# Step 1: Process value in normal mode
|
||||
record = False
|
||||
|
||||
# If the value is a string, it is treated as a reference
|
||||
if isinstance(value, str):
|
||||
reference = value
|
||||
|
||||
# If the value is a dictionary, extract the reference from it
|
||||
elif isinstance(value, dict):
|
||||
reference = value.get("reference")
|
||||
|
||||
record = self._update_or_create_related_record(
|
||||
comodel, reference, value, create_immediately=True
|
||||
)
|
||||
else:
|
||||
return False
|
||||
|
||||
# Step 2: Final fallback: attempt to retrieve the record by reference if set,
|
||||
# return its ID or False
|
||||
if not record and reference:
|
||||
record = comodel.get_by_reference(reference)
|
||||
return record.id if record else False
|
||||
|
||||
def _process_x2m_values(
|
||||
self, comodel, field_type, values, explode_related_record, record_mode=False
|
||||
):
|
||||
"""Post process many2many value
|
||||
Args:
|
||||
comodel (BaseClass): Model the value belongs to
|
||||
field_type (Char): Field type
|
||||
values (list()): Values to process
|
||||
explode_related_record (Bool): If True return entire record dict
|
||||
instead of a reference
|
||||
record_mode (Bool): If True process value as a record value
|
||||
else process value as a YAML value
|
||||
|
||||
Returns:
|
||||
dict() or Char: record dictionary if fetch_record else reference
|
||||
"""
|
||||
|
||||
# -- (Record -> YAML)
|
||||
if record_mode:
|
||||
record_list = []
|
||||
for value in values:
|
||||
# Retrieve the record based on the ID provided in the value
|
||||
record = comodel.browse(value)
|
||||
|
||||
# If the context specifies to explode the related record,
|
||||
# return its dictionary representation
|
||||
if explode_related_record:
|
||||
record_list.append(
|
||||
record.with_context(
|
||||
no_yaml_service_fields=True
|
||||
)._prepare_record_for_yaml()
|
||||
if record
|
||||
else False
|
||||
)
|
||||
|
||||
# Otherwise, return just the reference
|
||||
# (or False if record does not exist)
|
||||
else:
|
||||
record_list.append(record.reference if record else False)
|
||||
|
||||
return record_list
|
||||
|
||||
# -- (YAML -> Record)
|
||||
# Step 1: Process value in normal mode
|
||||
record_ids = []
|
||||
|
||||
for value in values:
|
||||
record = False
|
||||
# If the value is a string, it is treated as a reference
|
||||
if isinstance(value, str):
|
||||
reference = value
|
||||
|
||||
# If the value is a dictionary, extract the reference from it
|
||||
elif isinstance(value, dict):
|
||||
reference = value.get("reference")
|
||||
record = self._update_or_create_related_record(
|
||||
comodel,
|
||||
reference,
|
||||
value,
|
||||
create_immediately=field_type == "many2many",
|
||||
)
|
||||
|
||||
# Step 2: Final fallback: attempt to retrieve the record by reference
|
||||
# Return record ID or False if reference is not defined
|
||||
if not record and reference:
|
||||
record = comodel.get_by_reference(reference)
|
||||
|
||||
# Save record data
|
||||
if record:
|
||||
record_ids.append(
|
||||
record if isinstance(record, tuple) else (4, record.id)
|
||||
)
|
||||
|
||||
return record_ids
|
||||
|
||||
def _update_or_create_related_record(
|
||||
self, model, reference, values, create_immediately=False
|
||||
):
|
||||
"""Update related record with provided values or create a new one
|
||||
|
||||
Args:
|
||||
model (BaseModel): Related record model
|
||||
values (dict()): Values to update existing/create new record
|
||||
reference (Char): Record reference
|
||||
create_immediately (Bool): If True create a new record immediately.
|
||||
Used for Many2one fields.
|
||||
|
||||
Context:
|
||||
force_create_related_record (Bool): If True, create a new record
|
||||
even if reference is provided.
|
||||
|
||||
Returns:
|
||||
record: Existing record or new record tuple
|
||||
"""
|
||||
|
||||
# If reference is found, retrieve the corresponding record
|
||||
if reference and (
|
||||
model._name in self._get_force_x2m_resolve_models()
|
||||
or not self._context.get("force_create_related_record")
|
||||
):
|
||||
record = model.get_by_reference(reference)
|
||||
# If the record exists, update it with the values from the dictionary
|
||||
if record:
|
||||
# Remove reference from values to avoid possible consequences
|
||||
values.pop("reference", None)
|
||||
record.with_context(from_yaml=True).write(
|
||||
record._post_process_yaml_dict_values(values)
|
||||
)
|
||||
|
||||
# If the record does not exist, create a new one
|
||||
else:
|
||||
if create_immediately:
|
||||
record = model.with_context(from_yaml=True).create(
|
||||
model._post_process_yaml_dict_values(values)
|
||||
)
|
||||
else:
|
||||
# Use "Create" service command tuple
|
||||
record = (0, 0, model._post_process_yaml_dict_values(values))
|
||||
|
||||
# If there's no reference but value is a dict, create a new record
|
||||
else:
|
||||
if create_immediately:
|
||||
# Only 'reference' provided, no other data: do not create,
|
||||
# just log warning
|
||||
if set(values.keys()) == {"reference"}:
|
||||
_logger.warning(
|
||||
"Attempted to import a record for model '%s' with reference "
|
||||
"'%s', but only the 'reference' field was provided. "
|
||||
"It is possible that this record has already been imported. "
|
||||
"Creation will be skipped.",
|
||||
model._name,
|
||||
reference,
|
||||
)
|
||||
return False
|
||||
|
||||
record = model.with_context(from_yaml=True).create(
|
||||
model._post_process_yaml_dict_values(values)
|
||||
)
|
||||
else:
|
||||
# Use "Create" service command tuple
|
||||
record = (0, 0, model._post_process_yaml_dict_values(values))
|
||||
|
||||
# Return the record's ID if it exists, otherwise return False
|
||||
return record or False
|
||||
3
addons/cetmix_tower_yaml/pyproject.toml
Normal file
3
addons/cetmix_tower_yaml/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
||||
1
addons/cetmix_tower_yaml/readme/CONFIGURE.md
Normal file
1
addons/cetmix_tower_yaml/readme/CONFIGURE.md
Normal file
@@ -0,0 +1 @@
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions.
|
||||
3
addons/cetmix_tower_yaml/readme/DESCRIPTION.md
Normal file
3
addons/cetmix_tower_yaml/readme/DESCRIPTION.md
Normal file
@@ -0,0 +1,3 @@
|
||||
This module implements YAML format data import/export for [Cetmix Tower](https://cetmix.com/tower).
|
||||
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed information.
|
||||
69
addons/cetmix_tower_yaml/readme/HISTORY.md
Normal file
69
addons/cetmix_tower_yaml/readme/HISTORY.md
Normal file
@@ -0,0 +1,69 @@
|
||||
## 16.0.2.0.1 (2025-10-29)
|
||||
|
||||
- Features: Improve the way secrets are listed in the YAML import widget. (5010)
|
||||
|
||||
|
||||
## 16.0.1.4.2 (2025-10-06)
|
||||
|
||||
- Bugfixes: Add the missing 'create' function decorator (4980)
|
||||
|
||||
|
||||
## 16.0.1.4.1 (2025-08-26)
|
||||
|
||||
- Bugfixes: Make selection values lowercase to simplify their management. (4896)
|
||||
|
||||
|
||||
## 16.0.1.3.0 (2025-07-30)
|
||||
|
||||
- Features: Optional behaviour when file uploaded by command already exists on the server. (4740)
|
||||
|
||||
|
||||
## 16.0.1.1.4 (2025-07-08)
|
||||
|
||||
- Bugfixes: Fix missing model names in YAML exports when exporting multiple commands with flight plans (4820)
|
||||
|
||||
|
||||
## 16.0.1.1.3 (2025-07-07)
|
||||
|
||||
- Bugfixes: Import servers with `Password` ssh authentication mode (4812)
|
||||
|
||||
|
||||
## 16.0.1.1.1 (2025-06-23)
|
||||
|
||||
- Features: YAML code optimisation (4728)
|
||||
|
||||
|
||||
## 16.0.1.1.0 (2025-06-20)
|
||||
|
||||
- Features: Export/import scheduled tasks to/from YAML. (4650)
|
||||
|
||||
|
||||
## 16.0.1.0.5 (2025-05-21)
|
||||
|
||||
- Features: Export/import secret values related to Server. (4696)
|
||||
|
||||
|
||||
## 16.0.1.0.4 (2025-05-16)
|
||||
|
||||
- Features: Export/import servers and files to/from YAML. (4670)
|
||||
|
||||
|
||||
## 16.0.1.0.3 (2025-05-09)
|
||||
|
||||
- Bugfixes: Non-critical issues and performance improvements. (4663)
|
||||
|
||||
|
||||
## 16.0.1.0.2 (2025-04-30)
|
||||
|
||||
- Features: User groups are visible without developer mode. (4642)
|
||||
|
||||
|
||||
## 16.0.1.0.1 (2025-04-21)
|
||||
|
||||
- Features: Export additional fields for shortcuts, variables and options.
|
||||
Add action menu to export keys/secrets. (4602)
|
||||
|
||||
|
||||
## 16.0.1.0.0
|
||||
|
||||
Release for Odoo 16.0
|
||||
1
addons/cetmix_tower_yaml/readme/USAGE.md
Normal file
1
addons/cetmix_tower_yaml/readme/USAGE.md
Normal file
@@ -0,0 +1 @@
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions.
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="ir_module_category_tower_yaml_export" model="ir.module.category">
|
||||
<field name="parent_id" ref="cetmix_tower_server.ir_module_category_tower" />
|
||||
<field name="name">YAML Export</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_module_category_tower_yaml_import" model="ir.module.category">
|
||||
<field name="parent_id" ref="cetmix_tower_server.ir_module_category_tower" />
|
||||
<field name="name">YAML Import</field>
|
||||
</record>
|
||||
|
||||
<record id="group_export" model="res.groups">
|
||||
<field name="name">Allow</field>
|
||||
<field name="category_id" ref="ir_module_category_tower_yaml_export" />
|
||||
<field name="comment">
|
||||
Export data to YAML.
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="group_import" model="res.groups">
|
||||
<field name="name">Allow</field>
|
||||
<field name="category_id" ref="ir_module_category_tower_yaml_import" />
|
||||
<field name="comment">
|
||||
Import data from YAML.
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- cx.tower.yaml.export.wiz -->
|
||||
<record id="rule_cx_tower_yaml_export_wiz_creator_only" model="ir.rule">
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_yaml_export_wiz" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
<!-- cx.tower.yaml.export.wiz.download -->
|
||||
|
||||
<record
|
||||
id="rule_cx_tower_yaml_export_wiz_download_creator_only"
|
||||
model="ir.rule"
|
||||
>
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_yaml_export_wiz_download" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- cx.tower.yaml.import.wiz -->
|
||||
<record id="rule_cx_tower_yaml_import_wiz_creator_only" model="ir.rule">
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_yaml_import_wiz" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- cx.tower.yaml.import.wiz.upload -->
|
||||
<record id="rule_cx_tower_yaml_import_wiz_upload_creator_only" model="ir.rule">
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_yaml_import_wiz_upload" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
9
addons/cetmix_tower_yaml/security/ir.model.access.csv
Normal file
9
addons/cetmix_tower_yaml/security/ir.model.access.csv
Normal file
@@ -0,0 +1,9 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_yaml_export_wizard,Export YAML,model_cx_tower_yaml_export_wiz,group_export,1,1,1,1
|
||||
access_yaml_export_wizard_download,Export YAML File,model_cx_tower_yaml_export_wiz_download,group_export,1,1,1,1
|
||||
access_yaml_import_wizard_upload,Import YAML,model_cx_tower_yaml_import_wiz_upload,group_import,1,1,1,1
|
||||
access_yaml_import_wizard,Import YAML,model_cx_tower_yaml_import_wiz,group_import,1,1,1,1
|
||||
access_manifest_tmpl_read_export,Manifest tmpl read (export),model_cx_tower_yaml_manifest_tmpl,cetmix_tower_yaml.group_export,1,0,0,0
|
||||
access_manifest_tmpl_admin,Manifest tmpl admin,model_cx_tower_yaml_manifest_tmpl,cetmix_tower_server.group_root,1,1,1,1
|
||||
access_manifest_author_read_export,Manifest author read (export),model_cx_tower_yaml_manifest_author,cetmix_tower_yaml.group_export,1,0,0,0
|
||||
access_manifest_author_admin,Manifest author admin,model_cx_tower_yaml_manifest_author,cetmix_tower_server.group_root,1,1,1,1
|
||||
|
BIN
addons/cetmix_tower_yaml/static/description/icon.png
Normal file
BIN
addons/cetmix_tower_yaml/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
534
addons/cetmix_tower_yaml/static/description/index.html
Normal file
534
addons/cetmix_tower_yaml/static/description/index.html
Normal file
@@ -0,0 +1,534 @@
|
||||
<!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 YAML</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-yaml">
|
||||
<h1 class="title">Cetmix Tower YAML</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:96e8f3f1df3ab25b952a9534d0914149740cc036b62efe2c7795f9d2d9636177
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<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_yaml"><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 YAML format data import/export for <a class="reference external" href="https://cetmix.com/tower">Cetmix
|
||||
Tower</a>.</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.1 (2025-10-29)</a></li>
|
||||
<li><a class="reference internal" href="#section-2" id="toc-entry-5">16.0.1.4.2 (2025-10-06)</a></li>
|
||||
<li><a class="reference internal" href="#section-3" id="toc-entry-6">16.0.1.4.1 (2025-08-26)</a></li>
|
||||
<li><a class="reference internal" href="#section-4" id="toc-entry-7">16.0.1.3.0 (2025-07-30)</a></li>
|
||||
<li><a class="reference internal" href="#section-5" id="toc-entry-8">16.0.1.1.4 (2025-07-08)</a></li>
|
||||
<li><a class="reference internal" href="#section-6" id="toc-entry-9">16.0.1.1.3 (2025-07-07)</a></li>
|
||||
<li><a class="reference internal" href="#section-7" id="toc-entry-10">16.0.1.1.1 (2025-06-23)</a></li>
|
||||
<li><a class="reference internal" href="#section-8" id="toc-entry-11">16.0.1.1.0 (2025-06-20)</a></li>
|
||||
<li><a class="reference internal" href="#section-9" id="toc-entry-12">16.0.1.0.5 (2025-05-21)</a></li>
|
||||
<li><a class="reference internal" href="#section-10" id="toc-entry-13">16.0.1.0.4 (2025-05-16)</a></li>
|
||||
<li><a class="reference internal" href="#section-11" id="toc-entry-14">16.0.1.0.3 (2025-05-09)</a></li>
|
||||
<li><a class="reference internal" href="#section-12" id="toc-entry-15">16.0.1.0.2 (2025-04-30)</a></li>
|
||||
<li><a class="reference internal" href="#section-13" id="toc-entry-16">16.0.1.0.1 (2025-04-21)</a></li>
|
||||
<li><a class="reference internal" href="#section-14" id="toc-entry-17">16.0.1.0.0</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-18">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="toc-entry-19">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="toc-entry-20">Authors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="toc-entry-21">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.1 (2025-10-29)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Improve the way secrets are listed in the YAML import
|
||||
widget. (5010)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-2">
|
||||
<h2><a class="toc-backref" href="#toc-entry-5">16.0.1.4.2 (2025-10-06)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Add the missing ‘create’ function decorator (4980)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-3">
|
||||
<h2><a class="toc-backref" href="#toc-entry-6">16.0.1.4.1 (2025-08-26)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Make selection values lowercase to simplify their
|
||||
management. (4896)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-4">
|
||||
<h2><a class="toc-backref" href="#toc-entry-7">16.0.1.3.0 (2025-07-30)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Optional behaviour when file uploaded by command already
|
||||
exists on the server. (4740)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-5">
|
||||
<h2><a class="toc-backref" href="#toc-entry-8">16.0.1.1.4 (2025-07-08)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Fix missing model names in YAML exports when exporting
|
||||
multiple commands with flight plans (4820)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-6">
|
||||
<h2><a class="toc-backref" href="#toc-entry-9">16.0.1.1.3 (2025-07-07)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Import servers with <tt class="docutils literal">Password</tt> ssh authentication mode
|
||||
(4812)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-7">
|
||||
<h2><a class="toc-backref" href="#toc-entry-10">16.0.1.1.1 (2025-06-23)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: YAML code optimisation (4728)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-8">
|
||||
<h2><a class="toc-backref" href="#toc-entry-11">16.0.1.1.0 (2025-06-20)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Export/import scheduled tasks to/from YAML. (4650)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-9">
|
||||
<h2><a class="toc-backref" href="#toc-entry-12">16.0.1.0.5 (2025-05-21)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Export/import secret values related to Server. (4696)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-10">
|
||||
<h2><a class="toc-backref" href="#toc-entry-13">16.0.1.0.4 (2025-05-16)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Export/import servers and files to/from YAML. (4670)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-11">
|
||||
<h2><a class="toc-backref" href="#toc-entry-14">16.0.1.0.3 (2025-05-09)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Bugfixes: Non-critical issues and performance improvements. (4663)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-12">
|
||||
<h2><a class="toc-backref" href="#toc-entry-15">16.0.1.0.2 (2025-04-30)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: User groups are visible without developer mode. (4642)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-13">
|
||||
<h2><a class="toc-backref" href="#toc-entry-16">16.0.1.0.1 (2025-04-21)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Export additional fields for shortcuts, variables and
|
||||
options. Add action menu to export keys/secrets. (4602)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="section-14">
|
||||
<h2><a class="toc-backref" href="#toc-entry-17">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-18">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_yaml%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-19">Credits</a></h1>
|
||||
<div class="section" id="authors">
|
||||
<h2><a class="toc-backref" href="#toc-entry-20">Authors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Cetmix</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2><a class="toc-backref" href="#toc-entry-21">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_yaml">cetmix/cetmix-tower</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
8
addons/cetmix_tower_yaml/tests/__init__.py
Normal file
8
addons/cetmix_tower_yaml/tests/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from . import test_command
|
||||
from . import test_tower_yaml_mixin
|
||||
from . import test_file_template
|
||||
from . import test_plan
|
||||
from . import test_yaml_export_wizard
|
||||
from . import test_yaml_import_wizard
|
||||
from . import test_server_log
|
||||
from . import test_server_yaml
|
||||
347
addons/cetmix_tower_yaml/tests/test_command.py
Normal file
347
addons/cetmix_tower_yaml/tests/test_command.py
Normal file
@@ -0,0 +1,347 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
import yaml
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
|
||||
class TestTowerCommand(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls, *args, **kwargs):
|
||||
super().setUpClass(*args, **kwargs)
|
||||
|
||||
cls.Command = cls.env["cx.tower.command"]
|
||||
|
||||
# Expected YAML content of the test command
|
||||
cls.command_test_yaml = """cetmix_tower_model: command
|
||||
access_level: manager
|
||||
reference: test_yaml_in_tests
|
||||
name: Test YAML
|
||||
action: ssh_command
|
||||
allow_parallel_run: false
|
||||
note: |-
|
||||
Test YAML command conversion.
|
||||
Ensure all fields are rendered properly.
|
||||
os_ids: false
|
||||
tag_ids: false
|
||||
path: false
|
||||
file_template_id: false
|
||||
flight_plan_id: false
|
||||
code: |-
|
||||
cd /home/{{ tower.server.ssh_username }} \\
|
||||
&& ls -lha
|
||||
server_status: false
|
||||
variable_ids: false
|
||||
secret_ids: false
|
||||
no_split_for_sudo: false
|
||||
if_file_exists: skip
|
||||
disconnect_file: false
|
||||
"""
|
||||
|
||||
# YAML content translated into Python dict
|
||||
cls.command_test_yaml_dict = yaml.safe_load(cls.command_test_yaml)
|
||||
|
||||
def test_yaml_from_command(self):
|
||||
"""Test if YAML is generated properly from a command"""
|
||||
|
||||
# -- 0 --
|
||||
# Create test command
|
||||
# Test command
|
||||
command_test = self.Command.create(
|
||||
{
|
||||
"name": "Test YAML",
|
||||
"reference": "test_yaml_in_tests",
|
||||
"action": "ssh_command",
|
||||
"code": """cd /home/{{ tower.server.ssh_username }} \\
|
||||
&& ls -lha""",
|
||||
"note": """Test YAML command conversion.
|
||||
Ensure all fields are rendered properly.""",
|
||||
}
|
||||
)
|
||||
|
||||
# -- 1 --
|
||||
# Check it YAML generated by the command matches
|
||||
# YAML from the template file
|
||||
self.assertEqual(
|
||||
command_test.yaml_code,
|
||||
self.command_test_yaml,
|
||||
"YAML generated from command doesn't match template file one",
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Check if YAML key values match Cetmix Tower ones
|
||||
|
||||
self.assertEqual(
|
||||
command_test.access_level,
|
||||
self.Command.TO_TOWER_ACCESS_LEVEL[
|
||||
self.command_test_yaml_dict["access_level"]
|
||||
],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command_test.action,
|
||||
self.command_test_yaml_dict["action"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command_test.allow_parallel_run,
|
||||
self.command_test_yaml_dict["allow_parallel_run"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command_test.code,
|
||||
self.command_test_yaml_dict["code"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command_test.name,
|
||||
self.command_test_yaml_dict["name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command_test.note,
|
||||
self.command_test_yaml_dict["note"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command_test.path,
|
||||
self.command_test_yaml_dict["path"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command_test.reference,
|
||||
self.command_test_yaml_dict["reference"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command_test.if_file_exists,
|
||||
self.command_test_yaml_dict["if_file_exists"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command_test.disconnect_file,
|
||||
self.command_test_yaml_dict["disconnect_file"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
|
||||
def test_command_from_yaml(self):
|
||||
"""Test if YAML is generated properly from a command"""
|
||||
|
||||
def test_yaml(command):
|
||||
"""Checks if yaml values are inserted correctly
|
||||
|
||||
Args:
|
||||
command(cx.tower.command): _description_
|
||||
"""
|
||||
self.assertEqual(
|
||||
command.access_level,
|
||||
self.Command.TO_TOWER_ACCESS_LEVEL[
|
||||
self.command_test_yaml_dict["access_level"]
|
||||
],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command.action,
|
||||
self.command_test_yaml_dict["action"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command.allow_parallel_run,
|
||||
self.command_test_yaml_dict["allow_parallel_run"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command.code,
|
||||
self.command_test_yaml_dict["code"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command.name,
|
||||
self.command_test_yaml_dict["name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command.note,
|
||||
self.command_test_yaml_dict["note"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command.path,
|
||||
self.command_test_yaml_dict["path"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command.reference,
|
||||
self.command_test_yaml_dict["reference"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command.if_file_exists,
|
||||
self.command_test_yaml_dict["if_file_exists"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
command.disconnect_file,
|
||||
self.command_test_yaml_dict["disconnect_file"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
|
||||
# Create test command
|
||||
command_test = self.Command.create(
|
||||
{"name": "New Command", "action": "python_code"}
|
||||
)
|
||||
|
||||
# -- 1 --
|
||||
# Insert YAML into the command and
|
||||
# check if YAML key values match Cetmix Tower ones
|
||||
command_test.yaml_code = self.command_test_yaml
|
||||
test_yaml(command_test)
|
||||
|
||||
# -- 2 --
|
||||
# Insert some non supported keys and ensure nothing bad happens
|
||||
yaml_with_non_supported_keys = """access_level: manager
|
||||
action: ssh_command
|
||||
doge: wow
|
||||
memes: much nice!
|
||||
allow_parallel_run: false
|
||||
cetmix_tower_model: command
|
||||
code: |-
|
||||
cd /home/{{ tower.server.ssh_username }} \\
|
||||
&& ls -lha
|
||||
file_template_id: false
|
||||
flight_plan_id: false
|
||||
name: Test YAML
|
||||
note: |-
|
||||
Test YAML command conversion.
|
||||
Ensure all fields are rendered properly.
|
||||
path: false
|
||||
reference: test_yaml_in_tests
|
||||
tag_ids: false
|
||||
"""
|
||||
command_test.yaml_code = yaml_with_non_supported_keys
|
||||
test_yaml(command_test)
|
||||
|
||||
# -- 3 --
|
||||
# Insert non existing selection field value and exception is raised
|
||||
yaml_with_non_supported_keys = """access_level: manager
|
||||
action: non_existing_action
|
||||
doge: wow
|
||||
memes: much nice!
|
||||
allow_parallel_run: false
|
||||
cetmix_tower_model: command
|
||||
code: |-
|
||||
cd /home/{{ tower.server.ssh_username }} \\
|
||||
&& ls -lha
|
||||
file_template_id: false
|
||||
flight_plan_id: false
|
||||
name: Test YAML
|
||||
note: |-
|
||||
Test YAML command conversion.
|
||||
Ensure all fields are rendered properly.
|
||||
path: false
|
||||
reference: test_yaml_in_tests
|
||||
tag_ids: false
|
||||
"""
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
command_test.yaml_code = yaml_with_non_supported_keys
|
||||
self.assertIn("non_existing_action", str(e.exception))
|
||||
self.assertEqual(
|
||||
str(e),
|
||||
"Wrong value for cx.tower.command.action: 'non_existing_action'",
|
||||
"Exception message doesn't match",
|
||||
)
|
||||
|
||||
def test_command_with_action_file_template(self):
|
||||
"""Test command with 'File from template' action"""
|
||||
yaml_with_reference = """cetmix_tower_model: command
|
||||
access_level: manager
|
||||
reference: such_much_test_command
|
||||
name: Such Much Command
|
||||
action: file_using_template
|
||||
allow_parallel_run: false
|
||||
note: Just a note
|
||||
os_ids: false
|
||||
tag_ids: false
|
||||
path: false
|
||||
file_template_id: my_custom_test_template
|
||||
flight_plan_id: false
|
||||
code: false
|
||||
server_status: false
|
||||
variable_ids: false
|
||||
secret_ids: false
|
||||
no_split_for_sudo: false
|
||||
if_file_exists: skip
|
||||
disconnect_file: false
|
||||
"""
|
||||
# Add file template
|
||||
file_template = self.env["cx.tower.file.template"].create(
|
||||
{
|
||||
"name": "Such much demo",
|
||||
"reference": "my_custom_test_template",
|
||||
"file_name": "much_logs.txt",
|
||||
"server_dir": "/var/log/my/files",
|
||||
"source": "tower",
|
||||
"file_type": "text",
|
||||
"note": "Hey!",
|
||||
"keep_when_deleted": False,
|
||||
}
|
||||
)
|
||||
command_with_template = self.Command.create(
|
||||
{
|
||||
"name": "Such Much Command",
|
||||
"reference": "such_much_test_command",
|
||||
"action": "file_using_template",
|
||||
"note": "Just a note",
|
||||
"file_template_id": file_template.id,
|
||||
}
|
||||
)
|
||||
|
||||
# -- 1 --
|
||||
# Check if final YAML composed correctly
|
||||
self.assertEqual(
|
||||
command_with_template.yaml_code,
|
||||
yaml_with_reference,
|
||||
"YAML is not composed correctly",
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Explode related record and check the YAML
|
||||
yaml_with_reference_exploded = """cetmix_tower_model: command
|
||||
access_level: manager
|
||||
reference: such_much_test_command
|
||||
name: Such Much Command
|
||||
action: file_using_template
|
||||
allow_parallel_run: false
|
||||
note: Just a note
|
||||
os_ids: false
|
||||
tag_ids: false
|
||||
path: false
|
||||
file_template_id:
|
||||
reference: my_custom_test_template
|
||||
name: Such much demo
|
||||
source: tower
|
||||
file_type: text
|
||||
server_dir: /var/log/my/files
|
||||
file_name: much_logs.txt
|
||||
keep_when_deleted: false
|
||||
tag_ids: false
|
||||
note: Hey!
|
||||
code: false
|
||||
variable_ids: false
|
||||
secret_ids: false
|
||||
flight_plan_id: false
|
||||
code: false
|
||||
server_status: false
|
||||
variable_ids: false
|
||||
secret_ids: false
|
||||
no_split_for_sudo: false
|
||||
if_file_exists: skip
|
||||
disconnect_file: false
|
||||
"""
|
||||
command_with_template.invalidate_recordset(["yaml_code"])
|
||||
self.assertEqual(
|
||||
command_with_template.with_context(explode_related_record=True).yaml_code,
|
||||
yaml_with_reference_exploded,
|
||||
"YAML is not composed correctly",
|
||||
)
|
||||
320
addons/cetmix_tower_yaml/tests/test_file_template.py
Normal file
320
addons/cetmix_tower_yaml/tests/test_file_template.py
Normal file
@@ -0,0 +1,320 @@
|
||||
import yaml
|
||||
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
|
||||
class TestTowerFileTemplate(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls, *args, **kwargs):
|
||||
super().setUpClass(*args, **kwargs)
|
||||
|
||||
cls.FileTemplate = cls.env["cx.tower.file.template"]
|
||||
|
||||
# Expected YAML content of the test file template
|
||||
cls.file_template_test_yaml = """cetmix_tower_model: file_template
|
||||
reference: dockerfile_unit_test
|
||||
name: Dockerfile Test
|
||||
source: tower
|
||||
file_type: text
|
||||
server_dir: /opt
|
||||
file_name: Dockerfile
|
||||
keep_when_deleted: true
|
||||
tag_ids: false
|
||||
note: |-
|
||||
Used to build Odoo addons image.
|
||||
Depends on Odoo core image.
|
||||
code: |-
|
||||
FROM odoo:{{ odoo_test_version }}
|
||||
# Install git-aggregator and tools for requirements generation
|
||||
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
|
||||
# Let's go!
|
||||
USER odoo
|
||||
variable_ids: false
|
||||
secret_ids: false
|
||||
""" # noqa
|
||||
|
||||
# Expected YAML content of the test file template
|
||||
# without empty x2mvalues
|
||||
cls.file_template_test_yaml_no_empty_values = """cetmix_tower_model: file_template
|
||||
reference: dockerfile_unit_test
|
||||
name: Dockerfile Test
|
||||
source: tower
|
||||
file_type: text
|
||||
server_dir: /opt
|
||||
file_name: Dockerfile
|
||||
keep_when_deleted: true
|
||||
note: |-
|
||||
Used to build Odoo addons image.
|
||||
Depends on Odoo core image.
|
||||
code: |-
|
||||
FROM odoo:{{ odoo_test_version }}
|
||||
# Install git-aggregator and tools for requirements generation
|
||||
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
|
||||
# Let's go!
|
||||
USER odoo
|
||||
""" # noqa
|
||||
|
||||
# YAML content translated into Python dict
|
||||
cls.file_template_test_yaml_dict = yaml.safe_load(cls.file_template_test_yaml)
|
||||
cls.file_template_test_yaml_dict_no_empty_values = yaml.safe_load(
|
||||
cls.file_template_test_yaml_no_empty_values
|
||||
)
|
||||
|
||||
def test_yaml_from_file_template(self):
|
||||
"""Test if YAML is generated properly from a file"""
|
||||
|
||||
# -- 0 --
|
||||
# Create test file
|
||||
# Test file
|
||||
file_template_test = self.FileTemplate.create(
|
||||
{
|
||||
"name": "Dockerfile Test",
|
||||
"reference": "dockerfile_unit_test",
|
||||
"file_name": "Dockerfile",
|
||||
"server_dir": "/opt",
|
||||
"source": "tower",
|
||||
"keep_when_deleted": True,
|
||||
"file_type": "text",
|
||||
"code": """FROM odoo:{{ odoo_test_version }}
|
||||
# Install git-aggregator and tools for requirements generation
|
||||
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
|
||||
# Let's go!
|
||||
USER odoo""",
|
||||
"note": """Used to build Odoo addons image.
|
||||
Depends on Odoo core image.""",
|
||||
}
|
||||
)
|
||||
|
||||
# -- 1 --
|
||||
# Check it YAML generated by the file matches
|
||||
# YAML from the template file
|
||||
|
||||
self.assertEqual(
|
||||
file_template_test.yaml_code,
|
||||
self.file_template_test_yaml,
|
||||
"YAML generated from file doesn't match template file one",
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Check if YAML key values match Cetmix Tower ones
|
||||
|
||||
self.assertEqual(
|
||||
file_template_test.source,
|
||||
self.file_template_test_yaml_dict["source"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.file_name,
|
||||
self.file_template_test_yaml_dict["file_name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.code,
|
||||
self.file_template_test_yaml_dict["code"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.name,
|
||||
self.file_template_test_yaml_dict["name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.note,
|
||||
self.file_template_test_yaml_dict["note"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.server_dir,
|
||||
self.file_template_test_yaml_dict["server_dir"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.reference,
|
||||
self.file_template_test_yaml_dict["reference"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.file_type,
|
||||
self.file_template_test_yaml_dict["file_type"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.keep_when_deleted,
|
||||
self.file_template_test_yaml_dict["keep_when_deleted"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
|
||||
def test_yaml_from_file_template_no_empty_values(self):
|
||||
"""Test if YAML is generated properly from a file"""
|
||||
|
||||
# -- 0 --
|
||||
# Create test file
|
||||
# Test file
|
||||
file_template_test = self.FileTemplate.with_context(
|
||||
remove_empty_values=True
|
||||
).create(
|
||||
{
|
||||
"name": "Dockerfile Test",
|
||||
"reference": "dockerfile_unit_test",
|
||||
"file_name": "Dockerfile",
|
||||
"server_dir": "/opt",
|
||||
"source": "tower",
|
||||
"keep_when_deleted": True,
|
||||
"file_type": "text",
|
||||
"code": """FROM odoo:{{ odoo_test_version }}
|
||||
# Install git-aggregator and tools for requirements generation
|
||||
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
|
||||
# Let's go!
|
||||
USER odoo""",
|
||||
"note": """Used to build Odoo addons image.
|
||||
Depends on Odoo core image.""",
|
||||
}
|
||||
)
|
||||
|
||||
# -- 1 --
|
||||
# Check it YAML generated by the file matches
|
||||
# YAML from the template file
|
||||
|
||||
self.assertEqual(
|
||||
file_template_test.yaml_code,
|
||||
self.file_template_test_yaml_no_empty_values,
|
||||
"YAML generated from file doesn't match template file one",
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Check if YAML key values match Cetmix Tower ones
|
||||
|
||||
self.assertEqual(
|
||||
file_template_test.source,
|
||||
self.file_template_test_yaml_dict_no_empty_values["source"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.file_name,
|
||||
self.file_template_test_yaml_dict_no_empty_values["file_name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.code,
|
||||
self.file_template_test_yaml_dict_no_empty_values["code"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.name,
|
||||
self.file_template_test_yaml_dict_no_empty_values["name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.note,
|
||||
self.file_template_test_yaml_dict_no_empty_values["note"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.server_dir,
|
||||
self.file_template_test_yaml_dict_no_empty_values["server_dir"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.reference,
|
||||
self.file_template_test_yaml_dict_no_empty_values["reference"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.file_type,
|
||||
self.file_template_test_yaml_dict_no_empty_values["file_type"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.keep_when_deleted,
|
||||
self.file_template_test_yaml_dict_no_empty_values["keep_when_deleted"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
|
||||
def test_file_template_from_yaml(self):
|
||||
"""Test if YAML is generated properly from a file"""
|
||||
|
||||
def test_yaml(file_template):
|
||||
"""Checks if yaml values are inserted correctly
|
||||
|
||||
Args:
|
||||
file_template (cx.tower.file.template): File template
|
||||
"""
|
||||
self.assertEqual(
|
||||
file_template.source,
|
||||
self.file_template_test_yaml_dict["source"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.file_name,
|
||||
self.file_template_test_yaml_dict["file_name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.code,
|
||||
self.file_template_test_yaml_dict["code"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.name,
|
||||
self.file_template_test_yaml_dict["name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.note,
|
||||
self.file_template_test_yaml_dict["note"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.server_dir,
|
||||
self.file_template_test_yaml_dict["server_dir"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.reference,
|
||||
self.file_template_test_yaml_dict["reference"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.file_type,
|
||||
self.file_template_test_yaml_dict["file_type"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.keep_when_deleted,
|
||||
self.file_template_test_yaml_dict["keep_when_deleted"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
|
||||
# Create test file template
|
||||
file_template_test = self.FileTemplate.create({"name": "New file template"})
|
||||
|
||||
# -- 1 --
|
||||
# Insert YAML into the file and
|
||||
# check if YAML key values match Cetmix Tower ones
|
||||
file_template_test.yaml_code = self.file_template_test_yaml
|
||||
test_yaml(file_template_test)
|
||||
|
||||
# -- 2 --
|
||||
# Insert some non supported keys and ensure nothing bad happens
|
||||
yaml_with_non_supported_keys = """cetmix_tower_model: file_template
|
||||
code: |-
|
||||
FROM odoo:{{ odoo_test_version }}
|
||||
# Install git-aggregator and tools for requirements generation
|
||||
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
|
||||
# Let's go!
|
||||
USER odoo
|
||||
doge: SoMuch style!
|
||||
file_name: Dockerfile
|
||||
file_type: text
|
||||
keep_when_deleted: true
|
||||
name: Dockerfile Test
|
||||
note: |-
|
||||
Used to build Odoo addons image.
|
||||
Depends on Odoo core image.
|
||||
reference: dockerfile_unit_test
|
||||
server_dir: /opt
|
||||
source: tower
|
||||
tag_ids: false
|
||||
""" # noqa
|
||||
file_template_test.yaml_code = yaml_with_non_supported_keys
|
||||
test_yaml(file_template_test)
|
||||
179
addons/cetmix_tower_yaml/tests/test_plan.py
Normal file
179
addons/cetmix_tower_yaml/tests/test_plan.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
|
||||
class TestTowerPlan(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls, *args, **kwargs):
|
||||
super().setUpClass(*args, **kwargs)
|
||||
|
||||
cls.Plan = cls.env["cx.tower.plan"]
|
||||
|
||||
def test_plan_create_from_yaml(self):
|
||||
"""Test plan creation from YAML."""
|
||||
|
||||
plan_yaml = """cetmix_tower_model: plan
|
||||
access_level: manager
|
||||
reference: test_plan_from_yaml
|
||||
name: 'Test Plan From Yaml'
|
||||
allow_parallel_run: false
|
||||
color: 0
|
||||
tag_ids:
|
||||
- reference: doge_test_plan_tag
|
||||
name: Doge Test Plan Tag
|
||||
color: 1
|
||||
on_error_action: e
|
||||
custom_exit_code: 0
|
||||
line_ids:
|
||||
- sequence: 5
|
||||
condition: false
|
||||
use_sudo: false
|
||||
path: /such/much/{{ test_plan_dir }}
|
||||
command_id:
|
||||
access_level: manager
|
||||
reference: very_much_command_test
|
||||
name: Very much command
|
||||
action: ssh_command
|
||||
allow_parallel_run: false
|
||||
note: false
|
||||
code: Such much code
|
||||
variable_ids:
|
||||
- cetmix_tower_model: variable
|
||||
reference: test_plan_dir
|
||||
name: Test Plan Directory
|
||||
action_ids:
|
||||
- sequence: 1
|
||||
condition: ==
|
||||
value_char: '0'
|
||||
action: n
|
||||
custom_exit_code: 0
|
||||
variable_value_ids:
|
||||
- cetmix_tower_model: variable_value
|
||||
variable_id:
|
||||
cetmix_tower_yaml_version: 1
|
||||
cetmix_tower_model: variable
|
||||
reference: test_plan_branch
|
||||
name: Test Plan Branch
|
||||
value_char: production
|
||||
- cetmix_tower_model: variable_value
|
||||
variable_id:
|
||||
cetmix_tower_yaml_version: 1
|
||||
cetmix_tower_model: variable
|
||||
reference: test_plan_some_unique_variable
|
||||
name: Test Plan Some Unique Variable
|
||||
value_char: 'Final Value'
|
||||
- cetmix_tower_model: plan_line_action
|
||||
access_level: manager
|
||||
sequence: 2
|
||||
condition: '>'
|
||||
value_char: '0'
|
||||
action: ec
|
||||
custom_exit_code: 255
|
||||
variable_value_ids: false
|
||||
variable_ids: false
|
||||
"""
|
||||
# -- 1 --
|
||||
# Create plan from YAML
|
||||
plan_form_yaml = self.Plan.create(
|
||||
{"name": "Name Placeholder", "yaml_code": plan_yaml}
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_form_yaml.reference,
|
||||
"test_plan_from_yaml",
|
||||
"Reference is not set from YAML",
|
||||
)
|
||||
# Name should be set from YAML
|
||||
self.assertEqual(
|
||||
plan_form_yaml.name, "Test Plan From Yaml", "Name is not set from YAML"
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Check plan tags
|
||||
plan_tags = plan_form_yaml.tag_ids
|
||||
self.assertEqual(len(plan_tags), 1)
|
||||
self.assertEqual(plan_tags.name, "Doge Test Plan Tag")
|
||||
|
||||
# -- 3 --
|
||||
# Check plan lines
|
||||
plan_lines = plan_form_yaml.line_ids
|
||||
self.assertEqual(len(plan_lines), 1, "Line count is not 1")
|
||||
self.assertFalse(plan_lines.condition, "Condition is not false")
|
||||
self.assertEqual(
|
||||
plan_lines.path,
|
||||
"/such/much/{{ test_plan_dir }}",
|
||||
"Path is not set from YAML",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_lines.command_id.reference,
|
||||
"very_much_command_test",
|
||||
"Command reference is not set from YAML",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_lines.command_id.name,
|
||||
"Very much command",
|
||||
"Command name is not set from YAML",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_lines.command_id.action,
|
||||
"ssh_command",
|
||||
"Command action is not set from YAML",
|
||||
)
|
||||
self.assertFalse(
|
||||
plan_lines.command_id.allow_parallel_run,
|
||||
"Command allow parallel run is not set from YAML",
|
||||
)
|
||||
self.assertFalse(
|
||||
plan_lines.command_id.note, "Command note is not set from YAML"
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_lines.command_id.variable_ids.mapped("reference"),
|
||||
["test_plan_dir"],
|
||||
"Command variable ids is not set from YAML",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_lines.command_id.access_level,
|
||||
"2",
|
||||
"Command access level is not set from YAML",
|
||||
)
|
||||
|
||||
# -- 4 --
|
||||
# Check plan line actions
|
||||
plan_actions = plan_form_yaml.line_ids.action_ids
|
||||
self.assertEqual(len(plan_actions), 2, "Action count is not 2")
|
||||
self.assertEqual(
|
||||
plan_actions[0].condition, "==", "First action condition is not equal"
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_actions[0].value_char, "0", "First action value char is not 0"
|
||||
)
|
||||
self.assertEqual(plan_actions[0].action, "n", "First action action is not n")
|
||||
self.assertEqual(
|
||||
plan_actions[0].custom_exit_code,
|
||||
0,
|
||||
"First action custom exit code is not 0",
|
||||
)
|
||||
self.assertEqual(
|
||||
len(plan_actions[0].variable_value_ids),
|
||||
2,
|
||||
"Number of variable value ids is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_actions[0].variable_value_ids.mapped("value_char"),
|
||||
["production", "Final Value"],
|
||||
"Variable value chars are not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_actions[1].condition, ">", "Second action condition is not greater"
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_actions[1].value_char, "0", "Second action value char is not 0"
|
||||
)
|
||||
self.assertEqual(plan_actions[1].action, "ec", "Second action action is not ec")
|
||||
self.assertEqual(
|
||||
plan_actions[1].custom_exit_code,
|
||||
255,
|
||||
"Second action custom exit code is not 255",
|
||||
)
|
||||
self.assertFalse(
|
||||
plan_actions[1].variable_value_ids,
|
||||
"Second action variable value ids is not false",
|
||||
)
|
||||
127
addons/cetmix_tower_yaml/tests/test_server_log.py
Normal file
127
addons/cetmix_tower_yaml/tests/test_server_log.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
"""
|
||||
Tests for the cx.tower.server.log model YAML export/import.
|
||||
|
||||
Covers:
|
||||
1. YAML export of a file-type log must include `file_id` and allow suffixes.
|
||||
2. A full round-trip (export → delete → import) preserves the `file_id` relation.
|
||||
3. Exporting a non-file log must include a falsy `file_id`.
|
||||
4. Importing YAML with a bogus `file_id` reference raises ValidationError.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestServerLog(TransactionCase):
|
||||
"""YAML export/import tests for cx.tower.server.log."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
env = cls.env
|
||||
cls.File = env["cx.tower.file"]
|
||||
cls.Server = env["cx.tower.server"]
|
||||
cls.ServerLog = env["cx.tower.server.log"]
|
||||
|
||||
# Create a file to reference from the log
|
||||
cls.file = cls.File.create(
|
||||
{
|
||||
"name": "repos.yaml",
|
||||
"reference": "reposyaml",
|
||||
"source": "tower",
|
||||
"file_type": "text",
|
||||
"server_dir": "/tmp",
|
||||
"code": "# Example\nHello, Tower!",
|
||||
}
|
||||
)
|
||||
|
||||
# Create a server (use password auth to satisfy constraints)
|
||||
cls.server = cls.Server.create(
|
||||
{
|
||||
"name": "Srv-YAML-Test",
|
||||
"reference": "srv_yaml_test",
|
||||
"ip_v4_address": "127.0.0.1",
|
||||
"ssh_username": "admin",
|
||||
"ssh_port": 22,
|
||||
"ssh_auth_mode": "p",
|
||||
"ssh_password": "dummy",
|
||||
"use_sudo": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a file-type log linked to the file above
|
||||
cls.log = cls.ServerLog.create(
|
||||
{
|
||||
"name": "Log from file",
|
||||
"reference": "log_from_file",
|
||||
"log_type": "file",
|
||||
"file_id": cls.file.id,
|
||||
"server_id": cls.server.id,
|
||||
"use_sudo": False,
|
||||
}
|
||||
)
|
||||
|
||||
def test_yaml_export_contains_file_id(self):
|
||||
"""Exported YAML must include a file_id starting with the file's reference."""
|
||||
data = yaml.safe_load(self.log.yaml_code)
|
||||
# Ensure file_id is present
|
||||
self.assertIn("file_id", data, "`file_id` is missing from YAML export")
|
||||
# Allow for auto-appended suffixes, so only check prefix
|
||||
self.assertTrue(
|
||||
data["file_id"].startswith(self.file.reference),
|
||||
f"`file_id` value '{data['file_id']}' should start with "
|
||||
f"'{self.file.reference}'",
|
||||
)
|
||||
|
||||
def test_yaml_roundtrip_restores_file_id(self):
|
||||
"""A full export→delete→import cycle must restore the file_id relation."""
|
||||
yaml_dict = yaml.safe_load(self.log.yaml_code)
|
||||
# Remove the original log
|
||||
self.log.unlink()
|
||||
# Recreate from YAML
|
||||
vals = self.ServerLog._post_process_yaml_dict_values(yaml_dict)
|
||||
restored = self.ServerLog.with_context(from_yaml=True).create(vals)
|
||||
# Verify relation restored
|
||||
self.assertEqual(
|
||||
restored.file_id.id,
|
||||
self.file.id,
|
||||
"`file_id` was not restored after round-trip",
|
||||
)
|
||||
|
||||
def test_yaml_export_without_file_id(self):
|
||||
"""Logs of non-file type should not include file_id in YAML."""
|
||||
cmd_log = self.ServerLog.create(
|
||||
{
|
||||
"name": "Log no file",
|
||||
"reference": "log_no_file",
|
||||
"log_type": "command",
|
||||
"server_id": self.server.id,
|
||||
"use_sudo": False,
|
||||
}
|
||||
)
|
||||
data = yaml.safe_load(cmd_log.yaml_code)
|
||||
# key is present, but must be falsy
|
||||
self.assertIn("file_id", data, "`file_id` key is missing")
|
||||
self.assertFalse(
|
||||
data["file_id"],
|
||||
"`file_id` for non-file log must be False/empty",
|
||||
)
|
||||
|
||||
def test_yaml_import_with_missing_file_reference(self):
|
||||
"""Missing file reference is accepted, but file_id stays empty."""
|
||||
yaml_dict = yaml.safe_load(self.log.yaml_code)
|
||||
yaml_dict["file_id"] = "does_not_exist"
|
||||
|
||||
vals = self.ServerLog._post_process_yaml_dict_values(yaml_dict)
|
||||
new_log = self.ServerLog.with_context(from_yaml=True).create(vals)
|
||||
|
||||
# Log is created, but the relation is not resolved
|
||||
self.assertFalse(
|
||||
new_log.file_id,
|
||||
"file_id should be empty when reference cannot be resolved",
|
||||
)
|
||||
124
addons/cetmix_tower_yaml/tests/test_server_yaml.py
Normal file
124
addons/cetmix_tower_yaml/tests/test_server_yaml.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
"""
|
||||
Tests for cx.tower.server YAML export/import covering command_ids and plan_ids.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestServerYAML(TransactionCase):
|
||||
"""YAML export/import tests for cx.tower.server with commands and plans."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
env = cls.env
|
||||
cls.Server = env["cx.tower.server"]
|
||||
cls.Command = env["cx.tower.command"]
|
||||
cls.Plan = env["cx.tower.plan"]
|
||||
|
||||
# Create a command to attach (use defaults for access_level)
|
||||
cls.command = cls.Command.create(
|
||||
{
|
||||
"name": "Test Command",
|
||||
"reference": "test_command",
|
||||
"action": "ssh_command",
|
||||
"allow_parallel_run": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a flight plan to attach
|
||||
cls.plan = cls.Plan.create(
|
||||
{
|
||||
"name": "Test Flight Plan",
|
||||
"reference": "test_plan",
|
||||
"allow_parallel_run": False,
|
||||
"color": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Create server and link command and plan
|
||||
cls.server = cls.Server.create(
|
||||
{
|
||||
"name": "Server YAML Test",
|
||||
"reference": "srv_yaml_test",
|
||||
"ip_v4_address": "127.0.0.1",
|
||||
"ssh_username": "admin",
|
||||
"ssh_port": 22,
|
||||
"ssh_auth_mode": "p",
|
||||
"ssh_password": "dummy",
|
||||
"use_sudo": False,
|
||||
# Link the m2m fields
|
||||
"command_ids": [(6, 0, [cls.command.id])],
|
||||
"plan_ids": [(6, 0, [cls.plan.id])],
|
||||
}
|
||||
)
|
||||
|
||||
def test_yaml_export_contains_command_and_plan(self):
|
||||
"""Exported YAML include command_ids and plan_ids with correct references."""
|
||||
data = yaml.safe_load(self.server.yaml_code)
|
||||
# Check command_ids
|
||||
self.assertIn(
|
||||
"command_ids",
|
||||
data,
|
||||
"`command_ids` is missing from YAML export",
|
||||
)
|
||||
self.assertIsInstance(
|
||||
data["command_ids"], list, "`command_ids` should be a list in YAML"
|
||||
)
|
||||
self.assertTrue(
|
||||
data["command_ids"],
|
||||
"`command_ids` list should not be empty",
|
||||
)
|
||||
# Only reference should be exported
|
||||
self.assertEqual(
|
||||
data["command_ids"][0],
|
||||
self.command.reference,
|
||||
"Exported command reference does not match",
|
||||
)
|
||||
|
||||
# Check plan_ids
|
||||
self.assertIn(
|
||||
"plan_ids",
|
||||
data,
|
||||
"`plan_ids` is missing from YAML export",
|
||||
)
|
||||
self.assertIsInstance(
|
||||
data["plan_ids"], list, "`plan_ids` should be a list in YAML"
|
||||
)
|
||||
self.assertTrue(
|
||||
data["plan_ids"],
|
||||
"`plan_ids` list should not be empty",
|
||||
)
|
||||
self.assertEqual(
|
||||
data["plan_ids"][0],
|
||||
self.plan.reference,
|
||||
"Exported plan reference does not match",
|
||||
)
|
||||
|
||||
def test_yaml_roundtrip_restores_command_and_plan(self):
|
||||
"""A full export→delete→import cycle must restore the m2m relations."""
|
||||
yaml_dict = yaml.safe_load(self.server.yaml_code)
|
||||
# Remove original server
|
||||
self.server.unlink()
|
||||
# Prepare values and import
|
||||
vals = self.Server._post_process_yaml_dict_values(yaml_dict)
|
||||
restored = self.Server.with_context(
|
||||
from_yaml=True, skip_ssh_settings_check=True
|
||||
).create(vals)
|
||||
|
||||
# Verify m2m links restored
|
||||
self.assertEqual(
|
||||
restored.command_ids.ids,
|
||||
[self.command.id],
|
||||
"`command_ids` were not restored correctly",
|
||||
)
|
||||
self.assertEqual(
|
||||
restored.plan_ids.ids,
|
||||
[self.plan.id],
|
||||
"`plan_ids` were not restored correctly",
|
||||
)
|
||||
525
addons/cetmix_tower_yaml/tests/test_tower_yaml_mixin.py
Normal file
525
addons/cetmix_tower_yaml/tests/test_tower_yaml_mixin.py
Normal file
@@ -0,0 +1,525 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
|
||||
from odoo import _
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
class TestTowerYamlMixin(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls, *args, **kwargs):
|
||||
super().setUpClass(*args, **kwargs)
|
||||
cls.Users = cls.env["res.users"].with_context(no_reset_password=True)
|
||||
cls.YamlMixin = cls.env["cx.tower.yaml.mixin"]
|
||||
TowerTag = cls.env["cx.tower.tag"]
|
||||
cls.tag_doge = TowerTag.create({"name": "Doge", "reference": "doge"})
|
||||
cls.tag_pepe = TowerTag.create({"name": "Pepe", "reference": "pepe"})
|
||||
|
||||
def test_convert_dict_to_yaml(self):
|
||||
# -- 1 --
|
||||
# Test regular flow
|
||||
self.assertEqual(
|
||||
self.YamlMixin._convert_dict_to_yaml({"a": 1, "b": 2}),
|
||||
"a: 1\nb: 2\n",
|
||||
"Dictionary was not converted to YAML correctly",
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Test flow with exception due to wrong values
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
self.YamlMixin._convert_dict_to_yaml("not_a_dict")
|
||||
self.assertEqual(
|
||||
str(e.exception),
|
||||
_("Values must be a dictionary"),
|
||||
"Exception message doesn't match",
|
||||
)
|
||||
|
||||
def test_yaml_field_access(self):
|
||||
# Create Root user with no access to the 'yaml_code field
|
||||
user_root = self.Users.create(
|
||||
{
|
||||
"name": "Root User",
|
||||
"login": "root@example.com",
|
||||
"groups_id": [
|
||||
(4, self.env.ref("base.group_user").id),
|
||||
(4, self.env.ref("cetmix_tower_server.group_root").id),
|
||||
],
|
||||
}
|
||||
)
|
||||
with self.assertRaises(AccessError):
|
||||
self.tag_doge.with_user(user_root).read(["yaml_code"])
|
||||
|
||||
# Add user to the 'cetmix_tower_yaml.group_export' group
|
||||
# and check if access is granted
|
||||
user_root.write(
|
||||
{"groups_id": [(4, self.env.ref("cetmix_tower_yaml.group_export").id)]}
|
||||
)
|
||||
yaml_code = (
|
||||
self.tag_doge.with_user(user_root).read(["yaml_code"])[0].get("yaml_code")
|
||||
)
|
||||
|
||||
# Modify YAML code and check if it's saved
|
||||
yaml_code = yaml_code.replace("Doge", "WowDoge")
|
||||
with self.assertRaises(AccessError):
|
||||
self.tag_doge.with_user(user_root).write({"yaml_code": yaml_code})
|
||||
|
||||
# Add user to the 'cetmix_tower_yaml.group_import' group
|
||||
# and check if access is granted
|
||||
user_root.write(
|
||||
{"groups_id": [(4, self.env.ref("cetmix_tower_yaml.group_import").id)]}
|
||||
)
|
||||
self.tag_doge.with_user(user_root).write({"yaml_code": yaml_code})
|
||||
self.assertEqual(
|
||||
self.tag_doge.with_user(user_root).yaml_code,
|
||||
yaml_code,
|
||||
"YAML code was not saved",
|
||||
)
|
||||
|
||||
def test_post_process_record_values(self):
|
||||
"""Test value post processing.
|
||||
We test common fields only because this method can be overridden
|
||||
in models inheriting this mixin.
|
||||
"""
|
||||
|
||||
# Patch method to return "access_level" field too
|
||||
def _get_fields_for_yaml(self):
|
||||
return ["access_level", "name", "reference"]
|
||||
|
||||
self.YamlMixin._patch_method("_get_fields_for_yaml", _get_fields_for_yaml)
|
||||
|
||||
source_values = {
|
||||
"access_level": "3",
|
||||
"id": 22332,
|
||||
"name": "Doge Much Like",
|
||||
"reference": "such_much_doge",
|
||||
}
|
||||
|
||||
result_values = self.YamlMixin._post_process_record_values(source_values.copy())
|
||||
|
||||
self.assertNotIn("id", result_values, "ID must be removed")
|
||||
self.assertEqual(
|
||||
result_values["access_level"],
|
||||
self.YamlMixin.TO_YAML_ACCESS_LEVEL[source_values["access_level"]],
|
||||
"Access level is not parsed correctly",
|
||||
)
|
||||
self.assertEqual(
|
||||
result_values["name"],
|
||||
source_values["name"],
|
||||
"Other values should remain unchanged",
|
||||
)
|
||||
self.assertEqual(
|
||||
result_values["reference"],
|
||||
source_values["reference"],
|
||||
"Other values should remain unchanged",
|
||||
)
|
||||
|
||||
# Restore original method
|
||||
self.YamlMixin._revert_method("_get_fields_for_yaml")
|
||||
|
||||
def test_post_process_yaml_dict_values(self):
|
||||
"""Test YAML dict value post processing.
|
||||
We test common fields only because this method can be overridden
|
||||
in models inheriting this mixin.
|
||||
"""
|
||||
|
||||
# Patch method to return "access_level" field too
|
||||
def _get_fields_for_yaml(self):
|
||||
return ["access_level", "name", "reference"]
|
||||
|
||||
self.YamlMixin._patch_method("_get_fields_for_yaml", _get_fields_for_yaml)
|
||||
|
||||
# -- 1 --
|
||||
# Test regular flow
|
||||
source_values = {
|
||||
"access_level": "user",
|
||||
"name": "Doge Much Like",
|
||||
"reference": "such_much_doge",
|
||||
"some_doge_field": "some_meme",
|
||||
}
|
||||
|
||||
result_values = self.YamlMixin._post_process_yaml_dict_values(
|
||||
source_values.copy()
|
||||
)
|
||||
self.assertNotIn(
|
||||
"some_doge_field", result_values, "Non listed fields must be removed"
|
||||
)
|
||||
self.assertEqual(
|
||||
result_values["access_level"],
|
||||
self.YamlMixin.TO_TOWER_ACCESS_LEVEL[source_values["access_level"]],
|
||||
"Access level is not parsed correctly",
|
||||
)
|
||||
self.assertEqual(
|
||||
result_values["name"],
|
||||
source_values["name"],
|
||||
"Other values should remain unchanged",
|
||||
)
|
||||
self.assertEqual(
|
||||
result_values["reference"],
|
||||
source_values["reference"],
|
||||
"Other values should remain unchanged",
|
||||
)
|
||||
|
||||
# -- Test 2 --
|
||||
# Submit wrong value for access level
|
||||
source_values.update(
|
||||
{
|
||||
"access_level": "doge",
|
||||
}
|
||||
)
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
result_values = self.YamlMixin._post_process_yaml_dict_values(
|
||||
source_values.copy()
|
||||
)
|
||||
self.assertEqual(
|
||||
str(e.exception),
|
||||
_(
|
||||
"Wrong value for 'access_level' key: %(acv)s",
|
||||
acv="doge",
|
||||
),
|
||||
"Exception message doesn't match",
|
||||
)
|
||||
|
||||
# Restore original method
|
||||
self.YamlMixin._revert_method("_get_fields_for_yaml")
|
||||
|
||||
def test_process_relation_field_value_no_explode(self):
|
||||
"""Test non exploded related field values.
|
||||
Non exploded values represent related record with reference only.
|
||||
|
||||
Covers the following child functions:
|
||||
- _process_m2o_value(..)
|
||||
- _process_x2m_values(..)
|
||||
"""
|
||||
|
||||
# We are using command with file template for that
|
||||
file_template = self.env["cx.tower.file.template"].create(
|
||||
{"name": "Test m2o", "reference": "test_m2o"}
|
||||
)
|
||||
command = self.env["cx.tower.command"].create(
|
||||
{
|
||||
"name": "Command test m2o",
|
||||
"action": "file_using_template",
|
||||
"file_template_id": file_template.id,
|
||||
"tag_ids": [(4, self.tag_doge.id), (4, self.tag_pepe.id)],
|
||||
}
|
||||
)
|
||||
|
||||
# -- 1 --
|
||||
# Record -> Yaml
|
||||
|
||||
# -- 1.1 --
|
||||
# Many2one
|
||||
result = command._process_relation_field_value(
|
||||
field="file_template_id",
|
||||
value=(command.file_template_id.id, command.file_template_id.name),
|
||||
record_mode=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
result, file_template.reference, "Reference was not resolved correctly"
|
||||
)
|
||||
# -- 1.2 --
|
||||
# Many2many
|
||||
result = command._process_relation_field_value(
|
||||
field="tag_ids",
|
||||
value=[self.tag_doge.id, self.tag_pepe.id],
|
||||
record_mode=True,
|
||||
)
|
||||
|
||||
self.assertEqual(len(result), 2, "Must be 2 references")
|
||||
self.assertIn(
|
||||
self.tag_doge.reference, result, "Reference was not resolved correctly"
|
||||
)
|
||||
self.assertIn(
|
||||
self.tag_pepe.reference, result, "Reference was not resolved correctly"
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Yaml -> Record
|
||||
|
||||
# -- 2.1. --
|
||||
# Many2one
|
||||
result = command._process_relation_field_value(
|
||||
field="file_template_id", value=file_template.reference, record_mode=False
|
||||
)
|
||||
self.assertEqual(
|
||||
result, file_template.id, "Record ID was not resolved correctly"
|
||||
)
|
||||
|
||||
# -- 2.2 --
|
||||
# Many2many
|
||||
result = command._process_relation_field_value(
|
||||
field="tag_ids",
|
||||
value=[self.tag_doge.reference, self.tag_pepe.reference],
|
||||
record_mode=False,
|
||||
)
|
||||
self.assertEqual(len(result), 2, "Must be 2 records")
|
||||
self.assertIn(
|
||||
(4, self.tag_doge.id), result, "Record ID was not resolved correctly"
|
||||
)
|
||||
self.assertIn(
|
||||
(4, self.tag_pepe.id), result, "Record ID was not resolved correctly"
|
||||
)
|
||||
|
||||
# -- 3 --
|
||||
# Yaml with non existing reference -> Record
|
||||
result = command._process_relation_field_value(
|
||||
field="file_template_id", value="such_much_not_reference", record_mode=False
|
||||
)
|
||||
self.assertFalse(result, "Must be 'False'")
|
||||
|
||||
# -- 4 --
|
||||
# No record -> Yaml
|
||||
result = command._process_relation_field_value(
|
||||
field="file_template_id",
|
||||
value=self.env["cx.tower.file.template"],
|
||||
record_mode=True,
|
||||
)
|
||||
self.assertFalse(result, "Result must be 'False'")
|
||||
|
||||
def test_process_relation_field_value_explode(self):
|
||||
"""Test exploded related field values.
|
||||
Exploded values represent related record with a child YAML structure.
|
||||
|
||||
Covers the following child functions:
|
||||
- _process_m2o_value(..)
|
||||
- _process_x2m_values(..)
|
||||
"""
|
||||
|
||||
# We are using command with file template for that
|
||||
file_template = self.env["cx.tower.file.template"].create(
|
||||
{"name": "Test m2o", "reference": "test_m2o"}
|
||||
)
|
||||
file_template_values = file_template.with_context(
|
||||
no_yaml_service_fields=True
|
||||
)._prepare_record_for_yaml()
|
||||
tag_doge_values = self.tag_doge.with_context(
|
||||
no_yaml_service_fields=True
|
||||
)._prepare_record_for_yaml()
|
||||
tag_pepe_values = self.tag_pepe.with_context(
|
||||
no_yaml_service_fields=True
|
||||
)._prepare_record_for_yaml()
|
||||
command = (
|
||||
self.env["cx.tower.command"]
|
||||
.create(
|
||||
{
|
||||
"name": "Command test m2o",
|
||||
"action": "file_using_template",
|
||||
"file_template_id": file_template.id,
|
||||
"tag_ids": [(4, self.tag_doge.id), (4, self.tag_pepe.id)],
|
||||
}
|
||||
)
|
||||
.with_context(explode_related_record=True)
|
||||
) # and this is the actual trigger
|
||||
|
||||
# -- 1 --
|
||||
# Record -> Yaml
|
||||
|
||||
# -- 1.1 --
|
||||
# Many2one
|
||||
result = command._process_relation_field_value(
|
||||
field="file_template_id",
|
||||
value=(command.file_template_id.id, command.file_template_id.name),
|
||||
record_mode=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
result, file_template_values, "Reference was not resolved correctly"
|
||||
)
|
||||
|
||||
# -- 1.2 --
|
||||
# Many2many
|
||||
result = command._process_relation_field_value(
|
||||
field="tag_ids",
|
||||
value=[self.tag_doge.id, self.tag_pepe.id],
|
||||
record_mode=True,
|
||||
)
|
||||
self.assertEqual(len(result), 2, "Must be 2 records")
|
||||
self.assertIn(tag_doge_values, result, "Record ID was not resolved correctly")
|
||||
self.assertIn(tag_pepe_values, result, "Record ID was not resolved correctly")
|
||||
|
||||
# -- 2 --
|
||||
# Yaml -> Record
|
||||
|
||||
# -- 2.1 --
|
||||
# Many2one
|
||||
result = command._process_relation_field_value(
|
||||
field="file_template_id", value=file_template_values, record_mode=False
|
||||
)
|
||||
self.assertEqual(
|
||||
result, file_template.id, "Record ID was not resolved correctly"
|
||||
)
|
||||
|
||||
# -- 2.2 --
|
||||
# Many2many
|
||||
result = command._process_relation_field_value(
|
||||
field="tag_ids", value=[tag_doge_values, tag_pepe_values], record_mode=False
|
||||
)
|
||||
self.assertEqual(len(result), 2, "Must be 2 records")
|
||||
self.assertIn(
|
||||
(4, self.tag_doge.id), result, "Record ID was not resolved correctly"
|
||||
)
|
||||
self.assertIn(
|
||||
(4, self.tag_pepe.id), result, "Record ID was not resolved correctly"
|
||||
)
|
||||
# -- 3 --
|
||||
# Yaml with non existing reference -> Record
|
||||
file_template_values.update(
|
||||
{
|
||||
"name": "Very new name",
|
||||
"reference": "such_much_not_reference",
|
||||
"source": "server",
|
||||
"file_type": "binary",
|
||||
}
|
||||
)
|
||||
result = command._process_relation_field_value(
|
||||
field="file_template_id", value=file_template_values, record_mode=False
|
||||
)
|
||||
|
||||
# New record must be created
|
||||
record = self.env["cx.tower.file.template"].browse(result)
|
||||
self.assertEqual(
|
||||
record.name, file_template_values["name"], "New record value doesn't match"
|
||||
)
|
||||
self.assertEqual(
|
||||
record.reference,
|
||||
file_template_values["reference"],
|
||||
"New record value doesn't match",
|
||||
)
|
||||
self.assertEqual(
|
||||
record.source,
|
||||
file_template_values["source"],
|
||||
"New record value doesn't match",
|
||||
)
|
||||
self.assertEqual(
|
||||
record.file_type,
|
||||
file_template_values["file_type"],
|
||||
"New record value doesn't match",
|
||||
)
|
||||
|
||||
# -- 4 --
|
||||
# Yaml with no reference at all -> Record
|
||||
values_with_no_references = {
|
||||
"name": "Sorry no reference here",
|
||||
"source": "tower",
|
||||
"file_type": "binary",
|
||||
}
|
||||
result = command._process_relation_field_value(
|
||||
field="file_template_id", value=values_with_no_references, record_mode=False
|
||||
)
|
||||
|
||||
# New record must be created
|
||||
record = self.env["cx.tower.file.template"].browse(result)
|
||||
|
||||
self.assertEqual(
|
||||
record.name,
|
||||
values_with_no_references["name"],
|
||||
"New record value doesn't match",
|
||||
)
|
||||
self.assertEqual(
|
||||
record.source,
|
||||
values_with_no_references["source"],
|
||||
"New record value doesn't match",
|
||||
)
|
||||
self.assertEqual(
|
||||
record.file_type,
|
||||
values_with_no_references["file_type"],
|
||||
"New record value doesn't match",
|
||||
)
|
||||
|
||||
# -- 5 --
|
||||
# No record -> Yaml
|
||||
result = command._process_relation_field_value(
|
||||
field="file_template_id",
|
||||
value=self.env["cx.tower.file.template"],
|
||||
record_mode=True,
|
||||
)
|
||||
self.assertFalse(result, "Result must be 'False'")
|
||||
|
||||
def test_update_or_create_related_record(self):
|
||||
"""Test if related record is updated or created correctly"""
|
||||
|
||||
# -- 1 --
|
||||
# Update existing values
|
||||
# We are using file template for that
|
||||
FileTemplateModel = self.env["cx.tower.file.template"]
|
||||
file_template = self.env["cx.tower.file.template"].create(
|
||||
{"name": "Test m2o", "reference": "test_m2o"}
|
||||
)
|
||||
values_to_update = {"name": "Much new name"}
|
||||
record = FileTemplateModel._update_or_create_related_record(
|
||||
model=FileTemplateModel,
|
||||
reference=file_template.reference,
|
||||
values=values_to_update,
|
||||
)
|
||||
self.assertEqual(
|
||||
record.name, values_to_update["name"], "Value was not updated properly"
|
||||
)
|
||||
self.assertEqual(record.id, file_template.id, "Same record must be updated")
|
||||
|
||||
# -- 2 --
|
||||
# Reference not found. Must create a new record
|
||||
values_to_update = {"name": "Doge file"}
|
||||
record = FileTemplateModel._update_or_create_related_record(
|
||||
model=FileTemplateModel,
|
||||
reference="doge_file",
|
||||
values=values_to_update,
|
||||
create_immediately=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
record.name, values_to_update["name"], "Value was not updated properly"
|
||||
)
|
||||
self.assertNotEqual(record.id, file_template.id, "New record must be created")
|
||||
|
||||
# -- 2 --
|
||||
# Reference not provided. Must create a new record
|
||||
values_to_update = {"name": "Doge file"}
|
||||
record = FileTemplateModel._update_or_create_related_record(
|
||||
model=FileTemplateModel,
|
||||
reference=False,
|
||||
values=values_to_update,
|
||||
create_immediately=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
record.name, values_to_update["name"], "Value was not updated properly"
|
||||
)
|
||||
self.assertNotEqual(record.id, file_template.id, "New record must be created")
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
def test_prepare_record_truncates_code_for_server_files(self):
|
||||
"""Mixin must set code=False for cx.tower.file when source=='server'."""
|
||||
File = self.env["cx.tower.file"]
|
||||
srv_file = File.create(
|
||||
{
|
||||
"name": "srv.log",
|
||||
"reference": "srvlog",
|
||||
"source": "server",
|
||||
"file_type": "text",
|
||||
"server_dir": "/tmp",
|
||||
"code": "BIG DATA",
|
||||
}
|
||||
)
|
||||
rec = srv_file._prepare_record_for_yaml()
|
||||
self.assertIn("code", rec)
|
||||
self.assertFalse(rec["code"], "Expected code=False for server-sourced files")
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
def test_prepare_record_keeps_code_for_tower_files(self):
|
||||
"""Mixin must keep code for cx.tower.file when source=='tower'."""
|
||||
File = self.env["cx.tower.file"]
|
||||
tw_file = File.create(
|
||||
{
|
||||
"name": "local.txt",
|
||||
"reference": "localtxt",
|
||||
"source": "tower",
|
||||
"file_type": "text",
|
||||
"server_dir": "/etc",
|
||||
"code": "SMALL DATA",
|
||||
}
|
||||
)
|
||||
rec = tw_file._prepare_record_for_yaml()
|
||||
self.assertEqual(
|
||||
rec["code"],
|
||||
"SMALL DATA",
|
||||
"Expected original code for tower-sourced files",
|
||||
)
|
||||
375
addons/cetmix_tower_yaml/tests/test_yaml_export_wizard.py
Normal file
375
addons/cetmix_tower_yaml/tests/test_yaml_export_wizard.py
Normal file
@@ -0,0 +1,375 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
import base64
|
||||
|
||||
import yaml
|
||||
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
|
||||
from odoo.addons.base.tests.common import BaseCommon
|
||||
|
||||
|
||||
class TestYamlExportWizard(BaseCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls, *args, **kwargs):
|
||||
super().setUpClass(*args, **kwargs)
|
||||
|
||||
# Used to ensure that the file header
|
||||
# is present in the YAML code
|
||||
cls.file_header = """
|
||||
# This file is generated with Cetmix Tower.
|
||||
# Details and documentation: https://cetmix.com/tower
|
||||
"""
|
||||
# Create a command
|
||||
cls.TowerCommand = cls.env["cx.tower.command"]
|
||||
cls.command_test_wizard = cls.TowerCommand.create(
|
||||
{
|
||||
"reference": "test_command_from_yaml",
|
||||
"name": "Test Command From Yaml",
|
||||
"code": "echo 'Test Command From Yaml'",
|
||||
}
|
||||
)
|
||||
cls.command_test_wizard_2 = cls.TowerCommand.create(
|
||||
{
|
||||
"reference": "test_command_from_yaml_2",
|
||||
"name": "Test Command From Yaml 2",
|
||||
"code": "echo 'Test Command From Yaml 2'",
|
||||
}
|
||||
)
|
||||
|
||||
# Create a flight plan
|
||||
cls.FlightPlan = cls.env["cx.tower.plan"]
|
||||
cls.flight_plan_test_wizard = cls.FlightPlan.create(
|
||||
{
|
||||
"name": "Test Flight Plan From Yaml",
|
||||
"line_ids": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"command_id": cls.command_test_wizard.id,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Create a server template
|
||||
cls.ServerTemplate = cls.env["cx.tower.server.template"]
|
||||
cls.server_template_test_wizard = cls.ServerTemplate.create(
|
||||
{
|
||||
"name": "Test Server Template From Yaml",
|
||||
"flight_plan_id": cls.flight_plan_test_wizard.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a wizard and trigger onchange
|
||||
cls.YamlExportWizard = cls.env["cx.tower.yaml.export.wiz"]
|
||||
cls.test_wizard = cls.YamlExportWizard.with_context(
|
||||
active_model="cx.tower.server.template",
|
||||
active_ids=[cls.server_template_test_wizard.id],
|
||||
).create({})
|
||||
cls.test_wizard.onchange_explode_child_records()
|
||||
|
||||
def test_user_without_export_group_cannot_export(self):
|
||||
"""Test if user without export group cannot export"""
|
||||
|
||||
# Tower manager user without export group
|
||||
self.user_yaml_export = self.env["res.users"].create(
|
||||
{
|
||||
"name": "No Yaml Export User",
|
||||
"login": "no_yaml_export_user",
|
||||
"groups_id": [
|
||||
(4, self.env.ref("cetmix_tower_server.group_manager").id)
|
||||
],
|
||||
}
|
||||
)
|
||||
with self.assertRaises(AccessError):
|
||||
self.test_wizard.with_user(self.user_yaml_export).read([])
|
||||
|
||||
def test_yaml_export_wizard_yaml_generation(self):
|
||||
"""Test code generation of YAML export wizard."""
|
||||
|
||||
wizard_yaml = """
|
||||
# This file is generated with Cetmix Tower.
|
||||
# Details and documentation: https://cetmix.com/tower
|
||||
cetmix_tower_yaml_version: 1
|
||||
records:
|
||||
- cetmix_tower_model: command
|
||||
access_level: manager
|
||||
reference: test_command_from_yaml
|
||||
name: Test Command From Yaml
|
||||
action: ssh_command
|
||||
allow_parallel_run: false
|
||||
note: false
|
||||
path: false
|
||||
code: echo 'Test Command From Yaml'
|
||||
server_status: false
|
||||
no_split_for_sudo: false
|
||||
if_file_exists: skip
|
||||
disconnect_file: false
|
||||
- cetmix_tower_model: command
|
||||
access_level: manager
|
||||
reference: test_command_from_yaml_2
|
||||
name: Test Command From Yaml 2
|
||||
action: ssh_command
|
||||
allow_parallel_run: false
|
||||
note: false
|
||||
path: false
|
||||
code: echo 'Test Command From Yaml 2'
|
||||
server_status: false
|
||||
no_split_for_sudo: false
|
||||
if_file_exists: skip
|
||||
disconnect_file: false
|
||||
"""
|
||||
|
||||
# -- 1 --
|
||||
# Test with two commands
|
||||
context = {
|
||||
"default_explode_child_records": True,
|
||||
"default_remove_empty_values": True,
|
||||
"active_model": "cx.tower.command",
|
||||
"active_ids": [self.command_test_wizard.id, self.command_test_wizard_2.id],
|
||||
}
|
||||
wizard = self.YamlExportWizard.with_context(context).create({}) # pylint: disable=context-overridden # new need a new clean context
|
||||
wizard.onchange_explode_child_records()
|
||||
self.assertEqual(wizard.yaml_code, wizard_yaml)
|
||||
|
||||
def test_yaml_export_wizard(self):
|
||||
"""Test the YAML export wizard."""
|
||||
|
||||
# -- 1 --
|
||||
# Test wizard action
|
||||
result = self.test_wizard.action_generate_yaml_file()
|
||||
self.assertEqual(
|
||||
result["type"], "ir.actions.act_window", "Action should be a window"
|
||||
)
|
||||
self.assertEqual(
|
||||
result["res_model"],
|
||||
"cx.tower.yaml.export.wiz.download",
|
||||
"Result model should be the download wizard",
|
||||
)
|
||||
self.assertTrue(result["res_id"], "Wizard should have been created")
|
||||
|
||||
# -- 2 --
|
||||
# Ensure download wizard file name is generated
|
||||
# based on the record reference
|
||||
download_wizard = self.env["cx.tower.yaml.export.wiz.download"].browse(
|
||||
result["res_id"]
|
||||
)
|
||||
self.assertEqual(
|
||||
download_wizard.yaml_file_name,
|
||||
f"server_template_{self.server_template_test_wizard.reference}.yaml",
|
||||
"YAML file name should be generated based on record reference",
|
||||
)
|
||||
|
||||
# -- 3 --
|
||||
# Decode YAML file and check if it's valid
|
||||
yaml_file_content = base64.decodebytes(download_wizard.yaml_file).decode(
|
||||
"utf-8"
|
||||
)
|
||||
self.assertEqual(
|
||||
yaml_file_content,
|
||||
self.test_wizard.yaml_code,
|
||||
"YAML file content should be the same as the original YAML code",
|
||||
)
|
||||
|
||||
# -- 4 --
|
||||
# Test if empty YAML code is handled correctly
|
||||
self.test_wizard.yaml_code = ""
|
||||
with self.assertRaises(ValidationError):
|
||||
self.test_wizard.action_generate_yaml_file()
|
||||
|
||||
def test_reference_object_uniqueness(self):
|
||||
"""
|
||||
Ensure each reference is exported as a full object only once
|
||||
(other times only as ref).
|
||||
"""
|
||||
|
||||
# Prepare YAML export for flight_plan with two same commands
|
||||
self.flight_plan_test_wizard.line_ids = [
|
||||
(0, 0, {"command_id": self.command_test_wizard.id}),
|
||||
(0, 0, {"command_id": self.command_test_wizard.id}),
|
||||
]
|
||||
|
||||
# Prepare YAML code
|
||||
self.test_wizard.onchange_explode_child_records()
|
||||
yaml_data = yaml.safe_load(self.test_wizard.yaml_code)
|
||||
|
||||
# reference counters
|
||||
ref_full = set()
|
||||
ref_refs = set()
|
||||
|
||||
# Recursively walk through the YAML data and count references
|
||||
def walk(obj):
|
||||
if isinstance(obj, dict):
|
||||
ref = obj.get("reference")
|
||||
# dict only with "reference" = ref, otherwise — full object
|
||||
if ref:
|
||||
if list(obj.keys()) == ["reference"]:
|
||||
ref_refs.add(ref)
|
||||
else:
|
||||
ref_full.add(ref)
|
||||
for v in obj.values():
|
||||
walk(v)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
walk(v)
|
||||
|
||||
# Walk through the YAML data
|
||||
walk(yaml_data["records"])
|
||||
|
||||
# Each reference as a full object — only once
|
||||
for ref in ref_full:
|
||||
self.assertEqual(
|
||||
list(ref_full).count(ref),
|
||||
1,
|
||||
f"Reference '{ref}' appears as a full object more than once",
|
||||
)
|
||||
# Check that no full objects appear more than once
|
||||
self.assertEqual(
|
||||
len(ref_full),
|
||||
len(set(ref_full)),
|
||||
"Some full objects appear more than once",
|
||||
)
|
||||
|
||||
# Check that for each ref there is no only reference, but no full object
|
||||
for ref in ref_refs:
|
||||
self.assertIn(
|
||||
ref,
|
||||
ref_full,
|
||||
f"Reference '{ref}' is used only as a reference, "
|
||||
"but no full object present",
|
||||
)
|
||||
|
||||
def test_export_required_model_name_in_yaml(self):
|
||||
"""
|
||||
Test that the model name is required in the YAML file for each record
|
||||
"""
|
||||
# create a command to run flight plan
|
||||
command_run_flight_plan = self.TowerCommand.create(
|
||||
{
|
||||
"name": "Run Flight Plan",
|
||||
"action": "plan",
|
||||
"flight_plan_id": self.flight_plan_test_wizard.id,
|
||||
}
|
||||
)
|
||||
# export 2 commands: command_run_flight_plan and command_test_wizard
|
||||
wizard = self.YamlExportWizard.with_context(
|
||||
active_model="cx.tower.command",
|
||||
active_ids=[command_run_flight_plan.id, self.command_test_wizard.id],
|
||||
).create({})
|
||||
|
||||
wizard.onchange_explode_child_records()
|
||||
|
||||
yaml_data = yaml.safe_load(wizard.yaml_code)
|
||||
|
||||
# check that the model name is present in the YAML file for each record
|
||||
for record in yaml_data["records"]:
|
||||
self.assertIn("cetmix_tower_model", record)
|
||||
|
||||
def test_default_yaml_file_name_is_used(self):
|
||||
"""
|
||||
Wizard should pre-fill `yaml_file_name` with the auto-generated
|
||||
value that ends with '.yaml' and contains the model prefix.
|
||||
"""
|
||||
wiz = self.YamlExportWizard.with_context(
|
||||
active_model="cx.tower.command",
|
||||
active_ids=[self.command_test_wizard.id],
|
||||
).create({})
|
||||
|
||||
default_name = wiz.yaml_file_name
|
||||
|
||||
self.assertFalse(
|
||||
default_name.endswith(".yaml"),
|
||||
"Default file name must NO have .yaml suffix",
|
||||
)
|
||||
self.assertIn(
|
||||
"command_",
|
||||
default_name,
|
||||
"Default file name should include model prefix",
|
||||
)
|
||||
|
||||
def test_yaml_file_name_is_auto_fixed(self):
|
||||
"""
|
||||
When the user assigns an invalid name, wizard should auto-sanitise
|
||||
it to a safe *basename* (lowercase, underscores, no extension).
|
||||
"""
|
||||
wiz = self.YamlExportWizard.with_context(
|
||||
active_model="cx.tower.command",
|
||||
active_ids=[self.command_test_wizard.id],
|
||||
).create({})
|
||||
|
||||
# user enters a 'dirty' name with spaces, capitals, symbols
|
||||
wiz.write({"yaml_file_name": "My File!@# .YAML"})
|
||||
|
||||
# write() override strips to a basename WITHOUT '.yaml'
|
||||
self.assertEqual(
|
||||
wiz.yaml_file_name,
|
||||
"my_file",
|
||||
"Wizard field must hold only the cleaned basename, without extension",
|
||||
)
|
||||
|
||||
def test_action_generate_appends_extension(self):
|
||||
"""
|
||||
When generating the download record, the system must append
|
||||
the `.yaml` extension to the sanitized basename.
|
||||
"""
|
||||
wiz = self.YamlExportWizard.with_context(
|
||||
active_model="cx.tower.command",
|
||||
active_ids=[self.command_test_wizard.id],
|
||||
).create({})
|
||||
wiz.onchange_explode_child_records()
|
||||
act = wiz.action_generate_yaml_file()
|
||||
download = self.env["cx.tower.yaml.export.wiz.download"].browse(act["res_id"])
|
||||
self.assertTrue(download.yaml_file_name.endswith(".yaml"))
|
||||
|
||||
def test_custom_requires_text(self):
|
||||
"""Creating a template with license 'custom' but no text must fail"""
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env["cx.tower.yaml.manifest.tmpl"].create(
|
||||
{
|
||||
"name": "Bad Manifest",
|
||||
"license": "custom",
|
||||
}
|
||||
)
|
||||
|
||||
tmpl_ok = self.env["cx.tower.yaml.manifest.tmpl"].create(
|
||||
{
|
||||
"name": "Good Manifest",
|
||||
"license": "custom",
|
||||
"license_text": "Custom license terms",
|
||||
}
|
||||
)
|
||||
self.assertEqual(tmpl_ok.license, "custom")
|
||||
self.assertEqual(tmpl_ok.license_text, "Custom license terms")
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env["cx.tower.yaml.manifest.tmpl"].create(
|
||||
{
|
||||
"name": "Bad Manifest 2",
|
||||
"license": "custom",
|
||||
"license_text": " ",
|
||||
}
|
||||
)
|
||||
|
||||
def test_wizard_resets_price_on_license_change(self):
|
||||
"""Wizard must reset price/currency when license changes away from 'custom'"""
|
||||
wiz = self.YamlExportWizard.new(
|
||||
{
|
||||
"manifest_license": "custom",
|
||||
"manifest_price": 42.0,
|
||||
"manifest_currency": "EUR",
|
||||
}
|
||||
)
|
||||
wiz.manifest_license = "agpl-3"
|
||||
wiz._onchange_manifest_license()
|
||||
self.assertEqual(wiz.manifest_price, 0.0)
|
||||
self.assertFalse(wiz.manifest_currency)
|
||||
|
||||
wiz.manifest_price = 7.5
|
||||
wiz.manifest_currency = "USD"
|
||||
wiz.manifest_license = "custom"
|
||||
wiz._onchange_manifest_license()
|
||||
self.assertEqual(wiz.manifest_price, 7.5)
|
||||
self.assertEqual(wiz.manifest_currency, "USD")
|
||||
703
addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py
Normal file
703
addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py
Normal file
@@ -0,0 +1,703 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
|
||||
import base64
|
||||
|
||||
import yaml
|
||||
|
||||
from odoo import _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests import TransactionCase
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
|
||||
class TestTowerYamlImportWizUpload(TransactionCase):
|
||||
"""Test Tower YAML Import Wizard Upload"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
# Variables
|
||||
cls.Variable = cls.env["cx.tower.variable"]
|
||||
cls.variable_yaml_test = cls.Variable.create(
|
||||
{"name": "YAML Test", "reference": "yaml_test"}
|
||||
)
|
||||
cls.variable_yaml_url = cls.Variable.create(
|
||||
{"name": "YAML URL", "reference": "yaml_url"}
|
||||
)
|
||||
|
||||
# Tags
|
||||
cls.Tag = cls.env["cx.tower.tag"]
|
||||
cls.tag_yaml_test = cls.Tag.create(
|
||||
{"name": "YAML Test", "reference": "yaml_test"}
|
||||
)
|
||||
cls.tag_another_yaml_test = cls.Tag.create(
|
||||
{"name": "Another YAML Test", "reference": "another_yaml_test"}
|
||||
)
|
||||
|
||||
# Commands
|
||||
cls.Command = cls.env["cx.tower.command"]
|
||||
cls.command_yaml_test = cls.Command.create(
|
||||
{"name": "Test Yaml Command", "reference": "test_yaml_command"}
|
||||
)
|
||||
|
||||
# Flight Plan
|
||||
cls.FlightPlan = cls.env["cx.tower.plan"]
|
||||
cls.flight_plan_yaml_test = cls.FlightPlan.create(
|
||||
{
|
||||
"name": "Test Yaml Flight Plan",
|
||||
"reference": "test_yaml_flight_plan",
|
||||
"line_ids": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"condition": False,
|
||||
"use_sudo": False,
|
||||
"command_id": cls.command_yaml_test.id,
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Create Server Template used for testing
|
||||
cls.server_template_yaml_test = cls.env["cx.tower.server.template"].create(
|
||||
{
|
||||
"name": "Test Server Template",
|
||||
"tag_ids": [
|
||||
(4, cls.tag_yaml_test.id),
|
||||
(4, cls.tag_another_yaml_test.id),
|
||||
],
|
||||
"variable_value_ids": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"variable_id": cls.variable_yaml_test.id,
|
||||
"value_char": "Some Test Value",
|
||||
},
|
||||
),
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"variable_id": cls.variable_yaml_url.id,
|
||||
"value_char": "https://cetmix.com",
|
||||
},
|
||||
),
|
||||
],
|
||||
"flight_plan_id": cls.flight_plan_yaml_test.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Server Logs
|
||||
cls.ServerLog = cls.env["cx.tower.server.log"]
|
||||
cls.server_log_yaml_test = cls.ServerLog.create(
|
||||
{
|
||||
"name": "Test Server Log",
|
||||
"reference": "test_server_log",
|
||||
"command_id": cls.command_yaml_test.id,
|
||||
"log_type": "command",
|
||||
"server_template_id": cls.server_template_yaml_test.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create an export wizard and generate YAML code
|
||||
context = {
|
||||
"active_model": "cx.tower.server.template",
|
||||
"active_ids": [cls.server_template_yaml_test.id],
|
||||
}
|
||||
cls.export_wizard = (
|
||||
cls.env["cx.tower.yaml.export.wiz"].with_context(context).create({}) # pylint: disable=context-overridden # new need a new clean context
|
||||
)
|
||||
cls.export_wizard.onchange_explode_child_records()
|
||||
cls.export_wizard.action_generate_yaml_file()
|
||||
cls.yaml_code = cls.export_wizard.yaml_code
|
||||
cls.yaml_file = base64.b64encode(cls.yaml_code.encode("utf-8"))
|
||||
|
||||
# YAML import upload wizard
|
||||
cls.YamlImportWizUpload = cls.env["cx.tower.yaml.import.wiz.upload"]
|
||||
cls.yaml_upload_wizard = cls.YamlImportWizUpload.create(
|
||||
{"yaml_file": cls.yaml_file, "file_name": "test_yaml_file.yaml"}
|
||||
)
|
||||
|
||||
# YAML import wizard
|
||||
cls.import_wizard_action = cls.yaml_upload_wizard.action_import_yaml()
|
||||
cls.import_wizard = cls.env[cls.import_wizard_action["res_model"]].browse(
|
||||
cls.import_wizard_action["res_id"]
|
||||
)
|
||||
cls.import_wizard.if_record_exists = "update"
|
||||
|
||||
def test_extract_yaml_data(self):
|
||||
"""Test extract YAML data from file"""
|
||||
|
||||
# -- 1 --
|
||||
# Test if YAML file is valid
|
||||
extracted_yaml_data = self.yaml_upload_wizard._extract_yaml_data()
|
||||
self.assertEqual(
|
||||
extracted_yaml_data,
|
||||
self.yaml_code,
|
||||
"YAML code is not extracted correctly",
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Test if invalid model is handled properly
|
||||
# Replace model name with invalid model
|
||||
self.invalid_yaml_code = self.yaml_code.replace(
|
||||
"server_template", "invalid_model"
|
||||
)
|
||||
self.invalid_yaml_file = base64.b64encode(
|
||||
self.invalid_yaml_code.encode("utf-8")
|
||||
)
|
||||
self.yaml_upload_wizard.yaml_file = self.invalid_yaml_file
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
self.yaml_upload_wizard._extract_yaml_data()
|
||||
self.assertEqual(
|
||||
str(e.exception),
|
||||
_("'invalid_model' is not a valid model"),
|
||||
"Exception message does not match",
|
||||
)
|
||||
# -- 3 --
|
||||
# Test if non YAML supported model is handled properly
|
||||
# Replace model name with non YAML supported model
|
||||
self.non_yaml_supported_yaml_code = self.yaml_code.replace(
|
||||
"server_template", "command_run_wizard"
|
||||
)
|
||||
self.non_yaml_supported_yaml_file = base64.b64encode(
|
||||
self.non_yaml_supported_yaml_code.encode("utf-8")
|
||||
)
|
||||
self.yaml_upload_wizard.yaml_file = self.non_yaml_supported_yaml_file
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
self.yaml_upload_wizard._extract_yaml_data()
|
||||
self.assertEqual(
|
||||
str(e.exception),
|
||||
_("Model 'command_run_wizard' does not support YAML import"),
|
||||
"Exception message does not match",
|
||||
)
|
||||
|
||||
# -- 4 --
|
||||
# Test if YAML that is not a dictionary is handled properly
|
||||
self.invalid_yaml_file = base64.b64encode(b"Invalid YAML file")
|
||||
self.yaml_upload_wizard.yaml_file = self.invalid_yaml_file
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
self.yaml_upload_wizard._extract_yaml_data()
|
||||
self.assertEqual(
|
||||
str(e.exception),
|
||||
_("Yaml file doesn't contain valid data"),
|
||||
"Exception message does not match",
|
||||
)
|
||||
|
||||
# -- 5 --
|
||||
# Test if TypeError is handled properly
|
||||
self.non_unicode_yaml_file = base64.b64encode(b"\x80")
|
||||
self.yaml_upload_wizard.yaml_file = self.non_unicode_yaml_file
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
self.yaml_upload_wizard._extract_yaml_data()
|
||||
self.assertEqual(
|
||||
str(e.exception),
|
||||
_("YAML file cannot be decoded properly"),
|
||||
"Exception message does not match",
|
||||
)
|
||||
|
||||
# -- 6 --
|
||||
# Test if YAML file is empty
|
||||
self.empty_yaml_file = ""
|
||||
self.yaml_upload_wizard.yaml_file = self.empty_yaml_file
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
self.yaml_upload_wizard._extract_yaml_data()
|
||||
self.assertEqual(
|
||||
str(e.exception),
|
||||
_("File is empty"),
|
||||
"Exception message does not match",
|
||||
)
|
||||
|
||||
# -- 7 --
|
||||
# Test if YAML file with unsupported YAML version is handled properly
|
||||
yaml_with_unsupported_version = self.yaml_code.replace(
|
||||
f"cetmix_tower_yaml_version: {self.FlightPlan.CETMIX_TOWER_YAML_VERSION}",
|
||||
f"cetmix_tower_yaml_version: {self.FlightPlan.CETMIX_TOWER_YAML_VERSION + 1}", # noqa: E501
|
||||
)
|
||||
self.unsupported_yaml_version_yaml_file = base64.b64encode(
|
||||
yaml_with_unsupported_version.encode("utf-8")
|
||||
)
|
||||
self.yaml_upload_wizard.yaml_file = self.unsupported_yaml_version_yaml_file
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
self.yaml_upload_wizard._extract_yaml_data()
|
||||
self.assertEqual(
|
||||
str(e.exception),
|
||||
_(
|
||||
"YAML version is higher than version"
|
||||
" supported by your Cetmix Tower instance."
|
||||
" %(code_version)s > %(tower_version)s",
|
||||
code_version=self.FlightPlan.CETMIX_TOWER_YAML_VERSION + 1,
|
||||
tower_version=self.FlightPlan.CETMIX_TOWER_YAML_VERSION,
|
||||
),
|
||||
"Exception message does not match",
|
||||
)
|
||||
|
||||
# -- 8 --
|
||||
# Test YAML file with no records
|
||||
self.import_wizard.yaml_code = "cetmix_tower_yaml_version: 1"
|
||||
with self.assertRaises(ValidationError) as e:
|
||||
self.import_wizard.action_import_yaml()
|
||||
self.assertEqual(
|
||||
str(e.exception),
|
||||
_("YAML file doesn't contain any records"),
|
||||
"Exception message does not match",
|
||||
)
|
||||
|
||||
def test_action_import_yaml_skip_if_exists(self):
|
||||
"""Test YAML import wizard action when skipping an existing record"""
|
||||
|
||||
self.import_wizard.if_record_exists = "skip"
|
||||
|
||||
# Run import wizard action
|
||||
import_wizard_result_action = self.import_wizard.action_import_yaml()
|
||||
|
||||
# Test if action is composed properly
|
||||
self.assertEqual(
|
||||
import_wizard_result_action["type"],
|
||||
"ir.actions.client",
|
||||
"Import wizard action type is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
import_wizard_result_action["tag"],
|
||||
"display_notification",
|
||||
"Import wizard action tag is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
import_wizard_result_action["params"]["title"],
|
||||
_("Record Import"),
|
||||
"Import wizard action title is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
import_wizard_result_action["params"]["message"],
|
||||
_("No records were created or updated"),
|
||||
"Import wizard action message is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
import_wizard_result_action["params"]["sticky"],
|
||||
True,
|
||||
"Import wizard action sticky is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
import_wizard_result_action["params"]["type"],
|
||||
"warning",
|
||||
"Import wizard action type is not correct",
|
||||
)
|
||||
|
||||
def test_action_import_yaml_update_existing_record(self):
|
||||
"""Test YAML import wizard action when updating an existing record"""
|
||||
|
||||
# -- 1 --
|
||||
# Test if new import wizard record is created properly
|
||||
self.assertEqual(
|
||||
self.import_wizard_action["res_model"],
|
||||
"cx.tower.yaml.import.wiz",
|
||||
"Import wizard action model is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.import_wizard_action["view_mode"],
|
||||
"form",
|
||||
"Import wizard action view mode is not correct",
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Modify Server Template name and variable value
|
||||
self.import_wizard.yaml_code = self.import_wizard.yaml_code.replace(
|
||||
"name: Test Server Template",
|
||||
"name: Updated Test Server Template",
|
||||
).replace(
|
||||
"value_char: Some Test Value",
|
||||
"value_char: Updated Test Value",
|
||||
)
|
||||
variable_value_to_update = (
|
||||
self.server_template_yaml_test.variable_value_ids.filtered(
|
||||
lambda v: v.value_char == "Some Test Value"
|
||||
)
|
||||
)
|
||||
|
||||
# Run import wizard action another time
|
||||
import_wizard_result_action = self.import_wizard.action_import_yaml()
|
||||
|
||||
# -- 3 --
|
||||
# Test if record is updated properly
|
||||
self.assertEqual(
|
||||
import_wizard_result_action["res_model"],
|
||||
"cx.tower.server.template",
|
||||
"Import wizard action model is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
import_wizard_result_action["domain"],
|
||||
[("id", "in", self.server_template_yaml_test.ids)],
|
||||
"ID must match existing record ID",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.server_template_yaml_test.name,
|
||||
"Updated Test Server Template",
|
||||
"Record is not updated properly",
|
||||
)
|
||||
self.assertEqual(
|
||||
variable_value_to_update.value_char,
|
||||
"Updated Test Value",
|
||||
"Variable value is not updated properly",
|
||||
)
|
||||
|
||||
# -- 4 --
|
||||
# Test if server log remains the same
|
||||
self.assertEqual(
|
||||
len(self.server_template_yaml_test.server_log_ids),
|
||||
1,
|
||||
"Server Log must remain the same",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.server_log_yaml_test.id,
|
||||
self.server_template_yaml_test.server_log_ids.id,
|
||||
"Server Log must remain the same",
|
||||
)
|
||||
|
||||
def test_action_import_yaml_create_new_record(self):
|
||||
"""Test YAML import wizard action when creating a new record"""
|
||||
self.import_wizard.if_record_exists = "create"
|
||||
with mute_logger("odoo.addons.cetmix_tower_yaml.models.cx_tower_yaml_mixin"):
|
||||
import_wizard_result_action = self.import_wizard.action_import_yaml()
|
||||
|
||||
# -- 1 --
|
||||
# Test if new record is created instead of updating existing one
|
||||
self.assertEqual(
|
||||
import_wizard_result_action["res_model"],
|
||||
"cx.tower.server.template",
|
||||
"Import wizard action model is not correct",
|
||||
)
|
||||
self.assertNotEqual(
|
||||
import_wizard_result_action["domain"],
|
||||
f"[('id', '=', {self.server_template_yaml_test.ids})]",
|
||||
"ID must not match existing record ID",
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Ensure that existing flight plan is used instead of creating a new one
|
||||
new_server_template = self.env[import_wizard_result_action["res_model"]].search(
|
||||
import_wizard_result_action["domain"]
|
||||
)
|
||||
self.assertEqual(
|
||||
new_server_template.flight_plan_id,
|
||||
self.flight_plan_yaml_test,
|
||||
"Existing flight plan must be used",
|
||||
)
|
||||
|
||||
# -- 3 --
|
||||
# Ensure that existing tags are used instead of creating new ones
|
||||
for tag in self.server_template_yaml_test.tag_ids:
|
||||
self.assertIn(
|
||||
tag,
|
||||
new_server_template.tag_ids,
|
||||
"Existing tag must be used",
|
||||
)
|
||||
|
||||
# -- 4 --
|
||||
# Ensure that new variable values are created
|
||||
for variable_value in self.server_template_yaml_test.variable_value_ids:
|
||||
self.assertNotIn(
|
||||
variable_value,
|
||||
new_server_template.variable_value_ids,
|
||||
"New variable value must be created instead of updating existing one",
|
||||
)
|
||||
|
||||
# -- 5 --
|
||||
# Test if server log is created instead of updated
|
||||
for server_log in self.server_template_yaml_test.server_log_ids:
|
||||
self.assertNotIn(
|
||||
server_log,
|
||||
new_server_template.server_log_ids,
|
||||
"New Server Log must be created instead of updating existing one",
|
||||
)
|
||||
|
||||
def test_extract_secret_names(self):
|
||||
"""Test extract secret names from YAML data"""
|
||||
|
||||
# NB: this is not a real model, it's just for testing
|
||||
yaml_code = """cetmix_tower_yaml_version: 1
|
||||
records:
|
||||
- cetmix_tower_model: test_model
|
||||
access_level: manager
|
||||
reference: such_much_test_record
|
||||
name: Such Much Command
|
||||
action: file_using_template
|
||||
allow_parallel_run: false
|
||||
note: Just a note
|
||||
os_ids: false
|
||||
tag_ids: false
|
||||
path: false
|
||||
file_template_id: false
|
||||
flight_plan_id: false
|
||||
code: false
|
||||
variable_ids: false
|
||||
secret_ids: false
|
||||
ssh_key_id:
|
||||
reference: test_ssh_key
|
||||
name: Test SSH Key
|
||||
key_type: k
|
||||
note: false
|
||||
- cetmix_tower_model: another_test_model
|
||||
reference: such_much_test_record_2
|
||||
name: Such Much Test Record 2
|
||||
note: Just a note 2
|
||||
ssh_key_id:
|
||||
reference: test_ssh_key
|
||||
name: Test SSH Key
|
||||
key_type: k
|
||||
note: false
|
||||
secret_ids:
|
||||
- reference: secret_2
|
||||
name: Secret 2
|
||||
key_type: s
|
||||
note: false
|
||||
- reference: secret_3
|
||||
name: Secret 3
|
||||
key_type: s
|
||||
note: false
|
||||
- cetmix_tower_model: another_test_model
|
||||
reference: such_much_test_record_3
|
||||
name: Such Much Test Record 3
|
||||
note: Just a note 3
|
||||
ssh_key_id:
|
||||
reference: another_ssh_key
|
||||
name: Another SSH Key
|
||||
sub_record:
|
||||
reference: such_much_test_record_4
|
||||
name: Such Much Test Record 4
|
||||
note: Just a note 4
|
||||
secret_ids:
|
||||
- reference: secret_1
|
||||
name: Secret 3
|
||||
key_type: s
|
||||
note: false
|
||||
- reference: secret_2
|
||||
name: Secret 4
|
||||
key_type: s
|
||||
note: false
|
||||
file_template_id:
|
||||
reference: my_custom_test_template
|
||||
name: Such much demo
|
||||
source: tower
|
||||
file_type: text
|
||||
server_dir: /var/log/my/files
|
||||
file_name: much_logs.txt
|
||||
keep_when_deleted: false
|
||||
tag_ids: false
|
||||
note: Hey!
|
||||
code: false
|
||||
variable_ids: false
|
||||
secret_ids: false
|
||||
flight_plan_id: false
|
||||
code: false
|
||||
variable_ids: false
|
||||
secret_ids:
|
||||
- reference: secret_1
|
||||
name: Secret 1
|
||||
key_type: s
|
||||
note: false
|
||||
- reference: secret_2
|
||||
name: Secret 2
|
||||
key_type: s
|
||||
note: false
|
||||
"""
|
||||
secret_list = self.env["cx.tower.yaml.import.wiz"]._extract_secret_names(
|
||||
yaml.safe_load(yaml_code)
|
||||
)
|
||||
# We expect 6 secrets in the list:
|
||||
# 2 keys: 'Test SSH Key', 'Another SSH Key'
|
||||
# 4 secrets: 'Secret 3', 'Secret 4', 'Secret 1', 'Secret 2'
|
||||
self.assertEqual(len(secret_list), 6, "Secret list length is not correct")
|
||||
self.assertIn("Test SSH Key", secret_list, "Key is not in the list")
|
||||
self.assertIn("Another SSH Key", secret_list, "Key is not in the list")
|
||||
self.assertIn("Secret 3", secret_list, "Key is not in the list")
|
||||
self.assertIn("Secret 4", secret_list, "Key is not in the list")
|
||||
self.assertIn("Secret 1", secret_list, "Key is not in the list")
|
||||
self.assertIn("Secret 2", secret_list, "Key is not in the list")
|
||||
|
||||
def test_extract_secret_names_with_key_id(self):
|
||||
"""Test extract secret names when secrets are nested under key_id"""
|
||||
yaml_code = """cetmix_tower_yaml_version: 1
|
||||
records:
|
||||
- cetmix_tower_model: test_model
|
||||
reference: rec_1
|
||||
name: Test Record
|
||||
secret_ids:
|
||||
- key_id:
|
||||
reference: secret_1
|
||||
name: Nested Secret 1
|
||||
- key_id:
|
||||
reference: secret_2
|
||||
name: Nested Secret 2
|
||||
ssh_key_id:
|
||||
name: SSH Key Nested
|
||||
"""
|
||||
secret_list = self.env["cx.tower.yaml.import.wiz"]._extract_secret_names(
|
||||
yaml.safe_load(yaml_code)
|
||||
)
|
||||
|
||||
# We expect 3 secrets total:
|
||||
# - SSH Key Nested (from ssh_key_id)
|
||||
# - Nested Secret 1
|
||||
# - Nested Secret 2
|
||||
self.assertCountEqual(
|
||||
secret_list,
|
||||
["Nested Secret 1", "Nested Secret 2", "SSH Key Nested"],
|
||||
"Unexpected secrets extracted for nested structure",
|
||||
)
|
||||
|
||||
def test_create_records_different_models(self):
|
||||
"""Test create records with different models"""
|
||||
|
||||
yaml_code = """cetmix_tower_yaml_version: 1
|
||||
records:
|
||||
- cetmix_tower_model: command
|
||||
access_level: manager
|
||||
reference: much_much_command
|
||||
name: Much Much Command
|
||||
action: file_using_template
|
||||
allow_parallel_run: false
|
||||
note: Just a note
|
||||
os_ids: false
|
||||
tag_ids: false
|
||||
path: false
|
||||
file_template_id: false
|
||||
flight_plan_id: false
|
||||
code: false
|
||||
variable_ids: false
|
||||
secret_ids: false
|
||||
ssh_key_id:
|
||||
reference: test_ssh_key
|
||||
name: Test SSH Key
|
||||
key_type: k
|
||||
note: false
|
||||
- cetmix_tower_model: server_template
|
||||
reference: wow_much_server_template
|
||||
name: Wow Much Server Template
|
||||
note: Just a note 2
|
||||
- cetmix_tower_model: tag
|
||||
reference: such_much_tag
|
||||
name: Such Much Tag
|
||||
"""
|
||||
# Create a new command record
|
||||
self.import_wizard.if_record_exists = "update"
|
||||
self.import_wizard.yaml_code = yaml_code
|
||||
|
||||
action = self.import_wizard.action_import_yaml()
|
||||
|
||||
# Check if action is composed properly
|
||||
self.assertEqual(
|
||||
action["type"],
|
||||
"ir.actions.client",
|
||||
"Import wizard action type is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
action["tag"],
|
||||
"display_notification",
|
||||
"Import wizard action tag is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
action["params"]["title"],
|
||||
_("Record Import"),
|
||||
"Import wizard action title is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
action["params"]["type"],
|
||||
"success",
|
||||
"Import wizard action type is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
action["params"]["sticky"],
|
||||
True,
|
||||
"Import wizard action sticky is not correct",
|
||||
)
|
||||
|
||||
# Check command
|
||||
self.assertTrue(
|
||||
self.Command.get_by_reference("much_much_command"),
|
||||
"Command must be created",
|
||||
)
|
||||
|
||||
# Check server template
|
||||
self.assertTrue(
|
||||
self.env["cx.tower.server.template"].get_by_reference(
|
||||
"wow_much_server_template"
|
||||
),
|
||||
"Server template must be created",
|
||||
)
|
||||
|
||||
# Check tag
|
||||
self.assertTrue(
|
||||
self.Tag.get_by_reference("such_much_tag"), "Tag must be created"
|
||||
)
|
||||
|
||||
def test_yaml_import_server_without_password(self):
|
||||
"""Wizard should import server without ssh_password."""
|
||||
yaml_code = (
|
||||
"cetmix_tower_yaml_version: 1\n"
|
||||
"records:\n"
|
||||
"- reference: srv_nopass\n"
|
||||
" cetmix_tower_model: server\n"
|
||||
" name: YAML NoPass\n"
|
||||
" ssh_auth_mode: p\n"
|
||||
" ssh_username: root\n"
|
||||
" ip_v4_address: 10.0.0.3\n"
|
||||
)
|
||||
wiz = self.env["cx.tower.yaml.import.wiz"].create(
|
||||
{
|
||||
"yaml_code": yaml_code,
|
||||
"if_record_exists": "create",
|
||||
}
|
||||
)
|
||||
wiz.action_import_yaml()
|
||||
|
||||
srv = self.env["cx.tower.server"].get_by_reference("srv_nopass")
|
||||
self.assertTrue(srv, "Server was not created")
|
||||
self.assertFalse(
|
||||
srv._get_secret_value("ssh_password"),
|
||||
"ssh_password must stay empty after import",
|
||||
)
|
||||
|
||||
def test_orm_create_server_requires_password(self):
|
||||
"""Creating a server via ORM/UI must fail when ssh_password is missing."""
|
||||
with self.assertRaises(ValidationError) as err:
|
||||
self.env["cx.tower.server"].create(
|
||||
{
|
||||
"reference": "srv_ui",
|
||||
"name": "UI NoPass",
|
||||
"ssh_auth_mode": "p",
|
||||
"ssh_username": "root",
|
||||
"ip_v4_address": "10.0.0.2",
|
||||
}
|
||||
)
|
||||
self.assertIn("Please provide SSH password", str(err.exception))
|
||||
|
||||
def test_yaml_import_server_with_skip_ssh_check(self):
|
||||
"""Explicit skip_ssh_settings_check also bypasses password validation."""
|
||||
yaml_code = (
|
||||
"cetmix_tower_yaml_version: 1\n"
|
||||
"records:\n"
|
||||
"- reference: srv_skip\n"
|
||||
" cetmix_tower_model: server\n"
|
||||
" name: YAML Skip Check\n"
|
||||
" ssh_auth_mode: p\n"
|
||||
" ssh_username: root\n"
|
||||
" ip_v4_address: 10.0.0.4\n"
|
||||
)
|
||||
wiz = self.env["cx.tower.yaml.import.wiz"].create(
|
||||
{
|
||||
"yaml_code": yaml_code,
|
||||
"if_record_exists": "create",
|
||||
}
|
||||
)
|
||||
wiz.with_context(skip_ssh_settings_check=True).action_import_yaml()
|
||||
|
||||
srv = self.env["cx.tower.server"].get_by_reference("srv_skip")
|
||||
self.assertTrue(
|
||||
srv, "Server must be created when skip_ssh_settings_check is set"
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user