Compare commits
6 Commits
cetmix_tow
...
tk_constru
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd01d44be5 | ||
| 63c62699f5 | |||
| c412640ca2 | |||
| fd62a75b51 | |||
| e50acbac83 | |||
| ed5f0d6535 |
81
addons/cetmix_tower_yaml/README.rst
Normal file
81
addons/cetmix_tower_yaml/README.rst
Normal file
@@ -0,0 +1,81 @@
|
||||
=================
|
||||
Cetmix Tower YAML
|
||||
=================
|
||||
|
||||
..
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:67e4f5c3d94d23c826affb93f25cdacdccc0ef69084fe3d293e9e140270b8394
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
|
||||
:target: https://odoo-community.org/page/development-status
|
||||
:alt: Beta
|
||||
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
|
||||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||
:alt: License: AGPL-3
|
||||
.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github
|
||||
:target: https://github.com/cetmix/cetmix-tower/tree/18.0/cetmix_tower_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
|
||||
=========
|
||||
|
||||
18.0.2.0.0 (2026-04-07)
|
||||
-----------------------
|
||||
|
||||
- Features: Jets! (4700)
|
||||
|
||||
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:%2018.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/18.0/cetmix_tower_yaml>`_ project on GitHub.
|
||||
|
||||
You are welcome to contribute.
|
||||
2
addons/cetmix_tower_yaml/__init__.py
Normal file
2
addons/cetmix_tower_yaml/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizards
|
||||
43
addons/cetmix_tower_yaml/__manifest__.py
Normal file
43
addons/cetmix_tower_yaml/__manifest__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# 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": "18.0.2.0.0",
|
||||
"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_jet_template_view.xml",
|
||||
"views/cx_tower_yaml_manifest_template_views.xml",
|
||||
"views/cx_tower_yaml_manifest_author_views.xml",
|
||||
"wizards/cx_tower_yaml_export_wiz.xml",
|
||||
"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",
|
||||
],
|
||||
}
|
||||
13
addons/cetmix_tower_yaml/demo/demo_data.xml
Normal file
13
addons/cetmix_tower_yaml/demo/demo_data.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?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>
|
||||
1048
addons/cetmix_tower_yaml/i18n/cetmix_tower_yaml.pot
Normal file
1048
addons/cetmix_tower_yaml/i18n/cetmix_tower_yaml.pot
Normal file
File diff suppressed because it is too large
Load Diff
587
addons/cetmix_tower_yaml/i18n/hr.po
Normal file
587
addons/cetmix_tower_yaml/i18n/hr.po
Normal file
@@ -0,0 +1,587 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * cetmix_tower_yaml
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 18.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."
|
||||
1042
addons/cetmix_tower_yaml/i18n/it.po
Normal file
1042
addons/cetmix_tower_yaml/i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
27
addons/cetmix_tower_yaml/models/__init__.py
Normal file
27
addons/cetmix_tower_yaml/models/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
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_scheduled_task_cv
|
||||
from . import cx_tower_file
|
||||
from . import cx_tower_server
|
||||
from . import cx_tower_yaml_manifest_template
|
||||
from . import cx_tower_yaml_manifest_author
|
||||
from . import cx_tower_jet_template
|
||||
from . import cx_tower_jet_template_dependency
|
||||
from . import cx_tower_jet_state
|
||||
from . import cx_tower_jet_action
|
||||
from . import cx_tower_jet_waypoint_template
|
||||
43
addons/cetmix_tower_yaml/models/cx_tower_command.py
Normal file
43
addons/cetmix_tower_yaml/models/cx_tower_command.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# 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",
|
||||
"if_file_exists",
|
||||
"disconnect_file",
|
||||
"flight_plan_id",
|
||||
"jet_template_id",
|
||||
"jet_action_id",
|
||||
"waypoint_template_id",
|
||||
"fly_here",
|
||||
"code",
|
||||
"no_split_for_sudo",
|
||||
"server_status",
|
||||
"variable_ids",
|
||||
"secret_ids",
|
||||
]
|
||||
return res
|
||||
|
||||
def _get_deferred_m2o_import_fields(self):
|
||||
"""Return m2o command fields resolved after the main import pass."""
|
||||
return {
|
||||
"jet_template_id": "cx.tower.jet.template",
|
||||
"jet_action_id": "cx.tower.jet.action",
|
||||
"waypoint_template_id": "cx.tower.jet.waypoint.template",
|
||||
}
|
||||
46
addons/cetmix_tower_yaml/models/cx_tower_file.py
Normal file
46
addons/cetmix_tower_yaml/models/cx_tower_file.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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
|
||||
25
addons/cetmix_tower_yaml/models/cx_tower_file_template.py
Normal file
25
addons/cetmix_tower_yaml/models/cx_tower_file_template.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# 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
|
||||
26
addons/cetmix_tower_yaml/models/cx_tower_jet_action.py
Normal file
26
addons/cetmix_tower_yaml/models/cx_tower_jet_action.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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
|
||||
22
addons/cetmix_tower_yaml/models/cx_tower_jet_state.py
Normal file
22
addons/cetmix_tower_yaml/models/cx_tower_jet_state.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class 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
|
||||
42
addons/cetmix_tower_yaml/models/cx_tower_jet_template.py
Normal file
42
addons/cetmix_tower_yaml/models/cx_tower_jet_template.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# 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",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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",
|
||||
}
|
||||
20
addons/cetmix_tower_yaml/models/cx_tower_key.py
Normal file
20
addons/cetmix_tower_yaml/models/cx_tower_key.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class 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
|
||||
18
addons/cetmix_tower_yaml/models/cx_tower_key_value.py
Normal file
18
addons/cetmix_tower_yaml/models/cx_tower_key_value.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class 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
|
||||
20
addons/cetmix_tower_yaml/models/cx_tower_os.py
Normal file
20
addons/cetmix_tower_yaml/models/cx_tower_os.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class 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
|
||||
40
addons/cetmix_tower_yaml/models/cx_tower_plan.py
Normal file
40
addons/cetmix_tower_yaml/models/cx_tower_plan.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# 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
|
||||
|
||||
def _get_deferred_x2m_import_fields(self):
|
||||
"""Defer plan lines whose command is not resolvable during nested import.
|
||||
|
||||
Deep YAML (e.g. a command's waypoint inlines a jet template whose plans
|
||||
reference that same command) creates a forward reference: plan lines are
|
||||
prepared before the command exists in the database. Queue those lines
|
||||
and create them after the main import pass when ``command_id`` can be
|
||||
resolved.
|
||||
"""
|
||||
return {
|
||||
"line_ids": {
|
||||
"child_model": "cx.tower.plan.line",
|
||||
"deferred_field": "command_id",
|
||||
"target_model": "cx.tower.command",
|
||||
}
|
||||
}
|
||||
21
addons/cetmix_tower_yaml/models/cx_tower_plan_line.py
Normal file
21
addons/cetmix_tower_yaml/models/cx_tower_plan_line.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# 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
|
||||
20
addons/cetmix_tower_yaml/models/cx_tower_plan_line_action.py
Normal file
20
addons/cetmix_tower_yaml/models/cx_tower_plan_line_action.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class 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
|
||||
42
addons/cetmix_tower_yaml/models/cx_tower_scheduled_task.py
Normal file
42
addons/cetmix_tower_yaml/models/cx_tower_scheduled_task.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# 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",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
"custom_variable_value_ids",
|
||||
]
|
||||
return res
|
||||
|
||||
def _get_deferred_x2m_import_fields(self):
|
||||
"""Return scheduled-task child records resolved after import."""
|
||||
return {
|
||||
"custom_variable_value_ids": {
|
||||
"child_model": "cx.tower.scheduled.task.cv",
|
||||
"deferred_field": "variable_value_id",
|
||||
"target_model": "cx.tower.variable.value",
|
||||
"skip_empty": True,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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
|
||||
52
addons/cetmix_tower_yaml/models/cx_tower_server.py
Normal file
52
addons/cetmix_tower_yaml/models/cx_tower_server.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# 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
|
||||
23
addons/cetmix_tower_yaml/models/cx_tower_server_log.py
Normal file
23
addons/cetmix_tower_yaml/models/cx_tower_server_log.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerServerLog(models.Model):
|
||||
_name = "cx.tower.server.log"
|
||||
_inherit = [
|
||||
"cx.tower.server.log",
|
||||
"cx.tower.yaml.mixin",
|
||||
]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"log_type",
|
||||
"command_id",
|
||||
"use_sudo",
|
||||
"file_template_id",
|
||||
"file_id",
|
||||
]
|
||||
return res
|
||||
41
addons/cetmix_tower_yaml/models/cx_tower_server_template.py
Normal file
41
addons/cetmix_tower_yaml/models/cx_tower_server_template.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerServerTemplate(models.Model):
|
||||
_name = "cx.tower.server.template"
|
||||
_inherit = [
|
||||
"cx.tower.server.template",
|
||||
"cx.tower.yaml.mixin",
|
||||
]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"color",
|
||||
"os_id",
|
||||
"tag_ids",
|
||||
"note",
|
||||
"ssh_port",
|
||||
"ssh_username",
|
||||
"ssh_key_id",
|
||||
"ssh_auth_mode",
|
||||
"use_sudo",
|
||||
"variable_value_ids",
|
||||
"server_log_ids",
|
||||
"shortcut_ids",
|
||||
"scheduled_task_ids",
|
||||
"flight_plan_id",
|
||||
"plan_delete_id",
|
||||
]
|
||||
return res
|
||||
|
||||
def _get_force_x2m_resolve_models(self):
|
||||
res = super()._get_force_x2m_resolve_models()
|
||||
|
||||
# Add Flight Plan in order to always try to use existing one
|
||||
# This is useful to avoid duplicating existing plans
|
||||
res += ["cx.tower.plan", "cx.tower.shortcut", "cx.tower.scheduled.task"]
|
||||
return res
|
||||
22
addons/cetmix_tower_yaml/models/cx_tower_shortcut.py
Normal file
22
addons/cetmix_tower_yaml/models/cx_tower_shortcut.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerShortcut(models.Model):
|
||||
_name = "cx.tower.shortcut"
|
||||
_inherit = ["cx.tower.shortcut", "cx.tower.yaml.mixin"]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"sequence",
|
||||
"access_level",
|
||||
"action",
|
||||
"command_id",
|
||||
"use_sudo",
|
||||
"plan_id",
|
||||
"note",
|
||||
]
|
||||
return res
|
||||
16
addons/cetmix_tower_yaml/models/cx_tower_tag.py
Normal file
16
addons/cetmix_tower_yaml/models/cx_tower_tag.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerTag(models.Model):
|
||||
_name = "cx.tower.tag"
|
||||
_inherit = ["cx.tower.tag", "cx.tower.yaml.mixin"]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"color",
|
||||
]
|
||||
return res
|
||||
23
addons/cetmix_tower_yaml/models/cx_tower_variable.py
Normal file
23
addons/cetmix_tower_yaml/models/cx_tower_variable.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerVariable(models.Model):
|
||||
_name = "cx.tower.variable"
|
||||
_inherit = ["cx.tower.variable", "cx.tower.yaml.mixin"]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"access_level",
|
||||
"variable_type",
|
||||
"option_ids",
|
||||
"applied_expression",
|
||||
"validation_pattern",
|
||||
"validation_message",
|
||||
"note",
|
||||
"tag_ids",
|
||||
]
|
||||
return res
|
||||
18
addons/cetmix_tower_yaml/models/cx_tower_variable_option.py
Normal file
18
addons/cetmix_tower_yaml/models/cx_tower_variable_option.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerVariableOption(models.Model):
|
||||
_name = "cx.tower.variable.option"
|
||||
_inherit = ["cx.tower.variable.option", "cx.tower.yaml.mixin"]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"sequence",
|
||||
"access_level",
|
||||
"name",
|
||||
"value_char",
|
||||
]
|
||||
return res
|
||||
20
addons/cetmix_tower_yaml/models/cx_tower_variable_value.py
Normal file
20
addons/cetmix_tower_yaml/models/cx_tower_variable_value.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import models
|
||||
|
||||
|
||||
class CxTowerVariableValue(models.Model):
|
||||
_name = "cx.tower.variable.value"
|
||||
_inherit = ["cx.tower.variable.value", "cx.tower.yaml.mixin"]
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"sequence",
|
||||
"access_level",
|
||||
"variable_id",
|
||||
"value_char",
|
||||
"variable_ids",
|
||||
"required",
|
||||
]
|
||||
return res
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class CxTowerYamlManifestAuthor(models.Model):
|
||||
"""Author of a YAML manifest (can be one or many)."""
|
||||
|
||||
_name = "cx.tower.yaml.manifest.author"
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"yaml_manifest_author_name_uniq",
|
||||
"unique(name)",
|
||||
"Author name must be unique.",
|
||||
)
|
||||
]
|
||||
_description = "YAML Manifest Author"
|
||||
_order = "name"
|
||||
|
||||
name = fields.Char(required=True, translate=False)
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
import re
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class CxTowerYamlManifestTemplate(models.Model):
|
||||
"""Pre-defined YAML manifest template storing common metadata
|
||||
such as authors, website, license, and currency for reuse
|
||||
during YAML exports."""
|
||||
|
||||
_name = "cx.tower.yaml.manifest.tmpl"
|
||||
_description = "YAML Manifest Template"
|
||||
_order = "name"
|
||||
|
||||
name = fields.Char(
|
||||
required=True,
|
||||
help="Name of the manifest template.",
|
||||
)
|
||||
website = fields.Char(help="Website URL for the manifest.")
|
||||
|
||||
author_ids = fields.Many2many(
|
||||
"cx.tower.yaml.manifest.author",
|
||||
string="Authors",
|
||||
help="List of author names to include in the YAML manifest.",
|
||||
)
|
||||
|
||||
license = fields.Selection(
|
||||
selection=lambda self: self._selection_license(),
|
||||
help="License used for the code snippet.",
|
||||
)
|
||||
license_text = fields.Text(
|
||||
help="Custom license text when license type is Custom.",
|
||||
)
|
||||
|
||||
currency = fields.Selection(
|
||||
selection=lambda self: self._selection_currency(),
|
||||
help="Currency for pricing information.",
|
||||
)
|
||||
|
||||
version = fields.Char(
|
||||
help="Version in Major.Minor.Patch format, e.g. 1.0.0",
|
||||
default="1.0.0",
|
||||
)
|
||||
|
||||
file_prefix = fields.Char(
|
||||
string="File prefix",
|
||||
help="Add prefix to the exported YAML file name when this template is selected",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _selection_license(self):
|
||||
"""Return available license options for manifest."""
|
||||
return [
|
||||
("agpl-3", "AGPL-3"),
|
||||
("lgpl-3", "LGPL-3"),
|
||||
("mit", "MIT"),
|
||||
("custom", _("Custom")),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _selection_currency(self):
|
||||
"""Return available currency options for manifest pricing."""
|
||||
return [
|
||||
("EUR", _("Euro")),
|
||||
("USD", _("US Dollar")),
|
||||
]
|
||||
|
||||
@api.constrains("license", "license_text")
|
||||
def _check_license_text_for_custom(self):
|
||||
"""Ensure that custom license text is provided when license is 'custom'."""
|
||||
for rec in self:
|
||||
if rec.license == "custom" and not (rec.license_text or "").strip():
|
||||
raise ValidationError(
|
||||
_("Provide Custom License Text when License is set to 'Custom'.")
|
||||
)
|
||||
|
||||
@api.constrains("version")
|
||||
def _check_version_format(self):
|
||||
"""Ensure the template version follows the x.y.z semantic format.
|
||||
|
||||
The version must consist of three non-negative integers (major, minor, patch)
|
||||
separated by dots—for example, “1.2.3”. Raises a ValidationError otherwise.
|
||||
"""
|
||||
semver = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
for rec in self:
|
||||
if rec.version and not semver.match(rec.version):
|
||||
raise ValidationError(
|
||||
_("Version must be in the Major.Minor.Patch format, e.g. 1.2.3")
|
||||
)
|
||||
776
addons/cetmix_tower_yaml/models/cx_tower_yaml_mixin.py
Normal file
776
addons/cetmix_tower_yaml/models/cx_tower_yaml_mixin.py
Normal file
@@ -0,0 +1,776 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import copy
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
DEFERRED_M2O_IMPORT = object()
|
||||
|
||||
|
||||
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:
|
||||
# Use a shared collector from context when one is provided (e.g. by
|
||||
# the export wizard for cross-record deduplication); otherwise use a
|
||||
# fresh per-record collector so that each record's yaml_code is
|
||||
# deterministic regardless of which sibling records are batched.
|
||||
collector = record._context.get("yaml_collector") or YamlExportCollector()
|
||||
# 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.with_context(yaml_collector=collector)._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"))
|
||||
|
||||
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 _get_deferred_m2o_import_fields(self):
|
||||
"""Map m2o fields that should be resolved after the main import pass.
|
||||
|
||||
Returns:
|
||||
dict: Field name to expected target model mapping.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def _get_deferred_x2m_import_fields(self):
|
||||
"""Map x2m child records that should be created after the main import pass.
|
||||
|
||||
Returns:
|
||||
dict: Parent field name to deferred child spec mapping.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def _has_meaningful_yaml_value(self, value):
|
||||
"""Return whether a YAML value contains meaningful payload."""
|
||||
if value is False or value is None or value == "":
|
||||
return False
|
||||
if isinstance(value, dict):
|
||||
if set(value.keys()) == {"reference"}:
|
||||
return bool(value.get("reference"))
|
||||
return any(
|
||||
self._has_meaningful_yaml_value(item)
|
||||
for key, item in value.items()
|
||||
if key != "reference"
|
||||
)
|
||||
if isinstance(value, list):
|
||||
return any(self._has_meaningful_yaml_value(item) for item in value)
|
||||
return True
|
||||
|
||||
def _get_reference_only_yaml_relation_reference(self, value):
|
||||
"""Return reference for reference-only YAML relation values.
|
||||
|
||||
Args:
|
||||
value (str | dict): YAML relation value.
|
||||
|
||||
Returns:
|
||||
str | bool: Reference if the value is reference-only, otherwise False.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict) and set(value.keys()) == {"reference"}:
|
||||
return value.get("reference") or False
|
||||
return False
|
||||
|
||||
def _queue_deferred_m2o_import(self, field, comodel, value):
|
||||
"""Queue unresolved m2o relation for the deferred import pass.
|
||||
|
||||
Args:
|
||||
field (str): Owner field name.
|
||||
comodel (BaseModel): Related model.
|
||||
value (str | dict): YAML relation value.
|
||||
|
||||
Returns:
|
||||
bool: True when the relation was queued for deferred resolution.
|
||||
"""
|
||||
queue = self._context.get("yaml_deferred_m2o_queue")
|
||||
if queue is None:
|
||||
return False
|
||||
|
||||
deferred_fields = self._get_deferred_m2o_import_fields()
|
||||
expected_model = deferred_fields.get(field)
|
||||
if not expected_model or expected_model != comodel._name:
|
||||
return False
|
||||
|
||||
target_reference = self._get_reference_only_yaml_relation_reference(value)
|
||||
if not target_reference or comodel.get_by_reference(target_reference):
|
||||
return False
|
||||
|
||||
record_reference = self._context.get("yaml_import_record_reference")
|
||||
if not record_reference:
|
||||
return False
|
||||
|
||||
queue.append(
|
||||
{
|
||||
"record_model": self._name,
|
||||
"record_reference": record_reference,
|
||||
"field_name": field,
|
||||
"target_model": comodel._name,
|
||||
"target_reference": target_reference,
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
def _queue_deferred_x2m_import(self, field, comodel, value):
|
||||
"""Queue unresolved x2m child record for the deferred import pass.
|
||||
|
||||
Args:
|
||||
field (str): Owner x2m field name.
|
||||
comodel (BaseModel): Related child model.
|
||||
value (dict): YAML child record value.
|
||||
|
||||
Returns:
|
||||
bool: True when the child was queued for deferred creation or
|
||||
should be skipped (e.g., empty value with skip_empty=True).
|
||||
"""
|
||||
queue = self._context.get("yaml_deferred_x2m_queue")
|
||||
if queue is None or not isinstance(value, dict):
|
||||
return False
|
||||
|
||||
deferred_fields = self._get_deferred_x2m_import_fields()
|
||||
spec = deferred_fields.get(field) or {}
|
||||
if spec.get("child_model") != comodel._name:
|
||||
return False
|
||||
|
||||
if spec.get("skip_empty") and not self._has_meaningful_yaml_value(value):
|
||||
return True
|
||||
|
||||
deferred_field = spec.get("deferred_field")
|
||||
if not deferred_field:
|
||||
return False
|
||||
|
||||
target_model = spec.get("target_model")
|
||||
target_value = value.get(deferred_field)
|
||||
target_reference = self._get_reference_only_yaml_relation_reference(
|
||||
target_value
|
||||
)
|
||||
if not target_model or not target_reference:
|
||||
return False
|
||||
|
||||
target_record = self.env[target_model].get_by_reference(target_reference)
|
||||
if target_record:
|
||||
return False
|
||||
|
||||
record_reference = self._context.get("yaml_import_record_reference")
|
||||
if not record_reference:
|
||||
return False
|
||||
|
||||
queue.append(
|
||||
{
|
||||
"record_model": self._name,
|
||||
"record_reference": record_reference,
|
||||
"field_name": field,
|
||||
"child_model": comodel._name,
|
||||
"deferred_field": deferred_field,
|
||||
"target_model": target_model,
|
||||
"target_reference": target_reference,
|
||||
"values": copy.deepcopy(value),
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
def _get_yaml_duplicate_reference_dict(self, ref, values):
|
||||
"""Return the stub emitted when a record has already been serialized.
|
||||
|
||||
The collector deduplicates by (model, reference); subsequent occurrences
|
||||
are collapsed to a reference-only dict. Import must never attempt to create
|
||||
from this stub — it must resolve the record by reference instead.
|
||||
|
||||
Args:
|
||||
ref (str): Record reference.
|
||||
values (dict): Raw values (unused; kept for signature compatibility
|
||||
in case subclasses need them).
|
||||
|
||||
Returns:
|
||||
dict: ``{"reference": ref}`` only.
|
||||
"""
|
||||
return {"reference": ref}
|
||||
|
||||
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 self._get_yaml_duplicate_reference_dict(ref, values)
|
||||
|
||||
if collector and collector_key:
|
||||
collector.add(collector_key)
|
||||
|
||||
if collector and collector_key:
|
||||
collector.add(collector_key)
|
||||
|
||||
# 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})
|
||||
|
||||
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 list(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,
|
||||
yaml_import_record_reference=filtered_values.get("reference"),
|
||||
)._process_relation_field_value(key, value, record_mode=False)
|
||||
if processed_value is DEFERRED_M2O_IMPORT:
|
||||
filtered_values.pop(key, None)
|
||||
else:
|
||||
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(
|
||||
field, comodel, value, explode_related_record, record_mode
|
||||
)
|
||||
if field_type in ["one2many", "many2many"]:
|
||||
return self._process_x2m_values(
|
||||
field, 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, field, comodel, value, explode_related_record, record_mode=False
|
||||
):
|
||||
"""Post process many2one value
|
||||
Args:
|
||||
field (Char): Field the value belongs to
|
||||
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 self._queue_deferred_m2o_import(field, comodel, value):
|
||||
return DEFERRED_M2O_IMPORT
|
||||
|
||||
# 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")
|
||||
if self._get_reference_only_yaml_relation_reference(value):
|
||||
record = False
|
||||
else:
|
||||
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,
|
||||
field,
|
||||
comodel,
|
||||
field_type,
|
||||
values,
|
||||
explode_related_record,
|
||||
record_mode=False,
|
||||
):
|
||||
"""Post process many2many value
|
||||
Args:
|
||||
field (Char): Field the value belongs to
|
||||
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):
|
||||
if self._queue_deferred_x2m_import(field, comodel, value):
|
||||
continue
|
||||
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 set(values.keys()) == {"reference"}:
|
||||
_logger.warning(
|
||||
"Attempted to import a record for model '%s' "
|
||||
"with reference "
|
||||
"'%s', but only the 'reference' field was provided. "
|
||||
"Creation will be skipped until the target record "
|
||||
"exists.",
|
||||
model._name,
|
||||
reference,
|
||||
)
|
||||
return False
|
||||
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:
|
||||
# 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
|
||||
|
||||
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))
|
||||
|
||||
# Return the record's ID if it exists, otherwise return False
|
||||
return record or False
|
||||
3
addons/cetmix_tower_yaml/pyproject.toml
Normal file
3
addons/cetmix_tower_yaml/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
||||
1
addons/cetmix_tower_yaml/readme/CONFIGURE.md
Normal file
1
addons/cetmix_tower_yaml/readme/CONFIGURE.md
Normal file
@@ -0,0 +1 @@
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed configuration instructions.
|
||||
3
addons/cetmix_tower_yaml/readme/DESCRIPTION.md
Normal file
3
addons/cetmix_tower_yaml/readme/DESCRIPTION.md
Normal file
@@ -0,0 +1,3 @@
|
||||
This module implements YAML format data import/export for [Cetmix Tower](https://cetmix.com/tower).
|
||||
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed information.
|
||||
3
addons/cetmix_tower_yaml/readme/HISTORY.md
Normal file
3
addons/cetmix_tower_yaml/readme/HISTORY.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## 18.0.2.0.0 (2026-04-07)
|
||||
|
||||
- Features: Jets! (4700)
|
||||
1
addons/cetmix_tower_yaml/readme/USAGE.md
Normal file
1
addons/cetmix_tower_yaml/readme/USAGE.md
Normal file
@@ -0,0 +1 @@
|
||||
Please refer to the [official documentation](https://cetmix.com/tower) for detailed usage instructions.
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="ir_module_category_tower_yaml_export" model="ir.module.category">
|
||||
<field name="parent_id" ref="cetmix_tower_server.ir_module_category_tower" />
|
||||
<field name="name">YAML Export</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_module_category_tower_yaml_import" model="ir.module.category">
|
||||
<field name="parent_id" ref="cetmix_tower_server.ir_module_category_tower" />
|
||||
<field name="name">YAML Import</field>
|
||||
</record>
|
||||
|
||||
<record id="group_export" model="res.groups">
|
||||
<field name="name">Allow</field>
|
||||
<field name="category_id" ref="ir_module_category_tower_yaml_export" />
|
||||
<field name="comment">
|
||||
Export data to YAML.
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="group_import" model="res.groups">
|
||||
<field name="name">Allow</field>
|
||||
<field name="category_id" ref="ir_module_category_tower_yaml_import" />
|
||||
<field name="comment">
|
||||
Import data from YAML.
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo noupdate="1">
|
||||
<!-- cx.tower.yaml.export.wiz -->
|
||||
<record id="rule_cx_tower_yaml_export_wiz_creator_only" model="ir.rule">
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_yaml_export_wiz" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
<!-- cx.tower.yaml.export.wiz.download -->
|
||||
|
||||
<record id="rule_cx_tower_yaml_export_wiz_download_creator_only" model="ir.rule">
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_yaml_export_wiz_download" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- cx.tower.yaml.import.wiz -->
|
||||
<record id="rule_cx_tower_yaml_import_wiz_creator_only" model="ir.rule">
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_yaml_import_wiz" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- cx.tower.yaml.import.wiz.upload -->
|
||||
<record id="rule_cx_tower_yaml_import_wiz_upload_creator_only" model="ir.rule">
|
||||
<field name="name">Creator only</field>
|
||||
<field name="model_id" ref="model_cx_tower_yaml_import_wiz_upload" />
|
||||
<field name="global" eval="True" />
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
9
addons/cetmix_tower_yaml/security/ir.model.access.csv
Normal file
9
addons/cetmix_tower_yaml/security/ir.model.access.csv
Normal file
@@ -0,0 +1,9 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_yaml_export_wizard,Export YAML,model_cx_tower_yaml_export_wiz,group_export,1,1,1,1
|
||||
access_yaml_export_wizard_download,Export YAML File,model_cx_tower_yaml_export_wiz_download,group_export,1,1,1,1
|
||||
access_yaml_import_wizard_upload,Import YAML,model_cx_tower_yaml_import_wiz_upload,group_import,1,1,1,1
|
||||
access_yaml_import_wizard,Import YAML,model_cx_tower_yaml_import_wiz,group_import,1,1,1,1
|
||||
access_manifest_tmpl_read_export,Manifest tmpl read (export),model_cx_tower_yaml_manifest_tmpl,cetmix_tower_yaml.group_export,1,0,0,0
|
||||
access_manifest_tmpl_admin,Manifest tmpl admin,model_cx_tower_yaml_manifest_tmpl,cetmix_tower_server.group_root,1,1,1,1
|
||||
access_manifest_author_read_export,Manifest author read (export),model_cx_tower_yaml_manifest_author,cetmix_tower_yaml.group_export,1,0,0,0
|
||||
access_manifest_author_admin,Manifest author admin,model_cx_tower_yaml_manifest_author,cetmix_tower_server.group_root,1,1,1,1
|
||||
|
BIN
addons/cetmix_tower_yaml/static/description/icon.png
Normal file
BIN
addons/cetmix_tower_yaml/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
439
addons/cetmix_tower_yaml/static/description/index.html
Normal file
439
addons/cetmix_tower_yaml/static/description/index.html
Normal file
@@ -0,0 +1,439 @@
|
||||
<!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:67e4f5c3d94d23c826affb93f25cdacdccc0ef69084fe3d293e9e140270b8394
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<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/18.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">18.0.2.0.0 (2026-04-07)</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-5">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="toc-entry-6">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="toc-entry-7">Authors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="toc-entry-8">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">18.0.2.0.0 (2026-04-07)</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Features: Jets! (4700)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h1><a class="toc-backref" href="#toc-entry-5">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:%2018.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-6">Credits</a></h1>
|
||||
<div class="section" id="authors">
|
||||
<h2><a class="toc-backref" href="#toc-entry-7">Authors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Cetmix</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2><a class="toc-backref" href="#toc-entry-8">Maintainers</a></h2>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/cetmix/cetmix-tower/tree/18.0/cetmix_tower_yaml">cetmix/cetmix-tower</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
8
addons/cetmix_tower_yaml/tests/__init__.py
Normal file
8
addons/cetmix_tower_yaml/tests/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from . import test_command
|
||||
from . import test_tower_yaml_mixin
|
||||
from . import test_file_template
|
||||
from . import test_plan
|
||||
from . import test_yaml_export_wizard
|
||||
from . import test_yaml_import_wizard
|
||||
from . import test_server_log
|
||||
from . import test_server_yaml
|
||||
334
addons/cetmix_tower_yaml/tests/test_command.py
Normal file
334
addons/cetmix_tower_yaml/tests/test_command.py
Normal file
@@ -0,0 +1,334 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
import yaml
|
||||
|
||||
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
|
||||
if_file_exists: skip
|
||||
disconnect_file: false
|
||||
flight_plan_id: false
|
||||
jet_template_id: false
|
||||
jet_action_id: false
|
||||
waypoint_template_id: false
|
||||
fly_here: false
|
||||
code: |-
|
||||
cd /home/{{ tower.server.ssh_username }} \\
|
||||
&& ls -lha
|
||||
no_split_for_sudo: false
|
||||
server_status: false
|
||||
variable_ids: false
|
||||
secret_ids: 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
|
||||
# TODO: Odoo 18.0 doesn't raise an exception
|
||||
# when a selection field value is not valid.
|
||||
# Add a method to handle this case.
|
||||
|
||||
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
|
||||
if_file_exists: skip
|
||||
disconnect_file: false
|
||||
flight_plan_id: false
|
||||
jet_template_id: false
|
||||
jet_action_id: false
|
||||
waypoint_template_id: false
|
||||
fly_here: false
|
||||
code: false
|
||||
no_split_for_sudo: false
|
||||
server_status: false
|
||||
variable_ids: false
|
||||
secret_ids: 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
|
||||
if_file_exists: skip
|
||||
disconnect_file: false
|
||||
flight_plan_id: false
|
||||
jet_template_id: false
|
||||
jet_action_id: false
|
||||
waypoint_template_id: false
|
||||
fly_here: false
|
||||
code: false
|
||||
no_split_for_sudo: false
|
||||
server_status: false
|
||||
variable_ids: false
|
||||
secret_ids: false
|
||||
"""
|
||||
command_with_template.invalidate_recordset(["yaml_code"])
|
||||
self.assertEqual(
|
||||
command_with_template.with_context(explode_related_record=True).yaml_code,
|
||||
yaml_with_reference_exploded,
|
||||
"YAML is not composed correctly",
|
||||
)
|
||||
320
addons/cetmix_tower_yaml/tests/test_file_template.py
Normal file
320
addons/cetmix_tower_yaml/tests/test_file_template.py
Normal file
@@ -0,0 +1,320 @@
|
||||
import yaml
|
||||
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
|
||||
class TestTowerFileTemplate(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls, *args, **kwargs):
|
||||
super().setUpClass(*args, **kwargs)
|
||||
|
||||
cls.FileTemplate = cls.env["cx.tower.file.template"]
|
||||
|
||||
# Expected YAML content of the test file template
|
||||
cls.file_template_test_yaml = """cetmix_tower_model: file_template
|
||||
reference: dockerfile_unit_test
|
||||
name: Dockerfile Test
|
||||
source: tower
|
||||
file_type: text
|
||||
server_dir: /opt
|
||||
file_name: Dockerfile
|
||||
keep_when_deleted: true
|
||||
tag_ids: false
|
||||
note: |-
|
||||
Used to build Odoo addons image.
|
||||
Depends on Odoo core image.
|
||||
code: |-
|
||||
FROM odoo:{{ odoo_test_version }}
|
||||
# Install git-aggregator and tools for requirements generation
|
||||
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
|
||||
# Let's go!
|
||||
USER odoo
|
||||
variable_ids: false
|
||||
secret_ids: false
|
||||
""" # noqa
|
||||
|
||||
# Expected YAML content of the test file template
|
||||
# without empty x2mvalues
|
||||
cls.file_template_test_yaml_no_empty_values = """cetmix_tower_model: file_template
|
||||
reference: dockerfile_unit_test
|
||||
name: Dockerfile Test
|
||||
source: tower
|
||||
file_type: text
|
||||
server_dir: /opt
|
||||
file_name: Dockerfile
|
||||
keep_when_deleted: true
|
||||
note: |-
|
||||
Used to build Odoo addons image.
|
||||
Depends on Odoo core image.
|
||||
code: |-
|
||||
FROM odoo:{{ odoo_test_version }}
|
||||
# Install git-aggregator and tools for requirements generation
|
||||
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
|
||||
# Let's go!
|
||||
USER odoo
|
||||
""" # noqa
|
||||
|
||||
# YAML content translated into Python dict
|
||||
cls.file_template_test_yaml_dict = yaml.safe_load(cls.file_template_test_yaml)
|
||||
cls.file_template_test_yaml_dict_no_empty_values = yaml.safe_load(
|
||||
cls.file_template_test_yaml_no_empty_values
|
||||
)
|
||||
|
||||
def test_yaml_from_file_template(self):
|
||||
"""Test if YAML is generated properly from a file"""
|
||||
|
||||
# -- 0 --
|
||||
# Create test file
|
||||
# Test file
|
||||
file_template_test = self.FileTemplate.create(
|
||||
{
|
||||
"name": "Dockerfile Test",
|
||||
"reference": "dockerfile_unit_test",
|
||||
"file_name": "Dockerfile",
|
||||
"server_dir": "/opt",
|
||||
"source": "tower",
|
||||
"keep_when_deleted": True,
|
||||
"file_type": "text",
|
||||
"code": """FROM odoo:{{ odoo_test_version }}
|
||||
# Install git-aggregator and tools for requirements generation
|
||||
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
|
||||
# Let's go!
|
||||
USER odoo""",
|
||||
"note": """Used to build Odoo addons image.
|
||||
Depends on Odoo core image.""",
|
||||
}
|
||||
)
|
||||
|
||||
# -- 1 --
|
||||
# Check it YAML generated by the file matches
|
||||
# YAML from the template file
|
||||
|
||||
self.assertEqual(
|
||||
file_template_test.yaml_code,
|
||||
self.file_template_test_yaml,
|
||||
"YAML generated from file doesn't match template file one",
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Check if YAML key values match Cetmix Tower ones
|
||||
|
||||
self.assertEqual(
|
||||
file_template_test.source,
|
||||
self.file_template_test_yaml_dict["source"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.file_name,
|
||||
self.file_template_test_yaml_dict["file_name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.code,
|
||||
self.file_template_test_yaml_dict["code"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.name,
|
||||
self.file_template_test_yaml_dict["name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.note,
|
||||
self.file_template_test_yaml_dict["note"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.server_dir,
|
||||
self.file_template_test_yaml_dict["server_dir"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.reference,
|
||||
self.file_template_test_yaml_dict["reference"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.file_type,
|
||||
self.file_template_test_yaml_dict["file_type"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.keep_when_deleted,
|
||||
self.file_template_test_yaml_dict["keep_when_deleted"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
|
||||
def test_yaml_from_file_template_no_empty_values(self):
|
||||
"""Test if YAML is generated properly from a file"""
|
||||
|
||||
# -- 0 --
|
||||
# Create test file
|
||||
# Test file
|
||||
file_template_test = self.FileTemplate.with_context(
|
||||
remove_empty_values=True
|
||||
).create(
|
||||
{
|
||||
"name": "Dockerfile Test",
|
||||
"reference": "dockerfile_unit_test",
|
||||
"file_name": "Dockerfile",
|
||||
"server_dir": "/opt",
|
||||
"source": "tower",
|
||||
"keep_when_deleted": True,
|
||||
"file_type": "text",
|
||||
"code": """FROM odoo:{{ odoo_test_version }}
|
||||
# Install git-aggregator and tools for requirements generation
|
||||
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
|
||||
# Let's go!
|
||||
USER odoo""",
|
||||
"note": """Used to build Odoo addons image.
|
||||
Depends on Odoo core image.""",
|
||||
}
|
||||
)
|
||||
|
||||
# -- 1 --
|
||||
# Check it YAML generated by the file matches
|
||||
# YAML from the template file
|
||||
|
||||
self.assertEqual(
|
||||
file_template_test.yaml_code,
|
||||
self.file_template_test_yaml_no_empty_values,
|
||||
"YAML generated from file doesn't match template file one",
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Check if YAML key values match Cetmix Tower ones
|
||||
|
||||
self.assertEqual(
|
||||
file_template_test.source,
|
||||
self.file_template_test_yaml_dict_no_empty_values["source"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.file_name,
|
||||
self.file_template_test_yaml_dict_no_empty_values["file_name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.code,
|
||||
self.file_template_test_yaml_dict_no_empty_values["code"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.name,
|
||||
self.file_template_test_yaml_dict_no_empty_values["name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.note,
|
||||
self.file_template_test_yaml_dict_no_empty_values["note"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.server_dir,
|
||||
self.file_template_test_yaml_dict_no_empty_values["server_dir"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.reference,
|
||||
self.file_template_test_yaml_dict_no_empty_values["reference"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.file_type,
|
||||
self.file_template_test_yaml_dict_no_empty_values["file_type"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template_test.keep_when_deleted,
|
||||
self.file_template_test_yaml_dict_no_empty_values["keep_when_deleted"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
|
||||
def test_file_template_from_yaml(self):
|
||||
"""Test if YAML is generated properly from a file"""
|
||||
|
||||
def test_yaml(file_template):
|
||||
"""Checks if yaml values are inserted correctly
|
||||
|
||||
Args:
|
||||
file_template (cx.tower.file.template): File template
|
||||
"""
|
||||
self.assertEqual(
|
||||
file_template.source,
|
||||
self.file_template_test_yaml_dict["source"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.file_name,
|
||||
self.file_template_test_yaml_dict["file_name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.code,
|
||||
self.file_template_test_yaml_dict["code"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.name,
|
||||
self.file_template_test_yaml_dict["name"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.note,
|
||||
self.file_template_test_yaml_dict["note"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.server_dir,
|
||||
self.file_template_test_yaml_dict["server_dir"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.reference,
|
||||
self.file_template_test_yaml_dict["reference"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.file_type,
|
||||
self.file_template_test_yaml_dict["file_type"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
self.assertEqual(
|
||||
file_template.keep_when_deleted,
|
||||
self.file_template_test_yaml_dict["keep_when_deleted"],
|
||||
"YAML value doesn't match Cetmix Tower one",
|
||||
)
|
||||
|
||||
# Create test file template
|
||||
file_template_test = self.FileTemplate.create({"name": "New file template"})
|
||||
|
||||
# -- 1 --
|
||||
# Insert YAML into the file and
|
||||
# check if YAML key values match Cetmix Tower ones
|
||||
file_template_test.yaml_code = self.file_template_test_yaml
|
||||
test_yaml(file_template_test)
|
||||
|
||||
# -- 2 --
|
||||
# Insert some non supported keys and ensure nothing bad happens
|
||||
yaml_with_non_supported_keys = """cetmix_tower_model: file_template
|
||||
code: |-
|
||||
FROM odoo:{{ odoo_test_version }}
|
||||
# Install git-aggregator and tools for requirements generation
|
||||
RUN pip3 install --upgrade pip && pip install manifestoo setuptools-odoo git-aggregator
|
||||
# Let's go!
|
||||
USER odoo
|
||||
doge: SoMuch style!
|
||||
file_name: Dockerfile
|
||||
file_type: text
|
||||
keep_when_deleted: true
|
||||
name: Dockerfile Test
|
||||
note: |-
|
||||
Used to build Odoo addons image.
|
||||
Depends on Odoo core image.
|
||||
reference: dockerfile_unit_test
|
||||
server_dir: /opt
|
||||
source: tower
|
||||
tag_ids: false
|
||||
""" # noqa
|
||||
file_template_test.yaml_code = yaml_with_non_supported_keys
|
||||
test_yaml(file_template_test)
|
||||
179
addons/cetmix_tower_yaml/tests/test_plan.py
Normal file
179
addons/cetmix_tower_yaml/tests/test_plan.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from odoo.tests import TransactionCase
|
||||
|
||||
|
||||
class TestTowerPlan(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls, *args, **kwargs):
|
||||
super().setUpClass(*args, **kwargs)
|
||||
|
||||
cls.Plan = cls.env["cx.tower.plan"]
|
||||
|
||||
def test_plan_create_from_yaml(self):
|
||||
"""Test plan creation from YAML."""
|
||||
|
||||
plan_yaml = """cetmix_tower_model: plan
|
||||
access_level: manager
|
||||
reference: test_plan_from_yaml
|
||||
name: 'Test Plan From Yaml'
|
||||
allow_parallel_run: false
|
||||
color: 0
|
||||
tag_ids:
|
||||
- reference: doge_test_plan_tag
|
||||
name: Doge Test Plan Tag
|
||||
color: 1
|
||||
on_error_action: e
|
||||
custom_exit_code: 0
|
||||
line_ids:
|
||||
- sequence: 5
|
||||
condition: false
|
||||
use_sudo: false
|
||||
path: /such/much/{{ test_plan_dir }}
|
||||
command_id:
|
||||
access_level: manager
|
||||
reference: very_much_command_test
|
||||
name: Very much command
|
||||
action: ssh_command
|
||||
allow_parallel_run: false
|
||||
note: false
|
||||
code: Such much code
|
||||
variable_ids:
|
||||
- cetmix_tower_model: variable
|
||||
reference: test_plan_dir
|
||||
name: Test Plan Directory
|
||||
action_ids:
|
||||
- sequence: 1
|
||||
condition: ==
|
||||
value_char: '0'
|
||||
action: n
|
||||
custom_exit_code: 0
|
||||
variable_value_ids:
|
||||
- cetmix_tower_model: variable_value
|
||||
variable_id:
|
||||
cetmix_tower_yaml_version: 1
|
||||
cetmix_tower_model: variable
|
||||
reference: test_plan_branch
|
||||
name: Test Plan Branch
|
||||
value_char: production
|
||||
- cetmix_tower_model: variable_value
|
||||
variable_id:
|
||||
cetmix_tower_yaml_version: 1
|
||||
cetmix_tower_model: variable
|
||||
reference: test_plan_some_unique_variable
|
||||
name: Test Plan Some Unique Variable
|
||||
value_char: 'Final Value'
|
||||
- cetmix_tower_model: plan_line_action
|
||||
access_level: manager
|
||||
sequence: 2
|
||||
condition: '>'
|
||||
value_char: '0'
|
||||
action: ec
|
||||
custom_exit_code: 255
|
||||
variable_value_ids: false
|
||||
variable_ids: false
|
||||
"""
|
||||
# -- 1 --
|
||||
# Create plan from YAML
|
||||
plan_form_yaml = self.Plan.create(
|
||||
{"name": "Name Placeholder", "yaml_code": plan_yaml}
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_form_yaml.reference,
|
||||
"test_plan_from_yaml",
|
||||
"Reference is not set from YAML",
|
||||
)
|
||||
# Name should be set from YAML
|
||||
self.assertEqual(
|
||||
plan_form_yaml.name, "Test Plan From Yaml", "Name is not set from YAML"
|
||||
)
|
||||
|
||||
# -- 2 --
|
||||
# Check plan tags
|
||||
plan_tags = plan_form_yaml.tag_ids
|
||||
self.assertEqual(len(plan_tags), 1)
|
||||
self.assertEqual(plan_tags.name, "Doge Test Plan Tag")
|
||||
|
||||
# -- 3 --
|
||||
# Check plan lines
|
||||
plan_lines = plan_form_yaml.line_ids
|
||||
self.assertEqual(len(plan_lines), 1, "Line count is not 1")
|
||||
self.assertFalse(plan_lines.condition, "Condition is not false")
|
||||
self.assertEqual(
|
||||
plan_lines.path,
|
||||
"/such/much/{{ test_plan_dir }}",
|
||||
"Path is not set from YAML",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_lines.command_id.reference,
|
||||
"very_much_command_test",
|
||||
"Command reference is not set from YAML",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_lines.command_id.name,
|
||||
"Very much command",
|
||||
"Command name is not set from YAML",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_lines.command_id.action,
|
||||
"ssh_command",
|
||||
"Command action is not set from YAML",
|
||||
)
|
||||
self.assertFalse(
|
||||
plan_lines.command_id.allow_parallel_run,
|
||||
"Command allow parallel run is not set from YAML",
|
||||
)
|
||||
self.assertFalse(
|
||||
plan_lines.command_id.note, "Command note is not set from YAML"
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_lines.command_id.variable_ids.mapped("reference"),
|
||||
["test_plan_dir"],
|
||||
"Command variable ids is not set from YAML",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_lines.command_id.access_level,
|
||||
"2",
|
||||
"Command access level is not set from YAML",
|
||||
)
|
||||
|
||||
# -- 4 --
|
||||
# Check plan line actions
|
||||
plan_actions = plan_form_yaml.line_ids.action_ids
|
||||
self.assertEqual(len(plan_actions), 2, "Action count is not 2")
|
||||
self.assertEqual(
|
||||
plan_actions[0].condition, "==", "First action condition is not equal"
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_actions[0].value_char, "0", "First action value char is not 0"
|
||||
)
|
||||
self.assertEqual(plan_actions[0].action, "n", "First action action is not n")
|
||||
self.assertEqual(
|
||||
plan_actions[0].custom_exit_code,
|
||||
0,
|
||||
"First action custom exit code is not 0",
|
||||
)
|
||||
self.assertEqual(
|
||||
len(plan_actions[0].variable_value_ids),
|
||||
2,
|
||||
"Number of variable value ids is not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_actions[0].variable_value_ids.mapped("value_char"),
|
||||
["production", "Final Value"],
|
||||
"Variable value chars are not correct",
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_actions[1].condition, ">", "Second action condition is not greater"
|
||||
)
|
||||
self.assertEqual(
|
||||
plan_actions[1].value_char, "0", "Second action value char is not 0"
|
||||
)
|
||||
self.assertEqual(plan_actions[1].action, "ec", "Second action action is not ec")
|
||||
self.assertEqual(
|
||||
plan_actions[1].custom_exit_code,
|
||||
255,
|
||||
"Second action custom exit code is not 255",
|
||||
)
|
||||
self.assertFalse(
|
||||
plan_actions[1].variable_value_ids,
|
||||
"Second action variable value ids is not false",
|
||||
)
|
||||
127
addons/cetmix_tower_yaml/tests/test_server_log.py
Normal file
127
addons/cetmix_tower_yaml/tests/test_server_log.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
"""
|
||||
Tests for the cx.tower.server.log model YAML export/import.
|
||||
|
||||
Covers:
|
||||
1. YAML export of a file-type log must include `file_id` and allow suffixes.
|
||||
2. A full round-trip (export → delete → import) preserves the `file_id` relation.
|
||||
3. Exporting a non-file log must include a falsy `file_id`.
|
||||
4. Importing YAML with a bogus `file_id` reference raises ValidationError.
|
||||
"""
|
||||
|
||||
import yaml
|
||||
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestServerLog(TransactionCase):
|
||||
"""YAML export/import tests for cx.tower.server.log."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
env = cls.env
|
||||
cls.File = env["cx.tower.file"]
|
||||
cls.Server = env["cx.tower.server"]
|
||||
cls.ServerLog = env["cx.tower.server.log"]
|
||||
|
||||
# Create a file to reference from the log
|
||||
cls.file = cls.File.create(
|
||||
{
|
||||
"name": "repos.yaml",
|
||||
"reference": "reposyaml",
|
||||
"source": "tower",
|
||||
"file_type": "text",
|
||||
"server_dir": "/tmp",
|
||||
"code": "# Example\nHello, Tower!",
|
||||
}
|
||||
)
|
||||
|
||||
# Create a server (use password auth to satisfy constraints)
|
||||
cls.server = cls.Server.create(
|
||||
{
|
||||
"name": "Srv-YAML-Test",
|
||||
"reference": "srv_yaml_test",
|
||||
"ip_v4_address": "127.0.0.1",
|
||||
"ssh_username": "admin",
|
||||
"ssh_port": 22,
|
||||
"ssh_auth_mode": "p",
|
||||
"ssh_password": "dummy",
|
||||
"use_sudo": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a file-type log linked to the file above
|
||||
cls.log = cls.ServerLog.create(
|
||||
{
|
||||
"name": "Log from file",
|
||||
"reference": "log_from_file",
|
||||
"log_type": "file",
|
||||
"file_id": cls.file.id,
|
||||
"server_id": cls.server.id,
|
||||
"use_sudo": False,
|
||||
}
|
||||
)
|
||||
|
||||
def test_yaml_export_contains_file_id(self):
|
||||
"""Exported YAML must include a file_id starting with the file's reference."""
|
||||
data = yaml.safe_load(self.log.yaml_code)
|
||||
# Ensure file_id is present
|
||||
self.assertIn("file_id", data, "`file_id` is missing from YAML export")
|
||||
# Allow for auto-appended suffixes, so only check prefix
|
||||
self.assertTrue(
|
||||
data["file_id"].startswith(self.file.reference),
|
||||
f"`file_id` value '{data['file_id']}' should start with "
|
||||
f"'{self.file.reference}'",
|
||||
)
|
||||
|
||||
def test_yaml_roundtrip_restores_file_id(self):
|
||||
"""A full export→delete→import cycle must restore the file_id relation."""
|
||||
yaml_dict = yaml.safe_load(self.log.yaml_code)
|
||||
# Remove the original log
|
||||
self.log.unlink()
|
||||
# Recreate from YAML
|
||||
vals = self.ServerLog._post_process_yaml_dict_values(yaml_dict)
|
||||
restored = self.ServerLog.with_context(from_yaml=True).create(vals)
|
||||
# Verify relation restored
|
||||
self.assertEqual(
|
||||
restored.file_id.id,
|
||||
self.file.id,
|
||||
"`file_id` was not restored after round-trip",
|
||||
)
|
||||
|
||||
def test_yaml_export_without_file_id(self):
|
||||
"""Logs of non-file type should not include file_id in YAML."""
|
||||
cmd_log = self.ServerLog.create(
|
||||
{
|
||||
"name": "Log no file",
|
||||
"reference": "log_no_file",
|
||||
"log_type": "command",
|
||||
"server_id": self.server.id,
|
||||
"use_sudo": False,
|
||||
}
|
||||
)
|
||||
data = yaml.safe_load(cmd_log.yaml_code)
|
||||
# key is present, but must be falsy
|
||||
self.assertIn("file_id", data, "`file_id` key is missing")
|
||||
self.assertFalse(
|
||||
data["file_id"],
|
||||
"`file_id` for non-file log must be False/empty",
|
||||
)
|
||||
|
||||
def test_yaml_import_with_missing_file_reference(self):
|
||||
"""Missing file reference is accepted, but file_id stays empty."""
|
||||
yaml_dict = yaml.safe_load(self.log.yaml_code)
|
||||
yaml_dict["file_id"] = "does_not_exist"
|
||||
|
||||
vals = self.ServerLog._post_process_yaml_dict_values(yaml_dict)
|
||||
new_log = self.ServerLog.with_context(from_yaml=True).create(vals)
|
||||
|
||||
# Log is created, but the relation is not resolved
|
||||
self.assertFalse(
|
||||
new_log.file_id,
|
||||
"file_id should be empty when reference cannot be resolved",
|
||||
)
|
||||
125
addons/cetmix_tower_yaml/tests/test_server_yaml.py
Normal file
125
addons/cetmix_tower_yaml/tests/test_server_yaml.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# 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",
|
||||
)
|
||||
768
addons/cetmix_tower_yaml/tests/test_tower_yaml_mixin.py
Normal file
768
addons/cetmix_tower_yaml/tests/test_tower_yaml_mixin.py
Normal file
@@ -0,0 +1,768 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
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"]
|
||||
cls.Command = cls.env["cx.tower.command"]
|
||||
cls.JetTemplate = cls.env["cx.tower.jet.template"]
|
||||
cls.ScheduledTask = cls.env["cx.tower.scheduled.task"]
|
||||
TowerTag = cls.env["cx.tower.tag"]
|
||||
cls.tag_doge = TowerTag.create({"name": "Doge", "reference": "doge"})
|
||||
cls.tag_pepe = TowerTag.create({"name": "Pepe", "reference": "pepe"})
|
||||
cls.jet_state_running = cls.env["cx.tower.jet.state"].get_by_reference(
|
||||
"running"
|
||||
)
|
||||
cls.command_for_schedule = cls.Command.create(
|
||||
{"name": "Command for schedule", "action": "ssh_command"}
|
||||
)
|
||||
cls.jet_template_existing = cls.env["cx.tower.jet.template"].create(
|
||||
{"name": "Existing Jet Template", "reference": "existing_jet_template"}
|
||||
)
|
||||
cls.waypoint_template_existing = cls.env[
|
||||
"cx.tower.jet.waypoint.template"
|
||||
].create(
|
||||
{
|
||||
"name": "Existing Waypoint Template",
|
||||
"reference": "existing_waypoint_template",
|
||||
"jet_template_id": cls.jet_template_existing.id,
|
||||
}
|
||||
)
|
||||
|
||||
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"]
|
||||
|
||||
with patch(
|
||||
"odoo.addons.cetmix_tower_yaml.models.cx_tower_yaml_mixin.CxTowerYamlMixin._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",
|
||||
)
|
||||
|
||||
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"]
|
||||
|
||||
with patch(
|
||||
"odoo.addons.cetmix_tower_yaml.models.cx_tower_yaml_mixin.CxTowerYamlMixin._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",
|
||||
)
|
||||
|
||||
def test_post_process_yaml_dict_values_defers_command_template_links(self):
|
||||
"""Reference-only unresolved command template links must be deferred."""
|
||||
deferred_queue = []
|
||||
values = {
|
||||
"reference": "command_deferred_links",
|
||||
"name": "Command Deferred Links",
|
||||
"action": "jet_action",
|
||||
"jet_template_id": "future_jet_template",
|
||||
"waypoint_template_id": {"reference": "future_waypoint_template"},
|
||||
}
|
||||
|
||||
result_values = self.Command.with_context(
|
||||
yaml_deferred_m2o_queue=deferred_queue
|
||||
)._post_process_yaml_dict_values(values)
|
||||
|
||||
self.assertNotIn(
|
||||
"jet_template_id",
|
||||
result_values,
|
||||
"Deferred jet template link must be omitted from first-pass values",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"waypoint_template_id",
|
||||
result_values,
|
||||
"Deferred waypoint template link must be omitted from first-pass values",
|
||||
)
|
||||
self.assertEqual(len(deferred_queue), 2, "Two deferred items must be queued")
|
||||
self.assertEqual(
|
||||
deferred_queue[0]["record_reference"],
|
||||
values["reference"],
|
||||
"Deferred queue must preserve command reference",
|
||||
)
|
||||
self.assertEqual(
|
||||
deferred_queue[0]["field_name"],
|
||||
"jet_template_id",
|
||||
"Deferred queue must preserve the deferred field name",
|
||||
)
|
||||
self.assertEqual(
|
||||
deferred_queue[1]["field_name"],
|
||||
"waypoint_template_id",
|
||||
"Deferred queue must preserve each deferred field separately",
|
||||
)
|
||||
|
||||
def test_post_process_yaml_dict_values_resolves_existing_command_template_links(
|
||||
self,
|
||||
):
|
||||
"""Already existing command template links must be resolved immediately."""
|
||||
deferred_queue = []
|
||||
values = {
|
||||
"reference": "command_immediate_links",
|
||||
"name": "Command Immediate Links",
|
||||
"action": "create_waypoint",
|
||||
"jet_template_id": self.jet_template_existing.reference,
|
||||
"waypoint_template_id": {
|
||||
"reference": self.waypoint_template_existing.reference
|
||||
},
|
||||
}
|
||||
|
||||
result_values = self.Command.with_context(
|
||||
yaml_deferred_m2o_queue=deferred_queue
|
||||
)._post_process_yaml_dict_values(values)
|
||||
|
||||
self.assertEqual(
|
||||
result_values["jet_template_id"],
|
||||
self.jet_template_existing.id,
|
||||
"Existing jet template must resolve during the first import pass",
|
||||
)
|
||||
self.assertEqual(
|
||||
result_values["waypoint_template_id"],
|
||||
self.waypoint_template_existing.id,
|
||||
"Existing waypoint template must resolve during the first import pass",
|
||||
)
|
||||
self.assertFalse(
|
||||
deferred_queue,
|
||||
"No deferred items must be queued when targets already exist",
|
||||
)
|
||||
|
||||
def test_post_process_yaml_dict_values_defers_template_dependency_children(self):
|
||||
"""Unresolved template dependency children must be deferred."""
|
||||
deferred_queue = []
|
||||
values = {
|
||||
"reference": "owner_template_deferred_dependency",
|
||||
"name": "Owner Template Deferred Dependency",
|
||||
"template_requires_ids": [
|
||||
{
|
||||
"reference": False,
|
||||
"template_required_id": {
|
||||
"reference": "future_template_dependency_target"
|
||||
},
|
||||
"state_required_id": {
|
||||
"reference": self.jet_state_running.reference
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
result_values = self.JetTemplate.with_context(
|
||||
yaml_deferred_x2m_queue=deferred_queue
|
||||
)._post_process_yaml_dict_values(values)
|
||||
|
||||
self.assertEqual(
|
||||
result_values.get("template_requires_ids"),
|
||||
[],
|
||||
"Deferred dependency child must be removed from first-pass create values",
|
||||
)
|
||||
self.assertEqual(
|
||||
len(deferred_queue),
|
||||
1,
|
||||
"One dependency child must be queued for deferred creation",
|
||||
)
|
||||
self.assertEqual(
|
||||
deferred_queue[0]["field_name"],
|
||||
"template_requires_ids",
|
||||
"Deferred queue must preserve the parent x2m field name",
|
||||
)
|
||||
self.assertEqual(
|
||||
deferred_queue[0]["target_reference"],
|
||||
"future_template_dependency_target",
|
||||
"Deferred queue must preserve the missing dependency target reference",
|
||||
)
|
||||
|
||||
def test_post_process_yaml_dict_values_skips_empty_scheduled_task_custom_values(
|
||||
self,
|
||||
):
|
||||
"""Placeholder scheduled-task custom values must be skipped."""
|
||||
deferred_queue = []
|
||||
scheduled_task_values = {
|
||||
"reference": "scheduled_task_skip_empty_child",
|
||||
"name": "Scheduled Task Skip Empty Child",
|
||||
"action": "command",
|
||||
"command_id": self.command_for_schedule.reference,
|
||||
"interval_number": 1,
|
||||
"interval_type": "days",
|
||||
"next_call": "2026-03-27 00:00:00",
|
||||
"custom_variable_value_ids": [{"reference": False}],
|
||||
}
|
||||
|
||||
result_values = self.ScheduledTask.with_context(
|
||||
yaml_deferred_x2m_queue=deferred_queue
|
||||
)._post_process_yaml_dict_values(scheduled_task_values)
|
||||
|
||||
self.assertEqual(
|
||||
result_values.get("custom_variable_value_ids"),
|
||||
[],
|
||||
"Placeholder child rows must be removed from scheduled task import values",
|
||||
)
|
||||
self.assertFalse(
|
||||
deferred_queue,
|
||||
"Empty placeholder rows must be skipped rather than deferred",
|
||||
)
|
||||
|
||||
def test_post_process_yaml_dict_values_defers_scheduled_task_custom_values(self):
|
||||
"""Unresolved scheduled-task custom values must be deferred."""
|
||||
deferred_queue = []
|
||||
scheduled_task_values = {
|
||||
"reference": "scheduled_task_deferred_custom_value",
|
||||
"name": "Scheduled Task Deferred Custom Value",
|
||||
"action": "command",
|
||||
"command_id": self.command_for_schedule.reference,
|
||||
"interval_number": 1,
|
||||
"interval_type": "days",
|
||||
"next_call": "2026-03-27 00:00:00",
|
||||
"custom_variable_value_ids": [
|
||||
{
|
||||
"reference": False,
|
||||
"variable_value_id": {"reference": "future_variable_value_ref"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
result_values = self.ScheduledTask.with_context(
|
||||
yaml_deferred_x2m_queue=deferred_queue
|
||||
)._post_process_yaml_dict_values(scheduled_task_values)
|
||||
|
||||
self.assertEqual(
|
||||
result_values.get("custom_variable_value_ids"),
|
||||
[],
|
||||
"Deferred scheduled-task child rows must be removed from first-pass values",
|
||||
)
|
||||
self.assertEqual(
|
||||
len(deferred_queue),
|
||||
1,
|
||||
"One scheduled-task custom value row must be queued for deferred creation",
|
||||
)
|
||||
self.assertEqual(
|
||||
deferred_queue[0]["field_name"],
|
||||
"custom_variable_value_ids",
|
||||
"Deferred queue must preserve the scheduled-task child field name",
|
||||
)
|
||||
self.assertEqual(
|
||||
deferred_queue[0]["target_reference"],
|
||||
"future_variable_value_ref",
|
||||
"Deferred queue must preserve the missing variable value reference",
|
||||
)
|
||||
|
||||
def test_process_relation_field_value_reference_only_dict_no_placeholder_create(
|
||||
self,
|
||||
):
|
||||
"""Reference-only dict must not create placeholder m2o records."""
|
||||
command = self.Command.create(
|
||||
{
|
||||
"name": "Command reference-only dict",
|
||||
"action": "file_using_template",
|
||||
}
|
||||
)
|
||||
missing_reference = "missing_file_template_reference_only"
|
||||
|
||||
result = command._process_relation_field_value(
|
||||
field="file_template_id",
|
||||
value={"reference": missing_reference},
|
||||
record_mode=False,
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
result,
|
||||
"Reference-only dict must stay unresolved instead of creating a record",
|
||||
)
|
||||
self.assertFalse(
|
||||
self.env["cx.tower.file.template"].get_by_reference(missing_reference),
|
||||
"Reference-only dict must not create a placeholder related record",
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
377
addons/cetmix_tower_yaml/tests/test_yaml_export_wizard.py
Normal file
377
addons/cetmix_tower_yaml/tests/test_yaml_export_wizard.py
Normal file
@@ -0,0 +1,377 @@
|
||||
# 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
|
||||
if_file_exists: skip
|
||||
disconnect_file: false
|
||||
fly_here: false
|
||||
code: echo 'Test Command From Yaml'
|
||||
no_split_for_sudo: false
|
||||
server_status: 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
|
||||
if_file_exists: skip
|
||||
disconnect_file: false
|
||||
fly_here: false
|
||||
code: echo 'Test Command From Yaml 2'
|
||||
no_split_for_sudo: false
|
||||
server_status: 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")
|
||||
1008
addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py
Normal file
1008
addons/cetmix_tower_yaml/tests/test_yaml_import_wizard.py
Normal file
File diff suppressed because it is too large
Load Diff
10
addons/cetmix_tower_yaml/views/cx_tower_command_view.xml
Normal file
10
addons/cetmix_tower_yaml/views/cx_tower_command_view.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<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" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<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" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="cx_tower_jet_template_view_form" model="ir.ui.view">
|
||||
<field name="name">cx.tower.jet.template.yaml.view.form</field>
|
||||
<field name="model">cx.tower.jet.template</field>
|
||||
<field
|
||||
name="inherit_id"
|
||||
ref="cetmix_tower_server.cx_tower_jet_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_jet_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_jet_template" />
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="groups_id" eval="[(4, ref('cetmix_tower_yaml.group_export'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
10
addons/cetmix_tower_yaml/views/cx_tower_key_view.xml
Normal file
10
addons/cetmix_tower_yaml/views/cx_tower_key_view.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?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" />
|
||||
</record>
|
||||
</odoo>
|
||||
10
addons/cetmix_tower_yaml/views/cx_tower_os_view.xml
Normal file
10
addons/cetmix_tower_yaml/views/cx_tower_os_view.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?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" />
|
||||
</record>
|
||||
</odoo>
|
||||
10
addons/cetmix_tower_yaml/views/cx_tower_plan_view.xml
Normal file
10
addons/cetmix_tower_yaml/views/cx_tower_plan_view.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<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" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<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" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<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" />
|
||||
</record>
|
||||
</odoo>
|
||||
10
addons/cetmix_tower_yaml/views/cx_tower_server_view.xml
Normal file
10
addons/cetmix_tower_yaml/views/cx_tower_server_view.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<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" />
|
||||
</record>
|
||||
</odoo>
|
||||
10
addons/cetmix_tower_yaml/views/cx_tower_shortcut_view.xml
Normal file
10
addons/cetmix_tower_yaml/views/cx_tower_shortcut_view.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<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" />
|
||||
</record>
|
||||
</odoo>
|
||||
10
addons/cetmix_tower_yaml/views/cx_tower_tag_view.xml
Normal file
10
addons/cetmix_tower_yaml/views/cx_tower_tag_view.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?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" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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" />
|
||||
</record>
|
||||
</odoo>
|
||||
10
addons/cetmix_tower_yaml/views/cx_tower_variable_view.xml
Normal file
10
addons/cetmix_tower_yaml/views/cx_tower_variable_view.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?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" />
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,41 @@
|
||||
<odoo>
|
||||
<record id="view_yaml_manifest_author_tree" model="ir.ui.view">
|
||||
<field name="name">yaml.manifest.author.list</field>
|
||||
<field name="model">cx.tower.yaml.manifest.author</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name" />
|
||||
</list>
|
||||
</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="path">cetmix_tower_yaml_manifest_authors</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="target">current</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first YAML manifest author!
|
||||
</p>
|
||||
<p>
|
||||
YAML manifest authors represent organizations or users who are authors of YAML manifests.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,54 @@
|
||||
<odoo>
|
||||
<record id="view_yaml_manifest_template_tree" model="ir.ui.view">
|
||||
<field name="name">cx.tower.yaml.manifest.tmpl.list</field>
|
||||
<field name="model">cx.tower.yaml.manifest.tmpl</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<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" />
|
||||
</list>
|
||||
</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" invisible="license != 'custom'" />
|
||||
<field name="currency" 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="path">cetmix_tower_yaml_manifest_templates</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="view_id" ref="view_yaml_manifest_template_tree" />
|
||||
<field name="target">current</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first YAML manifest template!
|
||||
</p>
|
||||
<p>
|
||||
YAML manifest templates represent pre-defined YAML manifests with their metadata and configuration.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
33
addons/cetmix_tower_yaml/views/menuitems.xml
Normal file
33
addons/cetmix_tower_yaml/views/menuitems.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<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>
|
||||
4
addons/cetmix_tower_yaml/wizards/__init__.py
Normal file
4
addons/cetmix_tower_yaml/wizards/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
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
|
||||
367
addons/cetmix_tower_yaml/wizards/cx_tower_yaml_export_wiz.py
Normal file
367
addons/cetmix_tower_yaml/wizards/cx_tower_yaml_export_wiz.py
Normal file
@@ -0,0 +1,367 @@
|
||||
# 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 won’t 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)
|
||||
110
addons/cetmix_tower_yaml/wizards/cx_tower_yaml_export_wiz.xml
Normal file
110
addons/cetmix_tower_yaml/wizards/cx_tower_yaml_export_wiz.xml
Normal file
@@ -0,0 +1,110 @@
|
||||
<?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" invisible="not add_manifest">
|
||||
<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" required="add_manifest" />
|
||||
<field
|
||||
name="manifest_summary"
|
||||
required="manifest_name"
|
||||
placeholder="Short summary, 160 symbols max"
|
||||
/>
|
||||
<field
|
||||
name="manifest_author_ids"
|
||||
widget="many2many_tags"
|
||||
required="manifest_name"
|
||||
/>
|
||||
<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" required="manifest_name" />
|
||||
<field
|
||||
name="manifest_price"
|
||||
invisible="manifest_license != 'custom'"
|
||||
/>
|
||||
<field
|
||||
name="manifest_currency"
|
||||
invisible="manifest_price == 0"
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Description" invisible="not add_manifest">
|
||||
<field
|
||||
name="manifest_description"
|
||||
widget="text"
|
||||
nolabel="1"
|
||||
colspan="4"
|
||||
placeholder="Detailed description (optional)"
|
||||
/>
|
||||
</page>
|
||||
|
||||
<page
|
||||
string="License text"
|
||||
invisible="manifest_license != 'custom'"
|
||||
>
|
||||
<field
|
||||
name="manifest_license_text"
|
||||
widget="text"
|
||||
nolabel="1"
|
||||
colspan="4"
|
||||
placeholder="License text"
|
||||
required="manifest_license == 'custom'"
|
||||
/>
|
||||
</page>
|
||||
|
||||
<page string="Preview code" invisible="not preview_code">
|
||||
<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>
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,18 @@
|
||||
<?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>
|
||||
493
addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.py
Normal file
493
addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.py
Normal file
@@ -0,0 +1,493 @@
|
||||
# Copyright (C) 2024 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import copy
|
||||
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 _get_import_model(self, model_name, model_cache):
|
||||
"""Return cached model configured for YAML import."""
|
||||
model = model_cache.get(model_name)
|
||||
if model is None:
|
||||
model = self.env[f"cx.tower.{model_name.replace('_', '.')}"].with_context(
|
||||
skip_ssh_settings_check=(model_name == "server")
|
||||
)
|
||||
model_cache[model_name] = model
|
||||
return model
|
||||
|
||||
def _get_import_model_context(
|
||||
self,
|
||||
deferred_m2o_queue,
|
||||
deferred_x2m_queue,
|
||||
force_create_related_record,
|
||||
):
|
||||
"""Build context used while converting YAML values to record values."""
|
||||
return {
|
||||
"yaml_deferred_m2o_queue": deferred_m2o_queue,
|
||||
"yaml_deferred_x2m_queue": deferred_x2m_queue,
|
||||
"force_create_related_record": force_create_related_record,
|
||||
}
|
||||
|
||||
def _format_deferred_resolution_error(self, item):
|
||||
"""Format one unresolved deferred relation entry."""
|
||||
return _(
|
||||
"Record %(record_model)s '%(record_reference)s': field "
|
||||
"'%(field)s' could not resolve "
|
||||
"%(target_model)s '%(target_reference)s'",
|
||||
record_model=item["record_model"],
|
||||
record_reference=item["record_reference"],
|
||||
field=item["field_name"],
|
||||
target_model=item["target_model"],
|
||||
target_reference=item["target_reference"],
|
||||
)
|
||||
|
||||
def _apply_deferred_m2o_imports(self, deferred_queue):
|
||||
"""Resolve queued m2o imports after the main import pass."""
|
||||
unresolved = []
|
||||
for item in deferred_queue:
|
||||
record_id = item.get("record_id")
|
||||
if record_id:
|
||||
record = self.env[item["record_model"]].browse(record_id).exists()
|
||||
else:
|
||||
record = self.env[item["record_model"]].get_by_reference(
|
||||
item["record_reference"]
|
||||
)
|
||||
target = self.env[item["target_model"]].get_by_reference(
|
||||
item["target_reference"]
|
||||
)
|
||||
if not record or not target:
|
||||
unresolved.append(item)
|
||||
continue
|
||||
record.with_context(from_yaml=True).write({item["field_name"]: target.id})
|
||||
|
||||
if unresolved:
|
||||
details = "\n".join(
|
||||
self._format_deferred_resolution_error(item) for item in unresolved
|
||||
)
|
||||
raise ValidationError(
|
||||
_("Deferred relation resolution failed:\n%(details)s", details=details)
|
||||
)
|
||||
|
||||
def _format_deferred_x2m_resolution_error(self, item):
|
||||
"""Format one unresolved deferred x2m child entry."""
|
||||
return _(
|
||||
"Record '%(record)s': field '%(field)s' could not resolve "
|
||||
"%(target_model)s '%(target_reference)s'",
|
||||
record=item["record_reference"],
|
||||
field=item["field_name"],
|
||||
target_model=item["target_model"],
|
||||
target_reference=item["target_reference"],
|
||||
)
|
||||
|
||||
def _apply_deferred_x2m_imports(self, deferred_queue):
|
||||
"""Create queued x2m child records after the main import pass."""
|
||||
unresolved = []
|
||||
for item in deferred_queue:
|
||||
owner_model = self.env[item["record_model"]]
|
||||
record_id = item.get("record_id")
|
||||
if record_id:
|
||||
owner_record = owner_model.browse(record_id).exists()
|
||||
else:
|
||||
owner_record = owner_model.get_by_reference(item["record_reference"])
|
||||
target_record = self.env[item["target_model"]].get_by_reference(
|
||||
item["target_reference"]
|
||||
)
|
||||
if not owner_record or not target_record:
|
||||
unresolved.append(item)
|
||||
continue
|
||||
|
||||
owner_field = owner_model._fields[item["field_name"]]
|
||||
inverse_name = owner_field.inverse_name
|
||||
child_model = self.env[item["child_model"]]
|
||||
child_values = child_model.with_context(
|
||||
yaml_deferred_m2o_queue=[],
|
||||
yaml_deferred_x2m_queue=[],
|
||||
force_create_related_record=False,
|
||||
)._post_process_yaml_dict_values(copy.deepcopy(item["values"]))
|
||||
child_values[inverse_name] = owner_record.id
|
||||
if not child_values.get(item["deferred_field"]):
|
||||
unresolved.append(item)
|
||||
continue
|
||||
|
||||
# Guard against creating a duplicate child when the same
|
||||
# (owner, target) pair was already inserted — e.g. because a
|
||||
# duplicate YAML entry queued the same item twice, or the child
|
||||
# was created by a first-pass write after the queue was built.
|
||||
existing = child_model.search(
|
||||
[
|
||||
(inverse_name, "=", owner_record.id),
|
||||
(item["deferred_field"], "=", child_values[item["deferred_field"]]),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
child_model.with_context(from_yaml=True).create(child_values)
|
||||
|
||||
if unresolved:
|
||||
details = "\n".join(
|
||||
self._format_deferred_x2m_resolution_error(item) for item in unresolved
|
||||
)
|
||||
raise ValidationError(
|
||||
_("Deferred relation resolution failed:\n%(details)s", details=details)
|
||||
)
|
||||
|
||||
def _tag_deferred_queue_items(self, queue, start, record_id, owner_model_name):
|
||||
"""Stamp deferred queue items that belong to *owner_model_name*.
|
||||
|
||||
Nested imports queue deferred relations for inner models (e.g. plan lines)
|
||||
while creating a top-level record (e.g. jet template). Only items whose
|
||||
``record_model`` matches the record that was just created must receive
|
||||
``record_id``; others keep reference-based resolution in the apply pass.
|
||||
|
||||
Args:
|
||||
queue (list): The deferred import queue (m2o or x2m).
|
||||
start (int): Index of the first item belonging to the current batch.
|
||||
record_id (int): Database ID of the newly created/updated owner record.
|
||||
owner_model_name (str): Technical name of the model *record_id* belongs to.
|
||||
"""
|
||||
for item in queue[start:]:
|
||||
if item["record_model"] == owner_model_name:
|
||||
item["record_id"] = record_id
|
||||
|
||||
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 = []
|
||||
deferred_m2o_queue = []
|
||||
deferred_x2m_queue = []
|
||||
|
||||
with self.env.cr.savepoint():
|
||||
# Process each record
|
||||
for record in records:
|
||||
m2o_start = len(deferred_m2o_queue)
|
||||
x2m_start = len(deferred_x2m_queue)
|
||||
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 = self._get_import_model(model_name, model_cache)
|
||||
|
||||
# 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
|
||||
if self.if_record_exists == "update" and odoo_record:
|
||||
try:
|
||||
record_values = model.with_context(
|
||||
**self._get_import_model_context(
|
||||
deferred_m2o_queue,
|
||||
deferred_x2m_queue,
|
||||
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
|
||||
self._tag_deferred_queue_items(
|
||||
deferred_m2o_queue,
|
||||
m2o_start,
|
||||
odoo_record.id,
|
||||
model._name,
|
||||
)
|
||||
self._tag_deferred_queue_items(
|
||||
deferred_x2m_queue,
|
||||
x2m_start,
|
||||
odoo_record.id,
|
||||
model._name,
|
||||
)
|
||||
_logger.info(
|
||||
"Updated record '%s' in model '%s'",
|
||||
record_reference,
|
||||
model_name,
|
||||
)
|
||||
continue
|
||||
|
||||
# Or create a new record
|
||||
record_values = model.with_context(
|
||||
**self._get_import_model_context(
|
||||
deferred_m2o_queue,
|
||||
deferred_x2m_queue,
|
||||
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
|
||||
self._tag_deferred_queue_items(
|
||||
deferred_m2o_queue, m2o_start, odoo_record.id, model._name
|
||||
)
|
||||
self._tag_deferred_queue_items(
|
||||
deferred_x2m_queue, x2m_start, odoo_record.id, model._name
|
||||
)
|
||||
_logger.info(
|
||||
"Created record '%s' in model '%s'",
|
||||
record_reference,
|
||||
model_name,
|
||||
)
|
||||
|
||||
self._apply_deferred_m2o_imports(deferred_m2o_queue)
|
||||
self._apply_deferred_x2m_imports(deferred_x2m_queue)
|
||||
|
||||
# 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)
|
||||
135
addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.xml
Normal file
135
addons/cetmix_tower_yaml/wizards/cx_tower_yaml_import_wiz.xml
Normal file
@@ -0,0 +1,135 @@
|
||||
<?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" invisible="not secret_list">
|
||||
<field name="secret_list" nolabel="1" />
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<field name="preview_code" widget="boolean_toggle" />
|
||||
</group>
|
||||
|
||||
<group invisible="not manifest_name">
|
||||
<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"
|
||||
invisible="not manifest_website"
|
||||
/>
|
||||
</group>
|
||||
<group string="License and pricing">
|
||||
<field name="manifest_license" string="License" />
|
||||
<field
|
||||
name="manifest_price"
|
||||
string="Price"
|
||||
invisible="not manifest_price"
|
||||
/>
|
||||
<field
|
||||
name="manifest_currency"
|
||||
string="Currency"
|
||||
invisible="not manifest_currency"
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Description" invisible="not manifest_description">
|
||||
<field
|
||||
name="manifest_description"
|
||||
widget="text"
|
||||
nolabel="1"
|
||||
colspan="4"
|
||||
/>
|
||||
</page>
|
||||
|
||||
<page string="License text" invisible="not manifest_license_text">
|
||||
<field
|
||||
name="manifest_license_text"
|
||||
widget="text"
|
||||
nolabel="1"
|
||||
colspan="4"
|
||||
/>
|
||||
</page>
|
||||
|
||||
<page string="Code preview" invisible="not preview_code">
|
||||
<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"
|
||||
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"
|
||||
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"
|
||||
invisible="if_record_exists != 'update'"
|
||||
confirm="This may overwrite existing records. Proceed?"
|
||||
/>
|
||||
<button
|
||||
string="Import"
|
||||
type="object"
|
||||
name="action_import_yaml"
|
||||
class="oe_highlight"
|
||||
invisible="if_record_exists == 'update'"
|
||||
/>
|
||||
<button string="Close" special="cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,137 @@
|
||||
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"].sudo()
|
||||
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
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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"
|
||||
invisible="not yaml_file"
|
||||
/>
|
||||
<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>
|
||||
123
addons/cx_web_refresh_from_backend/README.rst
Normal file
123
addons/cx_web_refresh_from_backend/README.rst
Normal file
@@ -0,0 +1,123 @@
|
||||
========================
|
||||
Web Refresh From Backend
|
||||
========================
|
||||
|
||||
..
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:199e0da56a7d94568d062706d1f34ac6b38310034c25f5840e2631722e9d9f65
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |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-LGPL--3-blue.png
|
||||
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
|
||||
:alt: License: LGPL-3
|
||||
.. |badge3| image:: https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github
|
||||
:target: https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend
|
||||
:alt: cetmix/cetmix-tower
|
||||
|
||||
|badge1| |badge2| |badge3|
|
||||
|
||||
Refresh UI views from backend
|
||||
=============================
|
||||
|
||||
This is a **technical module** that allows triggering a **UI reload**
|
||||
from the backend. It enables triggering the reload action for selected
|
||||
users and record IDs.
|
||||
|
||||
--------------
|
||||
|
||||
🔧 Helper Function: ``reload_views``
|
||||
------------------------------------
|
||||
|
||||
A special helper function ``reload_views`` is added to the ``res.users``
|
||||
model.
|
||||
|
||||
**Arguments**
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
+----------------+--------------------------+--------------------------+
|
||||
| Argument | Type | Description |
|
||||
+================+==========================+==========================+
|
||||
| **model** | ``Char`` | Model name, e.g. |
|
||||
| | | ``'res.partner'`` |
|
||||
+----------------+--------------------------+--------------------------+
|
||||
| **view_types** | ``List of Char`` | View types to reload, |
|
||||
| | *(optional)* | e.g. |
|
||||
| | | ``["form", "kanban"]``. |
|
||||
| | | Leave blank to reload |
|
||||
| | | all views. |
|
||||
+----------------+--------------------------+--------------------------+
|
||||
| **rec_ids** | ``List of Integer`` | The view will be |
|
||||
| | *(optional)* | reloaded only if a |
|
||||
| | | record with an ID from |
|
||||
| | | this list is present in |
|
||||
| | | the view. |
|
||||
+----------------+--------------------------+--------------------------+
|
||||
|
||||
--------------
|
||||
|
||||
⚠️ Important Notes
|
||||
------------------
|
||||
|
||||
Use this function **wisely**.
|
||||
|
||||
When reloading **form views**, be aware that if a user is currently
|
||||
editing a record, **their unsaved updates may be lost** when the form
|
||||
reloads from the server (no confirmation dialog is shown).
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
🧩 Example Usage
|
||||
----------------
|
||||
|
||||
Below is a code snippet showing how to use the ``reload_views`` helper
|
||||
function.
|
||||
|
||||
.. code:: python
|
||||
|
||||
# Reload the kanban and form views for all salespeople when an opportunity is won
|
||||
# Will reload views only if the current opportunity is being displayed
|
||||
|
||||
group_id = self.env.ref("sales_team.group_sale_salesman").id
|
||||
users_to_reload = self.env["res.users"].search([("groups_id", "in", [group_id])])
|
||||
users_to_reload.reload_views(
|
||||
model="crm.lead",
|
||||
view_types=["kanban", "form"],
|
||||
rec_ids=[self.ids],
|
||||
)
|
||||
|
||||
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:%20cx_web_refresh_from_backend%0Aversion:%2018.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/18.0/cx_web_refresh_from_backend>`_ project on GitHub.
|
||||
|
||||
You are welcome to contribute.
|
||||
4
addons/cx_web_refresh_from_backend/__init__.py
Normal file
4
addons/cx_web_refresh_from_backend/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from . import models
|
||||
30
addons/cx_web_refresh_from_backend/__manifest__.py
Normal file
30
addons/cx_web_refresh_from_backend/__manifest__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
# Mail is required: its ir.websocket override subscribes the partner channel to the
|
||||
# bus, so users receive web.refresh_view notifications.
|
||||
|
||||
{
|
||||
"name": "Web Refresh From Backend",
|
||||
"summary": "Refresh frontend views from backend",
|
||||
"version": "18.0.1.0.0",
|
||||
"category": "Web",
|
||||
"license": "LGPL-3",
|
||||
"author": "Cetmix",
|
||||
"website": "https://tower.cetmix.com",
|
||||
"images": ["static/description/banner.png"],
|
||||
"depends": ["mail"],
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"cx_web_refresh_from_backend/static/src/views/utils/get_loaded_record_ids.esm.js",
|
||||
"cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js",
|
||||
"cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm.js",
|
||||
"cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm.js",
|
||||
],
|
||||
"web.qunit_suite_tests": [
|
||||
"cx_web_refresh_from_backend/static/tests/refresh_from_backend_tests.esm.js",
|
||||
],
|
||||
},
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * cx_web_refresh_from_backend
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 18.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm.js:0
|
||||
msgid "Could not reload form. %(message)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm.js:0
|
||||
msgid "Could not reload kanban. %(message)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "Could not reload list. %(message)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "Could not save record. %(message)s"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "List is being refreshed from backend"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "Save & Refresh"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#: model:ir.model,name:cx_web_refresh_from_backend.model_res_users
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
#. module: cx_web_refresh_from_backend
|
||||
#. odoo-javascript
|
||||
#: code:addons/cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm.js:0
|
||||
msgid "You have unsaved edits. Save them before refreshing?"
|
||||
msgstr ""
|
||||
4
addons/cx_web_refresh_from_backend/models/__init__.py
Normal file
4
addons/cx_web_refresh_from_backend/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from . import res_users
|
||||
50
addons/cx_web_refresh_from_backend/models/res_users.py
Normal file
50
addons/cx_web_refresh_from_backend/models/res_users.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = "res.users"
|
||||
|
||||
def reload_views(self, model, view_types=None, rec_ids=None):
|
||||
"""
|
||||
Trigger UI reload for selected users and record IDs.
|
||||
|
||||
This method allows to reload specific views from the backend.
|
||||
Be aware that when reloading form views, if a user is currently
|
||||
doing some updates, those updates may be lost when the form reloads
|
||||
(no confirmation dialog on the client).
|
||||
|
||||
:param model: str, Model name (e.g., 'res.partner')
|
||||
:param view_types: list of str, optional, View types to reload
|
||||
(e.g., ['form', 'kanban']). Leave blank to reload all views.
|
||||
:param rec_ids: list of int, optional, View will be reloaded only if a record
|
||||
with id from the list is present in the view.
|
||||
|
||||
Example usage:
|
||||
# Reload the kanban and form views for all salespeople
|
||||
# when an opportunity is won.
|
||||
# Will reload views only if the current opportunity is being displayed
|
||||
group_id = self.env.ref("sales_team.group_sale_salesman").id
|
||||
users_to_reload = self.env["res.users"].search(
|
||||
[("groups_id", "in", [group_id])]
|
||||
)
|
||||
users_to_reload.reload_views(
|
||||
model="crm.lead",
|
||||
view_types=["kanban", "form"],
|
||||
rec_ids=[self.ids]
|
||||
)
|
||||
"""
|
||||
|
||||
# Prepare the message payload
|
||||
bus_message = {
|
||||
"model": model,
|
||||
"view_types": view_types or [],
|
||||
"rec_ids": rec_ids or [],
|
||||
}
|
||||
|
||||
# Send one notification per user's partner in deterministic order.
|
||||
bus_bus = self.env["bus.bus"]
|
||||
for user in self.sorted("id"):
|
||||
bus_bus._sendone(user.partner_id, "web.refresh_view", bus_message)
|
||||
3
addons/cx_web_refresh_from_backend/pyproject.toml
Normal file
3
addons/cx_web_refresh_from_backend/pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["whool"]
|
||||
build-backend = "whool.buildapi"
|
||||
28
addons/cx_web_refresh_from_backend/readme/DESCRIPTION.md
Normal file
28
addons/cx_web_refresh_from_backend/readme/DESCRIPTION.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Refresh UI views from backend
|
||||
|
||||
This is a **technical module** that allows triggering a **UI reload** from the backend.
|
||||
It enables triggering the reload action for selected users and record IDs.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Helper Function: `reload_views`
|
||||
|
||||
A special helper function `reload_views` is added to the `res.users` model.
|
||||
|
||||
### **Arguments**
|
||||
|
||||
| Argument | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| **model** | `Char` | Model name, e.g. `'res.partner'` |
|
||||
| **view_types** | `List of Char` *(optional)* | View types to reload, e.g. `["form", "kanban"]`. Leave blank to reload all views. |
|
||||
| **rec_ids** | `List of Integer` *(optional)* | The view will be reloaded only if a record with an ID from this list is present in the view. |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Important Notes
|
||||
|
||||
Use this function **wisely**.
|
||||
|
||||
When reloading **form views**, be aware that if a user is currently editing a record,
|
||||
**their unsaved updates may be lost** when the form reloads from the server (no confirmation
|
||||
dialog is shown).
|
||||
16
addons/cx_web_refresh_from_backend/readme/USAGE.md
Normal file
16
addons/cx_web_refresh_from_backend/readme/USAGE.md
Normal file
@@ -0,0 +1,16 @@
|
||||
## 🧩 Example Usage
|
||||
|
||||
Below is a code snippet showing how to use the `reload_views` helper function.
|
||||
|
||||
```python
|
||||
# Reload the kanban and form views for all salespeople when an opportunity is won
|
||||
# Will reload views only if the current opportunity is being displayed
|
||||
|
||||
group_id = self.env.ref("sales_team.group_sale_salesman").id
|
||||
users_to_reload = self.env["res.users"].search([("groups_id", "in", [group_id])])
|
||||
users_to_reload.reload_views(
|
||||
model="crm.lead",
|
||||
view_types=["kanban", "form"],
|
||||
rec_ids=[self.ids],
|
||||
)
|
||||
```
|
||||
BIN
addons/cx_web_refresh_from_backend/static/description/banner.png
Normal file
BIN
addons/cx_web_refresh_from_backend/static/description/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
BIN
addons/cx_web_refresh_from_backend/static/description/icon.png
Normal file
BIN
addons/cx_web_refresh_from_backend/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
479
addons/cx_web_refresh_from_backend/static/description/index.html
Normal file
479
addons/cx_web_refresh_from_backend/static/description/index.html
Normal file
@@ -0,0 +1,479 @@
|
||||
<!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>Web Refresh From Backend</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="web-refresh-from-backend">
|
||||
<h1 class="title">Web Refresh From Backend</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! source digest: sha256:199e0da56a7d94568d062706d1f34ac6b38310034c25f5840e2631722e9d9f65
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<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/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/license-LGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend"><img alt="cetmix/cetmix-tower" src="https://img.shields.io/badge/github-cetmix%2Fcetmix--tower-lightgray.png?logo=github" /></a></p>
|
||||
<div class="section" id="refresh-ui-views-from-backend">
|
||||
<h1>Refresh UI views from backend</h1>
|
||||
<p>This is a <strong>technical module</strong> that allows triggering a <strong>UI reload</strong>
|
||||
from the backend. It enables triggering the reload action for selected
|
||||
users and record IDs.</p>
|
||||
<hr class="docutils" />
|
||||
<div class="section" id="helper-function-reload-views">
|
||||
<h2>🔧 Helper Function: <tt class="docutils literal">reload_views</tt></h2>
|
||||
<p>A special helper function <tt class="docutils literal">reload_views</tt> is added to the <tt class="docutils literal">res.users</tt>
|
||||
model.</p>
|
||||
<div class="section" id="arguments">
|
||||
<h3><strong>Arguments</strong></h3>
|
||||
<table border="1" class="docutils">
|
||||
<colgroup>
|
||||
<col width="24%" />
|
||||
<col width="38%" />
|
||||
<col width="38%" />
|
||||
</colgroup>
|
||||
<thead valign="bottom">
|
||||
<tr><th class="head">Argument</th>
|
||||
<th class="head">Type</th>
|
||||
<th class="head">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody valign="top">
|
||||
<tr><td><strong>model</strong></td>
|
||||
<td><tt class="docutils literal">Char</tt></td>
|
||||
<td>Model name, e.g.
|
||||
<tt class="docutils literal">'res.partner'</tt></td>
|
||||
</tr>
|
||||
<tr><td><strong>view_types</strong></td>
|
||||
<td><tt class="docutils literal">List of Char</tt>
|
||||
<em>(optional)</em></td>
|
||||
<td>View types to reload,
|
||||
e.g.
|
||||
<tt class="docutils literal">["form", "kanban"]</tt>.
|
||||
Leave blank to reload
|
||||
all views.</td>
|
||||
</tr>
|
||||
<tr><td><strong>rec_ids</strong></td>
|
||||
<td><tt class="docutils literal">List of Integer</tt>
|
||||
<em>(optional)</em></td>
|
||||
<td>The view will be
|
||||
reloaded only if a
|
||||
record with an ID from
|
||||
this list is present in
|
||||
the view.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="docutils" />
|
||||
<div class="section" id="important-notes">
|
||||
<h2>⚠️ Important Notes</h2>
|
||||
<p>Use this function <strong>wisely</strong>.</p>
|
||||
<p>When reloading <strong>form views</strong>, be aware that if a user is currently
|
||||
editing a record, <strong>their unsaved updates may be lost</strong> when the form
|
||||
reloads from the server (no confirmation dialog is shown).</p>
|
||||
<p><strong>Table of contents</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="usage">
|
||||
<h1>Usage</h1>
|
||||
<div class="section" id="example-usage">
|
||||
<h2>🧩 Example Usage</h2>
|
||||
<p>Below is a code snippet showing how to use the <tt class="docutils literal">reload_views</tt> helper
|
||||
function.</p>
|
||||
<pre class="code python literal-block">
|
||||
<span class="c1"># Reload the kanban and form views for all salespeople when an opportunity is won</span><span class="w">
|
||||
</span><span class="c1"># Will reload views only if the current opportunity is being displayed</span><span class="w">
|
||||
|
||||
</span><span class="n">group_id</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">env</span><span class="o">.</span><span class="n">ref</span><span class="p">(</span><span class="s2">"sales_team.group_sale_salesman"</span><span class="p">)</span><span class="o">.</span><span class="n">id</span><span class="w">
|
||||
</span><span class="n">users_to_reload</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">env</span><span class="p">[</span><span class="s2">"res.users"</span><span class="p">]</span><span class="o">.</span><span class="n">search</span><span class="p">([(</span><span class="s2">"groups_id"</span><span class="p">,</span> <span class="s2">"in"</span><span class="p">,</span> <span class="p">[</span><span class="n">group_id</span><span class="p">])])</span><span class="w">
|
||||
</span><span class="n">users_to_reload</span><span class="o">.</span><span class="n">reload_views</span><span class="p">(</span><span class="w">
|
||||
</span> <span class="n">model</span><span class="o">=</span><span class="s2">"crm.lead"</span><span class="p">,</span><span class="w">
|
||||
</span> <span class="n">view_types</span><span class="o">=</span><span class="p">[</span><span class="s2">"kanban"</span><span class="p">,</span> <span class="s2">"form"</span><span class="p">],</span><span class="w">
|
||||
</span> <span class="n">rec_ids</span><span class="o">=</span><span class="p">[</span><span class="bp">self</span><span class="o">.</span><span class="n">ids</span><span class="p">],</span><span class="w">
|
||||
</span><span class="p">)</span>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h1>Bug Tracker</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:%20cx_web_refresh_from_backend%0Aversion:%2018.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>Credits</h1>
|
||||
<div class="section" id="authors">
|
||||
<h2>Authors</h2>
|
||||
<ul class="simple">
|
||||
<li>Cetmix</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2>Maintainers</h2>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/cetmix/cetmix-tower/tree/18.0/cx_web_refresh_from_backend">cetmix/cetmix-tower</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,161 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {FormController} from "@web/views/form/form_controller";
|
||||
import {isResIdInRecIds} from "../utils/get_loaded_record_ids.esm";
|
||||
import {onWillUnmount} from "@odoo/owl";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import {_t} from "@web/core/l10n/translation";
|
||||
|
||||
patch(FormController.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
|
||||
// Bus_service is async; useService("bus_service") breaks (SERVICES_METADATA).
|
||||
this.busService = this.env.services.bus_service;
|
||||
this.notificationService = useService("notification");
|
||||
|
||||
this._lastLocalSave = null;
|
||||
this._isRefreshInFlight = false;
|
||||
this._hasRefreshQueued = false;
|
||||
|
||||
this._boundBusHandler = this._onWebRefreshNotification.bind(this);
|
||||
this.busService.subscribe("web.refresh_view", this._boundBusHandler);
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this.busService && this._boundBusHandler) {
|
||||
this.busService.unsubscribe("web.refresh_view", this._boundBusHandler);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a web.refresh_view bus notification for this form.
|
||||
* Called once per notification; coalesces concurrent refreshes via _queueRefresh.
|
||||
*
|
||||
* @param {Object} payload - Notification payload {model, view_types, rec_ids}
|
||||
*/
|
||||
async _onWebRefreshNotification(payload) {
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
if (this._shouldRefreshView(payload)) {
|
||||
await this._queueRefresh("refreshForm");
|
||||
}
|
||||
},
|
||||
|
||||
async _queueRefresh(methodName) {
|
||||
if (this._isRefreshInFlight) {
|
||||
this._hasRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
this._isRefreshInFlight = true;
|
||||
try {
|
||||
do {
|
||||
this._hasRefreshQueued = false;
|
||||
await this[methodName]();
|
||||
} while (this._hasRefreshQueued);
|
||||
} finally {
|
||||
this._isRefreshInFlight = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether a refresh notification is relevant to this form.
|
||||
*
|
||||
* Returns true when all of the following hold:
|
||||
* - model matches current form model
|
||||
* - requested view types include "form" (or none specified)
|
||||
* - record id matches current record (or none specified)
|
||||
* - form is not inside a dialog / wizard
|
||||
*
|
||||
* @param {Object} payload - Notification payload
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
_shouldRefreshView(payload) {
|
||||
const {model, view_types = [], rec_ids = []} = payload;
|
||||
|
||||
if (this.props.resModel !== model) {
|
||||
return false;
|
||||
}
|
||||
if (view_types.length > 0 && !view_types.includes("form")) {
|
||||
return false;
|
||||
}
|
||||
const currentResId = this.model && this.model.root && this.model.root.resId;
|
||||
if (rec_ids.length > 0 && !isResIdInRecIds(currentResId, rec_ids)) {
|
||||
return false;
|
||||
}
|
||||
// Skip refresh when form is in a dialog or when a wizard is on top
|
||||
// of the stack. Refreshing in that context can leave wizard/confirmation
|
||||
// dialogs stuck open (e.g. confirm="..." in wizard view).
|
||||
if (this.env.inDialog) {
|
||||
return false;
|
||||
}
|
||||
const currentController = this.actionService.currentController;
|
||||
const currentAction = currentController && currentController.action;
|
||||
if (currentAction && currentAction.target === "new") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the form with actual data from server.
|
||||
*
|
||||
* Reloads without confirmation even when the record is dirty (client changes
|
||||
* may be discarded). Dialog / wizard forms are filtered out in
|
||||
* _shouldRefreshView().
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshForm() {
|
||||
if (this._lastLocalSave && Date.now() - this._lastLocalSave < 2500) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = this.model.root;
|
||||
|
||||
try {
|
||||
await record.load();
|
||||
} catch (error) {
|
||||
this.notificationService.add(this._getRefreshErrorMessage(error), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.model && this.model.root) {
|
||||
this.render(true);
|
||||
}
|
||||
},
|
||||
|
||||
_getRefreshErrorMessage(error) {
|
||||
const message =
|
||||
(error && error.data && error.data.message) ||
|
||||
(error && error.message) ||
|
||||
String(error);
|
||||
return _t("Could not reload form. %(message)s", {message});
|
||||
},
|
||||
|
||||
/**
|
||||
* Override of save button handler.
|
||||
*
|
||||
* After a successful save, stores a timestamp to avoid immediate auto-refresh
|
||||
* triggered by our own write (bus notification). Failed saves leave the
|
||||
* timestamp unchanged so refresh suppression does not apply incorrectly.
|
||||
*
|
||||
* @param {Object} params - Save options
|
||||
* @returns {Promise<Boolean|undefined>} Result of the core save (truthy when save succeeded)
|
||||
*/
|
||||
async saveButtonClicked(params) {
|
||||
const result = await super.saveButtonClicked(params);
|
||||
if (result) {
|
||||
this._lastLocalSave = Date.now();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
getLoadedRecordIds,
|
||||
hasAnyLoadedIdInRecIds,
|
||||
} from "../utils/get_loaded_record_ids.esm";
|
||||
import {KanbanController} from "@web/views/kanban/kanban_controller";
|
||||
import {onWillUnmount} from "@odoo/owl";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import {_t} from "@web/core/l10n/translation";
|
||||
|
||||
patch(KanbanController.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
// Bus_service is async; useService("bus_service") breaks (SERVICES_METADATA).
|
||||
this.busService = this.env.services.bus_service;
|
||||
this.notificationService = useService("notification");
|
||||
this._isRefreshInFlight = false;
|
||||
this._hasRefreshQueued = false;
|
||||
|
||||
this._boundBusHandler = this._onWebRefreshNotification.bind(this);
|
||||
this.busService.subscribe("web.refresh_view", this._boundBusHandler);
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this.busService && this._boundBusHandler) {
|
||||
this.busService.unsubscribe("web.refresh_view", this._boundBusHandler);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a web.refresh_view bus notification for this kanban.
|
||||
* Called once per notification; coalesces concurrent refreshes via _queueRefresh.
|
||||
*
|
||||
* @param {Object} payload - Notification payload {model, view_types, rec_ids}
|
||||
*/
|
||||
async _onWebRefreshNotification(payload) {
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
if (this._shouldRefreshView(payload)) {
|
||||
await this._queueRefresh("refreshList");
|
||||
}
|
||||
},
|
||||
|
||||
async _queueRefresh(methodName) {
|
||||
if (this._isRefreshInFlight) {
|
||||
this._hasRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
this._isRefreshInFlight = true;
|
||||
try {
|
||||
do {
|
||||
this._hasRefreshQueued = false;
|
||||
await this[methodName]();
|
||||
} while (this._hasRefreshQueued);
|
||||
} finally {
|
||||
this._isRefreshInFlight = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether a refresh notification is relevant to this kanban.
|
||||
*
|
||||
* Returns true when all of the following hold:
|
||||
* - model matches current kanban model
|
||||
* - requested view types include "kanban" (or none specified)
|
||||
* - at least one loaded record id is in rec_ids (or none specified)
|
||||
*
|
||||
* @param {Object} payload - Notification payload
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
_shouldRefreshView(payload) {
|
||||
const {model, view_types = [], rec_ids = []} = payload;
|
||||
|
||||
if (this.props.resModel !== model) {
|
||||
return false;
|
||||
}
|
||||
if (view_types.length > 0 && !view_types.includes("kanban")) {
|
||||
return false;
|
||||
}
|
||||
if (rec_ids.length > 0) {
|
||||
const loadedIds = getLoadedRecordIds(this.model.root);
|
||||
if (!hasAnyLoadedIdInRecIds(loadedIds, rec_ids)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the kanban with actual data from server.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshList() {
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.model.root;
|
||||
|
||||
try {
|
||||
await list.load();
|
||||
} catch (error) {
|
||||
this.notificationService.add(this._getRefreshErrorMessage(error), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.model && this.model.root) {
|
||||
this.render(true);
|
||||
}
|
||||
},
|
||||
|
||||
_getRefreshErrorMessage(error) {
|
||||
const message =
|
||||
(error && error.data && error.data.message) ||
|
||||
(error && error.message) ||
|
||||
String(error);
|
||||
return _t("Could not reload kanban. %(message)s", {message});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import {
|
||||
getLoadedRecordIds,
|
||||
hasAnyLoadedIdInRecIds,
|
||||
} from "../utils/get_loaded_record_ids.esm";
|
||||
import {ConfirmationDialog} from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import {ListController} from "@web/views/list/list_controller";
|
||||
import {onWillUnmount} from "@odoo/owl";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
import {useService} from "@web/core/utils/hooks";
|
||||
import {_t} from "@web/core/l10n/translation";
|
||||
|
||||
patch(ListController.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
// Bus_service is async; useService("bus_service") breaks (SERVICES_METADATA).
|
||||
this.busService = this.env.services.bus_service;
|
||||
this.notificationService = useService("notification");
|
||||
this._isRefreshInFlight = false;
|
||||
this._hasRefreshQueued = false;
|
||||
|
||||
this._boundBusHandler = this._onWebRefreshNotification.bind(this);
|
||||
this.busService.subscribe("web.refresh_view", this._boundBusHandler);
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this.busService && this._boundBusHandler) {
|
||||
this.busService.unsubscribe("web.refresh_view", this._boundBusHandler);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a web.refresh_view bus notification for this list.
|
||||
* Called once per notification; coalesces concurrent refreshes via _queueRefresh.
|
||||
*
|
||||
* @param {Object} payload - Notification payload {model, view_types, rec_ids}
|
||||
*/
|
||||
async _onWebRefreshNotification(payload) {
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
if (this._shouldRefreshView(payload)) {
|
||||
await this._queueRefresh("refreshList");
|
||||
}
|
||||
},
|
||||
|
||||
async _queueRefresh(methodName) {
|
||||
if (this._isRefreshInFlight) {
|
||||
this._hasRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
this._isRefreshInFlight = true;
|
||||
try {
|
||||
do {
|
||||
this._hasRefreshQueued = false;
|
||||
await this[methodName]();
|
||||
} while (this._hasRefreshQueued);
|
||||
} finally {
|
||||
this._isRefreshInFlight = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether a refresh notification is relevant to this list.
|
||||
*
|
||||
* Returns true when all of the following hold:
|
||||
* - model matches current list model
|
||||
* - requested view types include "list" or "tree" (or none specified)
|
||||
* - at least one loaded record id is in rec_ids (or none specified)
|
||||
*
|
||||
* @param {Object} payload - Notification payload
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
_shouldRefreshView(payload) {
|
||||
const {model, view_types = [], rec_ids = []} = payload;
|
||||
|
||||
if (this.props.resModel !== model) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
view_types.length > 0 &&
|
||||
!view_types.includes("list") &&
|
||||
!view_types.includes("tree")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (rec_ids.length > 0) {
|
||||
const loadedIds = getLoadedRecordIds(this.model.root);
|
||||
if (!hasAnyLoadedIdInRecIds(loadedIds, rec_ids)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the list with actual data from server.
|
||||
* If there is an edited record, asks the user to save or cancel.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshList() {
|
||||
if (!this.model || !this.model.root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.model.root;
|
||||
|
||||
if (list.editedRecord) {
|
||||
const confirmed = await this._confirmListRefresh();
|
||||
|
||||
if (!confirmed) {
|
||||
// User declined: drop coalesced refreshes queued during the dialog.
|
||||
this._hasRefreshQueued = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await list.editedRecord.save();
|
||||
} catch (error) {
|
||||
this.notificationService.add(this._getSaveErrorMessage(error), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await list.load();
|
||||
} catch (error) {
|
||||
this.notificationService.add(this._getReloadErrorMessage(error), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.model && this.model.root) {
|
||||
this.render(true);
|
||||
}
|
||||
},
|
||||
|
||||
async _confirmListRefresh() {
|
||||
return await new Promise((resolve) => {
|
||||
this.dialogService.add(ConfirmationDialog, {
|
||||
title: _t("List is being refreshed from backend"),
|
||||
body: _t("You have unsaved edits. Save them before refreshing?"),
|
||||
confirm: () => resolve(true),
|
||||
cancel: () => resolve(false),
|
||||
confirmLabel: _t("Save & Refresh"),
|
||||
cancelLabel: _t("Cancel"),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_getSaveErrorMessage(error) {
|
||||
const message =
|
||||
(error && error.data && error.data.message) ||
|
||||
(error && error.message) ||
|
||||
String(error);
|
||||
return _t("Could not save record. %(message)s", {message});
|
||||
},
|
||||
|
||||
_getReloadErrorMessage(error) {
|
||||
const message =
|
||||
(error && error.data && error.data.message) ||
|
||||
(error && error.message) ||
|
||||
String(error);
|
||||
return _t("Could not reload list. %(message)s", {message});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/** @odoo-module */
|
||||
|
||||
/**
|
||||
* Get IDs of records currently loaded in list-like root models.
|
||||
* Supports both plain and grouped datasets.
|
||||
*
|
||||
* @param {Object} root - View root model (list/kanban)
|
||||
* @returns {Array<Number>}
|
||||
*/
|
||||
export function getLoadedRecordIds(root) {
|
||||
if (root.isGrouped) {
|
||||
const recordIds = [];
|
||||
const collectIds = (groups) => {
|
||||
for (const group of groups) {
|
||||
if (group.list && group.list.records) {
|
||||
recordIds.push(...group.list.records.map((record) => record.resId));
|
||||
}
|
||||
if (group.groups) {
|
||||
collectIds(group.groups);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectIds(root.groups);
|
||||
return recordIds;
|
||||
}
|
||||
return root.records.map((record) => record.resId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any loaded record id is present in the notification id list.
|
||||
* Uses a Set for O(n + m) membership checks instead of O(n * m) with includes.
|
||||
*
|
||||
* @param {Number[]} loadedIds - IDs currently visible in the view
|
||||
* @param {Number[]} rec_ids - IDs from the bus payload
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function hasAnyLoadedIdInRecIds(loadedIds, rec_ids) {
|
||||
const recIdSet = new Set(rec_ids);
|
||||
return loadedIds.some((id) => recIdSet.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a single record id is in the notification id list.
|
||||
* Uses a Set for O(m) build + O(1) lookup vs repeated includes.
|
||||
*
|
||||
* @param {Number|undefined|false} resId - Current record id (e.g. form root)
|
||||
* @param {Number[]} rec_ids - IDs from the bus payload
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function isResIdInRecIds(resId, rec_ids) {
|
||||
if (!resId) {
|
||||
return false;
|
||||
}
|
||||
return new Set(rec_ids).has(resId);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/** @odoo-module */
|
||||
/* global QUnit */
|
||||
|
||||
import "cx_web_refresh_from_backend/static/src/views/form/form_controller_patch.esm";
|
||||
import "cx_web_refresh_from_backend/static/src/views/kanban/kanban_controller_patch.esm";
|
||||
import "cx_web_refresh_from_backend/static/src/views/list/list_controller_patch.esm";
|
||||
|
||||
import {
|
||||
editInput,
|
||||
getFixture,
|
||||
makeDeferred,
|
||||
nextTick,
|
||||
} from "@web/../tests/helpers/utils";
|
||||
import {
|
||||
makeView,
|
||||
makeViewInDialog,
|
||||
setupViewRegistries,
|
||||
} from "@web/../tests/views/helpers";
|
||||
|
||||
let serverData = null;
|
||||
let target = null;
|
||||
|
||||
/**
|
||||
* Simulate a web.refresh_view notification on the patched controller.
|
||||
*
|
||||
* The unit tests exercise the controller filtering and refresh logic, so they
|
||||
* can call the public notification handler directly instead of reproducing the
|
||||
* bus service internals.
|
||||
*
|
||||
* @param {Object} controller - Patched view controller instance
|
||||
* @param {Object} payload - {model, view_types, rec_ids}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function triggerRefresh(controller, payload) {
|
||||
return controller._onWebRefreshNotification(payload);
|
||||
}
|
||||
|
||||
QUnit.module("cx_web_refresh_from_backend", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
serverData = {
|
||||
models: {
|
||||
"res.partner": {
|
||||
fields: {
|
||||
name: {string: "Name", type: "char"},
|
||||
},
|
||||
records: [
|
||||
{id: 1, name: "Partner 1"},
|
||||
{id: 2, name: "Partner 2"},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
setupViewRegistries();
|
||||
target = getFixture();
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"form: refresh runs only for matching notifications",
|
||||
async function (assert) {
|
||||
const form = await makeView({
|
||||
type: "form",
|
||||
resModel: "res.partner",
|
||||
serverData,
|
||||
resId: 1,
|
||||
arch: '<form><field name="name"/></form>',
|
||||
});
|
||||
|
||||
let refreshCalls = 0;
|
||||
form.refreshForm = async () => {
|
||||
refreshCalls++;
|
||||
};
|
||||
|
||||
triggerRefresh(form, {
|
||||
model: "res.users",
|
||||
view_types: ["form"],
|
||||
rec_ids: [1],
|
||||
});
|
||||
triggerRefresh(form, {
|
||||
model: "res.partner",
|
||||
view_types: ["list"],
|
||||
rec_ids: [1],
|
||||
});
|
||||
triggerRefresh(form, {
|
||||
model: "res.partner",
|
||||
view_types: ["form"],
|
||||
rec_ids: [2],
|
||||
});
|
||||
triggerRefresh(form, {
|
||||
model: "res.partner",
|
||||
view_types: ["form"],
|
||||
rec_ids: [1],
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(refreshCalls, 1);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"form in dialog: matching notification is ignored",
|
||||
async function (assert) {
|
||||
const form = await makeViewInDialog({
|
||||
type: "form",
|
||||
resModel: "res.partner",
|
||||
serverData,
|
||||
resId: 1,
|
||||
arch: '<form><field name="name"/></form>',
|
||||
});
|
||||
|
||||
let refreshCalls = 0;
|
||||
form.refreshForm = async () => {
|
||||
refreshCalls++;
|
||||
};
|
||||
|
||||
triggerRefresh(form, {
|
||||
model: "res.partner",
|
||||
view_types: ["form"],
|
||||
rec_ids: [1],
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(refreshCalls, 0);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"form: dirty form reloads from backend without confirmation dialog",
|
||||
async function (assert) {
|
||||
const form = await makeView({
|
||||
type: "form",
|
||||
resModel: "res.partner",
|
||||
serverData,
|
||||
resId: 1,
|
||||
arch: '<form><field name="name"/></form>',
|
||||
});
|
||||
|
||||
await form.model.root.switchMode("edit");
|
||||
await editInput(
|
||||
target,
|
||||
".o_field_widget[name='name'] input",
|
||||
"Changed Name"
|
||||
);
|
||||
|
||||
triggerRefresh(form, {
|
||||
model: "res.partner",
|
||||
view_types: ["form"],
|
||||
rec_ids: [1],
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
assert.containsNone(
|
||||
target,
|
||||
".modal",
|
||||
"backend refresh must not open a confirmation dialog"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("list: burst notifications are coalesced", async function (assert) {
|
||||
const list = await makeView({
|
||||
type: "list",
|
||||
resModel: "res.partner",
|
||||
serverData,
|
||||
arch: '<list><field name="name"/></list>',
|
||||
});
|
||||
|
||||
const deferred = makeDeferred();
|
||||
let refreshCalls = 0;
|
||||
list.refreshList = async () => {
|
||||
refreshCalls++;
|
||||
if (refreshCalls === 1) {
|
||||
await deferred;
|
||||
}
|
||||
};
|
||||
|
||||
const payload = {model: "res.partner", view_types: ["list"], rec_ids: [1]};
|
||||
triggerRefresh(list, payload);
|
||||
triggerRefresh(list, payload);
|
||||
triggerRefresh(list, payload);
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(
|
||||
refreshCalls,
|
||||
1,
|
||||
"only one refresh should run while in flight"
|
||||
);
|
||||
|
||||
deferred.resolve();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(
|
||||
refreshCalls,
|
||||
2,
|
||||
"one additional refresh should run after in-flight refresh finishes"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("kanban: burst notifications are coalesced", async function (assert) {
|
||||
const kanban = await makeView({
|
||||
type: "kanban",
|
||||
resModel: "res.partner",
|
||||
serverData,
|
||||
arch: '<kanban><templates><t t-name="card"><div><field name="name"/></div></t></templates></kanban>',
|
||||
});
|
||||
|
||||
const deferred = makeDeferred();
|
||||
let refreshCalls = 0;
|
||||
kanban.refreshList = async () => {
|
||||
refreshCalls++;
|
||||
if (refreshCalls === 1) {
|
||||
await deferred;
|
||||
}
|
||||
};
|
||||
|
||||
const payload = {model: "res.partner", view_types: ["kanban"], rec_ids: [1]};
|
||||
triggerRefresh(kanban, payload);
|
||||
triggerRefresh(kanban, payload);
|
||||
triggerRefresh(kanban, payload);
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(
|
||||
refreshCalls,
|
||||
1,
|
||||
"only one refresh should run while in flight"
|
||||
);
|
||||
|
||||
deferred.resolve();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(
|
||||
refreshCalls,
|
||||
2,
|
||||
"one additional refresh should run after in-flight refresh finishes"
|
||||
);
|
||||
});
|
||||
});
|
||||
4
addons/cx_web_refresh_from_backend/tests/__init__.py
Normal file
4
addons/cx_web_refresh_from_backend/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from . import test_reload_views
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright 2025 Cetmix OÜ
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.tests import tagged
|
||||
|
||||
from odoo.addons.base.tests.common import BaseCommon
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestReloadViews(BaseCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.user_admin = cls.env.ref("base.user_admin")
|
||||
cls.user_demo = cls.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "test_refresh_user",
|
||||
"email": "test_refresh@example.com",
|
||||
}
|
||||
)
|
||||
|
||||
def test_reload_views_basic(self):
|
||||
"""Test basic reload_views call without parameters"""
|
||||
with patch.object(type(self.env["bus.bus"]), "_sendone") as mock_sendone:
|
||||
self.user_admin.reload_views(model="res.partner")
|
||||
|
||||
mock_sendone.assert_called_once()
|
||||
partner, channel, message = mock_sendone.call_args[0]
|
||||
self.assertEqual(partner, self.user_admin.partner_id)
|
||||
self.assertEqual(channel, "web.refresh_view")
|
||||
self.assertEqual(message["model"], "res.partner")
|
||||
self.assertEqual(message["view_types"], [])
|
||||
self.assertEqual(message["rec_ids"], [])
|
||||
|
||||
def test_reload_views_with_params(self):
|
||||
"""Test reload_views with view_types and rec_ids parameters"""
|
||||
with patch.object(type(self.env["bus.bus"]), "_sendone") as mock_sendone:
|
||||
self.user_admin.reload_views(
|
||||
model="res.partner",
|
||||
view_types=["form", "kanban"],
|
||||
rec_ids=[self.partner.id],
|
||||
)
|
||||
|
||||
mock_sendone.assert_called_once()
|
||||
message = mock_sendone.call_args[0][2]
|
||||
self.assertEqual(message["view_types"], ["form", "kanban"])
|
||||
self.assertEqual(message["rec_ids"], [self.partner.id])
|
||||
|
||||
def test_reload_views_recordset(self):
|
||||
"""Test reload_views on a multi-record user recordset.
|
||||
|
||||
Ensures that calling reload_views on a recordset sends one notification
|
||||
per user through _sendone.
|
||||
"""
|
||||
users = self.user_admin | self.user_demo
|
||||
|
||||
with patch.object(type(self.env["bus.bus"]), "_sendone") as mock_sendone:
|
||||
users.reload_views(model="res.partner")
|
||||
|
||||
self.assertEqual(mock_sendone.call_count, 2)
|
||||
|
||||
# Verify both users' partners are notified and payload is correct.
|
||||
notified_partners = set()
|
||||
for call in mock_sendone.call_args_list:
|
||||
partner, channel, message = call[0]
|
||||
notified_partners.add(partner)
|
||||
self.assertEqual(channel, "web.refresh_view")
|
||||
self.assertEqual(message["model"], "res.partner")
|
||||
self.assertEqual(message["view_types"], [])
|
||||
self.assertEqual(message["rec_ids"], [])
|
||||
self.assertEqual(len(notified_partners), 2)
|
||||
self.assertEqual(
|
||||
notified_partners,
|
||||
{self.user_admin.partner_id, self.user_demo.partner_id},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.5399 2.62671C13.5133 5.18671 10.9399 8.66671 8.7866 10.3934L7.47327 11.4467C7.3066 11.5667 7.13993 11.6734 6.95327 11.7467C6.95327 11.6267 6.9466 11.4934 6.9266 11.3667C6.85327 10.8067 6.59993 10.2867 6.15326 9.84004C5.69993 9.38671 5.1466 9.12004 4.57993 9.04671C4.4466 9.04004 4.31326 9.02671 4.17993 9.04004C4.25326 8.83337 4.3666 8.64004 4.5066 8.48004L5.5466 7.16671C7.2666 5.01337 10.7599 2.42671 13.3133 1.40671C13.7066 1.26004 14.0866 1.36671 14.3266 1.61337C14.5799 1.86004 14.6999 2.24004 14.5399 2.62671Z" stroke="#292D32" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.95343 11.7467C6.95343 12.48 6.67343 13.18 6.14677 13.7133C5.7401 14.12 5.18677 14.4 4.52677 14.4867L2.88677 14.6667C1.99343 14.7667 1.22677 14.0067 1.33343 13.1L1.51343 11.46C1.67343 9.99999 2.89343 9.06666 4.18677 9.03999C4.3201 9.03333 4.4601 9.03999 4.58677 9.04666C5.15343 9.11999 5.70677 9.37999 6.1601 9.83999C6.60677 10.2867 6.8601 10.8067 6.93343 11.3667C6.9401 11.4933 6.95343 11.62 6.95343 11.7467Z" stroke="#292D32" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.49342 9.64662C9.49342 7.90662 8.08009 6.49329 6.34009 6.49329" stroke="#292D32" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user