Wipe cetmix_tower_yaml (polluted by overlapping uploads)

This commit is contained in:
Tower Deploy
2026-04-27 13:43:58 +03:00
parent 18dd9c7a1f
commit 7cef9f1a32
80 changed files with 0 additions and 9275 deletions

View File

@@ -1,152 +0,0 @@
=================
Cetmix Tower YAML
=================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:96e8f3f1df3ab25b952a9534d0914149740cc036b62efe2c7795f9d2d9636177
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |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_yaml
:alt: cetmix/cetmix-tower
|badge1| |badge2| |badge3|
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.
**Table of contents**
.. contents::
:local:
Configuration
=============
Please refer to the `official
documentation <https://cetmix.com/tower>`__ for detailed configuration
instructions.
Usage
=====
Please refer to the `official
documentation <https://cetmix.com/tower>`__ for detailed usage
instructions.
Changelog
=========
16.0.2.0.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
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_yaml%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_yaml>`_ project on GitHub.
You are welcome to contribute.

View File

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

View File

@@ -1,42 +0,0 @@
# Copyright Cetmix OÜ 2024
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Cetmix Tower YAML",
"summary": "Cetmix Tower YAML export/import",
"version": "16.0.2.0.3",
"development_status": "Beta",
"category": "Productivity",
"website": "https://tower.cetmix.com",
"author": "Cetmix",
"license": "AGPL-3",
"installable": True,
"depends": ["cetmix_tower_server"],
"external_dependencies": {"python": ["pyyaml"]},
"data": [
"security/cetmix_tower_yaml_groups.xml",
"security/cx_tower_yaml_wizard_access_rules.xml",
"security/ir.model.access.csv",
"views/cx_tower_command_view.xml",
"views/cx_tower_file_template_view.xml",
"views/cx_tower_plan_view.xml",
"views/cx_tower_server_template_view.xml",
"views/cx_tower_server_view.xml",
"views/cx_tower_variable_view.xml",
"views/cx_tower_variable_value_view.xml",
"views/cx_tower_os_view.xml",
"views/cx_tower_tag_view.xml",
"views/cx_tower_shortcut_view.xml",
"views/cx_tower_scheduled_task_view.xml",
"views/cx_tower_key_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",
"wizards/cx_tower_yaml_export_wiz_download.xml",
"wizards/cx_tower_yaml_import_wiz_upload.xml",
"wizards/cx_tower_yaml_import_wiz.xml",
"views/menuitems.xml",
],
"demo": [
"demo/demo_data.xml",
],
}

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<!-- Add demo users to groups -->
<record id="base.user_admin" model="res.users">
<field
name="groups_id"
eval="[
(4, ref('cetmix_tower_yaml.group_export')),
(4, ref('cetmix_tower_yaml.group_import')),
]"
/>
</record>
</odoo>

File diff suppressed because it is too large Load Diff

View File

@@ -1,587 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * cetmix_tower_yaml
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-03-06 09:11+0000\n"
"Last-Translator: Bole <bole@dajmi5.com>\n"
"Language-Team: Croatian <https://hosted.weblate.org/projects/"
"tower-server-14-0-dev/cetmix_tower_yaml/hr/>\n"
"Language: hr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"X-Generator: Weblate 5.10.3-dev\n"
#. module: cetmix_tower_yaml
#: model:res.groups,comment:cetmix_tower_yaml.group_export
msgid ""
"\n"
" Export data to YAML.\n"
" "
msgstr ""
"\n"
" Izvoz podataka u YAML.\n"
" "
#. module: cetmix_tower_yaml
#: model:res.groups,comment:cetmix_tower_yaml.group_import
msgid ""
"\n"
" Import data from YAML.\n"
" "
msgstr ""
"\n"
" Uvoz podataka iz YAML.\n"
" "
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid ""
"<strong>Important:</strong> To maintain data consistency, the following "
"model records will always be updated if they exist in Odoo:"
msgstr ""
"<strong>Važno:</strong> Za održavanje konsistencije podataka, sljedeći "
"zapisi modela će uvijek biti ažurirani ako postoje u Odoo:"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,help:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__explode_child_records
msgid ""
"Add entire child record definitions to the exported YAML file. Otherwise "
"only references to child records will be added."
msgstr ""
"Dodaj sve podređene definicije podataka u izveženu YAML datoteku. Inače će "
"biti dodane samo reference na podređene zapise."
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_command
msgid "Cetmix Tower Command"
msgstr "Cetmix Tower Naredba"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_plan
msgid "Cetmix Tower Flight Plan"
msgstr "Cetmix Tower Plan Leta"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_plan_line
msgid "Cetmix Tower Flight Plan Line"
msgstr "Cetmix Tower Stavka plana leta"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_plan_line_action
msgid "Cetmix Tower Flight Plan Line Action"
msgstr "Cetmix Tower Akcija stavke plana leta"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_os
msgid "Cetmix Tower Operating System"
msgstr "Cetmix Tower Operativni sistem"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_server_log
msgid "Cetmix Tower Server Log"
msgstr "Cetmix Tower Log servera"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_server_template
msgid "Cetmix Tower Server Template"
msgstr "Cetmix Tower Predložak servera"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_tag
msgid "Cetmix Tower Tag"
msgstr "Cetmix Tower Oznaka"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_variable
msgid "Cetmix Tower Variable"
msgstr "Cetmix Tower Varijabla"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_variable_option
msgid "Cetmix Tower Variable Options"
msgstr "Cetmix Tower Opcija varijable"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_variable_value
msgid "Cetmix Tower Variable Values"
msgstr "Cetmix Tower Vrijednosti varijabli"
#. module: cetmix_tower_yaml
#: model:ir.module.category,name:cetmix_tower_yaml.ir_module_category_tower_yaml
msgid "Cetmix Tower YAML"
msgstr "Cetmix Tower YAML"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_yaml_export_wiz_download
msgid "Cetmix Tower YAML Export File Download"
msgstr "Cetmix Tower YAML preuzimanje izvežene datoteke"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_yaml_export_wiz
msgid "Cetmix Tower YAML Export Wizard"
msgstr "Cetmix Tower YAML Čarobnjak za izvoz"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_yaml_import_wiz
msgid "Cetmix Tower YAML Import Wizard"
msgstr "Cetmix Tower YAML Čarobnjak za uvoz"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_yaml_import_wiz_upload
msgid "Cetmix Tower YAML Import Wizard Upload"
msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_yaml_mixin
msgid "Cetmix Tower YAML rendering mixin"
msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_key
msgid "Cetmix Tower private key storage"
msgstr "Cetmix Tower Pohrana privatnih ključeva"
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_export_wiz_download_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_export_wiz_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_upload_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid "Close"
msgstr "Zatvori"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__comment
msgid "Comment"
msgstr "Komentar"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,help:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__comment
msgid "Comment to be added to the beginning of exported YAML file"
msgstr "Komentar koje će biti dodan na početku izvežene YAML datoteke"
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid "Create New Record"
msgstr "Kreiraj novi zapis"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz_download__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__create_uid
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz_upload__create_uid
msgid "Created by"
msgstr "Kreirao"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__create_date
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz_download__create_date
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__create_date
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz_upload__create_date
msgid "Created on"
msgstr "Kreirano"
#. module: cetmix_tower_yaml
#: model:ir.model,name:cetmix_tower_yaml.model_cx_tower_file_template
msgid "Cx Tower File Template"
msgstr "CxTower Predložak datoteke"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_command__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_file_template__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_key__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_os__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line_action__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_log__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_template__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_tag__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable_option__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable_value__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz_download__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz_upload__display_name
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_mixin__display_name
msgid "Display Name"
msgstr "Naziv"
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid ""
"Existing record will be updated with the new data. Related records, present in the YAML code, will be updated too.\n"
" If any of those related records doesn't exist, it will be created automatically."
msgstr ""
"Postojeći zapis će biti ažuriran sa novim podacima. Povezani zapisi, "
"prisutni u YAML kodu, će također biti ažurirani.\n"
" Ukoliko neki od "
"povezanih zapisa ne postoji, biti će kreiran automatski."
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__explode_child_records
msgid "Explode Child Records"
msgstr "Proširi podređene zapise"
#. module: cetmix_tower_yaml
#: model:res.groups,name:cetmix_tower_yaml.group_export
msgid "Export"
msgstr "Izvoz"
#. 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_plan_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form
msgid "Export YAML"
msgstr "Izvoz YAML"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/models/cx_tower_yaml_mixin.py:0
#, python-format
msgid "Failed to convert dictionary to YAML: %(error)s"
msgstr "Neuspjelo pretvaranje dictionary u YAML: %(error)s"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_export_wiz.py:0
#, python-format
msgid ""
"Failed to encode YAML content. Please ensure all characters are UTF-8 "
"compatible."
msgstr ""
"Neuspjelo kreiranje YAML sadržaja. Molimo provjerite jesu li svi znakovi UTF-"
"8 kompatabilni."
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz_upload__file_name
msgid "File Name"
msgstr "Naziv datoteke"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py:0
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz_upload.py:0
#, python-format
msgid "File is not a valid base64-encoded file"
msgstr "Datoteka nije ispravno base64 kodirana"
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_export_wiz_view_form
msgid "Generate YAML file"
msgstr "Generiraj YAML datoteku"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_command__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_file_template__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_key__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_os__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line_action__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_log__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_template__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_tag__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable_option__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable_value__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz_download__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz_upload__id
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_mixin__id
msgid "ID"
msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model.fields,help:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__update_existing_record
msgid ""
"If enabled, existing records will be updated with the new data. Otherwise, "
"new records will be created."
msgstr ""
"Ako je označeno, postojeći zapisi će biti ažurirani sa novim podacima. "
"Inače, novi zapisi će biti kreirani."
#. module: cetmix_tower_yaml
#: model:res.groups,name:cetmix_tower_yaml.group_import
msgid "Import"
msgstr "Uvoz"
#. module: cetmix_tower_yaml
#: model:ir.actions.act_window,name:cetmix_tower_yaml.action_cx_tower_yaml_import_wiz_upload
#: model:ir.ui.menu,name:cetmix_tower_yaml.menu_cetmix_tower_yaml_import
msgid "Import YAML"
msgstr "Uvoz YAML"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz_upload.py:0
#, python-format
msgid "Invalid YAML file"
msgstr "Neispravna YAML datoteka"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py:0
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz_upload.py:0
#, python-format
msgid "Invalid model specified in the YAML file"
msgstr "Neispravan model naveden u YAML datoteci"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_command____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_file_template____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_key____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_os____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan_line_action____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_log____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_template____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_tag____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable_option____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable_value____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz_download____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz_upload____last_update
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_mixin____last_update
msgid "Last Modified on"
msgstr "Zadnje modificirano"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz_download__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__write_uid
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz_upload__write_uid
msgid "Last Updated by"
msgstr "Zadnji ažurirao"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__write_date
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz_download__write_date
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__write_date
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz_upload__write_date
msgid "Last Updated on"
msgstr "Zadnje ažurirano"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__model_description
msgid "Model"
msgstr ""
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__model_name
msgid "Model Name"
msgstr "Naziv modela"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py:0
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz_upload.py:0
#, python-format
msgid "Model does not support YAML import"
msgstr "Model ne podržava YAML uvoz"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,help:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__model_name
msgid "Model to create records in"
msgstr "Model u kojem će podaci biti kreirani"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_export_wiz.py:0
#, python-format
msgid "No YAML code is present."
msgstr "YAML kod nije dostupan."
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz_upload.py:0
#, python-format
msgid "No model for import is specified in the YAML file"
msgstr "Model za uvoz nije naveden u YAML datoteci"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_export_wiz.py:0
#, python-format
msgid "No model or records selected"
msgstr "Nisu odabrani zapisi ili model"
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid "OSs"
msgstr ""
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid "Open Existing Record"
msgstr "Otvori postojeći zapis"
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_upload_view_form
msgid "Process"
msgstr "U procesu"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__record_id
msgid "Record"
msgstr "Zapis"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,help:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__record_id
msgid "Record ID to update"
msgstr "ID zapisa za ažuriranje"
#. 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"
msgstr "Ukloni prazne x2m vrijednosti polja"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,help:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__remove_empty_values
msgid ""
"Remove empty Many2one, Many2many and One2many field values from the exported"
" YAML file."
msgstr ""
"Ukloni prazne Many2one, Many2many i One2many vrijednosti polja iz izvežene "
"YAML datoteke."
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid "Tags"
msgstr "Oznake"
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid "This will create a new record. Proceed?"
msgstr "Ovo će kreirati novi zapis. Nastaviti?"
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid "This will overwrite the existing record. Proceed?"
msgstr "Ovo će prepisati preko postojećeg zapisa. Nastaviti?"
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid ""
"To create new entities instead of updating existing ones, remove or modify "
"the <code>reference</code> field in the YAML code for those entities."
msgstr ""
"Za kreiranje novih entiteta umjesto ažuriranja postojećih, uklonite ili "
"modificirajte <code>reference</code> polje u YAML kodu za te zapise."
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__update_existing_record
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid "Update Existing Record"
msgstr "Ažuriraj postojeći zapis"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/models/cx_tower_yaml_mixin.py:0
#: code:addons/cetmix_tower_yaml/tests/test_tower_yaml_mixin.py:0
#, python-format
msgid "Values must be a dictionary"
msgstr "Vrijednosti moraju biti dictionary"
#. module: cetmix_tower_yaml
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_yaml_import_wiz_view_form
msgid "Variables"
msgstr "Varijable"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/models/cx_tower_yaml_mixin.py:0
#: code:addons/cetmix_tower_yaml/tests/test_tower_yaml_mixin.py:0
#, python-format
msgid "Wrong value for 'access_level' key: %(acv)s"
msgstr "Pogrešna vrijednost za ključ 'access_level': %(acv)s"
#. 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_plan_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form
msgid "YAML"
msgstr ""
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py:0
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz_upload.py:0
#, python-format
msgid "YAML file cannot be decoded properly"
msgstr "YAML datoteku nije moguće pročitati ili dekodirati"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py:0
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz_upload.py:0
#, python-format
msgid ""
"YAML file version is not supported. You may need to update the Cetmix Tower "
"Yaml module."
msgstr ""
"Verzija YAML datoteke nije podržana. Možda morate ažurirati Cetmix Tower "
"Yaml modul."
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/models/cx_tower_yaml_mixin.py:0
#: code:addons/cetmix_tower_yaml/tests/test_tower_yaml_mixin.py:0
#, python-format
msgid ""
"YAML version is higher than version supported by your Cetmix Tower instance."
" %(code_version)s > %(tower_version)s"
msgstr ""
"YAML verzija je viša od verzije podržane na vašoj Cetmix Tower instanci. "
"%(code_version)s>%(tower_version)s"
#. module: cetmix_tower_yaml
#: 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_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_os__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_plan__yaml_code
#: 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_server_log__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_server_template__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_tag__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable_option__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_variable_value__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz__yaml_code
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_mixin__yaml_code
msgid "Yaml Code"
msgstr "YAML kod"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz_download__yaml_file
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_import_wiz_upload__yaml_file
msgid "Yaml File"
msgstr "YAML datoteka"
#. module: cetmix_tower_yaml
#: model:ir.model.fields,field_description:cetmix_tower_yaml.field_cx_tower_yaml_export_wiz_download__yaml_file_name
msgid "Yaml File Name"
msgstr "YAML naziv datoteke"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py:0
#: code:addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz_upload.py:0
#, python-format
msgid "Yaml file doesn't contain valid data"
msgstr "Yaml datoteka ne sadrži valjane podatke"
#. module: cetmix_tower_yaml
#: code:addons/cetmix_tower_yaml/models/cx_tower_yaml_mixin.py:0
#, python-format
msgid "You are not allowed to create records from YAML"
msgstr "Nije vam dozvoljeno kreiranje zapisa pomoću YAML datoteka"
#. 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_plan_view_form
#: model_terms:ir.ui.view,arch_db:cetmix_tower_yaml.cx_tower_server_template_view_form
msgid "You must be a member of the \"YAML/Export\" group to export data as YAML."
msgstr "Morate bit član \"YAML/Izvoz\" grupe za izvoz podataka u YAML."

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +0,0 @@
def migrate(cr, version):
"""
Normalize existing license values to lowercase.
Runs only on upgrade (version != False).
"""
if not version:
return
# Skip rows already lowercase for efficiency
cr.execute(
"""
UPDATE cx_tower_yaml_manifest_tmpl
SET license = LOWER(TRIM(license))
WHERE license IS NOT NULL
AND (
license <> LOWER(license)
OR license <> TRIM(license)
)
"""
)

View File

@@ -1,21 +0,0 @@
from . import cx_tower_yaml_mixin
from . import cx_tower_command
from . import cx_tower_file_template
from . import cx_tower_tag
from . import cx_tower_plan
from . import cx_tower_plan_line
from . import cx_tower_plan_line_action
from . import cx_tower_variable
from . import cx_tower_variable_option
from . import cx_tower_variable_value
from . import cx_tower_os
from . import cx_tower_server_template
from . import cx_tower_key
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_file
from . import cx_tower_server
from . import cx_tower_yaml_manifest_template
from . import cx_tower_yaml_manifest_author

View File

@@ -1,31 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerCommand(models.Model):
_name = "cx.tower.command"
_inherit = ["cx.tower.command", "cx.tower.yaml.mixin"]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"access_level",
"name",
"action",
"allow_parallel_run",
"note",
"os_ids",
"tag_ids",
"path",
"file_template_id",
"flight_plan_id",
"code",
"server_status",
"variable_ids",
"secret_ids",
"no_split_for_sudo",
"if_file_exists",
"disconnect_file",
]
return res

View File

@@ -1,46 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerFile(models.Model):
_name = "cx.tower.file"
_inherit = ["cx.tower.file", "cx.tower.yaml.mixin"]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"source",
"file_type",
"server_dir",
"code",
"file",
"variable_ids",
"secret_ids",
"template_id",
"keep_when_deleted",
"auto_sync",
"auto_sync_interval",
"sync_date_next",
"sync_date_last",
"server_response",
]
return res
def _post_create_write(self, op_type="write"):
# Do not pull/push files if they are being created from YAML
if self.env.context.get("from_yaml"):
return
super()._post_create_write(op_type)
def _prepare_record_for_yaml(self):
"""
Override to drop file `code` when the source is 'server'.
"""
record_dict = super()._prepare_record_for_yaml()
if record_dict.get("source") == "server":
record_dict["code"] = False
return record_dict

View File

@@ -1,25 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerFileTemplate(models.Model):
_name = "cx.tower.file.template"
_inherit = ["cx.tower.file.template", "cx.tower.yaml.mixin"]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"source",
"file_type",
"server_dir",
"file_name",
"keep_when_deleted",
"tag_ids",
"note",
"code",
"variable_ids",
"secret_ids",
]
return res

View File

@@ -1,26 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerJetAction(models.Model):
_name = "cx.tower.jet.action"
_inherit = [
"cx.tower.jet.action",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"note",
"priority",
"access_level",
"state_from_id",
"state_transit_id",
"state_to_id",
"state_error_id",
"plan_id",
]
return res

View File

@@ -1,22 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerJetState(models.Model):
_name = "cx.tower.jet.state"
_inherit = [
"cx.tower.jet.state",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"sequence",
"access_level",
"color",
"note",
]
return res

View File

@@ -1,42 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerJetTemplate(models.Model):
_name = "cx.tower.jet.template"
_inherit = [
"cx.tower.jet.template",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"note",
"tag_ids",
"limit_per_server",
"show_in_create_wizard",
"plan_install_id",
"plan_uninstall_id",
"plan_clone_same_server_id",
"plan_clone_different_server_id",
"variable_value_ids",
"action_ids",
"template_requires_ids",
"waypoint_template_ids",
"server_log_ids",
"scheduled_task_ids",
]
return res
def _get_deferred_x2m_import_fields(self):
"""Return x2m child records resolved after the main import pass."""
return {
"template_requires_ids": {
"child_model": "cx.tower.jet.template.dependency",
"deferred_field": "template_required_id",
"target_model": "cx.tower.jet.template",
}
}

View File

@@ -1,19 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerJetTemplateDependency(models.Model):
_name = "cx.tower.jet.template.dependency"
_inherit = [
"cx.tower.jet.template.dependency",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"template_required_id",
"state_required_id",
]
return res

View File

@@ -1,32 +0,0 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerJetWaypointTemplate(models.Model):
_name = "cx.tower.jet.waypoint.template"
_inherit = [
"cx.tower.jet.waypoint.template",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"sequence",
"access_level",
"jet_template_id",
"plan_create_id",
"plan_arrive_id",
"plan_leave_id",
"plan_delete_id",
"note",
]
return res
def _get_deferred_m2o_import_fields(self):
"""Return m2o waypoint-template fields resolved after import."""
return {
"jet_template_id": "cx.tower.jet.template",
}

View File

@@ -1,20 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerKey(models.Model):
_name = "cx.tower.key"
_inherit = [
"cx.tower.key",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"key_type",
"note",
]
return res

View File

@@ -1,18 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerKeyValue(models.Model):
_name = "cx.tower.key.value"
_inherit = [
"cx.tower.key.value",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"key_id",
]
return res

View File

@@ -1,20 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerOs(models.Model):
_name = "cx.tower.os"
_inherit = [
"cx.tower.os",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"color",
"parent_id",
]
return res

View File

@@ -1,23 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerPlan(models.Model):
_name = "cx.tower.plan"
_inherit = ["cx.tower.plan", "cx.tower.yaml.mixin"]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"access_level",
"allow_parallel_run",
"color",
"tag_ids",
"note",
"on_error_action",
"custom_exit_code",
"line_ids",
]
return res

View File

@@ -1,21 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerPlanLine(models.Model):
_name = "cx.tower.plan.line"
_inherit = ["cx.tower.plan.line", "cx.tower.yaml.mixin"]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"sequence",
"condition",
"use_sudo",
"path",
"command_id",
"action_ids",
"variable_ids",
]
return res

View File

@@ -1,20 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerPlanLineAction(models.Model):
_name = "cx.tower.plan.line.action"
_inherit = ["cx.tower.plan.line.action", "cx.tower.yaml.mixin"]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"sequence",
"condition",
"value_char",
"action",
"custom_exit_code",
"variable_value_ids",
]
return res

View File

@@ -1,23 +0,0 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerScheduledTask(models.Model):
_name = "cx.tower.scheduled.task"
_inherit = ["cx.tower.scheduled.task", "cx.tower.yaml.mixin"]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"sequence",
"action",
"command_id",
"plan_id",
"interval_number",
"interval_type",
"next_call",
"last_call",
]
return res

View File

@@ -1,36 +0,0 @@
# Copyright (C) 2025 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerScheduledTaskCv(models.Model):
_name = "cx.tower.scheduled.task.cv"
_inherit = [
"cx.tower.scheduled.task.cv",
"cx.tower.yaml.mixin",
"cx.tower.reference.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += ["variable_value_id"]
return res
def _post_process_yaml_dict_values(self, values):
"""Populate required child fields from the linked variable value."""
res = super()._post_process_yaml_dict_values(values)
variable_value_id = res.get("variable_value_id")
if variable_value_id:
variable_value = self.env["cx.tower.variable.value"].browse(
variable_value_id
)
if variable_value.exists():
res.update(
{
"name": variable_value.name,
"variable_id": variable_value.variable_id.id,
"option_id": variable_value.option_id.id or False,
"value_char": variable_value.value_char,
}
)
return res

View File

@@ -1,52 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class CxTowerServer(models.Model):
_name = "cx.tower.server"
_inherit = [
"cx.tower.server",
"cx.tower.yaml.mixin",
]
def _get_fields_for_yaml(self):
res = super()._get_fields_for_yaml()
res += [
"name",
"ip_v4_address",
"ip_v6_address",
"skip_host_key",
"color",
"os_id",
"tag_ids",
"url",
"note",
"ssh_port",
"ssh_username",
"ssh_key_id",
"ssh_auth_mode",
"use_sudo",
"variable_value_ids",
"secret_ids",
"server_log_ids",
"shortcut_ids",
"scheduled_task_ids",
"plan_delete_id",
"file_ids",
"command_ids",
"plan_ids",
]
return res
def _get_force_x2m_resolve_models(self):
res = super()._get_force_x2m_resolve_models()
# This is useful to avoid duplicating existing plans
res += [
"cx.tower.shortcut",
"cx.tower.scheduled.task",
"cx.tower.command",
"cx.tower.plan",
]
return res

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
<?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>

View File

@@ -1,39 +0,0 @@
<?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>

View File

@@ -1,9 +0,0 @@
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yaml_export_wizard Export YAML model_cx_tower_yaml_export_wiz group_export 1 1 1 1
3 access_yaml_export_wizard_download Export YAML File model_cx_tower_yaml_export_wiz_download group_export 1 1 1 1
4 access_yaml_import_wizard_upload Import YAML model_cx_tower_yaml_import_wiz_upload group_import 1 1 1 1
5 access_yaml_import_wizard Import YAML model_cx_tower_yaml_import_wiz group_import 1 1 1 1
6 access_manifest_tmpl_read_export Manifest tmpl read (export) model_cx_tower_yaml_manifest_tmpl cetmix_tower_yaml.group_export 1 0 0 0
7 access_manifest_tmpl_admin Manifest tmpl admin model_cx_tower_yaml_manifest_tmpl cetmix_tower_server.group_root 1 1 1 1
8 access_manifest_author_read_export Manifest author read (export) model_cx_tower_yaml_manifest_author cetmix_tower_yaml.group_export 1 0 0 0
9 access_manifest_author_admin Manifest author admin model_cx_tower_yaml_manifest_author cetmix_tower_server.group_root 1 1 1 1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,534 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Cetmix Tower 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_command_view_form" model="ir.ui.view">
<field name="name">cx.tower.command.yaml.view.form</field>
<field name="model">cx.tower.command</field>
<field name="inherit_id" ref="cetmix_tower_server.cx_tower_command_view_form" />
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<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>
</xpath>
</field>
</record>
<record id="action_cx_tower_command_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_command" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_file_template_view_form" model="ir.ui.view">
<field name="name">cx.tower.file.template.yaml.view.form</field>
<field name="model">cx.tower.file.template</field>
<field
name="inherit_id"
ref="cetmix_tower_server.cx_tower_file_template_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<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>
</xpath>
</field>
</record>
<record
id="action_cx_tower_file_template_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_file_template" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="action_cx_tower_key_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_key" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="action_cx_tower_os_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_os" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_plan_view_form" model="ir.ui.view">
<field name="name">cx.tower.plan.view.form</field>
<field name="model">cx.tower.plan</field>
<field name="inherit_id" ref="cetmix_tower_server.cx_tower_plan_view_form" />
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<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>
</xpath>
</field>
</record>
<record id="action_cx_tower_plan_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_plan" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_cx_tower_scheduled_task_view_form" model="ir.ui.view">
<field name="name">cx.tower.scheduled.task.view.form</field>
<field name="model">cx.tower.scheduled.task</field>
<field
name="inherit_id"
ref="cetmix_tower_server.view_cx_tower_scheduled_task_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<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>
</xpath>
</field>
</record>
<record
id="action_cx_tower_scheduled_task_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_scheduled_task" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_server_template_view_form" model="ir.ui.view">
<field name="name">cx.tower.server.template.yaml.view.form</field>
<field name="model">cx.tower.server.template</field>
<field
name="inherit_id"
ref="cetmix_tower_server.cx_tower_server_template_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<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>
</xpath>
</field>
</record>
<record
id="action_cx_tower_server_template_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_server_template" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_server_view_form" model="ir.ui.view">
<field name="name">cx.tower.server.yaml.view.form</field>
<field name="model">cx.tower.server</field>
<field name="inherit_id" ref="cetmix_tower_server.cx_tower_server_view_form" />
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<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>
</xpath>
</field>
</record>
<record id="action_cx_tower_server_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_server" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_shortcut_view_form" model="ir.ui.view">
<field name="name">cx.tower.shortcut.view.form</field>
<field name="model">cx.tower.shortcut</field>
<field
name="inherit_id"
ref="cetmix_tower_server.cx_tower_shortcut_view_form"
/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<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>
</xpath>
</field>
</record>
<record id="action_cx_tower_shortcut_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_shortcut" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="action_cx_tower_tag_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_tag" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record
id="action_cx_tower_variable_value_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_variable_value" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="action_cx_tower_variable_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_variable" />
<field name="binding_view_types">list</field>
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
</record>
</odoo>

View File

@@ -1,33 +0,0 @@
<odoo>
<record id="view_yaml_manifest_author_tree" model="ir.ui.view">
<field name="name">yaml.manifest.author.tree</field>
<field name="model">cx.tower.yaml.manifest.author</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
</tree>
</field>
</record>
<record id="view_yaml_manifest_author_form" model="ir.ui.view">
<field name="name">yaml.manifest.author.form</field>
<field name="model">cx.tower.yaml.manifest.author</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name" />
</group>
</sheet>
</form>
</field>
</record>
<record id="action_yaml_manifest_author" model="ir.actions.act_window">
<field name="name">YAML Manifest Authors</field>
<field name="res_model">cx.tower.yaml.manifest.author</field>
<field name="view_mode">tree,form</field>
<field name="target">current</field>
</record>
</odoo>

View File

@@ -1,51 +0,0 @@
<odoo>
<record id="view_yaml_manifest_template_tree" model="ir.ui.view">
<field name="name">cx.tower.yaml.manifest.tmpl.tree</field>
<field name="model">cx.tower.yaml.manifest.tmpl</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="file_prefix" />
<field name="author_ids" widget="many2many_tags" />
<field name="version" />
<field name="website" />
<field name="license" />
<field name="currency" />
</tree>
</field>
</record>
<record id="view_yaml_manifest_template_form" model="ir.ui.view">
<field name="name">cx.tower.yaml.manifest.tmpl.form</field>
<field name="model">cx.tower.yaml.manifest.tmpl</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name" />
<field name="file_prefix" />
<field name="author_ids" widget="many2many_tags" />
<field name="version" />
<field name="website" />
<field name="license" />
<field
name="license_text"
attrs="{'invisible': [('license', '!=', 'custom')]}"
/>
<field
name="currency"
attrs="{'invisible': [('license', '!=', 'custom')]}"
/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_yaml_manifest_template" model="ir.actions.act_window">
<field name="name">YAML Manifest Templates</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">cx.tower.yaml.manifest.tmpl</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_yaml_manifest_template_tree" />
<field name="target">current</field>
</record>
</odoo>

View File

@@ -1,33 +0,0 @@
<odoo>
<!-- Import YAML -> Tools -->
<menuitem
id="menu_cetmix_tower_yaml_import"
name="Import YAML"
parent="cetmix_tower_server.menu_tools"
sequence="10"
groups="group_import"
action="action_cx_tower_yaml_import_wiz_upload"
/>
<!-- YAML Manifest Settings -> Settings -->
<menuitem
id="menu_yaml_settings_root"
name="YAML Export/Import"
parent="cetmix_tower_server.menu_settings"
sequence="60"
/>
<menuitem
id="menu_yaml_manifest_author_action"
name="Manifest Authors"
parent="menu_yaml_settings_root"
action="action_yaml_manifest_author"
sequence="1"
/>
<menuitem
id="menu_yaml_manifest_template"
name="Manifest Templates"
parent="menu_yaml_settings_root"
action="action_yaml_manifest_template"
sequence="2"
/>
</odoo>

View File

@@ -1,4 +0,0 @@
from . import cx_tower_yaml_export_wiz
from . import cx_tower_yaml_export_wiz_download
from . import cx_tower_yaml_import_wiz
from . import cx_tower_yaml_import_wiz_upload

View File

@@ -1,367 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import base64
import re
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from ..models.cx_tower_yaml_mixin import YamlExportCollector
FILE_HEADER = """
# This file is generated with Cetmix Tower.
# Details and documentation: https://cetmix.com/tower
"""
CLEAN_STR = re.compile(r"[^a-z0-9_]")
class CxTowerYamlExportWiz(models.TransientModel):
"""Cetmix Tower YAML Export Wizard"""
_name = "cx.tower.yaml.export.wiz"
_description = "Cetmix Tower YAML Export Wizard"
yaml_code = fields.Text()
yaml_file_name = fields.Char(
string="YAML File Name",
size=255,
default=lambda self: self._default_yaml_file_name(),
help="Snippet file name without extension, eg 'my_snippet'",
)
explode_child_records = fields.Boolean(
default=True,
help="Add entire child record definitions to the exported YAML file. "
"Otherwise only references to child records will be added.",
)
remove_empty_values = fields.Boolean(
string="Remove Empty x2m Field Values",
default=True,
help="Remove empty Many2one, Many2many and One2many"
" field values from the exported YAML file.",
)
preview_code = fields.Boolean()
add_manifest = fields.Boolean()
MANIFEST_FIELDS = [
"manifest_template_id",
"manifest_name",
"manifest_author_ids",
"manifest_version",
"manifest_summary",
"manifest_description",
"manifest_website",
"manifest_license",
"manifest_license_text",
"manifest_currency",
"manifest_price",
]
@api.model
def _get_manifest_license_selection(self):
return self.env["cx.tower.yaml.manifest.tmpl"]._selection_license()
@api.model
def _get_manifest_currency_selection(self):
return self.env["cx.tower.yaml.manifest.tmpl"]._selection_currency()
manifest_template_id = fields.Many2one(
"cx.tower.yaml.manifest.tmpl",
)
manifest_name = fields.Char(
compute="_compute_manifest",
readonly=False,
store=True,
string="Snippet Name",
help="Leave this field blank if you don't want to create a manifest",
)
manifest_website = fields.Char(
compute="_compute_manifest",
readonly=False,
string="Website",
store=True,
)
manifest_license = fields.Selection(
selection="_get_manifest_license_selection",
compute="_compute_manifest",
readonly=False,
string="License",
store=True,
)
manifest_author_ids = fields.Many2many(
"cx.tower.yaml.manifest.author",
compute="_compute_manifest",
readonly=False,
string="Authors",
store=True,
)
manifest_license_text = fields.Text(
compute="_compute_manifest", readonly=False, string="License Text", store=True
)
manifest_currency = fields.Selection(
selection="_get_manifest_currency_selection",
compute="_compute_manifest",
string="Currency",
readonly=False,
store=True,
)
manifest_summary = fields.Char(
string="Summary",
size=160,
help="Short summary that includes core information. 160 symbols max",
)
manifest_description = fields.Text("Description")
manifest_price = fields.Float("Price")
manifest_version = fields.Char(
compute="_compute_manifest",
readonly=False,
store=True,
string="Version",
help="Use the Major.Minor.Patch format, e.g. 1.2.3",
)
def _clean_yaml_basename(self, name: str) -> str:
"""
Return *always-valid* basename (no extension) built from arbitrary *name*.
"""
raw = (name or "").strip().lower()
base = raw[:-5] if raw.endswith(".yaml") else raw
base = CLEAN_STR.sub("_", base)
base = re.sub(r"_+", "_", base).strip("_") or "snippet"
return base
def _default_yaml_file_name(self):
"""
Build the *initial* file name shown to the user.
Pattern: <model>_<reference>, without “.yaml” suffix.
"""
records = self._get_model_record()
prefix = records._name.replace("cx.tower.", "").replace(".", "_")
ref = records.reference if len(records) == 1 else "selected"
return f"{prefix}_{ref}"
@api.depends("manifest_template_id")
def _compute_manifest(self):
mapping = {
"manifest_author_ids": "author_ids",
"manifest_website": "website",
"manifest_license": "license",
"manifest_license_text": "license_text",
"manifest_currency": "currency",
"manifest_version": "version",
}
for rec in self:
tmpl = rec.manifest_template_id
if not tmpl:
continue
for wiz_field, tmpl_field in mapping.items():
if not rec[wiz_field]:
rec[wiz_field] = tmpl[tmpl_field]
# prepend template's file prefix to YAML file name
prefix = (tmpl.file_prefix or "").strip()
if prefix:
# sanitize prefix without defaulting to a placeholder like "snippet"
raw = prefix.lower()
sanitized_prefix = re.sub(r"_+", "_", CLEAN_STR.sub("_", raw)).strip(
"_"
)
if sanitized_prefix:
# use current or default base name, then clean it
current = rec.yaml_file_name or rec._default_yaml_file_name()
base = rec._clean_yaml_basename(current)
# avoid double-prefixing
if not base.startswith(f"{sanitized_prefix}_"):
rec.yaml_file_name = rec._clean_yaml_basename(
f"{sanitized_prefix}_{base}"
)
@api.onchange("manifest_license")
def _onchange_manifest_license(self):
"""Drop price and currency when user switches off the 'custom' license.
If manifest_license != 'custom', reset manifest_price to 0.0 and
manifest_currency to False so they wont appear in the generated YAML.
"""
for rec in self:
if rec.manifest_license != "custom":
rec.manifest_price = 0.0
rec.manifest_currency = False
@api.onchange("explode_child_records", "remove_empty_values", *MANIFEST_FIELDS)
def onchange_explode_child_records(self):
"""Compute YAML code and file content."""
self.ensure_one()
# Get model records
records = self._get_model_record()
if not records:
raise ValidationError(_("No valid records selected"))
explode_related_record = self.explode_child_records
remove_empty_values = self.remove_empty_values
# Prepare YAML header
yaml_header = FILE_HEADER.rstrip("\n")
# Use the YAML export collector for unique records
collector = YamlExportCollector()
record_list = []
for rec in records:
record_yaml_dict = rec.with_context(
explode_related_record=explode_related_record,
remove_empty_values=remove_empty_values,
yaml_collector=collector,
)._prepare_record_for_yaml()
if not record_yaml_dict:
continue
if isinstance(record_yaml_dict, dict) and list(record_yaml_dict) == [
"reference"
]:
continue
if "cetmix_tower_model" not in record_yaml_dict:
record_yaml_dict["cetmix_tower_model"] = rec._name.replace(
"cx.tower.", ""
).replace(".", "_")
record_list.append(record_yaml_dict)
if not record_list:
self.yaml_code = f"{yaml_header}\n"
return
if not self.manifest_name:
manifest = {}
else:
lic = (self.manifest_license or "").lower()
fields_order = [
("name", self.manifest_name),
("summary", self.manifest_summary),
("description", self.manifest_description),
("author", self.manifest_author_ids.mapped("name")),
("version", self.manifest_version),
("website", self.manifest_website),
("license", self.manifest_license),
(
"license_text",
(self.manifest_license_text or "").strip()
if lic == "custom"
else None,
),
("price", self.manifest_price),
(
"currency",
self.manifest_currency if lic == "custom" else None,
),
]
manifest = {k: v for k, v in fields_order if v not in (False, None, "", [])}
result_dict = {
"cetmix_tower_yaml_version": self.env[
"cx.tower.yaml.mixin"
].CETMIX_TOWER_YAML_VERSION,
}
if manifest:
result_dict["manifest"] = manifest
result_dict["records"] = record_list
self.yaml_code = f"{yaml_header}\n{records._convert_dict_to_yaml(result_dict)}"
@api.onchange("yaml_file_name")
def _onchange_yaml_file_name(self):
"""
Live-clean the YAML file name as the user types:
- lowercase, trim whitespace
- replace invalid characters with “_”
- collapse repeated underscores
- ensure a single “.yaml” suffix
"""
for rec in self:
rec.yaml_file_name = rec._clean_yaml_basename(rec.yaml_file_name)
@api.constrains("manifest_version")
def _check_manifest_version_format(self):
"""
Ensure the user types a semantic version (x.y.z) in the wizard itself.
"""
semver = re.compile(r"^\d+\.\d+\.\d+$")
for rec in self:
if rec.manifest_version and not semver.match(rec.manifest_version):
raise ValidationError(
_("Version must be in format Major.Minor.Patch, e.g. 1.2.3")
)
def _validate_manifest(self):
"""Logical cross-checks before saving YAML."""
if self.manifest_price and not self.manifest_currency:
raise ValidationError(_("Currency is required when price is specified"))
if (self.manifest_license or "").lower() == "custom" and not (
self.manifest_license_text or ""
).strip():
raise ValidationError(_("License text is required for a custom license"))
def write(self, vals):
"""
Override write to always sanitize `yaml_file_name`
before persisting, making programmatic assignments safe.
"""
if "yaml_file_name" in vals:
vals["yaml_file_name"] = self._clean_yaml_basename(vals["yaml_file_name"])
return super().write(vals)
def action_generate_yaml_file(self):
"""Save YAML file"""
self.ensure_one()
self._validate_manifest()
if not self.yaml_code:
raise ValidationError(_("No YAML code is present."))
# Generate YAML file
try:
yaml_file = base64.encodebytes(self.yaml_code.encode("utf-8"))
yaml_file_name = (
f"{self.yaml_file_name or self._default_yaml_file_name()}.yaml"
)
except Exception as exc:
raise ValidationError(
_(
"Failed to encode YAML content. Please ensure all characters are UTF-8 compatible." # noqa: E501
)
) from exc
download_wizard = self.env["cx.tower.yaml.export.wiz.download"].create(
{
"yaml_file": yaml_file,
"yaml_file_name": yaml_file_name,
}
)
return {
"type": "ir.actions.act_window",
"res_model": "cx.tower.yaml.export.wiz.download",
"res_id": download_wizard.id,
"target": "new",
"view_mode": "form",
}
def _get_model_record(self):
"""Get model records based on context values
Raises:
ValidationError: in case no model or records selected
Returns:
ModelRecords: a recordset of selected records
"""
model_name = self.env.context.get("active_model")
record_ids = self.env.context.get("active_ids")
if not model_name or not record_ids:
raise ValidationError(_("No model or records selected"))
return self.env[model_name].browse(record_ids)

View File

@@ -1,130 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_yaml_export_wiz_view_form" model="ir.ui.view">
<field name="name">cx.tower.yaml.export.wiz.view.form</field>
<field name="model">cx.tower.yaml.export.wiz</field>
<field name="arch" type="xml">
<form>
<group>
<field name="yaml_file_name" placeholder="my_snippet.yaml" />
</group>
<group>
<group>
<field name="explode_child_records" />
<field name="remove_empty_values" />
</group>
<group>
<field name="add_manifest" />
<field name="preview_code" />
</group>
</group>
<group
string="Manifest"
attrs="{'invisible': [('add_manifest','=',False)]}"
>
<field
name="manifest_template_id"
placeholder="Select a pre-defined template"
help="Select a template to auto-populate manifest fields"
/>
<group string="Information">
<field
name="manifest_name"
attrs="{'required': [('add_manifest','!=',False)]}"
/>
<field
name="manifest_summary"
attrs="{'required': [('manifest_name','!=',False)]}"
placeholder="Short summary, 160 symbols max"
/>
<field
name="manifest_author_ids"
widget="many2many_tags"
attrs="{'required': [('manifest_name','!=',False)]}"
/>
<field
name="manifest_version"
placeholder="Use the Major.Minor.Patch format, e.g. 1.2.3"
/>
<field name="manifest_website" />
</group>
<group string="License and pricing">
<field
name="manifest_license"
attrs="{'required': [('manifest_name','!=',False)]}"
/>
<field
name="manifest_price"
attrs="{'invisible': [('manifest_license', '!=', 'custom')]}"
/>
<field
name="manifest_currency"
attrs="{'invisible': [('manifest_price', '=', 0)]}"
/>
</group>
</group>
<notebook>
<page
string="Description"
attrs="{'invisible': [('add_manifest','=',False)]}"
>
<field
name="manifest_description"
widget="text"
nolabel="1"
colspan="4"
placeholder="Detailed description (optional)"
/>
</page>
<page
string="License text"
attrs="{'invisible': [('manifest_license', '!=', 'custom')]}"
>
<field
name="manifest_license_text"
widget="text"
nolabel="1"
colspan="4"
placeholder="License text"
attrs="{'required': [('manifest_license', '=', 'custom')]}"
/>
</page>
<page
string="Preview code"
attrs="{'invisible': [('preview_code','=',False)]}"
>
<field
name="yaml_code"
widget="ace"
options="{'mode': 'yaml'}"
force_save="1"
nolabel="1"
colspan="4"
readonly="1"
/>
</page>
</notebook>
<footer>
<button
string="Generate YAML file"
type="object"
name="action_generate_yaml_file"
class="oe_highlight"
/>
<button string="Close" special="cancel" />
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,11 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class CxTowerYamlExportWizDownload(models.TransientModel):
_name = "cx.tower.yaml.export.wiz.download"
_description = "Cetmix Tower YAML Export File Download"
yaml_file = fields.Binary(readonly=True, attachment=False)
yaml_file_name = fields.Char(readonly=True)

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_yaml_export_wiz_download_view_form" model="ir.ui.view">
<field name="name">cx.tower.yaml.export.wiz.download.view.form</field>
<field name="model">cx.tower.yaml.export.wiz.download</field>
<field name="arch" type="xml">
<form>
<group>
<field name="yaml_file" filename="yaml_file_name" />
<field name="yaml_file_name" invisible="1" />
</group>
<footer>
<button string="Close" special="cancel" class="oe_highlight" />
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,314 +0,0 @@
# Copyright (C) 2024 Cetmix OÜ
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
import yaml
from markupsafe import escape
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class CxTowerYamlImportWiz(models.TransientModel):
"""
Process YAML data and create records in Odoo.
"""
_name = "cx.tower.yaml.import.wiz"
_description = "Cetmix Tower YAML Import Wizard"
yaml_code = fields.Text(readonly=True)
model_names = fields.Char(readonly=True, help="Models to create records in")
if_record_exists = fields.Selection(
selection=[
("skip", "Skip record"),
("update", "Update existing record"),
("create", "Create a new record"),
],
default="skip",
required=True,
help="What to do if record with the same reference already exists",
)
secret_list = fields.Html(
help="List of secrets present in the YAML file (formatted as HTML list)",
compute="_compute_secret_list",
)
preview_code = fields.Boolean(
help="Toggle to show or hide YAML code preview",
)
manifest_name = fields.Char(
readonly=True, compute="_compute_yaml_data", string="Snippet Name"
)
manifest_summary = fields.Char(
readonly=True, compute="_compute_yaml_data", string="Summary"
)
manifest_description = fields.Text(
readonly=True, compute="_compute_yaml_data", string="Description"
)
manifest_author_string = fields.Char(
readonly=True,
compute="_compute_yaml_data",
help="Comma-separated list",
string="Author",
)
manifest_version = fields.Char(
readonly=True, compute="_compute_yaml_data", string="Version"
)
manifest_website = fields.Char(
readonly=True, compute="_compute_yaml_data", string="Website"
)
manifest_license = fields.Char(
readonly=True, compute="_compute_yaml_data", string="License"
)
manifest_license_text = fields.Text(
readonly=True, compute="_compute_yaml_data", string="License text"
)
manifest_price = fields.Float(
readonly=True, compute="_compute_yaml_data", string="Price"
)
manifest_currency = fields.Char(
readonly=True, compute="_compute_yaml_data", string="Currency"
)
@api.depends("yaml_code")
def _compute_secret_list(self):
"""Compute list of secrets present in the YAML file"""
for record in self:
yaml_data = yaml.safe_load(record.yaml_code or "{}")
secret_list = self._extract_secret_names(yaml_data)
if not secret_list:
record.secret_list = False
continue
# Build deterministic HTML list of secrets
items = "".join(f"<li>{escape(name)}</li>" for name in sorted(secret_list))
secrets_html = f"<ul>{items}</ul>"
record.secret_list = _(
"Following secrets are used in the code:<br/>%(secrets)s",
secrets=secrets_html,
)
@api.depends("yaml_code")
def _compute_yaml_data(self):
for record in self:
data = yaml.safe_load(record.yaml_code or "{}")
manifest = data.get("manifest", {}) if isinstance(data, dict) else {}
authors = manifest.get("author")
if isinstance(authors, list | tuple):
manifest_author_string = ", ".join(authors)
elif isinstance(authors, str):
manifest_author_string = authors
else:
manifest_author_string = False
record.update(
{
"manifest_name": manifest.get("name"),
"manifest_summary": manifest.get("summary"),
"manifest_description": manifest.get("description"),
"manifest_author_string": manifest_author_string,
"manifest_version": manifest.get("version"),
"manifest_website": manifest.get("website"),
"manifest_license": manifest.get("license"),
"manifest_license_text": manifest.get("license_text"),
"manifest_price": manifest.get("price"),
"manifest_currency": manifest.get("currency"),
}
)
def action_import_yaml(self):
"""Process YAML data and create records in Odoo"""
self.ensure_one()
# Parse YAML code
yaml_data = yaml.safe_load(self.yaml_code)
records = yaml_data.get("records")
if not records:
raise ValidationError(_("YAML file doesn't contain any records"))
# Cache models
model_cache = {}
odoo_record_ids = []
# Process each record
for record in records:
record_reference = record.get("reference")
if not record_reference:
raise ValidationError(_("Record reference is missing"))
model_name = record.get("cetmix_tower_model")
if not model_name:
raise ValidationError(
_("Record model is missing for record %s", record_reference)
)
# Get model from cache or create new one
model = model_cache.get(model_name)
if not model:
model = self.env[
f"cx.tower.{model_name.replace('_', '.')}"
].with_context(skip_ssh_settings_check=(model_name == "server"))
model_cache[model_name] = model
# Get existing record by reference
# NOTE: we don't validate models here because they are
# already validated in the file upload wizard.
odoo_record = model.get_by_reference(record_reference)
# Skip
if self.if_record_exists == "skip" and odoo_record:
_logger.info(
"Skipping record '%s' in model '%s'" " because it already exists",
record_reference,
model_name,
)
continue
# Update existing record
elif self.if_record_exists == "update" and odoo_record:
try:
record_values = model.with_context(
force_create_related_record=False,
)._post_process_yaml_dict_values(record)
odoo_record.with_context(
from_yaml=True,
).write(record_values)
odoo_record_ids.append(odoo_record.id)
except Exception as e:
raise ValidationError(
_(
"Error updating record %(reference)s: %(error)s",
reference=record_reference,
error=e,
)
) from e
_logger.info(
f"Updated record '{record_reference}' in model '{model_name}'"
)
continue
# Or create a new record
record_values = model.with_context(
force_create_related_record=self.if_record_exists == "create",
)._post_process_yaml_dict_values(record)
try:
odoo_record = model.with_context(
from_yaml=True,
).create(record_values)
odoo_record_ids.append(odoo_record.id)
except Exception as e:
raise ValidationError(
_(
"Error creating record '%(reference)s' in model"
" '%(model)s': %(error)s",
reference=record_reference,
model=model_name,
error=e,
)
) from e
_logger.info(f"Created record '{record_reference}' in model '{model_name}'")
# No records were created or updated
if not odoo_record_ids:
action = {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Record Import"),
"message": _("No records were created or updated"),
"sticky": True,
"type": "warning",
"next": {"type": "ir.actions.act_window_close"},
},
}
# All records from the same model
elif len(model_cache) == 1:
model = list(model_cache.values())[0]
action = {
"name": _("Import result: %(model)s", model=model._description),
"type": "ir.actions.act_window",
"res_model": model._name,
"target": "current",
"domain": [("id", "in", odoo_record_ids)],
}
if len(odoo_record_ids) == 1:
# Open single record in form view
action["res_id"] = odoo_record_ids[0]
action["view_mode"] = "form"
else:
# Open list view of all records
action["view_mode"] = "list,form"
# Records from different models
else:
model_names = ", ".join(
f"'{model._description}'" for model in model_cache.values()
)
action = {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Record Import"),
"message": _(
"Records of the following models were created "
"or updated: %(models)s",
models=model_names,
),
"sticky": True,
"type": "success",
"next": {"type": "ir.actions.act_window_close"},
},
}
return action
def _extract_secret_names(self, data: dict) -> list:
"""Extract names of secrets from YAML data.
Supports both formats:
- secret_ids -> [{name: ...}]
- secret_ids -> [{key_id: {name: ...}}]
"""
secret_names = set()
def _recursive_extract(node):
"""Recursively extract secret names from nested structures."""
if isinstance(node, dict):
if "secret_ids" in node and isinstance(node["secret_ids"], list):
for item in node["secret_ids"]:
if not isinstance(item, dict):
continue
# Format 1: direct name
if "name" in item:
secret_names.add(item["name"])
# Format 2: nested key_id -> name
elif (
"key_id" in item
and isinstance(item["key_id"], dict)
and "name" in item["key_id"]
):
secret_names.add(item["key_id"]["name"])
# Handle single ssh_key_id
if "ssh_key_id" in node and isinstance(node["ssh_key_id"], dict):
if "name" in node["ssh_key_id"]:
secret_names.add(node["ssh_key_id"]["name"])
# Recursively process the rest of the dictionary
for value in node.values():
_recursive_extract(value)
elif isinstance(node, list):
for item in node:
_recursive_extract(item)
_recursive_extract(data)
return list(secret_names)

View File

@@ -1,153 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_yaml_import_wiz_view_form" model="ir.ui.view">
<field name="name">cx.tower.yaml.import.wiz.view.form</field>
<field name="model">cx.tower.yaml.import.wiz</field>
<field name="arch" type="xml">
<form>
<group>
<field name="if_record_exists" />
</group>
<div
class="alert alert-info"
role="alert"
attrs="{'invisible': [('secret_list', '=', False)]}"
>
<field name="secret_list" nolabel="1" />
</div>
<group>
<field name="preview_code" widget="boolean_toggle" />
</group>
<group
attrs="{'invisible': [
('manifest_name', '=', False),
]}"
>
<group string="Information">
<field name="manifest_name" string="Name" />
<field name="manifest_summary" string="Summary" />
<field name="manifest_author_string" string="Author" />
<field name="manifest_version" string="Version" />
<field
name="manifest_website"
string="Website"
attrs="{'invisible': [('manifest_website', '=', False)]}"
/>
</group>
<group string="License and pricing">
<field name="manifest_license" string="License" />
<field
name="manifest_price"
string="Price"
attrs="{'invisible': [('manifest_price', '=', False)]}"
/>
<field
name="manifest_currency"
string="Currency"
attrs="{'invisible': [('manifest_currency', '=', False)]}"
/>
</group>
</group>
<notebook>
<page
string="Description"
attrs="{'invisible':[('manifest_description','=',False)]}"
>
<field
name="manifest_description"
widget="text"
nolabel="1"
colspan="4"
/>
</page>
<page
string="License text"
attrs="{'invisible':[('manifest_license_text','=',False)]}"
>
<field
name="manifest_license_text"
widget="text"
nolabel="1"
colspan="4"
/>
</page>
<page
string="Code preview"
attrs="{'invisible': [('preview_code', '=', False)]}"
>
<group>
<field
name="yaml_code"
widget="ace"
options="{'mode': 'yaml'}"
force_save="1"
nolabel="1"
colspan="4"
/>
</group>
</page>
</notebook>
<div
class="alert alert-warning"
role="alert"
attrs="{'invisible': [('if_record_exists', '!=', 'create')]}"
style="margin-bottom:0px;"
>
<p>
<strong
>Important:</strong> To maintain data consistency, the following
model records will always be updated if they exist in Odoo:
</p>
<ul>
<li>Variables</li>
<li>Variable Options</li>
<li>Key/Secrets</li>
<li>Tags</li>
<li>OSs</li>
</ul>
<p>
To create new entities instead of updating existing ones, remove or modify
the <code
>reference</code> field in the YAML code for those entities.
</p>
</div>
<div
class="alert alert-warning"
role="alert"
attrs="{'invisible': [('if_record_exists', '!=', 'update')]}"
style="margin-bottom:0px;"
>
<p>
Existing record will be updated with the new data. Related records, present in the YAML code, will be updated too.
If any of those related records doesn't exist, it will be created automatically.
</p>
</div>
<footer>
<button
string="Import"
type="object"
name="action_import_yaml"
class="oe_highlight"
attrs="{'invisible': [('if_record_exists', '!=', 'update')]}"
confirm="This may overwrite existing records. Proceed?"
/>
<button
string="Import"
type="object"
name="action_import_yaml"
class="oe_highlight"
attrs="{'invisible': [('if_record_exists', '=', 'update')]}"
/>
<button string="Close" special="cancel" />
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1,137 +0,0 @@
import binascii
from base64 import b64decode
import yaml
from odoo import _, fields, models
from odoo.exceptions import ValidationError
class CxTowerYamlImportWizUpload(models.TransientModel):
"""
Upload YAML file and perform initial validation.
Submit YAML data to import wizard for further processing.
"""
_name = "cx.tower.yaml.import.wiz.upload"
_description = "Cetmix Tower YAML Import Wizard Upload"
file_name = fields.Char()
yaml_file = fields.Binary(required=True)
def action_import_yaml(self):
"""Parse YAML data to the import wizard
Returns:
Action Window: Action to open the import wizard
"""
decoded_file = self._extract_yaml_data()
import_wizard = self.env["cx.tower.yaml.import.wiz"].create(
{
"yaml_code": decoded_file,
}
)
return {
"type": "ir.actions.act_window",
"res_model": "cx.tower.yaml.import.wiz",
"res_id": import_wizard.id,
"view_mode": "form",
"target": "new",
}
def _extract_yaml_data(self):
"""Extract data from YAML file and validate them
Returns:
decoded_file (Text): YAML code
Raises:
ValidationError: If the YAML file is invalid
or contains unsupported data
"""
self.ensure_one()
# Decode base64 file
try:
raw_bytes = b64decode(self.yaml_file or b"")
except (TypeError, binascii.Error) as e:
# Not a valid base-64 payload
raise ValidationError(_("File is not a valid base64-encoded file")) from e
if not raw_bytes:
raise ValidationError(_("File is empty"))
try:
decoded_file = raw_bytes.decode("utf-8")
except UnicodeDecodeError as e:
raise ValidationError(_("YAML file cannot be decoded properly")) from e
# Parse YAML file
try:
yaml_data = yaml.safe_load(decoded_file)
except yaml.YAMLError as e:
raise ValidationError(_("Invalid YAML file")) from e
if not yaml_data or not isinstance(yaml_data, dict):
raise ValidationError(_("Yaml file doesn't contain valid data"))
# Check Cetmix Tower YAML version
yaml_version = yaml_data.pop("cetmix_tower_yaml_version", None)
supported_version = self.env["cx.tower.yaml.mixin"].CETMIX_TOWER_YAML_VERSION
if (
yaml_version
and isinstance(yaml_version, int)
and yaml_version > supported_version
):
raise ValidationError(
_(
"YAML version is higher than version"
" supported by your Cetmix Tower instance."
" %(code_version)s > %(tower_version)s",
code_version=yaml_version,
tower_version=supported_version,
)
)
# Get records from YAML
records = yaml_data.get("records")
if not records:
raise ValidationError(_("YAML file doesn't contain any records"))
# Collect and validate all record models
ir_model_obj = self.env["ir.model"]
unique_models = {}
# First pass: check all records have models and collect unique models
for record in records:
record_model = record.get("cetmix_tower_model")
if not record_model:
raise ValidationError(
_(
"Record model is missing for record %s",
record.get("reference", ""),
)
)
if record_model not in unique_models:
odoo_model = f"cx.tower.{record_model}".replace("_", ".")
unique_models[record_model] = odoo_model
# Second pass: validate all unique models in a single query
odoo_models = list(unique_models.values())
valid_models = {
model.model: model
for model in ir_model_obj.search([("model", "in", odoo_models)])
}
# Third pass: check models exist and support YAML import
for record_model, odoo_model in unique_models.items():
if odoo_model not in valid_models:
raise ValidationError(_("'%s' is not a valid model", record_model))
if not hasattr(self.env[odoo_model], "yaml_code"):
raise ValidationError(
_("Model '%s' does not support YAML import", record_model)
)
return decoded_file

View File

@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="cx_tower_yaml_import_wiz_upload_view_form" model="ir.ui.view">
<field name="name">cx.tower.yaml.import.wiz.upload.view.form</field>
<field name="model">cx.tower.yaml.import.wiz.upload</field>
<field name="arch" type="xml">
<form>
<group>
<field name="file_name" invisible="1" />
<field
name="yaml_file"
filename="file_name"
options="{'accepted_file_extensions': '.yaml,.yml'}"
/>
</group>
<footer>
<button
string="Process"
type="object"
name="action_import_yaml"
class="oe_highlight"
attrs="{'invisible': [('yaml_file', '=', False)]}"
/>
<button string="Close" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="action_cx_tower_yaml_import_wiz_upload" model="ir.actions.act_window">
<field name="name">Import YAML</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">cx.tower.yaml.import.wiz.upload</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>